Agent Class and Lifecycle
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
- Holding state:
AgentStatecontainsmessages/tools/model/thinkingLevel/isStreaming/streamingMessage/pendingToolCalls/errorMessage, initialized at construction bycreateMutableAgentState. Seepackages/agent/src/agent.ts:158-188. - Queue management:
steeringQueueandfollowUpQueueeach wrap aPendingMessageQueuewith mode"one-at-a-time"or"all". Seepackages/agent/src/agent.ts:113-144andpackages/agent/src/agent.ts:200-280. - Lifecycle hooks:
beforeToolCall/afterToolCallare stored at construction and packed intocreateLoopConfigfor the underlying loop. Seepackages/agent/src/agent.ts:410-436. - Run wrapping:
runWithLifecyclecreates theAbortController, setsisStreaming, catches exceptions throughhandleRunFailure, and callsfinishRuninfinally. Seepackages/agent/src/agent.ts:438-486. - Three entry points:
promptaccepts new input,continueresumes from the tail,steer/followUpqueue messages. Seepackages/agent/src/agent.ts:312-353andpackages/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
packages/agent/src/agent.ts:113-144—PendingMessageQueue:enqueue/drain/clear;drainreturns all in"all"mode, otherwise just the first.packages/agent/src/agent.ts:158-207—class Agentfields and constructor; defaultstreamFnpoints tostreamSimple,toolExecutiondefaults to"parallel".packages/agent/src/agent.ts:312-323—promptoverloads: supportsAgentMessage,AgentMessage[],string + images; internally routes throughnormalizePromptInput→runPromptMessages.packages/agent/src/agent.ts:355-372—normalizePromptInput: turns a string + images into a userAgentMessagewith atimestamp.packages/agent/src/agent.ts:374-400—runPromptMessages/runContinuationcallrunAgentLoop/runAgentLoopContinuerespectively, both wrapped inrunWithLifecycle.packages/agent/src/agent.ts:410-436—createLoopConfig: buildsAgentLoopConfigfrom instance fields;getSteeringMessages/getFollowUpMessagesclose over the queues'drain.packages/agent/src/agent.ts:438-486—runWithLifecycle+handleRunFailure+finishRun.
The constructor wires up every replaceable part, with streamFn defaulting to streamSimple:
// 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:
// 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:
// 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/continuethrow whenactiveRunalready exists instead of silently queueing. Callers should usesteer/followUpinstead. Seepackages/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". Seepackages/agent/src/agent.ts:336-350. - Failures don't lose messages:
handleRunFailurewraps the error into an assistantAgentMessagewithstopReason: "aborted"|"error"appended to the transcript, so the UI can render it directly. Seepackages/agent/src/agent.ts:463-478. - reset clears cleanly:
resetclears messages, streaming state, pendingToolCalls, errorMessage, and both queues. Seepackages/agent/src/agent.ts:301-310. - Single abort source:
abort()only callsactiveRun.abortController.abort(); the loop and stream listen to the same signal, so no multi-path coordination is needed. Seepackages/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.