Skip to content

Part II · Pi in Source / Ch. 12

Part II Chapter 12: The Relay Race — End-to-End ​

Xia A and Lao Z pass a glowing baton and tool result between stations, showing an Agent request moving through a relay of handoffs.

Chapter guide: The end of the source route. This chapter reads no new files. It only threads together models.ts → lazy.ts → agent-loop.ts → agent-session.ts → main.ts, the files the previous ten chapters walked. pi-mono has no "master controller": one complete request is a control flow stitched across packages, and each station only hands off — it never does the whole job for the next one.

Part II · Pi in Source was over. Xiao A closed his laptop and let out a long breath: "So pi-mono must have a 'master controller', right? I type one line at the command line, and it runs from the first line all the way through and prints the result."

"There is no such function." Lao Z laughed. "And that is not a defect. What Part II · Pi in Source gave you was a map: connect the real call sites across the packages and you have the walkthrough route. The route below is stitched together from upstream pi@v1.0.0 (commit a13d35a7). This chapter reads no new files; it only threads the handoff points from Chapters 19 to 28. How the Agent actually runs behind a remote host was never read together, so it is not filled in here either."

The normal path ​

text
CLI main / mode selection
 → configuration, resources, and model runtime
 → AgentSession
 → Agent and runAgentLoop
 → Models.streamSimple → Provider.streamSimple
 → model events → assistant message / tool call
 → tool results → follow-up model messages
 → session entries and TUI updates

"packages/coding-agent/src/main.ts and cli.ts are the command-line startup path; core/resource-loader.ts, core/model-runtime.ts, and core/agent-session.ts handle resource, model, and session assembly. packages/agent/src/agent.ts and agent-loop.ts carry the state and the loop; at v1.0.0 pi-agent-core no longer has a harness, and the session and compaction live in core/session-manager.ts and core/compaction/. streamSimple in packages/ai/src/models.ts resolves authentication and delegates the stream to the Provider. Interactive mode is in modes/interactive/interactive-mode.ts, and the TUI infrastructure is in packages/tui/src."

Metaphor: the relay race

Each package only runs its own leg of the baton (context, model, events, entries).

"Every segment of this chain has its own entry point, so this is control flow stitched across modules. Do not read the arrows above as these files calling one another in the listed order."

Xiao A was not entirely reassured: "Without a master controller, how does each segment know what the previous one handed it?"

"By the type contracts." Lao Z went through the handoff points one by one. "The CLI layer parses args into a configuration object and passes it to resource-loader.ts; resource-loader aggregates it into Resources and passes it to agent-session.ts; the session assembles an AgentContext (messages, where the system message carries the system prompt and the tool declarations, plus executable tools), a Model, and an AbortSignal, and hands them to Agent; once the loop has those, it calls streamFunction, which is Models.streamSimple; Models resolves authentication and then delegates to Provider.streamSimple. Each segment consumes only the arguments explicitly passed down from upstream and reads no global singleton. That is why replacing the Provider or swapping the TUI never requires rewriting the loop. The price is that troubleshooting means correlating the segments one handoff at a time."

"Draw out the full shape of one local interaction and it looks like this," Lao Z said:

text
main() ──args──> createAgentSessionFromServices ──> AgentSession (holds the Agent)
   │                       │
   └──interactive mode─────┘
InteractiveMode.init ──> AgentSession.subscribe(handleEvent)
User input ──> Agent.runPromptMessages ──> runAgentLoop(prompts, context, config, emit, signal, streamFn)
                                           ├─ streamAssistantResponse ──> Models.streamSimple
                                           │        │  lazyStream returns synchronously, auth resolves in the background
                                           │        v
                                           │  Provider.streamSimple ──> the real HTTP / SDK stream
                                           │        v
                                           │  assistant message (stop / toolUse / length / error / aborted)
                                           v
                                 tool call ──> executeToolCalls / failToolCallsFromTruncatedMessage
                                           v
                                 tool result pushed back into context as a message
                                           v
                                 next stream → turn_end → agent_end
                                           v
                                 AgentSession persists + InteractiveMode refreshes the TUI

