Learn Agents from Pi
中文
On this page

06. The Agent Kernel and Its Lifecycle

By now you have a message protocol, providers, tools, and streaming events. The next step is to organize them into an agent kernel. The kernel is not a product interface, nor a wrapper around some provider. Its job is to maintain running state, advance turns, execute tools, emit events, and expose customization points to the layers above.

This chapter looks from inside the Agent Core and asks how one run advances correctly. It does not yet own resource discovery, cross-turn configuration snapshots, session-write ordering inside event callbacks, or the rule that idle begins only after every listener settles. Those belong to the harness above the Core, and Chapter 15 connects the two layers explicitly.

Four responsibility boundaries

A clean agent kernel usually splits into four parts:

  • State: current messages, tools, model, configuration, queues, and running status.
  • Runner: the loop that advances turns — the loop from Chapter 02.
  • Tool executor: validates, schedules, and executes tools, producing tool results.
  • Event bus: broadcasts lifecycle events to the UI, logs, extensions, and tests.

Do not let the UI modify messages directly, and do not let tools call the provider directly. Each layer interacts through explicit contracts. That is what lets you add a JSON mode, an SDK, an extension system, or a test harness in the future without rewriting the core loop.

A minimal set for the kernel state can follow that of a mature system:

  • messages: the array of session messages.
  • tools and systemPrompt: the capabilities and rules currently exposed to the model.
  • model and the reasoning-effort setting.
  • isStreaming: whether there is a model request in progress.
  • streamingMessage: the partial assistant message during streaming.
  • pendingToolCalls: the set of tool call ids currently executing.
  • errorMessage: the reason for the most recent failure.
  • The steering and follow-up queues (covered in the next chapter).

Session names, theme colors, recent command history, and the like belong to the product layer. Putting them in the kernel lets the SDK, CLI, and TUI contaminate one another.

The public API and the run lifecycle

The kernel's outward method surface should be small:

type Agent = {
  prompt(input: string | UserMessage): Promise<void>;
  steer(input: UserMessage): void;
  followUp(input: UserMessage): void;
  abort(): void;
  waitForIdle(): Promise<void>;
  subscribe(listener: (event: AgentEvent) => void | Promise<void>): () => void;
  readonly state: AgentStateSnapshot;
};

Each prompt starts a run. The kernel creates an AbortController and a completion promise for it; abort() triggers the former, and waitForIdle() awaits the latter. There are three key lifecycle rules:

  1. Any exception within a run (including one thrown by transformContext, the provider, or a subscriber) must not escape unhandled; instead it is synthesized into an assistant message with stopReason: "error" that travels the normal event and log paths.
  2. On run completion, cleanup happens uniformly: isStreaming resets, pendingToolCalls clears, and the promise resolves. This cleanup must live in a finally, or a single exception will leave the agent looking "in progress" forever.
  3. If a subscriber returns a promise, it is awaited and counted into the run's completion timing. This way "wait for the agent to be idle" naturally includes "the log has been written and the UI has finished rendering," and tests do not need to sleep.

The third rule is an easily overlooked design decision. If events were fire-and-forget, a log subscriber might not have written the last message by the time the process exits; folding subscribers into the run's settlement upgrades "the event was emitted" to "the event was handled." The price is that a slow subscriber slows down the whole run, so subscribers must either be fast or queue their own work.

The kernel state is also worth giving an explicit phase field, turning "which stage is the agent in right now" into a fact you can query and assert on, rather than something scattered across a few booleans. A minimal phase machine usually transitions between these states:

idle -> turn -> idle
turn -> compaction -> turn
turn -> retry -> turn

idle means no run is in progress and a new prompt can be accepted; turn means a model request plus the tools it triggered is running; when the context approaches the window limit it enters compaction, then returns to turn to continue; when the provider reports a retryable error it enters retry, backs off, resends the same request, and returns to turn on success. Making the phases explicit has three payoffs: the UI can show "compacting" accurately instead of a vague "running"; steering and follow-ups know which boundary to inject at; and tests can assert directly on which phases a task passed through. The run's AbortController and its set of "pending file writes" both hang off this phase machine — aborting is just triggering the abort in whatever phase you are in, then running the uniform cleanup back to idle.

Lifecycle events and ordering guarantees

