omk-agent-core 0.90.7 → 0.90.9

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.
Files changed (62) hide show
  1. package/README.md +97 -1
  2. package/dist/agent-loop.d.ts +25 -2
  3. package/dist/agent-loop.d.ts.map +1 -1
  4. package/dist/agent-loop.js +524 -189
  5. package/dist/agent-loop.js.map +1 -1
  6. package/dist/agent.d.ts +29 -7
  7. package/dist/agent.d.ts.map +1 -1
  8. package/dist/agent.js +81 -46
  9. package/dist/agent.js.map +1 -1
  10. package/dist/builtin-tool-resource-claims.d.ts +19 -0
  11. package/dist/builtin-tool-resource-claims.d.ts.map +1 -0
  12. package/dist/builtin-tool-resource-claims.js +200 -0
  13. package/dist/builtin-tool-resource-claims.js.map +1 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/node-resource-resolver.d.ts +42 -0
  19. package/dist/node-resource-resolver.d.ts.map +1 -0
  20. package/dist/node-resource-resolver.js +149 -0
  21. package/dist/node-resource-resolver.js.map +1 -0
  22. package/dist/node.d.ts +1 -0
  23. package/dist/node.d.ts.map +1 -1
  24. package/dist/node.js +2 -0
  25. package/dist/node.js.map +1 -1
  26. package/dist/parallel-tool-batch.d.ts +12 -1
  27. package/dist/parallel-tool-batch.d.ts.map +1 -1
  28. package/dist/parallel-tool-batch.js +71 -49
  29. package/dist/parallel-tool-batch.js.map +1 -1
  30. package/dist/path-segments.d.ts +21 -1
  31. package/dist/path-segments.d.ts.map +1 -1
  32. package/dist/path-segments.js +91 -9
  33. package/dist/path-segments.js.map +1 -1
  34. package/dist/plain-data.d.ts +7 -0
  35. package/dist/plain-data.d.ts.map +1 -0
  36. package/dist/plain-data.js +70 -0
  37. package/dist/plain-data.js.map +1 -0
  38. package/dist/tool-dag-scheduler.d.ts +86 -0
  39. package/dist/tool-dag-scheduler.d.ts.map +1 -0
  40. package/dist/tool-dag-scheduler.js +171 -0
  41. package/dist/tool-dag-scheduler.js.map +1 -0
  42. package/dist/tool-execution-boundary.d.ts +52 -0
  43. package/dist/tool-execution-boundary.d.ts.map +1 -0
  44. package/dist/tool-execution-boundary.js +185 -0
  45. package/dist/tool-execution-boundary.js.map +1 -0
  46. package/dist/tool-resource-claims.d.ts +31 -0
  47. package/dist/tool-resource-claims.d.ts.map +1 -0
  48. package/dist/tool-resource-claims.js +128 -0
  49. package/dist/tool-resource-claims.js.map +1 -0
  50. package/dist/tool-timeout.d.ts +96 -0
  51. package/dist/tool-timeout.d.ts.map +1 -0
  52. package/dist/tool-timeout.js +173 -0
  53. package/dist/tool-timeout.js.map +1 -0
  54. package/dist/tool-transcript-integrity.d.ts +65 -0
  55. package/dist/tool-transcript-integrity.d.ts.map +1 -0
  56. package/dist/tool-transcript-integrity.js +223 -0
  57. package/dist/tool-transcript-integrity.js.map +1 -0
  58. package/dist/types.d.ts +219 -10
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js +50 -1
  61. package/dist/types.js.map +1 -1
  62. package/package.json +2 -2
@@ -3,7 +3,13 @@
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
5
  import { EventStream, streamSimple, validateToolArguments, } from "omk-ai";
