Chapter 9

Pi Agent Execution Loop: State Machine, 11 Lifecycle Events and 7-Layer Tool Pipeline

Chapter 9: Pi Agent Execution Loop: State Machine, 11 Lifecycle Events, and the 7-Layer Tool Pipeline

9.1 Complete Parameter Reference for createAgentSession

createAgentSession is Pi framework's core entry point. Understanding the role of each parameter is the foundation for understanding the entire execution loop.

interface AgentSessionParams {
  // ── Basic Runtime Environment ──
  cwd: string;                    // Working directory (where bash commands execute)
  agentDir: string;               // Agent config directory (stores system prompt and other resources)
  
  // ── Authentication and Storage ──
  authStorage: AuthStorage;       // API key storage (OS keychain)
  
  // ── Model Configuration ──
  modelRegistry: ModelRegistry;   // Registry of available models
  model: string;                  // Model ID or alias to use
  thinkingLevel: "none" | "low" | "medium" | "high";  // Extended thinking depth
  
  // ── Tool Configuration ──
  tools: Tool[];                  // Base tool list (typically the four Pi tools)
  customTools?: Tool[];           // Channel-specific custom tools
  
  // ── State Management ──
  sessionManager: SessionManager; // Session persistence (JSONL storage)
  settingsManager: SettingsManager; // Runtime settings (timeouts, limits, etc.)
  resourceLoader: ResourceLoader; // Resource hot-reloading
  
  // ── Event and Session Association ──
  parentSessionId?: string;       // Parent session ID (for sub-Agents)
  sessionId?: string;             // Specify when resuming an existing session
  
  // ── Lifecycle Control ──
  abortSignal?: AbortSignal;      // External interrupt signal
  onEvent?: (event: AgentEvent) => void; // Event callback
}

9.1.1 The Deeper Role of Each Parameter

The difference between cwd and agentDir:

cwd (working directory):
  /workspace/my-project/       ← bash commands execute here
  ├── src/
  ├── package.json
  └── ...

agentDir (Agent config directory):
  ~/.openclaw/agents/my-agent/  ← The Agent's "brain config" location
  ├── system-prompt.md          ← System Prompt template
  ├── tools.yaml                ← Tool configuration
  └── resources/                ← Other static resources

The practical impact of thinkingLevel:

"none"   → No thinking feature, fastest, suitable for simple tasks
"low"    → Up to 1024 tokens of internal reasoning, lightly complex tasks
"medium" → Up to 8192 tokens, suitable for moderately complex engineering problems
"high"   → Up to 32768 tokens, suitable for complex architecture design and hard debugging

Higher thinking levels significantly improve multi-step reasoning but also increase latency and cost.


9.2 Complete State Machine for 11 Lifecycle Events

9.2.1 State Machine Overview

                    ┌─────────────────────────────────────────┐
                    │              AGENT State Machine          │
                    └─────────────────────────────────────────┘

  Session created
       │
       ▼
① agent_start ──────────────────────────────────────────────────────┐
       │                                                             │
       ▼                                                             │
② turn_start                                                         │
       │                                                             │
       ▼                                                             │
③ message_start                                                       │
       │                                                             │
       ▼                                                             │
④ text_delta (×N)  ← Streaming output, fires repeatedly             │
       │                                                             │
       ├────── Has tool calls ──→ ⑤ tool_execution_start            │
       │                                  │                         │
       │                                  ▼                         │
       │                         ⑥ tool_execution_update (×N)       │
       │                                  │                         │
       │                                  ▼                         │
       │                         ⑦ tool_execution_end               │
       │                                  │                         │
       │                                  └──→ back to ③ (continue) │
       │                                                             │
       ▼                                                             │
⑧ message_end                                                         │
       │                                                             │
       ▼                                                             │
⑨ turn_end                                                           │
       │                                                             │
       ├─── Has tool calls & termination condition not met ──→ back to ② │
       │                                                             │
       ▼                                                             │
⑩ agent_end ◄───────────────────────────────────────────────────────┘
       │          (Errors or interrupts at any stage jump directly to agent_end)
       ▼
⑪ error (if applicable)

9.2.2 Detailed Description of Each Event

① agent_start

interface AgentStartEvent {
  type: "agent_start";
  sessionId: string;
  model: string;
  tools: string[];           // List of tool names
  thinkingLevel: string;
  resumedFrom?: string;      // If resuming a session, the original session ID
  timestamp: number;
}

The signal that the Agent has begun execution. At this point all parameters are validated, the tool pipeline is built, but no LLM call has occurred yet.

