Skip to content

Agent Class and Lifecycle

源码版本v0.73.1

Agent is the stateful entry point exposed by @mariozechner/pi-agent-core. Below it sits the bare runAgentLoop loop; above it sit orchestration layers like AgentSession. Agent sits in the middle: it holds the transcript and tool table, manages the activeRun lifecycle, maintains the steering/followUp pending queues, translates AgentOptions into AgentLoopConfig, wraps the actual loop call in runWithLifecycle, and dispatches events to external listeners via subscribe. Think of it as a "state shell on top of the loop" — the loop itself stores no state.

Responsibilities

  1. Holding state: AgentState contains messages/tools/model/thinkingLevel/isStreaming/streamingMessage/pendingToolCalls/errorMessage, initialized at construction by createMutableAgentState. See packages/agent/src/agent.ts:158-188.
  2. Queue management: steeringQueue and followUpQueue each wrap a PendingMessageQueue with mode "one-at-a-time" or "all". See packages/agent/src/agent.ts:113-144 and packages/agent/src/agent.ts:200-280.
  3. Lifecycle hooks: beforeToolCall/afterToolCall are stored at construction and packed into createLoopConfig for the underlying loop. See packages/agent/src/agent.ts:410-436.
  4. Run wrapping: runWithLifecycle creates the AbortController, sets isStreaming, catches exceptions through handleRunFailure, and calls finishRun in finally. See packages/agent/src/agent.ts:438-486.
  5. Three entry points: prompt accepts new input, continue resumes from the tail, steer/followUp queue messages. See packages/agent/src/agent.ts:312-353 and packages/agent/src/agent.ts:252-259.

Design motivation

Why not let upper layers call runAgentLoop directly? Because the loop itself is purely functional — you pass in a context, it runs, returns newMessages, and stores nothing. A real coding assistant needs "cross-turn transcript, messages typed during streaming to be queued, abort with a single source of truth, failures written into state as errorMessage". Pushing all that into runLoop would bloat the loop and make it non-reusable; pushing it into upper layers would duplicate it across print/rpc/TUI modes. Agent is extracted as the single state shell — the loop only runs, state only stores.

Queues are split into two rather than one because steering (an interjection in the current turn, injected before the next assistant reply) and followUp (starting another turn when one should have ended) have different semantics — steering is pulled in the inner while via getSteeringMessages, followUp is pulled at the end of the outer while via getFollowUpMessages. See Double while Loop. The "all"/"one-at-a-time" mode of PendingMessageQueue lets the caller decide whether to drain everything or take one.

Key files

The constructor wires up every replaceable part, with streamFn defaulting to streamSimple:

typescript
// packages/agent/src/agent.ts:190-207
constructor(options: AgentOptions = {}) {
  this._state = createMutableAgentState(options.initialState);
  this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
  this.transformContext = options.transformContext;
  this.streamFn = options.streamFn ?? streamSimple;
  // ... getApiKey / onPayload / onResponse / beforeToolCall / afterToolCall ...
  this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
  this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
  this.transport = options.transport ?? "auto";
  this.toolExecution = options.toolExecution ?? "parallel";
}

createLoopConfig wraps the queues' drain into async functions so the loop can pull messages via these two callbacks:

typescript
// packages/agent/src/agent.ts:427-434
getSteeringMessages: async () => {
  if (skipInitialSteeringPoll) {
    skipInitialSteeringPoll = false;
    return [];
  }
  return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),

runWithLifecycle is the common shell for every run entry point; on error it goes through handleRunFailure to write the error into state, then emits agent_end:

typescript
// packages/agent/src/agent.ts:454-461
try {
  await executor(abortController.signal);
} catch (error) {
  await this.handleRunFailure(error, abortController.signal.aborted);
} finally {
  this.finishRun();
}

Data flow

Lifecycle of prompt(text):

Boundaries and failure

  • Concurrent prompt rejected: prompt/continue throw when activeRun already exists instead of silently queueing. Callers should use steer/followUp instead. See packages/agent/src/agent.ts:316-320.
  • continue role check: if the last message is assistant, steering/followUp queues are consumed first; when both are empty it throws "Cannot continue from message role: assistant". See packages/agent/src/agent.ts:336-350.
  • Failures don't lose messages: handleRunFailure wraps the error into an assistant AgentMessage with stopReason: "aborted"|"error" appended to the transcript, so the UI can render it directly. See packages/agent/src/agent.ts:463-478.
  • reset clears cleanly: reset clears messages, streaming state, pendingToolCalls, errorMessage, and both queues. See packages/agent/src/agent.ts:301-310.
  • Single abort source: abort() only calls activeRun.abortController.abort(); the loop and stream listen to the same signal, so no multi-path coordination is needed. See packages/agent/src/agent.ts:287-290.

Summary

Agent wraps runAgentLoop into a stateful object: it holds the transcript, manages two queues, installs lifecycle hooks, and unifies the abort signal. Above it sits the orchestration layer like AgentSession; below it sits Double while Loop. For how lifecycle hooks are consumed by the tool execution path, see Tool Execution sequential/parallel; for the state fields and AgentMessage/AgentEvent shapes, see Type Contracts.