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:
- Each parameter of createAgentSession has a clear responsibility, together forming the Agent's complete runtime environment
- 11 lifecycle events cover the complete span from startup to termination, providing fine-grained observability
- 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
- 5 termination conditions provide layered protection: normal completion, soft timeout, hard timeout, external interrupt, Gateway disconnection
- subscribeEmbeddedPiSession bridges Pi internal events to Gateway WebSocket events, enabling real-time client subscriptions
- Per-session Promise chain provides lock-free async serialization, complementing Session Lane for double-layered state safety
- Session tree structure supports sub-Agent tracking and branch navigation;
parentIdis 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.