Learn Agents from Pi
中文
On this page

01. The Full Protocol of a Tool Call

The first foundation stone of an agent is tool calling. Many tutorials describe it as "give the model a list of functions and the model will pick one." That is not wrong, but it is far too coarse. What you actually need to understand is this: tool calling is a message protocol. The model never executes a function directly — it merely declares, inside an assistant message, "I want to call this tool with these arguments." Your runtime reads that declaration, executes the local tool, and sends the result back to the model as a new message.

Why a naive call is not enough

A plain LLM call takes messages as input and produces a block of text as output. That works for "explain this code" or "write a function," but not for "fix the bug in this repository." Fixing a bug requires inspecting files, running commands, and iterating on failures. The model cannot access the file system directly, and it cannot run the tests itself. You have to give it a set of controlled tools and fold each tool result back into the context at every step.

If you stuff the file contents into the prompt all at once, you run into three problems:

  • There are too many files, and you exceed the context window.
  • The model has no way to verify whether the changes it wrote pass the tests.
  • The user cannot audit what the model actually read, changed, or executed.

The tool protocol solves "let the model request actions," not "give the model system privileges."

One tool use round trip

A complete tool call involves at least four stages:

  1. You send the tool schemas and the current messages to the model.
  2. The model returns an assistant message whose content includes a tool call.
  3. The runtime executes the corresponding tool, keyed by the tool call id.
  4. The runtime appends a tool result message and asks the model to continue.

The minimal message types can be written like this:

type TextBlock = {
  type: "text";
  text: string;
};

type ToolCallBlock = {
  type: "toolCall";
  id: string;
  name: string;
  input: unknown;
};

type UserMessage = {
  role: "user";
  content: TextBlock[];
};

type AssistantMessage = {
  role: "assistant";
  content: Array<TextBlock | ToolCallBlock>;
  stopReason: "stop" | "toolUse" | "length" | "error" | "aborted";
  model: string;
  usage: { inputTokens: number; outputTokens: number };
  errorMessage?: string;
};

type ToolResultMessage = {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  isError: boolean;
  content: TextBlock[];
};

There are a few key points here. toolCallId must be echoed back verbatim; otherwise the model cannot match results to requests. isError is not UI decoration — it tells the model that this tool call failed and it should try a different approach. The actual trigger for tool execution is the presence of tool calls in the assistant message; stopReason explains why this turn stopped and helps the runtime handle states with no tool calls. Its five values each carry a distinct meaning:

  • toolUse: the model requests a tool, usually alongside one or more tool calls.
  • stop: the model considers this turn complete; the task wraps up or waits for the user.
  • length: the output was cut off by max tokens or the context overran; this usually triggers compaction or asks the model to wrap up.
  • error: the provider or network failed; errorMessage explains why, and the retry policy decides what happens next.
  • aborted: the user actively stopped; the runtime must write the partial content and state it has received so far into the log.

The last three are often skipped in teaching implementations, but in real systems they appear far more often than you would expect: networks drop, streams cut off, users press the stop key. If the protocol has no place for them, error handling can only fall back to exceptions punching through, and neither the log nor the UI can explain "what just happened."

The tool call id is not the kind of id you think it is

Tool call ids generated by different providers vary enormously. Some APIs generate ids over 450 characters long, containing special symbols like |; other APIs require the id to match ^[a-zA-Z0-9_-]+$ and be no longer than 64 characters. As soon as your system supports switching models mid-session, a tool call id in an older message may not be accepted by the new provider.

The mature approach is to normalize ids at the provider boundary: map the ids in historical messages into a form the target API will accept, and simultaneously update the references in the corresponding tool results so the pairing is preserved. The teaching project does not need to implement this right away, but keep it in mind when designing the protocol: a tool call id is "a correlation key internal to the runtime." Do not assume it has any particular format, and do not overload it with business meaning.

Here is a concrete example. The first half of the session uses model A, which generated a long id containing | for a read; after switching to model B, which only accepts ^[a-zA-Z0-9_-]{1,64}$, the adapter must rewrite both sides as a pair before sending the request:

History (generated by model A, kept verbatim in the log):
  toolCall   id         = "call_a1b2c3...d9|verbose"
  toolResult toolCallId = "call_a1b2c3...d9|verbose"

Before sending to model B (adapter normalization, this request only):
  toolCall   id         = "call_0"
  toolResult toolCallId = "call_0"

