omk-agent-core 0.96.2 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,8 +107,8 @@ Tool execution mode is configurable:
107
107
 
108
108
  In parallel mode the batch can be scheduled with one of two schedulers:
109
109
 
110
- - `waves-v1` (historical): partitions the batch into ordered waves using `partitionToolBatchWaves`. Calls in the same wave run concurrently, and waves run one after another in source order. A fully safe batch is a single concurrent wave; bash, `clarify`, sequential-policy tools, unknown tools, and file tools with overlapping target paths become their own waves, so one conflicting call no longer serializes the independent rest of the batch.
111
- - `dag-v2` (opt-in): builds a deterministic resource-claim DAG per call. Tools declare `resourceClaims` and an access mode (`read` or `write`); the scheduler runs independent calls in source-directed levels, keeps `bash`, unknown tools, and unclaimed extension tools exclusive, and still preserves source-order result artifacts. A claim-planning failure closes only that call and does not block other claimable calls in the batch. Set `toolScheduler: "dag-v2"` in `Agent` options to enable it. `OMK_TOOL_SCHEDULER=dag-v2` is also honored in the OMK CLI.
110
+ - `dag-v2` (default): builds a deterministic resource-claim DAG per call. Tools declare `resourceClaims` and an access mode (`read` or `write`); the scheduler runs independent calls in source-directed levels, keeps `bash`, unknown tools, and unclaimed extension tools exclusive, and still preserves source-order result artifacts. A claim-planning failure closes only that call and does not block other claimable calls in the batch.
111
+ - `waves-v1` (rollback): partitions the batch into ordered waves using `partitionToolBatchWaves`. Calls in the same wave run concurrently, and waves run one after another in source order. Set `toolScheduler: "waves-v1"` only when temporarily rolling back DAG scheduling. The OMK CLI also accepts `OMK_TOOL_SCHEDULER=waves-v1`.
112
112
 
113
113
  Tool completion events follow tool completion order, but persisted toolResult messages still follow assistant source order.
114
114
 
