Agent Loop (pi-mono)
Source: Agent Loop (pi-mono)
Original file: packages/agent/src/agent-loop.ts at commit 53264524 in badlogic/pi-mono
Topic: Core agent loop implementation in pi-mono — the library underlying OpenClaw.
Overview
agent-loop.ts is the execution engine of pi-mono's @mariozechner/pi-agent package. It orchestrates LLM streaming, tool calling, and event emission. OpenClaw wraps this via runEmbeddedPiAgent / subscribeEmbeddedPiSession.
Key design principle: AgentMessage[] is used throughout internally; conversion to Message[] (LLM wire format) happens only at the LLM call boundary.
Public API
agentLoop(prompts, context, config, signal?, streamFn?)
Starts a new agent run by prepending prompts to the context, then running the loop. Returns an EventStream<AgentEvent, AgentMessage[]>.
agentLoopContinue(context, config, signal?, streamFn?)
Continues from existing context without adding new messages — used for retries. The last message in context must be non-assistant (user or toolResult); otherwise throws.
Both are thin wrappers: they create an EventStream and fire-and-forget the async runAgentLoop / runAgentLoopContinue, which push events onto the stream and call stream.end(messages) on completion.
Loop structure (runLoop)
Outer loop
Runs until no follow-up messages are returned by config.getFollowUpMessages(). This enables a "continue after natural stop" pattern: a user message queued while the agent was working can extend the run.
Inner loop
Runs while hasMoreToolCalls || pendingMessages.length > 0:
- Emit
turn_start. - Inject steering messages (from
config.getSteeringMessages()) into context + events before the next assistant response. Steering messages are checked at loop start and after each turn. - Stream assistant response — calls
streamAssistantResponse(), which:- Optionally applies
config.transformContext(messages)(AgentMessage[] → AgentMessage[]) before conversion. - Calls
config.convertToLlm(messages)to getMessage[]for the LLM. - Resolves API key per-turn via
config.getApiKey(provider)(supports expiring tokens). - Streams via
streamFn(defaults tostreamSimple). - Mutates
context.messages[last]in-place during streaming; replaces with final ondone/error.
- Optionally applies
- On
stopReason === "error" | "aborted": emitturn_end+agent_end, return early. - Execute tool calls if any (see below).
- Emit
turn_end. - Check steering messages again for next iteration.
Tool execution
Configurable via config.toolExecution: "sequential" | "parallel" (default implied parallel).
Pipeline per tool call
prepareArguments (tool.prepareArguments)
→ validateToolArguments (schema validation)
→ beforeToolCall hook (can block with reason)
→ tool.execute (with partialResult callback)
→ afterToolCall hook (can rewrite content/details/isError)
→ emitToolCallOutcome → ToolResultMessage
Parallel execution: All tool calls are prepared first; immediate outcomes (blocked / tool-not-found / validation error) are collected immediately; remaining calls are fired concurrently, then awaited in order.
Sequential execution: Each call is fully resolved (prepare → execute → finalize) before the next starts.
Events emitted
| Event | When |
|---|---|
agent_start |
Once per agentLoop / agentLoopContinue call |
agent_end |
On normal exit or early termination; carries messages: AgentMessage[] |
turn_start |
Before each inner-loop iteration |
turn_end |
After assistant response + tool results; carries message + toolResults[] |
message_start |
When a message is first seen (prompt, assistant partial, toolResult) |
message_update |
On each assistant streaming delta (text/thinking/toolcall events) |
message_end |
When a message is finalized |
tool_execution_start |
Before prepareToolCall |
tool_execution_update |
Partial result from tool.execute callback |
tool_execution_end |
After tool result finalized; carries result, isError |
Config hooks
| Hook | Signature | Effect |
|---|---|---|
transformContext |
(msgs, signal) → AgentMessage[] |
Pre-LLM context rewrite (e.g. compaction) |
convertToLlm |
(msgs) → Message[] |
Required: serialize to LLM wire format |
getApiKey |
(provider) → string |
Per-turn key resolution (expiring tokens) |
getSteeringMessages |
() → AgentMessage[] |
Inject user messages mid-run |
getFollowUpMessages |
() → AgentMessage[] |
Inject messages after natural stop |
beforeToolCall |
({assistantMessage, toolCall, args, context}, signal) → {block?, reason?} |
Block tool with reason |
afterToolCall |
({…, result, isError}, signal) → {content?, details?, isError?} |
Rewrite result |
Key types
AgentMessage: Internal union (user / assistant / toolResult) with timestamp and agent-specific fields.AgentTool<T>:{ name, prepareArguments?, execute(id, args, signal, onPartial) → AgentToolResult }.AgentToolResult<T>:{ content: ContentBlock[], details: T }.EventStream<E, R>: Async-iterable with a termination predicate and a result extractor.
Related wiki pages
- pi-mono Agent Loop — concept summary
- pi-mono — entity
- Agent Loop (OpenClaw) — OpenClaw wrapper around this loop
- OpenClaw Hooks — hook names as seen from OpenClaw layer
- Streaming — full event type reference