omk-agent-core 0.90.8 → 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 (58) 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 +492 -187
  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 +4 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -0
  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/path-segments.d.ts +8 -0
  27. package/dist/path-segments.d.ts.map +1 -1
  28. package/dist/path-segments.js +62 -9
  29. package/dist/path-segments.js.map +1 -1
  30. package/dist/plain-data.d.ts +7 -0
  31. package/dist/plain-data.d.ts.map +1 -0
  32. package/dist/plain-data.js +70 -0
  33. package/dist/plain-data.js.map +1 -0
  34. package/dist/tool-dag-scheduler.d.ts +86 -0
  35. package/dist/tool-dag-scheduler.d.ts.map +1 -0
  36. package/dist/tool-dag-scheduler.js +171 -0
  37. package/dist/tool-dag-scheduler.js.map +1 -0
  38. package/dist/tool-execution-boundary.d.ts +52 -0
  39. package/dist/tool-execution-boundary.d.ts.map +1 -0
  40. package/dist/tool-execution-boundary.js +185 -0
  41. package/dist/tool-execution-boundary.js.map +1 -0
  42. package/dist/tool-resource-claims.d.ts +31 -0
  43. package/dist/tool-resource-claims.d.ts.map +1 -0
  44. package/dist/tool-resource-claims.js +128 -0
  45. package/dist/tool-resource-claims.js.map +1 -0
  46. package/dist/tool-timeout.d.ts +96 -0
  47. package/dist/tool-timeout.d.ts.map +1 -0
  48. package/dist/tool-timeout.js +173 -0
  49. package/dist/tool-timeout.js.map +1 -0
  50. package/dist/tool-transcript-integrity.d.ts +65 -0
  51. package/dist/tool-transcript-integrity.d.ts.map +1 -0
  52. package/dist/tool-transcript-integrity.js +223 -0
  53. package/dist/tool-transcript-integrity.js.map +1 -0
  54. package/dist/types.d.ts +219 -10
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js +50 -1
  57. package/dist/types.js.map +1 -1
  58. 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 { bindToolIdentity, isPlainArguments } from "./builtin-tool-resource-claims.js";
6
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.
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.
130
178
  *
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.
136
- *
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 ?? []) {
@@ -371,25 +466,197 @@ async function executeToolCallsInWaves(currentContext, assistantMessage, toolCal
371
466
  : await executeToolCallsParallel(currentContext, assistantMessage, waveCalls, config, signal, emit);
372
467
  messages.push(...executedWave.messages);
373
468
  waveTerminates.push(executedWave.terminate);
374
- if (signal?.aborted) {
469
+ if (signal?.aborted)
375
470
  break;
376
- }
377
471
  }
378
472
  return {
379
473
  messages,
380
474
  terminate: waveTerminates.length > 0 && waveTerminates.every(Boolean),
381
475
  };
382
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 };
655
+ }
383
656
  async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
384
657
  const finalizedCalls = [];
385
658
  const messages = [];
386
659
  for (const toolCall of toolCalls) {
387
- await emit({
388
- type: "tool_execution_start",
389
- toolCallId: toolCall.id,
390
- toolName: toolCall.name,
391
- args: toolCall.arguments,
392
- });
393
660
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
394
661
  let finalized;
395
662
  if (preparation.kind === "immediate") {
@@ -397,15 +664,31 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
397
664
  toolCall,
398
665
  result: preparation.result,
399
666
  isError: preparation.isError,
667
+ envelope: preparation.envelope,
400
668
  };
401
669
  }
402
670
  else {
403
- const executed = await executePreparedToolCall(preparation, signal, emit);
404
- 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
+ });
405
681
  }
406
- await emitToolExecutionEnd(finalized, emit);
407
682
  const toolResultMessage = createToolResultMessage(finalized);
408
- 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
+ }
409
692
  finalizedCalls.push(finalized);
410
693
  messages.push(toolResultMessage);
