Openclaw · EN · Part 2

Ch 05 Agent Runtime And Pi Integration

05-ch-05-Agent-Runtime-and-Pi-Integration.md

第5章 Agent Runtime and Pi Integration

The Pi Agent Runtime

OpenClaw embeds the Pi agent framework as its core execution engine. Unlike traditional chatbot systems that simply pass messages to an LLM API, OpenClaw's PiEmbeddedRunner provides a sophisticated runtime that handles streaming responses, tool execution, prompt caching, compaction, and retry logic—all while maintaining session state across conversation turns.

PiEmbeddedRunner Architecture

The Pi runtime is organized into several specialized modules, each handling a specific concern:

// src/agents/pi-embedded-runner/run.ts
export async function runEmbeddedPiAgent(
  params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {
  // 1. Resolve workspace and agent context
  const workspaceResolution = resolveRunWorkspaceDir({
    workspaceDir: params.workspaceDir,
    sessionKey: params.sessionKey,
    agentId: params.agentId,
    config: params.config,
  });

  // 2. Initialize runtime plugins and auth
  ensureRuntimePluginsLoaded({
    config: params.config,
    workspaceDir: resolvedWorkspace,
    allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
  });

  // 3. Resolve model and auth profiles
  const { model, error, authStorage } = await resolveModelAsync(
    provider, modelId, agentDir, params.config,
  );

  // 4. Execute the agent with retry/compaction handling
  const attempt = await runEmbeddedAttempt({
    sessionId: params.sessionId,
    prompt: params.prompt,
    model: effectiveModel,
    // ... extensive configuration
  });
}

The runEmbeddedPiAgent function orchestrates the entire lifecycle: workspace resolution, authentication, model selection, retry loops, compaction recovery, and final payload assembly. It's a 600+ line function that handles everything from context overflow to API key rotation.

🤿 深入底层: The runtime maintains separate "lanes" for execution—sessionLane for session-specific operations and globalLane for cross-session concerns. This prevents concurrent operations on the same session from corrupting state while allowing parallel execution across different sessions.

Agent Lifecycle Management

An agent run progresses through several distinct phases:

  1. Initialization: Workspace resolution, plugin loading, auth profile selection
  2. Context Assembly: Session history loading, prompt construction, tool registration
  3. Model Invocation: API call construction, streaming response handling
  4. Tool Execution: Parsing tool calls, executing through Gateway, returning results
  5. Completion: Final payload assembly, usage reporting, cleanup

Each phase has its own error handling and retry logic. Context overflow triggers automatic compaction; API errors may trigger auth profile rotation or model fallback; timeout handling ensures runaway operations don't block forever.

Agent Configuration and Invocation

Agents are configured through a combination of session-level settings, agent-specific defaults, and per-invocation parameters:

// Session configuration through config system
type SessionConfig = {
  provider?: string;        // e.g., "anthropic", "openai"
  model?: string;           // e.g., "sonnet-4.6", "gpt-5.4"
  thinking?: "off" | "low" | "high";
  toolsAllow?: string[];    // Whitelist of tool IDs
  toolsDeny?: string[];     // Blacklist of tool IDs
};

// Per-invocation parameters override session config
const result = await runEmbeddedPiAgent({
  sessionId: "main",
  sessionKey: "telegram/@user",
  provider: "anthropic",
  model: "sonnet-4.6",
  prompt: [{ role: "user", content: "What's the weather?" }],
  // Streaming callbacks
  onPartialReply: (text) => console.log(text),
  onToolResult: (result) => console.log("Tool:", result),
  // Execution context
  messageChannel: "telegram",
  agentAccountId: "123456789",
  senderId: "user123",
});

RPC Mode vs Direct Execution

OpenClaw supports two execution modes for agent invocation:

RPC Mode (default): Agent runs as a subprocess, communicating through the Gateway WebSocket protocol. This provides isolation and resource management at the cost of inter-process overhead.

// Agent spawned via Gateway RPC
const client = await GatewayClient.connect({
  url: "ws://localhost:18789",
  deviceId: "cli-node",
});
await client.sessions.create({
  sessionId: "main",
  prompt: [{ role: "user", content: "Hello" }],
});

Direct Execution: Agent runs in-process via runEmbeddedPiAgent, used by CLI commands and internal operations. This avoids serialization overhead but requires careful resource management.

⚠️ 避坑指南: Direct execution mode does not enforce the same resource limits as RPC mode. Long-running operations or memory leaks can affect the parent process. Use RPC mode for untrusted inputs or production deployments.

Message Flow Through the Agent

Tracing a message from Gateway receipt to agent response reveals the full execution pipeline. Let's follow a simple user message: "What files are in my workspace?"

Context Assembly

Before the prompt reaches the LLM, OpenClaw assembles context from multiple sources:

// src/agents/pi-embedded-runner/run/attempt.ts
async function runEmbeddedAttempt(params: RunEmbeddedAttemptParams) {
  // 1. Load session history from disk
  const sessionHistory = await loadSessionHistory(params.sessionFile);

  // 2. Apply history limits (DM vs group, provider-specific)
  const limitedHistory = limitHistoryTurns(sessionHistory, {
    maxTurns: getHistoryLimitFromSessionKey(params.sessionKey),
    dmHistoryLimit: getDmHistoryLimitFromSessionKey(params.sessionKey),
  });

  // 3. Sanitize for policy compliance
  const sanitizedHistory = sanitizeSessionHistory(limitedHistory, {
    allowImages: params.allowImages ?? true,
    maxImageSize: params.maxImageSize,
    imageSanitizationLimits: resolveImageSanitizationLimits(),
  });

  // 4. Build system prompt override if configured
  const systemPrompt = createSystemPromptOverride({
    agentId: params.agentId,
    sessionKey: params.sessionKey,
    extraSystemPrompt: params.extraSystemPrompt,
  });
}

The context assembly process respects message channel limits (DMs may have different history limits than groups), image sanitization policies, and agent-specific system prompts.

Prompt Construction

The final prompt sent to the LLM includes:

  1. System prompt: Agent behavior instructions, tool descriptions
  2. Session history: Previous conversation turns (after compaction/limits)
  3. Current message: The user's new input
  4. Tool results: Results from any tools executed in previous turns
// Final prompt structure for LLM API
const messages = [
  { role: "system", content: systemPrompt },
  ...sessionHistory,  // Previous turns
  { role: "user", content: userMessage },
  ...toolResults,     // Pending tool results
];

☕ 茶水间时间: Why does OpenClaw put the system prompt as a separate message rather than concatenating it? Many LLM APIs (especially Anthropic's) treat the system message specially—it's excluded from prompt caching calculations and may be processed differently internally. This keeps your token usage lower while maintaining behavioral instructions.

Model Invocation

With the prompt constructed, OpenClaw invokes the model provider API:

// Provider-agnostic invocation
const response = await model.stream({
  messages: promptMessages,
  tools: registeredTools,
  maxTokens: ctxInfo.tokens,
  onChunk: (chunk) => {
    // Handle streaming tokens
    if (chunk.type === "text") {
      params.onPartialReply?.(chunk.text);
    }
  },
});

The streaming response is parsed incrementally, with text chunks passed to onPartialReply for real-time UI updates and tool calls extracted when complete.

Response Streaming

The subscribeEmbeddedPiSession function handles the complex streaming protocol:

// src/agents/pi-embedded-subscribe.ts
export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionParams) {
  const state: EmbeddedPiSubscribeState = {
    assistantTexts: [],
    toolMetas: [],
    blockBuffer: "",
    blockState: { thinking: false, final: false },
    // ... more state tracking
  };

  // Subscribe to Pi agent events
  const session = createEmbeddedPiSession({
    onText: (text) => {
      // Buffer and emit text chunks
      state.blockBuffer += text;
      params.onPartialReply?.(text);
    },
    onToolCall: (toolCall) => {
      // Execute tool and feed result back
      executeTool(toolCall).then(result => {
        session.continue({ toolResult: result });
      });
    },
    onMessage: (message) => {
      // Full message received
      return buildEmbeddedRunPayloads(state, message);
    },
  });
}

The subscriber manages buffering for partial chunks, handles special tags like <thinking> and <final>, and coordinates tool execution with response streaming.

Prompt Caching for Performance

Prompt caching is one of OpenClaw's most important performance optimizations. By maintaining cache stability, OpenClaw reduces both latency and API costs dramatically.

Cache Stability Importance

Anthropic's prompt caching (and similar features from other providers) works by fingerprinting the exact byte sequence of your prompt prefix. If the prefix matches a previous request, you pay significantly less for those cached tokens.

A cache hit on a 100K token context can reduce costs by ~90% and improve response time by 2-3x. But cache hits require byte-identical prefixes—any change invalidates the entire cache.

Deterministic Ordering

OpenClaw enforces deterministic ordering for all cached content:

// src/agents/pi-tools.ts
function resolveGatewayScopedTools(params: {
  cfg: OpenClawConfig;
  sessionKey: string;
  agentId?: string;
}): { agentId: string; tools: AnyAgentTool[] } {
  // Tools are sorted alphabetically for consistent ordering
  const sortedTools = [...availableTools].sort((a, b) =>
    a.name.localeCompare(b.name)
  );

  // Maps are converted to sorted arrays
  const sortedConfig = Object.entries(config)
    .sort(([keyA], [keyB]) => keyA.localeCompare(keyB));

  return { agentId, tools: sortedTools };
}

Any code that assembles model payloads from maps, sets, or filesystem reads must sort before serialization. This includes tool lists, MCP catalogs, plugin manifests, and session history.

⚠️ 避坑指南: Never use Object.keys(), Object.entries(), or for...in loops on objects whose order will affect prompts without explicit sorting. JavaScript object key order is insertion-order for string keys, which can vary across runs.

Performance and Cost Benefits

With effective caching, a typical conversation shows dramatic savings:

TurnInput TokensCache HitCost (Cached)Cost (Uncached)
Turn 1120KNo$1.20$1.20
Turn 2122KYes (118K)$0.08$1.22
Turn 3125KYes (118K)$0.12$1.25
Turn 4118KYes (118K)$0.00$1.18

After the initial turn, costs drop by ~90% as long as the conversation context prefix remains stable.

Tool Execution During Agent Runs

Tool execution is where agents interact with the outside world—reading files, making web requests, sending messages, and more. OpenClaw's tool system bridges the gap between LLM text generation and real-world side effects.

Tool Registration

Tools are registered through the Plugin SDK and exposed to agents during runtime:

// src/agents/pi-tools.ts
export function registerAgentTool(tool: AnyAgentTool): void {
  const normalizedTool = {
    ...tool,
    parameters: normalizeToolParameters(tool.parameters),
    // Apply wrappers for policy, auth, validation
    execute: wrapToolWithPolicy(
      wrapToolWithBeforeToolCallHook(
        wrapToolWithAbortSignal(tool.execute)
      )
    ),
  };

  toolRegistry.set(tool.name, normalizedTool);
}

// Example tool registration
registerAgentTool({
  name: "file_search",
  description: "Search for files by name pattern in workspace",
  parameters: Type.Object({
    pattern: Type.String({ description: "Glob pattern to match" }),
    path: Type.Optional(Type.String({ description: "Starting directory" })),
  }),
  execute: async (toolCallId, args) => {
    const results = await glob(args.pattern, { cwd: args.path });
    return jsonResult(results);
  },
});

Each tool goes through a wrapping pipeline that applies policy checks, abort signal support, parameter validation, and before/after hooks.

Invocation Protocols

Tools can be invoked through multiple pathways:

Direct Agent Invocation: Agent requests tool execution during response generation.

// Agent emits tool call during streaming
{
  "type": "tool_use",
  "id": "toolu_01A1B2C3D",
  "name": "file_search",
  "input": { "pattern": "**/*.ts", "path": "./src" }
}

// OpenClaw executes the tool
const result = await toolRegistry.get("file_search").execute(
  "toolu_01A1B2C3D",
  { pattern: "**/*.ts", path: "./src" }
);

// Result fed back to agent for continuation
{
  "type": "tool_result",
  "tool_use_id": "toolu_01A1B2C3D",
  "content": JSON.stringify(["src/a.ts", "src/b.ts"])
}

HTTP API Invocation: External systems invoke tools via /tools/invoke endpoint.

// src/gateway/tools-invoke-http.ts
export async function handleToolsInvokeHttpRequest(
  req: IncomingMessage,
  res: ServerResponse,
): Promise<boolean> {
  // Authenticate request
  const auth = await authorizeGatewayHttpRequestOrReply({ req, res, auth });

  // Parse request body
  const body = await readJsonBodyOrError(req, res);
  const toolName = body.tool;
  const args = body.args;

  // Resolve tool with policy filtering
  const { agentId, tools } = resolveGatewayScopedTools({
    cfg: loadConfig(),
    sessionKey: body.sessionKey,
  });
  const tool = tools.find(t => t.name === toolName);

  // Execute tool
  const result = await tool.execute(`http-${Date.now()}`, args);
  sendJson(res, 200, { ok: true, result });
}

Result Handling

Tool results undergo validation, sanitization, and formatting before being fed back to the agent:

// src/agents/pi-tools.before-tool-call.ts
export async function runBeforeToolCallHook(params: {
  toolName: string;
  params: Record<string, unknown>;
  toolCallId: string;
  ctx: HookContext;
}): Promise<{ blocked: boolean; reason?: string; params: Record<string, unknown> }> {
  // Run policy checks
  const policyResult = await applyToolPolicy(params);
  if (policyResult.blocked) {
    return { blocked: true, reason: policyResult.reason };
  }

  // Run before-tool-call hooks
  const hookResult = await hookRunner.runBeforeToolCall({
    toolName: params.toolName,
    params: params.params,
    toolCallId: params.toolCallId,
    hookContext: params.ctx,
  });

  return {
    blocked: hookResult.blocked,
    reason: hookResult.reason,
    params: hookResult.params ?? params.params,
  };
}

Bidirectional Flow

Tool execution in OpenClaw is fundamentally bidirectional: agents invoke tools to affect the world, and tools can trigger new agent runs (subagents) for complex operations.

Rendering diagram...

This bidirectional flow enables powerful workflows: an agent can spawn a subagent to research a topic, the subagent uses web search tools to gather information, and the final result flows back up through the parent agent to the user.

Chapter 5: Creating a Custom Tool

Let's put theory into practice by creating a custom tool that agents can invoke.

Example: A Simple Calculator Tool

// src/agents/tools/calculator-tool.ts
import { Type } from "@sinclair/typebox";
import type { AnyAgentTool } from "../pi-tools.types.js";
import { jsonResult } from "./common.js";

const calculatorTool: AnyAgentTool = {
  name: "calculator",
  description: "Perform basic arithmetic calculations",
  parameters: Type.Object({
    expression: Type.String({
      description: "Mathematical expression to evaluate (e.g., '2 + 2 * 3')"
    }),
  }),
  execute: async (toolCallId, args) => {
    try {
      // Safe evaluation using Function constructor with restricted scope
      const sanitized = args.expression.replace(/[^0-9+\-*/().\s]/g, '');
      const result = Function(`"use strict"; return (${sanitized})`)();
      
      return jsonResult({
        expression: args.expression,
        result: Number(result),
        success: true
      });
    } catch (error) {
      return jsonResult({
        expression: args.expression,
        error: error instanceof Error ? error.message : String(error),
        success: false
      });
    }
  }
};

export { calculatorTool };

To register this tool, you'd add it to your agent's tool configuration:

// In your agent initialization or plugin
import { calculatorTool } from "./tools/calculator-tool.js";

registerAgentTool(calculatorTool);

Now agents can invoke your calculator:

User: What's 15% of 240?
Agent: [Invokes calculator tool: 240 * 0.15]
Agent: 15% of 240 is 36.

Chapter Exercises

  1. Create a custom tool: Build a tool that retrieves the current weather for a given location (hint: use a weather API or mock data). Register it and test agent invocation.

  2. Measure cache hit rates: Enable verbose logging (--thinking high) and run a multi-turn conversation. Observe the cacheRead vs input token counts in the session usage report. What percentage of tokens are cached after turn 3?

  3. Trace an agent error: Intentionally trigger an error (e.g., invalid tool parameters, missing auth credentials) and trace the error path through the system. Where is the error caught? How does it reach the user? What retry logic kicks in?


Agents don't exist in isolation—they're tied to messaging channels that deliver their messages and receive user input. Let's explore the channel system next.