What Happens After You Press Enter

In Pi Agent Harness, the important question after a prompt is not whether a model can produce text. It is how that text becomes a controlled sequence of decisions and effects. This chapter follows one request through the agent core so you can see where context is transformed, where a provider stream ends and a tool begins, and how Pi decides whether to continue, stop, or report failure.

The distinction is practical. If a model gives a plausible explanation, the system has produced an answer. If it asks to edit a file, the system has proposed an external effect. Those two outcomes must travel through the same loop, but they cannot be treated as the same kind of event. Pi's agent core makes the difference explicit in its message model and lifecycle.

The transcript is richer than the model input

The agent works with AgentMessage, an extensible union that can contain ordinary user, assistant, and tool-result messages as well as application-specific messages. A host may use those extra messages for status, a custom workflow, or information that is useful to the interface but should not be sent to a model. The provider APIs, by contrast, understand a narrower set of roles: user, assistant, and tool result.

Pi does not flatten the transcript as soon as a message is created. Before each model request, it can first run transformContext over the richer AgentMessage list. That is the stage for operations such as pruning old material or injecting external context while the application still has access to its own message types. It then runs convertToLlm, which filters or transforms the result into the provider-facing message format. The boundary is therefore a deliberate projection:

AgentMessage transcript → context transformation → provider-compatible messages → model request

This two-step design matters for extensibility. A UI-only notification can remain in the agent's state and event stream without confusing the provider. A custom message can be converted into a user message if it carries information the model needs. A context policy can remove old messages without changing the provider adapter. The model sees the projection, while the host keeps a richer record of what the application knows.

That projection happens at the call boundary, not once for the lifetime of a session. A later turn can use a different context transform, active tool set, model, or provider. The agent core remains responsible for the shape and order of the loop; the application decides how its richer state becomes a request.

A turn is a loop of guarded effects

Suppose the user asks Pi to find a failing test and edit the relevant file. The agent begins by emitting lifecycle events for the run and its first turn, then records the user message. The provider receives the projected context and starts streaming an assistant response. During that stream, Pi can surface text and thinking deltas to the interface. If the response contains only text, the turn can finish there. If it contains a tool call, the response becomes an instruction to continue through the effect path.

The tool call is not executed merely because the model named a tool. Pi locates the registered tool, prepares and validates its arguments against the tool schema, and invokes the beforeToolCall gate. Only after those checks pass does the tool receive the call. The tool can emit progress updates and return content plus implementation-specific details. Pi wraps the result as a toolResult message, emits the result lifecycle, and places that message into the current context. The next provider request can then explain the edit, inspect another file, or ask for another effect.

sequenceDiagram participant U as User participant A as Agent core participant T as AgentMessage transcript participant P as Provider stream participant G as Validation and beforeToolCall gate participant X as Tool execution U->>A: Submit coding request A->>T: Add user message A->>A: transformContext A->>A: convertToLlm A->>P: Send provider-compatible context P-->>A: Stream assistant text and tool call A->>G: Prepare and validate arguments alt Allowed tool call G->>X: Execute tool X-->>A: Progress and final result A->>T: Add toolResult message else Invalid or blocked call G-->>A: Error toolResult A->>T: Add toolResult message end A-->>U: Emit tool and turn events A->>A: Check steering and follow-up queues A->>P: Continue with result when needed P-->>A: Stream final response A->>T: Persist finalized messages A-->>U: Emit turn_end and agent_end

The visible sequence hides several safeguards. The low-level loop emits agent_start, turn_start, and message events before and during the provider request. It emits tool-execution events around the effect, then turn_end after the assistant message and all tool results for that turn have been assembled. A stateful Agent stores the transcript, tracks which tool calls are pending, exposes an abort signal, and waits for event listeners before declaring itself idle. A caller can therefore build a responsive interface without taking over the control loop.

The timing of those events is part of the programming model. At the beginning of a run, the agent has a system prompt, a current model, a tool registry, and a message list. The user prompt is added to a working copy of that list. The provider-facing projection is produced only when the next assistant response is about to start. Once the provider begins streaming, the agent can expose a partial assistant message to the interface while keeping the same response connected to its eventual final message. A completed message replaces the partial one; it is not a second, unrelated answer.

That distinction is useful when a terminal UI wants to show text as it arrives, when an RPC client wants to assemble deltas, or when a host wants to persist only authoritative results. It also keeps the loop honest about failure. If the provider fails, the run ends with an assistant error or aborted result rather than pretending that a normal answer was produced. If a tool throws after the provider has successfully asked for it, Pi records an error tool result instead. The model can then see that the effect failed and decide whether to try another action. The two failures happen at different boundaries and therefore support different responses.

