Build a Coding Agent from Scratch
中文

02. A Minimal Agent Loop

Once you understand the tool protocol, the heart of an agent is the loop. It is not a while true that lets the model keep talking; it is a set of state transitions driven by the stop reason the model returns. The model says it needs a tool, so the runtime executes the tool; the model says it is done, so the runtime finishes; the model runs over length or gets aborted, so the runtime records the state and lets the layer above decide how to handle it.

Failure mode: treating tool calls as ordinary text

The most common mistake is handling only the assistant text and ignoring the tool calls. The model earnestly says "I need to read the file," and the system does nothing. The second mistake is executing one tool and then stopping, forgetting to send the result back to the model. The model then never gets a chance to reason further based on what it observed. The third mistake is throwing an exception and exiting when a tool fails, so the model can never correct itself.

The agent loop's job is to pin down these boundaries:

  • A model response is one turn.
  • A turn can contain zero or more tool calls.
  • Every executed tool call must produce a tool result message.
  • Tool results go into the next round of context.
  • The task ends only when the stop reason indicates completion.

The minimal structure

First define the model boundary and the tool boundary:

type Message = UserMessage | AssistantMessage | ToolResultMessage;

type ModelClient = {
  complete(input: { messages: Message[]; tools: ToolDefinition[] }): Promise<AssistantMessage>;
};

type ToolDefinition = {
  name: string;
  description: string;
  parameters: unknown;
};

type ToolRuntime = {
  definition: ToolDefinition;
  execute(input: unknown, signal: AbortSignal): Promise<ToolResultMessage>;
};

Then implement the loop. The teaching version can execute tools serially for now; parallelism and the file-write queue come later:

async function runAgent(input: {
  model: ModelClient;
  tools: ToolRuntime[];
  messages: Message[];
  signal: AbortSignal;
  maxTurns: number;
}): Promise<Message[]> {
  const messages = [...input.messages];
  const toolsByName = new Map(input.tools.map((tool) => [tool.definition.name, tool]));

  for (let turn = 0; turn < input.maxTurns; turn += 1) {
    const assistant = await input.model.complete({
      messages,
      tools: input.tools.map((tool) => tool.definition),
    });
    messages.push(assistant);

    if (assistant.stopReason !== "toolUse") {
      return messages;
    }

    for (const block of assistant.content) {
      if (block.type !== "toolCall") {
        continue;
      }
      const tool = toolsByName.get(block.name);
      if (!tool) {
        messages.push({
          role: "toolResult",
          toolCallId: block.id,
          toolName: block.name,
          isError: true,
          content: [{ type: "text", text: `Unknown tool: ${block.name}` }],
        });
        continue;
      }
      const result = await tool.execute(block.input, input.signal);
      messages.push({ ...result, toolCallId: block.id, toolName: block.name });
    }
  }

  throw new Error(`Agent exceeded ${input.maxTurns} turns`);
}

The point of this code is not completeness but control flow: the assistant message enters history first, then the tools execute, then the tool results enter history, then control returns to the model. This order must not be shuffled. Otherwise the session log, UI events, restoration, and debugging all lose their single shared basis.

Feed tool errors back to the model

A tool failure is not a task failure. Suppose the model calls read_file with a wrong path; the runtime should return:

toolResult(read_file, isError=true):
  File not found: src/maths.ts. Did you mean src/math.ts?

On the next turn, the model will likely switch to the correct path. This closed loop is one of the differences between an agent and an ordinary script. A script usually exits on error; an agent should put correctable information back into the context and let the model keep trying.

Of course, not every error should be fed back for another attempt. Permission denials, user cancellations, and blocked dangerous commands usually call for writing the error to the log and stopping, or waiting for user confirmation. The test is: can the model fix this error with more reasoning? If not, do not let it retry indefinitely.

Turn limits and termination

The minimal loop must have maxTurns. An agent without an upper bound can run forever due to tool failures, model repetition, missing compaction, or a misleading prompt. The limit is not the final answer for user experience, but it is the runtime's safety valve. The product layer can render "limit exceeded" as a state the user can understand: the current task did not converge, this was the last action taken, and here is how the user might continue.

Aborting must not simply let an exception fly through every layer either. A better approach is to turn the abort into an assistant message with stopReason: "aborted", write it to the log, and let the UI know that this turn was an interruption by the user, not a model failure.

Checkpoint

After finishing this chapter, you should have an agent that can run through this script:

user: list the files in the workspace
assistant: toolCall list_files {}
toolResult: README.md, src/index.ts
assistant: The workspace contains README.md and src/index.ts.

Acceptance criteria:

  • The assistant's tool calls are executed.
  • Tool results are included in the next model request.
  • An unknown tool does not crash the process; it produces an error tool result.
  • Exceeding the turn limit produces a clear error.
  • All messages are preserved in the order they occurred.