The Tool Loop That Turns a Chatbot Into an Agent
Developing a Coding Agent from Scratch (Part 9): Implementing the Agent's Tool Call Loop
This article is the ninth in the "Developing a Coding Agent from Scratch" series. In the previous article, we used the Agent class to save conversation history and forwarded events generated by agentLoop to the upper layer.
However, the current Agent can only "chat." If the model returns a tool call, such as "please read a certain file," the Agent will end this message as a normal reply, neither executing the tool nor telling the model the execution result.
This article will fill in the most critical control flow:
User makes a request
-> Model decides to call a tool
-> Agent finds and executes the tool
-> Agent appends the tool result to the message history
-> Requests the model again
-> Model gives the final answer based on the tool result
This is the Tool Call Loop. Once completed, the Agent is no longer just a chat interface but possesses the basic ability to "observe, act, and think again."
This article only implements a generic tool execution mechanism, not actual read, write, or bash tools. Security issues like file permissions, command timeouts, and output truncation will be handled separately when implementing specific tools.
The Model Does Not Execute Tools Itself
When first encountering tool calls, it's easy to misunderstand: does the model returning tool_call mean the tool has already been executed?
No.
Large models run on remote services; they can neither see our local files nor directly call TypeScript functions in our project. The model can only return a structured "call intent":
{
type: "tool_call",
id: "call-1",
name: "echo",
arguments: { value: "hello" },
}
This data expresses:
- I want to call the tool named
echo; - The call ID is
call-1; - I intend to pass
{ value: "hello" }.
The one who actually finds the echo function, validates the parameters, and executes it is the local Agent. Assuming the tool execution yields echoed: hello, the Agent must also construct a tool result message:
{
role: "tool_result",
toolCallId: "call-1",
toolName: "echo",
content: [{ type: "text", text: "echoed: hello" }],
isError: false,
timestamp: 30,
}
Then put this message into the next Provider request. Only when the model sees the tool result does it know what the tool actually returned.
Therefore, a seemingly simple "call a tool and answer" typically requires at least two model requests:
sequenceDiagram
participant U as User
participant A as Agent
participant P as Provider
participant T as Tool
U->>A: echo hello
A->>P: First request
P-->>A: tool_call(echo, { value: 'hello' })
A->>T: execute('call-1', { value: 'hello' })
T-->>A: echoed: hello
A->>P: Second request, with tool_result
P-->>A: Final answer done
A-->>U: done
One Prompt and One Turn Are Not the Same Thing
In the previous article's text-only Agent, one prompt() only requested the model once, so "one prompt" and "one turn" seemed identical.
After adding tools, one prompt can contain multiple turns. A turn here can be understood as "one model response, plus the tool execution triggered by this response."
For example:
One prompt
|
+-- turn 1: Model requests calling echo -> Agent executes echo
|
+-- turn 2: Model sees echo's result -> Returns final text
So the entire prompt only emits one agent_start and one agent_end, but multiple pairs of turn_start and turn_end can appear in between.
When a tool is successfully called, the message history should ultimately be:
user
assistant <- contains tool_call
tool_result <- appended by Agent after local execution
assistant <- model's final answer
The order cannot be changed. In particular, do not initiate a second model request before appending tool_result, otherwise the model still won't know what the tool returned.
The Difference Between ToolDefinition and AgentTool
In the AI package, we have already defined ToolDefinition. It contains the tool name, description, and parameter schema:
interface ToolDefinition<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
These three pieces of information are for the model to see. For example:
{
name: "echo",
description: "Return the supplied value",
parameters: Type.Object({ value: Type.String() }),
}
The model decides what the tool is called, when to use it, and what parameters to generate based on this information. But ToolDefinition has no execute(), because the AI package is only responsible for Provider-agnostic protocols and cannot hold local execution capabilities.
The execution function belongs to the Agent layer. Open:
packages/agent/src/types.ts
Import tool-related types from the AI package:
import type {
AssistantMessage,
Message,
Model,
Provider,
Static,
StreamEvent,
ToolDefinition,
ToolResultContent,
ToolResultMessage,
TSchema,
UserMessage,
} from "@di-code/ai";
Then define AgentTool:
export interface AgentTool<TParameters extends TSchema = TSchema> extends ToolDefinition<TParameters> {
execute(toolCallId: string, parameters: Static<TParameters>, signal?: AbortSignal): Promise<ToolResultContent[]>;
}
This interface can be understood in two halves:
Model-visible definition
name + description + parameters
Locally executable capability
execute(toolCallId, parameters, signal)
Static<TParameters> converts a TypeBox schema into a TypeScript type. For example:
const echoParameters = Type.Object({ value: Type.String() });
The corresponding parameters type is:
{ value: string }
Therefore, when accessing parameters.value inside execute(), TypeScript knows it must be a string.
toolCallId is used to match the result with the original call. One model response might request multiple tool calls; you cannot identify results by tool name alone.
signal is the same AbortSignal. It is passed from Agent.prompt() all the way to the Provider and tools, allowing cancellation operations to cross all asynchronous boundaries.
Extending AgentContext and Event Types
The Agent Loop needs to know which tools are available in the current round, so add tools to AgentContext:
export interface AgentContext {
systemPrompt?: string;
messages: Message[];
tools?: readonly AgentTool[];
}
readonly is used here to indicate that the Loop can only read this tool list and cannot add or remove tools during runtime.
Next, extend AgentEvent. When a tool starts and ends, the upper layer needs to receive events to display statuses like "reading file" or "command execution failed" in the future:
export type AgentEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| {
type: "message_start";
message: UserMessage | AssistantMessagePreview | ToolResultMessage;
}
| { type: "message_update"; event: MessageUpdateEvent; message: AssistantMessagePreview }
| { type: "message_end"; message: UserMessage | AssistantMessage | ToolResultMessage }
| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
arguments: Record<string, unknown>;
}
| {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: ToolResultMessage;
}
| { type: "agent_end"; messages: Message[] };
There are three changes here:
message_startandmessage_endnow also allow carryingToolResultMessage.turn_endaddstoolResults, making it convenient for the upper layer to know which tools were executed in this round.- New
tool_execution_startandtool_execution_endexplicitly wrap a single tool execution.
A text-only turn has no tool results, so its toolResults is an empty array:
emit({ type: "turn_end", message: assistant, toolResults: [] });
Injecting Tools into the Agent
Tools should be passed in when creating the Agent, not hardcoded in the Loop. Open:
packages/agent/src/agent.ts
First, let AgentOptions accept tools:
export interface AgentOptions {
readonly provider: Provider;
readonly model: Model;
readonly tools?: readonly AgentTool[];
readonly systemPrompt?: string;
readonly now?: () => number;
}
Save a snapshot of the tools array in Agent:
private readonly tools: readonly AgentTool[];
constructor(options: AgentOptions) {
this.provider = options.provider;
this.model = options.model;
this.tools = [...(options.tools ?? [])];
this.systemPrompt = options.systemPrompt;
this.now = options.now ?? Date.now;
}
Why write [...(options.tools ?? [])] instead of directly saving options.tools?
Because the caller might still hold the original array. If the Agent directly references it, the caller modifying the array during prompt execution could cause the same conversation round to see different tool sets before and after. After copying the array, the Agent has a stable tool list.
Each time prompt() is called, put the tools into the current round's context:
const context: AgentContext = {
systemPrompt: this.systemPrompt,
messages: [...this.messages],
tools: [...this.tools],
};
Copying again here ensures each Loop uses its own context snapshot. The Agent's responsibility remains just saving configuration and conversation state; the actual tool lookup and execution are placed in agent-loop.ts.
Only Giving Tool Definitions to the Provider
Open:
packages/agent/src/agent-loop.ts
The context passed to the Provider needs to include tool definitions, otherwise the model won't know what it can call. But you cannot directly give the entire AgentTool to the Provider, because its execute() is a local function that cannot and should not be serialized into a network request.
First, write a conversion function:
function toolDefinitions(context: AgentContext) {
return context.tools?.map(({ name, description, parameters }) => ({
name,
description,
parameters,
}));
}
It only picks out the three fields the Provider needs. Then include tools when requesting the model:
const response = config.provider.stream(
config.model,
{
systemPrompt: context.systemPrompt,
messages: [...messages],
tools: toolDefinitions(context),
},
{ signal },
);
This boundary can be summarized as:
Provider can see: name, description, parameters
Provider cannot see: execute function
Agent can see both
Unifying Execution Results into ToolResultMessage
A tool might succeed or fail, but no matter what happens, the Loop needs to get a unified message shape. First, create a helper function:
function createToolResult(
toolCall: ToolCallContent,
content: ToolResultContent[],
isError: boolean,
config: AgentLoopConfig,
): ToolResultMessage {
return {
role: "tool_result",
toolCallId: toolCall.id,
toolName: toolCall.name,
content,
isError,
timestamp: (config.now ?? Date.now)(),
};
}
Prepare another function to convert unknown exceptions to text:
function errorMessage(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}
JavaScript allows throw of any value, so cause in catch (cause) is unknown; you cannot directly assume it has .message. This function keeps error handling type-safe.
Implementing a Single Tool Call
Below is the most important function of the entire article: executeToolCall(). First, look at the complete code, then break it down section by section:
async function executeToolCall(
toolCall: ToolCallContent,
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: (event: AgentEvent) => void,
): Promise<ToolResultMessage> {
emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
arguments: toolCall.arguments,
});
let content: ToolResultContent[];
let isError = false;
const tool = context.tools?.find((candidate) => candidate.name === toolCall.name);
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
} else if (!tool) {
content = [{ type: "text", text: `Unknown tool "${toolCall.name}".` }];
isError = true;
} else {
try {
const parameters = validateToolArguments(tool, toolCall.arguments);
content = await tool.execute(toolCall.id, parameters, signal);
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
}
} catch (cause) {
content = signal?.aborted
? [{ type: "text", text: "Tool execution aborted." }]
: [{ type: "text", text: `Tool "${toolCall.name}" failed: ${errorMessage(cause)}` }];
isError = true;
}
}
const result = createToolResult(toolCall, content, isError, config);
emit({
type: "tool_execution_end",
toolCallId: toolCall.id,
toolName: toolCall.name,
result,
});
emit({ type: "message_start", message: result });
emit({ type: "message_end", message: result });
return result;
}
Step 1: Emit the Start Event First
emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
arguments: toolCall.arguments,
});
This event does not mean the tool definitely exists, nor that the parameters are definitely valid. It only means the Loop has started processing this call. This way, unknown tools and parameter errors also have a complete start and end lifecycle.
Step 2: Find the Tool by Name
const tool = context.tools?.find((candidate) => candidate.name === toolCall.name);
toolCall.name comes from the model and is untrusted input. The model might misspell the name or return a tool that doesn't exist at all. Therefore, you must search; you cannot use a non-null assertion to forcefully assume the tool exists.
If not found, produce a model-visible error result:
content = [{ type: "text", text: `Unknown tool "${toolCall.name}".` }];
isError = true;
Note that throw is not used here to terminate the entire Agent. Unknown tools are usually errors the model can correct. After sending the result back to the model, it can switch to a correct tool or explain to the user that the operation cannot be completed currently.
Step 3: Validate Parameters Before Execution
const parameters = validateToolArguments(tool, toolCall.arguments);
content = await tool.execute(toolCall.id, parameters, signal);
Model-generated parameters are also untrusted input. Even if TypeScript declares the tool needs { value: string }, it might still receive { value: 42 } at runtime.
The previous tool parameter tutorial already implemented validateToolArguments(). It must be called here first, then its return value passed to execute(). You cannot directly pass toolCall.arguments in.
The order is:
Model raw parameters
-> validateToolArguments
-> Validated and copied strongly-typed parameters
-> tool.execute
If parameters don't match the schema, the validation function throws an error, the tool won't execute, and the exception will subsequently be converted into a tool result with isError: true.
Also note: schema validation can only prove the data structure is correct. For example, it can prove path is a string, but it cannot prove this path is allowed to be read. Path permissions must be further checked by future specific file tools.
Step 4: Catch Tool Exceptions
} catch (cause) {
content = signal?.aborted
? [{ type: "text", text: "Tool execution aborted." }]
: [{ type: "text", text: `Tool "${toolCall.name}" failed: ${errorMessage(cause)}` }];
isError = true;
}
Parameter validation failures and tool execution throws are both normalized here. They won't directly crash agentLoop but become a formal ToolResultMessage.
Why design it this way? Because these errors are different from Provider network interruptions. Tool name, parameter, or execution failures are all part of the current conversation round; the model has a chance to see the error and make the next judgment.
Step 5: Check Cancellation Before and After Execution
The function checks once before calling the tool:
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
}
And checks again after the tool returns:
content = await tool.execute(toolCall.id, parameters, signal);
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
}
The second check is important. AbortSignal only expresses "please cancel"; it won't automatically interrupt any Promise. Some tools might ignore the signal, or might receive the cancellation during execution and still return a result.
Therefore, the Loop must re-confirm the state after await. Once cancelled, discard the seemingly successful result and no longer treat it as normal output.
Extracting Tool Calls from AssistantMessage
The model's reply content is a union type array, which might simultaneously contain text, thinking content, and tool calls. We only need the parts where type === "tool_call":
function getToolCalls(message: AssistantMessage): ToolCallContent[] {
return message.content.filter((content): content is ToolCallContent => content.type === "tool_call");
}
content is ToolCallContent is a type predicate. It tells TypeScript: as long as the filter condition holds, the elements in the returned array can be used as ToolCallContent.
Transforming a Single Request into a Loop
The previous article's runAgentLoop() only requested the Provider once. Now, "request model -> check tools -> execute tools" needs to be placed inside while (true).
First, keep the events at the start of the prompt:
const messages: Message[] = [...context.messages, prompt];
const emit = (event: AgentEvent): void => stream.push(event);
emit({ type: "agent_start" });
emit({ type: "turn_start" });
emit({ type: "message_start", message: prompt });
emit({ type: "message_end", message: prompt });
Then enter the loop:
while (true) {
const assistant = await streamAssistantResponse(messages, context, config, signal, emit);
messages.push(assistant);
emit({ type: "message_end", message: assistant });
const toolCalls = getToolCalls(assistant);
if (
assistant.stopReason === "error" ||
assistant.stopReason === "aborted" ||
assistant.stopReason !== "tool_use" ||
toolCalls.length === 0
) {
emit({ type: "turn_end", message: assistant, toolResults: [] });
emit({ type: "agent_end", messages });
return;
}
const toolResults: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
const result = await executeToolCall(toolCall, context, config, signal, emit);
messages.push(result);
toolResults.push(result);
if (signal?.aborted) {
break;
}
}
emit({ type: "turn_end", message: assistant, toolResults });
if (signal?.aborted) {
emit({ type: "turn_start" });
const aborted = createFailureMessage(config, signal, new Error("Tool loop aborted"));
messages.push(aborted);
emit({ type: "message_start", message: createPreview(config, "") });
emit({ type: "message_end", message: aborted });
emit({ type: "turn_end", message: aborted, toolResults: [] });
emit({ type: "agent_end", messages });
return;
}
emit({ type: "turn_start" });
}
Below, this code is explained by control flow.
Case 1: Model Gives Final Answer
If stopReason is not tool_use, it means the model does not require further tool calls. The Loop emits turn_end and agent_end, then ends:
emit({ type: "turn_end", message: assistant, toolResults: [] });
emit({ type: "agent_end", messages });
return;
Provider errors and model generation cancellations also take this branch, because they similarly cannot continue executing tools.
Simultaneously checking toolCalls.length === 0 is a defensive boundary. If the Provider claims the end reason is tool_use but the message has no complete tool calls, the Loop should not blindly enter the next round.
Case 2: Model Requests Tool Calls
If the message contains tool calls, execute them sequentially one by one:
const toolResults: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
const result = await executeToolCall(toolCall, context, config, signal, emit);
messages.push(result);
toolResults.push(result);
if (signal?.aborted) {
break;
}
}
Serial execution is used here, not Promise.all() parallel execution. Serial makes it easier to guarantee:
- The order of tool start and end events is fixed;
- The order of results in
messagesis fixed; - After one tool triggers cancellation, subsequent tools won't start;
- Tests don't depend on who finishes first.
At the current stage, determinism is more important than concurrency speed.
Only after all results are appended to messages does the current turn end:
emit({ type: "turn_end", message: assistant, toolResults });
If not cancelled, emit the next turn_start and return to the top of the while loop to request the Provider again. Since messages now contains the previous tool_result, the model can see the actual execution results this time.
Case 3: Cancellation During Tool Execution
After cancellation, three things must be done simultaneously:
- Do not execute the remaining tools in the current batch;
- Do not initiate the next Provider request;
- Still let the Agent end in a complete, committable state.
The first two points are guaranteed by break and the cancellation branch. The third point is slightly special: the previous article's Agent.prompt() stipulated that the last message in agent_end.messages must be an AssistantMessage. If it stops directly at tool_result, the Agent cannot return a final assistant message.
Therefore, after cancellation, append a locally constructed aborted message:
const aborted = createFailureMessage(config, signal, new Error("Tool loop aborted"));
messages.push(aborted);
This is not requesting the model again, but a local safe wrap-up. The final message's role is still assistant, and stopReason is aborted, so Agent.prompt() can settle normally and restore isStreaming to false.
Complete Event Sequence
A successful tool call produces the following events:
agent_start
turn_start
message_start(user)
message_end(user)
message_start(assistant preview)
message_update(...streaming events for tool call...)
message_end(assistant with tool_call)
tool_execution_start
tool_execution_end
message_start(tool_result)
message_end(tool_result)
turn_end(toolResults: [result])
turn_start
message_start(assistant preview)
message_update(...streaming events for final text...)
message_end(final assistant)
turn_end(toolResults: [])
agent_end
It can be observed that:
- There is only one pair of
agent_startandagent_endin one prompt; - There is a
turn_startbefore each model request; - Tool results are also formal messages, hence they have
message_startandmessage_end; tool_execution_start/enddescribe the execution action,message_start/enddescribe the result entering the message history; the two have different responsibilities.
Testing the Successful Loop with Faux Provider
The tool loop cannot rely on real API testing. The Faux Provider can prepare two responses in advance, stably reproducing the complete flow.
Create the test file:
packages/agent/test/tool-loop.test.ts
First, define the simplest echo tool parameters:
const echoParameters = Type.Object({ value: Type.String() });
Then prepare two model responses: the first requests a tool call, the second gives the final answer.
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [
{
type: "tool_call",
id: "call-1",
name: "echo",
arguments: { value: "hello" },
},
],
},
{ type: "success", content: [{ type: "text", text: "done" }] },
],
now: () => 20,
});
Define the local tool:
const executions: Array<{ id: string; value: string; signal?: AbortSignal }> = [];
const echo = {
name: "echo",
description: "Return the supplied value",
parameters: echoParameters,
async execute(id, parameters, signal) {
executions.push({ id, value: parameters.value, signal });
return [{ type: "text" as const, text: `echoed: ${parameters.value}` }];
},
} satisfies AgentTool<typeof echoParameters>;
satisfies checks that the object conforms to AgentTool while preserving the precise parameter type inferred from echoParameters.
Create the Agent and call:
const agent = new Agent({
provider: faux.provider,
model: faux.model,
tools: [echo],
now: () => 30,
});
const assistant = await agent.prompt("echo hello");
Core assertions include:
expect(assistant).toMatchObject({
stopReason: "stop",
content: [{ type: "text", text: "done" }],
});
expect(executions).toEqual([
{ id: "call-1", value: "hello", signal: undefined },
]);
expect(agent.transcript.map((message) => message.role)).toEqual([
"user",
"assistant",
"tool_result",
"assistant",
]);
expect(faux.pendingResponses()).toBe(0);
The last assertion proves both Faux responses were consumed, meaning the Loop indeed requested the model twice.
You should also capture the context of the second Provider request to confirm it truly contains the tool result. Otherwise, a false success could occur: the code consumes the second response but didn't give the first round's result to the model.
The message roles of the second request must be:
expect(requestedMessages[1]?.map((message) => message.role)).toEqual([
"user",
"assistant",
"tool_result",
]);
Testing Unknown Tools
The model might request calling an unregistered tool:
{
type: "tool_call",
id: "missing-1",
name: "missing",
arguments: {},
}
The Agent should not crash but should produce an error result and continue requesting the model:
expect(toolResult(agent.transcript, "missing-1")).toMatchObject({
toolName: "missing",
isError: true,
content: [{ type: "text", text: 'Unknown tool "missing".' }],
});
This test proves tool existence is checked before execution.
Testing Illegal Parameters
The echo tool requires value to be a string, but the model might return a number:
arguments: { value: 42 }
In the test, use a counter to record whether the tool was called:
let executions = 0;
const echo = {
name: "echo",
description: "Return the supplied value",
parameters: echoParameters,
async execute(_id, parameters) {
executions++;
return [{ type: "text" as const, text: parameters.value }];
},
} satisfies AgentTool<typeof echoParameters>;
Ultimately, it must satisfy:
expect(executions).toBe(0);
expect(result.isError).toBe(true);
This is more important than just checking the error text. It directly proves the side-effect function did not run under illegal parameters.
Testing Tool Execution Failure
A tool might also throw internally:
async execute() {
throw new Error("disk offline");
}
The Loop should convert the exception into a model-visible result:
expect(toolResult(agent.transcript, "failed-1")).toMatchObject({
isError: true,
content: [{ type: "text", text: 'Tool "echo" failed: disk offline' }],
});
The Faux Provider's second response must also be consumed, proving the model had a chance to see the failure and explain it.
Testing Cancellation During Tool Execution
Cancellation testing needs to be stricter than "passing an already aborted signal." We let the first tool actively trigger cancellation during execution, then deliberately return a seemingly successful result:
const controller = new AbortController();
const abort = {
name: "abort",
description: "Abort this run",
parameters: Type.Object({}),
async execute(_id, _parameters, signal) {
controller.abort("test cancellation");
return [{ type: "text" as const, text: "too late" }];
},
};
The same model message also requests calling a second tool never. The correct result should be:
expect(executions).toEqual(["abort"]);
expect(assistant.stopReason).toBe("aborted");
expect(faux.pendingResponses()).toBe(1);
These three assertions respectively prove:
- The second tool did not start;
- The Agent ended with an explicit cancelled state;
- The second model response remains in the queue, meaning no further Provider request was made after cancellation.
Running Verification
In PowerShell, enter the project directory:
Set-Location D:\pi\di-code
First, run the tool loop tests:
npm test --workspace packages/agent -- --run tool
Currently, it should collect tool-loop.test.ts, totaling 1 file / 5 tests, all five tests passing.
Then run the previous article's regression tests to confirm text-only Agent behavior is not broken:
npm test --workspace packages/agent -- --run agent-loop.test.ts
npm test --workspace packages/agent -- --run agent.test.ts
Finally, run the full repo check and Agent build:
npm run check
npm run build --workspace @di-code/agent
These commands respectively check the tool loop, old behavior regression, code formatting, TypeScript types, and build artifacts.
Summary
This article expanded the original "one request and done" Agent Loop into a true tool call loop. The entire process can be condensed into six steps:
- Tell the Provider the tool's name, description, and parameter schema.
- Extract
tool_callfrom the model's reply. - Find the local tool by name and validate parameters before execution.
- Unify success, unknown tool, illegal parameters, and execution exceptions into
ToolResultMessage. - First append the tool result to the message history, then initiate the next model request.
- End when there are no new tool calls; upon cancellation, stop remaining tools and subsequent requests, and wrap up safely.
Now, the Agent possesses the core loop of "model proposes action -> local execution -> model observes result." The next step can connect a CLI on top of this stable generic mechanism, allowing users to observe the final text and complete events from the command line; after that, actual read tools with file system side effects will be implemented.
Git address: qddidi/di-code
If you are also interested in Agent development, likes and follows are welcome. Column: Developing a Coding Agent from Scratch - Dongfang Xiaoyue's Column - Juejin
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
An agent loop from agent_start to end refers to the end of the entire conversation; a turn loop refers to the end of a single large model streaming request.
[Like]