A concrete read-then-edit task makes the cycle clearer. The first assistant response may request a read of a test file. The harness validates the path-shaped arguments and executes the read. The resulting text is not silently merged into the assistant's prose; it arrives as a tool result with its own lifecycle and is appended to context. The next model request can use that result to identify a failing assertion and request an edit. The edit goes through the same validation and gate, even though the user considers both actions part of one task. If the edit is blocked, the model receives the reason. If it succeeds, the next turn can run a check or explain the change. “One task” is a user-level description; internally, Pi preserves each proposal, effect, result, and transition.

The stateful wrapper also keeps human control separate from provider control. Calling prompt() while an agent run is active is rejected; the caller must use a steering or follow-up queue, or wait for idle. That prevents two independent prompts from racing to mutate one transcript. An abort signal can cancel the current operation, but it does not retroactively erase messages or claim that an already-started external effect never happened. The host receives an event sequence that describes the interruption, and the session layer can decide how to retain it.

This separation is what lets different front ends remain thin. An interactive interface can render each lifecycle event. A structured client can subscribe to the same events and ignore terminal-specific presentation. An extension can observe a tool call or change its result without reimplementing the provider loop. The control loop is valuable precisely because its transitions are stable enough to compose with, while its context projection and effect policy remain replaceable.

Validation is the first effect boundary

Pi's tool definition has a name, a description, and a TypeBox parameter schema. The schema is more than documentation. The harness uses it to validate the arguments that came back from the model before passing them to the implementation. An unknown tool name, a malformed argument object, or a failed compatibility preparation becomes an error tool result rather than an external operation.

The same rule applies when a response is cut short by the output limit. A streamed tool call may contain JSON that happens to parse even though the model never finished producing all of its arguments. Pi treats tool calls in a length-truncated assistant message as unsafe and reports them as failed so the model can issue a complete call. This is a small but important consequence of distinguishing “the model produced a parsable object” from “the model completed a request that is safe to execute.”

The beforeToolCall hook adds a policy boundary after validation and before execution. An extension can inspect the tool, its validated arguments, and the current context, then block the call with a reason. The block is returned to the model as an error result, so the model remains inside the conversation rather than receiving a hidden failure. An afterToolCall hook can replace content, details, usage, or the error state before the result is emitted and added to context. These hooks do not make an arbitrary tool safe; they make the decision point visible and replaceable.

Concurrency changes ordering, not history

Pi supports sequential and parallel tool execution. In sequential mode, one call is prepared, executed, finalized, and reported before the next begins. In parallel mode, tool calls are preflighted in assistant source order, then allowed implementations can run concurrently. Completion events may arrive in the order the tools finish, but the final toolResult messages are emitted in the assistant's source order.

That distinction serves two needs at once. Parallel execution can reduce idle time when a model asks for independent reads. Stable result order keeps the conversation and its persisted artifacts deterministic enough for the next provider request. It does not solve shared-state races inside the tools. If two custom tools both perform read-modify-write work on the same file, they must use Pi's per-file mutation queue or one update can overwrite the other. The loop can coordinate tool calls; it cannot infer the correct locking policy for every custom effect.

A tool may return a termination hint when its result is meant to end the automatic follow-up. Pi honors that hint only when every finalized call in the batch asks for termination. One terminating call alongside an ordinary call does not silently discard the other call's result or stop the model from seeing the complete batch. Again, the rule is conservative: a local tool preference must not override the meaning of sibling effects.

Queues and failures decide what happens next

A person may need to redirect a task while the model is working. A steering message is held until the current assistant turn has finished executing its tool calls; it is then inserted before the next provider request. The current tool call is not retroactively canceled just because a new instruction was typed. A follow-up message waits longer: it is delivered only after the agent would otherwise stop, when no tool calls or steering messages remain. The two queues express different relationships to work already in flight.

The loop also distinguishes failures by where they occur. A provider error or abort produces an assistant message with an error or aborted stop reason and ends the current run. A tool execution that throws is converted into an error toolResult, allowing the model to see the failure and potentially choose another action. A blocked or invalid tool call follows the same conversational path. The result is not that Pi guarantees recovery; it is that recovery has a typed place in the event and message sequence.

This is why stopping is part of the agent protocol rather than a terminal-interface detail. The caller may observe a completed turn, wait for the agent's final event listeners, or enqueue another message. An extension can ask the loop to stop after a completed turn—for example, before context becomes too full—without aborting the provider stream or canceling tools that are already running. Conversely, a queued message can cause another turn after an apparent agent_end unless the session layer has fully settled.

Once the user prompt, provider response, tool gate, external effect, and result are separated, Pi becomes easier to adapt without changing its center. The provider layer can change how the request is encoded. An extension can change which effects are permitted. A host can change how events are displayed or persisted. The next question is what happens when the provider itself changes: which parts of this loop remain stable when the model, credentials, or API protocol are different?

References