Type Contracts
packages/agent/src/types.ts is the complete type contract exposed by pi-agent-core. It defines the loop entry StreamFn, the tool scheduling ToolExecutionMode, the loop-assembly AgentLoopConfig, the runtime state AgentState, the tool protocol AgentTool, the context snapshot AgentContext, the event stream AgentEvent, and the extensible message union AgentMessage. This layer is intentionally all interface/type with no runtime code, so AgentSession/Agent/runLoop all depend on abstractions rather than concretions.
Responsibilities
- Stream function contract:
StreamFnreuses the signature ofstreamSimpledirectly; implementations must not throw on request/model failure, and must encode failures intoerrorevents andstopReason: "error"in the returned stream. Seepackages/agent/src/types.ts:15-26. - Tool execution mode:
ToolExecutionMode = "sequential" | "parallel", appearing both inAgentLoopConfig.toolExecutionand inAgentTool.executionMode(per-tool override). Seepackages/agent/src/types.ts:28-36. - Loop config:
AgentLoopConfig extends SimpleStreamOptions, containingmodel/convertToLlm/transformContext/getApiKey/shouldStopAfterTurn/getSteeringMessages/getFollowUpMessages/toolExecution/beforeToolCall/afterToolCall. Seepackages/agent/src/types.ts:115-248. - Runtime state:
AgentStatedeclarestoolsandmessagesas setter/getter pairs, allowing implementations to copy the top-level array;isStreaming/streamingMessage/pendingToolCalls/errorMessageare all readonly. Seepackages/agent/src/types.ts:288-313. - Tool protocol:
AgentTool<TParameters, TDetails>extends pi-ai'sTool, addinglabel/prepareArguments/execute/executionMode;executetakes four arguments:toolCallId/params/signal/onUpdate. Seepackages/agent/src/types.ts:332-355. - Event union:
AgentEventis a discriminated union of 9 event types, organized along the four-layer lifecycle of agent/turn/message/tool execution. Seepackages/agent/src/types.ts:374-389.
Design motivation
Why is AgentMessage defined as Message | CustomAgentMessages[keyof CustomAgentMessages]? Because a coding assistant needs to mix "UI-only messages" (artifacts, notifications, thinking summaries) into the transcript, and these should not be sent to the LLM. CustomAgentMessages is an empty interface by default; the app layer adds custom roles via declaration merging, and convertToLlm is responsible for filtering them or translating them into user/assistant/toolResult that the LLM understands. This design lets the transcript type extend at compile time instead of degrading into any.
Why do the hook contracts repeatedly stress "must not throw"? Because runLoop is a single-threaded event stream, and any callback throwing would break the loop and produce no normal agent_end event sequence — the UI would be stuck at isStreaming=true. So the docs for convertToLlm/transformContext/getApiKey/shouldStopAfterTurn/getSteeringMessages/getFollowUpMessages all explicitly say "must not throw or reject; return a fallback". Hook throws are caught at the call site and turned into error results, but the loop's own callbacks can't be caught — that's a hard constraint of the type contract.
Why does BeforeToolCallResult only have two optional fields, block/reason, instead of a full result? Because the before stage only decides "let it run or not", not "what the result is". A blocked tool gets an error result constructed by the loop, with reason written into content. This way the before hook doesn't need to care about the tool's specific result shape, while the after hook (AfterToolCallResult) is allowed field-level override of content/details/isError/terminate, because the result already exists at that point.
Why does AgentTool's execute use the generics TParameters/TDetails? TParameters is constrained to TSchema (typebox); Static<TParameters> is the parameter type derived at compile time; TDetails is the details shape the tool defines itself. This way the params received inside a tool implementation are strongly typed, while the loop side stores everything as AgentTool<any> — type-safe yet usable in a heterogeneous list.
Key files
packages/agent/src/types.ts:15-26—StreamFn: signature reusesstreamSimple; contract requires failures to be encoded into the stream rather than thrown.packages/agent/src/types.ts:28-36—ToolExecutionModecomments explaining the event-ordering difference between sequential and parallel.packages/agent/src/types.ts:47-73—BeforeToolCallResult/AfterToolCallResult: block+reason vs field-level override.packages/agent/src/types.ts:76-113—BeforeToolCallContext/AfterToolCallContext/ShouldStopAfterTurnContext, the hook input shapes.packages/agent/src/types.ts:115-248—AgentLoopConfigfull fields, each with a JSDoc contract note.packages/agent/src/types.ts:257-280—CustomAgentMessages+AgentMessageunion; declaration merging extension point.packages/agent/src/types.ts:288-313—AgentState: setter/getter pairs for copyable fields; runtime state all readonly.packages/agent/src/types.ts:316-355—AgentToolResult/AgentToolUpdateCallback/AgentTool, the tool protocol.packages/agent/src/types.ts:358-365—AgentContext: the loop input snapshot, containing only systemPrompt/messages/tools.packages/agent/src/types.ts:367-389—AgentEvent: discriminated union of 9 event types.
Core fields of AgentLoopConfig — convertToLlm is required, the rest are optional:
// packages/agent/src/types.ts:115-144
export interface AgentLoopConfig extends SimpleStreamOptions {
model: Model<any>;
/** Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. */
convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
// ...
}AgentMessage is extended via declaration merging; by default it's just the Message union from pi-ai:
// packages/agent/src/types.ts:271-280
export interface CustomAgentMessages {
// Empty by default - apps extend via declaration merging
}
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];AgentTool extends pi-ai's Tool with the execution protocol:
// packages/agent/src/types.ts:332-346
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
label: string;
prepareArguments?: (args: unknown) => Static<TParameters>;
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
// ...
}Data flow
How types flow across layers:
Boundaries and failure
- StreamFn failure contract: request/model failures must be encoded into the stream and must not throw, otherwise
runLoop'sfor awaitbreaks with noagent_end. Seepackages/agent/src/types.ts:16-23. - convertToLlm failure contract: must not throw; return a fallback
Message[]. The type notes say throwing breaks the loop with no event sequence. Seepackages/agent/src/types.ts:119-127. - AgentState setter copy:
tools/messagesare setter/getter pairs; implementations may copy the top-level array (the defaultcreateMutableAgentStatedoes exactly this) to prevent external mutation. Seepackages/agent/src/types.ts:295-300. - afterToolCall no deep merge:
content/detailsfield-level override is "full replace", not deep merge;isError/terminateare replaced individually. The docs explicitly state "No deep merge is performed". Seepackages/agent/src/types.ts:52-73. - AgentContext is a snapshot:
Agentcallsmessages.slice()/tools.slice()insidecreateContextSnapshotbefore passing to the loop; mutations inside the loop (pushing partials, pushing toolResults) don't affectAgent._state. Seepackages/agent/src/agent.ts:402-408.
Summary
types.ts is the contract layer of pi-agent-core: StreamFn pins down the stream function shape, AgentLoopConfig assembles the loop, AgentState exposes readonly runtime state, AgentTool defines the tool protocol, AgentMessage extends via declaration merging, and AgentEvent is the union of 9 events. The contract repeatedly stresses "must not throw" to protect the integrity of runLoop's event sequence. For how these types are consumed, see Agent Class and Lifecycle and Double while Loop; for how hooks are invoked inside tool execution, see Tool Execution sequential/parallel.