"Note that nothing in this diagram strings 'CLI → model → tool → session → TUI' into a function call stack," Lao Z said. "Every edge is an independent entry point: main() assembles, runAgentLoop loops, Models.streamSimple delegates authentication, AgentSession persists, InteractiveMode subscribes and renders. You read this diagram as 'who hands what to whom, at what moment'."

"Now look at the data shapes at the handoffs," Lao Z added. "What reaches the Agent is not a bare string prompt but an AgentContext (a messages array plus executable tools; the system prompt and the tool declarations live inside the system message). What the model layer receives is not a bare HTTP request either, but a Model reference plus SimpleStreamOptions. The failure semantics belong to the segments as well: a CLI failure is a configuration error, a session initialization failure never reaches the loop, a loop failure is an abnormal stopReason, and a Models failure is authentication or an unknown provider. The four kinds never blur into one."

"Sort the failures by layer. The dividing line that matters is whether it gets into the loop," Lao Z said, placing all six failure classes:

Failure layerTypical errorWhere it surfacesDoes it enter the loop
CLI configurationargument parsing, resource reading, cwd does not existbefore main() exitsno
session assemblymodel not selected, service initialization failurecreateAgentSessionFromServicesno
authentication / providerapplyAuth throws ModelsErrorthe error event of the lazy streamyes, as an error stopReason
model streamHTTP interruption, rate limiting, sampling anomalyprovider stream eventsyes, stopReason error / aborted
tool executionthe tool throwscaught by executeToolCallsyes, an isError tool result is returned
session persistenceJSONL write failureSessionManager._persist throws; the error propagates through AgentSession event handlingcan occur inside the loop and reach Agent failure handling; the UI may already have received the message event

How tool results get back into the loop ​

Xiao A pointed at the chain. "After a tool call goes out, what does the loop go on doing?"

"A tool call in the assistant stream is handed by the Agent loop to an assembled tool, and the tool result then enters the later model context as a message," Lao Z said. "runAgentLoop manages the stop reason, the abort, and the next stream; AgentSession hands state and entries to storage. Tool output can fail, be truncated, or be rejected, and the normal path must pass the result back explicitly."

Xiao A wanted finer detail: "At exactly which step of the loop does a tool call become a tool result?"

"Look at the inner loop of runAgentLoop." Lao Z went back to agent-loop.ts. "After the stream finishes one assistant message, it checks stopReason first: error or aborted emits agent_end and exits straight away, executing no tools. In the normal case it pulls the tool calls out of the message with filter(c => c.type === "toolCall"), and then comes the key branch: when stopReason === "length" it goes to failToolCallsFromTruncatedMessage, which marks every tool call as failed instead of executing it. A length truncation means a tool call's arguments may be incomplete, and executing them would be a semantic error. Only a stopReason other than length reaches executeToolCalls for real execution."

"The executed tool results get pushed into currentContext.messages and newMessages," Lao Z continued. "Then it checks hasMoreToolCalls = !executedToolBatch.terminate: if the tool batch asks to terminate — say a tool returned a fatal error — the inner loop stops. At v1.0.0 a host can also end the turn early by returning { action: "end" } from finishTurn. Otherwise the loop starts the next stream, and by then the context already carries the tool result messages, so the model sees them and decides the next step. A tool result is an ordinary message: the model treats tool output as part of the conversation history."

Xiao A started counting: "From being taken out of a message to becoming a result, how many steps is one tool call?"

"Four steps, and every one of them can short-circuit into an error result." Lao Z spread out the executeToolCalls pipeline. "Step one, prepareToolCall: find the tool by name; if it is not there, produce Tool not found outright. If it is, run prepareArguments to preprocess, validateToolArguments to validate the arguments, then the beforeToolCall hook — if the hook says block, the output is a blocked error result. Step two, tool.execute: a thrown error is caught into an error result. Step three, finalizeExecutedToolCall runs the afterToolCall hook, and an error thrown by the hook also degrades the result to an error. Step four, createToolResultMessage turns the outcome into a toolResult message. On top of that, in sequential execution the loop checks signal.aborted after each finished tool: an abort short-circuits the remaining tools, and the ones already executed are kept as they are."

Xiao A jumped in with a guess: "I'd say this one is some — if any single tool returns terminate, the whole batch stops?"

