blob: 6bf5cb3a3f065ae2740c4879eb19f5396ce0175c [file]
/**
* AiSdkBackend — single backend for all LLM providers via Vercel AI SDK.
*
* Provides one `streamText` API across Anthropic / OpenAI / Google / DeepSeek /
* OpenAI-compatible endpoints, while keeping all of our home-grown
* machinery: session sandbox boundaries, materializer, AsyncEventQueue,
* SessionStore SQLite persistence.
*
* Maka owns the agent loop. Each ModelAdapter call performs exactly one
* provider request; returned tool calls settle through ToolRuntime, become
* durable, and are reloaded before the next provider request.
*
* Design:
* send()
* ├─ build AsyncEventQueue<SessionEvent>
* ├─ resolve LanguageModelV2 via deps.modelFactory(connection, modelId)
* ├─ expose schema-only tools to the provider
* ├─ background task: project → stream one step → settle → reload
* └─ yield from queue
*/
import type {
SessionEvent,
CompleteEvent,
AbortEvent,
ErrorEvent,
TextCompleteEvent,
ThinkingCompleteEvent,
TokenUsageEvent,
TextDeltaEvent,
ThinkingDeltaEvent,
ProviderRetryEvent,
ProviderRetryReason,
ToolResultEvent,
ToolResultContent,
ToolStartEvent,
StorageRef,
AttachmentRef,
QuoteRef,
ContextBudgetExhaustedDetail,
} from '@maka/core';
import type {
StoredMessage,
AssistantMessage,
AssistantThinkingPart,
ToolCallMessage,
ToolResultMessage,
PermissionDecisionMessage,
TokenUsageMessage,
SystemNoteMessage,
BackendKind,
SessionHeader,
} from '@maka/core';
import type {
AgentBackend,
BackendCompactHistoryInput,
BackendCompactHistoryResult,
BackendSendInput,
HostedInteractionBridge,
} from '@maka/core';
import type { AgentSpec } from '@maka/core';
import type { RuntimeEvent } from '@maka/core';
import type { SandboxBoundaryResponse } from '@maka/core';
import type { UserQuestionResponse } from '@maka/core';
import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core';
import { resolveEffectiveOrchestration, type EffectiveOrchestration } from '@maka/core';
import type { PlanToolResult } from './plan-tools.js';
import {
bindToolResultArchiveDecoder,
type ToolResultArchiveCapability,
} from './tool-result-archive-capability.js';
import {
YIELD_AGENT_GRAPH_TOOL_NAME,
type YieldAgentGraphToolResult,
} from './stream-graph-supervisor-tools.js';
import type { AttachmentByteReader } from '@maka/core';
import {
MAX_PROVIDER_IMAGE_REQUEST_BYTES,
PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE,
} from '@maka/core';
import { stripUndefinedDeep } from '@maka/core';
import type { LlmCallRecord, PricingConfig, ToolInvocationRecord } from '@maka/core';
import type { ContextBudgetDiagnostic, PromptSegmentEstimate } from '@maka/core';
import { DEFAULT_CODE_MODE_LIMITS, executeCodeCell } from '@maka/code-mode';
import type {
JSONValue,
ModelFinishReason,
ModelMessage,
ReasoningPart,
ModelToolSet,
NormalizedUsage,
ModelFailureKind,
ToolCallPart,
ToolResultOutput,
UserContent,
} from './model-protocol.js';
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';
import { z } from 'zod';
import { AsyncEventQueue } from './async-queue.js';
import {
StreamWatchdog,
formatStreamWatchdogError,
type StreamWatchdogInput,
type StreamWatchdogPhase,
} from './stream-watchdog.js';
import {
MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN,
MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN,
TOOL_ERROR_RESULT_MAX_CHARS,
ToolRuntime,
formatSyntheticToolErrorText,
formatToolArgsViolationText,
isRuntimeCommitBoundaryError,
type MakaTool,
type MakaToolContext,
type DurableSessionEventSink,
type ToolRuntimeInput,
} from './tool-runtime.js';
import type { RuntimeCommitSink } from './runtime-commit-sink.js';
import type { SubagentExecutionRef } from './subagent-execution.js';
import {
ModelAdapter,
type ModelFactoryInput,
type NormalizedAiSdkUsage,
type ModelStreamResult,
type RepairableAiSdkToolCall,
} from './model-adapter.js';
import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js';
import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js';
import {
composeRequestProjection,
type RequestProjection,
type RequestProjectionContext,
type RequestProjectionStage,
} from './request-projection.js';
import type { ActiveToolResultPruneDiagnosticPatch } from './active-tool-result-prune.js';
import { toolResultOutput } from './tool-result-output.js';
import { buildActiveCompactionHeadAnchor } from './active-full-compact.js';
import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js';
import type { ProviderImageBudget } from './ai-sdk-compaction.js';
import {
AiSdkCompaction,
composeActiveCompactionProjection,
hasActiveToolResultPruneDiagnosticPatch,
hasBlockingReplayDiagnostics,
} from './ai-sdk-compaction.js';
import type { AiSdkCompactionCapabilities } from './ai-sdk-compaction-contract.js';
import type { ToolArtifactRecorder } from './tool-artifacts.js';
import { openAiChatReasoningFieldFromProviderOptions } from './openai-chat-reasoning-transport.js';
import { RunTrace, type RunTraceRecorder } from './run-trace.js';
import {
toSandboxRunTraceProjection,
type SandboxDiagnosticsSnapshot,
} from './sandbox/diagnostics.js';
import { renderSandboxTurnTailPrompt } from './system-prompt/sandbox-context-prompt.js';
import { computeCost } from './telemetry/cost.js';
import { getBuiltinPricing } from './telemetry/builtin-pricing.js';
import {
buildRuntimeEventModelReplayPlan,
buildSteeringEnvelope,
collectToolActivityTurnIds,
formatTextWithInlineRefs,
steeringMessagesMissingFromBase,
steeringModelMessage,
steeringProviderOptions,
stripSteeringMessages,
type RuntimeEventModelReplayItem,
type RuntimeEventModelReplayPlan,
type RuntimeEventReplayFallbackGate,
} from './model-history.js';
import {
computeRequestShapeDiagnostic,
toolSchemaCharsForDiagnostics,
type RequestShapeDiagnostic,
} from './request-shape.js';
import type { ModelCallAttempt, ModelCallKind } from '@maka/core';
import {
ProviderRequestTracker,
type ModelCallAccountingInput,
type ProviderRequestAttemptRecord,
type ProviderRequestCaptureRecord,
type ProviderRequestUsage,
type ResolvedModelCallCost,
} from './provider-request-telemetry.js';
import {
ToolAvailabilityRuntime,
type ToolAvailabilityConfig,
type ToolAvailabilityPlan,
} from './tool-availability.js';
import { renderSwarmModePrompt } from './swarm-mode.js';
import { renderGraphModePrompt } from './graph-mode.js';
import {
MEMORY_EXTRACT_TOOL_NAME,
MEMORY_REMEMBER_TOOL_NAME,
buildMemoryExtractionTriggerTools,
type MemoryExtractionSourceCapabilities,
type MemoryExtractionSourceSnapshot,
type MemoryExtractionTrigger,
} from './memory-extraction.js';
import { modelUsesNativeOpenAiResponses } from './model-runtime.js';
import {
applyRuntimeEventContextBudget,
buildContextBudgetDiagnosticShell,
buildHistoryCompactBlockFromSummary,
buildHistorySearchSource,
buildPromptSegmentEstimates,
estimateRuntimeEventsTokens,
hasOversizedRetainedHistoryTurn,
mergeContextBudgetDiagnostic,
mergeContextBudgetDiagnosticPatches,
mergeRuntimeEventsInOriginalOrder,
minimalContextBudgetDiagnostic,
rawEvidenceRequestReason,
retrieveArchivedToolResultsForReplay,
retrieveReplayHistoryAroundSearchSource,
retrieveRuntimeEventHistoryAround,
runtimeEventTurnKey,
shouldAppendContextCompactedNote,
shouldAppendContextCompactionFailedOpenNote,
type ContextBudgetPolicy,
} from './context-budget.js';
import {
evaluateHistoryCompactCheckpointReplay,
replaceHistoryCompactReplayBlocks,
} from './history-compact.js';
import { selectSynthesisCacheForReplay } from './synthesis-cache.js';
import {
historyCompactCheckpointToRuntimeEvent,
matchHistoryCompactCheckpointPrefix,
projectHistoryCompactCheckpointReplay,
type HistoryCompactCheckpoint,
} from './history-compact-checkpoint.js';
import { resolveSelectedModelContextWindow } from './context-budget-policy.js';
export {
DEFAULT_PERMISSION_TIMEOUT_MS,
MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN,
MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN,
TOOL_ERROR_RESULT_MAX_CHARS,
formatSyntheticToolErrorText,
} from './tool-runtime.js';
export { normalizeAiSdkUsage } from './model-adapter.js';
export type { ModelFactory, ModelFactoryInput, RepairableAiSdkToolCall } from './model-adapter.js';
export type { RunTraceEvent, RunTraceRecorder } from './run-trace.js';
const CHILD_STEP_BUDGET_FINALIZATION_PROMPT = [
'<step_budget_finalization>',
'This is the final budgeted step for this child-agent turn.',
'Do not call tools. Return the best concise final answer now using evidence already gathered.',
'Clearly separate verified findings from inference and explicitly name any remaining gaps.',
'</step_budget_finalization>',
].join('\n');
function providerToolResultContent(
toolName: string,
output: unknown,
input?: unknown,
): ToolResultContent {
if (output === undefined) {
return { kind: 'text', text: `${toolName} completed without a structured result.` };
}
if (toolName !== 'WebSearch') {
return { kind: 'json', value: output };
}
const queryFromInput = providerWebSearchQuery(input);
if (Array.isArray(output)) {
const rows: Array<{ title: string; url: string; snippet: string; source: string }> = [];
for (const result of output) {
if (
!result ||
typeof result !== 'object' ||
(result as { type?: unknown }).type !== 'web_search_result' ||
typeof (result as { url?: unknown }).url !== 'string'
) {
continue;
}
const item = result as {
url: string;
title?: unknown;
pageAge?: unknown;
};
try {
const parsed = new URL(item.url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
rows.push({
title: typeof item.title === 'string' && item.title.trim() ? item.title : parsed.hostname,
url: parsed.toString(),
snippet: typeof item.pageAge === 'string' ? item.pageAge : '',
source: parsed.hostname,
});
} catch {
// Provider source rows are untrusted; malformed URLs are dropped.
}
}
return { kind: 'web_search', provider: 'model', query: queryFromInput, rows };
}
if (!output || typeof output !== 'object') return { kind: 'json', value: output };
const providerError = output as { type?: unknown; errorCode?: unknown };
if (
providerError.type === 'web_search_tool_result_error' ||
typeof providerError.errorCode === 'string'
) {
return {
kind: 'web_search_error',
ok: false,
provider: 'model',
...(queryFromInput ? { query: queryFromInput } : {}),
reason: 'provider_error',
message:
typeof providerError.errorCode === 'string'
? `Provider web search failed: ${providerError.errorCode}`
: 'Provider web search failed.',
};
}
const action = (output as { action?: unknown }).action;
const sources = (output as { sources?: unknown }).sources;
let query = queryFromInput;
if (action && typeof action === 'object') {
const value = action as { type?: unknown; query?: unknown; queries?: unknown };
if (Array.isArray(value.queries)) {
query = value.queries.filter((item): item is string => typeof item === 'string').join(' | ');
} else if (typeof value.query === 'string') {
query = value.query;
}
}
const rows: Array<{ title: string; url: string; snippet: string; source: string }> = [];
if (Array.isArray(sources)) {
for (const source of sources) {
if (
!source ||
typeof source !== 'object' ||
(source as { type?: unknown }).type !== 'url' ||
typeof (source as { url?: unknown }).url !== 'string'
) {
continue;
}
const url = (source as { url: string }).url;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
rows.push({
title: parsed.hostname,
url: parsed.toString(),
snippet: '',
source: parsed.hostname,
});
} catch {
// Provider source rows are untrusted; malformed URLs are dropped.
}
}
}
return { kind: 'web_search', provider: 'model', query, rows };
}
function providerWebSearchQuery(input: unknown): string {
let value = input;
if (typeof input === 'string') {
try {
value = JSON.parse(input);
} catch {
return '';
}
}
if (!value || typeof value !== 'object') return '';
const query = (value as { query?: unknown }).query;
return typeof query === 'string' ? query : '';
}
function mergeTextProviderOptions(
current: NonNullable<ModelMessage['providerOptions']> | undefined,
next: NonNullable<ModelMessage['providerOptions']>,
textOffset: number,
): NonNullable<ModelMessage['providerOptions']> {
const shifted = structuredClone(next);
const shiftedOpenAi = shifted.openai;
if (shiftedOpenAi && typeof shiftedOpenAi === 'object' && !Array.isArray(shiftedOpenAi)) {
const annotations = (shiftedOpenAi as { annotations?: unknown }).annotations;
if (Array.isArray(annotations) && textOffset > 0) {
(shiftedOpenAi as { annotations: unknown[] }).annotations = annotations.map((annotation) => {
if (!annotation || typeof annotation !== 'object' || Array.isArray(annotation)) {
return annotation;
}
const value = { ...annotation } as Record<string, unknown>;
if (typeof value.startIndex === 'number') value.startIndex += textOffset;
if (typeof value.endIndex === 'number') value.endIndex += textOffset;
if (typeof value.start_index === 'number') value.start_index += textOffset;
if (typeof value.end_index === 'number') value.end_index += textOffset;
return value;
});
}
}
if (!current) return shifted;
const merged = { ...structuredClone(current), ...shifted };
const currentOpenAi = current.openai;
if (
currentOpenAi &&
typeof currentOpenAi === 'object' &&
!Array.isArray(currentOpenAi) &&
shiftedOpenAi &&
typeof shiftedOpenAi === 'object' &&
!Array.isArray(shiftedOpenAi)
) {
const left = currentOpenAi as Record<string, unknown>;
const right = shiftedOpenAi as Record<string, unknown>;
const openai: Record<string, unknown> = { ...left, ...right };
const leftAnnotations = Array.isArray(left.annotations) ? left.annotations : [];
const rightAnnotations = Array.isArray(right.annotations) ? right.annotations : [];
if (leftAnnotations.length > 0 || rightAnnotations.length > 0) {
openai.annotations = [...leftAnnotations, ...rightAnnotations];
}
if (
typeof left.itemId === 'string' &&
typeof right.itemId === 'string' &&
left.itemId !== right.itemId
) {
delete openai.itemId;
}
merged.openai = openai as NonNullable<ModelMessage['providerOptions']>[string];
}
return merged;
}
// ============================================================================
// AgentBackend interface — port contract now lives in @maka/core/backend-types;
// re-exported here for backward compatibility with existing import sites.
// ============================================================================
export type {
AgentBackend,
BackendCompactHistoryInput,
BackendCompactHistoryResult,
} from '@maka/core';
export const INVALID_TOOL_NAME = 'invalid';
function projectToolModePlan(
plan: ToolAvailabilityPlan,
toolMode: ToolMode,
execTool: MakaTool,
): ToolAvailabilityPlan {
if (toolMode === 'direct') return plan;
const withExec = (names: readonly string[]): string[] =>
[...new Set([...names, execTool.name])].sort((a, b) => a.localeCompare(b));
const invalid = plan.providerTools.filter((tool) => tool.name === INVALID_TOOL_NAME);
const visible = [
...plan.providerTools.filter((tool) => tool.name !== INVALID_TOOL_NAME),
execTool,
].sort((a, b) => a.name.localeCompare(b.name));
return {
...plan,
providerTools: [...visible, ...invalid],
activeTools: withExec(plan.activeTools),
...(plan.projectActiveTools
? {
projectActiveTools: (options) => ({
activeTools: withExec(plan.projectActiveTools?.(options).activeTools ?? []),
}),
}
: {}),
currentRepairToolNames: () => withExec(plan.currentRepairToolNames()),
diagnostics: (activeTools, visibleToolSchemaChars) => {
const baseActive = activeTools.filter((name) => name !== execTool.name);
const baseChars = toolSchemaCharsForDiagnostics(plan.providerTools, baseActive);
const diagnostic = plan.diagnostics(baseActive, baseChars);
if (!diagnostic) return undefined;
const execSchemaChars = Math.max(0, visibleToolSchemaChars - baseChars);
return {
...diagnostic,
visibleToolCount: (diagnostic.visibleToolCount ?? baseActive.length) + 1,
fullToolCount:
(diagnostic.fullToolCount ?? baseActive.length + (diagnostic.hiddenToolCount ?? 0)) + 1,
visibleToolSchemaChars,
fullToolSchemaChars:
(diagnostic.fullToolSchemaChars ??
baseChars + (diagnostic.toolSchemaCharReduction ?? 0)) + execSchemaChars,
};
},
};
}
function nestableToolSnapshot(
providerTools: readonly MakaTool[],
activeToolNames: readonly string[],
): ReadonlyMap<string, MakaTool> {
const active = new Set(activeToolNames);
return new Map(
providerTools
.filter(
(tool) =>
active.has(tool.name) &&
tool.name !== INVALID_TOOL_NAME &&
tool.name !== 'exec' &&
tool.providerTool === undefined &&
tool.nesting !== 'direct_only',
)
.map((tool) => [tool.name, tool] as const),
);
}
const codeModeJsonSchemaOptions = {
allErrors: true,
strict: false,
validateFormats: false,
} as const;
const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions);
const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions);
const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions);
const codeModeCompiledSchemas = new WeakMap<object, ValidateFunction>();
async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise<unknown> {
const parameters = tool.parameters as {
safeParseAsync?: (
value: unknown,
) => Promise<{ success: true; data: unknown } | { success: false; error: unknown }>;
safeParse?: (
value: unknown,
) => { success: true; data: unknown } | { success: false; error: unknown };
validate?: (
value: unknown,
) =>
| { success: true; value: unknown }
| { success: false; error: unknown }
| Promise<{ success: true; value: unknown } | { success: false; error: unknown }>;
jsonSchema?: unknown;
};
const parserResult = parameters.safeParseAsync
? await parameters.safeParseAsync(input)
: parameters.safeParse?.(input);
if (parserResult) {
if (parserResult.success) return parserResult.data;
throw invalidCodeModeToolArguments(tool.name, parserResult.error);
}
if (parameters.validate) {
const validationResult = await parameters.validate(input);
if (validationResult.success) return validationResult.value;
throw invalidCodeModeToolArguments(tool.name, validationResult.error);
}
const schema = await parameters.jsonSchema;
const validator = compileCodeModeJsonSchema(schema ?? tool.parameters);
if (!validator || validator(input)) return input;
throw invalidCodeModeToolArguments(tool.name, validator.errors);
}
function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined {
if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema);
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined;
const cached = codeModeCompiledSchemas.get(schema);
if (cached) return cached;
const declaredDialect = (schema as { readonly $schema?: unknown }).$schema;
const dialect = typeof declaredDialect === 'string' ? declaredDialect : '';
const validator = dialect.includes('draft-07')
? codeModeDraft7Validator
: dialect.includes('2019-09')
? codeModeDraft2019Validator
: codeModeDraft2020Validator;
const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema')
? { ...schema, $schema: dialect.replace('https://', 'http://') }
: schema;
const compiled = validator.compile(schemaForCompile as AnySchema);
codeModeCompiledSchemas.set(schema, compiled);
return compiled;
}
function invalidCodeModeToolArguments(toolName: string, error: unknown): Error {
return new Error(`Invalid arguments for tool "${toolName}": ${schemaErrorSummary(error)}`);
}
function schemaErrorSummary(error: unknown): string {
if (error && typeof error === 'object' && Array.isArray((error as { issues?: unknown }).issues)) {
const issues = (error as { issues: Array<{ path?: unknown; message?: unknown }> }).issues;
return issues
.slice(0, 5)
.map((issue) => {
const path = Array.isArray(issue.path) ? issue.path.join('.') : '';
const message = typeof issue.message === 'string' ? issue.message : 'invalid value';
return path ? `${path}: ${message}` : message;
})
.join('; ')
.slice(0, 1000);
}
if (Array.isArray(error)) {
return (error as ErrorObject[])
.slice(0, 5)
.map((issue) => {
const path = issue.instancePath || issue.schemaPath;
return `${path || 'input'} ${issue.message ?? 'is invalid'}`;
})
.join('; ')
.slice(0, 1000);
}
return 'input does not match the declared schema';
}
function joinPromptFragments(fragments: readonly (string | undefined)[]): string | undefined {
const joined = fragments
.map((fragment) => fragment?.trim())
.filter((fragment): fragment is string => Boolean(fragment))
.join('\n\n');
return joined.length > 0 ? joined : undefined;
}
// ============================================================================
// Constructor input — single object matches @kabi's BackendRegistry call site
// ============================================================================
/**
* Append-message writer — usually `(m) => store.appendMessage(sessionId, m)`.
* Allows callers to inject a custom queueing/buffering strategy if needed.
*/
export type AppendMessageFn = (m: StoredMessage) => Promise<void>;
export type ToolTelemetryRecorder = (record: ToolInvocationRecord) => void;
export type {
ActiveFullCompactBlockRecorder,
HistoryCompactCheckpointLoader,
HistoryCompactCheckpointRecorder,
HistoryCompactLoader,
HistoryCompactLoadInput,
HistoryCompactLoadResult,
HistoryCompactSummarizer,
HistoryCompactSummaryInput,
HistoryCompactWriter,
HistoryCompactWriteInput,
HistoryCompactWriteResult,
SemanticCompactBlockRecorder,
SynthesisCacheLoader,
SynthesisCacheLoadInput,
SynthesisCacheLoadResult,
SynthesisCacheWriter,
SynthesisCacheWriteInput,
SynthesisCacheWriteResult,
} from './ai-sdk-compaction-contract.js';
export interface AiSdkBackendInput extends AiSdkCompactionCapabilities {
// ── Session context ────────────────────────────────────────────────────
sessionId: string;
header: SessionHeader;
/** Append-message function bound to this session (e.g. SessionStore wrapper). */
appendMessage: AppendMessageFn;
/** Reads the authoritative session boundary immediately before every local tool invocation. */
readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary'];
createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest'];
settleSandboxBoundaryRequest?: ToolRuntimeInput['settleSandboxBoundaryRequest'];
// ── Process-singleton deps ─────────────────────────────────────────────
/** Canonical-named tools available this session. */
tools: MakaTool[];
/** Active profile and enforcement capability snapshot for this session backend. */
sandboxDiagnosticsSnapshot?: SandboxDiagnosticsSnapshot;
/** Diagnostic-only Plan Mode/execution identity snapshot. */
planTraceContext?: {
mode: 'agent' | 'plan';
storeVersion: number;
planId?: string;
proposalId?: string;
executionId?: string;
};
/**
* Optional unified tool-availability config (issue #37). With `economy: true`,
* only core + ungrouped tools are advertised each turn; each group's tools are
* withheld until the model activates the group via `load_tools`, which takes
* effect in the next Runtime request projection and persists across turns via the
* RuntimeEvent ledger. Omitted or `economy: false` advertises every tool every
* turn (full surface). The runtime owns the catalog, connector, activation,
* gating, and diagnostics.
*/
toolAvailability?: ToolAvailabilityConfig;
// ── Optional knobs (defaults shown) ────────────────────────────────────
/** ID generator; default `crypto.randomUUID()`. */
newId?: () => string;
/** Clock; default `Date.now()`. */
now?: () => number;
/** Optional cap on tool-call steps per turn; omitted means no step cap. */
maxSteps?: number;
/** Timeout before first SDK stream event; default 30s. */
streamConnectTimeoutMs?: number;
/** Timeout between SDK/tool events; paused while a tool is active. Default 120s. */
streamIdleTimeoutMs?: number;
/** Test seam for the Runtime-owned stream watchdog clock. */
streamWatchdogTimer?: Pick<Required<StreamWatchdogInput>, 'setTimer' | 'clearTimer'>;
/** Test seam for the Runtime-owned provider retry clock. */
providerRetrySleep?: (delayMs: number, signal: AbortSignal) => Promise<void>;
/** Optional system prompt (skills + workspace AGENTS.md merged upstream). */
systemPrompt?:
| string
| ((context: SystemPromptContext) => string | undefined | Promise<string | undefined>);
/** Optional provider-visible current-turn tail kept out of the durable system prefix. */
turnTailPrompt?:
| string
| ((context: SystemPromptContext) => string | undefined | Promise<string | undefined>);
/** Optional volatile ShellRun summary. Not persisted; appended to the current user turn tail only. */
shellRunContextSummary?: () => string | undefined | Promise<string | undefined>;
/** Provider-native options passed through to ai-sdk. */
providerOptions?: Record<string, unknown>;
/** Test seam for the adapter-owned incremental Responses transport. */
openAiResponsesTransportState?: OpenAiResponsesTransportState;
/** Optional fire-and-forget telemetry hook. Tool implementations remain unaware. */
recordToolInvocation?: ToolTelemetryRecorder;
/** Optional Phase 2 SQLite T1/T2 boundary for real tool execution. */
runtimeCommitSink?: RuntimeCommitSink;
/** Durable session-lifetime cumulative usage checkpoint after each completed provider step. */
recordUsageCheckpoint?: (
usage: NormalizedAiSdkUsage & { costUsd?: number },
) => void | Promise<void>;
/** Optional pricing lookup shared with telemetry; defaults to builtin public pricing. */
lookupPricing?: (modelKey: string) => PricingConfig | null;
spawnChildAgent?: (input: {
parentRunId: string;
spec: AgentSpec;
prompt: string;
abortSignal: AbortSignal;
onReady?: (input: {
turnId: string;
agentId: string;
agentName: string;
}) => void | Promise<void>;
onEvent?: (event: SessionEvent) => void;
}) => Promise<unknown>;
spawnChildSession?: ToolRuntimeInput['spawnChildSession'];
prepareChildAgentResume?: ToolRuntimeInput['prepareChildAgentResume'];
resumeChildAgent?: ToolRuntimeInput['resumeChildAgent'];
retryChildAgent?: (input: {
parentRunId: string;
sourceRunId: string;
execution?: SubagentExecutionRef;
abortSignal: AbortSignal;
onReady?: (input: {
childSessionId?: string;
turnId: string;
runId?: string;
agentId: string;
agentName: string;
}) => void | Promise<void>;
onEvent?: (event: SessionEvent) => void;
}) => Promise<unknown>;
listChildAgents?: () => Promise<unknown>;
readChildAgentOutput?: ToolRuntimeInput['readChildAgentOutput'];
/** Optional diagnostic trace hook for explaining a runtime turn without changing renderer events. */
recordRunTrace?: RunTraceRecorder;
/**
* Durable prepared-request capture boundary. When configured, rejection
* prevents the corresponding provider request from being dispatched.
*/
recordProviderRequestCapture?: (
capture: ProviderRequestCaptureRecord,
) => Promise<{ artifactId: string }>;
/** Best-effort durable row for one physical provider request attempt. */
recordProviderRequestAttempt?: (attempt: ProviderRequestAttemptRecord) => void | Promise<void>;
/**
* Canonical metering sink. Separate from `recordProviderRequestAttempt`, which
* stays a diagnostic trace: this one carries the accounting record.
*/
recordModelCallAttempt?: (attempt: ModelCallAttempt) => void | Promise<void>;
/**
* Pre-dispatch accounting gate, paired with `recordModelCallAttempt` and read
* only when it is present. Throws when the canonical record could not be
* written for this dispatch, which fails the send before the provider is
* called rather than producing spend nothing recorded.
*/
assertModelCallAccountingReady?: () => void;
/**
* Optional artifact recorder. Runtime derives only deterministic candidates
* from structured tool results / explicit redirects; desktop main owns
* file-backed persistence.
*/
recordToolArtifacts?: ToolArtifactRecorder;
/**
* Optional attachment byte reader. When set, image attachments on the current
* user turn may be rendered as provider image parts instead of placeholder text.
* Caller wires this to the session ArtifactStore; runtime never imports storage.
*/
readAttachmentBytes?: AttachmentByteReader;
/**
* Whether the selected model accepts image input. Only explicit true sends
* image parts; false/unknown stay as text refs with a fallback note.
*/
supportsVision?: boolean;
maxProviderImageRequestBytes?: number;
/** Host-owned bounded long-term-memory extraction. Source tools are Runtime-reserved. */
memoryExtraction?: MemoryExtractionSourceCapabilities;
}
export interface SystemPromptContext {
sessionId: string;
turnId: string;
cwd: string;
workspaceRoot: string;
/** Diagnostic-only skill catalog trace; never affects prompt construction. */
emitSkillCatalogTrace?: (message: string, data?: Record<string, unknown>) => void;
}
function appendNonVisionImageFallbackNotice(textContent: string): string {
return `${textContent}\n\n[image attachments omitted: the selected model does not support image input. Tell the user you cannot view the attached image(s) and ask them to describe the image or switch to a vision-capable model.]`;
}
function isImageToolResult(
value: unknown,
): value is { kind: 'image'; mimeType: string; ref: StorageRef } {
if (!value || typeof value !== 'object') return false;
const image = value as { kind?: unknown; mimeType?: unknown; ref?: unknown };
return (
image.kind === 'image' &&
typeof image.mimeType === 'string' &&
image.ref !== null &&
typeof image.ref === 'object'
);
}
function toolResultText(text: string): ToolResultOutput {
return { type: 'content', value: [{ type: 'text', text }] };
}
const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10;
const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1;
const PROVIDER_RETRY_BASE_DELAY_MS = 1_000;
const PROVIDER_RETRY_MAX_DELAY_MS = 32_000;
const PROVIDER_RETRY_JITTER_FACTOR = 0.25;
function providerRetryDelayMs(failedAttempt: number, retryAfterMs?: number): number {
if (retryAfterMs !== undefined) return retryAfterMs;
const base = Math.min(
PROVIDER_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, failedAttempt - 1),
PROVIDER_RETRY_MAX_DELAY_MS,
);
return Math.ceil(base + Math.random() * PROVIDER_RETRY_JITTER_FACTOR * base);
}
function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason {
switch (kind) {
case 'network':
case 'provider_unavailable':
case 'rate_limit':
case 'timeout':
return kind;
default:
return 'unknown';
}
}
function sleepForProviderRetry(delayMs: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}
return new Promise((resolve, reject) => {
const timer = setTimeout(finish, delayMs);
signal.addEventListener('abort', abort, { once: true });
function finish(): void {
signal.removeEventListener('abort', abort);
resolve();
}
function abort(): void {
clearTimeout(timer);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}
});
}
// ============================================================================
// Implementation
// ============================================================================
/**
* The mutable state of ONE `send()`.
*
* Identity is readonly and captured at dispatch: a tool that executes minutes
* later commits against the run that actually issued it, never against whatever
* run happens to be current when it finishes. The remaining fields are the
* turn's own stream/abort bookkeeping, isolated so an overlapping turn on the
* same backend cannot observe or clear them.
*
* Each scope owns its ToolRuntime for the same reason: gating, the loop gate,
* the subagent and child-run limiters, durable attempts, and step admission are
* all per-turn facts.
*/
class TurnScope {
readonly abortController = new AbortController();
aborted = false;
loopStopRequested = false;
loopStopReason: CompleteEvent['stopReason'] | undefined;
/** Paused while this turn waits on a user permission decision. */
watchdog: StreamWatchdog | null = null;
runTrace: RunTrace | null = null;
/**
* Image allowance for this turn, accumulated across its provider steps. Owned
* by the scope so an overlapping turn cannot spend it, and non-null for the
* scope's whole life so no path has to decide what "no budget" means.
*/
readonly imageBudget: ProviderImageBudget = { used: 0, decisions: new Map() };
/**
* User messages steered into this turn, drained from the caller's queue at
* step boundaries. Each entry is the canonical envelope-wrapped user
* ModelMessage — the SAME form the replay plan projects the persisted
* steering event as, so the envelope text is the message's identity when
* deduping against ledger-derived request bases (bare text is not an
* identity: a steer can equal the current prompt verbatim). Entries are added
* only AFTER the echoed steering_message event is durably consumed (seq-ack),
* so a provider request never carries an unpersisted steering directive.
*/
injectedSteeringMessages: ModelMessage[] = [];
memoryExtractRequested = false;
memorySourceMessages: readonly ModelMessage[] | undefined;
memorySourceEventMessagePositions: Readonly<Record<string, readonly number[]>> | undefined;
memorySourceSystemPrompt: string | undefined;
memorySourceTools: ModelToolSet | undefined;
memorySourceActiveTools: readonly string[] | undefined;
finalAssistantText: string | undefined;
codeModeTools: ReadonlyMap<string, MakaTool> | undefined;
constructor(
readonly turnId: string,
readonly runId: string | undefined,
readonly orchestration: EffectiveOrchestration,
readonly toolRuntime: ToolRuntime,
) {}
}
type PriorReplayResult =
| {
status: 'ready';
messages: ModelMessage[];
gate: RuntimeEventReplayFallbackGate | 'stored_message_projection';
diagnostics: RuntimeEventModelReplayPlan['diagnostics'];
runtimeEventCount?: number;
contextBudget?: ContextBudgetDiagnostic;
latestHistoryCompactCheckpoint?: HistoryCompactCheckpoint;
}
| {
status: 'context_budget_exhausted';
detail: ContextBudgetExhaustedDetail;
contextBudget?: ContextBudgetDiagnostic;
};
export class AiSdkBackend implements AgentBackend {
readonly kind: BackendKind = 'ai-sdk';
readonly sessionId: string;
// Pulled out of the input for ergonomic access on hot paths.
private readonly input: AiSdkBackendInput;
private readonly newId: () => string;
private readonly now: () => number;
private readonly maxSteps: number | undefined;
private readonly providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise<void>;
private readonly modelAdapter: ModelAdapter;
private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime;
/**
* Every `send()` currently in flight on this backend.
*
* A set, not a map: nothing looks a scope up by turn id. Control calls that
* arrive without a turn (`stop`, and the two `respond*` methods) iterate, and
* each scope is already held by reference everywhere else.
*
* A backend instance is reused for a whole Session and RuntimeKernel lets one
* backend generation hold several concurrent runs, so per-turn state cannot
* live on the instance: whichever turn started or finished last would speak
* for all of them. That is exactly how #1990 crashed a turn — one turn's
* teardown cleared the run identity a *different* turn's tool execution then
* read back as absent.
*/
private readonly activeTurns = new Set<TurnScope>();
/**
* Request-shape baseline for change attribution. Session-scoped on purpose:
* it compares each provider request against whatever this backend sent last,
* across turns.
*/
private priorRequestShape: RequestShapeDiagnostic | undefined;
private readonly compaction: AiSdkCompaction;
/** Session-scoped running total, deliberately accumulated across turns. */
private cumulativeUsageCheckpoint: NormalizedAiSdkUsage | undefined;
private readonly memoryReplayMessageEvents = new WeakMap<ModelMessage, readonly string[]>();
constructor(input: AiSdkBackendInput) {
this.input = input;
this.sessionId = input.sessionId;
this.newId = input.newId ?? (() => crypto.randomUUID());
this.now = input.now ?? (() => Date.now());
this.maxSteps = input.maxSteps;
this.providerRetrySleep = input.providerRetrySleep ?? sleepForProviderRetry;
this.modelAdapter = new ModelAdapter({
sessionId: input.sessionId,
connection: input.connection,
apiKey: input.apiKey,
modelId: input.modelId,
modelFactory: input.modelFactory,
providerOptions: input.providerOptions,
newId: this.newId,
now: this.now,
...(input.openAiResponsesTransportState
? { openAiResponsesTransportState: input.openAiResponsesTransportState }
: {}),
});
this.compaction = new AiSdkCompaction({
input,
sessionId: this.sessionId,
now: this.now,
modelAdapter: this.modelAdapter,
createProviderRequestTracker: (trackerInput) =>
this.createProviderRequestTracker(trackerInput),
materializeRuntimeReplayPlan: (plan, imageBudget) =>
this.materializeRuntimeReplayPlan(plan, imageBudget),
canReplayProviderNative: (plan) => this.canReplayProviderNative(plan),
appendTurnTailPrompt: (content, turnTailPrompt) =>
this.appendTurnTailPrompt(content, turnTailPrompt),
});
if (
input.tools.some(
(tool) => tool.name === MEMORY_REMEMBER_TOOL_NAME || tool.name === MEMORY_EXTRACT_TOOL_NAME,
)
) {
throw new Error('Long-term Memory trigger tool names are reserved by Runtime');
}
const memoryTools = input.memoryExtraction
? buildMemoryExtractionTriggerTools({
capabilities: input.memoryExtraction,
snapshot: (trigger, context) => this.memorySourceSnapshot(trigger, context),
markExtractRequested: (context) => {
const scope = [...this.activeTurns].find(
(candidate) =>
candidate.turnId === context.turnId && candidate.runId === context.runId,
);
if (scope) scope.memoryExtractRequested = true;
},
...(modelUsesNativeOpenAiResponses(input.connection, input.modelId)
? { unsupportedReason: 'provider_unsupported' as const }
: {}),
})
: [];
this.toolAvailabilityRuntime = new ToolAvailabilityRuntime(
// The archive decoder is a runtime protocol tool, not a host binding:
// this session's placeholders name it, so this session advertises it.
bindToolResultArchiveDecoder([...input.tools, ...memoryTools], input.toolResultArchive),
input.toolAvailability,
buildInvalidMakaTool(),
);
}
private memorySourceSnapshot(
trigger: MemoryExtractionTrigger,
context: MakaToolContext,
): MemoryExtractionSourceSnapshot | undefined {
if (trigger !== 'remember') return undefined;
const scope = [...this.activeTurns].find(
(candidate) => candidate.turnId === context.turnId && candidate.runId === context.runId,
);
return scope
? this.memorySourceSnapshotFromScope(scope, {
trigger: 'remember',
toolCallId: context.toolCallId,
})
: undefined;
}
private memorySourceSnapshotFromScope(
scope: TurnScope,
boundary:
| { readonly trigger: 'remember'; readonly toolCallId: string }
| { readonly trigger: 'extract'; readonly terminalEventId: string },
): MemoryExtractionSourceSnapshot | undefined {
if (
!scope.runId ||
!scope.memorySourceMessages ||
!scope.memorySourceTools ||
!scope.memorySourceActiveTools
) {
return undefined;
}
const sourceMessages =
boundary.trigger === 'extract' && scope.finalAssistantText
? [
...scope.memorySourceMessages,
{
role: 'assistant' as const,
content: [{ type: 'text' as const, text: scope.finalAssistantText }],
} as ModelMessage,
]
: scope.memorySourceMessages;
const memoryProjection = projectMemoryConversationPrefix(
sourceMessages,
scope.memorySourceEventMessagePositions,
);
return {
...boundary,
sourceHeader: memoryExtractionModelHeader(this.input.header),
...(scope.memorySourceSystemPrompt
? { sourceSystemPrompt: scope.memorySourceSystemPrompt }
: {}),
sourceMessages: structuredClone(memoryProjection.messages),
...(memoryProjection.eventMessagePositions
? {
sourceEventMessagePositions: structuredClone(memoryProjection.eventMessagePositions),
}
: {}),
sourceTools: { ...scope.memorySourceTools },
sourceActiveTools: [...scope.memorySourceActiveTools],
...(this.input.providerOptions
? { sourceProviderOptions: structuredClone(this.input.providerOptions) }
: {}),
...(this.modelAdapter.maxOutputTokens() !== undefined
? { sourceMaxOutputTokens: this.modelAdapter.maxOutputTokens() }
: {}),
sessionId: this.sessionId,
runId: scope.runId,
turnId: scope.turnId,
workspaceKey: this.input.header.workspaceRoot,
};
}
/**
* One ToolRuntime per `send()`, bound to that turn's identity for its whole
* lifetime. The scope is passed in rather than read back so a tool settling
* long after its step still resolves this turn's watchdog, trace, and run.
*/
private createToolRuntime(identity: {
turnId: string;
runId: string | undefined;
invocationId: string | undefined;
hostedInteraction: HostedInteractionBridge | undefined;
orchestrationMode: EffectiveOrchestration['mode'];
scope: () => TurnScope;
}): ToolRuntime {
const input = this.input;
return new ToolRuntime({
sessionId: input.sessionId,
header: input.header,
connection: input.connection,
modelId: input.modelId,
appendMessage: input.appendMessage,
readExecutionBoundary: input.readExecutionBoundary,
createSandboxBoundaryRequest: input.createSandboxBoundaryRequest,
settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest,
newId: this.newId,
now: this.now,
getPermissionPauseTarget: () => identity.scope().watchdog,
turnId: identity.turnId,
...(identity.hostedInteraction ? { hostedInteraction: identity.hostedInteraction } : {}),
...(identity.runId ? { runId: identity.runId } : {}),
orchestrationMode: identity.orchestrationMode,
...(identity.invocationId ? { invocationId: identity.invocationId } : {}),
materializeDefaultToolResultOutput: ({ toolCallId, output }) =>
this.materializeToolResultOutput(identity.scope().imageBudget, output, false, toolCallId),
spawnChildAgent: input.spawnChildAgent,
spawnChildSession: input.spawnChildSession,
prepareChildAgentResume: input.prepareChildAgentResume,
resumeChildAgent: input.resumeChildAgent,
retryChildAgent: input.retryChildAgent,
listChildAgents: input.listChildAgents,
readChildAgentOutput: input.readChildAgentOutput,
getRunTrace: () => identity.scope().runTrace,
recordToolInvocation: input.recordToolInvocation,
runtimeCommitSink: input.runtimeCommitSink,
recordToolArtifacts: input.recordToolArtifacts,
});
}
private createCodeModeExecTool(
scope: TurnScope,
eventSink: DurableSessionEventSink,
): MakaTool<{ code: string }> {
return {
name: 'exec',
description: [
'Execute a bounded orchestration cell over the active tools.',
'Use tools.<name>(args), await dependent calls, and Promise.all for independent calls.',
'The sandbox has no process, filesystem, network, timer, eval, import, or cross-cell state.',
'Terminate by returning a JSON-serializable value. Failures return a structured diagnostic.',
].join(' '),
parameters: z.object({ code: z.string() }),
executionSemantics: 'exclusive_step',
nesting: 'direct_only',
recoveryMode: 'never_auto_retry',
impl: (args, context) => this.executeCodeModeCell(scope, eventSink, args.code, context),
};
}
// --------------------------------------------------------------------------
// manual history compaction
// --------------------------------------------------------------------------
async compactHistory(input: BackendCompactHistoryInput): Promise<BackendCompactHistoryResult> {
return this.compaction.compactHistory(input, this.priorRequestShape?.requestShapeHash);
}
// --------------------------------------------------------------------------
// send()
// --------------------------------------------------------------------------
/**
* Register one turn's execution scope, with its own ToolRuntime and identity.
*
* The ToolRuntime holds the scope by reference, so a tool settling long after
* its step still reaches this turn's watchdog, trace, and budget — never a
* successor's. The accessor exists only because the ToolRuntime is built while
* the scope it belongs to is still being constructed.
*/
private openTurnScope(input: BackendSendInput): TurnScope {
const orchestration =
input.orchestration ??
resolveEffectiveOrchestration(this.input.header.orchestrationMode, undefined);
let scope: TurnScope;
scope = new TurnScope(
input.turnId,
input.runId,
orchestration,
this.createToolRuntime({
turnId: input.turnId,
runId: input.runId,
invocationId: input.invocationId ?? input.runId,
hostedInteraction: input.hostedInteraction,
orchestrationMode: orchestration.mode,
scope: () => scope,
}),
);
this.activeTurns.add(scope);
return scope;
}
async *send(input: BackendSendInput): AsyncIterable<SessionEvent> {
// Registration and deregistration live in ONE frame, so a scope that made it
// into activeTurns is always removed exactly once — including when setup
// throws before the provider pump exists (a mismatched hosted Interaction
// Run, an unreadable attachment). A leaked scope would be permanent: nothing
// overwrites a Set entry, and stop()/dispose() only iterate it.
const scope = this.openTurnScope(input);
try {
yield* this.sendWithinScope(scope, input);
} finally {
await this.cleanupAfterTurn(scope);
}
}
private async *sendWithinScope(
scope: TurnScope,
input: BackendSendInput,
): AsyncIterable<SessionEvent> {
const turnId = input.turnId;
const maxSteps = input.maxSteps ?? this.maxSteps;
const toolRuntime = scope.toolRuntime;
const turnAbortController = scope.abortController;
const midTurnState = this.compaction.buildMidTurnCapacityCompactState(input);
const queue = new AsyncEventQueue<SessionEvent>();
const codeModeExecTool = this.createCodeModeExecTool(scope, queue);
// One AssistantMessage is flushed per provider step (not per turn), so the
// ledger records the text↔tool timeline at step granularity and each step's
// Anthropic thinking signature stays paired with its own thinking text. The
// turn's first step reuses this id; every later step rotates to a fresh one
// at its step boundary (see the stream loop below).
let currentStepMessageId = this.newId();
let stepText = '';
let stepTextProviderOptions: NonNullable<ModelMessage['providerOptions']> | undefined;
let stepTextPartStartOffset = 0;
let stepThinking = '';
let sawStepThinking = false;
let stepThinkingProviderOptions: NonNullable<ModelMessage['providerOptions']> | undefined;
let stepResponsesThinkingParts: AssistantThinkingPart[] = [];
let stepSignature: string | undefined;
const startedAt = this.now();
// Flush the current step's AssistantMessage (text + thinking) and the paired
// terminal thinking/text events, then clear the per-step accumulators.
// Persist when the step produced text OR reasoning — a thinking-only step
// (Anthropic's signed/omitted reasoning has empty text) still round-trips its
// signed block; a pure-tool step (no text, no thinking) writes nothing, so
// tool-only steps leave no placeholder assistant row. thinking_complete
// precedes text_complete so the read-model attaches this step's reasoning to
// this step's assistant row. Hoisted to send() scope so both the streaming
// path and the abort/error handler can flush a partial step.
const flushStep = async (): Promise<void> => {
const hasThinking = sawStepThinking || stepSignature !== undefined;
if (stepText.length === 0 && !hasThinking) return;
const stepId = currentStepMessageId;
const thinkingParts: AssistantThinkingPart[] =
stepResponsesThinkingParts.length > 0
? stepResponsesThinkingParts
: [
{
text: stepThinking,
...(stepSignature !== undefined ? { signature: stepSignature } : {}),
...(stepThinkingProviderOptions !== undefined
? { providerOptions: stepThinkingProviderOptions }
: {}),
},
];
const msg: AssistantMessage = {
type: 'assistant',
id: stepId,
turnId,
ts: this.now(),
text: stepText,
...(stepTextProviderOptions !== undefined
? { providerOptions: stepTextProviderOptions }
: {}),
modelId: this.input.modelId,
...(hasThinking
? {
thinking: {
text: stepThinking,
...(thinkingParts.length === 1 && thinkingParts[0]!.signature !== undefined
? { signature: thinkingParts[0]!.signature }
: {}),
...(thinkingParts.length === 1 && thinkingParts[0]!.providerOptions !== undefined
? { providerOptions: thinkingParts[0]!.providerOptions }
: {}),
...(thinkingParts.length > 1 ? { parts: thinkingParts } : {}),
},
}
: {}),
};
await this.input.appendMessage(msg);
if (hasThinking) {
for (const part of thinkingParts) {
queue.push({
type: 'thinking_complete',
id: this.newId(),
turnId,
ts: this.now(),
messageId: stepId,
text: part.text,
...(part.signature !== undefined ? { signature: part.signature } : {}),
// No sanitiser here, unlike the tool call below: these options are
// not the provider's object. `translateChunk` rebuilds reasoning
// metadata from two named string fields, so an omitted provider
// field cannot arrive as an explicit `undefined` and break the
// canonical encoding. Passing the provider's object through
// instead would need the same `stripUndefinedDeep` a tool call has.
...(part.providerOptions !== undefined
? { providerOptions: part.providerOptions }
: {}),
} satisfies ThinkingCompleteEvent);
}
}
queue.push({
type: 'text_complete',
id: this.newId(),
turnId,
ts: this.now(),
messageId: stepId,
text: stepText,
...(stepTextProviderOptions !== undefined
? { providerOptions: stepTextProviderOptions }
: {}),
} satisfies TextCompleteEvent);
scope.finalAssistantText = stepText.length > 0 ? stepText : undefined;
stepText = '';
stepTextProviderOptions = undefined;
stepTextPartStartOffset = 0;
stepThinking = '';
sawStepThinking = false;
stepThinkingProviderOptions = undefined;
stepResponsesThinkingParts = [];
stepSignature = undefined;
};
let tokenUsage: NormalizedAiSdkUsage | undefined;
let tokenUsageCostUsd: number | undefined;
// Per-send sum of every COMPLETED step's usage, merged at each finish-step
// boundary. When the send aborts (mid-turn exhaust, user stop, stream
// error) the SDK's cumulative `usage` promise may not resolve, but this sum is
// real provider-reported evidence for the steps that did finish — IF every
// completed step produced a usable sample. One unusable sample makes the
// sum a partial cost, and LlmCallRecord has no partial marker, so the flag
// fails the whole fallback closed (#972: incomplete usage is no usage).
let completedStepUsage: NormalizedAiSdkUsage | undefined;
let sawUnusableStepUsage = false;
// Input tokens from the last completed step — the actual prompt token count
// of the final API request. Used to compute contextRemaining for the TUI
// statusline ctx segment (#1067): contextRemaining = contextWindow - this.
// result.usage.inputTokens is cumulative across steps and would produce
// misleading >100% percentages, so the per-step value is captured here.
let lastStepInputTokens: number | undefined;
let streamStatus: LlmCallRecord['status'] = 'success';
let streamErrorClass: string | undefined;
let streamedFinishReason: string | undefined;
let runtimeSteps = 0;
let requestShapeForTelemetry: RequestShapeDiagnostic | undefined;
let promptSegmentsForTelemetry: PromptSegmentEstimate[] = [];
let contextBudgetForTelemetry: ContextBudgetDiagnostic | undefined;
let contextCompactedNoteWritten = false;
let contextCompactionFailedOpenNoteWritten = false;
const trace = new RunTrace({
sessionId: this.sessionId,
turnId,
connectionSlug: this.input.connection.slug,
providerId: this.input.connection.providerType,
modelId: this.input.modelId,
newId: this.newId,
now: this.now,
record: this.input.recordRunTrace,
});
scope.runTrace = trace;
trace.turnStarted({
orchestrationMode: scope.orchestration.mode,
orchestrationSource: scope.orchestration.source,
agentSwarmAuthorization: scope.orchestration.agentSwarmAuthorization,
});
if (this.input.planTraceContext) {
trace.emit('plan', 'plan_context_resolved', 'Plan context resolved', {
...this.input.planTraceContext,
});
if (this.input.planTraceContext.executionId) {
trace.emit('plan', 'plan_execution_started', 'Plan execution turn started', {
...this.input.planTraceContext,
});
}
}
if (this.input.sandboxDiagnosticsSnapshot) {
trace.sandboxContextResolved(
toSandboxRunTraceProjection(this.input.sandboxDiagnosticsSnapshot),
);
}
const providerRequestTracker = this.createProviderRequestTracker({
turnId,
callKind: 'main',
modelId: this.input.modelId,
runId: scope.runId,
});
const providerRequestTraceId = providerRequestTracker?.traceId;
// --- Resolve model (API key already attached at construct time) ---
let model: unknown;
try {
model = this.modelAdapter.resolveModel();
trace.modelResolved();
} catch (err) {
trace.modelResolveFailed(err);
queue.push(this.makeErrorEvent(turnId, err));
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'error',
} satisfies CompleteEvent);
queue.close();
yield* this.drain(queue);
return;
}
// --- Build the provider-visible schema set. Tool execution stays in Runtime. ---
// One runtime owns provider-visible tool availability (issue #37): the
// catalog, the `load_tools` connector, same-turn activation between requests,
// the execute-boundary gating, and the diagnostics. Seed prior-turn group
// activations from the durable ledger (the current turn is excluded — it has
// not committed yet) so a group loaded earlier stays advertised.
const requiredOrchestrationTools =
scope.orchestration.mode === 'swarm'
? new Set([
'agent_list',
'update_agent_graph',
'yield_agent_graph',
'agent_swarm_status',
'agent_output',
])
: scope.orchestration.mode === 'graph'
? new Set([
'agent_list',
'view_agent_graph',
'update_agent_graph',
'yield_agent_graph',
'agent_swarm_status',
'agent_output',
])
: new Set<string>();
const requestedToolMode: unknown =
input.toolMode === undefined ? DEFAULT_TOOL_MODE : input.toolMode;
if (!isToolMode(requestedToolMode)) {
throw new Error(`Invalid tool mode: ${String(requestedToolMode)}`);
}
const toolMode = requestedToolMode;
if (toolMode === 'code_mode' && this.input.tools.some((tool) => tool.name === 'exec')) {
throw new Error('Tool name "exec" is reserved for Code Mode.');
}
const plan = projectToolModePlan(
this.toolAvailabilityRuntime.prepare(
(input.runtimeContext ?? []).filter((event) => event.turnId !== turnId),
requiredOrchestrationTools,
),
toolMode,
codeModeExecTool,
);
const providerTools = plan.providerTools;
let activeToolResultPruneDiagnosticPatch: ActiveToolResultPruneDiagnosticPatch = {};
let activeCompactDiagnosticPatch: Partial<ContextBudgetDiagnostic> | undefined;
// Tool names the repair path matches a mis-cased call against — follows the
// current step's snapshot so a group loaded mid-turn is repairable on the
// step it becomes active, not routed to `invalid`.
const currentRepairToolNames = plan.currentRepairToolNames;
if (plan.gating) {
toolRuntime.setGating(plan.gating);
}
const modelTools: ModelToolSet = {};
for (const t of providerTools) {
modelTools[t.name] = t.providerTool
? { kind: 'provider', providerTool: t.providerTool }
: {
kind: 'function',
description: t.description,
inputSchema: t.parameters,
};
}
// --- Build messages from RuntimeEvent history and its compatibility projection. ---
const priorReplayResult = await this.buildPriorMessages(scope, input);
if (scope.aborted) {
queue.push({
type: 'abort',
id: this.newId(),
turnId,
ts: this.now(),
reason: 'user_stop',
} satisfies AbortEvent);
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'user_stop',
} satisfies CompleteEvent);
queue.close();
yield* this.drain(queue);
return;
}
if (priorReplayResult.status === 'context_budget_exhausted') {
trace.modelStreamCompleted('context_budget_exhausted');
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'context_budget_exhausted',
contextBudgetExhaustedDetail: priorReplayResult.detail,
} satisfies CompleteEvent);
queue.close();
yield* this.drain(queue);
return;
}
const priorReplay = priorReplayResult;
if (input.continuation && priorReplay.messages.length === 0) {
const replay = priorReplayFailureTrace(priorReplay);
const error = new ContinuationReplayEmptyError(replay.gate, replay.diagnosticCodes);
trace.modelStreamFailed(error.code, error, replay);
queue.push(this.makeErrorEvent(turnId, error));
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'error',
} satisfies CompleteEvent);
queue.close();
yield* this.drain(queue);
return;
}
if (midTurnState) {
// Roll-forward seed: the latest durable checkpoint (loaded or written at
// turn start) so a mid-turn summary only re-reads the newly folded span.
midTurnState.previousCheckpoint = priorReplay.latestHistoryCompactCheckpoint;
}
// --- Background pump: streamText → stream → normalize → queue ---
const pumpDone: Promise<void> = (async () => {
const watchdogState: { current: StreamWatchdog | null } = { current: null };
let providerRequestAbortController = new AbortController();
const watchdogTimeoutState: {
current: { readonly phase: StreamWatchdogPhase; readonly error: Error } | null;
} = { current: null };
const currentWatchdogTimeout = () => watchdogTimeoutState.current;
const consumeWatchdogTimeout = () => {
const timeout = watchdogTimeoutState.current;
watchdogTimeoutState.current = null;
return timeout;
};
try {
const startWatchdog = (): void => {
watchdogState.current?.stop();
const next = new StreamWatchdog({
now: this.now,
connectTimeoutMs: this.input.streamConnectTimeoutMs,
idleTimeoutMs: this.input.streamIdleTimeoutMs,
...this.input.streamWatchdogTimer,
onTimeout: (timeout) => {
const error = new Error(formatStreamWatchdogError(timeout));
watchdogTimeoutState.current = { phase: timeout.phase, error };
providerRequestAbortController.abort(error);
},
});
watchdogState.current = next;
scope.watchdog = next;
next.start();
};
const activeTools = plan.activeTools;
const systemPrompt = joinPromptFragments([
await this.resolveSystemPrompt(scope),
scope.orchestration?.mode === 'swarm' ? renderSwarmModePrompt() : undefined,
scope.orchestration?.mode === 'graph' ? renderGraphModePrompt() : undefined,
]);
const turnTailPrompt = input.continuation
? undefined
: joinPromptFragments([
await this.resolveTurnTailPrompt(turnId),
await this.resolveShellRunContextSummary(),
this.input.sandboxDiagnosticsSnapshot
? renderSandboxTurnTailPrompt(this.input.sandboxDiagnosticsSnapshot)
: undefined,
]);
const currentUserContent = input.continuation
? undefined
: await this.buildCurrentUserContent(
scope.imageBudget,
input.text,
input.attachments,
input.quotes,
input.headAnchorRuntimeEvent?.id,
);
const messages =
currentUserContent === undefined
? [...priorReplay.messages]
: [
...priorReplay.messages,
{
role: 'user' as const,
content: this.appendTurnTailPrompt(currentUserContent, turnTailPrompt),
} as ModelMessage,
];
const settledModelOutputs = new Map<string, ToolResultOutput>();
const loadDurableTurnEvents = async (): Promise<RuntimeEvent[]> => {
const loadTurnRuntimeEvents = this.input.loadTurnRuntimeEvents;
if (!loadTurnRuntimeEvents) {
throw new Error('durable current-run reader is required for tool continuation');
}
await queue.waitUntilConsumedThroughCurrent();
return (await loadTurnRuntimeEvents(turnId)).filter((event) => event.turnId === turnId);
};
const loadDurableTurnProjection = async (): Promise<ModelMessage[]> => {
const turnEvents = await loadDurableTurnEvents();
const replayPlan = buildRuntimeEventModelReplayPlan(turnEvents, {
toolActivityTurnIds: collectToolActivityTurnIds([
...(input.runtimeContext ?? []),
...turnEvents,
]),
});
if (
hasBlockingReplayDiagnostics(replayPlan) ||
(replayPlan.hasProviderNativeSemantics && !this.canReplayProviderNative(replayPlan))
) {
throw new Error('durable current-run projection is not replayable');
}
const anchorEventId = input.headAnchorRuntimeEvent?.id;
let decoratedCurrentUser = false;
const replayItems = replayPlan.items.map((item) => {
if (item.kind !== 'text' || item.role !== 'user') {
return item;
}
if (
anchorEventId !== undefined ? item.eventId !== anchorEventId : decoratedCurrentUser
) {
return item;
}
decoratedCurrentUser = true;
return {
...item,
content: this.appendTurnTailPrompt(item.content, turnTailPrompt) as string,
};
});
const currentTurnMessages = await this.materializeRuntimeReplayPlan(
{ ...replayPlan, items: replayItems },
scope.imageBudget,
settledModelOutputs,
);
return [...priorReplay.messages, ...currentTurnMessages];
};
const activeCompactionHeadAnchor =
messages[messages.length - 1]?.role === 'user'
? buildActiveCompactionHeadAnchor(
messages,
messages.length - 1,
this.input.contextBudget?.charsPerToken,
)
: undefined;
// Diagnostics describe the provider-visible (active) tool subset. A group
// loaded *this* turn expands that subset on later provider requests,
// so the durable cost record is refined against the final active set once
// the stream is consumed (see below). Both computations classify against
// the same pre-turn baseline. The availability runtime builds the tool
// diagnostic from the same per-step active set + schema-char measurement.
contextBudgetForTelemetry = priorReplay.contextBudget;
const priorShapeBaseline = this.priorRequestShape;
const computeTurnDiagnostics = (active: readonly string[]) => {
const toolSchemaChars = toolSchemaCharsForDiagnostics(providerTools, active);
const toolAvailabilityDiagnostic = plan.diagnostics(active, toolSchemaChars);
return {
promptSegments: buildPromptSegmentEstimates({
systemPrompt,
toolSchemaChars,
toolCount: active.length,
priorMessages: priorReplay.messages,
priorRuntimeEventCount: priorReplay.runtimeEventCount,
currentUserContent: input.continuation
? ''
: formatTextWithInlineRefs(input.text, {
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
...(input.quotes !== undefined ? { quotes: input.quotes } : {}),
}),
turnTailPrompt,
}),
requestShape: computeRequestShapeDiagnostic(
{
connection: this.input.connection,
modelId: this.input.modelId,
systemPrompt,
providerOptions: this.input.providerOptions,
providerTools,
activeTools: active,
priorMessages: priorReplay.messages,
...(toolAvailabilityDiagnostic !== undefined
? { toolAvailability: toolAvailabilityDiagnostic }
: {}),
},
priorShapeBaseline,
),
};
};
// Publish a diagnostics snapshot to every telemetry sink at once so the
// cost record, the prefix baseline, and the context-budget high-water
// "after" hash never diverge — they must all describe the same active
// tool set. A same-turn deferred load re-publishes the final snapshot
// below; the high-water "before" hash is the pre-turn baseline, set once.
let turnDiagnostics = computeTurnDiagnostics(activeTools);
const publishTurnDiagnostics = (diag: typeof turnDiagnostics): void => {
turnDiagnostics = diag;
promptSegmentsForTelemetry = diag.promptSegments;
requestShapeForTelemetry = diag.requestShape;
this.priorRequestShape = diag.requestShape;
if (priorReplay.contextBudget?.highWaterReason) {
priorReplay.contextBudget.highWaterRequestShapeHashAfter =
diag.requestShape.requestShapeHash;
}
};
// Step-0 (turn-start) view: literally what the first request carries, so
// the stream-start trace reports it as the prefix actually sent.
if (priorReplay.contextBudget?.highWaterReason) {
priorReplay.contextBudget.highWaterRequestShapeHashBefore =
priorShapeBaseline?.requestShapeHash;
}
publishTurnDiagnostics(turnDiagnostics);
trace.modelStreamStarted(activeTools, {
systemPromptHash: turnDiagnostics.requestShape.componentHashes.systemPromptHash,
prefixHash: turnDiagnostics.requestShape.prefixHash,
prefixChangeReason: turnDiagnostics.requestShape.prefixChangeReason,
requestShapeHash: turnDiagnostics.requestShape.requestShapeHash,
requestShapeChangeReason: turnDiagnostics.requestShape.requestShapeChangeReason,
...(turnDiagnostics.requestShape.toolSchemaChangeReason !== undefined
? { toolSchemaChangeReason: turnDiagnostics.requestShape.toolSchemaChangeReason }
: {}),
...(turnDiagnostics.requestShape.toolAvailability !== undefined
? { toolAvailability: turnDiagnostics.requestShape.toolAvailability }
: {}),
promptSegments: turnDiagnostics.promptSegments,
...(priorReplay.contextBudget ? { contextBudget: priorReplay.contextBudget } : {}),
});
const stepRequestShapeHash = (
stepMessages: readonly ModelMessage[],
activeToolsForStep: readonly string[] | undefined,
): string =>
computeRequestShapeDiagnostic(
{
connection: this.input.connection,
modelId: this.input.modelId,
systemPrompt,
providerOptions: this.input.providerOptions,
providerTools,
activeTools: activeToolsForStep ?? plan.activeTools,
priorMessages: stepMessages,
},
priorShapeBaseline,
).requestShapeHash;
const activeCompactHook = composeActiveCompactionProjection(
this.compaction.buildSemanticCompactProjection(
turnId,
model,
input.runtimeContext,
activeCompactionHeadAnchor,
(messagesForStep, activeToolsForStep) =>
stepRequestShapeHash(messagesForStep, activeToolsForStep),
(patch) => {
activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches(
activeCompactDiagnosticPatch,
patch,
);
},
scope,
turnAbortController.signal,
),
this.compaction.buildActiveFullCompactProjection(
turnId,
input.runtimeContext,
activeCompactionHeadAnchor,
(messagesForStep, activeToolsForStep) =>
stepRequestShapeHash(messagesForStep, activeToolsForStep),
(patch) => {
activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches(
activeCompactDiagnosticPatch,
patch,
);
},
),
);
// Deterministic priority on a capacity-replaced step: the hard window
// invariant owns the projection, so semantic/active-full compaction
// yields for that step (recorded as a decision) instead of running a
// second summarizer over the same request.
const activeCompactAfterMidTurn =
activeCompactHook && midTurnState
? (options: RequestProjectionContext) => {
if (midTurnState.replacedStepNumber === options.stepNumber) {
activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches(
activeCompactDiagnosticPatch,
compactionDecisionDiagnosticPatch({
stage: 'activeStep',
sourceKind: 'providerMessages',
decision: 'unchanged',
boundaryKind: 'historyCompact',
reason: 'mid_turn_capacity_precedence',
skippedReasonCounts: { mid_turn_capacity_precedence: 1 },
}),
);
return undefined;
}
return activeCompactHook(options);
}
: activeCompactHook;
const onMidTurnDiagnosticPatch = (patch: Partial<ContextBudgetDiagnostic>): void => {
activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches(
activeCompactDiagnosticPatch,
patch,
);
};
const midTurnSystemPromptChars = systemPrompt?.length ?? 0;
const midTurnCapacityHook = this.compaction.buildMidTurnCapacityCompactProjection(
turnId,
midTurnState,
queue,
providerTools,
() => currentRepairToolNames(),
turnTailPrompt,
midTurnSystemPromptChars,
onMidTurnDiagnosticPatch,
scope,
turnAbortController.signal,
);
// When mid-turn capacity compaction is active, the prune must also cover
// the newest completed step; see collectPrunableCompletedStepToolCallIds.
const activeToolResultPruneIncludesNewestStep = midTurnState !== undefined;
const activeToolResultPruneHook = this.compaction.buildActiveToolResultPruneProjection(
turnId,
activeToolResultPruneIncludesNewestStep,
(patch) => {
activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches(
activeToolResultPruneDiagnosticPatch,
patch,
);
},
);
const shapedProjection = composeRequestProjection(
plan.projectActiveTools,
midTurnCapacityHook,
activeToolResultPruneHook,
activeCompactAfterMidTurn,
);
// The verdict owner wraps the WHOLE shaping pipeline: hooks shape, one
// owner measures the final payload and decides pass/terminate.
const requestProjection =
midTurnState && midTurnCapacityHook && shapedProjection
? this.compaction.buildMidTurnFinalRequestVerdict({
shaped: shapedProjection,
reentry: composeRequestProjection(
undefined,
midTurnCapacityHook,
activeToolResultPruneHook,
)!,
state: midTurnState,
providerTools,
fallbackActiveTools: () => currentRepairToolNames(),
charsPerToken: this.input.contextBudget?.charsPerToken ?? 4,
systemPromptChars: midTurnSystemPromptChars,
onDiagnosticPatch: onMidTurnDiagnosticPatch,
abortController: turnAbortController,
})
: shapedProjection;
const completedProviderSteps: RequestProjectionContext['completedSteps'][number][] = [];
let requestMessages: ModelMessage[] = messages;
let overflowRetryUsed = false;
let result: ModelStreamResult;
let finishReason: ModelFinishReason = 'stop';
agentLoop: for (;;) {
await this.drainSteeringInto(scope, input, queue);
if (this.input.loadTurnRuntimeEvents) {
requestMessages = await loadDurableTurnProjection();
} else {
const missingSteering = steeringMessagesMissingFromBase(
scope.injectedSteeringMessages,
requestMessages,
);
if (missingSteering.length > 0)
requestMessages = [...requestMessages, ...missingSteering];
}
const shaped = requestProjection
? await requestProjection({
completedSteps: completedProviderSteps,
stepNumber: runtimeSteps,
model,
messages: requestMessages,
})
: undefined;
if (midTurnState?.exhaustedDetail) {
throw new Error(
`context budget exhausted before provider dispatch: ${midTurnState.exhaustedDetail}`,
);
}
const projectedMessages = shaped?.messages ?? requestMessages;
const finalChildSummaryStep =
this.input.header.collaborationMode === 'agent' &&
maxSteps !== undefined &&
maxSteps > 1 &&
runtimeSteps === maxSteps - 1 &&
completedProviderSteps.length > 0;
const activeToolsForRequest = finalChildSummaryStep
? []
: (shaped?.activeTools ?? currentRepairToolNames());
const requestSystemPrompt = finalChildSummaryStep
? joinPromptFragments([systemPrompt, CHILD_STEP_BUDGET_FINALIZATION_PROMPT])
: systemPrompt;
providerRequestTracker?.setStep(runtimeSteps);
let attemptMessages = projectedMessages;
let providerAttempt = 1;
let idleWatchdogRetryCount = 0;
const returnedToolCalls: ToolCallPart[] = [];
let providerToolActivityCount = 0;
const providerToolInputs = new Map<string, unknown>();
let providerStepUsage: NormalizedUsage | undefined;
for (;;) {
providerRequestAbortController = new AbortController();
watchdogTimeoutState.current = null;
startWatchdog();
// Monotonic facts for this physical request. The step accumulators
// are cleared after flushStep(), so they cannot decide whether a
// later stream failure is safe to retry.
let attemptSawText = false;
let attemptSawThinking = false;
let attemptSawToolActivity = false;
let attemptSawContinuationMetadata = false;
let attemptReachedStepBoundary = false;
const attemptHasNoObservableOutput = () =>
!attemptSawText &&
!attemptSawThinking &&
!attemptSawToolActivity &&
!attemptSawContinuationMetadata &&
!attemptReachedStepBoundary;
const attemptCanRecoverFromIdleTimeout = () =>
!attemptSawText &&
!attemptSawToolActivity &&
!attemptSawContinuationMetadata &&
!attemptReachedStepBoundary;
scope.memorySourceMessages = [...attemptMessages];
scope.memorySourceEventMessagePositions =
this.memoryEventMessagePositions(attemptMessages);
scope.memorySourceSystemPrompt = requestSystemPrompt;
scope.memorySourceTools = modelTools;
scope.memorySourceActiveTools = [...activeToolsForRequest];
scope.finalAssistantText = undefined;
scope.codeModeTools =
toolMode === 'code_mode'
? nestableToolSnapshot(providerTools, activeToolsForRequest)
: undefined;
const requestWatchdog = watchdogState.current;
result = await this.modelAdapter.startStream({
model,
messages: attemptMessages,
tools: modelTools,
activeTools: activeToolsForRequest,
onStreamActivity: () => requestWatchdog?.markActivity(),
repairToolCall: async ({
toolCall,
error,
}: {
toolCall: RepairableAiSdkToolCall;
error: unknown;
}) => {
return repairMakaToolCall({
toolCall,
availableToolNames: currentRepairToolNames(),
toolParameters: (name) =>
providerTools.find((candidate) => candidate.name === name)?.parameters,
toolCategoryHint: (name) =>
providerTools.find((candidate) => candidate.name === name)?.categoryHint,
error,
});
},
system: requestSystemPrompt,
abortSignal: AbortSignal.any([
turnAbortController.signal,
providerRequestAbortController.signal,
]),
...(providerRequestTracker ? { providerRequestTracker } : {}),
continuationKey: scope.turnId,
});
let streamFailure: unknown;
let sawStreamError = false;
try {
for await (const event of result.events) {
if (scope.aborted) break;
if (event.kind === 'error') {
// A request-level error ends this stream; capture it and stop
// consuming (the synthesized trailer carries no real step) so
// the recovery decision runs on the outcome, not the trailer.
streamFailure = event.failure;
sawStreamError = true;
break;
}
if (event.kind === 'finish' || event.kind === 'step-finish') {
attemptReachedStepBoundary = true;
}
if (event.kind === 'step-finish') {
// Step boundary: AI SDK 7 delimits steps with `finish-step`
// (and `step-finish` for legacy replay fixtures); the adapter
// reduces both to this event. A duplicate boundary is harmless:
// the second flush no-ops (accumulators already cleared) and one
// extra id rotation just discards an unused id.
runtimeSteps += 1;
const stepUsage = event.usage;
providerStepUsage = stepUsage;
if (!stepUsage) sawUnusableStepUsage = true;
// Fail closed: reset on every step boundary so a missing final
// step's usage does not leave a stale value from an earlier step.
lastStepInputTokens = stepUsage?.inputTokens;
if (stepUsage) {
completedStepUsage = mergeNormalizedUsage(completedStepUsage, stepUsage);
this.cumulativeUsageCheckpoint = mergeNormalizedUsage(
this.cumulativeUsageCheckpoint,
stepUsage,
);
await this.input.recordUsageCheckpoint?.({
...this.cumulativeUsageCheckpoint,
costUsd: this.computeTokenUsageCostUsd(this.cumulativeUsageCheckpoint),
});
}
}
if (event.kind === 'finish' || event.kind === 'step-finish') {
streamedFinishReason = event.finishReason ?? streamedFinishReason;
}
if (event.kind === 'text-start') {
stepTextPartStartOffset = stepText.length;
} else if (event.kind === 'text') {
stepText += event.text;
if (event.text.length > 0) attemptSawText = true;
queue.push({
type: 'text_delta',
id: this.newId(),
turnId,
ts: this.now(),
messageId: currentStepMessageId,
text: event.text,
} satisfies TextDeltaEvent);
} else if (event.kind === 'text-metadata') {
attemptSawContinuationMetadata = true;
stepTextProviderOptions = mergeTextProviderOptions(
stepTextProviderOptions,
stripUndefinedDeep(event.providerOptions) as NonNullable<
ModelMessage['providerOptions']
>,
stepTextPartStartOffset,
);
} else if (event.kind === 'thinking') {
sawStepThinking = true;
stepThinking += event.text;
if (event.text.length > 0) attemptSawThinking = true;
if (event.providerOptions !== undefined) {
if (event.providerOptionsOrigin !== 'maka_transport') {
attemptSawContinuationMetadata = true;
}
stepThinkingProviderOptions = event.providerOptions;
}
const openai = event.providerOptions?.openai;
const itemId =
openai && typeof openai === 'object' && !Array.isArray(openai)
? (openai as { itemId?: unknown }).itemId
: undefined;
if (typeof itemId === 'string' && itemId.length > 0) {
let part = stepResponsesThinkingParts.find(
(candidate) =>
(candidate.providerOptions?.openai as { itemId?: unknown } | undefined)
?.itemId === itemId,
);
if (!part) {
part = {
text:
stepResponsesThinkingParts.length === 0 && event.text.length === 0
? stepThinking
: '',
providerOptions: event.providerOptions,
};
stepResponsesThinkingParts.push(part);
} else {
part.providerOptions = event.providerOptions;
}
part.text += event.text;
} else if (stepResponsesThinkingParts.length > 0) {
stepResponsesThinkingParts.at(-1)!.text += event.text;
}
queue.push({
type: 'thinking_delta',
id: this.newId(),
turnId,
ts: this.now(),
messageId: currentStepMessageId,
text: event.text,
} satisfies ThinkingDeltaEvent);
} else if (event.kind === 'thinking-signature') {
attemptSawContinuationMetadata = true;
stepSignature = event.signature;
} else if (event.kind === 'provider-tool-input') {
// The provider has started its own tool. Even without a
// final tool-call/result event, retrying can repeat external
// work that the Runtime cannot observe or reconcile.
attemptSawToolActivity = true;
} else if (event.kind === 'tool-call') {
attemptSawToolActivity = true;
if (event.toolCall.providerExecuted) {
providerToolActivityCount += 1;
providerToolInputs.set(event.toolCall.toolCallId, event.toolCall.input);
queue.push({
type: 'tool_start',
id: this.newId(),
turnId,
ts: this.now(),
toolUseId: event.toolCall.toolCallId,
toolName: event.toolCall.toolName,
args: event.toolCall.input,
providerExecuted: true,
activityKind: 'websearch',
displayName: 'Web search',
stepId: currentStepMessageId,
...(event.toolCall.providerOptions !== undefined
? {
providerOptions: stripUndefinedDeep(event.toolCall.providerOptions),
}
: {}),
} satisfies ToolStartEvent);
} else {
returnedToolCalls.push(event.toolCall);
}
} else if (event.kind === 'provider-tool-result') {
attemptSawToolActivity = true;
providerToolActivityCount += 1;
const providerOutput = stripUndefinedDeep(event.output);
queue.push({
type: 'tool_result',
id: this.newId(),
turnId,
ts: this.now(),
toolUseId: event.toolCallId,
providerExecuted: true,
...(providerOutput !== undefined ? { providerOutput } : {}),
isError: event.isError === true,
content: providerToolResultContent(
event.toolName,
providerOutput,
providerToolInputs.get(event.toolCallId),
),
} satisfies ToolResultEvent);
providerToolInputs.delete(event.toolCallId);
} else if (event.kind === 'step-finish') {
// The step's text/thinking deltas are all in (the stream is
// drained in order), so flush this step's AssistantMessage and
// rotate to a fresh id for the next step. Tool settlement
// below receives this step's pre-rotation id, so durable replay
// can regroup calls with this reasoning/text.
await flushStep();
if (midTurnState) {
// Durability clock: step N's thinking/text completion events
// are enqueued by flushStep just above, so only after this
// boundary can a seq-ack wait for step N mean anything. Wake
// waiters AFTER the increment or they would re-check a stale
// count and sleep.
midTurnState.flushedSteps += 1;
queue.wake();
}
}
}
} catch (error) {
streamFailure = error;
sawStreamError = true;
}
watchdogState.current?.stop();
// This timeout belongs to the physical request that just settled.
// Consume it before recovery/flush work: a later persistence error
// must not be reported as the already-handled watchdog timeout.
const settledWatchdogTimeout = consumeWatchdogTimeout();
if (!sawStreamError && settledWatchdogTimeout) {
streamFailure = settledWatchdogTimeout.error;
sawStreamError = true;
}
if (sawStreamError && !scope.aborted) {
const attemptFailure = settledWatchdogTimeout?.error ?? streamFailure;
if (scope.loopStopRequested) throw attemptFailure;
// A retry is a fresh provider request that would run at least one
// more step; with the send-level budget already spent there is
// nothing left to grant it, so the error is terminal.
const stepBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps;
const recovered =
stepBudgetRemains && attemptHasNoObservableOutput()
? await this.compaction.recoverFromOverflowError({
error: attemptFailure,
retryAlreadyUsed: overflowRetryUsed,
midTurnState,
turnId,
currentMessages: attemptMessages,
providerTools,
activeTools: activeToolsForRequest,
systemPromptChars: midTurnSystemPromptChars,
turnTailPrompt,
queue,
onDiagnosticPatch: onMidTurnDiagnosticPatch,
origin: scope,
abortSignal: turnAbortController.signal,
})
: undefined;
if (recovered) {
overflowRetryUsed = true;
// Recovery rebuilds the request from the durable ledger, whose
// tool results intentionally retain their full bodies. Re-enter
// the active-result projection before dispatch so an archived
// result cannot reappear in provider context on the retry.
const recoveredProjection = activeToolResultPruneHook
? await activeToolResultPruneHook({
completedSteps: completedProviderSteps,
stepNumber: runtimeSteps,
model,
messages: recovered.messages,
activeTools: activeToolsForRequest,
})
: undefined;
attemptMessages = recoveredProjection?.messages ?? recovered.messages;
continue;
}
const failure = this.modelAdapter.normalizeFailure(attemptFailure);
const idleWatchdogRecovery =
settledWatchdogTimeout?.phase === 'idle' &&
idleWatchdogRetryCount < MAX_IDLE_WATCHDOG_RETRIES_PER_STEP &&
attemptCanRecoverFromIdleTimeout();
if (
(failure.retryable || idleWatchdogRecovery) &&
providerAttempt < MAX_PROVIDER_ATTEMPTS_PER_STEP &&
stepBudgetRemains &&
(attemptHasNoObservableOutput() || idleWatchdogRecovery)
) {
if (idleWatchdogRecovery) {
idleWatchdogRetryCount += 1;
if (stepThinking.length > 0) {
await flushStep();
currentStepMessageId = this.newId();
}
}
// The failed request did not return authoritative usage. Keep
// effectiveness recoverable, but fail final metering closed.
sawUnusableStepUsage = true;
const delayMs = providerRetryDelayMs(providerAttempt, failure.retryAfterMs);
const nextAttempt = providerAttempt + 1;
const maxAttempts = idleWatchdogRecovery
? nextAttempt
: MAX_PROVIDER_ATTEMPTS_PER_STEP;
const reason = providerRetryReason(failure.kind);
queue.push({
type: 'provider_retry',
id: this.newId(),
turnId,
ts: this.now(),
phase: 'scheduled',
attempt: nextAttempt,
maxAttempts,
delayMs,
reason,
} satisfies ProviderRetryEvent);
await this.providerRetrySleep(delayMs, turnAbortController.signal);
providerAttempt = nextAttempt;
queue.push({
type: 'provider_retry',
id: this.newId(),
turnId,
ts: this.now(),
phase: 'started',
attempt: providerAttempt,
maxAttempts,
reason,
} satisfies ProviderRetryEvent);
continue;
}
// Unrecoverable (not context-length, latch spent, no seam, or no
// safe fold): surface the real provider error via the terminal
// handler — never a fabricated success.
throw attemptFailure;
}
break;
}
// If the stream loop exited because stop() flipped scope.aborted while a
// provider kept yielding after abort instead of throwing, route to the
// abort handling below. Without this, the post-stream success path would
// persist a partial assistant turn and emit a false end_turn completion.
if (scope.aborted) {
throw Object.assign(new Error('aborted'), { name: 'AbortError' });
}
// Mid-turn exhaustion aborts the SDK stream, but streamText ends
// gracefully on abort instead of throwing; route to the explicit
// outcome regardless of how the stream wound down.
if (midTurnState?.exhaustedDetail) {
throw Object.assign(
new Error(`mid-turn context budget exhausted: ${midTurnState.exhaustedDetail}`),
{ name: 'MidTurnContextBudgetExhaustedError' },
);
}
// Catch-all: flush any residual step content if the provider closed the
// stream without a trailing `finish-step` for the last step.
const providerStepId = currentStepMessageId;
await flushStep();
// The settled promise reports only the SDK's unified enum; the stream
// events carry what the provider itself said, which is strictly more
// specific and is already what telemetry prefers below. Reading the
// same value here keeps the turn's outcome and its record from
// disagreeing about why the stream ended.
finishReason =
streamedFinishReason ?? (await result.finishReason.catch(() => 'stop')) ?? 'stop';
await queue.waitUntilConsumedThroughCurrent();
if (returnedToolCalls.length > 0) {
const continuationBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps;
if (continuationBudgetRemains && !this.input.loadTurnRuntimeEvents) {
throw new Error('durable current-run reader is required for tool continuation');
}
if (this.input.loadTurnRuntimeEvents) {
// Queue consumption alone does not prove that the latest assistant
// facts remain readable. Fail before any external tool side effect
// when the authoritative ledger became unavailable after the step.
await loadDurableTurnEvents();
}
const toolsByName = new Map(providerTools.map((tool) => [tool.name, tool]));
const settlementOutcomes = await Promise.allSettled(
returnedToolCalls.map(async (toolCall) => {
if (toolCall.providerExecuted) {
throw new Error(
`Provider-executed tool call "${toolCall.toolName}" is outside the main-agent tool loop`,
);
}
const requestedTool = toolsByName.get(toolCall.toolName);
const tool = requestedTool ?? toolsByName.get(INVALID_TOOL_NAME);
if (!tool) throw new Error('Runtime invalid-tool fallback is unavailable');
return await toolRuntime.settleToolCall({
tool,
turnId,
stepId: providerStepId,
toolCallId: toolCall.toolCallId,
// Provider metadata is persisted verbatim into an immutable
// RuntimeEvent, and a field the response did not carry
// arrives as an explicit `undefined` — which JSON drops, so
// the event no longer reads back as it was written and the
// store refuses it. One refusal took every tool-calling turn
// with it.
...(toolCall.providerOptions !== undefined
? { providerOptions: stripUndefinedDeep(toolCall.providerOptions) }
: {}),
input:
requestedTool !== undefined
? toolCall.input
: {
tool: toolCall.toolName,
error: 'returned tool is unavailable',
},
abortSignal: turnAbortController.signal,
eventSink: queue,
});
}),
);
const rejectedSettlement = settlementOutcomes.find(
(outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected',
);
if (rejectedSettlement) throw rejectedSettlement.reason;
const settlements = settlementOutcomes.map((outcome) => {
// A rejected settlement was handled above, so preserving the
// original array shape also preserves tool-call identity by index.
if (outcome.status === 'rejected') throw outcome.reason;
return outcome.value;
});
for (let index = 0; index < settlements.length; index += 1) {
const settlement = settlements[index]!;
const toolCall = returnedToolCalls[index];
if (isPlanToolResult(settlement.result)) {
this.handlePlanToolResult(scope, settlement.result, queue);
}
if (
returnedToolCalls.length === 1 &&
toolCall?.toolName === YIELD_AGENT_GRAPH_TOOL_NAME &&
isAgentGraphYieldToolResult(settlement.result)
) {
this.handleAgentGraphYieldToolResult(scope, settlement.result);
}
}
await queue.waitUntilConsumedThroughCurrent();
for (let index = 0; index < returnedToolCalls.length; index += 1) {
const toolCall = returnedToolCalls[index];
const settlement = settlements[index];
if (toolCall && settlement) {
settledModelOutputs.set(toolCall.toolCallId, settlement.modelOutput);
}
}
const continuationWillRun =
(maxSteps === undefined || runtimeSteps < maxSteps) &&
!scope.loopStopRequested &&
!scope.aborted;
if (
continuationWillRun &&
this.modelAdapter.continuationResponsePending(scope.turnId)
) {
const persistedProjection = await loadDurableTurnProjection();
const responseMessages = persistedOpenAiResponsesStepMessages(
attemptMessages,
persistedProjection,
returnedToolCalls.map((toolCall) => toolCall.toolCallId),
);
if (responseMessages) {
this.modelAdapter.recordContinuationResponse(scope.turnId, responseMessages);
} else {
this.modelAdapter.clearContinuation(scope.turnId);
}
}
}
completedProviderSteps.push({
toolCalls: returnedToolCalls,
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
break agentLoop;
}
// Same-turn deferred load: request projection expanded the provider tool set on
// later steps, so refine the durable cost record + prefix baseline against
// the final active set — otherwise this turn under-reports the loaded
// schema and the cache reset would surface a turn late. No-op when nothing
// loaded this turn (the active set length is unchanged; the ratchet only
// grows it).
const finalActiveTools = currentRepairToolNames();
if (finalActiveTools.length !== activeTools.length) {
publishTurnDiagnostics(computeTurnDiagnostics(finalActiveTools));
}
// Final usage event. Each adapter result covers one provider request.
// The send-level owner is `completedStepUsage`, which spans every
// Runtime loop step and retry. Recording only the final result would
// silently drop prior requests. An unusable sample in ANY request fails
// the whole record closed (#972).
try {
const attemptTotalUsage = await result.usage;
tokenUsage = sawUnusableStepUsage ? undefined : (completedStepUsage ?? attemptTotalUsage);
if (tokenUsage) {
const systemPromptHash = turnDiagnostics.requestShape.componentHashes.systemPromptHash;
tokenUsageCostUsd = this.computeTokenUsageCostUsd(tokenUsage);
const contextBudgetForUsage = contextBudgetWithActiveProjectionDiagnostics(
contextBudgetForTelemetry,
activeToolResultPruneDiagnosticPatch,
activeCompactDiagnosticPatch,
);
const tu: TokenUsageMessage = {
type: 'token_usage',
id: this.newId(),
turnId,
ts: this.now(),
input: tokenUsage.inputTokens,
output: tokenUsage.outputTokens,
cacheHitInput: tokenUsage.cacheHitInputTokens,
cacheMissInput: tokenUsage.cacheMissInputTokens,
cacheMissInputSource: tokenUsage.cacheMissInputSource,
cacheWriteInput: tokenUsage.cacheWriteInputTokens,
reasoning: tokenUsage.reasoningTokens,
total: tokenUsage.totalTokens,
...(tokenUsage.rawFinishReason !== undefined
? { rawFinishReason: tokenUsage.rawFinishReason }
: {}),
...(runtimeSteps > 0 ? { runtimeSteps } : {}),
...(tokenUsage.cachedInputTokens > 0
? { cacheRead: tokenUsage.cachedInputTokens }
: {}),
...(tokenUsage.cacheWriteInputTokens > 0
? { cacheCreation: tokenUsage.cacheWriteInputTokens }
: {}),
...(tokenUsageCostUsd !== undefined ? { costUsd: tokenUsageCostUsd } : {}),
systemPromptHash,
prefixHash: turnDiagnostics.requestShape.prefixHash,
prefixChangeReason: turnDiagnostics.requestShape.prefixChangeReason,
requestShapeHash: turnDiagnostics.requestShape.requestShapeHash,
requestShapeChangeReason: turnDiagnostics.requestShape.requestShapeChangeReason,
promptSegments: turnDiagnostics.promptSegments,
...(contextBudgetForUsage ? { contextBudget: contextBudgetForUsage } : {}),
...(providerRequestTraceId ? { providerRequestTraceId } : {}),
};
await this.input.appendMessage(tu).catch(() => {});
if (
!contextCompactionFailedOpenNoteWritten &&
shouldAppendContextCompactionFailedOpenNote(contextBudgetForUsage)
) {
contextCompactionFailedOpenNoteWritten = true;
const note: SystemNoteMessage = {
type: 'system_note',
id: this.newId(),
turnId,
ts: this.now(),
kind: 'context_compaction_failed_open',
};
await this.input.appendMessage(note).catch(() => {});
}
if (
!contextCompactedNoteWritten &&
shouldAppendContextCompactedNote(contextBudgetForUsage)
) {
contextCompactedNoteWritten = true;
const note: SystemNoteMessage = {
type: 'system_note',
id: this.newId(),
turnId,
ts: this.now(),
kind: 'context_compacted',
};
await this.input.appendMessage(note).catch(() => {});
}
const contextRemainingForUsage = (() => {
const contextWindow = resolveSelectedModelContextWindow(
this.input.connection,
this.input.modelId,
);
if (lastStepInputTokens !== undefined && contextWindow !== undefined) {
return Math.max(0, contextWindow - lastStepInputTokens);
}
return undefined;
})();
queue.push({
type: 'token_usage',
id: this.newId(),
turnId,
ts: this.now(),
input: tokenUsage.inputTokens,
output: tokenUsage.outputTokens,
cacheHitInput: tokenUsage.cacheHitInputTokens,
cacheMissInput: tokenUsage.cacheMissInputTokens,
cacheMissInputSource: tokenUsage.cacheMissInputSource,
cacheWriteInput: tokenUsage.cacheWriteInputTokens,
reasoning: tokenUsage.reasoningTokens,
total: tokenUsage.totalTokens,
...(tokenUsage.rawFinishReason !== undefined
? { rawFinishReason: tokenUsage.rawFinishReason }
: {}),
...(runtimeSteps > 0 ? { runtimeSteps } : {}),
...(tokenUsage.cachedInputTokens > 0
? { cacheRead: tokenUsage.cachedInputTokens }
: {}),
...(tokenUsage.cacheWriteInputTokens > 0
? { cacheCreation: tokenUsage.cacheWriteInputTokens }
: {}),
...(tokenUsageCostUsd !== undefined ? { costUsd: tokenUsageCostUsd } : {}),
systemPromptHash,
prefixHash: turnDiagnostics.requestShape.prefixHash,
prefixChangeReason: turnDiagnostics.requestShape.prefixChangeReason,
requestShapeHash: turnDiagnostics.requestShape.requestShapeHash,
requestShapeChangeReason: turnDiagnostics.requestShape.requestShapeChangeReason,
promptSegments: turnDiagnostics.promptSegments,
...(contextBudgetForUsage ? { contextBudget: contextBudgetForUsage } : {}),
...(contextRemainingForUsage !== undefined
? { contextRemaining: contextRemainingForUsage }
: {}),
...(providerRequestTraceId ? { providerRequestTraceId } : {}),
} satisfies TokenUsageEvent);
}
} catch {
// best-effort; ai-sdk usage promise may reject on abort
}
// Nothing may await between this check and terminal emission: Stop must
// win even when it arrives during post-stream usage persistence.
if (scope.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
const stopReason =
scope.loopStopReason ??
(maxSteps !== undefined && finishReason === 'tool-calls'
? 'step_limit'
: this.mapFinishReason(finishReason));
if (stopReason === 'error') {
// Reaching a failed terminal without anything having been thrown.
// Every other `stopReason: 'error'` here comes out of the catch below
// with an error event and a failed trace behind it, and the session's
// `lastError` and the request ledger are fed by exactly those. Ending
// the turn failed while the telemetry still reads `success` is the
// same blindness this branch exists to remove.
//
// Two different things arrive here and the message says which: the
// provider stopping the stream on its own policy, and a stop nothing
// named at all.
const err = new Error(
finishReason === 'content-filter'
? 'Provider stopped the stream on a content filter'
: `Provider stream ended without finishing (${finishReason})`,
);
streamStatus = 'error';
streamErrorClass = this.modelAdapter.classifyError(err);
queue.push(this.makeErrorEvent(turnId, err));
trace.modelStreamFailed(streamErrorClass, err, priorReplayFailureTrace(priorReplay));
} else {
trace.modelStreamCompleted(stopReason);
}
const completeEvent = {
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason,
} satisfies CompleteEvent;
queue.push(completeEvent);
if (scope.memoryExtractRequested && this.input.memoryExtraction) {
const snapshot = this.memorySourceSnapshotFromScope(scope, {
trigger: 'extract',
terminalEventId: completeEvent.id,
});
if (snapshot) {
void queue
.waitUntilConsumedThroughCurrent()
.then(() => this.input.memoryExtraction?.extract(snapshot))
.catch(() => undefined);
}
}
} catch (err) {
streamStatus = scope.aborted ? 'aborted' : 'error';
streamErrorClass = this.modelAdapter.classifyError(currentWatchdogTimeout()?.error ?? err);
// Flush the in-flight step's partial text/thinking before the terminal
// abort/error events. Earlier steps already flushed at their
// `finish-step`; this keeps their and this step's streamed-out output on
// BOTH exits — user stop and provider error / watchdog timeout — so
// partialOutputRetained reflects what the user actually saw.
await flushStep().catch(() => {});
if (!scope.aborted && midTurnState?.exhaustedDetail) {
// Mid-turn compaction could not produce a provider-safe request: end
// the turn with the explicit first-class outcome, not a raw error.
streamErrorClass = 'ContextBudgetExhausted';
trace.modelStreamCompleted('context_budget_exhausted');
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'context_budget_exhausted',
contextBudgetExhaustedDetail: midTurnState.exhaustedDetail,
} satisfies CompleteEvent);
} else if (scope.aborted) {
queue.push({
type: 'abort',
id: this.newId(),
turnId,
ts: this.now(),
reason: 'user_stop',
} satisfies AbortEvent);
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'user_stop',
} satisfies CompleteEvent);
} else {
const terminalError = currentWatchdogTimeout()?.error ?? err;
queue.push(this.makeErrorEvent(turnId, terminalError));
trace.modelStreamFailed(
streamErrorClass,
terminalError,
priorReplayFailureTrace(priorReplay),
);
queue.push({
type: 'complete',
id: this.newId(),
turnId,
ts: this.now(),
stopReason: 'error',
} satisfies CompleteEvent);
}
} finally {
watchdogState.current?.stop();
if (scope.watchdog === watchdogState.current) scope.watchdog = null;
contextBudgetForTelemetry = contextBudgetWithActiveProjectionDiagnostics(
contextBudgetForTelemetry,
activeToolResultPruneDiagnosticPatch,
activeCompactDiagnosticPatch,
);
// `tokenUsage` still backfills from the completed steps when the send
// ended without a final `usage`: the terminal outcome and the
// `token_usage` SessionEvent below both read it. An unusable sample in
// any step fails it closed rather than posing a partial sum as the
// whole call (#972).
//
// The send-level `recordLlmCall` that used to sit here is gone (#1679).
// It measured the same provider requests the canonical seam now settles
// into `ModelCallAttempt`, one record per physical request instead of
// one aggregate per send, and keeping both would have been two
// independent meters free to disagree.
//
// What does NOT follow it out is the diagnostics that rode on it. The
// exhausted and aborted paths emit no `token_usage` SessionEvent, so
// their compaction decisions and the accumulated usage of the steps that
// did complete had that record as their only durable home. They move to
// the run trace, which carries no cost and meters nothing.
if (!tokenUsage && completedStepUsage && !sawUnusableStepUsage) {
tokenUsage = completedStepUsage;
tokenUsageCostUsd = this.computeTokenUsageCostUsd(tokenUsage);
}
trace.sendDiagnostics({
status: streamStatus,
...(streamErrorClass ? { errorClass: streamErrorClass } : {}),
...(tokenUsage
? {
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
totalTokens: tokenUsage.totalTokens,
}
: {}),
...(contextBudgetForTelemetry !== undefined
? { contextBudget: contextBudgetForTelemetry }
: {}),
...(promptSegmentsForTelemetry.length > 0
? { promptSegments: promptSegmentsForTelemetry }
: {}),
...(requestShapeForTelemetry !== undefined
? {
systemPromptHash: requestShapeForTelemetry.componentHashes.systemPromptHash,
prefixHash: requestShapeForTelemetry.prefixHash,
prefixChangeReason: requestShapeForTelemetry.prefixChangeReason,
requestShapeHash: requestShapeForTelemetry.requestShapeHash,
requestShapeChangeReason: requestShapeForTelemetry.requestShapeChangeReason,
...(requestShapeForTelemetry.toolSchemaChangeReason !== undefined
? { toolSchemaChangeReason: requestShapeForTelemetry.toolSchemaChangeReason }
: {}),
...(requestShapeForTelemetry.toolAvailability !== undefined
? { toolAvailability: requestShapeForTelemetry.toolAvailability }
: {}),
}
: {}),
});
queue.close();
}
})();
let drainedNormally = false;
try {
// drain() carries the seq-ack semantics (consumer pull = processed ack);
// every consumer-facing path must go through it.
yield* this.drain(queue);
drainedNormally = true;
} finally {
if (!drainedNormally) turnAbortController.abort();
await pumpDone.catch(() => {});
}
}
private async executeCodeModeCell(
scope: TurnScope,
eventSink: DurableSessionEventSink,
code: string,
context: MakaToolContext,
): Promise<unknown> {
const snapshot = new Map(scope.codeModeTools);
let nestedOutputBytes = 0;
let nestedOutputLimitExceeded = false;
const nestedEventSink: DurableSessionEventSink = {
push: (event) => {
if (event.type === 'tool_output_delta') {
const nextBytes = new TextEncoder().encode(event.chunk).byteLength;
if (
nestedOutputLimitExceeded ||
nestedOutputBytes + nextBytes > DEFAULT_CODE_MODE_LIMITS.maxToolOutputBytes
) {
nestedOutputLimitExceeded = true;
return;
}
nestedOutputBytes += nextBytes;
}
eventSink.push(event);
},
pushAndWaitUntilConsumed: (event) => eventSink.pushAndWaitUntilConsumed(event),
};
return executeCodeCell({
code,
signal: context.abortSignal,
tools: [...snapshot.values()].map((tool) => ({
name: tool.name,
})),
isFatalToolError: isRuntimeCommitBoundaryError,
callTool: async (name, input, signal) => {
const tool = snapshot.get(name);
if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`);
const parsedInput = await validateCodeModeToolInput(tool, input);
const settlement = await scope.toolRuntime.settleToolCallRaw({
tool,
turnId: context.turnId,
toolCallId: `${context.toolCallId}:nested:${this.newId()}`,
input: parsedInput,
abortSignal: signal,
eventSink: nestedEventSink,
origin: 'code_mode',
parentToolCallId: context.toolCallId,
...(context.operationId ? { parentOperationId: context.operationId } : {}),
maxResultBytes: DEFAULT_CODE_MODE_LIMITS.maxToolOutputBytes,
});
if (settlement.providerError !== undefined) {
throw new Error(settlement.providerError);
}
if (nestedOutputLimitExceeded) {
throw new Error('Code Mode nested output byte limit exceeded');
}
return settlement.result;
},
});
}
private handlePlanToolResult(
scope: TurnScope,
result: PlanToolResult,
queue: AsyncEventQueue<SessionEvent>,
): void {
const turnId = scope.turnId;
if (result.kind === 'plan_submitted') {
const proposal = result.proposal;
queue.push({
type: 'plan_submitted',
id: this.newId(),
turnId,
ts: this.now(),
planId: proposal.planId,
proposalId: proposal.proposalId,
revision: proposal.revision,
title: proposal.title,
...(proposal.overview ? { overview: proposal.overview } : {}),
...(proposal.risks ? { risks: proposal.risks } : {}),
steps: proposal.steps.map((step) => ({ ...step, status: 'pending' })),
});
scope.runTrace?.emit('plan', 'plan_submitted', 'Plan submitted', {
planId: proposal.planId,
proposalId: proposal.proposalId,
revision: proposal.revision,
storeVersion: result.storeVersion,
});
scope.loopStopReason = 'plan_handoff';
scope.loopStopRequested = true;
return;
}
const traceType = result.kind;
scope.runTrace?.emit('plan', traceType, 'Plan execution state changed', {
planId: result.execution.planId,
proposalId: result.execution.proposalId,
executionId: result.execution.executionId,
storeVersion: result.storeVersion,
});
if (result.kind === 'plan_execution_completed' || result.kind === 'plan_execution_cancelled') {
scope.loopStopRequested = true;
}
}
private handleAgentGraphYieldToolResult(
scope: TurnScope,
result: YieldAgentGraphToolResult,
): void {
scope.runTrace?.emit('agent_graph', 'graph_supervisor_yielded', 'Graph supervisor yielded', {
pendingWorkCount: result.pendingWorkCount,
liveOperatorCount: result.liveOperatorCount,
reason: result.reason,
});
scope.loopStopReason = 'graph_yield';
scope.loopStopRequested = true;
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
/**
* Stop every turn this backend is currently running.
*
* The control surface carries no turn id, so this stays a broadcast — the
* behavior it has always had. What changes is that each turn is now stopped
* as ITSELF: its own abort controller, its own ToolRuntime, and its own turn
* id on the `endTurn` record. Previously one shared `currentTurnId` labelled
* every concurrent turn's teardown, so an overlapping turn closed under a
* sibling's identity.
*
* Teardown is settled for every scope before any failure surfaces. `endTurn`
* throws when a durable sandbox denial cannot be written, and it is also the
* ONLY thing that rejects a tool parked on `askUserQuestion` — an abort signal
* does not wake the registry. So bailing on the first rejection would leave a
* sibling parked forever: its own `send()` cannot reach the `finally` that
* would clean it up, because that `finally` is waiting on the very tool the
* skipped `endTurn` was supposed to reject.
*/
async stop(
_reason: 'user_stop' | 'redirect',
mode: 'immediate' | 'after_step' = 'immediate',
): Promise<void> {
const scopes = [...this.activeTurns];
if (mode === 'after_step') {
for (const scope of scopes) {
scope.loopStopRequested = true;
scope.runTrace?.abortRequested(_reason);
}
return;
}
this.compaction.abortHistoryCompact();
for (const scope of scopes) {
scope.aborted = true;
scope.abortController.abort();
scope.runTrace?.abortRequested(_reason);
}
const settled = await Promise.allSettled(
scopes.map((scope) => scope.toolRuntime.endTurn('aborted')),
);
const failures = settled.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : [],
);
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, 'Failed to stop every active turn');
}
async respondToSandboxBoundary(decision: SandboxBoundaryResponse): Promise<void> {
// Routed by request id, which is already the identity the registry matches
// on: at most one turn parked this request.
for (const scope of this.activeTurns) {
if (await scope.toolRuntime.respondToSandboxBoundaryResponse(decision)) return;
}
throw new Error(`No pending sandbox boundary request ${decision.requestId}`);
}
async respondToUserQuestion(response: UserQuestionResponse): Promise<void> {
for (const scope of this.activeTurns) {
if (scope.toolRuntime.respondToUserQuestion(response)) return;
}
}
async dispose(): Promise<void> {
if (this.activeTurns.size > 0) await this.stop('user_stop');
else this.compaction.abortHistoryCompact();
this.modelAdapter.dispose();
}
/** Map ai-sdk finishReason → our CompleteEvent.stopReason. */
private mapFinishReason(reason: unknown): CompleteEvent['stopReason'] {
return this.modelAdapter.mapFinishReason(reason);
}
private makeErrorEvent(turnId: string, err: unknown): ErrorEvent {
return this.modelAdapter.makeErrorEvent(turnId, err);
}
private computeTokenUsageCostUsd(usage: NormalizedAiSdkUsage): number | undefined {
try {
const pricing = (this.input.lookupPricing ?? getBuiltinPricing)(
`${this.input.connection.providerType}:${this.input.modelId}`,
);
if (pricing === null) return undefined;
return computeCost(
{
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheHitInputTokens: usage.cacheHitInputTokens,
cacheMissInputTokens: usage.cacheMissInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
},
pricing,
).totalCost;
} catch {
return undefined;
}
}
/**
* One tracker for one physical provider call kind (#1679).
*
* Auxiliary calls get the same capture, attempt, and accounting plumbing the
* main send uses, built here because the sinks and the current run live on
* this backend. Callers receive a ready tracker rather than the ingredients:
* a half-wired tracker is what produces records nothing can attribute.
*
* Absent only when there is nothing to feed: no capture sink *and* no
* canonical sink. Metering deliberately does not depend on capture — capture
* is a diagnostic, and a deployment that turns it off must still be billed.
*/
private createProviderRequestTracker(input: {
turnId: string;
callKind: ModelCallKind;
modelId: string;
/**
* Stated by every caller, never defaulted: an unattributed provider request
* is silently dropped by usage accounting, so the compiler has to be the
* thing that catches a missing run id (#1990).
*/
runId: string | undefined;
}): ProviderRequestTracker | undefined {
const persistCapture = this.input.recordProviderRequestCapture;
const accounting = this.modelCallAccounting(input.callKind, {
modelId: input.modelId,
...(input.runId ? { runId: input.runId } : {}),
});
if (!persistCapture && !accounting) return undefined;
return new ProviderRequestTracker({
traceId: this.newId(),
turnId: input.turnId,
contextWindow: resolveSelectedModelContextWindow(this.input.connection, input.modelId),
now: this.now,
newId: this.newId,
...(persistCapture ? { persistCapture } : {}),
recordAttempt: this.input.recordProviderRequestAttempt ?? (() => {}),
...(accounting ? { accounting } : {}),
});
}
/**
* Accounting identity for one call kind (#1679).
*
* The run is always supplied by the caller. It cannot be read back off this
* backend: one instance serves several concurrent runs, so whichever turn
* touched it last would speak for all of them (#1990).
*
* Absent when there is no canonical sink, which leaves the corresponding
* tracker purely diagnostic.
*/
private modelCallAccounting(
callKind: ModelCallKind,
identity?: {
/** The run this call is billed to; absent only when there is none. */
runId?: string;
/** The model this call actually runs against; priced as that model. */
modelId?: string;
},
): ModelCallAccountingInput | undefined {
const record = this.input.recordModelCallAttempt;
if (!record) return undefined;
const modelId = identity?.modelId ?? this.input.modelId;
return {
sessionId: this.sessionId,
resolveRunId: () => identity?.runId,
connectionSlug: this.input.connection.slug,
providerId: this.input.connection.providerType,
callKind,
record,
resolveCost: (usage: ProviderRequestUsage) => this.resolveModelCallCost(usage, modelId),
...(this.input.assertModelCallAccountingReady
? { assertReady: this.input.assertModelCallAccountingReady }
: {}),
};
}
/**
* Resolves cost for a canonical accounting record at settlement time, together
* with the rates it was computed against.
*
* The basis travels with the amount because a figure recomputed later from
* whatever pricing is current would silently drift from what the call actually
* cost. An unresolvable price returns `undefined` rather than zero — the
* record then carries `costBasis: 'unpriced'`, which is not the same claim as
* a call that was free.
*
* Priced against the model that actually served the request, which is not
* always the session's model: a configured semantic-compact summarizer runs
* on its own. Recording one model's id beside another model's rates would
* make the stored amount unauditable in exactly the way `pricingRates` exists
* to prevent.
*/
private resolveModelCallCost(
usage: ProviderRequestUsage,
modelId: string,
): ResolvedModelCallCost | undefined {
try {
const pricing = (this.input.lookupPricing ?? getBuiltinPricing)(
`${this.input.connection.providerType}:${modelId}`,
);
if (pricing === null) return undefined;
const costUsd = computeCost(
{
inputTokens: usage.inputTokens ?? 0,
outputTokens: usage.outputTokens ?? 0,
cacheHitInputTokens: usage.cacheReadInputTokens ?? 0,
cacheMissInputTokens: usage.cacheMissInputTokens ?? 0,
cacheWriteInputTokens: usage.cacheWriteInputTokens ?? 0,
},
pricing,
).totalCost;
if (costUsd === undefined || !Number.isFinite(costUsd)) return undefined;
return { costUsd, pricingRates: pricing };
} catch {
return undefined;
}
}
/** Materialize RuntimeEvent-derived projections into ai-sdk's message format.
* V0.1: text-only round-tripping. Tool calls / results within projected
* history are deliberately NOT replayed unless RuntimeEvent native replay
* is available for the provider. */
private async buildPriorMessages(
scope: TurnScope,
input: BackendSendInput,
): Promise<PriorReplayResult> {
const priorStored = input.context.filter((message) => message.turnId !== input.turnId);
if (!input.runtimeContext) {
return {
status: 'ready',
messages: await this.materializePriorMessages(scope.imageBudget, priorStored),
gate: 'stored_message_projection',
diagnostics: [],
};
}
const priorRuntimeContext = input.runtimeContext.filter(
(event) => event.turnId !== input.turnId,
);
const projectedMessages = await this.materializePriorMessages(
scope.imageBudget,
priorStored,
buildSteeringSidecar(priorRuntimeContext),
);
const preparedContextBudget =
await this.compaction.prepareContextBudgetPolicy(priorRuntimeContext);
let contextBudget = preparedContextBudget.policy;
let budgeted = applyRuntimeEventContextBudget(priorRuntimeContext, contextBudget, {
historyCompactProtocol:
contextBudget?.historyCompact?.checkpoint ||
this.compaction.hasHistoryCompactCheckpointWriter()
? 'checkpoint_v2'
: 'legacy_v1',
});
const oversizedRetainedTurn = hasOversizedRetainedHistoryTurn(
budgeted?.events ?? priorRuntimeContext,
contextBudget,
);
let contextBudgetExhaustedDetail: ContextBudgetExhaustedDetail | undefined =
oversizedRetainedTurn && contextBudget?.historyCompact?.enabled !== true
? 'no_safe_completed_span'
: undefined;
if (oversizedRetainedTurn && contextBudget?.historyCompact?.enabled === true) {
const overflowRecoveryPolicy: ContextBudgetPolicy = {
...contextBudget,
minRecentTurns: 0,
historyCompact: {
...contextBudget.historyCompact,
minRecentTurns: 0,
},
};
contextBudget = overflowRecoveryPolicy;
budgeted = applyRuntimeEventContextBudget(priorRuntimeContext, overflowRecoveryPolicy, {
historyCompactProtocol:
overflowRecoveryPolicy.historyCompact?.checkpoint ||
this.compaction.hasHistoryCompactCheckpointWriter()
? 'checkpoint_v2'
: 'legacy_v1',
});
}
let runtimeContext = budgeted?.events ?? priorRuntimeContext;
let contextBudgetDiagnostic = budgeted?.diagnostic;
let latestHistoryCompactCheckpoint = contextBudget?.historyCompact?.checkpoint;
if (preparedContextBudget.diagnosticPatch) {
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
preparedContextBudget.diagnosticPatch,
);
}
if (
budgeted?.historyCompactBlocks?.length &&
contextBudget?.historyCompact?.mode === 'read_write' &&
this.compaction.hasHistoryCompactWriter()
) {
const loadedBlockIds = new Set(
(contextBudget.historyCompact.blocks ?? []).map((block) => block.blockId),
);
const draftBlocks = budgeted.historyCompactBlocks.filter(
(block) => !loadedBlockIds.has(block.blockId),
);
if (draftBlocks.length > 0) {
if (this.input.summarizeHistoryCompact && this.input.recordHistoryCompactCheckpoint) {
const writePatch = await this.compaction.writeHistoryCompactCheckpoint({
turnId: input.turnId,
// Stated, not resolved: this runs inside a send, and the backend
// may be serving another turn whose run is not this one (#1990).
runId: scope.runId,
contextBudget,
priorRuntimeContext,
draftBlock: draftBlocks[0]!,
abortSignal: scope.abortController.signal,
requestShapeHashBefore: this.priorRequestShape?.requestShapeHash,
});
if (writePatch.replacementCheckpoint) {
latestHistoryCompactCheckpoint = writePatch.replacementCheckpoint;
runtimeContext = [
historyCompactCheckpointToRuntimeEvent(writePatch.replacementCheckpoint),
...runtimeContext.filter((event) => !event.id.startsWith('history-compact:')),
];
} else {
if (oversizedRetainedTurn && !writePatch.fallbackCheckpoint) {
contextBudgetExhaustedDetail = 'summarizer_failed';
}
runtimeContext = writePatch.fallbackCheckpoint
? buildHistoryCompactCheckpointFailOpenContext(
writePatch.fallbackCheckpoint,
priorRuntimeContext,
contextBudget,
runtimeContext.filter((event) => !event.id.startsWith('history-compact:')),
)
: runtimeContext.filter((event) => !event.id.startsWith('history-compact:'));
}
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
writePatch.diagnosticPatch,
);
} else {
const writePatch = await this.compaction.writeHistoryCompactBlocks({
turnId: input.turnId,
contextBudget,
priorRuntimeContext,
draftBlocks,
abortSignal: scope.abortController.signal,
requestShapeHashBefore: this.priorRequestShape?.requestShapeHash,
});
if (writePatch.replacementBlocks.length > 0) {
runtimeContext = replaceHistoryCompactReplayBlocks(
runtimeContext,
writePatch.replacementBlocks,
);
} else {
runtimeContext = priorRuntimeContext;
contextBudgetDiagnostic = buildContextBudgetDiagnosticShell(
priorRuntimeContext,
runtimeContext,
contextBudget,
);
}
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
writePatch.diagnosticPatch,
);
}
}
}
if (
oversizedRetainedTurn &&
contextBudget?.maxHistoryEstimatedTokens !== undefined &&
estimateRuntimeEventsTokens(runtimeContext, contextBudget.charsPerToken) >
contextBudget.maxHistoryEstimatedTokens
) {
contextBudgetExhaustedDetail ??= 'no_safe_completed_span';
}
if (contextBudgetExhaustedDetail) {
return {
status: 'context_budget_exhausted',
detail: contextBudgetExhaustedDetail,
...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}),
};
}
const historySearchSource = buildHistorySearchSource(priorRuntimeContext, contextBudget);
const historyAround =
contextBudget?.archiveRetrieval?.mode === 'history_search_gated'
? retrieveReplayHistoryAroundSearchSource(
historySearchSource,
priorRuntimeContext,
input.text,
contextBudget?.historySearch,
{ charsPerToken: contextBudget?.charsPerToken },
)
: retrieveRuntimeEventHistoryAround(
historySearchSource,
input.text,
contextBudget?.historySearch,
{ charsPerToken: contextBudget?.charsPerToken },
);
const archiveRetrievalAllowedTurnIds =
contextBudget?.archiveRetrieval?.mode === 'history_search_gated'
? new Set(historyAround.events.map((event) => runtimeEventTurnKey(event)))
: undefined;
if (historyAround.events.length > 0) {
runtimeContext = mergeRuntimeEventsInOriginalOrder(
priorRuntimeContext,
runtimeContext,
historyAround.events,
);
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
historyAround.diagnosticPatch,
);
} else if (contextBudget?.historySearch?.enabled === true) {
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
historyAround.diagnosticPatch,
);
}
const synthesis = selectSynthesisCacheForReplay(
runtimeContext,
input.text,
contextBudget?.synthesisCache,
{
sessionId: this.sessionId,
charsPerToken: contextBudget?.charsPerToken,
},
);
runtimeContext = synthesis.events;
if (contextBudget?.synthesisCache?.enabled === true) {
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
synthesis.diagnosticPatch,
);
}
if (synthesis.selectedBlocks.length === 0) {
const retrieval = await retrieveArchivedToolResultsForReplay(
runtimeContext,
contextBudget?.archiveRetrieval,
this.input.toolResultArchive?.services.readToolResultArchive,
{
sessionId: this.sessionId,
charsPerToken: contextBudget?.charsPerToken,
allowedTurnIds: archiveRetrievalAllowedTurnIds,
},
);
runtimeContext = retrieval.events;
if (contextBudget?.archiveRetrieval?.enabled === true) {
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
retrieval.diagnosticPatch,
);
}
if (
contextBudget?.synthesisCache?.enabled === true &&
contextBudget.synthesisCache.mode === 'read_write' &&
this.input.writeSynthesisCache &&
(retrieval.retrievedSourceRefs?.length ?? 0) > 0 &&
(retrieval.diagnosticPatch.retrievedArchiveToolResults ?? 0) > 0
) {
const evidenceRequestReason = rawEvidenceRequestReason(input.text);
if (evidenceRequestReason) {
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
{
synthesisCacheWriteSkipped: 1,
synthesisCacheWriteSkippedReasonCounts: { [evidenceRequestReason]: 1 },
},
);
} else {
const writePatch = await this.compaction.writeSynthesisCacheBlocks({
turnId: input.turnId,
query: input.text,
hydratedRuntimeEvents: runtimeContext,
retrievedArchiveRefs: retrieval.retrievedSourceRefs ?? [],
archiveRetrievalMode: contextBudget.archiveRetrieval?.mode ?? 'eager',
contextBudget,
requestShapeHashBefore: this.priorRequestShape?.requestShapeHash,
});
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
writePatch,
);
}
} else if (
contextBudget?.synthesisCache?.enabled === true &&
contextBudget.synthesisCache.mode === 'read_write' &&
synthesis.selectedBlocks.length === 0 &&
(retrieval.diagnosticPatch.retrievedArchiveToolResults ?? 0) === 0
) {
contextBudgetDiagnostic = mergeContextBudgetDiagnostic(
contextBudgetDiagnostic ??
buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget),
{
synthesisCacheWriteSkipped: 1,
synthesisCacheWriteSkippedReasonCounts: { source_missing: 1 },
},
);
}
}
const plan = buildRuntimeEventModelReplayPlan(
runtimeContext,
// `runtimeContext` may be a budget/history-search slice; the tool-turn
// thinking skip is a whole-history invariant, so seed it from the full
// prior ledger so a sliced-in tool-turn thinking still gets skipped.
{ toolActivityTurnIds: collectToolActivityTurnIds(priorRuntimeContext) },
);
if (plan.items.length === 0) {
return {
status: 'ready',
messages: input.continuation
? await this.materializeRuntimeReplayTextOnly(scope.imageBudget, plan)
: projectedMessages,
gate: input.continuation ? 'runtime_replay_text_only' : 'stored_message_projection',
diagnostics: plan.diagnostics,
runtimeEventCount: runtimeContext.length,
...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}),
...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}),
};
}
if (hasBlockingReplayDiagnostics(plan)) {
return {
status: 'ready',
messages: input.continuation
? await this.materializeRuntimeReplayTextOnly(scope.imageBudget, plan)
: projectedMessages,
gate: input.continuation
? 'runtime_replay_text_only'
: 'runtime_replay_unsupported_semantics',
diagnostics: plan.diagnostics,
runtimeEventCount: runtimeContext.length,
...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}),
...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}),
};
}
if (!plan.hasProviderNativeSemantics) {
return {
status: 'ready',
messages: await this.materializeRuntimeReplayPlan(plan, scope.imageBudget),
gate: 'runtime_replay_text_only',
diagnostics: plan.diagnostics,
runtimeEventCount: runtimeContext.length,
...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}),
...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}),
};
}
if (!this.canReplayProviderNative(plan)) {
return {
status: 'ready',
messages: input.continuation
? await this.materializeRuntimeReplayTextOnly(scope.imageBudget, plan)
: projectedMessages,
gate: input.continuation
? 'runtime_replay_text_only'
: 'runtime_replay_unsupported_semantics',
diagnostics: plan.diagnostics,
runtimeEventCount: runtimeContext.length,
...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}),
...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}),
};
}
return {
status: 'ready',
messages: await this.materializeRuntimeReplayPlan(plan, scope.imageBudget),
gate: 'runtime_replay_provider_native',
diagnostics: plan.diagnostics,
runtimeEventCount: runtimeContext.length,
...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}),
...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}),
};
}
private canReplayProviderNative(plan: RuntimeEventModelReplayPlan): boolean {
const support = this.modelAdapter.runtimeEventReplaySupport();
for (const item of plan.items) {
if (item.kind === 'tool_call' && !support.toolCalls) return false;
if (item.kind === 'tool_result' && !support.toolResults) return false;
if (item.kind === 'thinking' && item.signature && !support.signedThinking) return false;
}
return true;
}
/**
* Materialize a replay plan into provider messages, grouping each assistant
* step's reasoning + text + tool calls into ONE assistant message (Anthropic
* requires the signed thinking block to lead the tool-use assistant message).
*
* The ledger lands a step's parts as: tool_call(s), tool_result(s), thinking,
* text (the per-step AssistantMessage flushes at `finish-step`, after the
* step's tool events). Model text carries the step id and closes the step.
* Client tools replay as `[reasoning, text, tool-call…]` followed by tool
* messages; provider-executed tools replay as
* `[reasoning, tool-call, tool-result, text]`, preserving provider chronology
* for item references and grounded text. Steps with no text closer — a
* thinking + tool step (its empty text closer is skipped from the plan as
* `empty_text_skipped`) or a pure-tool step — flush grouped by stepId,
* claiming any parked reasoning for that step. Legacy per-turn items (no step
* id) keep the older shape: tool calls form a tool-only assistant,
* text/thinking become standalone messages.
*/
private async materializeRuntimeReplayPlan(
plan: RuntimeEventModelReplayPlan,
budget: ProviderImageBudget,
settledModelOutputs?: ReadonlyMap<string, ToolResultOutput>,
): Promise<ModelMessage[]> {
type ToolCallItem = Extract<RuntimeEventModelReplayItem, { kind: 'tool_call' }>;
type ToolResultItem = Extract<RuntimeEventModelReplayItem, { kind: 'tool_result' }>;
type ThinkingItem = Extract<RuntimeEventModelReplayItem, { kind: 'thinking' }>;
type TextItem = Extract<RuntimeEventModelReplayItem, { kind: 'text' }>;
type ReplayReasoning = {
part?: ReasoningPart;
providerOptions?: NonNullable<ModelMessage['providerOptions']>;
};
const out: ModelMessage[] = [];
const push = (message: ModelMessage, eventIds: readonly string[]) => {
out.push(message);
this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]);
};
let bufferedCalls: ToolCallItem[] = [];
const results = new Map<string, ToolResultItem>();
const reasoningByStep = new Map<string, ThinkingItem[]>();
const textByStep = new Map<string, TextItem>();
const replaySupport = this.modelAdapter.runtimeEventReplaySupport();
const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => {
if (item.signature) {
return replaySupport.signedThinking
? {
part: {
type: 'reasoning' as const,
text: item.text,
providerOptions: { anthropic: { signature: item.signature } },
},
}
: undefined;
}
if (replaySupport.openAiResponsesThinking) {
const openai = item.providerOptions?.openai;
if (openai && typeof openai === 'object' && !Array.isArray(openai)) {
const { itemId, reasoningEncryptedContent } = openai as {
itemId?: unknown;
reasoningEncryptedContent?: unknown;
};
if (typeof itemId === 'string' && itemId.length > 0) {
return {
part: {
type: 'reasoning' as const,
text: item.text,
providerOptions: {
openai: {
itemId,
...(typeof reasoningEncryptedContent === 'string' ||
reasoningEncryptedContent === null
? { reasoningEncryptedContent }
: {}),
},
},
},
};
}
}
}
if (!replaySupport.unsignedThinking) return undefined;
const reasoningField = openAiChatReasoningFieldFromProviderOptions(item.providerOptions);
if (!reasoningField) return undefined;
return {
providerOptions: {
openaiCompatible: { [reasoningField]: item.text },
} as NonNullable<ModelMessage['providerOptions']>,
};
};
// Tool results are emitted only when their tool_call claims them here. A
// result whose call never appears in the plan (sliced-away call, corrupt
// ledger) is INTENTIONALLY dropped at the end: a standalone tool message
// with no preceding tool_use in an assistant message is an Anthropic 400.
// The old item-by-item materializer emitted such orphans; do not "fix" this
// back — the plan flags them as `unmatched_tool_result` (a non-blocking
// diagnostic precisely so this drop path is reachable; see
// hasBlockingReplayDiagnostics).
const materializeReplayToolResult = async (result: ToolResultItem): Promise<ToolResultOutput> =>
settledModelOutputs?.get(result.toolCallId) ??
(await this.materializeToolResultOutput(
budget,
result.output,
result.isError,
`runtime-event:${result.eventId}:tool-result`,
));
const pushClientToolResults = async (calls: readonly ToolCallItem[]) => {
for (const call of calls) {
const result = results.get(call.toolCallId);
if (!result || result.providerExecuted === true) continue;
results.delete(call.toolCallId);
push(
{
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: result.toolCallId,
toolName: result.toolName,
output: await materializeReplayToolResult(result),
},
],
},
[result.eventId],
);
}
};
// Emit one assistant message for a step, preserving the distinct client-
// and provider-executed tool chronologies described above.
const emitStep = async (
reasoning: readonly ThinkingItem[] | undefined,
text: TextItem | undefined,
calls: readonly ToolCallItem[],
) => {
const content: unknown[] = [];
const eventIds = [
...(reasoning ?? []).map((item) => item.eventId),
...(text ? [text.eventId] : []),
...calls.map((call) => call.eventId),
];
const replayReasoning = reasoning
?.map(reasoningReplay)
.filter((item): item is ReplayReasoning => item !== undefined);
for (const item of replayReasoning ?? []) {
if (item.part) content.push(item.part);
}
// Provider-owned tools execute before the grounded assistant text in the
// same provider step. Preserve that chronology for Responses item
// references and Anthropic server_tool_use/result replay. Client tools
// stay after text because their execution begins only after this step.
for (const call of calls) {
if (call.providerExecuted !== true) continue;
content.push({
type: 'tool-call',
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}),
providerExecuted: true,
});
const result = results.get(call.toolCallId);
if (!result || result.providerExecuted !== true) continue;
results.delete(call.toolCallId);
eventIds.push(result.eventId);
content.push({
type: 'tool-result',
toolCallId: result.toolCallId,
toolName: result.toolName,
output: await materializeReplayToolResult(result),
});
}
if (text && text.content.length > 0) {
content.push({
type: 'text',
text: text.content,
...(text.providerOptions !== undefined ? { providerOptions: text.providerOptions } : {}),
});
}
for (const call of calls) {
if (call.providerExecuted === true) continue;
content.push({
type: 'tool-call',
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}),
...(call.providerExecuted !== undefined
? { providerExecuted: call.providerExecuted }
: {}),
});
}
const replayProviderOptions = replayReasoning?.find(
(item) => item.providerOptions !== undefined,
)?.providerOptions;
if (content.length > 0 || replayProviderOptions) {
push(
{
role: 'assistant',
content,
...(replayProviderOptions ? { providerOptions: replayProviderOptions } : {}),
} as ModelMessage,
eventIds,
);
}
await pushClientToolResults(calls);
};
// Emit tool calls no assistant text closed: a thinking + tool step with no
// text (its empty closer is skipped from the plan), a pure-tool step, or a
// legacy per-turn tool block. Group consecutive calls by stepId so each step
// stays one assistant message, and claim the step's parked reasoning by
// stepId — this is how the common Anthropic interleaved-thinking step shape
// (reasoning + tool call, no text) gets its reasoning merged ahead of its
// calls. Calls without a stepId group together (legacy shape, no reasoning).
const emitGroupedCalls = async (calls: readonly ToolCallItem[]) => {
let group: ToolCallItem[] = [];
const emitGroup = async () => {
if (group.length === 0) return;
const stepId = group[0]!.stepId;
const reasoning = stepId !== undefined ? reasoningByStep.get(stepId) : undefined;
const text = stepId !== undefined ? textByStep.get(stepId) : undefined;
if (stepId !== undefined) {
reasoningByStep.delete(stepId);
textByStep.delete(stepId);
}
await emitStep(reasoning, text, group);
group = [];
};
for (const call of calls) {
if (group.length > 0 && group[0]!.stepId !== call.stepId) await emitGroup();
group.push(call);
}
await emitGroup();
};
const flushLooseCalls = async () => {
if (bufferedCalls.length === 0) return;
const calls = bufferedCalls;
bufferedCalls = [];
await emitGroupedCalls(calls);
};
const flushPendingSteps = async () => {
await flushLooseCalls();
for (const [stepId, text] of textByStep) {
textByStep.delete(stepId);
const reasoning = reasoningByStep.get(stepId);
reasoningByStep.delete(stepId);
await emitStep(reasoning, text, []);
}
for (const [stepId, reasoning] of reasoningByStep) {
reasoningByStep.delete(stepId);
await emitStep(reasoning, undefined, []);
}
};
for (const item of plan.items) {
switch (item.kind) {
case 'tool_call':
bufferedCalls.push(item);
break;
case 'tool_result':
results.set(item.toolCallId, item);
break;
case 'thinking':
if (item.stepId !== undefined) {
const stepReasoning = reasoningByStep.get(item.stepId) ?? [];
stepReasoning.push(item);
reasoningByStep.set(item.stepId, stepReasoning);
} else {
// Legacy standalone reasoning (pure-reasoning turn): emit on its own.
await flushPendingSteps();
const replayReasoning = reasoningReplay(item);
if (replayReasoning) {
push(
{
role: 'assistant',
content: replayReasoning.part ? [replayReasoning.part] : [],
...(replayReasoning.providerOptions
? { providerOptions: replayReasoning.providerOptions }
: {}),
} as ModelMessage,
[item.eventId],
);
}
}
break;
case 'text':
if (item.role !== 'assistant') {
await flushPendingSteps();
push(await this.materializeRuntimeReplayItem(budget, item), [item.eventId]);
break;
}
if (item.stepId !== undefined) {
const stepId = item.stepId;
const thisCalls = bufferedCalls.filter((call) => call.stepId === stepId);
const otherCalls = bufferedCalls.filter((call) => call.stepId !== stepId);
bufferedCalls = [];
// Earlier steps' unclosed calls flush first (with their own parked
// reasoning, if any) so step order is preserved.
if (otherCalls.length > 0) await emitGroupedCalls(otherCalls);
if (thisCalls.length > 0) {
await emitStep(reasoningByStep.get(stepId), item, thisCalls);
reasoningByStep.delete(stepId);
} else {
// Runtime-owned settlement persists assistant facts before the
// matching tool calls. Hold the step closer until those calls
// arrive; a terminal text-only step flushes below.
textByStep.set(stepId, item);
}
} else {
// Legacy per-turn assistant text: standalone after any tool block.
await flushPendingSteps();
push(
{
role: 'assistant',
content: item.content,
...(item.providerOptions !== undefined
? { providerOptions: item.providerOptions }
: {}),
},
[item.eventId],
);
}
break;
}
}
await flushPendingSteps();
return out;
}
private async materializeRuntimeReplayTextOnly(
budget: ProviderImageBudget,
plan: RuntimeEventModelReplayPlan,
): Promise<ModelMessage[]> {
const messages: ModelMessage[] = [];
for (const item of plan.items) {
if (item.kind === 'text')
this.pushMemoryIndexedMessage(
messages,
await this.materializeRuntimeReplayItem(budget, item),
[item.eventId],
);
}
return messages;
}
private pushMemoryIndexedMessage(
messages: ModelMessage[],
message: ModelMessage,
eventIds: readonly string[],
): void {
messages.push(message);
this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]);
}
private memoryEventMessagePositions(
messages: readonly ModelMessage[],
): Readonly<Record<string, readonly number[]>> | undefined {
const positions: Record<string, number[]> = {};
for (const [position, message] of messages.entries()) {
for (const eventId of this.memoryReplayMessageEvents.get(message) ?? []) {
(positions[eventId] ??= []).push(position);
}
}
return Object.keys(positions).length > 0 ? positions : undefined;
}
private async materializeRuntimeReplayItem(
budget: ProviderImageBudget,
item: Extract<RuntimeEventModelReplayItem, { kind: 'text' }>,
): Promise<ModelMessage> {
if (item.role === 'user') {
if (item.steering) {
// Already envelope-wrapped by the plan; carry the structured identity
// so injection dedupe recognizes the replayed message.
return {
role: 'user',
content: item.content,
providerOptions: steeringProviderOptions(item.steering.eventId),
};
}
return {
role: 'user',
content: await this.appendImageParts(
budget,
item.content,
item.attachments,
`runtime-event:${item.eventId}`,
),
} as ModelMessage;
}
return {
role: item.role,
content: item.content,
...(item.providerOptions !== undefined ? { providerOptions: item.providerOptions } : {}),
};
}
private async materializePriorMessages(
budget: ProviderImageBudget,
stored: readonly StoredMessage[],
steeringSidecar?: ReadonlyMap<string, { eventId: string }>,
): Promise<ModelMessage[]> {
const out: ModelMessage[] = [];
for (const m of stored) {
if (m.type === 'user') {
// Degraded projections lose the RuntimeEvent steering marker; the
// sidecar (keyed by the projection's stable message ids) restores the
// canonical envelope + structured identity so a fallback-gated turn
// still presents steering exactly once, in its one provider form.
const sidecar = steeringSidecar?.get(m.id);
if (sidecar) {
out.push(
steeringModelMessage(
sidecar.eventId,
await this.appendImageParts(
budget,
buildSteeringEnvelope(formatTextWithInlineRefs(m.text, m)),
m.attachments,
`steering:${sidecar.eventId}`,
),
),
);
continue;
}
out.push({
role: 'user',
content: await this.appendImageParts(
budget,
formatTextWithInlineRefs(m.text, m),
m.attachments,
),
} as ModelMessage);
}
// A thinking/tool-only step projects an assistant row with empty text;
// replaying it as an empty text block is a hard 400 on Anthropic-protocol
// providers.
else if (m.type === 'assistant' && m.text.length > 0)
out.push({
role: 'assistant',
content: m.text,
...(m.providerOptions !== undefined
? {
providerOptions: m.providerOptions as NonNullable<ModelMessage['providerOptions']>,
}
: {}),
} as ModelMessage);
// empty assistant / tool_call / tool_result / permission_decision / token_usage / system_note skipped
}
return out;
}
/** Append provider-visible volatile turn facts after the durable user content. */
private appendTurnTailPrompt(
content: ModelMessage['content'],
turnTailPrompt?: string,
): ModelMessage['content'] {
if (!turnTailPrompt) return content;
if (typeof content === 'string') return `${content}\n\n${turnTailPrompt}`;
return [
...(content as unknown[]),
{ type: 'text', text: turnTailPrompt },
] as ModelMessage['content'];
}
/** A decision key deduplicates re-materialization; no key charges each occurrence. */
private chargeImageBudget(
budget: ProviderImageBudget,
bytes: number,
decisionKey?: string,
): boolean {
if (decisionKey !== undefined) {
const cached = budget.decisions.get(decisionKey);
if (cached !== undefined) return cached;
}
const keep =
budget.used + bytes <=
(this.input.maxProviderImageRequestBytes ?? MAX_PROVIDER_IMAGE_REQUEST_BYTES);
if (keep) budget.used += bytes;
if (decisionKey !== undefined) budget.decisions.set(decisionKey, keep);
return keep;
}
/**
* Render provider-visible content for a user message: keep the given
* (already-formatted) text, and append image attachments as provider image
* parts only for explicitly vision-capable models. Non-image attachments stay
* as placeholder refs in the text. Shared by the current turn, RuntimeEvent
* replay, and the stored-message fallback so all paths present images identically.
*/
private async appendImageParts(
budget: ProviderImageBudget,
textContent: string,
attachments?: AttachmentRef[],
decisionKeyPrefix?: string,
): Promise<UserContent> {
const images = attachments?.filter((a) => a.kind === 'image') ?? [];
if (images.length === 0) {
return textContent;
}
if (this.input.supportsVision !== true) {
return appendNonVisionImageFallbackNotice(textContent);
}
if (!this.input.readAttachmentBytes) {
return textContent;
}
const parts: Array<
| { type: 'text'; text: string }
| { type: 'file'; data: { type: 'data'; data: Uint8Array }; mediaType: string }
> = [{ type: 'text', text: textContent }];
let omittedByBudget = 0;
for (const [index, image] of images.entries()) {
const read = await this.input.readAttachmentBytes(image.ref);
if (!read.ok) {
parts.push({
type: 'text',
text: `Image attachment "${image.name}" could not be loaded: ${read.reason}.`,
});
continue;
}
const decisionKey =
decisionKeyPrefix === undefined ? undefined : `${decisionKeyPrefix}:image:${index}`;
if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) {
omittedByBudget += 1;
continue;
}
parts.push({
type: 'file',
data: { type: 'data', data: read.bytes },
mediaType: image.mimeType,
});
}
if (omittedByBudget > 0) {
parts.push({
type: 'text',
text: `[${omittedByBudget} image attachment(s) omitted: the per-request image budget was exceeded. Earlier images were sent; ask the user to send fewer or smaller images.]`,
});
}
return parts;
}
private async materializeToolResultOutput(
budget: ProviderImageBudget,
output: unknown,
isError: boolean,
decisionKey: string,
): Promise<ToolResultOutput> {
if (isError || !isImageToolResult(output)) return toolResultOutput(output, isError);
if (this.input.supportsVision !== true) {
return toolResultText('Image was read, but the selected model does not support image input.');
}
if (!this.input.readAttachmentBytes) {
return toolResultText('Image was read, but its stored bytes are unavailable.');
}
if (budget && budget.decisions.get(decisionKey) === false) {
return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE);
}
let read: Awaited<ReturnType<AttachmentByteReader>>;
try {
read = await this.input.readAttachmentBytes(output.ref);
} catch {
return toolResultText('Image could not be loaded from artifact storage: read_failed.');
}
if (!read.ok) {
return toolResultText(`Image could not be loaded from artifact storage: ${read.reason}.`);
}
if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) {
return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE);
}
return {
type: 'content',
value: [
{ type: 'text', text: 'Image read successfully.' },
{
type: 'file',
data: { type: 'data', data: Buffer.from(read.bytes).toString('base64') },
mediaType: output.mimeType,
},
],
};
}
private async buildCurrentUserContent(
budget: ProviderImageBudget,
text: string,
attachments?: AttachmentRef[],
quotes?: QuoteRef[],
runtimeEventId?: string,
): Promise<UserContent> {
return await this.appendImageParts(
budget,
formatTextWithInlineRefs(text, {
...(attachments !== undefined ? { attachments } : {}),
...(quotes !== undefined ? { quotes } : {}),
}),
attachments,
runtimeEventId === undefined ? undefined : `runtime-event:${runtimeEventId}`,
);
}
private async resolveSystemPrompt(scope: TurnScope): Promise<string | undefined> {
const turnId = scope.turnId;
if (typeof this.input.systemPrompt === 'function') {
return await this.input.systemPrompt({
sessionId: this.sessionId,
turnId,
cwd: this.input.header.cwd,
workspaceRoot: this.input.header.workspaceRoot,
emitSkillCatalogTrace: (message, data) =>
scope.runTrace?.emit('skill', 'skill_catalog_built', message, data),
});
}
return this.input.systemPrompt;
}
private async resolveTurnTailPrompt(turnId: string): Promise<string | undefined> {
if (typeof this.input.turnTailPrompt === 'function') {
return await this.input.turnTailPrompt({
sessionId: this.sessionId,
turnId,
cwd: this.input.header.cwd,
workspaceRoot: this.input.header.workspaceRoot,
});
}
return this.input.turnTailPrompt;
}
private async resolveShellRunContextSummary(): Promise<string | undefined> {
return await this.input.shellRunContextSummary?.();
}
private async *drain(queue: AsyncEventQueue<SessionEvent>): AsyncIterable<SessionEvent> {
try {
for await (const ev of queue) {
yield ev;
// Generator backpressure IS the consumer's ack: this line runs only
// when the consumer's loop body finished for `ev` and pulled the next
// event, so `consumedCount` counts fully PROCESSED events. AgentRun
// persists each mapped event before continuing, so an acked event is
// either durable or deliberately skipped (partials, non-terminal
// errors) — exactly the set a durable read can ever return.
queue.ackConsumed();
}
} finally {
// The consumer abandoned or finished the stream; wake any seq-ack waiter
// so it observes `consumerDetached` instead of blocking forever.
queue.noteConsumerDetached();
}
}
/**
* Retire one turn's scope. Nothing is reset for reuse — the scope is dropped,
* so a sibling turn still running on this backend is untouched. Deregistering
* before `endTurn` also makes a late tool settlement resolve to "gone" rather
* than to whichever turn started next.
*/
private async cleanupAfterTurn(scope: TurnScope): Promise<void> {
this.activeTurns.delete(scope);
this.modelAdapter.endContinuation(scope.turnId);
await scope.toolRuntime.endTurn(scope.aborted ? 'aborted' : 'completed');
}
/**
* Drain the caller's pending steering at a step boundary. Each message is
* echoed as a `steering_message` event (so the ledger + transcript render the
* interjection in place) and accumulated as an envelope-wrapped user message
* for injection into subsequent provider requests.
*
* Persist-before-include invariant: the initial user message is durable
* before the backend is invoked, and a steered message must hold the same
* line — the provider must never start executing a directive the ledger does
* not carry. The seq-ack boundary provides that without a second write path:
* the consumer's pull is the ack, and AgentRun persists each mapped event
* before continuing (see drain()), so once everything enqueued up to the
* steering event is consumed, the event is durable. If the consumer detaches
* (the persist path failed or the turn is being torn down) before that, the
* message is nacked and NOT included in any request; an abort after the push
* waits for that same convergence — durable ⇒ ack (history owns it), detach
* ⇒ nack — and only then throws so the dying request is never sent.
*/
private async drainSteeringInto(
scope: TurnScope,
input: BackendSendInput,
queue: AsyncEventQueue<SessionEvent>,
): Promise<void> {
const turnId = scope.turnId;
const abortSignal = scope.abortController.signal;
const pull = input.pullSteering;
if (!pull) return;
const leases = pull();
if (leases.length === 0) return;
// Binary settlement: every pulled lease settles exactly once, decided
// ONLY by the persistence fact — durably consumed ⇒ ack + injection set;
// provably never persisted (never pushed, or the consumer detached
// without consuming it) ⇒ nack. An abort does NOT settle a pushed lease:
// it only stops new pushes and the dying request; the wait continues
// until the teardown converges it (the flow drains after terminal events
// or detaches on failure), because nacking a durably appended event
// would put the same directive in the account twice — once via history
// replay, once via the reclaimed queue.
const undelivered = [...leases];
try {
for (const lease of leases) {
if (scope.aborted || abortSignal?.aborted) {
// Never pushed: settles as undelivered.
throw Object.assign(new Error('aborted before steering was pushed'), {
name: 'AbortError',
});
}
if (queue.consumerDetached) {
throw new Error('steering message was not durably consumed: event consumer detached');
}
// Materialize provider content before publishing the durable event.
// After consumption there must be no fallible gap before ack/injection.
const eventId = this.newId();
const providerContent = await this.appendImageParts(
scope.imageBudget,
buildSteeringEnvelope(formatTextWithInlineRefs(lease.content.text, lease.content)),
lease.content.attachments,
`steering:${eventId}`,
);
if (scope.aborted || abortSignal?.aborted) {
throw Object.assign(new Error('aborted before steering was pushed'), {
name: 'AbortError',
});
}
if (queue.consumerDetached) {
throw new Error('steering message was not durably consumed: event consumer detached');
}
await queue.pushAndWaitUntilConsumed({
type: 'steering_message',
id: eventId,
turnId,
ts: this.now(),
messageId: lease.messageId,
content: lease.content,
...(lease.submittedContentDigest
? { submittedContentDigest: lease.submittedContentDigest }
: {}),
} satisfies SessionEvent);
// The mapped RuntimeEvent inherits this session event's id, so the
// injected message and its future ledger replay share one identity.
scope.injectedSteeringMessages.push(steeringModelMessage(eventId, providerContent));
input.ackSteering?.([lease.id]);
undelivered.shift();
if (scope.aborted || abortSignal?.aborted) {
// Settled (the ledger owns the message; the next turn replays it),
// but the send is dying: stop before any request is built with it.
throw Object.assign(new Error('aborted after steering was durable'), {
name: 'AbortError',
});
}
}
} catch (error) {
if (undelivered.length > 0) {
input.nackSteering?.(undelivered.map((lease) => lease.id));
}
throw error;
}
}
}
/**
* Steering identities for degraded StoredMessage projections, keyed by every
* stable id the projection may have used for the message (event id,
* providerEventId, storedMessageId), so the sidecar restore is exact.
*/
function buildSteeringSidecar(events: readonly RuntimeEvent[]): Map<string, { eventId: string }> {
const sidecar = new Map<string, { eventId: string }>();
for (const event of events) {
if (event.partial === true) continue;
if (event.content?.kind !== 'text' || event.content.steering !== true) continue;
const identity = { eventId: event.id };
sidecar.set(event.id, identity);
if (event.refs?.providerEventId) sidecar.set(event.refs.providerEventId, identity);
if (event.refs?.storedMessageId) sidecar.set(event.refs.storedMessageId, identity);
}
return sidecar;
}
function isPlanToolResult(output: unknown): output is PlanToolResult {
if (!output || typeof output !== 'object') return false;
return [
'plan_submitted',
'plan_progress_updated',
'plan_execution_completed',
'plan_execution_cancelled',
].includes(String((output as { kind?: unknown }).kind));
}
function isAgentGraphYieldToolResult(output: unknown): output is YieldAgentGraphToolResult {
if (output === null || typeof output !== 'object' || Array.isArray(output)) return false;
const result = output as Record<string, unknown>;
return (
Object.keys(result).length === 4 &&
result.kind === 'agent_graph_yielded' &&
typeof result.pendingWorkCount === 'number' &&
Number.isSafeInteger(result.pendingWorkCount) &&
result.pendingWorkCount > 0 &&
typeof result.liveOperatorCount === 'number' &&
Number.isSafeInteger(result.liveOperatorCount) &&
result.liveOperatorCount >= 0 &&
typeof result.reason === 'string' &&
result.reason.length > 0 &&
result.reason.length <= 4_000 &&
result.reason.trim() === result.reason
);
}
export function repairMakaToolCall(input: {
toolCall: RepairableAiSdkToolCall;
availableToolNames: readonly string[];
error: unknown;
/** Schema lookup for the tool that was called, when the caller has one. */
toolParameters?: (toolName: string) => unknown;
/**
* Category lookup for the same tool.
*
* Computer Use declares one flat wire object standing in for a per-action
* union, so its schema shape alone names every field of every action.
*/
toolCategoryHint?: (toolName: string) => string | undefined;
}): RepairableAiSdkToolCall | null {
const requestedName = input.toolCall.toolName;
if (requestedName === INVALID_TOOL_NAME) return null;
const lowerRequestedName = requestedName.toLowerCase();
const exactLowercaseMatch = input.availableToolNames.find(
(name) => name.toLowerCase() === lowerRequestedName,
);
if (exactLowercaseMatch && exactLowercaseMatch !== requestedName) {
return { ...input.toolCall, toolName: exactLowercaseMatch };
}
return {
...input.toolCall,
toolName: INVALID_TOOL_NAME,
input: JSON.stringify({
tool: requestedName,
error: describeUnrepairableToolCall(input),
}),
};
}
/**
* What the model is told about a call that could not be repaired.
*
* Two different failures arrive here. A name that matches nothing: the caller
* is holding the list of names that would have worked and used to drop it,
* leaving the model with its own wrong name and a validator's complaint — the
* same dead end `tool-availability` avoids by naming what is available.
* Arguments the tool's schema rejected: the schema knows which fields the call
* takes, so say them rather than let the model re-send the shape just refused.
*/
function describeUnrepairableToolCall(input: {
toolCall: RepairableAiSdkToolCall;
availableToolNames: readonly string[];
error: unknown;
toolParameters?: (toolName: string) => unknown;
toolCategoryHint?: (toolName: string) => string | undefined;
}): string {
const requestedName = input.toolCall.toolName;
const known = input.availableToolNames.includes(requestedName);
if (!known) {
const available = input.availableToolNames.join(', ');
const detail = formatSyntheticToolErrorText(input.error);
return available ? `${detail} Available tools: ${available}.` : detail;
}
return formatToolArgsViolationText({
toolName: requestedName,
parameters: input.toolParameters?.(requestedName),
categoryHint: input.toolCategoryHint?.(requestedName),
args: parseToolCallInput(input.toolCall.input),
error: input.error,
});
}
function parseToolCallInput(raw: unknown): unknown {
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function buildInvalidMakaTool(): MakaTool<{ tool?: string; error?: string }, never> {
return {
name: INVALID_TOOL_NAME,
description:
'Internal repair target for malformed or unknown tool calls. Do not call directly.',
parameters: z.object({
tool: z.string().optional(),
error: z.string().optional(),
}),
impl: ({ tool, error }) => {
const requested = tool ? ` "${tool}"` : '';
throw new Error(
`模型请求了不可用或格式错误的工具${requested}:${error || 'tool call could not be parsed'}`,
);
},
};
}
function priorReplayFailureTrace(replay: {
gate: string;
diagnostics: readonly { code: string }[];
}): { gate: string; diagnosticCodes: string[] } {
return {
gate: replay.gate,
diagnosticCodes: [...new Set(replay.diagnostics.map((diagnostic) => diagnostic.code))],
};
}
class ContinuationReplayEmptyError extends Error {
readonly code = 'continuation_replay_empty';
constructor(
readonly replayGate: string,
readonly diagnosticCodes: readonly string[],
) {
super(`Continuation replay is empty after ${replayGate}`);
this.name = 'ContinuationReplayEmptyError';
}
}
function mergeActiveToolResultPruneDiagnosticPatches(
left: ActiveToolResultPruneDiagnosticPatch,
right: ActiveToolResultPruneDiagnosticPatch,
): ActiveToolResultPruneDiagnosticPatch {
return {
...sumOptionalCounts('activePrunedToolResults', left, right),
...sumOptionalCounts('activeArchiveFailures', left, right),
...sumOptionalCounts('activeEstimatedTokensSaved', left, right),
};
}
function mergeNormalizedUsage(
current: NormalizedAiSdkUsage | undefined,
next: NormalizedAiSdkUsage,
): NormalizedAiSdkUsage {
if (!current) return next;
const cacheMissInputSource =
current.cacheMissInputSource === 'explicit' || next.cacheMissInputSource === 'explicit'
? 'explicit'
: 'derived';
const cacheHitInputTokens = current.cacheHitInputTokens + next.cacheHitInputTokens;
return {
inputTokens: current.inputTokens + next.inputTokens,
outputTokens: current.outputTokens + next.outputTokens,
cacheHitInputTokens,
cacheMissInputTokens: current.cacheMissInputTokens + next.cacheMissInputTokens,
cacheMissInputSource,
cacheWriteInputTokens: current.cacheWriteInputTokens + next.cacheWriteInputTokens,
reasoningTokens: current.reasoningTokens + next.reasoningTokens,
totalTokens: current.totalTokens + next.totalTokens,
...(next.rawFinishReason !== undefined ? { rawFinishReason: next.rawFinishReason } : {}),
cachedInputTokens: cacheHitInputTokens,
};
}
function sumOptionalCounts<K extends keyof ActiveToolResultPruneDiagnosticPatch>(
key: K,
left: ActiveToolResultPruneDiagnosticPatch,
right: ActiveToolResultPruneDiagnosticPatch,
): Pick<ActiveToolResultPruneDiagnosticPatch, K> | Record<string, never> {
const total = (left[key] ?? 0) + (right[key] ?? 0);
return total > 0 ? ({ [key]: total } as Pick<ActiveToolResultPruneDiagnosticPatch, K>) : {};
}
function contextBudgetWithActiveProjectionDiagnostics(
base: ContextBudgetDiagnostic | undefined,
patch: ActiveToolResultPruneDiagnosticPatch,
activeFullCompactPatch: Partial<ContextBudgetDiagnostic> | undefined,
): ContextBudgetDiagnostic | undefined {
const prunePatch = hasActiveToolResultPruneDiagnosticPatch(patch) ? patch : undefined;
const mergedPatch = mergeContextBudgetDiagnosticPatches(prunePatch, activeFullCompactPatch);
if (!mergedPatch) return base;
return mergeContextBudgetDiagnostic(base ?? minimalContextBudgetDiagnostic(), mergedPatch);
}
function buildHistoryCompactCheckpointFailOpenContext(
checkpoint: HistoryCompactCheckpoint,
priorRuntimeContext: readonly RuntimeEvent[],
policy: ContextBudgetPolicy,
retainedCandidates: readonly RuntimeEvent[],
): RuntimeEvent[] {
const charsPerToken = policy.charsPerToken ?? 4;
const compactableEvents = priorRuntimeContext.filter(
(event) => estimateRuntimeEventsTokens([event], charsPerToken) > 0,
);
const match = matchHistoryCompactCheckpointPrefix(checkpoint, compactableEvents);
if (match.reason) return [...retainedCandidates];
const coveredIds = new Set(match.coveredRuntimeEvents.map((event) => event.id));
const candidates = retainedCandidates.filter((event) => !coveredIds.has(event.id));
const turnOrder: string[] = [];
const byTurn = new Map<string, RuntimeEvent[]>();
for (const event of candidates) {
const group = byTurn.get(event.turnId);
if (group) group.push(event);
else {
turnOrder.push(event.turnId);
byTurn.set(event.turnId, [event]);
}
}
const maxTokens = policy.maxHistoryEstimatedTokens ?? Number.POSITIVE_INFINITY;
const replayPrefix = projectHistoryCompactCheckpointReplay(
checkpoint,
match.coveredRuntimeEvents,
[],
);
let selectedTokens = estimateRuntimeEventsTokens(replayPrefix, charsPerToken);
const selectedGroups: RuntimeEvent[][] = [];
for (let index = turnOrder.length - 1; index >= 0; index -= 1) {
const group = byTurn.get(turnOrder[index]!) ?? [];
const groupTokens = estimateRuntimeEventsTokens(group, charsPerToken);
if (selectedTokens + groupTokens > maxTokens) break;
selectedGroups.unshift(group);
selectedTokens += groupTokens;
}
const replayTail = selectedGroups.flat();
const replayEvents = projectHistoryCompactCheckpointReplay(
checkpoint,
match.coveredRuntimeEvents,
replayTail,
);
return evaluateHistoryCompactCheckpointReplay(
checkpoint,
replayEvents.slice(1),
policy?.charsPerToken,
policy?.maxHistoryEstimatedTokens,
{
sourceReplayEvents: [...match.coveredRuntimeEvents, ...replayTail],
},
).fits
? replayEvents
: [...retainedCandidates];
}
function projectMemoryConversationPrefix(
messages: readonly ModelMessage[],
eventMessagePositions?: Readonly<Record<string, readonly number[]>>,
): {
messages: ModelMessage[];
eventMessagePositions?: Readonly<Record<string, readonly number[]>>;
} {
// Context visibility and durable evidence authority are separate boundaries.
// Keep the exact source prefix so the auxiliary request preserves referents
// and provider-cache shape. The Evidence Index and trusted admission layer
// independently restrict durable citations to user-authored RuntimeEvents.
return {
messages: [...messages],
...(eventMessagePositions ? { eventMessagePositions } : {}),
};
}
function memoryExtractionModelHeader(
header: SessionHeader,
): MemoryExtractionSourceSnapshot['sourceHeader'] {
return {
llmConnectionSlug: header.llmConnectionSlug,
model: header.model,
...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}),
};
}