import { z } from "zod"; import { AssistantMessage, ToolCallPart, ToolMessage, UserMessage, } from "./message.js"; // Durable turn contract for the turn runtime (see // packages/core/docs/turn-runtime-design.md). This module is the // cross-boundary source of truth shared by core and renderer: event schemas, // the pure reducer, and pure derivations over the reduced state. It must // stay free of I/O and node-only imports so the renderer can consume it // directly. export type JsonValue = z.infer>; export const DEFAULT_MAX_MODEL_CALLS = 20; export const MODEL_CALL_LIMIT_ERROR_CODE = "model-call-limit"; // --------------------------------------------------------------------------- // Agent snapshot // --------------------------------------------------------------------------- export const ModelDescriptor = z.object({ provider: z.string(), model: z.string(), }); export const RequestedAgent = z.object({ agentId: z.string(), overrides: z .object({ model: ModelDescriptor.optional(), }) .optional(), }); export const ToolDescriptor = z.object({ toolId: z.string(), name: z.string(), description: z.string(), inputSchema: z.json(), execution: z.enum(["sync", "async"]), requiresHuman: z.boolean(), }); export const ResolvedAgent = z.object({ agentId: z.string(), systemPrompt: z.string(), model: ModelDescriptor, tools: z.array(ToolDescriptor), }); // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- // Context excludes system-role messages: the resolved agent owns the single // authoritative system prompt. export const ConversationMessage = z.discriminatedUnion("role", [ UserMessage, AssistantMessage, ToolMessage, ]); // A reference points at the immediately preceding turn; its materialized // value is that turn's transcript (context + input + produced messages). // Inline arrays are used by standalone turns and by callers that assemble // context themselves. Resolution is a runtime concern; the reducer treats // context as opaque. export const TurnContextRef = z.object({ previousTurnId: z.string(), }); export const TurnContext = z.union([ TurnContextRef, z.array(ConversationMessage), ]); // --------------------------------------------------------------------------- // Usage // --------------------------------------------------------------------------- export const TurnUsage = z.object({ inputTokens: z.number().optional(), outputTokens: z.number().optional(), totalTokens: z.number().optional(), reasoningTokens: z.number().optional(), cachedInputTokens: z.number().optional(), }); // --------------------------------------------------------------------------- // Durable events // --------------------------------------------------------------------------- export const TurnCreated = z.object({ type: z.literal("turn_created"), schemaVersion: z.literal(1), turnId: z.string(), ts: z.string(), sessionId: z.string().nullable(), agent: z.object({ requested: RequestedAgent, resolved: ResolvedAgent, }), context: TurnContext, input: UserMessage, config: z.object({ autoPermission: z.boolean(), humanAvailable: z.boolean(), maxModelCalls: z.number().int().positive(), }), }); // `messages` contains current-turn messages only; when the turn's context is // a reference, `contextRef` repeats it and the resolved prefix is implicit // (deterministically materializable because referenced turns are immutable). // When context is inline, `contextRef` is absent and `messages` begins with // the inline context. export const ModelRequest = z.object({ systemPrompt: z.string(), contextRef: TurnContextRef.optional(), messages: z.array(ConversationMessage), tools: z.array(ToolDescriptor), parameters: z.record(z.string(), z.json()), }); export const ModelCallRequested = z.object({ type: z.literal("model_call_requested"), turnId: z.string(), ts: z.string(), modelCallIndex: z.number().int().nonnegative(), request: ModelRequest, }); // Normalized provider step events kept for debugging. Raw text/reasoning // deltas are stream-only and never appear here. export const DurableLlmStepStreamEvent = z.discriminatedUnion("type", [ z.object({ type: z.literal("text_start") }), z.object({ type: z.literal("text_end"), text: z.string() }), z.object({ type: z.literal("reasoning_start") }), z.object({ type: z.literal("reasoning_end"), text: z.string() }), z.object({ type: z.literal("tool_call"), toolCall: ToolCallPart }), z.object({ type: z.literal("finish_step"), finishReason: z.string(), usage: TurnUsage.optional(), providerMetadata: z.json().optional(), }), z.object({ type: z.literal("provider_error"), error: z.string() }), ]); export const ModelStepEvent = z.object({ type: z.literal("model_step_event"), turnId: z.string(), ts: z.string(), modelCallIndex: z.number().int().nonnegative(), event: DurableLlmStepStreamEvent, }); export const ModelCallCompleted = z.object({ type: z.literal("model_call_completed"), turnId: z.string(), ts: z.string(), modelCallIndex: z.number().int().nonnegative(), message: AssistantMessage, finishReason: z.string(), usage: TurnUsage, providerMetadata: z.json().optional(), }); export const ModelCallFailed = z.object({ type: z.literal("model_call_failed"), turnId: z.string(), ts: z.string(), modelCallIndex: z.number().int().nonnegative(), error: z.string(), }); export const ToolPermissionRequired = z.object({ type: z.literal("tool_permission_required"), turnId: z.string(), ts: z.string(), toolCallId: z.string(), toolName: z.string(), request: z.json(), checkerError: z.string().optional(), }); export const ToolPermissionClassified = z.object({ type: z.literal("tool_permission_classified"), turnId: z.string(), ts: z.string(), toolCallId: z.string(), decision: z.enum(["allow", "deny", "defer"]), reason: z.string(), }); export const ToolPermissionClassificationFailed = z.object({ type: z.literal("tool_permission_classification_failed"), turnId: z.string(), ts: z.string(), toolCallIds: z.array(z.string()), error: z.string(), }); // The only effective execution decision; classifier records are provenance. export const ToolPermissionResolved = z.object({ type: z.literal("tool_permission_resolved"), turnId: z.string(), ts: z.string(), toolCallId: z.string(), decision: z.enum(["allow", "deny"]), source: z.enum(["classifier", "human", "human_unavailable"]), reason: z.string().optional(), metadata: z.json().optional(), }); export const ToolInvocationRequested = z.object({ type: z.literal("tool_invocation_requested"), turnId: z.string(), ts: z.string(), toolCallId: z.string(), toolId: z.string(), toolName: z.string(), execution: z.enum(["sync", "async"]), input: z.json(), }); export const ToolProgress = z.object({ type: z.literal("tool_progress"), turnId: z.string(), ts: z.string(), toolCallId: z.string(), source: z.enum(["sync", "async"]), progress: z.json(), }); export const ToolResultData = z.object({ output: z.json(), isError: z.boolean(), metadata: z.json().optional(), }); // `runtime` results cover permission denial, unknown tools, unusable calls, // human unavailability, cancellation, and interrupted sync execution. A // result may exist without an invocation when execution was rejected before // dispatch. export const ToolResult = z.object({ type: z.literal("tool_result"), turnId: z.string(), ts: z.string(), toolCallId: z.string(), toolName: z.string(), source: z.enum(["sync", "async", "runtime"]), result: ToolResultData, }); export const TurnSuspended = z.object({ type: z.literal("turn_suspended"), turnId: z.string(), ts: z.string(), pendingPermissions: z.array( z.object({ toolCallId: z.string(), toolName: z.string(), request: z.json(), }), ), pendingAsyncTools: z.array( z.object({ toolCallId: z.string(), toolId: z.string(), toolName: z.string(), input: z.json(), }), ), usage: TurnUsage, }); export const TurnCompleted = z.object({ type: z.literal("turn_completed"), turnId: z.string(), ts: z.string(), output: AssistantMessage, finishReason: z.string(), usage: TurnUsage, }); export const TurnFailed = z.object({ type: z.literal("turn_failed"), turnId: z.string(), ts: z.string(), error: z.string(), // Machine-readable discriminator for failures callers must tell apart; // MODEL_CALL_LIMIT_ERROR_CODE is defined by the spec. code: z.string().optional(), usage: TurnUsage, }); export const TurnCancelled = z.object({ type: z.literal("turn_cancelled"), turnId: z.string(), ts: z.string(), reason: z.string().optional(), usage: TurnUsage, }); export const TurnEvent = z.discriminatedUnion("type", [ TurnCreated, ModelCallRequested, ModelStepEvent, ModelCallCompleted, ModelCallFailed, ToolPermissionRequired, ToolPermissionClassified, ToolPermissionClassificationFailed, ToolPermissionResolved, ToolInvocationRequested, ToolProgress, ToolResult, TurnSuspended, TurnCompleted, TurnFailed, TurnCancelled, ]); // --------------------------------------------------------------------------- // Ephemeral stream-only deltas (never persisted) // --------------------------------------------------------------------------- export type TextDelta = { type: "text_delta"; turnId: string; modelCallIndex: number; delta: string; }; export type ReasoningDelta = { type: "reasoning_delta"; turnId: string; modelCallIndex: number; delta: string; }; export type TurnStreamEvent = | z.infer | TextDelta | ReasoningDelta; // --------------------------------------------------------------------------- // Derived turn state // --------------------------------------------------------------------------- export class TurnCorruptionError extends Error { constructor(message: string) { super(message); this.name = "TurnCorruptionError"; } } export interface ModelCallState { index: number; request: z.infer; stepEvents: Array>; response?: z.infer; finishReason?: string; usage?: z.infer; providerMetadata?: JsonValue; error?: string; } export interface ToolCallState { modelCallIndex: number; order: number; toolCallId: string; toolName: string; input: unknown; toolId?: string; execution?: "sync" | "async"; permission?: { required: z.infer; classification?: z.infer; resolved?: z.infer; }; invocation?: z.infer; progress: Array>; result?: z.infer; } export interface TurnState { definition: z.infer; modelCalls: ModelCallState[]; toolCalls: ToolCallState[]; suspension?: z.infer; terminal?: | z.infer | z.infer | z.infer; usage: z.infer; } function fail(message: string): never { throw new TurnCorruptionError(message); } function isContextRef( context: z.infer, ): context is z.infer { return !Array.isArray(context); } function findToolCall(state: TurnState, toolCallId: string): ToolCallState { const toolCall = state.toolCalls.find( (tc) => tc.toolCallId === toolCallId, ); if (!toolCall) { fail(`event targets unknown tool call: ${toolCallId}`); } return toolCall; } function openModelCall(state: TurnState): ModelCallState | undefined { const last = state.modelCalls[state.modelCalls.length - 1]; if (last && last.response === undefined && last.error === undefined) { return last; } return undefined; } function batchToolCalls(state: TurnState, modelCallIndex: number): ToolCallState[] { return state.toolCalls .filter((tc) => tc.modelCallIndex === modelCallIndex) .sort((a, b) => a.order - b.order); } function addUsage( total: z.infer, usage: z.infer, ): void { const keys = [ "inputTokens", "outputTokens", "totalTokens", "reasoningTokens", "cachedInputTokens", ] as const; for (const key of keys) { const value = usage[key]; if (value !== undefined) { total[key] = (total[key] ?? 0) + value; } } } function applyModelCallRequested( state: TurnState, event: z.infer, ): void { const expectedIndex = state.modelCalls.length; if (event.modelCallIndex !== expectedIndex) { fail( `model call index ${event.modelCallIndex} out of order; expected ${expectedIndex}`, ); } if (expectedIndex >= state.definition.config.maxModelCalls) { fail( `model call index ${event.modelCallIndex} exceeds maxModelCalls ${state.definition.config.maxModelCalls}`, ); } if (openModelCall(state)) { fail("concurrent unresolved model call requests"); } const unresolved = state.toolCalls.filter((tc) => !tc.result); if (unresolved.length > 0) { fail( `model call requested while tool calls are unresolved: ${unresolved .map((tc) => tc.toolCallId) .join(", ")}`, ); } const context = state.definition.context; if (isContextRef(context)) { if (event.request.contextRef?.previousTurnId !== context.previousTurnId) { fail("model request contextRef inconsistent with turn context"); } } else if (event.request.contextRef !== undefined) { fail("model request has contextRef but turn context is inline"); } // Model-facing tool-result order must match original call order: the tool // messages in the request (past any inline context prefix) must be the // concatenated batches of prior completed calls, in source order. const prefixLength = isContextRef(context) ? 0 : context.length; const toolMessageIds = event.request.messages .slice(prefixLength) .filter((m) => m.role === "tool") .map((m) => m.toolCallId); const expectedIds = state.modelCalls .filter((call) => call.response !== undefined) .flatMap((call) => batchToolCalls(state, call.index).map((tc) => tc.toolCallId), ); if ( toolMessageIds.length !== expectedIds.length || toolMessageIds.some((id, i) => id !== expectedIds[i]) ) { fail("model request tool-result order differs from original call order"); } state.modelCalls.push({ index: event.modelCallIndex, request: event.request, stepEvents: [], }); } function applyModelStepEvent( state: TurnState, event: z.infer, ): void { const call = state.modelCalls[event.modelCallIndex]; if (!call) { fail(`step event without matching model call request: ${event.modelCallIndex}`); } if (call.response !== undefined || call.error !== undefined) { fail(`step event after model call ${event.modelCallIndex} settled`); } call.stepEvents.push(event.event); } function applyModelCallCompleted( state: TurnState, event: z.infer, ): void { const call = state.modelCalls[event.modelCallIndex]; if (!call) { fail(`model call completion without matching request: ${event.modelCallIndex}`); } if (call.response !== undefined || call.error !== undefined) { fail(`duplicate settlement for model call ${event.modelCallIndex}`); } call.response = event.message; call.finishReason = event.finishReason; call.usage = event.usage; call.providerMetadata = event.providerMetadata; addUsage(state.usage, event.usage); const parts = Array.isArray(event.message.content) ? event.message.content : []; let order = 0; for (const part of parts) { if (part.type !== "tool-call") { continue; } if (state.toolCalls.some((tc) => tc.toolCallId === part.toolCallId)) { fail(`duplicate tool call id: ${part.toolCallId}`); } const descriptor = state.definition.agent.resolved.tools.find( (tool) => tool.name === part.toolName, ); state.toolCalls.push({ modelCallIndex: event.modelCallIndex, order: order++, toolCallId: part.toolCallId, toolName: part.toolName, input: part.arguments, toolId: descriptor?.toolId, execution: descriptor?.execution, progress: [], }); } } function applyModelCallFailed( state: TurnState, event: z.infer, ): void { const call = state.modelCalls[event.modelCallIndex]; if (!call) { fail(`model call failure without matching request: ${event.modelCallIndex}`); } if (call.response !== undefined || call.error !== undefined) { fail(`duplicate settlement for model call ${event.modelCallIndex}`); } call.error = event.error; } function applyToolPermissionRequired( state: TurnState, event: z.infer, ): void { const toolCall = findToolCall(state, event.toolCallId); if (toolCall.result) { fail(`permission requirement after tool result: ${event.toolCallId}`); } if (toolCall.invocation) { fail(`permission requirement after invocation: ${event.toolCallId}`); } if (toolCall.permission) { fail(`duplicate permission requirement: ${event.toolCallId}`); } toolCall.permission = { required: event }; } function applyToolPermissionClassified( state: TurnState, event: z.infer, ): void { const toolCall = findToolCall(state, event.toolCallId); if (!toolCall.permission) { fail(`classification without permission requirement: ${event.toolCallId}`); } if (toolCall.permission.resolved) { fail(`classification after permission resolution: ${event.toolCallId}`); } if (toolCall.permission.classification) { fail(`duplicate permission classification: ${event.toolCallId}`); } toolCall.permission.classification = event; } function applyToolPermissionClassificationFailed( state: TurnState, event: z.infer, ): void { for (const toolCallId of event.toolCallIds) { const toolCall = findToolCall(state, toolCallId); if (!toolCall.permission) { fail(`classification failure without permission requirement: ${toolCallId}`); } if (toolCall.permission.resolved) { fail(`classification failure after permission resolution: ${toolCallId}`); } } // Recorded for the audit trail; does not currently affect derived state. } function applyToolPermissionResolved( state: TurnState, event: z.infer, ): void { const toolCall = findToolCall(state, event.toolCallId); if (!toolCall.permission) { fail(`permission resolution without requirement: ${event.toolCallId}`); } if (toolCall.permission.resolved) { fail(`conflicting permission decisions: ${event.toolCallId}`); } if (toolCall.result) { fail(`permission resolution after tool result: ${event.toolCallId}`); } toolCall.permission.resolved = event; } function applyToolInvocationRequested( state: TurnState, event: z.infer, ): void { const toolCall = findToolCall(state, event.toolCallId); if (toolCall.invocation) { fail(`duplicate tool invocation: ${event.toolCallId}`); } if (toolCall.result) { fail(`tool invocation after result: ${event.toolCallId}`); } if (toolCall.toolName !== event.toolName) { fail(`tool invocation name mismatch: ${event.toolCallId}`); } if (toolCall.permission && toolCall.permission.resolved?.decision !== "allow") { fail(`tool invocation without permission allowance: ${event.toolCallId}`); } if (toolCall.execution !== undefined && toolCall.execution !== event.execution) { fail(`tool invocation execution mismatch: ${event.toolCallId}`); } if (toolCall.toolId !== undefined && toolCall.toolId !== event.toolId) { fail(`tool invocation toolId mismatch: ${event.toolCallId}`); } toolCall.execution = event.execution; toolCall.toolId = event.toolId; toolCall.invocation = event; } function applyToolProgress( state: TurnState, event: z.infer, ): void { const toolCall = findToolCall(state, event.toolCallId); if (!toolCall.invocation) { fail(`tool progress without invocation: ${event.toolCallId}`); } if (toolCall.result) { fail(`tool progress after terminal result: ${event.toolCallId}`); } if (event.source !== toolCall.execution) { fail(`tool progress source mismatch: ${event.toolCallId}`); } toolCall.progress.push(event); } function applyToolResult( state: TurnState, event: z.infer, ): void { const toolCall = findToolCall(state, event.toolCallId); if (toolCall.result) { fail(`duplicate tool result: ${event.toolCallId}`); } if (toolCall.toolName !== event.toolName) { fail(`tool result name mismatch: ${event.toolCallId}`); } if (event.source !== "runtime") { if (!toolCall.invocation) { fail(`${event.source} tool result without invocation: ${event.toolCallId}`); } if (event.source !== toolCall.execution) { fail(`tool result source mismatch: ${event.toolCallId}`); } } toolCall.result = event; } function sortedIds(ids: string[]): string { return [...ids].sort().join(","); } function applyTurnSuspended( state: TurnState, event: z.infer, ): void { if (openModelCall(state)) { fail("suspension while a model call is unsettled"); } const expectedPermissions = outstandingPermissions(state).map( (tc) => tc.toolCallId, ); const expectedAsync = outstandingAsyncTools(state).map((tc) => tc.toolCallId); if (expectedPermissions.length + expectedAsync.length === 0) { fail("suspension without pending external work"); } const claimedPermissions = event.pendingPermissions.map((p) => p.toolCallId); const claimedAsync = event.pendingAsyncTools.map((p) => p.toolCallId); if ( sortedIds(claimedPermissions) !== sortedIds(expectedPermissions) || sortedIds(claimedAsync) !== sortedIds(expectedAsync) ) { fail("suspension snapshot inconsistent with pending state"); } state.suspension = event; } function assertTerminalPreconditions(state: TurnState, kind: string): void { if (openModelCall(state)) { fail(`${kind} while a model call is unsettled`); } const unresolved = state.toolCalls.filter((tc) => !tc.result); if (unresolved.length > 0) { fail( `${kind} while tool calls lack terminal results: ${unresolved .map((tc) => tc.toolCallId) .join(", ")}`, ); } } function applyTurnCompleted( state: TurnState, event: z.infer, ): void { assertTerminalPreconditions(state, "completion"); const last = state.modelCalls[state.modelCalls.length - 1]; if (!last || last.response === undefined) { fail("completion without a completed model response"); } if (batchToolCalls(state, last.index).length > 0) { fail("completion while the final response has tool calls"); } state.terminal = event; } export function reduceTurn( events: Array>, ): TurnState { if (events.length === 0) { fail("turn log is empty"); } const [first, ...rest] = events; if (first.type !== "turn_created") { fail(`first event must be turn_created, got ${first.type}`); } if (first.schemaVersion !== 1) { fail(`unsupported turn schema version: ${String(first.schemaVersion)}`); } const state: TurnState = { definition: first, modelCalls: [], toolCalls: [], usage: {}, }; for (const event of rest) { if (event.turnId !== first.turnId) { fail( `event turnId ${event.turnId} does not match turn ${first.turnId}`, ); } if (state.terminal) { fail(`event after terminal turn event: ${event.type}`); } switch (event.type) { case "turn_created": fail("duplicate turn_created event"); break; case "model_call_requested": applyModelCallRequested(state, event); break; case "model_step_event": applyModelStepEvent(state, event); break; case "model_call_completed": applyModelCallCompleted(state, event); break; case "model_call_failed": applyModelCallFailed(state, event); break; case "tool_permission_required": applyToolPermissionRequired(state, event); break; case "tool_permission_classified": applyToolPermissionClassified(state, event); break; case "tool_permission_classification_failed": applyToolPermissionClassificationFailed(state, event); break; case "tool_permission_resolved": applyToolPermissionResolved(state, event); break; case "tool_invocation_requested": applyToolInvocationRequested(state, event); break; case "tool_progress": applyToolProgress(state, event); break; case "tool_result": applyToolResult(state, event); break; case "turn_suspended": applyTurnSuspended(state, event); break; case "turn_completed": applyTurnCompleted(state, event); break; case "turn_failed": assertTerminalPreconditions(state, "failure"); state.terminal = event; break; case "turn_cancelled": assertTerminalPreconditions(state, "cancellation"); state.terminal = event; break; default: { const unknown: never = event; fail(`unknown turn event type: ${(unknown as { type: string }).type}`); } } } return state; } // --------------------------------------------------------------------------- // Pure derivations over TurnState // --------------------------------------------------------------------------- export function outstandingPermissions(state: TurnState): ToolCallState[] { return state.toolCalls.filter( (tc) => tc.permission && !tc.permission.resolved && !tc.result, ); } export function outstandingAsyncTools(state: TurnState): ToolCallState[] { return state.toolCalls.filter( (tc) => tc.invocation && tc.execution === "async" && !tc.result, ); } export type TurnStatus = | "completed" | "failed" | "cancelled" | "suspended" | "idle"; // No durable running status exists by design: an "idle" turn is simply // non-terminal with no outstanding external work. Whether it is actively // being advanced right now is ephemeral bus state. export function deriveTurnStatus(state: TurnState): TurnStatus { if (state.terminal) { switch (state.terminal.type) { case "turn_completed": return "completed"; case "turn_failed": return "failed"; case "turn_cancelled": return "cancelled"; } } if ( outstandingPermissions(state).length > 0 || outstandingAsyncTools(state).length > 0 ) { return "suspended"; } return "idle"; } function toolResultContent(result: z.infer): string { const output = result.result.output; return typeof output === "string" ? output : JSON.stringify(output); } // The messages this turn contributed to the conversation: its input plus, per // completed model call, the assistant response and its tool results in source // order. Failed model calls contribute nothing. The turn's context prefix is // NOT included; materializing the full conversation is the context resolver's // job (core). Requires every tool call to have a terminal result, which holds // for all terminal turns. export function turnTranscript( state: TurnState, ): Array> { const messages: Array> = [ state.definition.input, ]; for (const call of state.modelCalls) { if (call.response === undefined) { continue; } messages.push(call.response); for (const toolCall of batchToolCalls(state, call.index)) { if (!toolCall.result) { throw new Error( `turnTranscript requires terminal tool results; ${toolCall.toolCallId} is unresolved`, ); } messages.push({ role: "tool", content: toolResultContent(toolCall.result), toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, }); } } return messages; }