omk-agent-core 0.95.0 → 0.95.2

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.
@@ -3,9 +3,9 @@
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
5
  import { EventStream, streamSimple, validateToolArguments, } from "omk-ai";
6
- import { bindToolIdentity, isPlainArguments } from "./builtin-tool-resource-claims.js";
6
+ import { bindToolIdentity } from "./builtin-tool-resource-claims.js";
7
7
  import { partitionToolBatchWaves } from "./parallel-tool-batch.js";
8
- import { scheduleDagLevels } from "./tool-dag-scheduler.js";
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
11
  import { createSyntheticToolResult, inspectTranscriptIntegrity, repairTranscriptIntegrity, } from "./tool-transcript-integrity.js";
@@ -193,6 +193,102 @@ function assertContinuableTranscript(messages) {
193
193
  throw new Error(`Cannot continue: invalid tool transcript (${summary}). ` +
194
194
  "Append terminal tool results or repair the transcript before continuing.");
195
195
  }
196
+ /** Throw when the tool transcript could not be accepted by a provider. */
197
+ function assertValidToolTranscript(messages, describe) {
198
+ const integrityReport = inspectTranscriptIntegrity(messages);
199
+ if (integrityReport.ok) {
200
+ return;
201
+ }
202
+ const summary = integrityReport.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(", ");
203
+ throw new Error(describe(summary));
204
+ }
205
+ /** Inject queued steering messages into the transcript before the next assistant turn. */
206
+ async function injectPendingMessages(pendingMessages, currentContext, newMessages, emit) {
207
+ for (const message of pendingMessages) {
208
+ await emit({ type: "message_start", message });
209
+ await emit({ type: "message_end", message });
210
+ currentContext.messages.push(message);
211
+ newMessages.push(message);
212
+ }
213
+ }
214
+ /** Close every emitted tool call with a synthetic terminal result and end the run. */
215
+ async function closeRunOnTerminalStop(currentContext, message, toolCalls, newMessages, emit) {
216
+ const toolResults = [];
217
+ const reason = message.stopReason === "aborted"
218
+ ? "Operation aborted"
219
+ : "Skipped because the provider terminated before tool execution";
220
+ const disposition = message.stopReason === "aborted" ? "aborted" : "skipped";
221
+ for (const toolCall of toolCalls) {
222
+ const result = createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, reason, Date.now(), disposition));
223
+ currentContext.messages.push(result);
224
+ newMessages.push(result);
225
+ toolResults.push(result);
226
+ await emitToolResultMessage(result, emit);
227
+ }
228
+ await emit({ type: "turn_end", message, toolResults });
229
+ await emit({ type: "agent_end", messages: newMessages });
230
+ }
231
+ /** Drain an optional message queue, normalizing absent queues to an empty list. */
232
+ async function drainMessageQueue(queue) {
233
+ return queue === undefined ? [] : await queue();
234
+ }
235
+ /** Validate an optional per-run provider-turn budget. */
236
+ function validateMaxTurns(maxTurns) {
237
+ if (maxTurns === undefined)
238
+ return undefined;
239
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
240
+ throw new RangeError("maxTurns must be a positive safe integer");
241
+ }
242
+ return maxTurns;
243
+ }
244
+ /** Reject ambiguous provider-emitted tool transcripts before any tool executes. */
245
+ function assertEmittedTranscriptUnambiguous(messages) {
246
+ const emittedAmbiguities = inspectTranscriptIntegrity(messages).issues.filter((issue) => issue.kind !== "missing_result");
247
+ if (emittedAmbiguities.length === 0) {
248
+ return;
249
+ }
250
+ const summary = emittedAmbiguities.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(", ");
251
+ throw new Error(`Refusing tool execution: invalid emitted tool transcript (${summary}).`);
252
+ }
253
+ /** Execute one assistant batch, closing unresolved calls and ending the run on abort. */
254
+ async function runToolBatchForTurn(currentContext, message, toolCalls, config, signal, emit, newMessages, dagScheduleCache) {
255
+ const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit, dagScheduleCache);
256
+ const toolResults = [...executedToolBatch.messages];
257
+ if (signal?.aborted) {
258
+ // Close only unresolved calls, preserving finalized results, then stop
259
+ // before hooks, queues, or another provider request.
260
+ const synthesized = await closeAbortedToolBatch(currentContext, toolCalls, toolResults, emit);
261
+ toolResults.push(...synthesized);
262
+ for (const result of toolResults)
263
+ newMessages.push(result);
264
+ await emit({ type: "turn_end", message, toolResults });
265
+ await emit({ type: "agent_end", messages: newMessages });
266
+ return { kind: "ended" };
267
+ }
268
+ for (const result of toolResults)
269
+ newMessages.push(result);
270
+ return {
271
+ kind: "continue",
272
+ toolResults,
273
+ hasMoreToolCalls: !executedToolBatch.terminate,
274
+ stopRun: executedToolBatch.stopRun ?? false,
275
+ };
276
+ }
277
+ /** Merge a prepareNextTurn snapshot into the active context and loop config. */
278
+ function applyNextTurnSnapshot(currentContext, config, snapshot) {
279
+ return {
280
+ context: snapshot.context ?? currentContext,
281
+ config: {
282
+ ...config,
283
+ model: snapshot.model ?? config.model,
284
+ reasoning: snapshot.thinkingLevel === undefined
285
+ ? config.reasoning
286
+ : snapshot.thinkingLevel === "off"
287
+ ? undefined
288
+ : snapshot.thinkingLevel,
289
+ },
290
+ };
291
+ }
196
292
  /**
197
293
  * Main loop logic shared by agentLoop and agentLoopContinue.
198
294
  */