411
694
  if (signal?.aborted) {
@@ -420,20 +703,14 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
420
703
  async function executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit) {
421
704
  const finalizedCalls = [];
422
705
  for (const toolCall of toolCalls) {
423
- await emit({
424
- type: "tool_execution_start",
425
- toolCallId: toolCall.id,
426
- toolName: toolCall.name,
427
- args: toolCall.arguments,
428
- });
429
706
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
430
707
  if (preparation.kind === "immediate") {
431
708
  const finalized = {
432
709
  toolCall,
433
710
  result: preparation.result,
434
711
  isError: preparation.isError,
712
+ envelope: preparation.envelope,
435
713
  };
436
- await emitToolExecutionEnd(finalized, emit);
437
714
  finalizedCalls.push(finalized);
438
715
  if (signal?.aborted) {
439
716
  break;
@@ -441,8 +718,16 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
441
718
  continue;
442
719
  }
443
720
  finalizedCalls.push(async () => {
444
- const executed = await executePreparedToolCall(preparation, signal, emit);
445
- 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
+ });
446
731
  await emitToolExecutionEnd(finalized, emit);
447
732
  return finalized;
448
733
  });
@@ -454,7 +739,13 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
454
739
  const messages = [];
455
740
  for (const finalized of orderedFinalizedCalls) {
456
741
  const toolResultMessage = createToolResultMessage(finalized);
457
- await emitToolResultMessage(toolResultMessage, emit);
742
+ currentContext.messages.push(toolResultMessage);
743
+ try {
744
+ await emitToolResultMessage(toolResultMessage, emit);
745
+ }
746
+ finally {
747
+ finalized.commitTerminal?.();
748
+ }
458
749
  messages.push(toolResultMessage);
459
750
  }
460
751
  return {
@@ -478,146 +769,160 @@ function prepareToolCallArguments(tool, toolCall) {
478
769
  arguments: preparedArguments,
479
770
  };
480
771
  }
481
- async function prepareToolCall(currentContext, assistantMessage, toolCall, config, signal) {
482
- const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
483
- if (!tool) {
484
- return {
485
- kind: "immediate",
486
- result: createErrorToolResult(`Tool ${toolCall.name} not found`),
487
- isError: true,
488
- };
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 };
489
793
  }
794
+ catch (error) {
795
+ return immediateOutcome("failed", error instanceof Error ? error.message : String(error));
796
+ }
797
+ }
798
+ async function authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal) {
490
799
  try {
491
- const preparedToolCall = prepareToolCallArguments(tool, toolCall);
492
- const validatedArgs = validateToolArguments(tool, preparedToolCall);
493
- if (config.beforeToolCall) {
494
- const beforeResult = await config.beforeToolCall({
495
- assistantMessage,
496
- toolCall,
497
- 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,
498
807
  context: currentContext,
499
- }, signal);
500
- if (signal?.aborted) {
501
- return {
502
- kind: "immediate",
503
- result: createErrorToolResult("Operation aborted"),
504
- isError: true,
505
- };
506
- }
507
- if (beforeResult?.block) {
508
- return {
509
- kind: "immediate",
510
- result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
511
- isError: true,
512
- };
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");
513
813
  }
514
814
  }
515
- if (signal?.aborted) {
516
- return {
517
- kind: "immediate",
518
- result: createErrorToolResult("Operation aborted"),
519
- isError: true,
520
- };
521
- }
815
+ if (signal?.aborted)
816
+ return immediateOutcome("aborted", "Operation aborted");
817
+ const args = createImmutableJsonSnapshot(hookArgs);
522
818
  return {
523
819
  kind: "prepared",
524
- toolCall,
525
- tool,
526
- 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),
527
825
  };
528
826
  }
529
827
  catch (error) {
530
- return {
531
- kind: "immediate",
532
- result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
533
- isError: true,
534
- };
828
+ return immediateOutcome("failed", error instanceof Error ? error.message : String(error));
535
829
  }
536
830
  }
537
- async function executePreparedToolCall(prepared, signal, emit) {
538
- const updateEvents = [];
539
- try {
540
- const result = await prepared.tool.execute(prepared.toolCall.id, prepared.args, signal, (partialResult) => {
541
- updateEvents.push(Promise.resolve(emit({
542
- type: "tool_execution_update",
543
- toolCallId: prepared.toolCall.id,
544
- toolName: prepared.toolCall.name,
545
- args: prepared.toolCall.arguments,
546
- partialResult,
547
- })));
548
- });
549
- await Promise.all(updateEvents);
550
- return { result, isError: false };
551
- }
552
- catch (error) {
553
- await Promise.all(updateEvents);
554
- return {
555
- result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
556
- isError: true,
557
- };
558
- }
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);
559
836
  }
560
- async function finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, signal) {
561
- let result = executed.result;
562
- let isError = executed.isError;
563
- if (config.afterToolCall) {
564
- try {
565
- const afterResult = await config.afterToolCall({
566
- assistantMessage,
567
- toolCall: prepared.toolCall,
568
- args: prepared.args,
569
- result,
570
- isError,
571
- context: currentContext,
572
- }, signal);
573
- if (afterResult) {
574
- result = {
575
- content: afterResult.content ?? result.content,
576
- details: afterResult.details ?? result.details,
577
- terminate: afterResult.terminate ?? result.terminate,
578
- };
579
- 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);
580
852
  }
581
- }
582
- catch (error) {
583
- result = createErrorToolResult(error instanceof Error ? error.message : String(error));
584
- isError = true;
585
- }
586
- }
587
- return {
588
- toolCall: prepared.toolCall,
589
- result,
590
- isError,
591
- };
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 };
592
877
  }
593
- function createErrorToolResult(message) {
594
- return {
595
- content: [{ type: "text", text: message }],
596
- details: {},
597
- };
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
+ });
598
885
  }
599
886
  async function emitToolExecutionEnd(finalized, emit) {
600
887
  await emit({
601
888
  type: "tool_execution_end",
602
889
  toolCallId: finalized.toolCall.id,
603
890
  toolName: finalized.toolCall.name,
604
- result: finalized.result,
891
+ result: createImmutableSnapshot(finalized.result),
605
892
  isError: finalized.isError,
606
893
  });
607
894
  }
608
895
  function createToolResultMessage(finalized) {
609
- return {
896
+ return createImmutableSnapshot({
610
897
  role: "toolResult",
611
898
  toolCallId: finalized.toolCall.id,
612
899
  toolName: finalized.toolCall.name,
613
900
  content: finalized.result.content,
614
- details: finalized.result.details,
901
+ details: stampToolResultEnvelope(finalized.result.details, finalized.envelope),
615
902
  isError: finalized.isError,
616
903
  timestamp: Date.now(),
617
- };
904
+ });
618
905
  }
619
906
  async function emitToolResultMessage(toolResultMessage, emit) {
620
907
  await emit({ type: "message_start", message: toolResultMessage });
621
908
  await emit({ type: "message_end", message: toolResultMessage });
622
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
+ }
623
928
  //# sourceMappingURL=agent-loop.js.map