"Look at shouldTerminateToolBatch." Lao Z pointed back at agent-loop.ts. "The condition is finalizedCalls.length > 0 && finalizedCalls.every(f => f.result.terminate === true): the batch terminates only when every result in it is marked terminate. That is every, not some. The terminate field comes back from the tool, and afterToolCall may rewrite it. The loop does not guess which error deserves to terminate; that is a product-level decision the tools state explicitly. The length branch is the exact opposite: over there the loop decides on the model's behalf that the arguments are incomplete."

Interruption is interruption, recovery is recovery ​

Xiao A thought of another spot: "So after I press Ctrl-C and bring the process back up, everything restores itself automatically, right?"

"Nothing automatic about it," Lao Z said. "The AbortSignal runs through the model stream and the Agent path, and model or tool errors also produce a stopped or error state. The session JSONL and compaction provide persistence and context management. When the process comes back up, the outside world does not thereby return to a correct state: file writes, network requests, or remote commands that already ran still need idempotence, auditing, and a product-level recovery strategy to cover them."

"Remote mode also adds the protocol frame, the handshake, and service subscriptions: a subscription snapshot is the full state, and service_update is a delta on top of that snapshot," Lao Z added. "None of that changes the transactional semantics of local tool side effects."

Seam-by-seam handoffs, using local interactive mode as the example ​

Xiao A wanted to land this in code: "Can we take one real local run and match each handoff point against the code?"

"Once the CLI has parsed the arguments and configuration, resource-loader.ts gathers the project resources, model-runtime.ts exposes the model catalog, and agent-session.ts assembles the model, the system prompt, the tools, the session services, and the runtime into a session."

Handoff pointUpstream dataDownstream consumerFailure / abort semantics
CLI → configurationargs, cwd, config pathsresource / model runtimeargument or resource reading fails; no session can be built
session → Agentprompt, tools, system messageAgent (through the socket on AgentSession)initialization failure never enters the loop
loop → ModelsContext, Model, AbortSignalModels.streamSimpleunauthenticated, unknown Provider, deliberate abort
Provider → loopAssistantMessage event streamrunAgentLooperror / aborted / length stop reasons
tool → looptool result messagethe next Contexta tool error is still a model-visible result
Agent → UI / storeevents and session entriesinteractive mode / sessionthe UI observing is not the same as persisting successfully

"The most typical segment in the middle is ModelsImpl.streamSimple." Lao Z opened packages/ai/src/models.ts:

ts
// packages/ai/src/models.ts, ModelsImpl.streamSimple (v1.0.0, excerpt)
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
	const provider = this.requireChatProvider(model);
	const { requestModel, requestOptions } = await this.applyAuth(model, options);
	return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);
});

"The cross-package data here is the Model, the normalized transcript, and the event stream. The authentication error happens in applyAuth, and the TUI takes no part in judging it. Only after a Provider is selected do you get concrete HTTP / SDK behavior."

Xiao A stared at that wrapper for a while: "What is special about lazyStream? Could we skip the wrapper?"

"Without it, streamSimple would have to wait for authentication to resolve before it could return," Lao Z said, pulling up packages/ai/src/api/lazy.ts. "lazyStream(model, setup) returns an empty AssistantMessageEventStream synchronously. The setup (requireChatProvider + applyAuth + provider.streamSimple) runs in the background: on success forwardStream forwards the events; on failure the error is wrapped into an assistant message with stopReason: "error", pushed to the outer stream, and then end(). So authentication and provider selection are both part of streaming: the loop starts consuming as soon as it holds the stream, and an authentication failure arrives as an error event inside the stream instead of throwing at the call site. That is where the row 'authentication failure enters the loop' in the error classification table comes from. applyAuth has one more detail: transformHeaders runs after authentication resolves, so a header transformation can see the auth headers that will actually be used."

Tools, stopping, and the session record ​

"runAgentLoop receives messages, tools, a stream function, and an abort signal. It collects the assistant message and puts tool results back into the pending messages." Lao Z drew its boundary first. "Parsing CLI arguments is not its job, and neither is terminal rendering. When you debug, you look at the loop's stop reason and the tool implementation on the coding-agent side together."

"Then what does a clean ending look like?" Xiao A asked.