② turn_start

interface TurnStartEvent {
  type: "turn_start";
  turnId: string;            // Unique ID for this turn
  turnIndex: number;         // Which turn this is (0-based)
  messages: Message[];       // Complete context carried into this turn
}

A "turn" spans from the LLM call beginning to the LLM stopping generation (either producing no tool calls, or completing all tool calls). An Agent execution typically contains multiple turns (user message → tool calls → tool results → continued generation → ...).

③ message_start

interface MessageStartEvent {
  type: "message_start";
  messageId: string;
  role: "assistant";
  model: string;
  usage: {
    inputTokens: number;   // Input token count (based on context)
    // outputTokens determined at message_end
  };
}

④ text_delta (streaming text, fires repeatedly)

interface TextDeltaEvent {
  type: "text_delta";
  messageId: string;
  delta: string;             // Incremental text fragment (typically 1-50 characters)
  index: number;             // Cumulative character position
}

Each delta should be rendered to the UI immediately to achieve the typewriter effect.

⑤ tool_execution_start

interface ToolExecutionStartEvent {
  type: "tool_execution_start";
  toolCallId: string;        // Uniquely identifies this tool call
  toolName: string;          // Tool name
  input: Record<string, unknown>;  // Tool parameters (LLM-generated)
  messageId: string;         // Owning message ID
}

⑥ tool_execution_update (fires repeatedly)

interface ToolExecutionUpdateEvent {
  type: "tool_execution_update";
  toolCallId: string;
  updateType: "stdout" | "stderr" | "progress" | "partial-result";
  content: string;
  progressPercent?: number;  // 0-100, if the tool supports progress reporting
}

⑦ tool_execution_end

interface ToolExecutionEndEvent {
  type: "tool_execution_end";
  toolCallId: string;
  success: boolean;
  output: string;            // Full output returned to the LLM
  durationMs: number;        // Execution duration
  error?: {
    code: string;
    message: string;
  };
}

⑧ message_end

interface MessageEndEvent {
  type: "message_end";
  messageId: string;
  stopReason: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence";
  usage: {
    inputTokens: number;
    outputTokens: number;
    thinkingTokens?: number;  // If thinking feature was used
    cacheReadTokens?: number; // Tokens served from cache
  };
}

⑨ turn_end

interface TurnEndEvent {
  type: "turn_end";
  turnId: string;
  hasToolCalls: boolean;
  shouldContinue: boolean;   // Whether the execution loop continues
  terminationReason?: string; // If terminating, the reason
}

⑩ agent_end

interface AgentEndEvent {
  type: "agent_end";
  sessionId: string;
  totalTurns: number;
  totalTokens: number;
  durationMs: number;
  terminationReason: 
    | "no_tool_calls"         // Normal completion: LLM produces no tool calls
    | "timeout_48h"           // 48-hour timeout
    | "idle_timeout_120s"     // LLM idle for 120 seconds
    | "abort_signal"          // External interrupt
    | "gateway_disconnected"  // Gateway disconnection
    | "error";                // Error termination
}

⑪ error

interface ErrorEvent {
  type: "error";
  code: string;
  message: string;
  context?: Record<string, unknown>;  // Context at the time of the error
  recoverable: boolean;               // Whether recovery can be attempted
}

9.3 The 7-Layer Tool Injection and Filtering Pipeline

Tools pass through 7 layers of processing before being handed to the LLM. This pipeline is built when createAgentSession is called, not rebuilt on each tool invocation.

Tool pipeline construction flow:

Input tools (params.tools + params.customTools)
           │
           ▼
     ┌─────────────┐
     │   Layer 1   │  Pi base tools
     │             │  read / write / edit / bash
     └──────┬──────┘
            │
            ▼
     ┌─────────────┐
     │   Layer 2   │  Sandbox-aware substitution
     │             │  bash → sandboxed-bash
     │             │  exec → sandboxed-exec (if present)
     └──────┬──────┘
            │
            ▼
     ┌─────────────┐
     │   Layer 3   │  OpenClaw extension tools
     │             │  session.branch / session.navigate
     │             │  gateway.reload / agent.interrupt
     └──────┬──────┘
            │
            ▼
     ┌─────────────┐
     │   Layer 4   │  Channel-specific tools
     │             │  discord.reply / slack.reaction
     │             │  (injected by the channel Binding)
     └──────┬──────┘
            │
            ▼
     ┌─────────────┐
     │   Layer 5   │  Policy filter
     │             │  Filter disallowed tools based on Agent permissions
     │             │  Restrict available tool set based on scopes
     └──────┬──────┘
            │
            ▼
     ┌─────────────┐
     │   Layer 6   │  Schema normalization
     │             │  Handle Gemini/OpenAI Schema differences
     │             │  Ensure JSON Schema compliance (additionalProperties, etc.)
     └──────┬──────┘
            │
            ▼
     ┌─────────────┐
     │   Layer 7   │  AbortSignal wrapping
     │             │  Inject cancellation signal into each tool call
     │             │  Connect to Session lifecycle management
     └──────┬──────┘
            │
            ▼