The key is that the pair is rewritten together or not at all. If you change the id in the assistant message but miss the corresponding tool result, you produce an invalid request with "a tool call whose result does not match," and the provider rejects it outright. The mapping table lives only inside this one conversion; the log always holds the original id, so switching back to model A loses no correlation.

The tool schema is a runtime contract

A tool description generally consists of a name, a natural-language explanation, and a parameter schema. The explanation is written for the model; the schema is written for the runtime. The model reads the explanation to decide when to call the tool; the runtime must validate the arguments against the schema, because the model may produce missing fields, wrong types, malformed paths, or extra fields.

The description of a read_file tool should tell the model both its capabilities and its boundaries:

const readFileTool = {
  name: "read_file",
  description: "Read a UTF-8 text file under the current workspace. Use this before editing a file you have not inspected.",
  parameters: {
    type: "object",
    properties: {
      path: { type: "string", description: "Workspace-relative file path." },
    },
    required: ["path"],
    additionalProperties: false,
  },
};

Do not write the description as just "reads a file." The model needs to know when to use it, when not to use it, how paths are expressed, and that the output may be truncated. The tool description is part of prompt engineering, but it must match the runtime's actual behavior. Otherwise the model will learn the wrong way to operate.

There is one more easily overlooked detail: the arguments are not an object the model passes when "calling a function" — they are JSON text the model generates character by character. It may contain invalid escapes, unpaired Unicode surrogate pairs, or even be cut into half a JSON document during streaming. Until it has the "complete arguments" in hand, the runtime must always treat them as untrusted text. Chapter 5 returns to this problem when it covers streaming.

What you should observe

Below is an idealized transcript. Focus on the message roles, not the text content:

user: Fix the divide-by-zero bug in src/math.ts
assistant(stopReason=toolUse):
  toolCall read_file { "path": "src/math.ts" }
toolResult(read_file, isError=false):
  export function divide(a: number, b: number) { return a / b; }
assistant(stopReason=toolUse):
  toolCall edit_file { "path": "src/math.ts", "oldText": "...", "newText": "..." }
toolResult(edit_file, isError=false):
  edited src/math.ts
assistant(stopReason=stop):
  Handled divide-by-zero input; divide now throws a RangeError when b is 0.

Nowhere in this flow does the model "access a file." It only makes requests. The runtime is the real executor, record keeper, and permission boundary.

Note one more hard constraint at the provider level: every tool call in an assistant message must have a corresponding tool result in a later message, or most APIs will not accept the next request. This means that even if a tool execution fails, is denied by permissions, or is aborted by the user, the runtime must still produce a result message for every tool call it emitted (even if the content is just "aborted"). "Silently skipping a failed tool" will make the entire session impossible to continue.

Production tradeoffs

A mature agent should not feed tool results back to the model raw and unbounded. Tool output must serve the model: state success or failure clearly, truncate when necessary, and tell the model how to proceed. Consider a set of defaults from real systems: grep returns at most 100 matches, and any single line over 500 characters is truncated; bash output keeps the last 2000 lines or 50KB (error messages are almost always at the end); read keeps the beginning, because the top of a file is usually imports and type declarations. The exact numbers can be tuned; what matters is that every tool has an explicit truncation policy and tells the model in its output "this was truncated, here is how to retrieve more."

At the same time, keep tool results and UI details separate. At the protocol level you can add a details field to ToolResultMessage that never enters the model context: the model sees "replacement succeeded, 2 lines changed," while the UI gets a structured diff, elapsed time, and exit code. The content of a tool result also does not have to be text only — once image blocks are supported, screenshot and drawing tools can hand images directly to a model with vision capabilities. Mixing these two layers bloats the prompt and makes the UI hard to render reliably.

Finally, add a timestamp to every message, and keep model and usage on assistant messages. A session is long-lived data; three months later, when you investigate "why did this task fail," you will need to know which model each response came from, how many tokens it cost, and when it happened.

Exercise

Implement a read-only tool protocol checkpoint:

  • Define UserMessage, AssistantMessage, and ToolResultMessage, with the stop reason covering all five values.
  • Define the tool schema for read_file.
  • Write a function that takes an assistant message and extracts all tool calls.
  • When the arguments are not { path: string }, return a tool result with isError: true.
  • Use a fixed transcript to verify that tool call ids are echoed back correctly.

Acceptance criteria: given a tool call that is missing path, the model's next context contains an error tool result instead of the runtime simply crashing; and given an assistant message with stopReason: "aborted", every tool call in it has a corresponding result message.