How the Earendil Pi Agent Runtime Handles Tool Calling: Architecture and Implementation

The Earendil Pi agent runtime processes LLM tool calls by instantiating a ToolExecutionComponent for each request, tracking pending operations in a pendingTools map keyed by toolCallId, and correlating asynchronous results back to the UI when the tool finishes.

The Earendil Pi agent (located in the earendil-works/pi repository) executes external tools through an interactive mode runtime that bridges large language model (LLM) requests with file system and shell operations. Understanding how this runtime handles tool calling is essential for developers extending the agent's capabilities or debugging execution flows. The implementation centers on the InteractiveMode class in packages/coding-agent/src/modes/interactive/interactive-mode.ts, which orchestrates the entire lifecycle from request to render.

The Tool Call Message Protocol

Before execution begins, the LLM emits a structured message requesting tool use. The runtime expects a specific interface defined in scripts/tool-stats.ts.

Message Structure Definition

According to the source analysis, the message adheres to this TypeScript shape found around lines 10-14 in scripts/tool-stats.ts:

{
  type: "toolCall";
  id: string;
  name: string;
  arguments?: Record<string, unknown>;
}

The id field serves as the unique toolCallId that persists throughout the request lifecycle.

The Interactive Mode Execution Loop

When the agent operates in interactive mode, the InteractiveMode class manages the conversation loop. Incoming messages stream through handleMessage, which detects tool requests and initiates execution.

Parsing and Registering Pending Tools

Upon detecting a block with type === "toolCall", the runtime immediately creates a ToolExecutionComponent to represent the pending operation. This component registers in an internal Map named pendingTools, keyed by the toolCallId. The logic around line 2790 in interactive-mode.ts performs this registration:

if (content.type === "toolCall") {
  const component = new ToolExecutionComponent({
    toolCallId: content.id,
    /* …other UI parameters… */
  });
  this.pendingTools.set(event.toolCallId, component);
}

The component renders a placeholder line (e.g., “⧗ read …”) while awaiting completion.

Tool Registry and Execution

The runtime resolves tool names through a registry built by createCodingTools, which includes built-in implementations such as read, write, and bash. Each tool’s execute method receives the same toolCallId to enable result correlation. For example, in packages/coding-agent/src/core/tools/read.ts (around line 220), the signature appears as:

export const createReadTool = (cwd: string): ToolDefinition => ({
  name: "read",
  execute: async (toolCallId, { path }, signal, onUpdate) => {
    // File reading logic
    return { type: "toolResult", toolName: "read", toolCallId, content: fileText };
  },
});

Streaming Incremental Updates

While the tool executes, it emits progress updates via the onUpdate callback. The ToolExecutionComponent forwards these updates to the terminal UI, appending output beneath the placeholder defined in packages/coding-agent/src/modes/interactive/components/tool-execution.ts (lines 23-54).

Handling Tool Results and Lifecycle Completion

Once a tool finishes, it returns a result message that the runtime must match to its pending UI component.

Correlating Results with Pending Calls

The result message carries role: "toolResult" along with the toolCallId. The InteractiveMode retrieves the corresponding component from pendingTools and finalizes the display. The cleanup logic around line 2835 in interactive-mode.ts performs this matching:

if (message.role === "toolResult" && message.toolName) {
  const component = this.pendingTools.get(message.toolCallId!);
  if (component) {
    component.renderResult(message);
    this.pendingTools.delete(message.toolCallId!);
  }
}

Rendering Final Output

After correlation, ToolExecutionComponent.renderResult formats the final output. For HTML export scenarios, packages/coding-agent/src/core/export-html/tool-renderer.ts manages rendering state using renderedArgs.set(toolCallId, args) to cache argument displays.

Tool Call Accounting and UI Status

The runtime maintains counters for diagnostic purposes. Around line 5184 in interactive-mode.ts, the UI status line increments stats.toolCalls and displays the total:

info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;

Summary

  • The Earendil Pi runtime defines tool calls via the ToolCallContent interface in scripts/tool-stats.ts, requiring a unique toolCallId for every request.
  • InteractiveMode tracks active executions in a pendingTools Map that associates each toolCallId with a ToolExecutionComponent.
  • The ToolExecutionComponent handles UI placeholders and streams incremental updates via the onUpdate callback during tool execution.
  • Built-in tools in packages/coding-agent/src/core/tools/ receive the toolCallId as a parameter, ensuring results can be correlated back to the correct conversation thread.
  • Completion triggers a toolResult message; the runtime matches it via toolCallId, renders final output, and removes the entry from pendingTools.
  • Execution statistics aggregate in real-time, displayed in the interactive UI status line.

Frequently Asked Questions

What is the shape of a tool call message in the Earendil Pi runtime?

The runtime expects a JSON object with type: "toolCall", a unique string id, the name of the tool, and an optional arguments record. This structure is formally defined in scripts/tool-stats.ts and parsed by InteractiveMode.handleMessage in packages/coding-agent/src/modes/interactive/interactive-mode.ts.

How does the runtime match tool results to the correct UI component?

It uses a Map called pendingTools keyed by toolCallId. When a toolResult message arrives, the runtime looks up the ID in interactive-mode.ts, invokes renderResult on the stored ToolExecutionComponent, and deletes the entry to free resources.

Where is the tool execution logic implemented for built-in tools like read and write?

Built-in tool implementations reside in packages/coding-agent/src/core/tools/ (e.g., read.ts, write.ts). The tool-definition-wrapper.ts file wraps these definitions to ensure the toolCallId propagates through the extension context to each execute method.

How does the runtime handle streaming output from long-running tools?

Tools stream incremental data through the onUpdate callback passed to their execute function. The ToolExecutionComponent subscribed to these events updates the terminal UI in real-time, keeping the user informed of progress before the final result arrives.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →