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:

  1. Emit turn_start.
  2. 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.
  3. Stream assistant response — calls streamAssistantResponse(), which:
    • Optionally applies config.transformContext(messages) (AgentMessage[] → AgentMessage[]) before conversion.
    • Calls config.convertToLlm(messages) to get Message[] for the LLM.
    • Resolves API key per-turn via config.getApiKey(provider) (supports expiring tokens).
    • Streams via streamFn (defaults to streamSimple).
    • Mutates context.messages[last] in-place during streaming; replaces with final on done/error.
  4. On stopReason === "error" | "aborted": emit turn_end + agent_end, return early.
  5. Execute tool calls if any (see below).
  6. Emit turn_end.
  7. 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