6
- import { shouldParallelizeToolBatch } from "./parallel-tool-batch.js";
6
+ import { bindToolIdentity, isPlainArguments } from "./builtin-tool-resource-claims.js";
7
+ import { partitionToolBatchWaves } from "./parallel-tool-batch.js";
8
+ import { scheduleDagLevels } from "./tool-dag-scheduler.js";
9
+ import { awaitWithAbort, createErrorToolResult, createImmutableJsonSnapshot, createImmutableSnapshot, finalizeExecutedToolCall, parseJsonValue, stampToolResultEnvelope, } from "./tool-execution-boundary.js";
10
+ import { resolveToolTimeoutMs, runToolCallWithTimeout } from "./tool-timeout.js";
11
+ import { createSyntheticToolResult, inspectTranscriptIntegrity, repairTranscriptIntegrity, } from "./tool-transcript-integrity.js";
12
+ import { createToolResultEnvelope, } from "./types.js";
7
13
  const EMPTY_USAGE = {
8
14
  input: 0,
9
15
  output: 0,
@@ -13,35 +19,76 @@ const EMPTY_USAGE = {
13
19
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14
20
  };
15
21
  /**
16
- * Terminate the public event stream after the underlying loop rejected.
22
+ * Decide how to terminate a run after the underlying loop rejected.
17
23
  *
18
- * Mirrors `Agent.handleRunFailure`: synthesizes an assistant failure message
19
- * so consumers observe a coherent message_start/message_end/turn_end/agent_end
20
- * sequence, then ends the stream so `for await` consumers and `stream.result()`
21
- * always settle.
24
+ * A synthetic assistant failure may only be appended on top of a transcript
25
+ * whose tool turns are all closed. When `completedMessages` ends with an open
26
+ * tool turn, a safe missing-only closure (synthetic results for the unambiguous
27
+ * missing tail calls) is appended first so the failure assistant never creates
28
+ * an `assistant(tool calls) -> assistant(failure)` interleaving.
22
29
  *
23
- * Invariant: non-abort failures keep stopReason "error" and the same event
24
- * sequence; when the loop signal was aborted, the synthetic message reports
25
- * stopReason "aborted".
30
+ * If the transcript is ambiguous (duplicate/orphan/interleave, or a
31
+ * mid-transcript gap) it is never auto-repaired: the plan returns no failure
32
+ * message so the caller ends the stream without fabricating a turn over
33
+ * corruption. Pure apart from `Date.now()` on the failure message.
26
34
  */
27
- function endStreamWithFailure(stream, config, completedMessages, error, signal) {
28
- const failureMessage = {
35
+ export function planFailureTermination(completedMessages, model, error, aborted) {
36
+ const messages = [...completedMessages];
37
+ const closureResults = [];
38
+ if (!inspectTranscriptIntegrity(messages).ok) {
39
+ try {
40
+ const repaired = repairTranscriptIntegrity(messages, "Tool result missing; run terminated by error");
41
+ // repairTranscriptIntegrity appends synthetic results only for
42
+ // unambiguous missing tail calls; anything ambiguous throws above.
43
+ for (let i = messages.length; i < repaired.length; i++) {
44
+ const result = createImmutableSnapshot(repaired[i]);
45
+ closureResults.push(result);
46
+ messages.push(result);
47
+ }
48
+ }
49
+ catch {
50
+ // Ambiguous transcript: never auto-repair. Fail closed without a
51
+ // synthetic assistant turn over a corrupt transcript.
52
+ return { messages, failureMessage: undefined, closureResults: [] };
53
+ }
54
+ }
55
+ const failureMessage = createImmutableSnapshot({
29
56
  role: "assistant",
30
57
  content: [{ type: "text", text: "" }],
31
- api: config.model.api,
32
- provider: config.model.provider,
33
- model: config.model.id,
58
+ api: model.api,
59
+ provider: model.provider,
60
+ model: model.id,
34
61
  usage: EMPTY_USAGE,
35
- stopReason: signal?.aborted ? "aborted" : "error",
62
+ stopReason: aborted ? "aborted" : "error",
36
63
  errorMessage: error instanceof Error ? error.message : String(error),
37
64
  timestamp: Date.now(),
38
- };
39
- const messages = [...completedMessages, failureMessage];
40
- stream.push({ type: "message_start", message: failureMessage });
41
- stream.push({ type: "message_end", message: failureMessage });
42
- stream.push({ type: "turn_end", message: failureMessage, toolResults: [] });
43
- stream.push({ type: "agent_end", messages });
44
- stream.end(messages);
65
+ });
66
+ return { messages: [...messages, failureMessage], failureMessage, closureResults };
67
+ }
68
+ /**
69
+ * Terminate the public event stream after the underlying loop rejected.
70
+ *
71
+ * Uses {@link planFailureTermination} so the disposition of any unresolved tool
72
+ * calls matches transcript repair exactly: an unambiguous open turn is closed
73
+ * with synthetic results before a coherent
74
+ * message_start/message_end/turn_end/agent_end sequence for the failure
75
+ * assistant, and an ambiguous transcript fails closed (agent_end only, no
76
+ * fabricated assistant). The stream always settles for `for await` consumers
77
+ * and `stream.result()`.
78
+ */
79
+ function endStreamWithFailure(stream, config, completedMessages, error, signal) {
80
+ const plan = planFailureTermination(completedMessages, config.model, error, signal?.aborted ?? false);
81
+ for (const result of plan.closureResults) {
82
+ stream.push({ type: "message_start", message: result });
83
+ stream.push({ type: "message_end", message: result });
84
+ }
85
+ if (plan.failureMessage) {
86
+ stream.push({ type: "message_start", message: plan.failureMessage });
87
+ stream.push({ type: "message_end", message: plan.failureMessage });
88
+ stream.push({ type: "turn_end", message: plan.failureMessage, toolResults: [] });
89
+ }
90
+ stream.push({ type: "agent_end", messages: plan.messages });
91
+ stream.end(plan.messages);
45
92
  }
46
93
  /**
47
94
  * Start an agent loop with a new prompt message.
@@ -79,7 +126,7 @@ export function agentLoopContinue(context, config, signal, streamFn) {
79
126
  // provider supports assistant pre-fill); only an assistant turn that still
80
127
  // carries unresolved tool calls is a hard error because the provider will
81
128
  // reject the request without matching tool results.
82
- assertContinuableTail(context.messages);
129
+ assertContinuableTranscript(context.messages);
83
130
  const stream = createAgentStream();
84
131
  const completedMessages = [];
85
132
  void runAgentLoopContinue(context, config, async (event) => {
@@ -96,60 +143,55 @@ export function agentLoopContinue(context, config, signal, streamFn) {
96
143
  }
97
144
  export async function runAgentLoop(prompts, context, config, emit, signal, streamFn) {
98
145
  const newMessages = [...prompts];
99
- const currentContext = {
100
- ...context,
101
- messages: [...context.messages, ...prompts],
102
- };
103
- await emit({ type: "agent_start" });
104
- await emit({ type: "turn_start" });
146
+ const currentContext = { ...context, messages: [...context.messages, ...prompts] };
147
+ const publish = (event) => emit(createImmutableSnapshot(event));
148
+ await publish({ type: "agent_start" });
149
+ await publish({ type: "turn_start" });
105
150
  for (const prompt of prompts) {
106
- await emit({ type: "message_start", message: prompt });
107
- await emit({ type: "message_end", message: prompt });
151
+ await publish({ type: "message_start", message: prompt });
152
+ await publish({ type: "message_end", message: prompt });
108
153
  }
109
- await runLoop(currentContext, newMessages, config, signal, emit, streamFn);
154
+ await runLoop(currentContext, newMessages, config, signal, publish, streamFn);
110
155
  return newMessages;
111
156
  }
112
157
  export async function runAgentLoopContinue(context, config, emit, signal, streamFn) {
113
158
  if (context.messages.length === 0) {
114
159
  throw new Error("Cannot continue: no messages in context");
115
160
  }
116
- assertContinuableTail(context.messages);
161
+ assertContinuableTranscript(context.messages);
117
162
  const newMessages = [];
118
163
  const currentContext = { ...context };
119
- await emit({ type: "agent_start" });
120
- await emit({ type: "turn_start" });
121
- await runLoop(currentContext, newMessages, config, signal, emit, streamFn);
164
+ const publish = (event) => emit(createImmutableSnapshot(event));
165
+ await publish({ type: "agent_start" });
166
+ await publish({ type: "turn_start" });
167
+ await runLoop(currentContext, newMessages, config, signal, publish, streamFn);
122
168
  return newMessages;
123
169
  }
124
170
  function createAgentStream() {
125
171
  return new EventStream((event) => event.type === "agent_end", (event) => (event.type === "agent_end" ? event.messages : []));
126
172
  }
127
173
  /**
128
- * Validate that the final message in `messages` is one a provider can continue
129
- * from without an opaque rejection.
130
- *
131
- * - A trailing user/toolResult message is always fine.
132
- * - A trailing assistant turn is fine ONLY when it carries no unresolved tool
133
- * calls. If it does carry tool calls, there is no matching toolResult to send
134
- * and every provider (OpenAI/Anthropic/Google) rejects the request, so we
135
- * fail fast with a clear, actionable error.
174
+ * Validate the full transcript before continuing. Replaces the earlier
175
+ * last-message-only tail check: `assistant(A,B) -> result(A)` and any
176
+ * duplicate/orphan/interleaved structure now fail before the first provider
177
+ * request, not only a trailing assistant message that still carries tool calls.
136
178
  *
137
- * This intentionally does NOT inspect `convertToLlm` output: that transform is
138
- * provider-specific and runs once per turn. The conservative check here keeps
139
- * the common "continue after a finished text answer" path working (compaction,
140
- * session resume, explicit retries) while still protecting the broken tool-call
141
- * case.
179
+ * A trailing assistant turn with no tool calls (plain text/thinking) remains
180
+ * continuable, so compaction, session resume, and explicit retries keep working.
142
181
  */
143
- function assertContinuableTail(messages) {
144
- const last = messages[messages.length - 1];
145
- if (last.role !== "assistant") {
182
+ function assertContinuableTranscript(messages) {
183
+ const report = inspectTranscriptIntegrity(messages);
184
+ if (report.ok) {
146
185
  return;
147
186
  }
148
- const hasPendingToolCalls = last.content.some((c) => c.type === "toolCall");
149
- if (hasPendingToolCalls) {
187
+ const last = messages[messages.length - 1];
188
+ if (last !== undefined && last.role === "assistant" && last.content.some((block) => block.type === "toolCall")) {
150
189
  throw new Error("Cannot continue: the last assistant message has pending tool calls without matching results. " +
151
190
  "Add tool results or a new user message before continuing.");
152
191
  }
192
+ const summary = report.issues.map((issue) => `${issue.kind}:${issue.toolCallId}`).join(", ");
193
+ throw new Error(`Cannot continue: invalid tool transcript (${summary}). ` +
194
+ "Append terminal tool results or repair the transcript before continuing.");
153
195
  }
154
196
  /**
155
197
  * Main loop logic shared by agentLoop and agentLoopContinue.
@@ -184,25 +226,59 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
184
226
  // Stream assistant response
185
227
  const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
186
228
  newMessages.push(message);
229
+ // Provider output is untrusted protocol input. Reject duplicate call IDs
230
+ // 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
+ }
237
+ const toolCalls = message.content.filter((c) => c.type === "toolCall");
187
238
  if (message.stopReason === "error" || message.stopReason === "aborted") {
188
- await emit({ type: "turn_end", message, toolResults: [] });
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 });
189
252
  await emit({ type: "agent_end", messages: newMessages });
190
253
  return;
191
254
  }
192
- // Check for tool calls
193
- const toolCalls = message.content.filter((c) => c.type === "toolCall");
194
255
  const toolResults = [];
256
+ let stopAfterToolBatch = false;
195
257
  hasMoreToolCalls = false;
196
258
  if (toolCalls.length > 0) {
197
259
  const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
198
260
  toolResults.push(...executedToolBatch.messages);
199
261
  hasMoreToolCalls = !executedToolBatch.terminate;
200
- for (const result of toolResults) {
201
- currentContext.messages.push(result);
202
- newMessages.push(result);
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 });
272
+ return;
203
273
  }
274
+ for (const result of toolResults)
275
+ newMessages.push(result);
204
276
  }
205
277
  await emit({ type: "turn_end", message, toolResults });
278
+ if (stopAfterToolBatch) {
279
+ await emit({ type: "agent_end", messages: newMessages });
280
+ return;
281
+ }
206
282
  const nextTurnContext = {
207
283
  message,
208
284
  toolResults,
@@ -250,10 +326,24 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
250
326
  * This is where AgentMessage[] gets transformed to Message[] for the LLM.
251
327
  */
252
328
  async function streamAssistantResponse(context, config, signal, emit, streamFn) {
329
+ // Validate the full transcript before every provider request. This fails
330
+ // fast for `assistant(A,B) -> result(A)` and any duplicate/orphan/interleaved
331
+ // 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
+ }
253
338
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
254
339
  let messages = context.messages;
255
340
  if (config.transformContext) {
256
341
  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
+ }
257
347
  }
258
348
  // Convert to LLM-compatible messages (AgentMessage[] → Message[])
259
349
  const llmMessages = await config.convertToLlm(messages);
@@ -333,6 +423,11 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn)
333
423
  */
334
424
  async function executeToolCalls(currentContext, assistantMessage, config, signal, emit) {
335
425
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
426
+ // dag-v2 is opt-in only. An explicit sequential execution mode takes
427
+ // precedence and continues through the established waves-v1 path below.
428
+ if (config.toolScheduler === "dag-v2" && config.toolExecution !== "sequential") {
429
+ return executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit);
430
+ }
336
431
  const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
337
432
  const toolPolicies = new Map();
338
433
  for (const tool of currentContext.tools ?? []) {
@@ -340,26 +435,228 @@ async function executeToolCalls(currentContext, assistantMessage, config, signal
340
435
  toolPolicies.set(tool.name, tool.executionMode);
341
436
  }
342
437
  }
343
- const batchParallelizable = shouldParallelizeToolBatch(toolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments })), {
438
+ const batchWaves = partitionToolBatchWaves(toolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments })), {
344
439
  cwd: config.cwd ?? process.cwd(),
345
440
  toolPolicies,
346
441
  allowUnknownParallel: (toolName) => toolPolicies.get(toolName) === "parallel",
347
442
  });