@@ -200,8 +296,11 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
200
296
  let currentContext = initialContext;
201
297
  let config = initialConfig;
202
298
  let firstTurn = true;
299
+ let turnsStarted = 0;
300
+ const maxTurns = validateMaxTurns(initialConfig.maxTurns);
301
+ const dagScheduleCache = new Map();
203
302
  // Check for steering messages at start (user may have typed while waiting)
204
- let pendingMessages = (await config.getSteeringMessages?.()) || [];
303
+ let pendingMessages = await drainMessageQueue(config.getSteeringMessages);
205
304
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
206
305
  while (true) {
207
306
  let hasMoreToolCalls = true;
@@ -210,75 +309,45 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
210
309
  if (!firstTurn) {
211
310
  await emit({ type: "turn_start" });
212
311
  }
213
- else {
214
- firstTurn = false;
215
- }
312
+ firstTurn = false;
216
313
  // Process pending messages (inject before next assistant response)
217
314
  if (pendingMessages.length > 0) {
218
- for (const message of pendingMessages) {
219
- await emit({ type: "message_start", message });
220
- await emit({ type: "message_end", message });
221
- currentContext.messages.push(message);
222
- newMessages.push(message);
223
- }
315
+ await injectPendingMessages(pendingMessages, currentContext, newMessages, emit);
224
316
  pendingMessages = [];
225
317
  }
226
318
  // Stream assistant response
319
+ turnsStarted++;
227
320
  const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
228
321
  newMessages.push(message);
229
322
  // Provider output is untrusted protocol input. Reject duplicate call IDs
230
323
  // and every other ambiguous turn before any tool can execute.
231
- const emittedIntegrity = inspectTranscriptIntegrity(currentContext.messages);
232
- const emittedAmbiguities = emittedIntegrity.issues.filter((issue) => issue.kind !== "missing_result");
233
- if (emittedAmbiguities.length > 0) {
234
- const summary = emittedAmbiguities.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(", ");
235
- throw new Error(`Refusing tool execution: invalid emitted tool transcript (${summary}).`);
236
- }
324
+ assertEmittedTranscriptUnambiguous(currentContext.messages);
237
325
  const toolCalls = message.content.filter((c) => c.type === "toolCall");
238
326
  if (message.stopReason === "error" || message.stopReason === "aborted") {
239
- const toolResults = [];
240
- const reason = message.stopReason === "aborted"
241
- ? "Operation aborted"
242
- : "Skipped because the provider terminated before tool execution";
243
- const disposition = message.stopReason === "aborted" ? "aborted" : "skipped";
244
- for (const toolCall of toolCalls) {
245
- const result = createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, reason, Date.now(), disposition));
246
- currentContext.messages.push(result);
247
- newMessages.push(result);
248
- toolResults.push(result);
249
- await emitToolResultMessage(result, emit);
250
- }
251
- await emit({ type: "turn_end", message, toolResults });
252
- await emit({ type: "agent_end", messages: newMessages });
327
+ await closeRunOnTerminalStop(currentContext, message, toolCalls, newMessages, emit);
253
328
  return;
254
329
  }
255
330
  const toolResults = [];
256
331
  let stopAfterToolBatch = false;
257
332
  hasMoreToolCalls = false;