The kernel should emit stable lifecycle events with explicit ordering guarantees:

agent_start
turn_start
message_start (assistant)
message_update ...
message_end (assistant)
tool_execution_start ...
tool_execution_end ...
message_start / message_end (each tool result)
turn_end
agent_end

The ordering guarantees include, at minimum: each message's events are strictly ordered start, update, end; before turn_end, all of this turn's tool execution events and tool result message events have already been emitted; and agent_end is the run's final event. These guarantees are not for looks — they support several kinds of capability:

  • The UI shows the current status without guessing.
  • The session log records the order of events accurately.
  • Extensions run permission checks before a tool call.
  • Tests assert the execution trace of a task.
  • Metrics systems compute latency and token cost.

If you do not have a unified event stream, each of these capabilities will invent its own notion of state, and the system quickly loses consistency.

A hook surface, not an inheritance tree

An agent is easily pushed toward complex inheritance by requirements: a security agent, a test agent, an agent with compaction, an agent with extensions. A more robust approach is to expose hooks. The kernel hook surface of a mature system is roughly:

type AgentHooks = {
  transformContext?: (messages: AgentMessage[], signal: AbortSignal) => Promise<AgentMessage[]>;
  convertToLlm?: (messages: AgentMessage[]) => Message[];
  beforeToolCall?: (call: ToolCallBlock) => Promise<{ block?: string }>;
  afterToolResult?: (result: ToolResultMessage) => Promise<void>;
  shouldStopAfterTurn?: (state: AgentStateSnapshot) => Promise<boolean>;
};

The first two hooks deserve extra explanation. transformContext projects the message array before each model request: compaction, trimming, and injecting external context all happen here. convertToLlm converts the message types the kernel stores into protocol-layer messages — its existence lets the layer above define custom message types (such as an application event like "the user ran a local command"), store them in the session, and then filter or rewrite them before sending to the model. These two hooks share one contract: they must not throw, and on failure they return their original input. A context-projection failure should not destroy the whole run.

When beforeToolCall throws or returns a block, the kernel turns it into an error tool result rather than terminating the task — a permission denial is, to the model, just another observable result. The timing and error semantics of every hook must be defined clearly at the kernel layer, or extension authors are left to work it out by trial and error.

Parallel tools and sequential facts

Many models request multiple tools within a single assistant turn. The scheduling strategy of a mature system is worth borrowing: the preparation phase (validation, permission checks) is always serial, following the order of the tool calls in the message; the execution phase runs in parallel (unless a tool declares it must be serial); tool_execution_end events are emitted in completion order so the UI can update in real time; but tool result messages are appended to history in the tool calls' original order. This way the UI sees the true completion timing, while the model and the log see a stable, reproducible order.

There is another concurrency detail: after a tool finishes executing, late progress callbacks may still arrive (timers, residual stdout). The kernel must flip a "no more updates" switch at the end and discard late updates, or the UI will flash a progress line after the tool has already been shown as complete.

The real problem parallel execution brings is file-write conflicts. Two tools read the old file at the same time, each computes its own modification, and the last one to write overwrites the previous one. That is not a model problem — it is the tool runtime lacking a write queue per file. The later chapter on coding tools deals with this specifically.

What you should observe

From the start of a user request to idle, the events might be:

agent_start
turn_start
message_start(assistant) ... message_end(assistant) stopReason=toolUse
tool_execution_start read
tool_execution_end read
message_start(toolResult) message_end(toolResult)
turn_end
turn_start
message_start(assistant) ... message_end(assistant) stopReason=stop
turn_end
agent_end

This single sequence can simultaneously drive a terminal spinner, log writes, extension permission gates, and test assertions. A stable event sequence is the foundation for turning an agent into a product.

Exercise

Wrap the current loop into an Agent class or factory function.

Acceptance criteria:

  • The outside can only drive the kernel through prompt, abort, subscribe, waitForIdle, and configuration methods.
  • Event subscribers cannot modify the internal messages directly; a promise returned by a subscriber counts toward run completion.
  • Any internal exception in the kernel is synthesized into an assistant message with stopReason: "error", and waitForIdle returns normally.
  • A faux provider test can assert the complete lifecycle event sequence, including a stable order for tool results when tools run in parallel.
  • Unknown tools, tool errors, and user aborts all emit explicit events.