348
- if (config.toolExecution === "sequential" || hasSequentialToolCall || !batchParallelizable) {
443
+ if (config.toolExecution === "sequential" ||
444
+ hasSequentialToolCall ||
445
+ batchWaves.every((wave) => wave.length === 1)) {
349
446
  return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
350
447
  }
351
- return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);
448
+ if (batchWaves.length === 1) {
449
+ return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);
450
+ }
451
+ return executeToolCallsInWaves(currentContext, assistantMessage, toolCalls, batchWaves, config, signal, emit);
452
+ }
453
+ /**
454
+ * Execute a partitioned tool-call batch wave by wave: waves run in source
455
+ * order, calls inside a multi-call wave run concurrently, and solo waves run
456
+ * sequentially. Waves are contiguous index runs, so the returned tool result
457
+ * messages keep the model's original tool-call order.
458
+ */
459
+ async function executeToolCallsInWaves(currentContext, assistantMessage, toolCalls, waves, config, signal, emit) {
460
+ const messages = [];
461
+ const waveTerminates = [];
462
+ for (const wave of waves) {
463
+ const waveCalls = wave.map((index) => toolCalls[index]);
464
+ const executedWave = waveCalls.length === 1
465
+ ? await executeToolCallsSequential(currentContext, assistantMessage, waveCalls, config, signal, emit)
466
+ : await executeToolCallsParallel(currentContext, assistantMessage, waveCalls, config, signal, emit);
467
+ messages.push(...executedWave.messages);
468
+ waveTerminates.push(executedWave.terminate);
469
+ if (signal?.aborted)
470
+ break;
471
+ }
472
+ return {
473
+ messages,
474
+ terminate: waveTerminates.length > 0 && waveTerminates.every(Boolean),
475
+ };
476
+ }
477
+ /**
478
+ * Execute a tool-call batch using the dag-v2 scheduler.
479
+ *
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.
484
+ */
485
+ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolCalls, config, signal, emit) {
486
+ const plans = toolCalls.map((toolCall) => planToolCall(currentContext, toolCall));
487
+ const boundTools = plans.flatMap((plan) => (plan.kind === "planned" ? [plan.tool] : []));
488
+ const toolPolicies = new Map();
489
+ for (const tool of boundTools) {
490
+ if (tool.executionMode && !toolPolicies.has(tool.name))
491
+ toolPolicies.set(tool.name, tool.executionMode);
492
+ }
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
+ }
516
+ const finalizedByIndex = new Array(toolCalls.length).fill(undefined);
517
+ let skippedReason;
518
+ let stoppedByUnsettledTimeout = false;
519
+ for (const level of levels) {
520
+ if (signal?.aborted)
521
+ break;
522
+ const executedLevel = await runDagLevelCalls(currentContext, assistantMessage, level, toolCalls, plans, toolPolicies, config, signal, emit);
523
+ for (const outcome of executedLevel.outcomes)
524
+ finalizedByIndex[outcome.sourceIndex] = outcome.finalized;
525
+ if (signal?.aborted)
526
+ break;
527
+ if (executedLevel.stoppedByUnsettledTimeout) {
528
+ skippedReason = "Skipped because a preceding DAG tool timed out before its execution promise settled";
529
+ stoppedByUnsettledTimeout = true;
530
+ break;
531
+ }
532
+ if (shouldTerminateToolBatch(executedLevel.outcomes.map((outcome) => outcome.finalized))) {
533
+ skippedReason = "Skipped because the preceding DAG level requested termination";
534
+ break;
535
+ }
536
+ }
537
+ const messages = [];
538
+ const finalizedCalls = [];
539
+ for (let index = 0; index < toolCalls.length; index++) {
540
+ const finalized = finalizedByIndex[index];
541
+ if (finalized) {
542
+ messages.push(createToolResultMessage(finalized));
543
+ finalizedCalls.push(finalized);
544
+ }
545
+ else if (signal?.aborted) {
546
+ const toolCall = toolCalls[index];
547
+ messages.push(createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, "Operation aborted")));
548
+ }
549
+ else if (skippedReason !== undefined) {
550
+ const toolCall = toolCalls[index];
551
+ messages.push(createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, skippedReason, Date.now(), "skipped")));
552
+ }
553
+ }
554
+ // Close the full source-ordered batch before result notification; calls not
555
+ // reached by a candidate level receive no execution lifecycle.
556
+ for (const message of messages) {
557
+ currentContext.messages.push(message);
558
+ try {
559
+ await emitToolResultMessage(message, emit);
560
+ }
561
+ finally {
562
+ finalizedCalls.find(({ toolCall }) => toolCall.id === message.toolCallId)?.commitTerminal?.();
563
+ }
564
+ }
565
+ return {
566
+ messages,
567
+ terminate: skippedReason !== undefined || shouldTerminateToolBatch(finalizedCalls),
568
+ stopRun: stoppedByUnsettledTimeout,
569
+ };
570
+ }
571
+ /** 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) {
573
+ const outcomes = [];
574
+ const runnable = [];
575
+ for (const sourceIndex of levelIndices) {
576
+ const toolCall = toolCalls[sourceIndex];
577
+ const plan = plans[sourceIndex];
578
+ const preparation = plan.kind === "immediate"
579
+ ? plan
580
+ : await authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);
581
+ if (preparation.kind === "immediate") {
582
+ outcomes.push({
583
+ sourceIndex,
584
+ finalized: {
585
+ toolCall,
586
+ result: preparation.result,
587
+ isError: preparation.isError,
588
+ envelope: preparation.envelope,
589
+ },
590
+ });
591
+ }
592
+ else {
593
+ runnable.push({ sourceIndex, preparation });
594
+ }
595
+ if (signal?.aborted)
596
+ return { outcomes, stoppedByUnsettledTimeout: false };
597
+ }
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
+ }
627
+ for (const executionLevel of executionLevels) {
628
+ if (signal?.aborted)
629
+ break;
630
+ for (const entryIndex of executionLevel) {
631
+ await emitToolExecutionStart(runnable[entryIndex].preparation, emit);
632
+ }
633
+ const finalizedLevel = await Promise.all(executionLevel.map(async (entryIndex) => {
634
+ const entry = runnable[entryIndex];
635
+ const executed = await executePreparedToolCall(entry.preparation, config, signal, emit);
636
+ const finalized = await finalizeExecutedToolCall({
637
+ currentContext,
638
+ assistantMessage,
639
+ prepared: entry.preparation,
640
+ executed,
641
+ afterToolCall: config.afterToolCall,
642
+ signal,
643
+ });
644
+ await emitToolExecutionEnd(finalized, emit);
645
+ return { sourceIndex: entry.sourceIndex, finalized };
646
+ }));
647
+ outcomes.push(...finalizedLevel);
648
+ if (signal?.aborted)
649
+ break;
650
+ if (finalizedLevel.some(({ finalized }) => finalized.envelope.disposition === "timeout" && finalized.isRealPromiseSettled?.() === false)) {
651
+ return { outcomes, stoppedByUnsettledTimeout: true };
652
+ }
653
+ }
654
+ return { outcomes, stoppedByUnsettledTimeout: false };
352
655
  }
353
656
  async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
354
657
  const finalizedCalls = [];
355
658
  const messages = [];
356
659
  for (const toolCall of toolCalls) {
357
- await emit({
358
- type: "tool_execution_start",
359
- toolCallId: toolCall.id,
360
- toolName: toolCall.name,
361
- args: toolCall.arguments,
362
- });
363
660
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
364
661
  let finalized;
365
662
  if (preparation.kind === "immediate") {
@@ -367,15 +664,31 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
367
664
  toolCall,
368
665
  result: preparation.result,
369
666
  isError: preparation.isError,
667
+ envelope: preparation.envelope,
370
668
  };
371
669
  }
372
670
  else {
373
- const executed = await executePreparedToolCall(preparation, signal, emit);
374
- finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
671
+ await emitToolExecutionStart(preparation, emit);
672
+ const executed = await executePreparedToolCall(preparation, config, signal, emit);
673
+ finalized = await finalizeExecutedToolCall({
674
+ currentContext,
675
+ assistantMessage,
676
+ prepared: preparation,
677
+ executed,
678
+ afterToolCall: config.afterToolCall,
679
+ signal,
680
+ });
375
681
  }
376
- await emitToolExecutionEnd(finalized, emit);
377
682
  const toolResultMessage = createToolResultMessage(finalized);
378
- await emitToolResultMessage(toolResultMessage, emit);
683
+ currentContext.messages.push(toolResultMessage);
684
+ if (preparation.kind === "prepared")
685
+ await emitToolExecutionEnd(finalized, emit);
686
+ try {
687
+ await emitToolResultMessage(toolResultMessage, emit);
688
+ }
689
+ finally {
690
+ finalized.commitTerminal?.();
691
+ }
379
692
  finalizedCalls.push(finalized);
380
693
  messages.push(toolResultMessage);
381
694
  if (signal?.aborted) {
@@ -390,20 +703,14 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
390
703
  async function executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit) {
391
704
  const finalizedCalls = [];
392
705
  for (const toolCall of toolCalls) {
393
- await emit({
394
- type: "tool_execution_start",
395
- toolCallId: toolCall.id,
396
- toolName: toolCall.name,
397
- args: toolCall.arguments,
398
- });
399
706
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
400
707
  if (preparation.kind === "immediate") {
401
708
  const finalized = {
402
709
  toolCall,
403
710
  result: preparation.result,
404
711
  isError: preparation.isError,
712
+ envelope: preparation.envelope,
405
713
  };
406
- await emitToolExecutionEnd(finalized, emit);
407
714
  finalizedCalls.push(finalized);
408
715
  if (signal?.aborted) {
409
716
  break;
@@ -411,8 +718,16 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
411
718
  continue;
412
719
  }
413
720
  finalizedCalls.push(async () => {
414
- const executed = await executePreparedToolCall(preparation, signal, emit);
415
- const finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
721
+ await emitToolExecutionStart(preparation, emit);
722
+ const executed = await executePreparedToolCall(preparation, config, signal, emit);
723
+ const finalized = await finalizeExecutedToolCall({
724
+ currentContext,
725
+ assistantMessage,
726
+ prepared: preparation,
727
+ executed,
728
+ afterToolCall: config.afterToolCall,
729
+ signal,
730
+ });
416
731
  await emitToolExecutionEnd(finalized, emit);
417
732
  return finalized;
418
733
  });
@@ -424,7 +739,13 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
424
739
  const messages = [];
425
740
  for (const finalized of orderedFinalizedCalls) {
426
741
  const toolResultMessage = createToolResultMessage(finalized);
427
- await emitToolResultMessage(toolResultMessage, emit);
742
+ currentContext.messages.push(toolResultMessage);
743
+ try {
744
+ await emitToolResultMessage(toolResultMessage, emit);
745
+ }
746
+ finally {
747
+ finalized.commitTerminal?.();
748
+ }
428
749
  messages.push(toolResultMessage);
429
750
  }
430
751
  return {
@@ -448,146 +769,160 @@ function prepareToolCallArguments(tool, toolCall) {
448
769
  arguments: preparedArguments,
449
770
  };
450
771
  }
451
- async function prepareToolCall(currentContext, assistantMessage, toolCall, config, signal) {
452
- const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
453
- if (!tool) {
454
- return {
455
- kind: "immediate",
456
- result: createErrorToolResult(`Tool ${toolCall.name} not found`),
457
- isError: true,
458
- };
772
+ function immediateOutcome(disposition, reason) {
773
+ return {
774
+ kind: "immediate",
775
+ result: createErrorToolResult(reason),
776
+ isError: true,
777
+ envelope: createToolResultEnvelope({ disposition, synthetic: true, executionStarted: false, reason }),
778
+ };
779
+ }
780
+ function planToolCall(currentContext, untrustedToolCall) {
781
+ try {
782
+ const toolCall = createImmutableJsonSnapshot(untrustedToolCall);
783
+ if (toolCall.id.length === 0 || toolCall.name.length === 0)
784
+ throw new TypeError("Invalid empty tool identity");
785
+ const candidate = currentContext.tools?.find((tool) => tool.name === toolCall.name);
786
+ if (!candidate)
787
+ return immediateOutcome("failed", `Tool ${toolCall.name} not found`);
788
+ const tool = bindToolIdentity(candidate, toolCall.name);
789
+ const prepared = prepareToolCallArguments(tool, toolCall);
790
+ const args = createImmutableJsonSnapshot(prepared.arguments);
791
+ const preparedToolCall = createImmutableSnapshot({ ...toolCall, arguments: args });
792
+ return { kind: "planned", toolCall, preparedToolCall, tool, args };
793
+ }
794
+ catch (error) {
795
+ return immediateOutcome("failed", error instanceof Error ? error.message : String(error));
459
796
  }
797
+ }
798
+ async function authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal) {
460
799
  try {
461
- const preparedToolCall = prepareToolCallArguments(tool, toolCall);
462
- const validatedArgs = validateToolArguments(tool, preparedToolCall);
463
- if (config.beforeToolCall) {
464
- const beforeResult = await config.beforeToolCall({
465
- assistantMessage,
466
- toolCall,
467
- args: validatedArgs,
800
+ const hookArgs = parseJsonValue(validateToolArguments(plan.tool, plan.preparedToolCall));
801
+ const beforeToolCall = config.beforeToolCall;
802
+ if (beforeToolCall) {
803
+ const bounded = await awaitWithAbort(() => beforeToolCall({
804
+ assistantMessage: createImmutableSnapshot(assistantMessage),
805
+ toolCall: plan.toolCall,
806
+ args: hookArgs,
468
807
  context: currentContext,
469
- }, signal);
470
- if (signal?.aborted) {
471
- return {
472
- kind: "immediate",
473
- result: createErrorToolResult("Operation aborted"),
474
- isError: true,
475
- };
476
- }
477
- if (beforeResult?.block) {
478
- return {
479
- kind: "immediate",
480
- result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
481
- isError: true,
482
- };
808
+ }, signal), signal);
809
+ if (bounded.kind === "aborted" || signal?.aborted)
810
+ return immediateOutcome("aborted", "Operation aborted");
811
+ if (bounded.value?.block) {
812
+ return immediateOutcome("blocked", bounded.value.reason || "Tool execution was blocked");
483
813
  }
484
814
  }
485
- if (signal?.aborted) {
486
- return {
487
- kind: "immediate",
488
- result: createErrorToolResult("Operation aborted"),
489
- isError: true,
490
- };
491
- }
815
+ if (signal?.aborted)
816
+ return immediateOutcome("aborted", "Operation aborted");
817
+ const args = createImmutableJsonSnapshot(hookArgs);
492
818
  return {
493
819
  kind: "prepared",
494
- toolCall,
495
- tool,
496
- args: validatedArgs,
820
+ toolCall: plan.toolCall,
821
+ tool: plan.tool,
822
+ args,
823
+ eventArgs: createImmutableSnapshot(args),
824
+ timeoutMs: resolveToolTimeoutMs(plan.tool, config, plan.toolCall.name),
497
825
  };
498
826
  }
499
827
  catch (error) {
500
- return {
501
- kind: "immediate",
502
- result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
503
- isError: true,
504
- };
828
+ return immediateOutcome("failed", error instanceof Error ? error.message : String(error));
505
829
  }
506
830
  }
507
- async function executePreparedToolCall(prepared, signal, emit) {
508
- const updateEvents = [];
509
- try {
510
- const result = await prepared.tool.execute(prepared.toolCall.id, prepared.args, signal, (partialResult) => {
511
- updateEvents.push(Promise.resolve(emit({
512
- type: "tool_execution_update",
513
- toolCallId: prepared.toolCall.id,
514
- toolName: prepared.toolCall.name,
515
- args: prepared.toolCall.arguments,
516
- partialResult,
517
- })));
518
- });
519
- await Promise.all(updateEvents);
520
- return { result, isError: false };
521
- }
522
- catch (error) {
523
- await Promise.all(updateEvents);
524
- return {
525
- result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
526
- isError: true,
527
- };
528
- }
831
+ async function prepareToolCall(currentContext, assistantMessage, toolCall, config, signal) {
832
+ const plan = planToolCall(currentContext, toolCall);
833
+ return plan.kind === "immediate"
834
+ ? plan
835
+ : authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);
529
836
  }
530
- async function finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, signal) {
531
- let result = executed.result;
532
- let isError = executed.isError;
533
- if (config.afterToolCall) {
534
- try {
535
- const afterResult = await config.afterToolCall({
536
- assistantMessage,
537
- toolCall: prepared.toolCall,
538
- args: prepared.args,
539
- result,
540
- isError,
541
- context: currentContext,
542
- }, signal);
543
- if (afterResult) {
544
- result = {
545
- content: afterResult.content ?? result.content,
546
- details: afterResult.details ?? result.details,
547
- terminate: afterResult.terminate ?? result.terminate,
548
- };
549
- isError = afterResult.isError ?? isError;
837
+ async function executePreparedToolCall(prepared, config, signal, emit) {
838
+ let realPromiseSettled = false;
839
+ let commitTerminal = () => { };
840
+ const terminalCommitted = new Promise((resolve) => {
841
+ commitTerminal = resolve;
842
+ });
843
+ const executed = await runToolCallWithTimeout({
844
+ toolCallId: prepared.toolCall.id,
845
+ toolName: prepared.toolCall.name,
846
+ timeoutMs: prepared.timeoutMs,
847
+ lateSettlement: config.toolExecutionPolicy?.lateSettlement,
848
+ signal,
849
+ start: async (childSignal, onUpdate) => {
850
+ try {
851
+ return await prepared.tool.execute(prepared.toolCall.id, prepared.args, childSignal, onUpdate);
550
852
  }
551
- }
552
- catch (error) {
553
- result = createErrorToolResult(error instanceof Error ? error.message : String(error));
554
- isError = true;
555
- }
556
- }
557
- return {
558
- toolCall: prepared.toolCall,
559
- result,
560
- isError,
561
- };
853
+ finally {
854
+ realPromiseSettled = true;
855
+ }
856
+ },
857
+ emitUpdate: (partialResult) => emit({
858
+ type: "tool_execution_update",
859
+ toolCallId: prepared.toolCall.id,
860
+ toolName: prepared.toolCall.name,
861
+ args: prepared.eventArgs,
862
+ partialResult: createImmutableSnapshot(partialResult),
863
+ }),
864
+ emitLateSettlement: async (settlement) => {
865
+ await terminalCommitted;
866
+ await emit({
867
+ type: "tool_execution_late_settlement",
868
+ toolCallId: settlement.toolCallId,
869
+ toolName: settlement.toolName,
870
+ disposition: settlement.disposition,
871
+ outcome: settlement.outcome,
872
+ });
873
+ },
874
+ toErrorResult: (error) => createErrorToolResult(error instanceof Error ? error.message : String(error)),
875
+ });
876
+ return { ...executed, isRealPromiseSettled: () => realPromiseSettled, commitTerminal };
562
877
  }
563
- function createErrorToolResult(message) {
564
- return {
565
- content: [{ type: "text", text: message }],
566
- details: {},
567
- };
878
+ async function emitToolExecutionStart(prepared, emit) {
879
+ await emit({
880
+ type: "tool_execution_start",
881
+ toolCallId: prepared.toolCall.id,
882
+ toolName: prepared.toolCall.name,
883
+ args: prepared.eventArgs,
884
+ });
568
885
  }
569
886
  async function emitToolExecutionEnd(finalized, emit) {
570
887
  await emit({
571
888
  type: "tool_execution_end",
572
889
  toolCallId: finalized.toolCall.id,
573
890
  toolName: finalized.toolCall.name,
574
- result: finalized.result,
891
+ result: createImmutableSnapshot(finalized.result),
575
892
  isError: finalized.isError,
576
893
  });
577
894
  }
578
895
  function createToolResultMessage(finalized) {
579
- return {
896
+ return createImmutableSnapshot({
580
897
  role: "toolResult",
581
898
  toolCallId: finalized.toolCall.id,
582
899
  toolName: finalized.toolCall.name,
583
900
  content: finalized.result.content,
584
- details: finalized.result.details,
901
+ details: stampToolResultEnvelope(finalized.result.details, finalized.envelope),
585
902
  isError: finalized.isError,
586
903
  timestamp: Date.now(),
587
- };
904
+ });
588
905
  }
589
906
  async function emitToolResultMessage(toolResultMessage, emit) {
590
907
  await emit({ type: "message_start", message: toolResultMessage });
591
908
  await emit({ type: "message_end", message: toolResultMessage });
592
909
  }
910
+ /** Commit and notify one aborted terminal for each unresolved, unstarted call. */
911
+ async function closeAbortedToolBatch(currentContext, toolCalls, existingResults, emit) {
912
+ const resolvedIds = new Set(existingResults.map((result) => result.toolCallId));
913
+ const synthesized = [];
914
+ for (const toolCall of toolCalls) {
915
+ if (resolvedIds.has(toolCall.id)) {
916
+ continue;
917
+ }
918
+ const result = createImmutableSnapshot(createSyntheticToolResult(toolCall.id, toolCall.name, "Operation aborted"));
919
+ currentContext.messages.push(result);
920
+ await emit({ type: "message_start", message: result });
921
+ await emit({ type: "message_end", message: result });
922
+ synthesized.push(result);
923
+ // Guard against a duplicated call id within the same assistant message.
924
+ resolvedIds.add(toolCall.id);
925
+ }
926
+ return synthesized;
927
+ }
593
928
  //# sourceMappingURL=agent-loop.js.map