@@ -210,8 +210,8 @@ const agent = new Agent({
210
210
  // Also "toolExecution: 'sequential'" forces waves/dag schedulers to run serially.
211
211
  toolExecution: "parallel",
212
212
 
213
- // Tool scheduler: "waves-v1" (default) or "dag-v2" (resource-claim DAG)
214
- toolScheduler: "waves-v1",
213
+ // Tool scheduler: "dag-v2" (default) or legacy "waves-v1" rollback
214
+ toolScheduler: "dag-v2",
215
215
 
216
216
  // Maximum concurrent tool calls in one DAG level when using "dag-v2".
217
217
  // 0 removes the cap. Default in the OMK CLI is 4.
@@ -51,7 +51,7 @@ export declare function runAgentLoopContinue(context: AgentContext, config: Agen
51
51
  * carries image blocks while the session model is text-only.
52
52
  *
53
53
  * `contextWindow`/`maxTokens` are NOT inherited from the session model — they
54
- * describe the actual Codex backend limits (400K window), which callers rely on
54
+ * describe the OMK GPT-5.6 family contract (1M window), which callers rely on
55
55
  * for compaction thresholds and overflow detection.
56
56
  */
57
57
  export declare const VISION_ROUTE_MODEL: {
@@ -62,7 +62,7 @@ export declare const VISION_ROUTE_MODEL: {
62
62
  readonly baseUrl: "https://chatgpt.com/backend-api";
63
63
  readonly reasoning: true;
64
64
  readonly input: readonly ["text", "image"];
65
- readonly contextWindow: 400000;
65
+ readonly contextWindow: 1000000;
66
66
  readonly maxTokens: 128000;
67
67
  };
68
68
  /** True when the given model is the auto-routed vision model. */
@@ -1 +1 @@
1
- {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAKN,WAAW,EACX,KAAK,KAAK,EAEV,KAAK,iBAAiB,EAEtB,MAAM,QAAQ,CAAC;AAGhB,OAAO,EAEN,KAAK,eAAe,EACpB,KAAK,wBAAwB,EAE7B,MAAM,yBAAyB,CAAC;AAYjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAOnE,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,eAAe,EAEpB,KAAK,YAAY,EAKjB,KAAK,QAAQ,EAGb,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAWzE,MAAM,WAAW,sBAAsB;IACtC,kFAAkF;IAClF,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,4EAA4E;IAC5E,cAAc,EAAE,YAAY,GAAG,SAAS,CAAC;IACzC,0EAA0E;IAC1E,cAAc,EAAE,iBAAiB,EAAE,CAAC;CACpC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACrC,iBAAiB,EAAE,SAAS,YAAY,EAAE,EAC1C,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,OAAO,GACd,sBAAsB,CAiCxB;AAqCD;;;GAGG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CA0BzC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAoCzC;AAED,wBAAsB,YAAY,CACjC,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAczB;AAED,wBAAsB,oBAAoB,CACzC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAezB;AAoTD;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;CAUrB,CAAC;AAEX,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAExG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAajE;AAsPD,4FAA4F;AAC5F,KAAK,gBAAgB,GAAG,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;AA8BrD;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CAC1C,SAAS,EAAE,SAAS,iBAAiB,EAAE,EACvC,OAAO,EAAE,wBAAwB,EACjC,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,KAAK,EAAE,gBAAgB,GACrB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAwBjC","sourcesContent":["/**\n * Agent loop that works with AgentMessage throughout.\n * Transforms to Message[] only at the LLM call boundary.\n */\n\nimport {\n\ttype AssistantMessage,\n\ttype AssistantMessageEvent,\n\ttype AssistantMessageEventStream,\n\ttype Context,\n\tEventStream,\n\ttype Model,\n\tstreamSimple,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"omk-ai\";\nimport { bindToolIdentity } from \"./builtin-tool-resource-claims.ts\";\nimport { partitionToolBatchWaves } from \"./parallel-tool-batch.ts\";\nimport {\n\tapplyConcurrencyCap,\n\ttype DagSchedulePlan,\n\ttype ScheduleDagLevelsOptions,\n\tscheduleDagLevels,\n} from \"./tool-dag-scheduler.ts\";\nimport {\n\tawaitWithAbort,\n\tcreateErrorToolResult,\n\tcreateImmutableJsonSnapshot,\n\tcreateImmutableSnapshot,\n\ttype ExecutedToolCallOutcome,\n\ttype FinalizedToolCallOutcome,\n\tfinalizeExecutedToolCall,\n\tparseJsonValue,\n\tstampToolResultEnvelope,\n} from \"./tool-execution-boundary.ts\";\nimport type { ClaimableToolCall } from \"./tool-resource-claims.ts\";\nimport { resolveToolTimeoutMs, runToolCallWithTimeout } from \"./tool-timeout.ts\";\nimport {\n\tcreateSyntheticToolResult,\n\tinspectTranscriptIntegrity,\n\trepairTranscriptIntegrity,\n} from \"./tool-transcript-integrity.ts\";\nimport {\n\ttype AgentContext,\n\ttype AgentEvent,\n\ttype AgentLoopConfig,\n\ttype AgentLoopTurnUpdate,\n\ttype AgentMessage,\n\ttype AgentTool,\n\ttype AgentToolCall,\n\ttype AgentToolResult,\n\tcreateToolResultEnvelope,\n\ttype StreamFn,\n\ttype ToolCallDisposition,\n\ttype ToolResultEnvelope,\n} from \"./types.ts\";\n\nexport type AgentEventSink = (event: AgentEvent) => Promise<void> | void;\n\nconst EMPTY_USAGE = {\n\tinput: 0,\n\toutput: 0,\n\tcacheRead: 0,\n\tcacheWrite: 0,\n\ttotalTokens: 0,\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\nexport interface FailureTerminationPlan {\n\t/** Messages to publish as the run result (closure results + optional failure). */\n\tmessages: AgentMessage[];\n\t/** Synthetic assistant failure message, or `undefined` when fail-closed. */\n\tfailureMessage: AgentMessage | undefined;\n\t/** Synthetic tool results used to close an open turn, in source order. */\n\tclosureResults: ToolResultMessage[];\n}\n\n/**\n * Decide how to terminate a run after the underlying loop rejected.\n *\n * A synthetic assistant failure may only be appended on top of a transcript\n * whose tool turns are all closed. When `completedMessages` ends with an open\n * tool turn, a safe missing-only closure (synthetic results for the unambiguous\n * missing tail calls) is appended first so the failure assistant never creates\n * an `assistant(tool calls) -> assistant(failure)` interleaving.\n *\n * If the transcript is ambiguous (duplicate/orphan/interleave, or a\n * mid-transcript gap) it is never auto-repaired: the plan returns no failure\n * message so the caller ends the stream without fabricating a turn over\n * corruption. Pure apart from `Date.now()` on the failure message.\n */\nexport function planFailureTermination(\n\tcompletedMessages: readonly AgentMessage[],\n\tmodel: Model<any>,\n\terror: unknown,\n\taborted: boolean,\n): FailureTerminationPlan {\n\tconst messages = [...completedMessages];\n\tconst closureResults: ToolResultMessage[] = [];\n\n\tif (!inspectTranscriptIntegrity(messages).ok) {\n\t\ttry {\n\t\t\tconst repaired = repairTranscriptIntegrity(messages, \"Tool result missing; run terminated by error\");\n\t\t\t// repairTranscriptIntegrity appends synthetic results only for\n\t\t\t// unambiguous missing tail calls; anything ambiguous throws above.\n\t\t\tfor (let i = messages.length; i < repaired.length; i++) {\n\t\t\t\tconst result = createImmutableSnapshot(repaired[i] as ToolResultMessage);\n\t\t\t\tclosureResults.push(result);\n\t\t\t\tmessages.push(result);\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ambiguous transcript: never auto-repair. Fail closed without a\n\t\t\t// synthetic assistant turn over a corrupt transcript.\n\t\t\treturn { messages, failureMessage: undefined, closureResults: [] };\n\t\t}\n\t}\n\n\tconst failureMessage: AgentMessage = createImmutableSnapshot({\n\t\trole: \"assistant\",\n\t\tcontent: [{ type: \"text\", text: \"\" }],\n\t\tapi: model.api,\n\t\tprovider: model.provider,\n\t\tmodel: model.id,\n\t\tusage: EMPTY_USAGE,\n\t\tstopReason: aborted ? \"aborted\" : \"error\",\n\t\terrorMessage: error instanceof Error ? error.message : String(error),\n\t\ttimestamp: Date.now(),\n\t});\n\treturn { messages: [...messages, failureMessage], failureMessage, closureResults };\n}\n\n/**\n * Terminate the public event stream after the underlying loop rejected.\n *\n * Uses {@link planFailureTermination} so the disposition of any unresolved tool\n * calls matches transcript repair exactly: an unambiguous open turn is closed\n * with synthetic results before a coherent\n * message_start/message_end/turn_end/agent_end sequence for the failure\n * assistant, and an ambiguous transcript fails closed (agent_end only, no\n * fabricated assistant). The stream always settles for `for await` consumers\n * and `stream.result()`.\n */\nfunction endStreamWithFailure(\n\tstream: EventStream<AgentEvent, AgentMessage[]>,\n\tconfig: AgentLoopConfig,\n\tcompletedMessages: AgentMessage[],\n\terror: unknown,\n\tsignal?: AbortSignal,\n): void {\n\tconst plan = planFailureTermination(completedMessages, config.model, error, signal?.aborted ?? false);\n\n\tfor (const result of plan.closureResults) {\n\t\tstream.push({ type: \"message_start\", message: result });\n\t\tstream.push({ type: \"message_end\", message: result });\n\t}\n\n\tif (plan.failureMessage) {\n\t\tstream.push({ type: \"message_start\", message: plan.failureMessage });\n\t\tstream.push({ type: \"message_end\", message: plan.failureMessage });\n\t\tstream.push({ type: \"turn_end\", message: plan.failureMessage, toolResults: [] });\n\t}\n\n\tstream.push({ type: \"agent_end\", messages: plan.messages });\n\tstream.end(plan.messages);\n}\n\n/**\n * Start an agent loop with a new prompt message.\n * The prompt is added to the context and events are emitted for it.\n */\nexport function agentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tconst stream = createAgentStream();\n\tconst completedMessages: AgentMessage[] = [];\n\n\tvoid runAgentLoop(\n\t\tprompts,\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tcompletedMessages.push(event.message);\n\t\t\t}\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then(\n\t\t(messages) => {\n\t\t\tstream.end(messages);\n\t\t},\n\t\t(error: unknown) => {\n\t\t\tendStreamWithFailure(stream, config, completedMessages, error, signal);\n\t\t},\n\t);\n\n\treturn stream;\n}\n\n/**\n * Continue an agent loop from the current context without adding a new message.\n * Used for retries - context already has user message or tool results.\n *\n * **Important:** The last message in context must convert to a `user` or `toolResult` message\n * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.\n * This cannot be validated here since `convertToLlm` is only called once per turn.\n */\nexport function agentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\t// Guard: the last message must be one the provider can build on. A plain\n\t// text/thinking assistant turn is acceptable (convertToLlm may merge or the\n\t// provider supports assistant pre-fill); only an assistant turn that still\n\t// carries unresolved tool calls is a hard error because the provider will\n\t// reject the request without matching tool results.\n\tassertContinuableTranscript(context.messages);\n\n\tconst stream = createAgentStream();\n\tconst completedMessages: AgentMessage[] = [];\n\n\tvoid runAgentLoopContinue(\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tcompletedMessages.push(event.message);\n\t\t\t}\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then(\n\t\t(messages) => {\n\t\t\tstream.end(messages);\n\t\t},\n\t\t(error: unknown) => {\n\t\t\tendStreamWithFailure(stream, config, completedMessages, error, signal);\n\t\t},\n\t);\n\n\treturn stream;\n}\n\nexport async function runAgentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tconst newMessages: AgentMessage[] = [...prompts];\n\tconst currentContext: AgentContext = { ...context, messages: [...context.messages, ...prompts] };\n\tconst publish: AgentEventSink = (event) => emit(createImmutableSnapshot(event));\n\n\tawait publish({ type: \"agent_start\" });\n\tawait publish({ type: \"turn_start\" });\n\tfor (const prompt of prompts) {\n\t\tawait publish({ type: \"message_start\", message: prompt });\n\t\tawait publish({ type: \"message_end\", message: prompt });\n\t}\n\n\tawait runLoop(currentContext, newMessages, config, signal, publish, streamFn);\n\treturn newMessages;\n}\n\nexport async function runAgentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tassertContinuableTranscript(context.messages);\n\n\tconst newMessages: AgentMessage[] = [];\n\tconst currentContext: AgentContext = { ...context };\n\tconst publish: AgentEventSink = (event) => emit(createImmutableSnapshot(event));\n\n\tawait publish({ type: \"agent_start\" });\n\tawait publish({ type: \"turn_start\" });\n\tawait runLoop(currentContext, newMessages, config, signal, publish, streamFn);\n\treturn newMessages;\n}\n\nfunction createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {\n\treturn new EventStream<AgentEvent, AgentMessage[]>(\n\t\t(event: AgentEvent) => event.type === \"agent_end\",\n\t\t(event: AgentEvent) => (event.type === \"agent_end\" ? event.messages : []),\n\t);\n}\n\n/**\n * Validate the full transcript before continuing. Replaces the earlier\n * last-message-only tail check: `assistant(A,B) -> result(A)` and any\n * duplicate/orphan/interleaved structure now fail before the first provider\n * request, not only a trailing assistant message that still carries tool calls.\n *\n * A trailing assistant turn with no tool calls (plain text/thinking) remains\n * continuable, so compaction, session resume, and explicit retries keep working.\n */\nfunction assertContinuableTranscript(messages: AgentMessage[]): void {\n\tconst report = inspectTranscriptIntegrity(messages);\n\tif (report.ok) {\n\t\treturn;\n\t}\n\tconst last = messages[messages.length - 1];\n\tif (last !== undefined && last.role === \"assistant\" && last.content.some((block) => block.type === \"toolCall\")) {\n\t\tthrow new Error(\n\t\t\t\"Cannot continue: the last assistant message has pending tool calls without matching results. \" +\n\t\t\t\t\"Add tool results or a new user message before continuing.\",\n\t\t);\n\t}\n\tconst summary = report.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(\", \");\n\tthrow new Error(\n\t\t`Cannot continue: invalid tool transcript (${summary}). ` +\n\t\t\t\"Append terminal tool results or repair the transcript before continuing.\",\n\t);\n}\n\n/** Throw when the tool transcript could not be accepted by a provider. */\nfunction assertValidToolTranscript(messages: AgentMessage[], describe: (summary: string) => string): void {\n\tconst integrityReport = inspectTranscriptIntegrity(messages);\n\tif (integrityReport.ok) {\n\t\treturn;\n\t}\n\tconst summary = integrityReport.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(\", \");\n\tthrow new Error(describe(summary));\n}\n\n/** Inject queued steering messages into the transcript before the next assistant turn. */\nasync function injectPendingMessages(\n\tpendingMessages: AgentMessage[],\n\tcurrentContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\temit: AgentEventSink,\n): Promise<void> {\n\tfor (const message of pendingMessages) {\n\t\tawait emit({ type: \"message_start\", message });\n\t\tawait emit({ type: \"message_end\", message });\n\t\tcurrentContext.messages.push(message);\n\t\tnewMessages.push(message);\n\t}\n}\n\n/** Close every emitted tool call with a synthetic terminal result and end the run. */\nasync function closeRunOnTerminalStop(\n\tcurrentContext: AgentContext,\n\tmessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tnewMessages: AgentMessage[],\n\temit: AgentEventSink,\n): Promise<void> {\n\tconst toolResults: ToolResultMessage[] = [];\n\tconst reason =\n\t\tmessage.stopReason === \"aborted\"\n\t\t\t? \"Operation aborted\"\n\t\t\t: \"Skipped because the provider terminated before tool execution\";\n\tconst disposition = message.stopReason === \"aborted\" ? \"aborted\" : \"skipped\";\n\tfor (const toolCall of toolCalls) {\n\t\tconst result = createImmutableSnapshot(\n\t\t\tcreateSyntheticToolResult(toolCall.id, toolCall.name, reason, Date.now(), disposition),\n\t\t);\n\t\tcurrentContext.messages.push(result);\n\t\tnewMessages.push(result);\n\t\ttoolResults.push(result);\n\t\tawait emitToolResultMessage(result, emit);\n\t}\n\tawait emit({ type: \"turn_end\", message, toolResults });\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/** Drain an optional message queue, normalizing absent queues to an empty list. */\nasync function drainMessageQueue(queue?: () => Promise<AgentMessage[]>): Promise<AgentMessage[]> {\n\treturn queue === undefined ? [] : await queue();\n}\n\n/** Validate an optional per-run provider-turn budget. */\nfunction validateMaxTurns(maxTurns: number | undefined): number | undefined {\n\tif (maxTurns === undefined) return undefined;\n\tif (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {\n\t\tthrow new RangeError(\"maxTurns must be a positive safe integer\");\n\t}\n\treturn maxTurns;\n}\n\n/** Reject ambiguous provider-emitted tool transcripts before any tool executes. */\nfunction assertEmittedTranscriptUnambiguous(messages: AgentMessage[]): void {\n\tconst emittedAmbiguities = inspectTranscriptIntegrity(messages).issues.filter(\n\t\t(issue) => issue.kind !== \"missing_result\",\n\t);\n\tif (emittedAmbiguities.length === 0) {\n\t\treturn;\n\t}\n\tconst summary = emittedAmbiguities.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(\", \");\n\tthrow new Error(`Refusing tool execution: invalid emitted tool transcript (${summary}).`);\n}\n\ntype ToolBatchTurnOutcome =\n\t| { kind: \"continue\"; toolResults: ToolResultMessage[]; hasMoreToolCalls: boolean; stopRun: boolean }\n\t| { kind: \"ended\" };\n\n/** Execute one assistant batch, closing unresolved calls and ending the run on abort. */\nasync function runToolBatchForTurn(\n\tcurrentContext: AgentContext,\n\tmessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tnewMessages: AgentMessage[],\n\tdagScheduleCache: DagScheduleCache,\n): Promise<ToolBatchTurnOutcome> {\n\tconst executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit, dagScheduleCache);\n\tconst toolResults = [...executedToolBatch.messages];\n\tif (signal?.aborted) {\n\t\t// Close only unresolved calls, preserving finalized results, then stop\n\t\t// before hooks, queues, or another provider request.\n\t\tconst synthesized = await closeAbortedToolBatch(currentContext, toolCalls, toolResults, emit);\n\t\ttoolResults.push(...synthesized);\n\t\tfor (const result of toolResults) newMessages.push(result);\n\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\treturn { kind: \"ended\" };\n\t}\n\tfor (const result of toolResults) newMessages.push(result);\n\treturn {\n\t\tkind: \"continue\",\n\t\ttoolResults,\n\t\thasMoreToolCalls: !executedToolBatch.terminate,\n\t\tstopRun: executedToolBatch.stopRun ?? false,\n\t};\n}\n\n/** Merge a prepareNextTurn snapshot into the active context and loop config. */\nfunction applyNextTurnSnapshot(\n\tcurrentContext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsnapshot: AgentLoopTurnUpdate,\n): { context: AgentContext; config: AgentLoopConfig } {\n\treturn {\n\t\tcontext: snapshot.context ?? currentContext,\n\t\tconfig: {\n\t\t\t...config,\n\t\t\tmodel: snapshot.model ?? config.model,\n\t\t\treasoning:\n\t\t\t\tsnapshot.thinkingLevel === undefined\n\t\t\t\t\t? config.reasoning\n\t\t\t\t\t: snapshot.thinkingLevel === \"off\"\n\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t: snapshot.thinkingLevel,\n\t\t},\n\t};\n}\n\n/**\n * Main loop logic shared by agentLoop and agentLoopContinue.\n */\nasync function runLoop(\n\tinitialContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tinitialConfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<void> {\n\tlet currentContext = initialContext;\n\tlet config = initialConfig;\n\tlet firstTurn = true;\n\tlet turnsStarted = 0;\n\tconst maxTurns = validateMaxTurns(initialConfig.maxTurns);\n\tconst dagScheduleCache: DagScheduleCache = new Map();\n\t// Check for steering messages at start (user may have typed while waiting)\n\tlet pendingMessages = await drainMessageQueue(config.getSteeringMessages);\n\n\t// Outer loop: continues when queued follow-up messages arrive after agent would stop\n\twhile (true) {\n\t\tlet hasMoreToolCalls = true;\n\n\t\t// Inner loop: process tool calls and steering messages\n\t\twhile (hasMoreToolCalls || pendingMessages.length > 0) {\n\t\t\tif (!firstTurn) {\n\t\t\t\tawait emit({ type: \"turn_start\" });\n\t\t\t}\n\t\t\tfirstTurn = false;\n\n\t\t\t// Process pending messages (inject before next assistant response)\n\t\t\tif (pendingMessages.length > 0) {\n\t\t\t\tawait injectPendingMessages(pendingMessages, currentContext, newMessages, emit);\n\t\t\t\tpendingMessages = [];\n\t\t\t}\n\n\t\t\t// Stream assistant response\n\t\t\tturnsStarted++;\n\t\t\tconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\t\t\tnewMessages.push(message);\n\n\t\t\t// Provider output is untrusted protocol input. Reject duplicate call IDs\n\t\t\t// and every other ambiguous turn before any tool can execute.\n\t\t\tassertEmittedTranscriptUnambiguous(currentContext.messages);\n\n\t\t\tconst toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n\t\t\tif (message.stopReason === \"error\" || message.stopReason === \"aborted\") {\n\t\t\t\tawait closeRunOnTerminalStop(currentContext, message, toolCalls, newMessages, emit);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst toolResults: ToolResultMessage[] = [];\n\t\t\tlet stopAfterToolBatch = false;\n\t\t\thasMoreToolCalls = false;\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\tconst batchOutcome = await runToolBatchForTurn(\n\t\t\t\t\tcurrentContext,\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolCalls,\n\t\t\t\t\tconfig,\n\t\t\t\t\tsignal,\n\t\t\t\t\temit,\n\t\t\t\t\tnewMessages,\n\t\t\t\t\tdagScheduleCache,\n\t\t\t\t);\n\t\t\t\tif (batchOutcome.kind === \"ended\") {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttoolResults.push(...batchOutcome.toolResults);\n\t\t\t\thasMoreToolCalls = batchOutcome.hasMoreToolCalls;\n\t\t\t\tstopAfterToolBatch = batchOutcome.stopRun;\n\t\t\t}\n\n\t\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\t\t\tif (stopAfterToolBatch) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (maxTurns !== undefined && turnsStarted >= maxTurns) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst nextTurnContext = {\n\t\t\t\tmessage,\n\t\t\t\ttoolResults,\n\t\t\t\tcontext: currentContext,\n\t\t\t\tnewMessages,\n\t\t\t};\n\t\t\tconst nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);\n\t\t\tif (nextTurnSnapshot) {\n\t\t\t\tconst applied = applyNextTurnSnapshot(currentContext, config, nextTurnSnapshot);\n\t\t\t\tcurrentContext = applied.context;\n\t\t\t\tconfig = applied.config;\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tawait config.shouldStopAfterTurn?.({\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolResults,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\tnewMessages,\n\t\t\t\t})\n\t\t\t) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpendingMessages = await drainMessageQueue(config.getSteeringMessages);\n\t\t}\n\n\t\t// Agent would stop here. Check for follow-up messages.\n\t\tconst followUpMessages = await drainMessageQueue(config.getFollowUpMessages);\n\t\tif (followUpMessages.length > 0) {\n\t\t\t// Set as pending so inner loop processes them\n\t\t\tpendingMessages = followUpMessages;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No more messages, exit\n\t\tbreak;\n\t}\n\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/**\n * Stream an assistant response from the LLM.\n * This is where AgentMessage[] gets transformed to Message[] for the LLM.\n */\n/** True when a content part is an image block (internal {type:\"image\"} shape). */\nfunction isImageContentPart(part: unknown): boolean {\n\treturn typeof part === \"object\" && part !== null && (part as { type?: unknown }).type === \"image\";\n}\n\n/**\n * Vision-route model: the Codex OAuth model used to serve turns whose transcript\n * carries image blocks while the session model is text-only.\n *\n * `contextWindow`/`maxTokens` are NOT inherited from the session model — they\n * describe the actual Codex backend limits (400K window), which callers rely on\n * for compaction thresholds and overflow detection.\n */\nexport const VISION_ROUTE_MODEL = {\n\tprovider: \"openai-codex\",\n\tid: \"gpt-5.6-luna\",\n\tname: \"GPT-5.6 Luna\",\n\tapi: \"openai-codex-responses\",\n\tbaseUrl: \"https://chatgpt.com/backend-api\",\n\treasoning: true,\n\tinput: [\"text\", \"image\"] as const,\n\tcontextWindow: 400000,\n\tmaxTokens: 128000,\n} as const;\n\n/** True when the given model is the auto-routed vision model. */\nexport function isVisionRouteModel(model: { provider?: string; id?: string } | undefined | null): boolean {\n\treturn model?.provider === VISION_ROUTE_MODEL.provider && model?.id === VISION_ROUTE_MODEL.id;\n}\n\n/**\n * Build the vision-route model for a session model that cannot see images.\n * Preserves the session model's identity/headers so auth resolution keeps\n * working, but overrides provider/API/window with the Codex vision model.\n */\nexport function getVisionRouteModel(model: Model<any>): Model<any> {\n\treturn {\n\t\t...model,\n\t\tprovider: VISION_ROUTE_MODEL.provider,\n\t\tid: VISION_ROUTE_MODEL.id,\n\t\tname: VISION_ROUTE_MODEL.name,\n\t\tapi: VISION_ROUTE_MODEL.api,\n\t\tbaseUrl: VISION_ROUTE_MODEL.baseUrl,\n\t\treasoning: VISION_ROUTE_MODEL.reasoning,\n\t\tinput: [...VISION_ROUTE_MODEL.input],\n\t\tcontextWindow: VISION_ROUTE_MODEL.contextWindow,\n\t\tmaxTokens: VISION_ROUTE_MODEL.maxTokens,\n\t};\n}\n\nasync function streamAssistantResponse(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<AssistantMessage> {\n\t// Validate the full transcript before every provider request. This fails\n\t// fast for `assistant(A,B) -> result(A)` and any duplicate/orphan/interleaved\n\t// structure that the provider would otherwise reject opaquely.\n\tassertValidToolTranscript(\n\t\tcontext.messages,\n\t\t(summary) =>\n\t\t\t`Refusing provider request: invalid tool transcript (${summary}). ` +\n\t\t\t\"Append terminal tool results or repair the transcript before retrying.\",\n\t);\n\n\t// Apply context transform if configured (AgentMessage[] → AgentMessage[])\n\tlet messages = context.messages;\n\tif (config.transformContext) {\n\t\tmessages = await config.transformContext(messages, signal);\n\t\tassertValidToolTranscript(\n\t\t\tmessages,\n\t\t\t(summary) => `Refusing provider request: transformed context has an invalid tool transcript (${summary}).`,\n\t\t);\n\t}\n\n\t// Convert to LLM-compatible messages (AgentMessage[] → Message[])\n\tconst llmMessages = await config.convertToLlm(messages);\n\n\t// Build LLM context\n\tconst llmContext: Context = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\tsystemPromptCacheBoundary: context.systemPromptCacheBoundary,\n\t\tsystemPromptCacheBoundaryBypass: context.systemPromptCacheBoundaryBypass,\n\t\tmessages: llmMessages,\n\t\ttools: context.tools,\n\t};\n\n\tconst streamFunction = streamFn || streamSimple;\n\n\t// Auto-route image-bearing turns to a vision-capable model (openai-codex/gpt-5.6-luna).\n\t// DeepSeek and other text-only providers reject image_url parts with a 400\n\t// (\"unknown variant `image_url`, expected `text`\"), so when the transcript\n\t// carries image blocks and the configured model has no vision input, swap the\n\t// whole request to the codex OAuth model for this turn only.\n\tconst llmHasImages = llmMessages.some(\n\t\t(m) => Array.isArray(m.content) && m.content.some((p: unknown) => isImageContentPart(p)),\n\t);\n\tlet routeModel = config.model;\n\tif (llmHasImages && !(config.model.input ?? []).includes(\"image\")) {\n\t\trouteModel = getVisionRouteModel(config.model);\n\t}\n\n\t// Resolve API key (important for expiring tokens)\n\tconst resolvedApiKey = (config.getApiKey ? await config.getApiKey(routeModel.provider) : undefined) || config.apiKey;\n\n\tconst response = await streamFunction(routeModel, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\n\t\tsignal,\n\t});\n\n\treturn consumeAssistantStream(response, context, emit);\n}\n\n/** Commit the final assistant message to the transcript and emit its lifecycle. */\nasync function commitFinalAssistantMessage(\n\tresponse: AssistantMessageEventStream,\n\tcontext: AgentContext,\n\taddedPartial: boolean,\n\temit: AgentEventSink,\n): Promise<AssistantMessage> {\n\tconst finalMessage = await response.result();\n\tif (addedPartial) {\n\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t} else {\n\t\tcontext.messages.push(finalMessage);\n\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t}\n\tawait emit({ type: \"message_end\", message: finalMessage });\n\treturn finalMessage;\n}\n\n/** Forward a partial assistant update into the transcript and event sink. */\nasync function forwardPartialUpdate(\n\tevent: Extract<AssistantMessageEvent, { partial: AssistantMessage }>,\n\tpartialMessage: AssistantMessage | null,\n\tcontext: AgentContext,\n\temit: AgentEventSink,\n): Promise<AssistantMessage | null> {\n\tif (!partialMessage) {\n\t\treturn partialMessage;\n\t}\n\tconst updated = event.partial;\n\tcontext.messages[context.messages.length - 1] = updated;\n\tawait emit({\n\t\ttype: \"message_update\",\n\t\tassistantMessageEvent: event,\n\t\tmessage: { ...updated },\n\t});\n\treturn updated;\n}\n\n/** Consume the assistant event stream, maintaining the partial message in the transcript. */\nasync function consumeAssistantStream(\n\tresponse: AssistantMessageEventStream,\n\tcontext: AgentContext,\n\temit: AgentEventSink,\n): Promise<AssistantMessage> {\n\tlet partialMessage: AssistantMessage | null = null;\n\tlet addedPartial = false;\n\n\tfor await (const event of response) {\n\t\tswitch (event.type) {\n\t\t\tcase \"start\":\n\t\t\t\tpartialMessage = event.partial;\n\t\t\t\tcontext.messages.push(partialMessage);\n\t\t\t\taddedPartial = true;\n\t\t\t\tawait emit({ type: \"message_start\", message: { ...partialMessage } });\n\t\t\t\tbreak;\n\t\t\tcase \"done\":\n\t\t\tcase \"error\":\n\t\t\t\treturn commitFinalAssistantMessage(response, context, addedPartial, emit);\n\t\t\tdefault:\n\t\t\t\tpartialMessage = await forwardPartialUpdate(event, partialMessage, context, emit);\n\t\t}\n\t}\n\n\treturn commitFinalAssistantMessage(response, context, addedPartial, emit);\n}\n\n/**\n * Execute tool calls from an assistant message.\n */\nasync function executeToolCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<ExecutedToolCallBatch> {\n\tconst toolCalls = assistantMessage.content.filter((c) => c.type === \"toolCall\");\n\t// dag-v2 is opt-in only. An explicit sequential execution mode takes\n\t// precedence and continues through the established waves-v1 path below.\n\tif (config.toolScheduler === \"dag-v2\" && config.toolExecution !== \"sequential\") {\n\t\treturn executeToolCallsDagLevels(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCalls,\n\t\t\tconfig,\n\t\t\tsignal,\n\t\t\temit,\n\t\t\tdagScheduleCache,\n\t\t);\n\t}\n\tconst hasSequentialToolCall = toolCalls.some(\n\t\t(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === \"sequential\",\n\t);\n\tconst toolPolicies = new Map<string, \"sequential\" | \"parallel\">();\n\tfor (const tool of currentContext.tools ?? []) {\n\t\tif (tool.executionMode) {\n\t\t\ttoolPolicies.set(tool.name, tool.executionMode);\n\t\t}\n\t}\n\tconst batchWaves = applyConcurrencyCap(\n\t\tpartitionToolBatchWaves(\n\t\t\ttoolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments as Record<string, unknown> })),\n\t\t\t{\n\t\t\t\tcwd: config.cwd ?? process.cwd(),\n\t\t\t\ttoolPolicies,\n\t\t\t\tallowUnknownParallel: (toolName) => toolPolicies.get(toolName) === \"parallel\",\n\t\t\t},\n\t\t),\n\t\tconfig.maxToolConcurrency,\n\t);\n\tif (\n\t\tconfig.toolExecution === \"sequential\" ||\n\t\thasSequentialToolCall ||\n\t\tbatchWaves.every((wave) => wave.length === 1)\n\t) {\n\t\treturn executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\tif (batchWaves.length === 1) {\n\t\treturn executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\treturn executeToolCallsInWaves(currentContext, assistantMessage, toolCalls, batchWaves, config, signal, emit);\n}\n\n/**\n * Execute a partitioned tool-call batch wave by wave: waves run in source\n * order, calls inside a multi-call wave run concurrently, and solo waves run\n * sequentially. Waves are contiguous index runs, so the returned tool result\n * messages keep the model's original tool-call order. An all-terminating wave\n * skips every later call with a synthesized \"skipped\" result and ends the\n * run, matching the dag-v2 level-termination contract.\n */\nasync function executeToolCallsInWaves(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\twaves: number[][],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst messages: ToolResultMessage[] = [];\n\tlet terminated = false;\n\tlet executedCount = 0;\n\tfor (const wave of waves) {\n\t\tconst waveCalls = wave.map((index) => toolCalls[index]);\n\t\tconst executedWave =\n\t\t\twaveCalls.length === 1\n\t\t\t\t? await executeToolCallsSequential(currentContext, assistantMessage, waveCalls, config, signal, emit)\n\t\t\t\t: await executeToolCallsParallel(currentContext, assistantMessage, waveCalls, config, signal, emit);\n\t\tmessages.push(...executedWave.messages);\n\t\texecutedCount += wave.length;\n\t\tif (executedWave.terminate) {\n\t\t\tterminated = true;\n\t\t\tfor (const toolCall of toolCalls.slice(executedCount)) {\n\t\t\t\tconst skipped = createImmutableSnapshot(\n\t\t\t\t\tcreateSyntheticToolResult(\n\t\t\t\t\t\ttoolCall.id,\n\t\t\t\t\t\ttoolCall.name,\n\t\t\t\t\t\t\"Skipped because the preceding tool wave requested termination\",\n\t\t\t\t\t\tDate.now(),\n\t\t\t\t\t\t\"skipped\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tcurrentContext.messages.push(skipped);\n\t\t\t\tmessages.push(skipped);\n\t\t\t\tawait emitToolResultMessage(skipped, emit);\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t\tif (signal?.aborted) break;\n\t}\n\treturn {\n\t\tmessages,\n\t\tterminate: terminated,\n\t};\n}\n\n/** Bounded per-run memo for DAG schedules; plans are pure functions of the keyed inputs. */\ntype DagScheduleCache = Map<string, DagSchedulePlan>;\n\nconst DAG_SCHEDULE_CACHE_LIMIT = 64;\n\n/**\n * Canonical key covering every input claim resolution depends on. A custom\n * `resourceKeyResolver` function cannot be fingerprinted, so callers skip the\n * memo entirely when one is configured. Within a run, tool definitions (and\n * their `resourceClaims` closures) are stable, so name/mode/claims-presence\n * fingerprints are sufficient.\n */\nfunction dagScheduleCacheKey(toolCalls: readonly ClaimableToolCall[], options: ScheduleDagLevelsOptions): string {\n\tconst policies = [...(options.toolPolicies?.entries() ?? [])].sort(([left], [right]) =>\n\t\tleft < right ? -1 : left > right ? 1 : 0,\n\t);\n\tconst registered = (options.registeredTools ?? []).map((tool) => [\n\t\ttool.name,\n\t\ttool.executionMode ?? \"\",\n\t\ttypeof tool.resourceClaims === \"function\" ? \"1\" : \"0\",\n\t]);\n\treturn JSON.stringify([\n\t\ttoolCalls.map((call) => [call.name, call.arguments ?? null]),\n\t\toptions.cwd,\n\t\toptions.strictExtensionClaims === true,\n\t\toptions.maxConcurrency ?? null,\n\t\tpolicies,\n\t\tregistered,\n\t]);\n}\n\n/**\n * Schedule with a per-run memo. Identical batches (provider retries, stubborn\n * re-emissions) re-resolve path identities and custom claims; the plan is a\n * pure function of the canonical inputs, so replaying it is safe. Returns\n * `null` when the underlying schedule was aborted. Cached levels are handed\n * out as copies because callers append to and reorder them.\n */\nexport async function scheduleDagLevelsMemo(\n\ttoolCalls: readonly ClaimableToolCall[],\n\toptions: ScheduleDagLevelsOptions,\n\tsignal: AbortSignal | undefined,\n\tcache: DagScheduleCache,\n): Promise<DagSchedulePlan | null> {\n\tif (options.resourceKeyResolver) {\n\t\tconst scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);\n\t\treturn scheduled.kind === \"aborted\" ? null : scheduled.value;\n\t}\n\tconst key = dagScheduleCacheKey(toolCalls, options);\n\tconst cached = cache.get(key);\n\tif (cached) {\n\t\tcache.delete(key);\n\t\tcache.set(key, cached);\n\t\treturn { levels: cached.levels.map((level) => level.slice()), planKey: cached.planKey };\n\t}\n\tconst scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);\n\tif (scheduled.kind === \"aborted\") {\n\t\treturn null;\n\t}\n\tif (cache.size >= DAG_SCHEDULE_CACHE_LIMIT) {\n\t\tconst oldest = cache.keys().next();\n\t\tif (!oldest.done) {\n\t\t\tcache.delete(oldest.value);\n\t\t}\n\t}\n\tcache.set(key, { levels: scheduled.value.levels.map((level) => level.slice()), planKey: scheduled.value.planKey });\n\treturn scheduled.value;\n}\n\n/**\n * Schedule planned calls into candidate DAG levels. Immediate plans fail\n * before any tool executes, so they carry no claims and fold into the first\n * level instead of degrading the whole batch to sequential singleton levels.\n * Unresolvable argument payloads stay in the schedule and fail closed into\n * exclusive barriers inside claim resolution.\n */\nasync function schedulePlannedDagLevels(\n\tplans: Array<PlannedToolCall | ImmediateToolCallOutcome>,\n\ttoolPolicies: ReadonlyMap<string, \"sequential\" | \"parallel\">,\n\tboundTools: AgentTool<any>[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<number[][]> {\n\tconst schedulableSourceIndices: number[] = [];\n\tconst claimableCalls: ClaimableToolCall[] = [];\n\tconst immediateSourceIndices: number[] = [];\n\tplans.forEach((plan, sourceIndex) => {\n\t\tif (plan.kind === \"planned\") {\n\t\t\tschedulableSourceIndices.push(sourceIndex);\n\t\t\tclaimableCalls.push({ id: plan.toolCall.id, name: plan.toolCall.name, arguments: plan.args });\n\t\t} else {\n\t\t\timmediateSourceIndices.push(sourceIndex);\n\t\t}\n\t});\n\tconst scheduled = await scheduleDagLevelsMemo(\n\t\tclaimableCalls,\n\t\t{\n\t\t\tcwd: config.cwd ?? process.cwd(),\n\t\t\ttoolPolicies,\n\t\t\tregisteredTools: boundTools,\n\t\t\tstrictExtensionClaims: config.strictExtensionClaims,\n\t\t\tmaxConcurrency: config.maxToolConcurrency,\n\t\t\tresourceKeyResolver: config.resourceKeyResolver,\n\t\t},\n\t\tsignal,\n\t\tdagScheduleCache,\n\t);\n\tif (scheduled === null) {\n\t\treturn [];\n\t}\n\tconst levels = scheduled.levels.map((level) => level.map((position) => schedulableSourceIndices[position]));\n\tif (immediateSourceIndices.length === 0) {\n\t\treturn levels;\n\t}\n\tif (levels.length === 0) {\n\t\treturn [[...immediateSourceIndices]];\n\t}\n\tlevels[0] = [...levels[0], ...immediateSourceIndices].sort((left, right) => left - right);\n\treturn levels;\n}\n\n/**\n * Execute a tool-call batch using the dag-v2 scheduler.\n *\n * Schedule every planned call through the DAG: immediate plans fail before\n * any tool executes, so they carry no claims and fold into the first level\n * instead of degrading the whole batch to sequential singleton levels.\n * Unresolvable argument payloads stay in the schedule and fail closed into\n * exclusive barriers inside claim resolution. Each candidate level authorizes\n * calls, re-resolves claims from exact final args, and emits lifecycle starts\n * only when a final safe sublevel begins. Results remain globally buffered\n * and are emitted in source order.\n */\nasync function executeToolCallsDagLevels(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<ExecutedToolCallBatch> {\n\tconst plans = toolCalls.map((toolCall) => planToolCall(currentContext, toolCall));\n\tconst boundTools = plans.flatMap((plan) => (plan.kind === \"planned\" ? [plan.tool] : []));\n\tconst toolPolicies = new Map<string, \"sequential\" | \"parallel\">();\n\tfor (const tool of boundTools) {\n\t\tif (tool.executionMode && !toolPolicies.has(tool.name)) toolPolicies.set(tool.name, tool.executionMode);\n\t}\n\n\tconst levels = await schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache);\n\n\tconst finalizedByIndex: Array<FinalizedToolCallOutcome | undefined> = new Array(toolCalls.length).fill(undefined);\n\tlet skippedReason: string | undefined;\n\tlet stoppedByUnsettledTimeout = false;\n\n\tfor (const level of levels) {\n\t\tif (signal?.aborted) break;\n\t\tconst executedLevel = await runDagLevelCalls(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\tlevel,\n\t\t\ttoolCalls,\n\t\t\tplans,\n\t\t\ttoolPolicies,\n\t\t\tconfig,\n\t\t\tsignal,\n\t\t\temit,\n\t\t\tdagScheduleCache,\n\t\t);\n\t\tfor (const outcome of executedLevel.outcomes) finalizedByIndex[outcome.sourceIndex] = outcome.finalized;\n\t\tif (signal?.aborted) break;\n\t\tif (executedLevel.stoppedByUnsettledTimeout) {\n\t\t\tskippedReason = \"Skipped because a preceding DAG tool timed out before its execution promise settled\";\n\t\t\tstoppedByUnsettledTimeout = true;\n\t\t\tbreak;\n\t\t}\n\t\tif (shouldTerminateToolBatch(executedLevel.outcomes.map((outcome) => outcome.finalized))) {\n\t\t\tskippedReason = \"Skipped because the preceding DAG level requested termination\";\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst messages: ToolResultMessage[] = [];\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tfor (let index = 0; index < toolCalls.length; index++) {\n\t\tconst finalized = finalizedByIndex[index];\n\t\tif (finalized) {\n\t\t\tmessages.push(createToolResultMessage(finalized));\n\t\t\tfinalizedCalls.push(finalized);\n\t\t} else if (signal?.aborted) {\n\t\t\tconst toolCall = toolCalls[index];\n\t\t\tmessages.push(\n\t\t\t\tcreateImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, \"Operation aborted\")),\n\t\t\t);\n\t\t} else if (skippedReason !== undefined) {\n\t\t\tconst toolCall = toolCalls[index];\n\t\t\tmessages.push(\n\t\t\t\tcreateImmutableSnapshot(\n\t\t\t\t\tcreateSyntheticToolResult(toolCall.id, toolCall.name, skippedReason, Date.now(), \"skipped\"),\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\t}\n\t// Close the full source-ordered batch before result notification; calls not\n\t// reached by a candidate level receive no execution lifecycle.\n\tfor (const message of messages) {\n\t\tcurrentContext.messages.push(message);\n\t\ttry {\n\t\t\tawait emitToolResultMessage(message, emit);\n\t\t} finally {\n\t\t\tfinalizedCalls.find(({ toolCall }) => toolCall.id === message.toolCallId)?.commitTerminal?.();\n\t\t}\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: skippedReason !== undefined || shouldTerminateToolBatch(finalizedCalls),\n\t\tstopRun: stoppedByUnsettledTimeout,\n\t};\n}\n\ntype DagLevelOutcome = { sourceIndex: number; finalized: FinalizedToolCallOutcome };\n\n/** Re-plan final claims for a runnable candidate level from exact post-hook arguments. */\nasync function rescheduleRunnableLevels(\n\trunnable: Array<{ sourceIndex: number; preparation: PreparedToolCall }>,\n\ttoolPolicies: ReadonlyMap<string, \"sequential\" | \"parallel\">,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<number[][] | null> {\n\tconst finalClaimableCalls = runnable.map(({ preparation }) => ({\n\t\tid: preparation.toolCall.id,\n\t\tname: preparation.toolCall.name,\n\t\targuments: preparation.args,\n\t}));\n\tconst scheduled = await scheduleDagLevelsMemo(\n\t\tfinalClaimableCalls,\n\t\t{\n\t\t\tcwd: config.cwd ?? process.cwd(),\n\t\t\ttoolPolicies,\n\t\t\tregisteredTools: runnable.map(({ preparation }) => preparation.tool),\n\t\t\tstrictExtensionClaims: config.strictExtensionClaims,\n\t\t\tmaxConcurrency: config.maxToolConcurrency,\n\t\t\tresourceKeyResolver: config.resourceKeyResolver,\n\t\t},\n\t\tsignal,\n\t\tdagScheduleCache,\n\t);\n\treturn scheduled === null ? null : scheduled.levels;\n}\n\n/** Authorize one candidate DAG level, re-plan final claims, and run its safe sublevels. */\nasync function runDagLevelCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tlevelIndices: readonly number[],\n\ttoolCalls: AgentToolCall[],\n\tplans: Array<PlannedToolCall | ImmediateToolCallOutcome>,\n\ttoolPolicies: ReadonlyMap<string, \"sequential\" | \"parallel\">,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<{ outcomes: DagLevelOutcome[]; stoppedByUnsettledTimeout: boolean }> {\n\tconst outcomes: DagLevelOutcome[] = [];\n\tconst runnable: Array<{ sourceIndex: number; preparation: PreparedToolCall }> = [];\n\n\tfor (const sourceIndex of levelIndices) {\n\t\tconst toolCall = toolCalls[sourceIndex];\n\t\tconst plan = plans[sourceIndex];\n\t\tconst preparation =\n\t\t\tplan.kind === \"immediate\"\n\t\t\t\t? plan\n\t\t\t\t: await authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\toutcomes.push({\n\t\t\t\tsourceIndex,\n\t\t\t\tfinalized: {\n\t\t\t\t\ttoolCall,\n\t\t\t\t\tresult: preparation.result,\n\t\t\t\t\tisError: preparation.isError,\n\t\t\t\t\tenvelope: preparation.envelope,\n\t\t\t\t},\n\t\t\t});\n\t\t} else {\n\t\t\trunnable.push({ sourceIndex, preparation });\n\t\t}\n\t\tif (signal?.aborted) return { outcomes, stoppedByUnsettledTimeout: false };\n\t}\n\n\t// Re-plan final claims from the exact post-hook arguments. Non-plain\n\t// payloads stay in the schedule and fail closed into exclusive barriers\n\t// inside claim resolution rather than degrading the level to sequential\n\t// singletons.\n\tconst executionLevels = await rescheduleRunnableLevels(runnable, toolPolicies, config, signal, dagScheduleCache);\n\tif (executionLevels === null) return { outcomes, stoppedByUnsettledTimeout: false };\n\n\tfor (const executionLevel of executionLevels) {\n\t\tif (signal?.aborted) break;\n\t\tfor (const entryIndex of executionLevel) {\n\t\t\tawait emitToolExecutionStart(runnable[entryIndex].preparation, emit);\n\t\t}\n\t\tconst finalizedLevel = await Promise.all(\n\t\t\texecutionLevel.map(async (entryIndex): Promise<DagLevelOutcome> => {\n\t\t\t\tconst entry = runnable[entryIndex];\n\t\t\t\tconst executed = await executePreparedToolCall(entry.preparation, config, signal, emit);\n\t\t\t\tconst finalized = await finalizeExecutedToolCall({\n\t\t\t\t\tcurrentContext,\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\tprepared: entry.preparation,\n\t\t\t\t\texecuted,\n\t\t\t\t\tafterToolCall: config.afterToolCall,\n\t\t\t\t\tsignal,\n\t\t\t\t});\n\t\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\t\treturn { sourceIndex: entry.sourceIndex, finalized };\n\t\t\t}),\n\t\t);\n\t\toutcomes.push(...finalizedLevel);\n\t\tif (signal?.aborted) break;\n\t\tif (\n\t\t\tfinalizedLevel.some(\n\t\t\t\t({ finalized }) =>\n\t\t\t\t\tfinalized.envelope.disposition === \"timeout\" && finalized.isRealPromiseSettled?.() === false,\n\t\t\t)\n\t\t) {\n\t\t\treturn { outcomes, stoppedByUnsettledTimeout: true };\n\t\t}\n\t}\n\treturn { outcomes, stoppedByUnsettledTimeout: false };\n}\n\ntype ExecutedToolCallBatch = {\n\tmessages: ToolResultMessage[];\n\tterminate: boolean;\n\t/**\n\t * End the whole run without another provider request. Only the dag-v2\n\t * scheduler produces this (unsettled-timeout guard); the sequential,\n\t * parallel, and waves paths deliberately continue after an unsettled\n\t * timeout — the divergence is pinned by the tool-timeout loop tests.\n\t */\n\tstopRun?: boolean;\n};\n\nasync function executeToolCallsSequential(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tconst messages: ToolResultMessage[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tfinalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t\tenvelope: preparation.envelope,\n\t\t\t};\n\t\t} else {\n\t\t\tawait emitToolExecutionStart(preparation, emit);\n\t\t\tconst executed = await executePreparedToolCall(preparation, config, signal, emit);\n\t\t\tfinalized = await finalizeExecutedToolCall({\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tprepared: preparation,\n\t\t\t\texecuted,\n\t\t\t\tafterToolCall: config.afterToolCall,\n\t\t\t\tsignal,\n\t\t\t});\n\t\t}\n\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tcurrentContext.messages.push(toolResultMessage);\n\t\tif (preparation.kind === \"prepared\") await emitToolExecutionEnd(finalized, emit);\n\t\ttry {\n\t\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\t} finally {\n\t\t\tfinalized.commitTerminal?.();\n\t\t}\n\t\tfinalizedCalls.push(finalized);\n\t\tmessages.push(toolResultMessage);\n\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(finalizedCalls),\n\t};\n}\n\nasync function executeToolCallsParallel(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tconst finalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t\tenvelope: preparation.envelope,\n\t\t\t} satisfies FinalizedToolCallOutcome;\n\t\t\tfinalizedCalls.push(finalized);\n\t\t\tif (signal?.aborted) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tfinalizedCalls.push(async () => {\n\t\t\tawait emitToolExecutionStart(preparation, emit);\n\t\t\tconst executed = await executePreparedToolCall(preparation, config, signal, emit);\n\t\t\tconst finalized = await finalizeExecutedToolCall({\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tprepared: preparation,\n\t\t\t\texecuted,\n\t\t\t\tafterToolCall: config.afterToolCall,\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\treturn finalized;\n\t\t});\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst orderedFinalizedCalls = await Promise.all(\n\t\tfinalizedCalls.map((entry) => (typeof entry === \"function\" ? entry() : Promise.resolve(entry))),\n\t);\n\tconst messages: ToolResultMessage[] = [];\n\tfor (const finalized of orderedFinalizedCalls) {\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tcurrentContext.messages.push(toolResultMessage);\n\t\ttry {\n\t\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\t} finally {\n\t\t\tfinalized.commitTerminal?.();\n\t\t}\n\t\tmessages.push(toolResultMessage);\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(orderedFinalizedCalls),\n\t};\n}\n\ntype PlannedToolCall = {\n\tkind: \"planned\";\n\ttoolCall: AgentToolCall;\n\tpreparedToolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\targs: unknown;\n};\n\ntype PreparedToolCall = {\n\tkind: \"prepared\";\n\ttoolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\t/** Immutable scheduler/executor arguments fixed after the authorization hook. */\n\targs: unknown;\n\t/** Separate immutable public-event snapshot. */\n\teventArgs: unknown;\n\t/** Effective per-call timeout in ms resolved by precedence; 0 disables it. */\n\ttimeoutMs: number;\n};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n\tenvelope: ToolResultEnvelope;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\n\nfunction shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {\n\treturn finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);\n}\n\nfunction prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {\n\tif (!tool.prepareArguments) {\n\t\treturn toolCall;\n\t}\n\tconst preparedArguments = tool.prepareArguments(toolCall.arguments);\n\tif (preparedArguments === toolCall.arguments) {\n\t\treturn toolCall;\n\t}\n\treturn {\n\t\t...toolCall,\n\t\targuments: preparedArguments as Record<string, any>,\n\t};\n}\n\nfunction immediateOutcome(disposition: ToolCallDisposition, reason: string): ImmediateToolCallOutcome {\n\treturn {\n\t\tkind: \"immediate\",\n\t\tresult: createErrorToolResult(reason),\n\t\tisError: true,\n\t\tenvelope: createToolResultEnvelope({ disposition, synthetic: true, executionStarted: false, reason }),\n\t};\n}\n\nfunction planToolCall(\n\tcurrentContext: AgentContext,\n\tuntrustedToolCall: AgentToolCall,\n): PlannedToolCall | ImmediateToolCallOutcome {\n\ttry {\n\t\tconst toolCall = createImmutableJsonSnapshot(untrustedToolCall);\n\t\tif (toolCall.id.length === 0 || toolCall.name.length === 0) throw new TypeError(\"Invalid empty tool identity\");\n\t\tconst candidate = currentContext.tools?.find((tool) => tool.name === toolCall.name);\n\t\tif (!candidate) return immediateOutcome(\"failed\", `Tool ${toolCall.name} not found`);\n\t\tconst tool = bindToolIdentity(candidate, toolCall.name);\n\t\tconst prepared = prepareToolCallArguments(tool, toolCall);\n\t\tconst args = createImmutableJsonSnapshot(prepared.arguments);\n\t\tconst preparedToolCall = createImmutableSnapshot({ ...toolCall, arguments: args });\n\t\treturn { kind: \"planned\", toolCall, preparedToolCall, tool, args };\n\t} catch (error) {\n\t\treturn immediateOutcome(\"failed\", error instanceof Error ? error.message : String(error));\n\t}\n}\n\nasync function authorizePlannedToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tplan: PlannedToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\ttry {\n\t\tconst hookArgs = parseJsonValue(validateToolArguments(plan.tool, plan.preparedToolCall));\n\t\tconst beforeToolCall = config.beforeToolCall;\n\t\tif (beforeToolCall) {\n\t\t\tconst bounded = await awaitWithAbort(\n\t\t\t\t() =>\n\t\t\t\t\tbeforeToolCall(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tassistantMessage: createImmutableSnapshot(assistantMessage),\n\t\t\t\t\t\t\ttoolCall: plan.toolCall,\n\t\t\t\t\t\t\targs: hookArgs,\n\t\t\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t),\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (bounded.kind === \"aborted\" || signal?.aborted) return immediateOutcome(\"aborted\", \"Operation aborted\");\n\t\t\tif (bounded.value?.block) {\n\t\t\t\treturn immediateOutcome(\"blocked\", bounded.value.reason || \"Tool execution was blocked\");\n\t\t\t}\n\t\t}\n\t\tif (signal?.aborted) return immediateOutcome(\"aborted\", \"Operation aborted\");\n\t\tconst args = createImmutableJsonSnapshot(hookArgs);\n\t\treturn {\n\t\t\tkind: \"prepared\",\n\t\t\ttoolCall: plan.toolCall,\n\t\t\ttool: plan.tool,\n\t\t\targs,\n\t\t\teventArgs: createImmutableSnapshot(args),\n\t\t\ttimeoutMs: resolveToolTimeoutMs(plan.tool, config, plan.toolCall.name),\n\t\t};\n\t} catch (error) {\n\t\treturn immediateOutcome(\"failed\", error instanceof Error ? error.message : String(error));\n\t}\n}\n\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\tconst plan = planToolCall(currentContext, toolCall);\n\treturn plan.kind === \"immediate\"\n\t\t? plan\n\t\t: authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tlet realPromiseSettled = false;\n\tlet commitTerminal = (): void => {};\n\tconst terminalCommitted = new Promise<void>((resolve) => {\n\t\tcommitTerminal = resolve;\n\t});\n\tconst executed = await runToolCallWithTimeout({\n\t\ttoolCallId: prepared.toolCall.id,\n\t\ttoolName: prepared.toolCall.name,\n\t\ttimeoutMs: prepared.timeoutMs,\n\t\tlateSettlement: config.toolExecutionPolicy?.lateSettlement,\n\t\tsignal,\n\t\tstart: async (childSignal, onUpdate) => {\n\t\t\ttry {\n\t\t\t\treturn await prepared.tool.execute(prepared.toolCall.id, prepared.args as never, childSignal, onUpdate);\n\t\t\t} finally {\n\t\t\t\trealPromiseSettled = true;\n\t\t\t}\n\t\t},\n\t\temitUpdate: (partialResult) =>\n\t\t\temit({\n\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\targs: prepared.eventArgs,\n\t\t\t\tpartialResult: createImmutableSnapshot(partialResult),\n\t\t\t}),\n\t\temitLateSettlement: async (settlement) => {\n\t\t\tawait terminalCommitted;\n\t\t\tawait emit({\n\t\t\t\ttype: \"tool_execution_late_settlement\",\n\t\t\t\ttoolCallId: settlement.toolCallId,\n\t\t\t\ttoolName: settlement.toolName,\n\t\t\t\tdisposition: settlement.disposition,\n\t\t\t\toutcome: settlement.outcome,\n\t\t\t});\n\t\t},\n\t\ttoErrorResult: (error) => createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t});\n\treturn { ...executed, isRealPromiseSettled: () => realPromiseSettled, commitTerminal };\n}\n\nasync function emitToolExecutionStart(prepared: PreparedToolCall, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_start\",\n\t\ttoolCallId: prepared.toolCall.id,\n\t\ttoolName: prepared.toolCall.name,\n\t\targs: prepared.eventArgs,\n\t});\n}\n\nasync function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_end\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tresult: createImmutableSnapshot(finalized.result),\n\t\tisError: finalized.isError,\n\t});\n}\n\nfunction createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {\n\treturn createImmutableSnapshot({\n\t\trole: \"toolResult\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tcontent: finalized.result.content,\n\t\tdetails: stampToolResultEnvelope(finalized.result.details, finalized.envelope),\n\t\tisError: finalized.isError,\n\t\ttimestamp: Date.now(),\n\t});\n}\n\nasync function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise<void> {\n\tawait emit({ type: \"message_start\", message: toolResultMessage });\n\tawait emit({ type: \"message_end\", message: toolResultMessage });\n}\n\n/** Commit and notify one aborted terminal for each unresolved, unstarted call. */\nasync function closeAbortedToolBatch(\n\tcurrentContext: AgentContext,\n\ttoolCalls: AgentToolCall[],\n\texistingResults: ToolResultMessage[],\n\temit: AgentEventSink,\n): Promise<ToolResultMessage[]> {\n\tconst resolvedIds = new Set(existingResults.map((result) => result.toolCallId));\n\tconst synthesized: ToolResultMessage[] = [];\n\tfor (const toolCall of toolCalls) {\n\t\tif (resolvedIds.has(toolCall.id)) {\n\t\t\tcontinue;\n\t\t}\n\t\tconst result = createImmutableSnapshot(\n\t\t\tcreateSyntheticToolResult(toolCall.id, toolCall.name, \"Operation aborted\"),\n\t\t);\n\t\tcurrentContext.messages.push(result);\n\t\tawait emit({ type: \"message_start\", message: result });\n\t\tawait emit({ type: \"message_end\", message: result });\n\t\tsynthesized.push(result);\n\t\t// Guard against a duplicated call id within the same assistant message.\n\t\tresolvedIds.add(toolCall.id);\n\t}\n\treturn synthesized;\n}\n"]}
1
+ {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAKN,WAAW,EACX,KAAK,KAAK,EAEV,KAAK,iBAAiB,EAEtB,MAAM,QAAQ,CAAC;AAGhB,OAAO,EAEN,KAAK,eAAe,EACpB,KAAK,wBAAwB,EAE7B,MAAM,yBAAyB,CAAC;AAYjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAQnE,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,eAAe,EAEpB,KAAK,YAAY,EAKjB,KAAK,QAAQ,EAGb,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAWzE,MAAM,WAAW,sBAAsB;IACtC,kFAAkF;IAClF,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,4EAA4E;IAC5E,cAAc,EAAE,YAAY,GAAG,SAAS,CAAC;IACzC,0EAA0E;IAC1E,cAAc,EAAE,iBAAiB,EAAE,CAAC;CACpC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACrC,iBAAiB,EAAE,SAAS,YAAY,EAAE,EAC1C,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,OAAO,GACd,sBAAsB,CAiCxB;AAqCD;;;GAGG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CA0BzC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAoCzC;AAED,wBAAsB,YAAY,CACjC,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAczB;AAED,wBAAsB,oBAAoB,CACzC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAezB;AAoTD;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;CAUrB,CAAC;AAEX,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAExG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAajE;AA4OD,4FAA4F;AAC5F,KAAK,gBAAgB,GAAG,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;AA8BrD;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CAC1C,SAAS,EAAE,SAAS,iBAAiB,EAAE,EACvC,OAAO,EAAE,wBAAwB,EACjC,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,KAAK,EAAE,gBAAgB,GACrB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAwBjC","sourcesContent":["/**\n * Agent loop that works with AgentMessage throughout.\n * Transforms to Message[] only at the LLM call boundary.\n */\n\nimport {\n\ttype AssistantMessage,\n\ttype AssistantMessageEvent,\n\ttype AssistantMessageEventStream,\n\ttype Context,\n\tEventStream,\n\ttype Model,\n\tstreamSimple,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"omk-ai\";\nimport { bindToolIdentity } from \"./builtin-tool-resource-claims.ts\";\nimport { partitionToolBatchWaves } from \"./parallel-tool-batch.ts\";\nimport {\n\tapplyConcurrencyCap,\n\ttype DagSchedulePlan,\n\ttype ScheduleDagLevelsOptions,\n\tscheduleDagLevels,\n} from \"./tool-dag-scheduler.ts\";\nimport {\n\tawaitWithAbort,\n\tcreateErrorToolResult,\n\tcreateImmutableJsonSnapshot,\n\tcreateImmutableSnapshot,\n\ttype ExecutedToolCallOutcome,\n\ttype FinalizedToolCallOutcome,\n\tfinalizeExecutedToolCall,\n\tparseJsonValue,\n\tstampToolResultEnvelope,\n} from \"./tool-execution-boundary.ts\";\nimport type { ClaimableToolCall } from \"./tool-resource-claims.ts\";\nimport { resolveToolTimeoutMs, runToolCallWithTimeout } from \"./tool-timeout.ts\";\nimport { hasUnsettledTimeout } from \"./tool-timeout-settlement.ts\";\nimport {\n\tcreateSyntheticToolResult,\n\tinspectTranscriptIntegrity,\n\trepairTranscriptIntegrity,\n} from \"./tool-transcript-integrity.ts\";\nimport {\n\ttype AgentContext,\n\ttype AgentEvent,\n\ttype AgentLoopConfig,\n\ttype AgentLoopTurnUpdate,\n\ttype AgentMessage,\n\ttype AgentTool,\n\ttype AgentToolCall,\n\ttype AgentToolResult,\n\tcreateToolResultEnvelope,\n\ttype StreamFn,\n\ttype ToolCallDisposition,\n\ttype ToolResultEnvelope,\n} from \"./types.ts\";\n\nexport type AgentEventSink = (event: AgentEvent) => Promise<void> | void;\n\nconst EMPTY_USAGE = {\n\tinput: 0,\n\toutput: 0,\n\tcacheRead: 0,\n\tcacheWrite: 0,\n\ttotalTokens: 0,\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\nexport interface FailureTerminationPlan {\n\t/** Messages to publish as the run result (closure results + optional failure). */\n\tmessages: AgentMessage[];\n\t/** Synthetic assistant failure message, or `undefined` when fail-closed. */\n\tfailureMessage: AgentMessage | undefined;\n\t/** Synthetic tool results used to close an open turn, in source order. */\n\tclosureResults: ToolResultMessage[];\n}\n\n/**\n * Decide how to terminate a run after the underlying loop rejected.\n *\n * A synthetic assistant failure may only be appended on top of a transcript\n * whose tool turns are all closed. When `completedMessages` ends with an open\n * tool turn, a safe missing-only closure (synthetic results for the unambiguous\n * missing tail calls) is appended first so the failure assistant never creates\n * an `assistant(tool calls) -> assistant(failure)` interleaving.\n *\n * If the transcript is ambiguous (duplicate/orphan/interleave, or a\n * mid-transcript gap) it is never auto-repaired: the plan returns no failure\n * message so the caller ends the stream without fabricating a turn over\n * corruption. Pure apart from `Date.now()` on the failure message.\n */\nexport function planFailureTermination(\n\tcompletedMessages: readonly AgentMessage[],\n\tmodel: Model<any>,\n\terror: unknown,\n\taborted: boolean,\n): FailureTerminationPlan {\n\tconst messages = [...completedMessages];\n\tconst closureResults: ToolResultMessage[] = [];\n\n\tif (!inspectTranscriptIntegrity(messages).ok) {\n\t\ttry {\n\t\t\tconst repaired = repairTranscriptIntegrity(messages, \"Tool result missing; run terminated by error\");\n\t\t\t// repairTranscriptIntegrity appends synthetic results only for\n\t\t\t// unambiguous missing tail calls; anything ambiguous throws above.\n\t\t\tfor (let i = messages.length; i < repaired.length; i++) {\n\t\t\t\tconst result = createImmutableSnapshot(repaired[i] as ToolResultMessage);\n\t\t\t\tclosureResults.push(result);\n\t\t\t\tmessages.push(result);\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ambiguous transcript: never auto-repair. Fail closed without a\n\t\t\t// synthetic assistant turn over a corrupt transcript.\n\t\t\treturn { messages, failureMessage: undefined, closureResults: [] };\n\t\t}\n\t}\n\n\tconst failureMessage: AgentMessage = createImmutableSnapshot({\n\t\trole: \"assistant\",\n\t\tcontent: [{ type: \"text\", text: \"\" }],\n\t\tapi: model.api,\n\t\tprovider: model.provider,\n\t\tmodel: model.id,\n\t\tusage: EMPTY_USAGE,\n\t\tstopReason: aborted ? \"aborted\" : \"error\",\n\t\terrorMessage: error instanceof Error ? error.message : String(error),\n\t\ttimestamp: Date.now(),\n\t});\n\treturn { messages: [...messages, failureMessage], failureMessage, closureResults };\n}\n\n/**\n * Terminate the public event stream after the underlying loop rejected.\n *\n * Uses {@link planFailureTermination} so the disposition of any unresolved tool\n * calls matches transcript repair exactly: an unambiguous open turn is closed\n * with synthetic results before a coherent\n * message_start/message_end/turn_end/agent_end sequence for the failure\n * assistant, and an ambiguous transcript fails closed (agent_end only, no\n * fabricated assistant). The stream always settles for `for await` consumers\n * and `stream.result()`.\n */\nfunction endStreamWithFailure(\n\tstream: EventStream<AgentEvent, AgentMessage[]>,\n\tconfig: AgentLoopConfig,\n\tcompletedMessages: AgentMessage[],\n\terror: unknown,\n\tsignal?: AbortSignal,\n): void {\n\tconst plan = planFailureTermination(completedMessages, config.model, error, signal?.aborted ?? false);\n\n\tfor (const result of plan.closureResults) {\n\t\tstream.push({ type: \"message_start\", message: result });\n\t\tstream.push({ type: \"message_end\", message: result });\n\t}\n\n\tif (plan.failureMessage) {\n\t\tstream.push({ type: \"message_start\", message: plan.failureMessage });\n\t\tstream.push({ type: \"message_end\", message: plan.failureMessage });\n\t\tstream.push({ type: \"turn_end\", message: plan.failureMessage, toolResults: [] });\n\t}\n\n\tstream.push({ type: \"agent_end\", messages: plan.messages });\n\tstream.end(plan.messages);\n}\n\n/**\n * Start an agent loop with a new prompt message.\n * The prompt is added to the context and events are emitted for it.\n */\nexport function agentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tconst stream = createAgentStream();\n\tconst completedMessages: AgentMessage[] = [];\n\n\tvoid runAgentLoop(\n\t\tprompts,\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tcompletedMessages.push(event.message);\n\t\t\t}\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then(\n\t\t(messages) => {\n\t\t\tstream.end(messages);\n\t\t},\n\t\t(error: unknown) => {\n\t\t\tendStreamWithFailure(stream, config, completedMessages, error, signal);\n\t\t},\n\t);\n\n\treturn stream;\n}\n\n/**\n * Continue an agent loop from the current context without adding a new message.\n * Used for retries - context already has user message or tool results.\n *\n * **Important:** The last message in context must convert to a `user` or `toolResult` message\n * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.\n * This cannot be validated here since `convertToLlm` is only called once per turn.\n */\nexport function agentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\t// Guard: the last message must be one the provider can build on. A plain\n\t// text/thinking assistant turn is acceptable (convertToLlm may merge or the\n\t// provider supports assistant pre-fill); only an assistant turn that still\n\t// carries unresolved tool calls is a hard error because the provider will\n\t// reject the request without matching tool results.\n\tassertContinuableTranscript(context.messages);\n\n\tconst stream = createAgentStream();\n\tconst completedMessages: AgentMessage[] = [];\n\n\tvoid runAgentLoopContinue(\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tcompletedMessages.push(event.message);\n\t\t\t}\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then(\n\t\t(messages) => {\n\t\t\tstream.end(messages);\n\t\t},\n\t\t(error: unknown) => {\n\t\t\tendStreamWithFailure(stream, config, completedMessages, error, signal);\n\t\t},\n\t);\n\n\treturn stream;\n}\n\nexport async function runAgentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tconst newMessages: AgentMessage[] = [...prompts];\n\tconst currentContext: AgentContext = { ...context, messages: [...context.messages, ...prompts] };\n\tconst publish: AgentEventSink = (event) => emit(createImmutableSnapshot(event));\n\n\tawait publish({ type: \"agent_start\" });\n\tawait publish({ type: \"turn_start\" });\n\tfor (const prompt of prompts) {\n\t\tawait publish({ type: \"message_start\", message: prompt });\n\t\tawait publish({ type: \"message_end\", message: prompt });\n\t}\n\n\tawait runLoop(currentContext, newMessages, config, signal, publish, streamFn);\n\treturn newMessages;\n}\n\nexport async function runAgentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tassertContinuableTranscript(context.messages);\n\n\tconst newMessages: AgentMessage[] = [];\n\tconst currentContext: AgentContext = { ...context };\n\tconst publish: AgentEventSink = (event) => emit(createImmutableSnapshot(event));\n\n\tawait publish({ type: \"agent_start\" });\n\tawait publish({ type: \"turn_start\" });\n\tawait runLoop(currentContext, newMessages, config, signal, publish, streamFn);\n\treturn newMessages;\n}\n\nfunction createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {\n\treturn new EventStream<AgentEvent, AgentMessage[]>(\n\t\t(event: AgentEvent) => event.type === \"agent_end\",\n\t\t(event: AgentEvent) => (event.type === \"agent_end\" ? event.messages : []),\n\t);\n}\n\n/**\n * Validate the full transcript before continuing. Replaces the earlier\n * last-message-only tail check: `assistant(A,B) -> result(A)` and any\n * duplicate/orphan/interleaved structure now fail before the first provider\n * request, not only a trailing assistant message that still carries tool calls.\n *\n * A trailing assistant turn with no tool calls (plain text/thinking) remains\n * continuable, so compaction, session resume, and explicit retries keep working.\n */\nfunction assertContinuableTranscript(messages: AgentMessage[]): void {\n\tconst report = inspectTranscriptIntegrity(messages);\n\tif (report.ok) {\n\t\treturn;\n\t}\n\tconst last = messages[messages.length - 1];\n\tif (last !== undefined && last.role === \"assistant\" && last.content.some((block) => block.type === \"toolCall\")) {\n\t\tthrow new Error(\n\t\t\t\"Cannot continue: the last assistant message has pending tool calls without matching results. \" +\n\t\t\t\t\"Add tool results or a new user message before continuing.\",\n\t\t);\n\t}\n\tconst summary = report.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(\", \");\n\tthrow new Error(\n\t\t`Cannot continue: invalid tool transcript (${summary}). ` +\n\t\t\t\"Append terminal tool results or repair the transcript before continuing.\",\n\t);\n}\n\n/** Throw when the tool transcript could not be accepted by a provider. */\nfunction assertValidToolTranscript(messages: AgentMessage[], describe: (summary: string) => string): void {\n\tconst integrityReport = inspectTranscriptIntegrity(messages);\n\tif (integrityReport.ok) {\n\t\treturn;\n\t}\n\tconst summary = integrityReport.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(\", \");\n\tthrow new Error(describe(summary));\n}\n\n/** Inject queued steering messages into the transcript before the next assistant turn. */\nasync function injectPendingMessages(\n\tpendingMessages: AgentMessage[],\n\tcurrentContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\temit: AgentEventSink,\n): Promise<void> {\n\tfor (const message of pendingMessages) {\n\t\tawait emit({ type: \"message_start\", message });\n\t\tawait emit({ type: \"message_end\", message });\n\t\tcurrentContext.messages.push(message);\n\t\tnewMessages.push(message);\n\t}\n}\n\n/** Close every emitted tool call with a synthetic terminal result and end the run. */\nasync function closeRunOnTerminalStop(\n\tcurrentContext: AgentContext,\n\tmessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tnewMessages: AgentMessage[],\n\temit: AgentEventSink,\n): Promise<void> {\n\tconst toolResults: ToolResultMessage[] = [];\n\tconst reason =\n\t\tmessage.stopReason === \"aborted\"\n\t\t\t? \"Operation aborted\"\n\t\t\t: \"Skipped because the provider terminated before tool execution\";\n\tconst disposition = message.stopReason === \"aborted\" ? \"aborted\" : \"skipped\";\n\tfor (const toolCall of toolCalls) {\n\t\tconst result = createImmutableSnapshot(\n\t\t\tcreateSyntheticToolResult(toolCall.id, toolCall.name, reason, Date.now(), disposition),\n\t\t);\n\t\tcurrentContext.messages.push(result);\n\t\tnewMessages.push(result);\n\t\ttoolResults.push(result);\n\t\tawait emitToolResultMessage(result, emit);\n\t}\n\tawait emit({ type: \"turn_end\", message, toolResults });\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/** Drain an optional message queue, normalizing absent queues to an empty list. */\nasync function drainMessageQueue(queue?: () => Promise<AgentMessage[]>): Promise<AgentMessage[]> {\n\treturn queue === undefined ? [] : await queue();\n}\n\n/** Validate an optional per-run provider-turn budget. */\nfunction validateMaxTurns(maxTurns: number | undefined): number | undefined {\n\tif (maxTurns === undefined) return undefined;\n\tif (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {\n\t\tthrow new RangeError(\"maxTurns must be a positive safe integer\");\n\t}\n\treturn maxTurns;\n}\n\n/** Reject ambiguous provider-emitted tool transcripts before any tool executes. */\nfunction assertEmittedTranscriptUnambiguous(messages: AgentMessage[]): void {\n\tconst emittedAmbiguities = inspectTranscriptIntegrity(messages).issues.filter(\n\t\t(issue) => issue.kind !== \"missing_result\",\n\t);\n\tif (emittedAmbiguities.length === 0) {\n\t\treturn;\n\t}\n\tconst summary = emittedAmbiguities.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(\", \");\n\tthrow new Error(`Refusing tool execution: invalid emitted tool transcript (${summary}).`);\n}\n\ntype ToolBatchTurnOutcome =\n\t| { kind: \"continue\"; toolResults: ToolResultMessage[]; hasMoreToolCalls: boolean; stopRun: boolean }\n\t| { kind: \"ended\" };\n\n/** Execute one assistant batch, closing unresolved calls and ending the run on abort. */\nasync function runToolBatchForTurn(\n\tcurrentContext: AgentContext,\n\tmessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tnewMessages: AgentMessage[],\n\tdagScheduleCache: DagScheduleCache,\n): Promise<ToolBatchTurnOutcome> {\n\tconst executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit, dagScheduleCache);\n\tconst toolResults = [...executedToolBatch.messages];\n\tif (signal?.aborted) {\n\t\t// Close only unresolved calls, preserving finalized results, then stop\n\t\t// before hooks, queues, or another provider request.\n\t\tconst synthesized = await closeUnresolvedToolBatch(currentContext, toolCalls, toolResults, emit);\n\t\ttoolResults.push(...synthesized);\n\t\tfor (const result of toolResults) newMessages.push(result);\n\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\treturn { kind: \"ended\" };\n\t}\n\tfor (const result of toolResults) newMessages.push(result);\n\treturn {\n\t\tkind: \"continue\",\n\t\ttoolResults,\n\t\thasMoreToolCalls: !executedToolBatch.terminate,\n\t\tstopRun: executedToolBatch.stopRun ?? false,\n\t};\n}\n\n/** Merge a prepareNextTurn snapshot into the active context and loop config. */\nfunction applyNextTurnSnapshot(\n\tcurrentContext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsnapshot: AgentLoopTurnUpdate,\n): { context: AgentContext; config: AgentLoopConfig } {\n\treturn {\n\t\tcontext: snapshot.context ?? currentContext,\n\t\tconfig: {\n\t\t\t...config,\n\t\t\tmodel: snapshot.model ?? config.model,\n\t\t\treasoning:\n\t\t\t\tsnapshot.thinkingLevel === undefined\n\t\t\t\t\t? config.reasoning\n\t\t\t\t\t: snapshot.thinkingLevel === \"off\"\n\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t: snapshot.thinkingLevel,\n\t\t},\n\t};\n}\n\n/**\n * Main loop logic shared by agentLoop and agentLoopContinue.\n */\nasync function runLoop(\n\tinitialContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tinitialConfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<void> {\n\tlet currentContext = initialContext;\n\tlet config = initialConfig;\n\tlet firstTurn = true;\n\tlet turnsStarted = 0;\n\tconst maxTurns = validateMaxTurns(initialConfig.maxTurns);\n\tconst dagScheduleCache: DagScheduleCache = new Map();\n\t// Check for steering messages at start (user may have typed while waiting)\n\tlet pendingMessages = await drainMessageQueue(config.getSteeringMessages);\n\n\t// Outer loop: continues when queued follow-up messages arrive after agent would stop\n\twhile (true) {\n\t\tlet hasMoreToolCalls = true;\n\n\t\t// Inner loop: process tool calls and steering messages\n\t\twhile (hasMoreToolCalls || pendingMessages.length > 0) {\n\t\t\tif (!firstTurn) {\n\t\t\t\tawait emit({ type: \"turn_start\" });\n\t\t\t}\n\t\t\tfirstTurn = false;\n\n\t\t\t// Process pending messages (inject before next assistant response)\n\t\t\tif (pendingMessages.length > 0) {\n\t\t\t\tawait injectPendingMessages(pendingMessages, currentContext, newMessages, emit);\n\t\t\t\tpendingMessages = [];\n\t\t\t}\n\n\t\t\t// Stream assistant response\n\t\t\tturnsStarted++;\n\t\t\tconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\t\t\tnewMessages.push(message);\n\n\t\t\t// Provider output is untrusted protocol input. Reject duplicate call IDs\n\t\t\t// and every other ambiguous turn before any tool can execute.\n\t\t\tassertEmittedTranscriptUnambiguous(currentContext.messages);\n\n\t\t\tconst toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n\t\t\tif (message.stopReason === \"error\" || message.stopReason === \"aborted\") {\n\t\t\t\tawait closeRunOnTerminalStop(currentContext, message, toolCalls, newMessages, emit);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst toolResults: ToolResultMessage[] = [];\n\t\t\tlet stopAfterToolBatch = false;\n\t\t\thasMoreToolCalls = false;\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\tconst batchOutcome = await runToolBatchForTurn(\n\t\t\t\t\tcurrentContext,\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolCalls,\n\t\t\t\t\tconfig,\n\t\t\t\t\tsignal,\n\t\t\t\t\temit,\n\t\t\t\t\tnewMessages,\n\t\t\t\t\tdagScheduleCache,\n\t\t\t\t);\n\t\t\t\tif (batchOutcome.kind === \"ended\") {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttoolResults.push(...batchOutcome.toolResults);\n\t\t\t\thasMoreToolCalls = batchOutcome.hasMoreToolCalls;\n\t\t\t\tstopAfterToolBatch = batchOutcome.stopRun;\n\t\t\t}\n\n\t\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\t\t\tif (stopAfterToolBatch) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (maxTurns !== undefined && turnsStarted >= maxTurns) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst nextTurnContext = {\n\t\t\t\tmessage,\n\t\t\t\ttoolResults,\n\t\t\t\tcontext: currentContext,\n\t\t\t\tnewMessages,\n\t\t\t};\n\t\t\tconst nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);\n\t\t\tif (nextTurnSnapshot) {\n\t\t\t\tconst applied = applyNextTurnSnapshot(currentContext, config, nextTurnSnapshot);\n\t\t\t\tcurrentContext = applied.context;\n\t\t\t\tconfig = applied.config;\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tawait config.shouldStopAfterTurn?.({\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolResults,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\tnewMessages,\n\t\t\t\t})\n\t\t\t) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpendingMessages = await drainMessageQueue(config.getSteeringMessages);\n\t\t}\n\n\t\t// Agent would stop here. Check for follow-up messages.\n\t\tconst followUpMessages = await drainMessageQueue(config.getFollowUpMessages);\n\t\tif (followUpMessages.length > 0) {\n\t\t\t// Set as pending so inner loop processes them\n\t\t\tpendingMessages = followUpMessages;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No more messages, exit\n\t\tbreak;\n\t}\n\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/**\n * Stream an assistant response from the LLM.\n * This is where AgentMessage[] gets transformed to Message[] for the LLM.\n */\n/** True when a content part is an image block (internal {type:\"image\"} shape). */\nfunction isImageContentPart(part: unknown): boolean {\n\treturn typeof part === \"object\" && part !== null && (part as { type?: unknown }).type === \"image\";\n}\n\n/**\n * Vision-route model: the Codex OAuth model used to serve turns whose transcript\n * carries image blocks while the session model is text-only.\n *\n * `contextWindow`/`maxTokens` are NOT inherited from the session model — they\n * describe the OMK GPT-5.6 family contract (1M window), which callers rely on\n * for compaction thresholds and overflow detection.\n */\nexport const VISION_ROUTE_MODEL = {\n\tprovider: \"openai-codex\",\n\tid: \"gpt-5.6-luna\",\n\tname: \"GPT-5.6 Luna\",\n\tapi: \"openai-codex-responses\",\n\tbaseUrl: \"https://chatgpt.com/backend-api\",\n\treasoning: true,\n\tinput: [\"text\", \"image\"] as const,\n\tcontextWindow: 1_000_000,\n\tmaxTokens: 128000,\n} as const;\n\n/** True when the given model is the auto-routed vision model. */\nexport function isVisionRouteModel(model: { provider?: string; id?: string } | undefined | null): boolean {\n\treturn model?.provider === VISION_ROUTE_MODEL.provider && model?.id === VISION_ROUTE_MODEL.id;\n}\n\n/**\n * Build the vision-route model for a session model that cannot see images.\n * Preserves the session model's identity/headers so auth resolution keeps\n * working, but overrides provider/API/window with the Codex vision model.\n */\nexport function getVisionRouteModel(model: Model<any>): Model<any> {\n\treturn {\n\t\t...model,\n\t\tprovider: VISION_ROUTE_MODEL.provider,\n\t\tid: VISION_ROUTE_MODEL.id,\n\t\tname: VISION_ROUTE_MODEL.name,\n\t\tapi: VISION_ROUTE_MODEL.api,\n\t\tbaseUrl: VISION_ROUTE_MODEL.baseUrl,\n\t\treasoning: VISION_ROUTE_MODEL.reasoning,\n\t\tinput: [...VISION_ROUTE_MODEL.input],\n\t\tcontextWindow: VISION_ROUTE_MODEL.contextWindow,\n\t\tmaxTokens: VISION_ROUTE_MODEL.maxTokens,\n\t};\n}\n\nasync function streamAssistantResponse(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<AssistantMessage> {\n\t// Validate the full transcript before every provider request. This fails\n\t// fast for `assistant(A,B) -> result(A)` and any duplicate/orphan/interleaved\n\t// structure that the provider would otherwise reject opaquely.\n\tassertValidToolTranscript(\n\t\tcontext.messages,\n\t\t(summary) =>\n\t\t\t`Refusing provider request: invalid tool transcript (${summary}). ` +\n\t\t\t\"Append terminal tool results or repair the transcript before retrying.\",\n\t);\n\n\t// Apply context transform if configured (AgentMessage[] → AgentMessage[])\n\tlet messages = context.messages;\n\tif (config.transformContext) {\n\t\tmessages = await config.transformContext(messages, signal);\n\t\tassertValidToolTranscript(\n\t\t\tmessages,\n\t\t\t(summary) => `Refusing provider request: transformed context has an invalid tool transcript (${summary}).`,\n\t\t);\n\t}\n\n\t// Convert to LLM-compatible messages (AgentMessage[] → Message[])\n\tconst llmMessages = await config.convertToLlm(messages);\n\n\t// Build LLM context\n\tconst llmContext: Context = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\tsystemPromptCacheBoundary: context.systemPromptCacheBoundary,\n\t\tsystemPromptCacheBoundaryBypass: context.systemPromptCacheBoundaryBypass,\n\t\tmessages: llmMessages,\n\t\ttools: context.tools,\n\t};\n\n\tconst streamFunction = streamFn || streamSimple;\n\n\t// Auto-route image-bearing turns to a vision-capable model (openai-codex/gpt-5.6-luna).\n\t// DeepSeek and other text-only providers reject image_url parts with a 400\n\t// (\"unknown variant `image_url`, expected `text`\"), so when the transcript\n\t// carries image blocks and the configured model has no vision input, swap the\n\t// whole request to the codex OAuth model for this turn only.\n\tconst llmHasImages = llmMessages.some(\n\t\t(m) => Array.isArray(m.content) && m.content.some((p: unknown) => isImageContentPart(p)),\n\t);\n\tlet routeModel = config.model;\n\tif (llmHasImages && !(config.model.input ?? []).includes(\"image\")) {\n\t\trouteModel = getVisionRouteModel(config.model);\n\t}\n\n\t// Resolve API key (important for expiring tokens)\n\tconst resolvedApiKey = (config.getApiKey ? await config.getApiKey(routeModel.provider) : undefined) || config.apiKey;\n\n\tconst response = await streamFunction(routeModel, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\n\t\tsignal,\n\t});\n\n\treturn consumeAssistantStream(response, context, emit);\n}\n\n/** Commit the final assistant message to the transcript and emit its lifecycle. */\nasync function commitFinalAssistantMessage(\n\tresponse: AssistantMessageEventStream,\n\tcontext: AgentContext,\n\taddedPartial: boolean,\n\temit: AgentEventSink,\n): Promise<AssistantMessage> {\n\tconst finalMessage = await response.result();\n\tif (addedPartial) {\n\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t} else {\n\t\tcontext.messages.push(finalMessage);\n\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t}\n\tawait emit({ type: \"message_end\", message: finalMessage });\n\treturn finalMessage;\n}\n\n/** Forward a partial assistant update into the transcript and event sink. */\nasync function forwardPartialUpdate(\n\tevent: Extract<AssistantMessageEvent, { partial: AssistantMessage }>,\n\tpartialMessage: AssistantMessage | null,\n\tcontext: AgentContext,\n\temit: AgentEventSink,\n): Promise<AssistantMessage | null> {\n\tif (!partialMessage) {\n\t\treturn partialMessage;\n\t}\n\tconst updated = event.partial;\n\tcontext.messages[context.messages.length - 1] = updated;\n\tawait emit({\n\t\ttype: \"message_update\",\n\t\tassistantMessageEvent: event,\n\t\tmessage: { ...updated },\n\t});\n\treturn updated;\n}\n\n/** Consume the assistant event stream, maintaining the partial message in the transcript. */\nasync function consumeAssistantStream(\n\tresponse: AssistantMessageEventStream,\n\tcontext: AgentContext,\n\temit: AgentEventSink,\n): Promise<AssistantMessage> {\n\tlet partialMessage: AssistantMessage | null = null;\n\tlet addedPartial = false;\n\n\tfor await (const event of response) {\n\t\tswitch (event.type) {\n\t\t\tcase \"start\":\n\t\t\t\tpartialMessage = event.partial;\n\t\t\t\tcontext.messages.push(partialMessage);\n\t\t\t\taddedPartial = true;\n\t\t\t\tawait emit({ type: \"message_start\", message: { ...partialMessage } });\n\t\t\t\tbreak;\n\t\t\tcase \"done\":\n\t\t\tcase \"error\":\n\t\t\t\treturn commitFinalAssistantMessage(response, context, addedPartial, emit);\n\t\t\tdefault:\n\t\t\t\tpartialMessage = await forwardPartialUpdate(event, partialMessage, context, emit);\n\t\t}\n\t}\n\n\treturn commitFinalAssistantMessage(response, context, addedPartial, emit);\n}\n\n/**\n * Execute tool calls from an assistant message.\n */\nasync function executeToolCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<ExecutedToolCallBatch> {\n\tconst toolCalls = assistantMessage.content.filter((c) => c.type === \"toolCall\");\n\t// dag-v2 is the safe default; waves-v1 remains an explicit rollback path.\n\tif ((config.toolScheduler ?? \"dag-v2\") === \"dag-v2\") {\n\t\treturn executeToolCallsDagLevels(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCalls,\n\t\t\tconfig,\n\t\t\tsignal,\n\t\t\temit,\n\t\t\tdagScheduleCache,\n\t\t);\n\t}\n\tconst hasSequentialToolCall = toolCalls.some(\n\t\t(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === \"sequential\",\n\t);\n\tconst toolPolicies = new Map<string, \"sequential\" | \"parallel\">();\n\tfor (const tool of currentContext.tools ?? []) {\n\t\tif (tool.executionMode) {\n\t\t\ttoolPolicies.set(tool.name, tool.executionMode);\n\t\t}\n\t}\n\tconst batchWaves = applyConcurrencyCap(\n\t\tpartitionToolBatchWaves(\n\t\t\ttoolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments as Record<string, unknown> })),\n\t\t\t{\n\t\t\t\tcwd: config.cwd ?? process.cwd(),\n\t\t\t\ttoolPolicies,\n\t\t\t\tallowUnknownParallel: (toolName) => toolPolicies.get(toolName) === \"parallel\",\n\t\t\t},\n\t\t),\n\t\tconfig.maxToolConcurrency,\n\t);\n\tif (\n\t\tconfig.toolExecution === \"sequential\" ||\n\t\thasSequentialToolCall ||\n\t\tbatchWaves.every((wave) => wave.length === 1)\n\t) {\n\t\treturn executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\tif (batchWaves.length === 1) {\n\t\treturn executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\treturn executeToolCallsInWaves(currentContext, assistantMessage, toolCalls, batchWaves, config, signal, emit);\n}\n\n/**\n * Execute a partitioned tool-call batch wave by wave: waves run in source\n * order, calls inside a multi-call wave run concurrently, and solo waves run\n * sequentially. Waves are contiguous index runs, so the returned tool result\n * messages keep the model's original tool-call order. An all-terminating wave\n * skips every later call with a synthesized \"skipped\" result and ends the\n * run, matching the dag-v2 level-termination contract.\n */\nasync function executeToolCallsInWaves(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\twaves: number[][],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst messages: ToolResultMessage[] = [];\n\tlet terminated = false;\n\tlet stopRun = false;\n\tfor (const wave of waves) {\n\t\tconst waveCalls = wave.map((index) => toolCalls[index]);\n\t\tconst executedWave =\n\t\t\twaveCalls.length === 1\n\t\t\t\t? await executeToolCallsSequential(currentContext, assistantMessage, waveCalls, config, signal, emit)\n\t\t\t\t: await executeToolCallsParallel(currentContext, assistantMessage, waveCalls, config, signal, emit);\n\t\tmessages.push(...executedWave.messages);\n\t\tif (executedWave.terminate || executedWave.stopRun) {\n\t\t\tterminated = true;\n\t\t\tstopRun = executedWave.stopRun ?? false;\n\t\t\tconst reason = stopRun\n\t\t\t\t? \"Skipped because the preceding tool wave timed out before settling\"\n\t\t\t\t: \"Skipped because the preceding tool wave requested termination\";\n\t\t\tconst skipped = await closeUnresolvedToolBatch(currentContext, toolCalls, messages, emit, {\n\t\t\t\treason,\n\t\t\t\tdisposition: \"skipped\",\n\t\t\t});\n\t\t\tmessages.push(...skipped);\n\t\t\tbreak;\n\t\t}\n\t\tif (signal?.aborted) break;\n\t}\n\treturn { messages, terminate: terminated, stopRun };\n}\n\n/** Bounded per-run memo for DAG schedules; plans are pure functions of the keyed inputs. */\ntype DagScheduleCache = Map<string, DagSchedulePlan>;\n\nconst DAG_SCHEDULE_CACHE_LIMIT = 64;\n\n/**\n * Canonical key covering every input claim resolution depends on. A custom\n * `resourceKeyResolver` function cannot be fingerprinted, so callers skip the\n * memo entirely when one is configured. Within a run, tool definitions (and\n * their `resourceClaims` closures) are stable, so name/mode/claims-presence\n * fingerprints are sufficient.\n */\nfunction dagScheduleCacheKey(toolCalls: readonly ClaimableToolCall[], options: ScheduleDagLevelsOptions): string {\n\tconst policies = [...(options.toolPolicies?.entries() ?? [])].sort(([left], [right]) =>\n\t\tleft < right ? -1 : left > right ? 1 : 0,\n\t);\n\tconst registered = (options.registeredTools ?? []).map((tool) => [\n\t\ttool.name,\n\t\ttool.executionMode ?? \"\",\n\t\ttypeof tool.resourceClaims === \"function\" ? \"1\" : \"0\",\n\t]);\n\treturn JSON.stringify([\n\t\ttoolCalls.map((call) => [call.name, call.arguments ?? null]),\n\t\toptions.cwd,\n\t\toptions.strictExtensionClaims === true,\n\t\toptions.maxConcurrency ?? null,\n\t\tpolicies,\n\t\tregistered,\n\t]);\n}\n\n/**\n * Schedule with a per-run memo. Identical batches (provider retries, stubborn\n * re-emissions) re-resolve path identities and custom claims; the plan is a\n * pure function of the canonical inputs, so replaying it is safe. Returns\n * `null` when the underlying schedule was aborted. Cached levels are handed\n * out as copies because callers append to and reorder them.\n */\nexport async function scheduleDagLevelsMemo(\n\ttoolCalls: readonly ClaimableToolCall[],\n\toptions: ScheduleDagLevelsOptions,\n\tsignal: AbortSignal | undefined,\n\tcache: DagScheduleCache,\n): Promise<DagSchedulePlan | null> {\n\tif (options.resourceKeyResolver) {\n\t\tconst scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);\n\t\treturn scheduled.kind === \"aborted\" ? null : scheduled.value;\n\t}\n\tconst key = dagScheduleCacheKey(toolCalls, options);\n\tconst cached = cache.get(key);\n\tif (cached) {\n\t\tcache.delete(key);\n\t\tcache.set(key, cached);\n\t\treturn { levels: cached.levels.map((level) => level.slice()), planKey: cached.planKey };\n\t}\n\tconst scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);\n\tif (scheduled.kind === \"aborted\") {\n\t\treturn null;\n\t}\n\tif (cache.size >= DAG_SCHEDULE_CACHE_LIMIT) {\n\t\tconst oldest = cache.keys().next();\n\t\tif (!oldest.done) {\n\t\t\tcache.delete(oldest.value);\n\t\t}\n\t}\n\tcache.set(key, { levels: scheduled.value.levels.map((level) => level.slice()), planKey: scheduled.value.planKey });\n\treturn scheduled.value;\n}\n\n/**\n * Schedule planned calls into candidate DAG levels. Immediate plans fail\n * before any tool executes, so they carry no claims and fold into the first\n * level instead of degrading the whole batch to sequential singleton levels.\n * Unresolvable argument payloads stay in the schedule and fail closed into\n * exclusive barriers inside claim resolution.\n */\nasync function schedulePlannedDagLevels(\n\tplans: Array<PlannedToolCall | ImmediateToolCallOutcome>,\n\ttoolPolicies: ReadonlyMap<string, \"sequential\" | \"parallel\">,\n\tboundTools: AgentTool<any>[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<number[][]> {\n\tconst schedulableSourceIndices: number[] = [];\n\tconst claimableCalls: ClaimableToolCall[] = [];\n\tconst immediateSourceIndices: number[] = [];\n\tplans.forEach((plan, sourceIndex) => {\n\t\tif (plan.kind === \"planned\") {\n\t\t\tschedulableSourceIndices.push(sourceIndex);\n\t\t\tclaimableCalls.push({ id: plan.toolCall.id, name: plan.toolCall.name, arguments: plan.args });\n\t\t} else {\n\t\t\timmediateSourceIndices.push(sourceIndex);\n\t\t}\n\t});\n\tconst scheduled = await scheduleDagLevelsMemo(\n\t\tclaimableCalls,\n\t\t{\n\t\t\tcwd: config.cwd ?? process.cwd(),\n\t\t\ttoolPolicies,\n\t\t\tregisteredTools: boundTools,\n\t\t\tstrictExtensionClaims: config.strictExtensionClaims,\n\t\t\tmaxConcurrency: config.maxToolConcurrency,\n\t\t\tresourceKeyResolver: config.resourceKeyResolver,\n\t\t},\n\t\tsignal,\n\t\tdagScheduleCache,\n\t);\n\tif (scheduled === null) {\n\t\treturn [];\n\t}\n\tconst levels = scheduled.levels.map((level) => level.map((position) => schedulableSourceIndices[position]));\n\tif (immediateSourceIndices.length === 0) {\n\t\treturn levels;\n\t}\n\tif (levels.length === 0) {\n\t\treturn [[...immediateSourceIndices]];\n\t}\n\tlevels[0] = [...levels[0], ...immediateSourceIndices].sort((left, right) => left - right);\n\treturn levels;\n}\n\n/**\n * Execute a tool-call batch using the dag-v2 scheduler.\n *\n * Schedule every planned call through the DAG: immediate plans fail before\n * any tool executes, so they carry no claims and fold into the first level\n * instead of degrading the whole batch to sequential singleton levels.\n * Unresolvable argument payloads stay in the schedule and fail closed into\n * exclusive barriers inside claim resolution. Each candidate level authorizes\n * calls, re-resolves claims from exact final args, and emits lifecycle starts\n * only when a final safe sublevel begins. Results remain globally buffered\n * and are emitted in source order.\n */\nasync function executeToolCallsDagLevels(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<ExecutedToolCallBatch> {\n\tconst plans = toolCalls.map((toolCall) => planToolCall(currentContext, toolCall));\n\tconst boundTools = plans.flatMap((plan) => (plan.kind === \"planned\" ? [plan.tool] : []));\n\tconst toolPolicies = new Map<string, \"sequential\" | \"parallel\">();\n\tfor (const tool of boundTools) {\n\t\tconst mode = config.toolExecution ?? tool.executionMode;\n\t\tif (mode && !toolPolicies.has(tool.name)) toolPolicies.set(tool.name, mode);\n\t}\n\n\tconst levels = await schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache);\n\n\tconst finalizedByIndex: Array<FinalizedToolCallOutcome | undefined> = new Array(toolCalls.length).fill(undefined);\n\tlet skippedReason: string | undefined;\n\tlet stoppedByUnsettledTimeout = false;\n\n\tfor (const level of levels) {\n\t\tif (signal?.aborted) break;\n\t\tconst executedLevel = await runDagLevelCalls(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\tlevel,\n\t\t\ttoolCalls,\n\t\t\tplans,\n\t\t\ttoolPolicies,\n\t\t\tconfig,\n\t\t\tsignal,\n\t\t\temit,\n\t\t\tdagScheduleCache,\n\t\t);\n\t\tfor (const outcome of executedLevel.outcomes) finalizedByIndex[outcome.sourceIndex] = outcome.finalized;\n\t\tif (signal?.aborted) break;\n\t\tif (executedLevel.stoppedByUnsettledTimeout) {\n\t\t\tskippedReason = \"Skipped because a preceding DAG tool timed out before its execution promise settled\";\n\t\t\tstoppedByUnsettledTimeout = true;\n\t\t\tbreak;\n\t\t}\n\t\tif (shouldTerminateToolBatch(executedLevel.outcomes.map((outcome) => outcome.finalized))) {\n\t\t\tskippedReason = \"Skipped because the preceding DAG level requested termination\";\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst messages: ToolResultMessage[] = [];\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tfor (let index = 0; index < toolCalls.length; index++) {\n\t\tconst finalized = finalizedByIndex[index];\n\t\tif (finalized) {\n\t\t\tmessages.push(createToolResultMessage(finalized));\n\t\t\tfinalizedCalls.push(finalized);\n\t\t} else if (signal?.aborted) {\n\t\t\tconst toolCall = toolCalls[index];\n\t\t\tmessages.push(\n\t\t\t\tcreateImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, \"Operation aborted\")),\n\t\t\t);\n\t\t} else if (skippedReason !== undefined) {\n\t\t\tconst toolCall = toolCalls[index];\n\t\t\tmessages.push(\n\t\t\t\tcreateImmutableSnapshot(\n\t\t\t\t\tcreateSyntheticToolResult(toolCall.id, toolCall.name, skippedReason, Date.now(), \"skipped\"),\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\t}\n\t// Close the full source-ordered batch before result notification; calls not\n\t// reached by a candidate level receive no execution lifecycle.\n\tfor (const message of messages) {\n\t\tcurrentContext.messages.push(message);\n\t\ttry {\n\t\t\tawait emitToolResultMessage(message, emit);\n\t\t} finally {\n\t\t\tfinalizedCalls.find(({ toolCall }) => toolCall.id === message.toolCallId)?.commitTerminal?.();\n\t\t}\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: skippedReason !== undefined || shouldTerminateToolBatch(finalizedCalls),\n\t\tstopRun: stoppedByUnsettledTimeout,\n\t};\n}\n\ntype DagLevelOutcome = { sourceIndex: number; finalized: FinalizedToolCallOutcome };\n\n/** Re-plan final claims for a runnable candidate level from exact post-hook arguments. */\nasync function rescheduleRunnableLevels(\n\trunnable: Array<{ sourceIndex: number; preparation: PreparedToolCall }>,\n\ttoolPolicies: ReadonlyMap<string, \"sequential\" | \"parallel\">,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<number[][] | null> {\n\tconst finalClaimableCalls = runnable.map(({ preparation }) => ({\n\t\tid: preparation.toolCall.id,\n\t\tname: preparation.toolCall.name,\n\t\targuments: preparation.args,\n\t}));\n\tconst scheduled = await scheduleDagLevelsMemo(\n\t\tfinalClaimableCalls,\n\t\t{\n\t\t\tcwd: config.cwd ?? process.cwd(),\n\t\t\ttoolPolicies,\n\t\t\tregisteredTools: runnable.map(({ preparation }) => preparation.tool),\n\t\t\tstrictExtensionClaims: config.strictExtensionClaims,\n\t\t\tmaxConcurrency: config.maxToolConcurrency,\n\t\t\tresourceKeyResolver: config.resourceKeyResolver,\n\t\t},\n\t\tsignal,\n\t\tdagScheduleCache,\n\t);\n\treturn scheduled === null ? null : scheduled.levels;\n}\n\n/** Authorize one candidate DAG level, re-plan final claims, and run its safe sublevels. */\nasync function runDagLevelCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tlevelIndices: readonly number[],\n\ttoolCalls: AgentToolCall[],\n\tplans: Array<PlannedToolCall | ImmediateToolCallOutcome>,\n\ttoolPolicies: ReadonlyMap<string, \"sequential\" | \"parallel\">,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tdagScheduleCache: DagScheduleCache,\n): Promise<{ outcomes: DagLevelOutcome[]; stoppedByUnsettledTimeout: boolean }> {\n\tconst outcomes: DagLevelOutcome[] = [];\n\tconst runnable: Array<{ sourceIndex: number; preparation: PreparedToolCall }> = [];\n\n\tfor (const sourceIndex of levelIndices) {\n\t\tconst toolCall = toolCalls[sourceIndex];\n\t\tconst plan = plans[sourceIndex];\n\t\tconst preparation =\n\t\t\tplan.kind === \"immediate\"\n\t\t\t\t? plan\n\t\t\t\t: await authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\toutcomes.push({\n\t\t\t\tsourceIndex,\n\t\t\t\tfinalized: {\n\t\t\t\t\ttoolCall,\n\t\t\t\t\tresult: preparation.result,\n\t\t\t\t\tisError: preparation.isError,\n\t\t\t\t\tenvelope: preparation.envelope,\n\t\t\t\t},\n\t\t\t});\n\t\t} else {\n\t\t\trunnable.push({ sourceIndex, preparation });\n\t\t}\n\t\tif (signal?.aborted) return { outcomes, stoppedByUnsettledTimeout: false };\n\t}\n\n\t// Re-plan final claims from the exact post-hook arguments. Non-plain\n\t// payloads stay in the schedule and fail closed into exclusive barriers\n\t// inside claim resolution rather than degrading the level to sequential\n\t// singletons.\n\tconst executionLevels = await rescheduleRunnableLevels(runnable, toolPolicies, config, signal, dagScheduleCache);\n\tif (executionLevels === null) return { outcomes, stoppedByUnsettledTimeout: false };\n\n\tfor (const executionLevel of executionLevels) {\n\t\tif (signal?.aborted) break;\n\t\tfor (const entryIndex of executionLevel) {\n\t\t\tawait emitToolExecutionStart(runnable[entryIndex].preparation, emit);\n\t\t}\n\t\tconst finalizedLevel = await Promise.all(\n\t\t\texecutionLevel.map(async (entryIndex): Promise<DagLevelOutcome> => {\n\t\t\t\tconst entry = runnable[entryIndex];\n\t\t\t\tconst executed = await executePreparedToolCall(entry.preparation, config, signal, emit);\n\t\t\t\tconst finalized = await finalizeExecutedToolCall({\n\t\t\t\t\tcurrentContext,\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\tprepared: entry.preparation,\n\t\t\t\t\texecuted,\n\t\t\t\t\tafterToolCall: config.afterToolCall,\n\t\t\t\t\tsignal,\n\t\t\t\t});\n\t\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\t\treturn { sourceIndex: entry.sourceIndex, finalized };\n\t\t\t}),\n\t\t);\n\t\toutcomes.push(...finalizedLevel);\n\t\tif (signal?.aborted) break;\n\t\tif (await hasUnsettledTimeout(finalizedLevel.map(({ finalized }) => finalized))) {\n\t\t\treturn { outcomes, stoppedByUnsettledTimeout: true };\n\t\t}\n\t}\n\treturn { outcomes, stoppedByUnsettledTimeout: false };\n}\n\ntype ExecutedToolCallBatch = {\n\tmessages: ToolResultMessage[];\n\tterminate: boolean;\n\t/** End the run without another provider request after an unsettled timeout. */\n\tstopRun?: boolean;\n};\n\nasync function executeToolCallsSequential(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tconst messages: ToolResultMessage[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tfinalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t\tenvelope: preparation.envelope,\n\t\t\t};\n\t\t} else {\n\t\t\tawait emitToolExecutionStart(preparation, emit);\n\t\t\tconst executed = await executePreparedToolCall(preparation, config, signal, emit);\n\t\t\tfinalized = await finalizeExecutedToolCall({\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tprepared: preparation,\n\t\t\t\texecuted,\n\t\t\t\tafterToolCall: config.afterToolCall,\n\t\t\t\tsignal,\n\t\t\t});\n\t\t}\n\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tcurrentContext.messages.push(toolResultMessage);\n\t\tif (preparation.kind === \"prepared\") await emitToolExecutionEnd(finalized, emit);\n\t\ttry {\n\t\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\t} finally {\n\t\t\tfinalized.commitTerminal?.();\n\t\t}\n\t\tfinalizedCalls.push(finalized);\n\t\tmessages.push(toolResultMessage);\n\n\t\tif (await hasUnsettledTimeout([finalized])) {\n\t\t\tconst skipped = await closeUnresolvedToolBatch(currentContext, toolCalls, messages, emit, {\n\t\t\t\treason: \"Skipped because a preceding tool timed out before settling\",\n\t\t\t\tdisposition: \"skipped\",\n\t\t\t});\n\t\t\tmessages.push(...skipped);\n\t\t\treturn { messages, terminate: true, stopRun: true };\n\t\t}\n\t\tif (signal?.aborted) break;\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(finalizedCalls),\n\t};\n}\n\nasync function executeToolCallsParallel(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tconst finalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t\tenvelope: preparation.envelope,\n\t\t\t} satisfies FinalizedToolCallOutcome;\n\t\t\tfinalizedCalls.push(finalized);\n\t\t\tif (signal?.aborted) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tfinalizedCalls.push(async () => {\n\t\t\tawait emitToolExecutionStart(preparation, emit);\n\t\t\tconst executed = await executePreparedToolCall(preparation, config, signal, emit);\n\t\t\tconst finalized = await finalizeExecutedToolCall({\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tprepared: preparation,\n\t\t\t\texecuted,\n\t\t\t\tafterToolCall: config.afterToolCall,\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\treturn finalized;\n\t\t});\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst orderedFinalizedCalls = await Promise.all(\n\t\tfinalizedCalls.map((entry) => (typeof entry === \"function\" ? entry() : Promise.resolve(entry))),\n\t);\n\tconst messages: ToolResultMessage[] = [];\n\tfor (const finalized of orderedFinalizedCalls) {\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tcurrentContext.messages.push(toolResultMessage);\n\t\ttry {\n\t\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\t} finally {\n\t\t\tfinalized.commitTerminal?.();\n\t\t}\n\t\tmessages.push(toolResultMessage);\n\t}\n\n\tconst stopRun = await hasUnsettledTimeout(orderedFinalizedCalls);\n\treturn { messages, terminate: stopRun || shouldTerminateToolBatch(orderedFinalizedCalls), stopRun };\n}\n\ntype PlannedToolCall = {\n\tkind: \"planned\";\n\ttoolCall: AgentToolCall;\n\tpreparedToolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\targs: unknown;\n};\n\ntype PreparedToolCall = {\n\tkind: \"prepared\";\n\ttoolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\t/** Immutable scheduler/executor arguments fixed after the authorization hook. */\n\targs: unknown;\n\t/** Separate immutable public-event snapshot. */\n\teventArgs: unknown;\n\t/** Effective per-call timeout in ms resolved by precedence; 0 disables it. */\n\ttimeoutMs: number;\n};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n\tenvelope: ToolResultEnvelope;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\n\nfunction shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {\n\treturn finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);\n}\n\nfunction prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {\n\tif (!tool.prepareArguments) {\n\t\treturn toolCall;\n\t}\n\tconst preparedArguments = tool.prepareArguments(toolCall.arguments);\n\tif (preparedArguments === toolCall.arguments) {\n\t\treturn toolCall;\n\t}\n\treturn {\n\t\t...toolCall,\n\t\targuments: preparedArguments as Record<string, any>,\n\t};\n}\n\nfunction immediateOutcome(disposition: ToolCallDisposition, reason: string): ImmediateToolCallOutcome {\n\treturn {\n\t\tkind: \"immediate\",\n\t\tresult: createErrorToolResult(reason),\n\t\tisError: true,\n\t\tenvelope: createToolResultEnvelope({ disposition, synthetic: true, executionStarted: false, reason }),\n\t};\n}\n\nfunction planToolCall(\n\tcurrentContext: AgentContext,\n\tuntrustedToolCall: AgentToolCall,\n): PlannedToolCall | ImmediateToolCallOutcome {\n\ttry {\n\t\tconst toolCall = createImmutableJsonSnapshot(untrustedToolCall);\n\t\tif (toolCall.id.length === 0 || toolCall.name.length === 0) throw new TypeError(\"Invalid empty tool identity\");\n\t\tconst candidate = currentContext.tools?.find((tool) => tool.name === toolCall.name);\n\t\tif (!candidate) return immediateOutcome(\"failed\", `Tool ${toolCall.name} not found`);\n\t\tconst tool = bindToolIdentity(candidate, toolCall.name);\n\t\tconst prepared = prepareToolCallArguments(tool, toolCall);\n\t\tconst args = createImmutableJsonSnapshot(prepared.arguments);\n\t\tconst preparedToolCall = createImmutableSnapshot({ ...toolCall, arguments: args });\n\t\treturn { kind: \"planned\", toolCall, preparedToolCall, tool, args };\n\t} catch (error) {\n\t\treturn immediateOutcome(\"failed\", error instanceof Error ? error.message : String(error));\n\t}\n}\n\nasync function authorizePlannedToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tplan: PlannedToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\ttry {\n\t\tconst hookArgs = parseJsonValue(validateToolArguments(plan.tool, plan.preparedToolCall));\n\t\tconst beforeToolCall = config.beforeToolCall;\n\t\tif (beforeToolCall) {\n\t\t\tconst bounded = await awaitWithAbort(\n\t\t\t\t() =>\n\t\t\t\t\tbeforeToolCall(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tassistantMessage: createImmutableSnapshot(assistantMessage),\n\t\t\t\t\t\t\ttoolCall: plan.toolCall,\n\t\t\t\t\t\t\targs: hookArgs,\n\t\t\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t),\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (bounded.kind === \"aborted\" || signal?.aborted) return immediateOutcome(\"aborted\", \"Operation aborted\");\n\t\t\tif (bounded.value?.block) {\n\t\t\t\treturn immediateOutcome(\"blocked\", bounded.value.reason || \"Tool execution was blocked\");\n\t\t\t}\n\t\t}\n\t\tif (signal?.aborted) return immediateOutcome(\"aborted\", \"Operation aborted\");\n\t\tconst args = createImmutableJsonSnapshot(hookArgs);\n\t\treturn {\n\t\t\tkind: \"prepared\",\n\t\t\ttoolCall: plan.toolCall,\n\t\t\ttool: plan.tool,\n\t\t\targs,\n\t\t\teventArgs: createImmutableSnapshot(args),\n\t\t\ttimeoutMs: resolveToolTimeoutMs(plan.tool, config, plan.toolCall.name),\n\t\t};\n\t} catch (error) {\n\t\treturn immediateOutcome(\"failed\", error instanceof Error ? error.message : String(error));\n\t}\n}\n\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\tconst plan = planToolCall(currentContext, toolCall);\n\treturn plan.kind === \"immediate\"\n\t\t? plan\n\t\t: authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tlet realPromiseSettled = false;\n\tlet commitTerminal = (): void => {};\n\tconst terminalCommitted = new Promise<void>((resolve) => {\n\t\tcommitTerminal = resolve;\n\t});\n\tconst executed = await runToolCallWithTimeout({\n\t\ttoolCallId: prepared.toolCall.id,\n\t\ttoolName: prepared.toolCall.name,\n\t\ttimeoutMs: prepared.timeoutMs,\n\t\tlateSettlement: config.toolExecutionPolicy?.lateSettlement,\n\t\tsignal,\n\t\tstart: async (childSignal, onUpdate) => {\n\t\t\ttry {\n\t\t\t\treturn await prepared.tool.execute(prepared.toolCall.id, prepared.args as never, childSignal, onUpdate);\n\t\t\t} finally {\n\t\t\t\trealPromiseSettled = true;\n\t\t\t}\n\t\t},\n\t\temitUpdate: (partialResult) =>\n\t\t\temit({\n\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\targs: prepared.eventArgs,\n\t\t\t\tpartialResult: createImmutableSnapshot(partialResult),\n\t\t\t}),\n\t\temitLateSettlement: async (settlement) => {\n\t\t\tawait terminalCommitted;\n\t\t\tawait emit({\n\t\t\t\ttype: \"tool_execution_late_settlement\",\n\t\t\t\ttoolCallId: settlement.toolCallId,\n\t\t\t\ttoolName: settlement.toolName,\n\t\t\t\tdisposition: settlement.disposition,\n\t\t\t\toutcome: settlement.outcome,\n\t\t\t});\n\t\t},\n\t\ttoErrorResult: (error) => createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t});\n\treturn { ...executed, isRealPromiseSettled: () => realPromiseSettled, commitTerminal };\n}\n\nasync function emitToolExecutionStart(prepared: PreparedToolCall, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_start\",\n\t\ttoolCallId: prepared.toolCall.id,\n\t\ttoolName: prepared.toolCall.name,\n\t\targs: prepared.eventArgs,\n\t});\n}\n\nasync function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_end\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tresult: createImmutableSnapshot(finalized.result),\n\t\tisError: finalized.isError,\n\t});\n}\n\nfunction createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {\n\treturn createImmutableSnapshot({\n\t\trole: \"toolResult\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tcontent: finalized.result.content,\n\t\tdetails: stampToolResultEnvelope(finalized.result.details, finalized.envelope),\n\t\tisError: finalized.isError,\n\t\ttimestamp: Date.now(),\n\t});\n}\n\nasync function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise<void> {\n\tawait emit({ type: \"message_start\", message: toolResultMessage });\n\tawait emit({ type: \"message_end\", message: toolResultMessage });\n}\n\n/** Commit and notify one synthetic terminal for each unresolved, unstarted call. */\nasync function closeUnresolvedToolBatch(\n\tcurrentContext: AgentContext,\n\ttoolCalls: AgentToolCall[],\n\texistingResults: ToolResultMessage[],\n\temit: AgentEventSink,\n\tclosure?: { reason: string; disposition: \"aborted\" | \"skipped\" },\n): Promise<ToolResultMessage[]> {\n\tconst resolvedIds = new Set(existingResults.map((result) => result.toolCallId));\n\tconst synthesized: ToolResultMessage[] = [];\n\tfor (const toolCall of toolCalls) {\n\t\tif (resolvedIds.has(toolCall.id)) continue;\n\t\tconst result = createImmutableSnapshot(\n\t\t\tcreateSyntheticToolResult(\n\t\t\t\ttoolCall.id,\n\t\t\t\ttoolCall.name,\n\t\t\t\tclosure?.reason ?? \"Operation aborted\",\n\t\t\t\tDate.now(),\n\t\t\t\tclosure?.disposition ?? \"aborted\",\n\t\t\t),\n\t\t);\n\t\tcurrentContext.messages.push(result);\n\t\tawait emit({ type: \"message_start\", message: result });\n\t\tawait emit({ type: \"message_end\", message: result });\n\t\tsynthesized.push(result);\n\t\t// Guard against a duplicated call id within the same assistant message.\n\t\tresolvedIds.add(toolCall.id);\n\t}\n\treturn synthesized;\n}\n"]}
@@ -8,6 +8,7 @@ import { partitionToolBatchWaves } from "./parallel-tool-batch.js";
8
8
  import { applyConcurrencyCap, scheduleDagLevels, } from "./tool-dag-scheduler.js";
9
9
  import { awaitWithAbort, createErrorToolResult, createImmutableJsonSnapshot, createImmutableSnapshot, finalizeExecutedToolCall, parseJsonValue, stampToolResultEnvelope, } from "./tool-execution-boundary.js";
10
10
  import { resolveToolTimeoutMs, runToolCallWithTimeout } from "./tool-timeout.js";
11
+ import { hasUnsettledTimeout } from "./tool-timeout-settlement.js";
11
12
  import { createSyntheticToolResult, inspectTranscriptIntegrity, repairTranscriptIntegrity, } from "./tool-transcript-integrity.js";
12
13
  import { createToolResultEnvelope, } from "./types.js";
13
14
  const EMPTY_USAGE = {
@@ -257,7 +258,7 @@ async function runToolBatchForTurn(currentContext, message, toolCalls, config, s
257
258
  if (signal?.aborted) {
258
259
  // Close only unresolved calls, preserving finalized results, then stop
259
260
  // before hooks, queues, or another provider request.
260
- const synthesized = await closeAbortedToolBatch(currentContext, toolCalls, toolResults, emit);
261
+ const synthesized = await closeUnresolvedToolBatch(currentContext, toolCalls, toolResults, emit);
261
262
  toolResults.push(...synthesized);
262
263
  for (const result of toolResults)
263
264
  newMessages.push(result);
@@ -396,7 +397,7 @@ function isImageContentPart(part) {
396
397
  * carries image blocks while the session model is text-only.
397
398
  *
398
399
  * `contextWindow`/`maxTokens` are NOT inherited from the session model — they
399
- * describe the actual Codex backend limits (400K window), which callers rely on
400
+ * describe the OMK GPT-5.6 family contract (1M window), which callers rely on
400
401
  * for compaction thresholds and overflow detection.
401
402
  */
402
403
  export const VISION_ROUTE_MODEL = {
@@ -407,7 +408,7 @@ export const VISION_ROUTE_MODEL = {
407
408
  baseUrl: "https://chatgpt.com/backend-api",
408
409
  reasoning: true,
409
410
  input: ["text", "image"],
410
- contextWindow: 400000,
411
+ contextWindow: 1_000_000,
411
412
  maxTokens: 128000,
412
413
  };
413
414
  /** True when the given model is the auto-routed vision model. */
@@ -528,9 +529,8 @@ async function consumeAssistantStream(response, context, emit) {
528
529
  */
529
530
  async function executeToolCalls(currentContext, assistantMessage, config, signal, emit, dagScheduleCache) {
530
531
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
531
- // dag-v2 is opt-in only. An explicit sequential execution mode takes
532
- // precedence and continues through the established waves-v1 path below.
533
- if (config.toolScheduler === "dag-v2" && config.toolExecution !== "sequential") {
532
+ // dag-v2 is the safe default; waves-v1 remains an explicit rollback path.
533
+ if ((config.toolScheduler ?? "dag-v2") === "dag-v2") {
534
534
  return executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit, dagScheduleCache);
535
535
  }
536
536
  const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
@@ -566,31 +566,30 @@ async function executeToolCalls(currentContext, assistantMessage, config, signal
566
566
  async function executeToolCallsInWaves(currentContext, assistantMessage, toolCalls, waves, config, signal, emit) {
567
567
  const messages = [];
568
568
  let terminated = false;
569
- let executedCount = 0;
569
+ let stopRun = false;
570
570
  for (const wave of waves) {
571
571
  const waveCalls = wave.map((index) => toolCalls[index]);
572
572
  const executedWave = waveCalls.length === 1
573
573
  ? await executeToolCallsSequential(currentContext, assistantMessage, waveCalls, config, signal, emit)
574
574
  : await executeToolCallsParallel(currentContext, assistantMessage, waveCalls, config, signal, emit);
575
575
  messages.push(...executedWave.messages);
576
- executedCount += wave.length;
577
- if (executedWave.terminate) {
576
+ if (executedWave.terminate || executedWave.stopRun) {
578
577
  terminated = true;
579
- for (const toolCall of toolCalls.slice(executedCount)) {
580
- const skipped = createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, "Skipped because the preceding tool wave requested termination", Date.now(), "skipped"));
581
- currentContext.messages.push(skipped);
582
- messages.push(skipped);
583
- await emitToolResultMessage(skipped, emit);
584
- }
578
+ stopRun = executedWave.stopRun ?? false;
579
+ const reason = stopRun
580
+ ? "Skipped because the preceding tool wave timed out before settling"
581
+ : "Skipped because the preceding tool wave requested termination";
582
+ const skipped = await closeUnresolvedToolBatch(currentContext, toolCalls, messages, emit, {
583
+ reason,
584
+ disposition: "skipped",
585
+ });
586
+ messages.push(...skipped);
585
587
  break;
586
588
  }
587
589
  if (signal?.aborted)
588
590
  break;
589
591
  }
590
- return {
591
- messages,
592
- terminate: terminated,
593
- };
592
+ return { messages, terminate: terminated, stopRun };
594
593
  }
595
594
  const DAG_SCHEDULE_CACHE_LIMIT = 64;
596
595
  /**
@@ -706,8 +705,9 @@ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolC
706
705
  const boundTools = plans.flatMap((plan) => (plan.kind === "planned" ? [plan.tool] : []));
707
706
  const toolPolicies = new Map();
708
707
  for (const tool of boundTools) {
709
- if (tool.executionMode && !toolPolicies.has(tool.name))
710
- toolPolicies.set(tool.name, tool.executionMode);
708
+ const mode = config.toolExecution ?? tool.executionMode;
709
+ if (mode && !toolPolicies.has(tool.name))
710
+ toolPolicies.set(tool.name, mode);
711
711
  }
712
712
  const levels = await schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache);
713
713
  const finalizedByIndex = new Array(toolCalls.length).fill(undefined);
@@ -839,7 +839,7 @@ async function runDagLevelCalls(currentContext, assistantMessage, levelIndices,
839
839
  outcomes.push(...finalizedLevel);
840
840
  if (signal?.aborted)
841
841
  break;
842
- if (finalizedLevel.some(({ finalized }) => finalized.envelope.disposition === "timeout" && finalized.isRealPromiseSettled?.() === false)) {
842
+ if (await hasUnsettledTimeout(finalizedLevel.map(({ finalized }) => finalized))) {
843
843
  return { outcomes, stoppedByUnsettledTimeout: true };
844
844
  }
845
845
  }
@@ -883,9 +883,16 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
883
883
  }
884
884
  finalizedCalls.push(finalized);
885
885
  messages.push(toolResultMessage);
886
- if (signal?.aborted) {
887
- break;
886
+ if (await hasUnsettledTimeout([finalized])) {
887
+ const skipped = await closeUnresolvedToolBatch(currentContext, toolCalls, messages, emit, {
888
+ reason: "Skipped because a preceding tool timed out before settling",
889
+ disposition: "skipped",
890
+ });
891
+ messages.push(...skipped);
892
+ return { messages, terminate: true, stopRun: true };
888
893
  }
894
+ if (signal?.aborted)
895
+ break;
889
896
  }
890
897
  return {
891
898
  messages,
@@ -940,10 +947,8 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
940
947
  }
941
948
  messages.push(toolResultMessage);
942
949
  }
943
- return {
944
- messages,
945
- terminate: shouldTerminateToolBatch(orderedFinalizedCalls),
946
- };
950
+ const stopRun = await hasUnsettledTimeout(orderedFinalizedCalls);
951
+ return { messages, terminate: stopRun || shouldTerminateToolBatch(orderedFinalizedCalls), stopRun };
947
952
  }
948
953
  function shouldTerminateToolBatch(finalizedCalls) {
949
954
  return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
@@ -1099,15 +1104,14 @@ async function emitToolResultMessage(toolResultMessage, emit) {
1099
1104
  await emit({ type: "message_start", message: toolResultMessage });
1100
1105
  await emit({ type: "message_end", message: toolResultMessage });
1101
1106
  }
1102
- /** Commit and notify one aborted terminal for each unresolved, unstarted call. */
1103
- async function closeAbortedToolBatch(currentContext, toolCalls, existingResults, emit) {
1107
+ /** Commit and notify one synthetic terminal for each unresolved, unstarted call. */
1108
+ async function closeUnresolvedToolBatch(currentContext, toolCalls, existingResults, emit, closure) {
1104
1109
  const resolvedIds = new Set(existingResults.map((result) => result.toolCallId));
1105
1110
  const synthesized = [];
1106
1111
  for (const toolCall of toolCalls) {
1107
- if (resolvedIds.has(toolCall.id)) {
1112
+ if (resolvedIds.has(toolCall.id))
1108
1113
  continue;
1109
- }
1110
- const result = createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, "Operation aborted"));
1114
+ const result = createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, closure?.reason ?? "Operation aborted", Date.now(), closure?.disposition ?? "aborted"));
1111
1115
  currentContext.messages.push(result);
1112
1116
  await emit({ type: "message_start", message: result });
1113
1117
  await emit({ type: "message_end", message: result });