Final tool list (passed to the LLM as the tools parameter)

9.3.1 Detail on Each Layer

Layer 1: Pi Base Tools

The starting point of the tool pipeline, containing the four core tools defined by Pi framework:

const piBaseTools: Tool[] = [
  {
    name: "read",
    description: "Read file contents. Supports partial reading (offset/limit).",
    inputSchema: {
      type: "object",
      properties: {
        file_path: { type: "string", description: "Absolute path" },
        offset: { type: "number", description: "Starting line (0-based)" },
        limit: { type: "number", description: "Line count limit" }
      },
      required: ["file_path"]
    },
    execute: readFileImpl
  },
  // write, edit, bash are similar
];

Layer 2: Sandbox-Aware Substitution

function applySandboxLayer(tools: Tool[], sandbox: Sandbox): Tool[] {
  return tools.map(tool => {
    if (tool.name === "bash") {
      return {
        ...tool,
        execute: (input) => sandbox.execute(input.command, {
          cwd: sessionParams.cwd,
          timeout: sandbox.config.timeout,
          allowedPaths: sandbox.config.allowedPaths
        })
      };
    }
    return tool;
  });
}

Layer 3: OpenClaw Extension Tools

const openclawExtensionTools: Tool[] = [
  {
    name: "session_branch",
    description: "Create a branch of the current session, returning the new sessionId",
    execute: async () => {
      return sessionManager.branchSession(currentSessionId, latestMessageId);
    }
  },
  {
    name: "agent_interrupt",
    description: "Request graceful stop of the current Agent execution",
    execute: async () => {
      abortController.abort("agent-requested");
      return { message: "Interrupt request sent" };
    }
  }
];

Layer 4: Channel Tools

Channel tools are injected by the Binding layer, allowing Agents to interact directly with message channels:

// Channel tools injected by the Discord Binding
const discordChannelTools: Tool[] = [
  {
    name: "discord_add_reaction",
    description: "Add an emoji reaction to the Discord message that triggered this session",
    execute: async (input) => {
      await discordClient.addReaction(
        binding.sourceMessageId,
        input.emoji
      );
    }
  },
  {
    name: "discord_send_file",
    description: "Send a file to the current Discord channel",
    execute: async (input) => {
      return discordClient.sendFile(binding.channelId, input.filePath);
    }
  }
];

Layer 5: Policy Filter

function applyPolicyFilter(tools: Tool[], policy: AgentPolicy): Tool[] {
  return tools.filter(tool => {
    // Check if the tool is in the allowlist
    if (policy.allowedTools && !policy.allowedTools.includes(tool.name)) {
      return false;
    }
    // Check required scope
    if (tool.requiredScope && !policy.scopes.includes(tool.requiredScope)) {
      return false;
    }
    return true;
  });
}

Layer 6: Schema Normalization

Different LLM Providers have varying levels of JSON Schema support:

function normalizeToolSchema(tool: Tool, provider: string): Tool {
  const schema = structuredClone(tool.inputSchema);
  
  if (provider === "gemini") {
    // Gemini does not support the $schema field
    delete schema["$schema"];
    // Gemini does not support additionalProperties: false
    // Must convert to equivalent Gemini format
    removeAdditionalPropertiesFalse(schema);
  }
  
  if (provider === "openai") {
    // OpenAI requires additionalProperties: false at the top level
    if (!schema.additionalProperties) {
      schema.additionalProperties = false;
    }
  }
  
  return { ...tool, inputSchema: schema };
}

Layer 7: AbortSignal Wrapping

function wrapWithAbortSignal(tools: Tool[], signal: AbortSignal): Tool[] {
  return tools.map(tool => ({
    ...tool,
    execute: async (input) => {
      // Check if already aborted
      if (signal.aborted) {
        throw new AbortError("Tool execution aborted before start");
      }
      
      // Execute the tool, passing the abort signal
      return tool.execute(input, { signal });
    }
  }));
}

