Skip to content

Type Contracts

源码版本v0.73.1

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

  1. Stream function contract: StreamFn reuses the signature of streamSimple directly; implementations must not throw on request/model failure, and must encode failures into error events and stopReason: "error" in the returned stream. See packages/agent/src/types.ts:15-26.
  2. Tool execution mode: ToolExecutionMode = "sequential" | "parallel", appearing both in AgentLoopConfig.toolExecution and in AgentTool.executionMode (per-tool override). See packages/agent/src/types.ts:28-36.
  3. Loop config: AgentLoopConfig extends SimpleStreamOptions, containing model/convertToLlm/transformContext/getApiKey/shouldStopAfterTurn/getSteeringMessages/getFollowUpMessages/toolExecution/beforeToolCall/afterToolCall. See packages/agent/src/types.ts:115-248.
  4. Runtime state: AgentState declares tools and messages as setter/getter pairs, allowing implementations to copy the top-level array; isStreaming/streamingMessage/pendingToolCalls/errorMessage are all readonly. See packages/agent/src/types.ts:288-313.
  5. Tool protocol: AgentTool<TParameters, TDetails> extends pi-ai's Tool, adding label/prepareArguments/execute/executionMode; execute takes four arguments: toolCallId/params/signal/onUpdate. See packages/agent/src/types.ts:332-355.
  6. Event union: AgentEvent is a discriminated union of 9 event types, organized along the four-layer lifecycle of agent/turn/message/tool execution. See packages/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

Core fields of AgentLoopConfigconvertToLlm is required, the rest are optional:

typescript
// 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:

typescript
// 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:

typescript
// 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's for await breaks with no agent_end. See packages/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. See packages/agent/src/types.ts:119-127.
  • AgentState setter copy: tools/messages are setter/getter pairs; implementations may copy the top-level array (the default createMutableAgentState does exactly this) to prevent external mutation. See packages/agent/src/types.ts:295-300.
  • afterToolCall no deep merge: content/details field-level override is "full replace", not deep merge; isError/terminate are replaced individually. The docs explicitly state "No deep merge is performed". See packages/agent/src/types.ts:52-73.
  • AgentContext is a snapshot: Agent calls messages.slice()/tools.slice() inside createContextSnapshot before passing to the loop; mutations inside the loop (pushing partials, pushing toolResults) don't affect Agent._state. See packages/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.