258
333
  if (toolCalls.length > 0) {
259
- const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
260
- toolResults.push(...executedToolBatch.messages);
261
- hasMoreToolCalls = !executedToolBatch.terminate;
262
- stopAfterToolBatch = executedToolBatch.stopRun ?? false;
263
- if (signal?.aborted) {
264
- // Close only unresolved calls, preserving finalized results, then stop
265
- // before hooks, queues, or another provider request.
266
- const synthesized = await closeAbortedToolBatch(currentContext, toolCalls, toolResults, emit);
267
- toolResults.push(...synthesized);
268
- for (const result of toolResults)
269
- newMessages.push(result);
270
- await emit({ type: "turn_end", message, toolResults });
271
- await emit({ type: "agent_end", messages: newMessages });
334
+ const batchOutcome = await runToolBatchForTurn(currentContext, message, toolCalls, config, signal, emit, newMessages, dagScheduleCache);
335
+ if (batchOutcome.kind === "ended") {
272
336
  return;
273
337
  }
274
- for (const result of toolResults)
275
- newMessages.push(result);
338
+ toolResults.push(...batchOutcome.toolResults);
339
+ hasMoreToolCalls = batchOutcome.hasMoreToolCalls;
340
+ stopAfterToolBatch = batchOutcome.stopRun;
276
341
  }
277
342
  await emit({ type: "turn_end", message, toolResults });
278
343
  if (stopAfterToolBatch) {
279
344
  await emit({ type: "agent_end", messages: newMessages });
280
345
  return;
281
346
  }
347
+ if (maxTurns !== undefined && turnsStarted >= maxTurns) {
348
+ await emit({ type: "agent_end", messages: newMessages });
349
+ return;
350
+ }
282
351
  const nextTurnContext = {
283
352
  message,
284
353
  toolResults,
@@ -287,16 +356,9 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
287
356
  };
288
357
  const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
289
358
  if (nextTurnSnapshot) {
290
- currentContext = nextTurnSnapshot.context ?? currentContext;
291
- config = {
292
- ...config,
293
- model: nextTurnSnapshot.model ?? config.model,
294
- reasoning: nextTurnSnapshot.thinkingLevel === undefined
295
- ? config.reasoning
296
- : nextTurnSnapshot.thinkingLevel === "off"
297
- ? undefined
298
- : nextTurnSnapshot.thinkingLevel,
299
- };
359
+ const applied = applyNextTurnSnapshot(currentContext, config, nextTurnSnapshot);
360
+ currentContext = applied.context;
361
+ config = applied.config;
300
362
  }
301
363
  if (await config.shouldStopAfterTurn?.({
302
364
  message,
@@ -307,10 +369,10 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
307
369
  await emit({ type: "agent_end", messages: newMessages });
308
370
  return;
309
371
  }
310
- pendingMessages = (await config.getSteeringMessages?.()) || [];
372
+ pendingMessages = await drainMessageQueue(config.getSteeringMessages);
311
373
  }
312
374
  // Agent would stop here. Check for follow-up messages.
313
- const followUpMessages = (await config.getFollowUpMessages?.()) || [];
375
+ const followUpMessages = await drainMessageQueue(config.getFollowUpMessages);
314
376
  if (followUpMessages.length > 0) {
315
377
  // Set as pending so inner loop processes them
316
378
  pendingMessages = followUpMessages;
@@ -325,42 +387,123 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
325
387
  * Stream an assistant response from the LLM.
326
388
  * This is where AgentMessage[] gets transformed to Message[] for the LLM.
327
389
  */
390
+ /** True when a content part is an image block (internal {type:"image"} shape). */
391
+ function isImageContentPart(part) {
392
+ return typeof part === "object" && part !== null && part.type === "image";
393
+ }
394
+ /**
395
+ * Vision-route model: the Codex OAuth model used to serve turns whose transcript
396
+ * carries image blocks while the session model is text-only.
397
+ *
398
+ * `contextWindow`/`maxTokens` are NOT inherited from the session model — they
399
+ * describe the actual Codex backend limits (400K window), which callers rely on
400
+ * for compaction thresholds and overflow detection.
401
+ */
402
+ export const VISION_ROUTE_MODEL = {
403
+ provider: "openai-codex",
404
+ id: "gpt-5.6-luna",
405
+ name: "GPT-5.6 Luna",
406
+ api: "openai-codex-responses",
407
+ baseUrl: "https://chatgpt.com/backend-api",
408
+ reasoning: true,
409
+ input: ["text", "image"],
410
+ contextWindow: 400000,
411
+ maxTokens: 128000,
412
+ };
413
+ /** True when the given model is the auto-routed vision model. */
414
+ export function isVisionRouteModel(model) {
415
+ return model?.provider === VISION_ROUTE_MODEL.provider && model?.id === VISION_ROUTE_MODEL.id;
416
+ }
417
+ /**
418
+ * Build the vision-route model for a session model that cannot see images.
419
+ * Preserves the session model's identity/headers so auth resolution keeps
420
+ * working, but overrides provider/API/window with the Codex vision model.
421
+ */
422
+ export function getVisionRouteModel(model) {
423
+ return {
424
+ ...model,
425
+ provider: VISION_ROUTE_MODEL.provider,
426
+ id: VISION_ROUTE_MODEL.id,
427
+ name: VISION_ROUTE_MODEL.name,
428
+ api: VISION_ROUTE_MODEL.api,
429
+ baseUrl: VISION_ROUTE_MODEL.baseUrl,
430
+ reasoning: VISION_ROUTE_MODEL.reasoning,
431
+ input: [...VISION_ROUTE_MODEL.input],
432
+ contextWindow: VISION_ROUTE_MODEL.contextWindow,
433
+ maxTokens: VISION_ROUTE_MODEL.maxTokens,
434
+ };
435
+ }
328
436
  async function streamAssistantResponse(context, config, signal, emit, streamFn) {
329
437
  // Validate the full transcript before every provider request. This fails
330
438
  // fast for `assistant(A,B) -> result(A)` and any duplicate/orphan/interleaved
331
439
  // structure that the provider would otherwise reject opaquely.
332
- const integrityReport = inspectTranscriptIntegrity(context.messages);
333
- if (!integrityReport.ok) {
334
- const summary = integrityReport.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(", ");
335
- throw new Error(`Refusing provider request: invalid tool transcript (${summary}). ` +
336
- "Append terminal tool results or repair the transcript before retrying.");
337
- }
440
+ assertValidToolTranscript(context.messages, (summary) => `Refusing provider request: invalid tool transcript (${summary}). ` +
441
+ "Append terminal tool results or repair the transcript before retrying.");
338
442
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
339
443
  let messages = context.messages;
340
444
  if (config.transformContext) {
341
445
  messages = await config.transformContext(messages, signal);
342
- const transformedIntegrity = inspectTranscriptIntegrity(messages);
343
- if (!transformedIntegrity.ok) {
344
- const summary = transformedIntegrity.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(", ");
345
- throw new Error(`Refusing provider request: transformed context has an invalid tool transcript (${summary}).`);
346
- }
446
+ assertValidToolTranscript(messages, (summary) => `Refusing provider request: transformed context has an invalid tool transcript (${summary}).`);
347
447
  }
348
448
  // Convert to LLM-compatible messages (AgentMessage[] → Message[])
349
449
  const llmMessages = await config.convertToLlm(messages);
350
450
  // Build LLM context
351
451
  const llmContext = {
352
452
  systemPrompt: context.systemPrompt,
453
+ systemPromptCacheBoundary: context.systemPromptCacheBoundary,
454
+ systemPromptCacheBoundaryBypass: context.systemPromptCacheBoundaryBypass,
353
455
  messages: llmMessages,
354
456
  tools: context.tools,
355
457
  };
356
458
  const streamFunction = streamFn || streamSimple;
459
+ // Auto-route image-bearing turns to a vision-capable model (openai-codex/gpt-5.6-luna).
460
+ // DeepSeek and other text-only providers reject image_url parts with a 400
461
+ // ("unknown variant `image_url`, expected `text`"), so when the transcript
462
+ // carries image blocks and the configured model has no vision input, swap the
463
+ // whole request to the codex OAuth model for this turn only.
464
+ const llmHasImages = llmMessages.some((m) => Array.isArray(m.content) && m.content.some((p) => isImageContentPart(p)));
465
+ let routeModel = config.model;
466
+ if (llmHasImages && !(config.model.input ?? []).includes("image")) {
467
+ routeModel = getVisionRouteModel(config.model);
468
+ }
357
469
  // Resolve API key (important for expiring tokens)
358
- const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
359
- const response = await streamFunction(config.model, llmContext, {
470
+ const resolvedApiKey = (config.getApiKey ? await config.getApiKey(routeModel.provider) : undefined) || config.apiKey;
471
+ const response = await streamFunction(routeModel, llmContext, {
360
472
  ...config,
361
473
  apiKey: resolvedApiKey,
362
474
  signal,
363
475
  });
476
+ return consumeAssistantStream(response, context, emit);
477
+ }
478
+ /** Commit the final assistant message to the transcript and emit its lifecycle. */
479
+ async function commitFinalAssistantMessage(response, context, addedPartial, emit) {
480
+ const finalMessage = await response.result();
481
+ if (addedPartial) {
482
+ context.messages[context.messages.length - 1] = finalMessage;
483
+ }
484
+ else {
485
+ context.messages.push(finalMessage);
486
+ await emit({ type: "message_start", message: { ...finalMessage } });
487
+ }
488
+ await emit({ type: "message_end", message: finalMessage });
489
+ return finalMessage;
490
+ }
491
+ /** Forward a partial assistant update into the transcript and event sink. */
492
+ async function forwardPartialUpdate(event, partialMessage, context, emit) {
493
+ if (!partialMessage) {
494
+ return partialMessage;
495
+ }
496
+ const updated = event.partial;
497
+ context.messages[context.messages.length - 1] = updated;
498
+ await emit({
499
+ type: "message_update",
500
+ assistantMessageEvent: event,
501
+ message: { ...updated },
502
+ });
503
+ return updated;
504
+ }
505
+ /** Consume the assistant event stream, maintaining the partial message in the transcript. */
506
+ async function consumeAssistantStream(response, context, emit) {
364
507
  let partialMessage = null;
365
508
  let addedPartial = false;
366
509
  for await (const event of response) {
@@ -371,62 +514,24 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn)
371
514
  addedPartial = true;
372
515
  await emit({ type: "message_start", message: { ...partialMessage } });
373
516
  break;
374
- case "text_start":
375
- case "text_delta":
376
- case "text_end":
377
- case "thinking_start":
378
- case "thinking_delta":
379
- case "thinking_end":
380
- case "toolcall_start":
381
- case "toolcall_delta":
382
- case "toolcall_end":
383
- if (partialMessage) {
384
- partialMessage = event.partial;
385
- context.messages[context.messages.length - 1] = partialMessage;
386
- await emit({
387
- type: "message_update",
388
- assistantMessageEvent: event,
389
- message: { ...partialMessage },
390
- });
391
- }
392
- break;
393
517
  case "done":
394
- case "error": {
395
- const finalMessage = await response.result();
396
- if (addedPartial) {
397
- context.messages[context.messages.length - 1] = finalMessage;
398
- }
399
- else {
400
- context.messages.push(finalMessage);
401
- }
402
- if (!addedPartial) {
403
- await emit({ type: "message_start", message: { ...finalMessage } });
404
- }
405
- await emit({ type: "message_end", message: finalMessage });
406
- return finalMessage;
407
- }
518
+ case "error":
519
+ return commitFinalAssistantMessage(response, context, addedPartial, emit);
520
+ default:
521
+ partialMessage = await forwardPartialUpdate(event, partialMessage, context, emit);
408
522
  }
409
523
  }
410
- const finalMessage = await response.result();
411
- if (addedPartial) {
412
- context.messages[context.messages.length - 1] = finalMessage;
413
- }
414
- else {
415
- context.messages.push(finalMessage);
416
- await emit({ type: "message_start", message: { ...finalMessage } });
417
- }
418
- await emit({ type: "message_end", message: finalMessage });
419
- return finalMessage;
524
+ return commitFinalAssistantMessage(response, context, addedPartial, emit);
420
525
  }
421
526
  /**
422
527
  * Execute tool calls from an assistant message.
423
528
  */
424
- async function executeToolCalls(currentContext, assistantMessage, config, signal, emit) {
529
+ async function executeToolCalls(currentContext, assistantMessage, config, signal, emit, dagScheduleCache) {
425
530
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
426
531
  // dag-v2 is opt-in only. An explicit sequential execution mode takes
427
532
  // precedence and continues through the established waves-v1 path below.
428
533
  if (config.toolScheduler === "dag-v2" && config.toolExecution !== "sequential") {
429
- return executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit);
534
+ return executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit, dagScheduleCache);
430
535
  }
431
536
  const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
432
537
  const toolPolicies = new Map();
@@ -435,11 +540,11 @@ async function executeToolCalls(currentContext, assistantMessage, config, signal
435
540
  toolPolicies.set(tool.name, tool.executionMode);
436
541
  }
437
542
  }
438
- const batchWaves = partitionToolBatchWaves(toolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments })), {
543
+ const batchWaves = applyConcurrencyCap(partitionToolBatchWaves(toolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments })), {
439
544
  cwd: config.cwd ?? process.cwd(),
440
545
  toolPolicies,
441
546
  allowUnknownParallel: (toolName) => toolPolicies.get(toolName) === "parallel",
442
- });
547
+ }), config.maxToolConcurrency);
443
548
  if (config.toolExecution === "sequential" ||
444
549
  hasSequentialToolCall ||
445
550
  batchWaves.every((wave) => wave.length === 1)) {
@@ -454,35 +559,149 @@ async function executeToolCalls(currentContext, assistantMessage, config, signal
454
559
  * Execute a partitioned tool-call batch wave by wave: waves run in source
455
560
  * order, calls inside a multi-call wave run concurrently, and solo waves run
456
561
  * sequentially. Waves are contiguous index runs, so the returned tool result
457
- * messages keep the model's original tool-call order.
562
+ * messages keep the model's original tool-call order. An all-terminating wave
563
+ * skips every later call with a synthesized "skipped" result and ends the
564
+ * run, matching the dag-v2 level-termination contract.
458
565
  */
459
566
  async function executeToolCallsInWaves(currentContext, assistantMessage, toolCalls, waves, config, signal, emit) {
460
567
  const messages = [];
461
- const waveTerminates = [];
568
+ let terminated = false;
569
+ let executedCount = 0;
462
570
  for (const wave of waves) {
463
571
  const waveCalls = wave.map((index) => toolCalls[index]);
464
572
  const executedWave = waveCalls.length === 1
465
573
  ? await executeToolCallsSequential(currentContext, assistantMessage, waveCalls, config, signal, emit)
466
574
  : await executeToolCallsParallel(currentContext, assistantMessage, waveCalls, config, signal, emit);
467
575
  messages.push(...executedWave.messages);
468
- waveTerminates.push(executedWave.terminate);
576
+ executedCount += wave.length;
577
+ if (executedWave.terminate) {
578
+ 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
+ }
585
+ break;
586
+ }
469
587
  if (signal?.aborted)
470
588
  break;
471
589
  }
472
590
  return {
473
591
  messages,
474
- terminate: waveTerminates.length > 0 && waveTerminates.every(Boolean),
592
+ terminate: terminated,
475
593
  };
476
594
  }
595
+ const DAG_SCHEDULE_CACHE_LIMIT = 64;
596
+ /**
597
+ * Canonical key covering every input claim resolution depends on. A custom
598
+ * `resourceKeyResolver` function cannot be fingerprinted, so callers skip the
599
+ * memo entirely when one is configured. Within a run, tool definitions (and
600
+ * their `resourceClaims` closures) are stable, so name/mode/claims-presence
601
+ * fingerprints are sufficient.
602
+ */
603
+ function dagScheduleCacheKey(toolCalls, options) {
604
+ const policies = [...(options.toolPolicies?.entries() ?? [])].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
605
+ const registered = (options.registeredTools ?? []).map((tool) => [
606
+ tool.name,
607
+ tool.executionMode ?? "",
608
+ typeof tool.resourceClaims === "function" ? "1" : "0",
609
+ ]);
610
+ return JSON.stringify([
611
+ toolCalls.map((call) => [call.name, call.arguments ?? null]),
612
+ options.cwd,
613
+ options.strictExtensionClaims === true,
614
+ options.maxConcurrency ?? null,
615
+ policies,
616
+ registered,
617
+ ]);
618
+ }
619
+ /**
620
+ * Schedule with a per-run memo. Identical batches (provider retries, stubborn
621
+ * re-emissions) re-resolve path identities and custom claims; the plan is a
622
+ * pure function of the canonical inputs, so replaying it is safe. Returns
623
+ * `null` when the underlying schedule was aborted. Cached levels are handed
624
+ * out as copies because callers append to and reorder them.
625
+ */
626
+ export async function scheduleDagLevelsMemo(toolCalls, options, signal, cache) {
627
+ if (options.resourceKeyResolver) {
628
+ const scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);
629
+ return scheduled.kind === "aborted" ? null : scheduled.value;
630
+ }
631
+ const key = dagScheduleCacheKey(toolCalls, options);
632
+ const cached = cache.get(key);
633
+ if (cached) {
634
+ cache.delete(key);
635
+ cache.set(key, cached);
636
+ return { levels: cached.levels.map((level) => level.slice()), planKey: cached.planKey };
637
+ }
638
+ const scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);
639
+ if (scheduled.kind === "aborted") {
640
+ return null;
641
+ }
642
+ if (cache.size >= DAG_SCHEDULE_CACHE_LIMIT) {
643
+ const oldest = cache.keys().next();
644
+ if (!oldest.done) {
645
+ cache.delete(oldest.value);
646
+ }
647
+ }
648
+ cache.set(key, { levels: scheduled.value.levels.map((level) => level.slice()), planKey: scheduled.value.planKey });
649
+ return scheduled.value;
650
+ }
651
+ /**
652
+ * Schedule planned calls into candidate DAG levels. Immediate plans fail
653
+ * before any tool executes, so they carry no claims and fold into the first
654
+ * level instead of degrading the whole batch to sequential singleton levels.
655
+ * Unresolvable argument payloads stay in the schedule and fail closed into
656
+ * exclusive barriers inside claim resolution.
657
+ */
658
+ async function schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache) {
659
+ const schedulableSourceIndices = [];
660
+ const claimableCalls = [];
661
+ const immediateSourceIndices = [];
662
+ plans.forEach((plan, sourceIndex) => {
663
+ if (plan.kind === "planned") {
664
+ schedulableSourceIndices.push(sourceIndex);
665
+ claimableCalls.push({ id: plan.toolCall.id, name: plan.toolCall.name, arguments: plan.args });
666
+ }
667
+ else {
668
+ immediateSourceIndices.push(sourceIndex);
669
+ }
670
+ });
671
+ const scheduled = await scheduleDagLevelsMemo(claimableCalls, {
672
+ cwd: config.cwd ?? process.cwd(),
673
+ toolPolicies,
674
+ registeredTools: boundTools,
675
+ strictExtensionClaims: config.strictExtensionClaims,
676
+ maxConcurrency: config.maxToolConcurrency,
677
+ resourceKeyResolver: config.resourceKeyResolver,
678
+ }, signal, dagScheduleCache);
679
+ if (scheduled === null) {
680
+ return [];
681
+ }
682
+ const levels = scheduled.levels.map((level) => level.map((position) => schedulableSourceIndices[position]));
683
+ if (immediateSourceIndices.length === 0) {
684
+ return levels;
685
+ }
686
+ if (levels.length === 0) {
687
+ return [[...immediateSourceIndices]];
688
+ }
689
+ levels[0] = [...levels[0], ...immediateSourceIndices].sort((left, right) => left - right);
690
+ return levels;
691
+ }
477
692
  /**
478
693
  * Execute a tool-call batch using the dag-v2 scheduler.
479
694
  *
480
- * Initial planning applies only the pure argument compatibility shim. Each
481
- * candidate level authorizes calls, re-resolves claims from exact final args,
482
- * and emits lifecycle starts only when a final safe sublevel begins. Results
483
- * remain globally buffered and are emitted in source order.
695
+ * Schedule every planned call through the DAG: immediate plans fail before
696
+ * any tool executes, so they carry no claims and fold into the first level
697
+ * instead of degrading the whole batch to sequential singleton levels.
698
+ * Unresolvable argument payloads stay in the schedule and fail closed into
699
+ * exclusive barriers inside claim resolution. Each candidate level authorizes
700
+ * calls, re-resolves claims from exact final args, and emits lifecycle starts
701
+ * only when a final safe sublevel begins. Results remain globally buffered
702
+ * and are emitted in source order.
484
703
  */