9.4 The 5 Loop Termination Conditions

Execution loop termination conditions (any one condition triggers termination):

Condition 1: No tool calls produced (normal completion)
  ├── The LLM generated a complete text response
  └── No tool_use blocks were included
  → terminationReason: "no_tool_calls"
  → This is the most common normal completion path

Condition 2: 48-hour timeout (hard limit)
  ├── Timer starts from agent_start
  └── Regardless of current state
  → terminationReason: "timeout_48h"
  → Prevents permanently running zombie sessions

Condition 3: LLM idle for 120 seconds (soft timeout)
  ├── LLM response has not produced any token for 120 seconds
  └── Usually indicates an LLM Provider service issue
  → terminationReason: "idle_timeout_120s"
  → Works with Condition 2 to provide layered timeout protection

Condition 4: AbortSignal triggered
  ├── User actively interrupts (Ctrl+C)
  ├── Interrupt injected by steer mode
  ├── Gateway receives agent.interrupt request
  └── Parent Agent terminates (sub-Agent AbortSignal propagation)
  → terminationReason: "abort_signal"
  → Highest priority, takes effect immediately (but waits for current tool call to finish)

Condition 5: Gateway disconnection
  ├── WebSocket connection drops unexpectedly
  └── Gateway process crashes and restarts
  → terminationReason: "gateway_disconnected"
  → Session state has been persisted; can be resumed after reconnection

9.5 Event Bridging: subscribeEmbeddedPiSession

When Pi Agent runs embedded, its internal events must be translated into OpenClaw's Gateway event stream so clients (Web UI, CLI) can subscribe to them.

// Core implementation of event bridging
function subscribeEmbeddedPiSession(
  piSession: AgentSession,
  gateway: Gateway,
  gatewaySessionId: string
): Unsubscribe {
  
  return piSession.onEvent((event: AgentEvent) => {
    // Translate Pi internal events to Gateway WebSocket events
    const gatewayEvent = translatePiEvent(event, gatewaySessionId);
    
    if (gatewayEvent) {
      gateway.broadcastToSession(gatewaySessionId, {
        type: "event",
        event: gatewayEvent.name,
        payload: gatewayEvent.payload,
        seq: gateway.nextSeq(),
        stateVersion: gateway.currentStateVersion()
      });
    }
  });
}

function translatePiEvent(event: AgentEvent, sessionId: string): GatewayEvent | null {
  switch (event.type) {
    case "text_delta":
      return {
        name: "session.message.delta",
        payload: {
          sessionId,
          messageId: event.messageId,
          delta: event.delta,
          index: event.index
        }
      };
    
    case "tool_execution_start":
      return {
        name: "session.tool.started",
        payload: {
          sessionId,
          toolCallId: event.toolCallId,
          toolName: event.toolName,
          input: event.input
        }
      };
    
    case "agent_end":
      return {
        name: "session.status.changed",
        payload: {
          sessionId,
          status: "idle",
          terminationReason: event.terminationReason
        }
      };
    
    default:
      return null;  // Some internal events do not need to be broadcast to clients
  }
}

9.6 Per-Session Queue Serialization

As discussed in Chapter 7, the Session Lane's serial design prevents concurrent state races within the same Session. But inside the execution loop, there is an additional layer of serialization:

class SessionExecutionQueue {
  private queue: Promise<void> = Promise.resolve();
  
  // All operations on this Session are serialized through this function
  async enqueue<T>(operation: () => Promise<T>): Promise<T> {
    let resolve!: (value: T) => void;
    let reject!: (error: Error) => void;
    
    const resultPromise = new Promise<T>((res, rej) => {
      resolve = res;
      reject = rej;
    });
    
    // Promise chaining ensures serial execution
    this.queue = this.queue.then(async () => {
      try {
        const result = await operation();
        resolve(result);
      } catch (error) {
        reject(error as Error);
      }
    });
    
    return resultPromise;
  }
}

This implementation uses Promise chaining for lock-free async serialization, which is more appropriate for JavaScript's single-threaded event loop model than traditional mutexes.


9.7 Session Tree Structure

Each Session carries an id and parentId, forming a tree structure:

interface SessionNode {
  id: string;               // Globally unique ID
  parentId: string | null;  // null indicates a root session
  branchPoint: string | null; // Message ID of the branch point (null for root nodes)
  createdAt: number;        // Unix timestamp
  lastActiveAt: number;
}

9.7.1 Practical Applications of the Tree Structure

Use case 1: Sub-Agent tracking

