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.
- package/README.md +21 -6
- package/dist/agent-loop.d.ts +43 -0
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +374 -182
- package/dist/agent-loop.js.map +1 -1
- package/dist/agent.d.ts +3 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +8 -0
- package/dist/agent.js.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/agent-loop.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
214
|
-
firstTurn = false;
|
|
215
|
-
}
|
|
312
|
+
firstTurn = false;
|
|
216
313
|
// Process pending messages (inject before next assistant response)
|
|
217
314
|
if (pendingMessages.length > 0) {
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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 =
|
|
372
|
+
pendingMessages = await drainMessageQueue(config.getSteeringMessages);
|
|
311
373
|
}
|
|
312
374
|
// Agent would stop here. Check for follow-up messages.
|
|
313
|
-
const followUpMessages =
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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(
|
|
359
|
-
const response = await streamFunction(
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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;
|