485
- async function executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit) {
704
+ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit, dagScheduleCache) {
486
705
  const plans = toolCalls.map((toolCall) => planToolCall(currentContext, toolCall));
487
706
  const boundTools = plans.flatMap((plan) => (plan.kind === "planned" ? [plan.tool] : []));
488
707
  const toolPolicies = new Map();
@@ -490,36 +709,14 @@ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolC
490
709
  if (tool.executionMode && !toolPolicies.has(tool.name))
491
710
  toolPolicies.set(tool.name, tool.executionMode);
492
711
  }
493
- const claimableCalls = [];
494
- for (const plan of plans) {
495
- if (plan.kind === "immediate" || !isPlainArguments(plan.args)) {
496
- claimableCalls.length = 0;
497
- break;
498
- }
499
- claimableCalls.push({ id: plan.toolCall.id, name: plan.toolCall.name, arguments: plan.args });
500
- }
501
- let levels;
502
- if (claimableCalls.length === toolCalls.length) {
503
- const scheduled = await awaitWithAbort(() => scheduleDagLevels(claimableCalls, {
504
- cwd: config.cwd ?? process.cwd(),
505
- toolPolicies,
506
- registeredTools: boundTools,
507
- strictExtensionClaims: config.strictExtensionClaims,
508
- maxConcurrency: config.maxToolConcurrency,
509
- resourceKeyResolver: config.resourceKeyResolver,
510
- }), signal);
511
- levels = scheduled.kind === "aborted" ? [] : scheduled.value.levels;
512
- }
513
- else {
514
- levels = toolCalls.map((_toolCall, sourceIndex) => [sourceIndex]);
515
- }
712
+ const levels = await schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache);
516
713
  const finalizedByIndex = new Array(toolCalls.length).fill(undefined);