sess_root (user's main session)
    │ parentId: null
    ├── sess_sub_1 (sub-Agent: analyzing src/)
    │       parentId: sess_root
    ├── sess_sub_2 (sub-Agent: analyzing tests/)
    │       parentId: sess_root
    └── sess_sub_3 (sub-Agent: generating report)
            parentId: sess_root

Use case 2: Branch navigation (exploring different approaches)

sess_v1 (initial approach A)
    │
    └── sess_v2 (branched at msg_020, exploring approach B)
            branchPoint: "msg_020"
            │
            └── sess_v3 (branched at msg_025, variant of approach B)
                    branchPoint: "msg_025"

Use case 3: User navigation in TUI

# List all sessions as a tree
openclaw sessions tree

# Switch to a specific branch
openclaw sessions switch sess_v2

# Create a new branch from msg_015
openclaw sessions branch --from msg_015

9.7.2 Data Inheritance When Branching

async function branchSession(
  parentSessionId: string,
  fromMessageId: string
): Promise<Session> {
  const parent = await sessionManager.loadSession(parentSessionId);
  
  // Find all messages up to the branch point
  const inheritedMessages = parent.transcript.filter(
    msg => msg.timestamp <= parent.messages.find(m => m.id === fromMessageId)!.timestamp
  );
  
  // Create new session with inherited message history
  const branchSession = await sessionManager.createSession({
    parentId: parentSessionId,
    branchPoint: fromMessageId,
    initialMessages: inheritedMessages  // Inherit history
  });
  
  return branchSession;
}

9.8 Complete Agent Loop Code Skeleton

async function runAgentLoop(params: AgentSessionParams): Promise<AgentEndEvent> {
  const session = await sessionManager.createSession(params);
  const tools = buildToolPipeline(params);  // Build 7-layer pipeline
  
  emit({ type: "agent_start", sessionId: session.id, ... });
  
  let turnIndex = 0;
  
  while (true) {
    // Check termination conditions (Conditions 2 and 4)
    if (params.abortSignal?.aborted) break;
    if (Date.now() - session.createdAt > 48 * 60 * 60 * 1000) break;
    
    emit({ type: "turn_start", turnId: newId(), turnIndex });
    
    // Call LLM (with 120s idle timeout detection)
    const stream = params.modelRegistry.getModel(params.model).chat(
      session.messages,
      { tools, signal: params.abortSignal }
    );
    
    let hasToolCalls = false;
    
    for await (const chunk of withIdleTimeout(stream, 120_000)) {
      // Handle streaming events
      if (chunk.type === "text_delta") {
        emit({ type: "text_delta", delta: chunk.delta, ... });
      }
      
      if (chunk.type === "tool_use") {
        hasToolCalls = true;
        emit({ type: "tool_execution_start", toolName: chunk.name, ... });
        
        const result = await executeToolWithUpdates(chunk, tools);
        
        emit({ type: "tool_execution_end", output: result, ... });
        session.appendToolResult(chunk.id, result);
      }
    }
    
    emit({ type: "turn_end", hasToolCalls, shouldContinue: hasToolCalls });
    
    // Condition 1: No tool calls produced, exit normally
    if (!hasToolCalls) break;
    
    turnIndex++;
  }
  
  const endEvent: AgentEndEvent = {
    type: "agent_end",
    sessionId: session.id,
    terminationReason: resolveTerminationReason(params.abortSignal),
    ...
  };
  
  emit(endEvent);
  return endEvent;
}

Chapter Summary

The precision of Pi Agent's execution loop lies in the coordination of multiple systems:

  1. Each parameter of createAgentSession has a clear responsibility, together forming the Agent's complete runtime environment
  2. 11 lifecycle events cover the complete span from startup to termination, providing fine-grained observability
  3. 7-layer tool pipeline is built once at Agent startup; each layer has a clear responsibility: base tools → sandbox → extensions → channel → policy → normalization → abort signal
  4. 5 termination conditions provide layered protection: normal completion, soft timeout, hard timeout, external interrupt, Gateway disconnection
  5. subscribeEmbeddedPiSession bridges Pi internal events to Gateway WebSocket events, enabling real-time client subscriptions
  6. Per-session Promise chain provides lock-free async serialization, complementing Session Lane for double-layered state safety
  7. Session tree structure supports sub-Agent tracking and branch navigation; parentId is the key field that makes all of this possible

The next chapter explores how messages are routed to the correct Agent — the details of the 8-level priority matching algorithm.

Rate this chapter
4.7  / 5  (44 ratings)

💬 Comments