"On the normal path, when the model stream ends because of tool use, the loop executes those tools and turns the results into follow-up context; when it ends with stop, the turn closes. The AbortSignal stops the stream and the session, but it does not undo external side effects that already happened. A tool error is not a transport error: it still has to be kept for the model so it can adjust its next step."

Xiao A tried to merge the two kinds of stop into one category: "abort and a tool error are both 'something went wrong and it stopped', aren't they?"

"They are different, and the paths differ." Lao Z went over runAgentLoop. "The signal is passed into streamAssistantResponse, the model stream checks signal.aborted internally, and an abort makes the stream produce an assistant message with stopReason: "aborted". When the loop sees error or aborted it emits turn_end + agent_end and exits, executing no tool call at all. A tool error takes another route: while executeToolCalls runs a tool, a thrown error is caught and wrapped into a tool result message with isError: true, pushed back into the context, and the loop continues to the next turn so the model sees the error and adjusts. So abort terminates the whole agent, while a tool error is just one recoverable failure. The two stop semantics cannot be mixed: treat a tool error as an abort and the model loses its chance to self-correct; treat an abort as a tool error and keep going and you are running on a session that was already cancelled."

"Distinguish forwarding from checking," Lao Z added. "streamAssistantResponse passes the signal to transformContext and streamFunction; it does not check signal.aborted itself. Cancelling the model stream depends on the downstream implementation responding to the signal. Tool preparation blocks new executions, while tool execution short-circuits the remaining tools and keeps the completed ones."

"Put the stop shapes side by side." Lao Z listed them on the spot:

Stop shapeWho triggers itloop behaviorRecoverable
errormodel stream / authentication erroremits turn_end + agent_endno
abortedAbortSignalemits turn_end + agent_endno
lengthtruncated at the token limitall tool calls marked as errors, then continuesyes
toolUsethe model issues a tool callexecutes the tools, returns the results, continuesyes
tool isErrorthe tool throws / is blockedpushes back into context, continues on the next turnyes

"One more thing: when message_end arrives, AgentSession calls SessionManager.appendMessage to save the session entry, over a different channel from the interactive-mode refresh," Lao Z said. "Seeing output on screen proves nothing about the entry being durable. The reverse holds just as much."

Where the remote path forks ​

Xiao A remembered the other half he had not asked about: "When we switch to remote use, is it still this chain?"

"Local interactive mode consumes Agent events directly at the session / TUI boundary. The protocol-based remote path adds pi-protocol, pi-server, and pi-client: an object goes through schema, CBOR, and frame first; the client sends hello first, then attaches a session and subscribes to services; the server answers with subscription snapshots and service_update," Lao Z said. "The confirmed server boundary is this: the server hands a service call to the session handle returned by the host's openSession. As for what the host uses behind that to run the Agent — at v1.0.0 it is an experimental implementation moved into pi-durable. This book never read it together, so it needs separate verification."

Xiao A went on to confirm: "In remote mode, are the local loop and the remote runtime the same thing?"

"Not necessarily." Lao Z pulled up packages/server/src/types.ts. "RoutedSessionHandle and RoutedSessionAttachment only specify attaching, calling a service, releasing, and closing. They do not even fix the session operations, let alone say whether what runs inside is runAgentLoop or some other loop. In local interactive mode the TUI subscribes directly to the Agent's event sink, and events are object references in memory. In remote mode a state change has to be encoded first into a service-subscription snapshot and a service_update envelope, cross the protocol layer, and be decoded at the client. The same semantic event, two transport shapes. That is why a local fault is investigated at the event sink and a remote fault at the protocol boundary."

"That also explains why state cannot be shared across packages," Lao Z added. "The local loop holds an object reference to currentContext. In remote mode the server's loop holds the context and the client only gets a serialized snapshot copy. Editing the client's local copy cannot touch the server's real state; only issuing another service call can."

Xiao A took an engineering view: "Those two paths, local and remote — where does the maintenance cost differ?"

"At every boundary between 'object reference' and 'serialized copy'." Lao Z gathered the trade-offs into one table:

Decision pointLocal interactiveRemote modeReuse / cost
event deliveryemit object references in memorysubscription snapshot + service_update encode/decodesemantics close, transport shapes differ
state authoritythe loop holds currentContextthe server holds it, the client holds a copythe copy is rebuilt from subscription snapshots
authenticationModels resolves model credentials directlyconnection authentication belongs to the transport layer listener; the protocol carries no tokenauthentication sits in a different place
tool executioninside the local processdepends on the backend runtimehas to be verified per backend
failure boundaryevent sink / stopReasonframe / handshake / attachment / subscriptionthe debugging layers differ

"In local mode the session runs directly on AgentSession. In remote mode the server forwards service calls to the host's session handle," Lao Z said. "What gets reused is the abstraction and the protocol; the memory layout is each side's own."

"A bad frame terminates the connection at the decoder layer, and a version error is answered at the handshake layer. After a disconnect, an unfinished request may be in an unknown commit state," Lao Z went on. "A subscription snapshot can rebuild the displayed state, but it cannot roll back a write that already happened. Re-attaching is a new protocol operation; the old socket does not continue by itself."

"The engineering trade-off is that layering lets local and remote reuse the runtime, and the price is that a fault has to be pinned down to the configuration, authentication, stream, loop, tool, session, UI, or protocol boundary," Lao Z said. "So logs should carry the session ID, the request ID, the tool call ID, and the Provider identifier."

Summary ​

Walking one complete request confirms that pi-mono has no "master controller" that runs from beginning to end. A request is a control flow stitched across packages: the CLI parses arguments and assembles resources, AgentSession organizes session state and lifecycle, the loop drives messages back and forth between model and tools, Models delegates the real stream to a Provider, tool results return as messages into the next context, and the TUI subscribes to events and refreshes the screen. Every handoff has clear upstream and downstream data and failure semantics: what is passed to the Agent is an AgentContext, a Model, tools, and an AbortSignal; authentication errors happen in applyAuth; a tool error is not a transport error and must be returned to the model explicitly as a result. Interruption and recovery are also two different things: the AbortSignal stops the stream and the session but does not undo external side effects that already happened, and JSONL plus compaction provide persistence without guaranteeing that the outside world returns to a correct state after a restart.

How to read this map: remember that every segment "only hands off, never does the job". The loop does not care about CLI argument parsing or terminal rendering; Models does not care about tool implementations; the Agent does not care about the Provider's HTTP details. Everyone honors the same contract: the Agent gives the loop an AgentContext, tools, and a signal; the loop points streamFunction at Models; Models gives the Provider a Model and the normalized transcript. A tool call has four independent paths inside the loop: on a length truncation everything is marked failed, a missing tool errors immediately, a thrown execution error is wrapped as isError, and terminate only stops the batch when every call in it is marked. All of these are product policy stated explicitly in the source; the loop itself does not guess.

The remote path forks at the protocol layer: an object goes through schema, CBOR, and frame first, the client sends hello first and then attaches and subscribes, and the server answers with subscription snapshots and service_update; a bad frame terminates the connection, a version error is returned at the handshake layer, and after a disconnect the commit state of an unfinished request is unknown. Local and remote reuse the same Agent abstraction but differ in event shape: local is an object reference in memory, remote is a serialized envelope plus snapshot reconstruction. So you investigate a local fault at the event sink and a remote fault at the protocol boundary. Part II · Pi in Source ends here, leaving a map: next time you hit a fault or want to extend something, you know which boundary to look at and which IDs the logs have to carry for you to locate it. Part III · Build It turns the other way: instead of reading someone else's system, you start from an empty directory and write a deliberately bounded agent yourself, to see which of these boundaries even the smallest artifact cannot avoid.

Source Walkthrough ​

  1. Follow packages/coding-agent/src/main.ts down to session creation, recording each configuration or resource object passed in.
  2. Mark the path in agent-loop.ts where an assistant tool call becomes a tool result, then follow it back into the next model stream.
  3. Trace models.ts down to the Provider's streamSimple, and for one abort mark every point where the AbortSignal is passed.
  4. Observe how interactive mode subscribes to session events; if you use remote mode, compare the arrival order of subscription snapshots and service_update.
  5. For one tool failure, collect the loop stop reason, the tool result, the session entry, and the UI event, and verify that the four have not blurred into a single kind of error.