517
714
  let skippedReason;
518
715
  let stoppedByUnsettledTimeout = false;
519
716
  for (const level of levels) {
520
717
  if (signal?.aborted)
521
718
  break;
522
- const executedLevel = await runDagLevelCalls(currentContext, assistantMessage, level, toolCalls, plans, toolPolicies, config, signal, emit);
719
+ const executedLevel = await runDagLevelCalls(currentContext, assistantMessage, level, toolCalls, plans, toolPolicies, config, signal, emit, dagScheduleCache);
523
720
  for (const outcome of executedLevel.outcomes)
524
721
  finalizedByIndex[outcome.sourceIndex] = outcome.finalized;
525
722
  if (signal?.aborted)
@@ -568,8 +765,25 @@ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolC
568
765
  stopRun: stoppedByUnsettledTimeout,
569
766
  };
570
767
  }
768
+ /** Re-plan final claims for a runnable candidate level from exact post-hook arguments. */
769
+ async function rescheduleRunnableLevels(runnable, toolPolicies, config, signal, dagScheduleCache) {
770
+ const finalClaimableCalls = runnable.map(({ preparation }) => ({
771
+ id: preparation.toolCall.id,
772
+ name: preparation.toolCall.name,
773
+ arguments: preparation.args,
774
+ }));
775
+ const scheduled = await scheduleDagLevelsMemo(finalClaimableCalls, {
776
+ cwd: config.cwd ?? process.cwd(),
777
+ toolPolicies,
778
+ registeredTools: runnable.map(({ preparation }) => preparation.tool),
779
+ strictExtensionClaims: config.strictExtensionClaims,
780
+ maxConcurrency: config.maxToolConcurrency,
781
+ resourceKeyResolver: config.resourceKeyResolver,
782
+ }, signal, dagScheduleCache);
783
+ return scheduled === null ? null : scheduled.levels;
784
+ }
571
785
  /** Authorize one candidate DAG level, re-plan final claims, and run its safe sublevels. */
572
- async function runDagLevelCalls(currentContext, assistantMessage, levelIndices, toolCalls, plans, toolPolicies, config, signal, emit) {
786
+ async function runDagLevelCalls(currentContext, assistantMessage, levelIndices, toolCalls, plans, toolPolicies, config, signal, emit, dagScheduleCache) {
573
787
  const outcomes = [];
574
788
  const runnable = [];
575
789
  for (const sourceIndex of levelIndices) {
@@ -595,35 +809,13 @@ async function runDagLevelCalls(currentContext, assistantMessage, levelIndices,
595
809
  if (signal?.aborted)
596
810
  return { outcomes, stoppedByUnsettledTimeout: false };
597
811
  }
598
- const finalClaimableCalls = [];
599
- for (const { preparation } of runnable) {
600
- if (!isPlainArguments(preparation.args)) {
601
- finalClaimableCalls.length = 0;
602
- break;
603
- }
604
- finalClaimableCalls.push({
605
- id: preparation.toolCall.id,
606
- name: preparation.toolCall.name,
607
- arguments: preparation.args,
608
- });
609
- }
610
- let executionLevels;
611
- if (finalClaimableCalls.length === runnable.length) {
612
- const scheduled = await awaitWithAbort(() => scheduleDagLevels(finalClaimableCalls, {
613
- cwd: config.cwd ?? process.cwd(),
614
- toolPolicies,
615
- registeredTools: runnable.map(({ preparation }) => preparation.tool),
616
- strictExtensionClaims: config.strictExtensionClaims,
617
- maxConcurrency: config.maxToolConcurrency,
618
- resourceKeyResolver: config.resourceKeyResolver,
619
- }), signal);
620
- if (scheduled.kind === "aborted")
621
- return { outcomes, stoppedByUnsettledTimeout: false };
622
- executionLevels = scheduled.value.levels;
623
- }
624
- else {
625
- executionLevels = runnable.map((_entry, index) => [index]);
626
- }
812
+ // Re-plan final claims from the exact post-hook arguments. Non-plain
813
+ // payloads stay in the schedule and fail closed into exclusive barriers
814
+ // inside claim resolution rather than degrading the level to sequential
815
+ // singletons.
816
+ const executionLevels = await rescheduleRunnableLevels(runnable, toolPolicies, config, signal, dagScheduleCache);
817
+ if (executionLevels === null)
818
+ return { outcomes, stoppedByUnsettledTimeout: false };
627
819
  for (const executionLevel of executionLevels) {
628
820
  if (signal?.aborted)
629
821
  break;