@linxiraos/pi-agent-core 1.1.14 → 1.1.16
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/CHANGELOG.md +169 -26
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/agent.d.ts +17 -4
- package/dist/types/append-only-context.d.ts +0 -4
- package/dist/types/compaction/anthropic.d.ts +108 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +2 -2
- package/dist/types/compaction/compaction.d.ts +33 -18
- package/dist/types/compaction/entries.d.ts +2 -0
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/compaction/openai.d.ts +9 -8
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +63 -0
- package/dist/types/thinking.d.ts +1 -1
- package/dist/types/types.d.ts +212 -3
- package/package.json +10 -10
- package/src/agent-loop.ts +582 -86
- package/src/agent.ts +183 -38
- package/src/append-only-context.ts +122 -1
- package/src/compaction/anthropic.ts +310 -0
- package/src/compaction/compaction-v2-streaming.ts +4 -3
- package/src/compaction/compaction.ts +299 -131
- package/src/compaction/entries.ts +2 -0
- package/src/compaction/index.ts +1 -0
- package/src/compaction/openai.ts +17 -10
- package/src/compaction/prompts/anthropic-compaction-instructions.md +17 -0
- package/src/index.ts +2 -0
- package/src/proxy.ts +49 -8
- package/src/speculative-execution.ts +953 -0
- package/src/telemetry.ts +1 -0
- package/src/thinking.ts +1 -1
- package/src/tokenizer.ts +11 -2
- package/src/types.ts +232 -4
package/src/agent-loop.ts
CHANGED
|
@@ -34,8 +34,10 @@ import * as AIError from "@linxiraos/pi-ai/error";
|
|
|
34
34
|
import {
|
|
35
35
|
type CursorExecResolvedCarrier,
|
|
36
36
|
copyCursorExecResolved,
|
|
37
|
+
getStreamingPartialJson,
|
|
37
38
|
kCursorExecResolved,
|
|
38
39
|
} from "@linxiraos/pi-ai/utils/block-symbols";
|
|
40
|
+
import { stamp } from "@linxiraos/pi-ai/utils/schema/stamps";
|
|
39
41
|
import {
|
|
40
42
|
createHarmonyAuditEvent,
|
|
41
43
|
detectHarmonyLeakInAssistantMessage,
|
|
@@ -50,6 +52,7 @@ import { logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
|
50
52
|
import { INTENT_FIELD } from "@linxiraos/pi-wire";
|
|
51
53
|
import { agentPauseGate } from "./pause";
|
|
52
54
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
55
|
+
import { SpeculativeOperationCoordinator } from "./speculative-execution";
|
|
53
56
|
import {
|
|
54
57
|
type AgentTelemetry,
|
|
55
58
|
failChatSpan,
|
|
@@ -85,9 +88,13 @@ import type {
|
|
|
85
88
|
SteeringQueueState,
|
|
86
89
|
StreamFn,
|
|
87
90
|
} from "./types";
|
|
88
|
-
import {
|
|
91
|
+
import {
|
|
92
|
+
ASIDE_MESSAGE_COMMIT,
|
|
93
|
+
ASIDE_MESSAGE_DISCARD,
|
|
94
|
+
isSoftToolRequirement,
|
|
95
|
+
SPECULATIVE_STREAM_SESSION,
|
|
96
|
+
} from "./types";
|
|
89
97
|
import { yieldIfDue } from "./utils/yield";
|
|
90
|
-
|
|
91
98
|
/** Stop-details marker for a provider error after assistant content/tool args already streamed. */
|
|
92
99
|
export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
|
|
93
100
|
|
|
@@ -389,6 +396,64 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
|
|
|
389
396
|
};
|
|
390
397
|
}
|
|
391
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Incremental variant of `snapshotAssistantMessage` for per-delta
|
|
401
|
+
* `message_update` events.
|
|
402
|
+
*
|
|
403
|
+
* The stream contract guarantees that every content-block mutation a provider
|
|
404
|
+
* makes is paired with an event carrying that block's `contentIndex` (every
|
|
405
|
+
* provider mutates then pushes), and that `output.content` is append-only
|
|
406
|
+
* within a turn. A fresh snapshot therefore only needs to re-clone:
|
|
407
|
+
*
|
|
408
|
+
* - the block the current event targets (`changedIndex`),
|
|
409
|
+
* - blocks still open (started but not ended) — Cursor's edit block merges
|
|
410
|
+
* `path`/`stream_content` into a live block without an event, so open blocks
|
|
411
|
+
* are re-cloned on every delta,
|
|
412
|
+
* - blocks appended since the previous snapshot.
|
|
413
|
+
*
|
|
414
|
+
* Every other (finalized) block is carried over from the previous snapshot by
|
|
415
|
+
* reference: finalized blocks are never mutated after their end event, so
|
|
416
|
+
* sharing them is exact and turns the per-delta cost from O(turn content) into
|
|
417
|
+
* O(open blocks) — the difference between quadratic and linear streaming cost
|
|
418
|
+
* on long turns (issue #10605).
|
|
419
|
+
*/
|
|
420
|
+
function snapshotAssistantMessageIncremental(
|
|
421
|
+
live: AssistantMessage,
|
|
422
|
+
prev: AssistantMessage,
|
|
423
|
+
changedIndex: number,
|
|
424
|
+
openBlocks: ReadonlySet<number>,
|
|
425
|
+
): AssistantMessage {
|
|
426
|
+
const liveContent = live.content;
|
|
427
|
+
const prevContent = prev.content;
|
|
428
|
+
const prevLen = prevContent.length;
|
|
429
|
+
// Reference-copy the previous snapshot's block array (native-speed), then
|
|
430
|
+
// patch in fresh clones only where the live state moved: the block this
|
|
431
|
+
// delta targeted, blocks still streaming, and blocks appended since the
|
|
432
|
+
// previous snapshot. Finalized blocks keep their existing snapshot clone.
|
|
433
|
+
const content = prevContent.slice();
|
|
434
|
+
for (let i = prevLen; i < liveContent.length; i++) {
|
|
435
|
+
content.push(snapshotAssistantContentBlock(liveContent[i]!));
|
|
436
|
+
}
|
|
437
|
+
if (changedIndex >= 0 && changedIndex < prevLen && changedIndex < liveContent.length) {
|
|
438
|
+
content[changedIndex] = snapshotAssistantContentBlock(liveContent[changedIndex]!);
|
|
439
|
+
}
|
|
440
|
+
for (const openIndex of openBlocks) {
|
|
441
|
+
if (openIndex !== changedIndex && openIndex >= 0 && openIndex < prevLen && openIndex < liveContent.length) {
|
|
442
|
+
content[openIndex] = snapshotAssistantContentBlock(liveContent[openIndex]!);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
...live,
|
|
447
|
+
content,
|
|
448
|
+
usage: {
|
|
449
|
+
...live.usage,
|
|
450
|
+
cost: { ...live.usage.cost },
|
|
451
|
+
},
|
|
452
|
+
disabledFeatures: live.disabledFeatures ? [...live.disabledFeatures] : undefined,
|
|
453
|
+
toolCallAbortMessages: live.toolCallAbortMessages ? { ...live.toolCallAbortMessages } : undefined,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
392
457
|
/**
|
|
393
458
|
* Deep-clone an assistant streaming event so subscribers get an immutable view.
|
|
394
459
|
* Pass `partialSnapshot` when the caller has already snapshotted `event.partial`
|
|
@@ -822,6 +887,29 @@ export function normalizeMessagesForProvider(
|
|
|
822
887
|
const INTENT_FIELD_DESCRIPTION = "concise intent";
|
|
823
888
|
const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
|
|
824
889
|
|
|
890
|
+
// Memoize injection per input schema identity: normalizeTools runs on every
|
|
891
|
+
// model call and injectIntentIntoSchema mints a fresh root object each time,
|
|
892
|
+
// which defeats the stamp-keyed schema memos downstream (toolWireSchema,
|
|
893
|
+
// stripSchemaDescriptions, tryEnforceStrictSchema each deep-clone + re-walk
|
|
894
|
+
// the whole catalog per request). The injected object is shared across
|
|
895
|
+
// requests — the same profile as the intent-off path, where parameters IS the
|
|
896
|
+
// shared memoized wire schema (see schema-immutability.test.ts). One stamp
|
|
897
|
+
// key per (mode, describeIntent) variant; index 0 = bare, 1 = described.
|
|
898
|
+
const INTENT_STAMPS = {
|
|
899
|
+
require: [Symbol("intent:require"), Symbol("intent:require:described")],
|
|
900
|
+
optional: [Symbol("intent:optional"), Symbol("intent:optional:described")],
|
|
901
|
+
} as const;
|
|
902
|
+
|
|
903
|
+
function memoizedInjectIntentIntoSchema(
|
|
904
|
+
schema: Record<string, unknown>,
|
|
905
|
+
mode: "require" | "optional",
|
|
906
|
+
describeIntent: boolean,
|
|
907
|
+
): unknown {
|
|
908
|
+
return stamp(schema, INTENT_STAMPS[mode][describeIntent ? 1 : 0], host =>
|
|
909
|
+
injectIntentIntoSchema(host, mode, describeIntent),
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
825
913
|
function injectIntentIntoSchema(
|
|
826
914
|
schema: unknown,
|
|
827
915
|
mode: "require" | "optional" = "require",
|
|
@@ -901,12 +989,14 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
|
|
|
901
989
|
// re-inject `i` (without its hint, which `describeIntent: false` omits) so
|
|
902
990
|
// intent tracing keeps the field while no descriptions ride the wire.
|
|
903
991
|
if (pruneDescriptions) {
|
|
904
|
-
|
|
905
|
-
|
|
992
|
+
const stripped = stripSchemaDescriptions(toolWireSchema(t));
|
|
993
|
+
const parameters = (
|
|
994
|
+
doInjectIntent ? memoizedInjectIntentIntoSchema(stripped, intentMode, false) : stripped
|
|
995
|
+
) as TSchema;
|
|
906
996
|
return { ...t, parameters, description: "" };
|
|
907
997
|
}
|
|
908
|
-
|
|
909
|
-
|
|
998
|
+
const wire = toolWireSchema(t);
|
|
999
|
+
const parameters = (doInjectIntent ? memoizedInjectIntentIntoSchema(wire, intentMode, true) : wire) as TSchema;
|
|
910
1000
|
const description = t.description ?? "";
|
|
911
1001
|
const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
|
|
912
1002
|
const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
|
|
@@ -1267,6 +1357,28 @@ async function runLoopBody(
|
|
|
1267
1357
|
hostToolChoice,
|
|
1268
1358
|
softRequirementState.forcedToolChoice,
|
|
1269
1359
|
preparedProviderCall,
|
|
1360
|
+
message => {
|
|
1361
|
+
const finalToolCalls = message.content.filter(
|
|
1362
|
+
(content): content is Extract<AssistantMessage["content"][number], { type: "toolCall" }> =>
|
|
1363
|
+
content.type === "toolCall" &&
|
|
1364
|
+
(content as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
1365
|
+
);
|
|
1366
|
+
if (
|
|
1367
|
+
finalToolCalls.length === 0 ||
|
|
1368
|
+
(message.stopReason !== "toolUse" && message.stopReason !== "stop") ||
|
|
1369
|
+
isDeadlineExceeded(config.deadline)
|
|
1370
|
+
) {
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
const softGateActive =
|
|
1374
|
+
softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
|
|
1375
|
+
return (
|
|
1376
|
+
!softGateActive ||
|
|
1377
|
+
finalToolCalls.every(
|
|
1378
|
+
toolCall => softSatisfies?.(toolCall) ?? toolCall.name === softRequiredTool,
|
|
1379
|
+
)
|
|
1380
|
+
);
|
|
1381
|
+
},
|
|
1270
1382
|
);
|
|
1271
1383
|
harmonyRetryAttempt = 0;
|
|
1272
1384
|
harmonyTruncateResumeCount = 0;
|
|
@@ -1404,6 +1516,7 @@ async function runLoopBody(
|
|
|
1404
1516
|
|
|
1405
1517
|
const toolResults: ToolResultMessage[] = [];
|
|
1406
1518
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1519
|
+
SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
|
|
1407
1520
|
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
1408
1521
|
throw new Error(
|
|
1409
1522
|
`Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
|
|
@@ -1452,6 +1565,11 @@ async function runLoopBody(
|
|
|
1452
1565
|
newMessages.push(result);
|
|
1453
1566
|
}
|
|
1454
1567
|
} else if (toolCalls.length > 0) {
|
|
1568
|
+
SpeculativeOperationCoordinator.discardForMessage(
|
|
1569
|
+
message,
|
|
1570
|
+
deadlinePassed ? "deadline exceeded before dispatch" : "final message was not runnable",
|
|
1571
|
+
deadlinePassed ? "aborted" : "discarded",
|
|
1572
|
+
);
|
|
1455
1573
|
// Turn ended on a non-runnable reason (`length` truncation) or deadline was exceeded
|
|
1456
1574
|
// but left toolCall blocks behind. pair each with a placeholder result.
|
|
1457
1575
|
const skipReason = deadlinePassed ? "aborted" : message.stopReason === "length" ? "length" : "skipped";
|
|
@@ -1656,6 +1774,7 @@ async function streamAssistantResponse(
|
|
|
1656
1774
|
hostToolChoice?: ToolChoice,
|
|
1657
1775
|
forcedToolChoice?: ToolChoice,
|
|
1658
1776
|
prepared?: PreparedProviderCall,
|
|
1777
|
+
canDispatchFinalToolCalls?: (message: AssistantMessage) => boolean,
|
|
1659
1778
|
): Promise<AssistantMessage> {
|
|
1660
1779
|
const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
|
|
1661
1780
|
const { model, context: llmContext, promptToolWireTools, ownedDialect } = providerCall;
|
|
@@ -1778,6 +1897,13 @@ async function streamAssistantResponse(
|
|
|
1778
1897
|
|
|
1779
1898
|
let partialMessage: AssistantMessage | null = null;
|
|
1780
1899
|
let addedPartial = false;
|
|
1900
|
+
// Previous `message_update` snapshot for the incremental rebuild below;
|
|
1901
|
+
// null until the turn's `start` event seeds it.
|
|
1902
|
+
let turnSnapshot: AssistantMessage | null = null;
|
|
1903
|
+
// Content indices of blocks that started streaming but have not ended
|
|
1904
|
+
// yet — re-cloned on every delta because live blocks may be patched
|
|
1905
|
+
// without a paired event (Cursor's silent edit-block merge).
|
|
1906
|
+
const openBlocks = new Set<number>();
|
|
1781
1907
|
const completedToolCallIds = new Set<string>();
|
|
1782
1908
|
const argStreams = new Map<number, { id: string; stream: AgentToolArgStream }>();
|
|
1783
1909
|
const cancelArgStreams = (): void => {
|
|
@@ -1790,7 +1916,18 @@ async function streamAssistantResponse(
|
|
|
1790
1916
|
}
|
|
1791
1917
|
argStreams.clear();
|
|
1792
1918
|
};
|
|
1793
|
-
|
|
1919
|
+
const speculationConfig =
|
|
1920
|
+
config.speculativeToolExecution?.enabled === true ? config.speculativeToolExecution : undefined;
|
|
1921
|
+
const speculationCoordinator = speculationConfig
|
|
1922
|
+
? new SpeculativeOperationCoordinator(speculationConfig, {
|
|
1923
|
+
context,
|
|
1924
|
+
loopConfig: config,
|
|
1925
|
+
signal: requestSignal,
|
|
1926
|
+
})
|
|
1927
|
+
: undefined;
|
|
1928
|
+
|
|
1929
|
+
let providerStreamSettled = false;
|
|
1930
|
+
let speculationSettled = false;
|
|
1794
1931
|
const responseIterator = response[Symbol.asyncIterator]();
|
|
1795
1932
|
const finishAbortedStream = async (): Promise<AssistantMessage> => {
|
|
1796
1933
|
try {
|
|
@@ -1799,6 +1936,8 @@ async function streamAssistantResponse(
|
|
|
1799
1936
|
} catch {
|
|
1800
1937
|
// Provider cancellation failures cannot change the committed aborted message.
|
|
1801
1938
|
}
|
|
1939
|
+
await speculationCoordinator?.discardAll("run aborted", "aborted");
|
|
1940
|
+
speculationSettled = true;
|
|
1802
1941
|
const aborted = emitAbortedAssistantMessage(
|
|
1803
1942
|
partialMessage,
|
|
1804
1943
|
addedPartial,
|
|
@@ -1840,7 +1979,10 @@ async function streamAssistantResponse(
|
|
|
1840
1979
|
} else {
|
|
1841
1980
|
next = await responseIterator.next();
|
|
1842
1981
|
}
|
|
1843
|
-
if (next.done)
|
|
1982
|
+
if (next.done) {
|
|
1983
|
+
providerStreamSettled = true;
|
|
1984
|
+
break;
|
|
1985
|
+
}
|
|
1844
1986
|
|
|
1845
1987
|
const event = next.value;
|
|
1846
1988
|
if (event.type === "done" || event.type === "error") {
|
|
@@ -1872,16 +2014,40 @@ async function streamAssistantResponse(
|
|
|
1872
2014
|
if (config.transformAssistantMessage) {
|
|
1873
2015
|
await config.transformAssistantMessage(finalMessage, requestSignal);
|
|
1874
2016
|
}
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
//
|
|
1878
|
-
//
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
finalMessage
|
|
1883
|
-
|
|
1884
|
-
|
|
2017
|
+
// A pre-dispatch hook may request approval or change external state, so
|
|
2018
|
+
// do not run it until the outer loop has established this tool turn can
|
|
2019
|
+
// actually dispatch. The same gate keeps host-deferred speculation from
|
|
2020
|
+
// being released for truncated, expired, or soft-tool-rejected turns.
|
|
2021
|
+
const finalToolCallsCanDispatch =
|
|
2022
|
+
!requestSignal?.aborted &&
|
|
2023
|
+
(canDispatchFinalToolCalls?.(finalMessage) ??
|
|
2024
|
+
(finalMessage.stopReason !== "error" &&
|
|
2025
|
+
finalMessage.stopReason !== "aborted" &&
|
|
2026
|
+
finalMessage.stopReason !== "length"));
|
|
2027
|
+
const preparedDispatch = finalToolCallsCanDispatch
|
|
2028
|
+
? await prepareToolCallDispatch(finalMessage, context, config, requestSignal)
|
|
2029
|
+
: undefined;
|
|
2030
|
+
if (preparedDispatch?.size) {
|
|
2031
|
+
preparedDispatchByMessage.set(finalMessage, preparedDispatch);
|
|
2032
|
+
}
|
|
2033
|
+
if (speculationCoordinator) {
|
|
2034
|
+
if (!finalToolCallsCanDispatch || !preparedDispatch) {
|
|
2035
|
+
await speculationCoordinator.discardAll(
|
|
2036
|
+
"final message cannot reach tool dispatch",
|
|
2037
|
+
requestSignal?.aborted ? "aborted" : "discarded",
|
|
2038
|
+
);
|
|
2039
|
+
} else {
|
|
2040
|
+
await speculationCoordinator.reconcileFinalCalls(
|
|
2041
|
+
await speculativeFinalCalls(
|
|
2042
|
+
finalMessage,
|
|
2043
|
+
preparedDispatch,
|
|
2044
|
+
config.transformToolCallArguments,
|
|
2045
|
+
speculationCoordinator,
|
|
2046
|
+
),
|
|
2047
|
+
);
|
|
2048
|
+
await speculationCoordinator.finalizeAdmissions();
|
|
2049
|
+
speculationCoordinator.attach(finalMessage);
|
|
2050
|
+
}
|
|
1885
2051
|
}
|
|
1886
2052
|
if (addedPartial) {
|
|
1887
2053
|
context.messages[context.messages.length - 1] = finalMessage;
|
|
@@ -1893,6 +2059,8 @@ async function streamAssistantResponse(
|
|
|
1893
2059
|
}
|
|
1894
2060
|
stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
|
|
1895
2061
|
await finishChat(finalMessage);
|
|
2062
|
+
speculationSettled = true;
|
|
2063
|
+
providerStreamSettled = true;
|
|
1896
2064
|
return finalMessage;
|
|
1897
2065
|
}
|
|
1898
2066
|
if (requestSignal?.aborted) {
|
|
@@ -1960,6 +2128,8 @@ async function streamAssistantResponse(
|
|
|
1960
2128
|
// consumer treats both as read-only, so cloning the identical partial
|
|
1961
2129
|
// twice per delta was pure waste.
|
|
1962
2130
|
const messageSnapshot = snapshotAssistantMessage(partialMessage);
|
|
2131
|
+
turnSnapshot = messageSnapshot;
|
|
2132
|
+
openBlocks.clear();
|
|
1963
2133
|
stream.push({
|
|
1964
2134
|
type: "message_update",
|
|
1965
2135
|
assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
|
|
@@ -1968,7 +2138,8 @@ async function streamAssistantResponse(
|
|
|
1968
2138
|
} else {
|
|
1969
2139
|
context.messages.push(partialMessage);
|
|
1970
2140
|
addedPartial = true;
|
|
1971
|
-
|
|
2141
|
+
turnSnapshot = snapshotAssistantMessage(partialMessage);
|
|
2142
|
+
stream.push({ type: "message_start", message: turnSnapshot });
|
|
1972
2143
|
}
|
|
1973
2144
|
break;
|
|
1974
2145
|
|
|
@@ -1983,56 +2154,195 @@ async function streamAssistantResponse(
|
|
|
1983
2154
|
case "toolcall_delta":
|
|
1984
2155
|
case "toolcall_end":
|
|
1985
2156
|
if (partialMessage) {
|
|
2157
|
+
if (
|
|
2158
|
+
event.type === "toolcall_start" &&
|
|
2159
|
+
speculationCoordinator &&
|
|
2160
|
+
!config.transformAssistantMessage
|
|
2161
|
+
) {
|
|
2162
|
+
// Stream sessions plan from pre-transform arguments, exactly like
|
|
2163
|
+
// direct candidates (see admitFinalized below): with a transformer
|
|
2164
|
+
// installed the authoritative call may differ, so any speculative
|
|
2165
|
+
// work started from the original would be phantom I/O.
|
|
2166
|
+
speculationCoordinator.register(event.contentIndex);
|
|
2167
|
+
const toolCall = event.partial.content[event.contentIndex];
|
|
2168
|
+
if (toolCall?.type === "toolCall") {
|
|
2169
|
+
const tool = context.tools?.find(candidate => candidate.name === toolCall.name);
|
|
2170
|
+
try {
|
|
2171
|
+
const session = await tool?.speculation?.stream?.open({
|
|
2172
|
+
coordinator: speculationCoordinator,
|
|
2173
|
+
parentToolCallId: toolCall.id,
|
|
2174
|
+
});
|
|
2175
|
+
if (session) {
|
|
2176
|
+
if (!speculationCoordinator.registerStreamSession(toolCall.id, session)) {
|
|
2177
|
+
await session.discard("stream speculation coordinator rejected session");
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
} catch {
|
|
2181
|
+
await speculationCoordinator.discardStreamSession(
|
|
2182
|
+
toolCall.id,
|
|
2183
|
+
"stream speculation policy failed to open",
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
if (event.type === "toolcall_delta") {
|
|
2189
|
+
const toolCall = event.partial.content[event.contentIndex];
|
|
2190
|
+
if (toolCall?.type === "toolCall") {
|
|
2191
|
+
try {
|
|
2192
|
+
await speculationCoordinator
|
|
2193
|
+
?.streamSession(toolCall.id)
|
|
2194
|
+
?.update(toolCall, getStreamingPartialJson(toolCall));
|
|
2195
|
+
} catch {
|
|
2196
|
+
await speculationCoordinator?.discardStreamSession(
|
|
2197
|
+
toolCall.id,
|
|
2198
|
+
"stream speculation update failed",
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
1986
2203
|
if (event.type === "toolcall_end") {
|
|
1987
2204
|
completedToolCallIds.add(event.toolCall.id);
|
|
2205
|
+
const session = speculationCoordinator?.streamSession(event.toolCall.id);
|
|
2206
|
+
try {
|
|
2207
|
+
await session?.finalize({
|
|
2208
|
+
toolCall: event.toolCall,
|
|
2209
|
+
args:
|
|
2210
|
+
event.toolCall.arguments && typeof event.toolCall.arguments === "object"
|
|
2211
|
+
? (event.toolCall.arguments as Record<string, unknown>)
|
|
2212
|
+
: {},
|
|
2213
|
+
});
|
|
2214
|
+
} catch {
|
|
2215
|
+
await speculationCoordinator?.discardStreamSession(
|
|
2216
|
+
event.toolCall.id,
|
|
2217
|
+
"stream speculation finalize failed",
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
1988
2220
|
}
|
|
1989
2221
|
partialMessage = event.partial;
|
|
1990
2222
|
context.messages[context.messages.length - 1] = partialMessage;
|
|
1991
2223
|
config.onAssistantMessageEvent?.(partialMessage, event);
|
|
1992
|
-
//
|
|
1993
|
-
//
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2224
|
+
// Track which blocks are still streaming: open blocks are
|
|
2225
|
+
// re-cloned on every delta, finalized blocks are shared.
|
|
2226
|
+
const contentIndex = (event as { contentIndex?: number }).contentIndex;
|
|
2227
|
+
if (contentIndex !== undefined) {
|
|
2228
|
+
if (event.type.endsWith("_start")) openBlocks.add(contentIndex);
|
|
2229
|
+
else if (event.type.endsWith("_end")) openBlocks.delete(contentIndex);
|
|
2230
|
+
}
|
|
2231
|
+
// READ-ONLY-CONSUMER INVARIANT: `message` and
|
|
2232
|
+
// `assistantMessageEvent.partial` intentionally share one snapshot,
|
|
2233
|
+
// and the snapshot is rebuilt incrementally — only the delta's
|
|
2234
|
+
// block, open blocks, and newly appended blocks are deep-cloned;
|
|
2235
|
+
// finalized blocks are carried over from the previous snapshot by
|
|
2236
|
+
// reference (see `snapshotAssistantMessageIncremental`), so they are
|
|
2237
|
+
// shared across ALL message_update snapshots of the turn.
|
|
2238
|
+
// Consumers MUST treat both fields — and every content block inside
|
|
2239
|
+
// them — as read-only: mutating a snapshot would corrupt every
|
|
2240
|
+
// earlier and later snapshot of the turn, not just this one. In
|
|
2241
|
+
// exchange, per-delta work is proportional to the live stream
|
|
2242
|
+
// instead of the whole turn (issue #10605).
|
|
2243
|
+
const messageSnapshot: AssistantMessage = turnSnapshot
|
|
2244
|
+
? snapshotAssistantMessageIncremental(
|
|
2245
|
+
partialMessage,
|
|
2246
|
+
turnSnapshot,
|
|
2247
|
+
contentIndex ?? -1,
|
|
2248
|
+
openBlocks,
|
|
2249
|
+
)
|
|
2250
|
+
: snapshotAssistantMessage(partialMessage);
|
|
2251
|
+
turnSnapshot = messageSnapshot;
|
|
1997
2252
|
stream.push({
|
|
1998
2253
|
type: "message_update",
|
|
1999
2254
|
assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
|
|
2000
2255
|
message: messageSnapshot,
|
|
2001
2256
|
});
|
|
2002
2257
|
}
|
|
2258
|
+
if (
|
|
2259
|
+
event.type === "toolcall_end" &&
|
|
2260
|
+
speculationCoordinator &&
|
|
2261
|
+
speculationConfig &&
|
|
2262
|
+
!config.transformAssistantMessage
|
|
2263
|
+
) {
|
|
2264
|
+
speculationCoordinator.admitFinalized(context, event.toolCall, config, requestSignal);
|
|
2265
|
+
}
|
|
2003
2266
|
break;
|
|
2004
2267
|
}
|
|
2005
2268
|
}
|
|
2006
2269
|
} finally {
|
|
2007
2270
|
detachAbortListener?.();
|
|
2008
2271
|
cancelArgStreams();
|
|
2272
|
+
if (!providerStreamSettled) {
|
|
2273
|
+
await speculationCoordinator?.discardAll("provider stream failed", "aborted");
|
|
2274
|
+
speculationSettled = true;
|
|
2275
|
+
}
|
|
2009
2276
|
}
|
|
2010
2277
|
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2278
|
+
try {
|
|
2279
|
+
let trailing = await response.result();
|
|
2280
|
+
if (harmonyMitigationEnabled) {
|
|
2281
|
+
const detection = detectHarmonyLeakInAssistantMessage(trailing);
|
|
2282
|
+
if (detection) {
|
|
2283
|
+
const recovered = recoverHarmonyToolCall(trailing, detection);
|
|
2284
|
+
const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
|
|
2285
|
+
if (addedPartial) {
|
|
2286
|
+
emitDiscardedHarmonyPartial(
|
|
2287
|
+
partialMessage,
|
|
2288
|
+
stream,
|
|
2289
|
+
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
|
|
2290
|
+
);
|
|
2291
|
+
context.messages.pop();
|
|
2292
|
+
addedPartial = false;
|
|
2293
|
+
}
|
|
2294
|
+
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
if (config.transformAssistantMessage) {
|
|
2298
|
+
await config.transformAssistantMessage(trailing, requestSignal);
|
|
2299
|
+
}
|
|
2300
|
+
trailing = snapshotAssistantMessage(trailing);
|
|
2301
|
+
const finalToolCallsCanDispatch =
|
|
2302
|
+
!requestSignal?.aborted &&
|
|
2303
|
+
(canDispatchFinalToolCalls?.(trailing) ??
|
|
2304
|
+
(trailing.stopReason !== "error" &&
|
|
2305
|
+
trailing.stopReason !== "aborted" &&
|
|
2306
|
+
trailing.stopReason !== "length"));
|
|
2307
|
+
const preparedDispatch = finalToolCallsCanDispatch
|
|
2308
|
+
? await prepareToolCallDispatch(trailing, context, config, requestSignal)
|
|
2309
|
+
: undefined;
|
|
2310
|
+
if (preparedDispatch?.size) {
|
|
2311
|
+
preparedDispatchByMessage.set(trailing, preparedDispatch);
|
|
2312
|
+
}
|
|
2313
|
+
if (speculationCoordinator) {
|
|
2314
|
+
if (!finalToolCallsCanDispatch || !preparedDispatch) {
|
|
2315
|
+
await speculationCoordinator.discardAll(
|
|
2316
|
+
"final message cannot reach tool dispatch",
|
|
2317
|
+
requestSignal?.aborted ? "aborted" : "discarded",
|
|
2318
|
+
);
|
|
2319
|
+
} else {
|
|
2320
|
+
await speculationCoordinator.reconcileFinalCalls(
|
|
2321
|
+
await speculativeFinalCalls(
|
|
2322
|
+
trailing,
|
|
2323
|
+
preparedDispatch,
|
|
2324
|
+
config.transformToolCallArguments,
|
|
2325
|
+
speculationCoordinator,
|
|
2326
|
+
),
|
|
2022
2327
|
);
|
|
2023
|
-
|
|
2024
|
-
|
|
2328
|
+
await speculationCoordinator.finalizeAdmissions();
|
|
2329
|
+
speculationCoordinator.attach(trailing);
|
|
2025
2330
|
}
|
|
2026
|
-
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
2027
2331
|
}
|
|
2332
|
+
if (addedPartial) {
|
|
2333
|
+
context.messages[context.messages.length - 1] = trailing;
|
|
2334
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
|
2335
|
+
}
|
|
2336
|
+
await finishChat(trailing);
|
|
2337
|
+
speculationSettled = true;
|
|
2338
|
+
providerStreamSettled = true;
|
|
2339
|
+
return trailing;
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
if (!speculationSettled) {
|
|
2342
|
+
await speculationCoordinator?.discardAll("provider stream finalization failed", "aborted");
|
|
2343
|
+
}
|
|
2344
|
+
throw error;
|
|
2028
2345
|
}
|
|
2029
|
-
trailing = snapshotAssistantMessage(trailing);
|
|
2030
|
-
if (addedPartial) {
|
|
2031
|
-
context.messages[context.messages.length - 1] = trailing;
|
|
2032
|
-
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
|
2033
|
-
}
|
|
2034
|
-
await finishChat(trailing);
|
|
2035
|
-
return trailing;
|
|
2036
2346
|
});
|
|
2037
2347
|
} catch (err) {
|
|
2038
2348
|
failChatSpan(telemetry, chatSpan, {
|
|
@@ -2236,6 +2546,10 @@ interface PreparedToolCall {
|
|
|
2236
2546
|
tool: AgentTool<any> | undefined;
|
|
2237
2547
|
/** Validated (possibly hook-revised) execution args; raw args when validation failed. */
|
|
2238
2548
|
args: Record<string, unknown>;
|
|
2549
|
+
/** Transformed args shared by final reconciliation and eventual dispatch. */
|
|
2550
|
+
executionArgs?: Record<string, unknown>;
|
|
2551
|
+
/** Transform failure retained for execution's scheduled error result. */
|
|
2552
|
+
transformError?: unknown;
|
|
2239
2553
|
validationErrorMessage?: string;
|
|
2240
2554
|
blocked?: boolean;
|
|
2241
2555
|
blockReason?: string;
|
|
@@ -2264,11 +2578,87 @@ function resolveToolForCall(
|
|
|
2264
2578
|
tools?.find(t => t.name === toolCall.name) ??
|
|
2265
2579
|
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
|
|
2266
2580
|
// Not in the advertised set: let the host route side-transport tools
|
|
2267
|
-
// (e.g. xd:// device mounts) called by their top-level name.
|
|
2268
|
-
|
|
2581
|
+
// (e.g. xd:// device mounts) called by their top-level name. It receives
|
|
2582
|
+
// the snapshot searched above, never the agent's live tools, so a
|
|
2583
|
+
// mid-stream roster change cannot widen what this request can reach.
|
|
2584
|
+
resolveFallbackTool?.(toolCall.name, tools ?? [])
|
|
2269
2585
|
);
|
|
2270
2586
|
}
|
|
2271
2587
|
|
|
2588
|
+
/** Shortest suggestable segment; below this the match is noise (`id`, `to`). */
|
|
2589
|
+
const MIN_TOOL_NAME_SUGGESTION_SEGMENT = 3;
|
|
2590
|
+
/** Cap on names listed for an ambiguous miss, so the error stays readable. */
|
|
2591
|
+
const MAX_TOOL_NAME_SUGGESTIONS = 3;
|
|
2592
|
+
|
|
2593
|
+
/**
|
|
2594
|
+
* Tool names sharing a trailing `_`-delimited segment with `name`.
|
|
2595
|
+
*
|
|
2596
|
+
* A model that mis-transcribes a long opaque tool name reliably keeps the
|
|
2597
|
+
* trailing verb — that segment is the only part carrying meaning, while any
|
|
2598
|
+
* leading id segments are high-entropy and mnemonic-free. Matching on it turns
|
|
2599
|
+
* an otherwise dead `not found` into a self-correcting one.
|
|
2600
|
+
*
|
|
2601
|
+
* Both the last `__` and last `_` boundary are tried, so a name that lost only
|
|
2602
|
+
* its separator (`…__resolve_library_id`) and one that lost a whole id segment
|
|
2603
|
+
* (`…__read`) both recover. `fallbackNames` adds targets the host can route but
|
|
2604
|
+
* never advertises (`xd://` device mounts); without them a corrupted device
|
|
2605
|
+
* call is the one miss with nothing to suggest, because the capability exists
|
|
2606
|
+
* in the session yet appears in no advertised name. Purely advisory: this only
|
|
2607
|
+
* builds an error string and never selects a tool, so dispatch semantics are
|
|
2608
|
+
* unchanged.
|
|
2609
|
+
*/
|
|
2610
|
+
function suggestToolNames(
|
|
2611
|
+
name: string,
|
|
2612
|
+
tools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">> | undefined,
|
|
2613
|
+
fallbackNames?: Iterable<string>,
|
|
2614
|
+
): string[] {
|
|
2615
|
+
const candidates: string[] = [];
|
|
2616
|
+
for (const tool of tools ?? []) {
|
|
2617
|
+
candidates.push(tool.name);
|
|
2618
|
+
if (tool.customWireName !== undefined) candidates.push(tool.customWireName);
|
|
2619
|
+
}
|
|
2620
|
+
// Devices rank after the advertised set: when one verb matches both, the
|
|
2621
|
+
// tool the model was actually offered is the better guess.
|
|
2622
|
+
if (fallbackNames !== undefined) for (const fallbackName of fallbackNames) candidates.push(fallbackName);
|
|
2623
|
+
if (candidates.length === 0) return [];
|
|
2624
|
+
const segments: string[] = [];
|
|
2625
|
+
for (const boundary of ["__", "_"]) {
|
|
2626
|
+
const idx = name.lastIndexOf(boundary);
|
|
2627
|
+
if (idx < 0) continue;
|
|
2628
|
+
const segment = name.slice(idx + boundary.length);
|
|
2629
|
+
if (segment.length >= MIN_TOOL_NAME_SUGGESTION_SEGMENT && !segments.includes(segment)) segments.push(segment);
|
|
2630
|
+
}
|
|
2631
|
+
if (segments.length === 0) return [];
|
|
2632
|
+
// Longest tail first. A distinctive `__` tail (`resolve_library_get`) is a
|
|
2633
|
+
// far stronger signal than the generic `_` tail it contains (`get`), and the
|
|
2634
|
+
// caller truncates the list — so the strongest match has to sort ahead of
|
|
2635
|
+
// however many tools happen to share the weak one.
|
|
2636
|
+
segments.sort((a, b) => b.length - a.length);
|
|
2637
|
+
const matches: string[] = [];
|
|
2638
|
+
for (const segment of segments) {
|
|
2639
|
+
for (const candidate of candidates) {
|
|
2640
|
+
if (candidate === name || matches.includes(candidate)) continue;
|
|
2641
|
+
if (candidate === segment || candidate.endsWith(`_${segment}`)) matches.push(candidate);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
return matches;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
/**
|
|
2648
|
+
* `Tool <name> not found`, plus a suggestion when the session holds a plausible
|
|
2649
|
+
* intended target. Exact wording is not a contract; the model reads it.
|
|
2650
|
+
*/
|
|
2651
|
+
function formatToolNotFoundMessage(
|
|
2652
|
+
name: string,
|
|
2653
|
+
tools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">> | undefined,
|
|
2654
|
+
fallbackNames?: Iterable<string>,
|
|
2655
|
+
): string {
|
|
2656
|
+
const suggestions = suggestToolNames(name, tools, fallbackNames);
|
|
2657
|
+
if (suggestions.length === 0) return `Tool ${name} not found`;
|
|
2658
|
+
if (suggestions.length === 1) return `Tool ${name} not found. Did you mean ${suggestions[0]}?`;
|
|
2659
|
+
return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2272
2662
|
/**
|
|
2273
2663
|
* Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
|
|
2274
2664
|
* call order: intent extraction, argument validation, and the `beforeToolCall`
|
|
@@ -2285,7 +2675,7 @@ async function prepareToolCallDispatch(
|
|
|
2285
2675
|
config: AgentLoopConfig,
|
|
2286
2676
|
signal: AbortSignal | undefined,
|
|
2287
2677
|
): Promise<Map<string, PreparedToolCall>> {
|
|
2288
|
-
const { resolveFallbackTool, intentTracing, beforeToolCall } = config;
|
|
2678
|
+
const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
|
|
2289
2679
|
const prepared = new Map<string, PreparedToolCall>();
|
|
2290
2680
|
for (const toolCall of assistantMessage.content) {
|
|
2291
2681
|
if (toolCall.type !== "toolCall") continue;
|
|
@@ -2312,7 +2702,9 @@ async function prepareToolCallDispatch(
|
|
|
2312
2702
|
}
|
|
2313
2703
|
const validate = (args: Record<string, unknown>): Record<string, unknown> | undefined => {
|
|
2314
2704
|
try {
|
|
2315
|
-
if (!tool)
|
|
2705
|
+
if (!tool) {
|
|
2706
|
+
throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools, suggestFallbackToolNames?.()));
|
|
2707
|
+
}
|
|
2316
2708
|
return validateToolArguments(tool, { ...toolCall, arguments: args });
|
|
2317
2709
|
} catch (validationError) {
|
|
2318
2710
|
if (tool?.lenientArgValidation) {
|
|
@@ -2361,6 +2753,76 @@ async function prepareToolCallDispatch(
|
|
|
2361
2753
|
}
|
|
2362
2754
|
return prepared;
|
|
2363
2755
|
}
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* Final calls that can still reach tool execution after pre-dispatch policy.
|
|
2759
|
+
* Omitting a call is deliberate: reconciliation discards its direct candidate
|
|
2760
|
+
* and streamed children before final admission can release deferred work.
|
|
2761
|
+
*/
|
|
2762
|
+
function transformedExecutionArgs(
|
|
2763
|
+
prepared: PreparedToolCall,
|
|
2764
|
+
toolCall: AgentToolCall,
|
|
2765
|
+
transformToolCallArguments: AgentLoopConfig["transformToolCallArguments"],
|
|
2766
|
+
): Record<string, unknown> | undefined {
|
|
2767
|
+
if (prepared.transformError !== undefined) return undefined;
|
|
2768
|
+
if (prepared.executionArgs !== undefined) return prepared.executionArgs;
|
|
2769
|
+
try {
|
|
2770
|
+
const args = transformToolCallArguments
|
|
2771
|
+
? transformToolCallArguments(prepared.args, toolCall.name)
|
|
2772
|
+
: prepared.args;
|
|
2773
|
+
prepared.executionArgs = args;
|
|
2774
|
+
return args;
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
prepared.transformError = error;
|
|
2777
|
+
return undefined;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
async function speculativeFinalCalls(
|
|
2782
|
+
assistantMessage: AssistantMessage,
|
|
2783
|
+
preparedDispatch: ReadonlyMap<string, PreparedToolCall>,
|
|
2784
|
+
transformToolCallArguments: AgentLoopConfig["transformToolCallArguments"],
|
|
2785
|
+
speculationCoordinator: SpeculativeOperationCoordinator | undefined,
|
|
2786
|
+
): Promise<Map<string, AgentToolCall>> {
|
|
2787
|
+
// Settle admissions first: a slow assessment would otherwise look like a
|
|
2788
|
+
// missing candidate and force a second transform application below.
|
|
2789
|
+
await speculationCoordinator?.settleAdmissions();
|
|
2790
|
+
const calls = new Map<string, AgentToolCall>();
|
|
2791
|
+
for (const content of assistantMessage.content) {
|
|
2792
|
+
if (content.type !== "toolCall" || (content as CursorExecResolvedCarrier)[kCursorExecResolved] === true) {
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
2795
|
+
const prepared = preparedDispatch.get(content.id);
|
|
2796
|
+
if (
|
|
2797
|
+
!prepared ||
|
|
2798
|
+
prepared.blocked ||
|
|
2799
|
+
prepared.prepareError !== undefined ||
|
|
2800
|
+
prepared.validationErrorMessage !== undefined
|
|
2801
|
+
) {
|
|
2802
|
+
continue;
|
|
2803
|
+
}
|
|
2804
|
+
// Reuse the admission-time transform when the finalized raw call is
|
|
2805
|
+
// unchanged, so a stateful transform runs exactly once end-to-end and
|
|
2806
|
+
// the reconciled path is the path already accessed. Changed raw args
|
|
2807
|
+
// (e.g. a beforeToolCall revision) fall through to a single fresh
|
|
2808
|
+
// transform, leaving the stale candidate for reconciliation to discard.
|
|
2809
|
+
const reused = speculationCoordinator?.directExecutionArgsFor(
|
|
2810
|
+
content.id,
|
|
2811
|
+
content.arguments as Record<string, unknown>,
|
|
2812
|
+
);
|
|
2813
|
+
let executionArgs: Record<string, unknown> | undefined;
|
|
2814
|
+
if (reused !== undefined) {
|
|
2815
|
+
prepared.executionArgs = reused;
|
|
2816
|
+
executionArgs = reused;
|
|
2817
|
+
} else {
|
|
2818
|
+
executionArgs = transformedExecutionArgs(prepared, content, transformToolCallArguments);
|
|
2819
|
+
}
|
|
2820
|
+
if (executionArgs === undefined) continue;
|
|
2821
|
+
calls.set(content.id, { ...content, arguments: executionArgs });
|
|
2822
|
+
}
|
|
2823
|
+
return calls;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2364
2826
|
/**
|
|
2365
2827
|
* Execute tool calls from an assistant message.
|
|
2366
2828
|
*/
|
|
@@ -2379,8 +2841,10 @@ async function executeToolCalls(
|
|
|
2379
2841
|
hasIrcInterrupts,
|
|
2380
2842
|
interruptMode = "immediate",
|
|
2381
2843
|
getToolContext,
|
|
2844
|
+
|
|
2382
2845
|
transformToolCallArguments,
|
|
2383
2846
|
resolveFallbackTool,
|
|
2847
|
+
suggestFallbackToolNames,
|
|
2384
2848
|
afterToolCall,
|
|
2385
2849
|
} = config;
|
|
2386
2850
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
@@ -2420,6 +2884,7 @@ async function executeToolCalls(
|
|
|
2420
2884
|
const preparedDispatch =
|
|
2421
2885
|
preparedDispatchByMessage.get(assistantMessage) ??
|
|
2422
2886
|
(await prepareToolCallDispatch(assistantMessage, currentContext, config, signal));
|
|
2887
|
+
const speculationCoordinator = SpeculativeOperationCoordinator.take(assistantMessage);
|
|
2423
2888
|
|
|
2424
2889
|
const records = toolCalls.map(toolCall => {
|
|
2425
2890
|
const prepared = preparedDispatch.get(toolCall.id) ?? {
|
|
@@ -2457,6 +2922,8 @@ async function executeToolCalls(
|
|
|
2457
2922
|
blocked: prepared.blocked === true,
|
|
2458
2923
|
blockReason: prepared.blockReason,
|
|
2459
2924
|
prepareError: prepared.prepareError,
|
|
2925
|
+
executionArgs: prepared.executionArgs,
|
|
2926
|
+
transformError: prepared.transformError,
|
|
2460
2927
|
};
|
|
2461
2928
|
});
|
|
2462
2929
|
|
|
@@ -2637,7 +3104,9 @@ async function executeToolCalls(
|
|
|
2637
3104
|
|
|
2638
3105
|
await runInActiveSpan(toolSpan, async () => {
|
|
2639
3106
|
try {
|
|
2640
|
-
if (!tool)
|
|
3107
|
+
if (!tool) {
|
|
3108
|
+
throw new Error(formatToolNotFoundMessage(toolCall.name, tools, suggestFallbackToolNames?.()));
|
|
3109
|
+
}
|
|
2641
3110
|
if (record.signal.aborted) {
|
|
2642
3111
|
result = createToolSignalAbortedResult(record.signal);
|
|
2643
3112
|
isError = true;
|
|
@@ -2648,45 +3117,71 @@ async function executeToolCalls(
|
|
|
2648
3117
|
if (record.blocked) {
|
|
2649
3118
|
throw new ToolCallBlockedError(record.blockReason);
|
|
2650
3119
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
3120
|
+
if (record.transformError !== undefined) throw record.transformError;
|
|
3121
|
+
const executionArgs =
|
|
3122
|
+
record.executionArgs ??
|
|
3123
|
+
(transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs);
|
|
2654
3124
|
record.args = executionArgs;
|
|
2655
3125
|
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
// AgentToolContext itself is app-built via declaration merging, so
|
|
2659
|
-
// the loop cannot construct or extend one structurally.
|
|
2660
|
-
const toolContext = getToolContext
|
|
2661
|
-
? getToolContext({
|
|
2662
|
-
batchId,
|
|
2663
|
-
index,
|
|
2664
|
-
total: toolCalls.length,
|
|
2665
|
-
toolCalls: toolCallInfos,
|
|
2666
|
-
steeringSignal: steeringSoftController.signal,
|
|
2667
|
-
providerMetadata: toolCall.providerMetadata,
|
|
2668
|
-
})
|
|
3126
|
+
const speculativeOutcome = speculationCoordinator
|
|
3127
|
+
? await speculationCoordinator.claim(tool, toolCall, executionArgs)
|
|
2669
3128
|
: undefined;
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
3129
|
+
if (speculativeOutcome) {
|
|
3130
|
+
// Normalize exactly like the ordinary execute path below: third-party
|
|
3131
|
+
// speculation policies/hosts may return malformed results (missing or
|
|
3132
|
+
// non-array content) that must never persist verbatim in history.
|
|
3133
|
+
const coerced = coerceToolResult(speculativeOutcome.result);
|
|
3134
|
+
result = coerced.result;
|
|
3135
|
+
if (coerced.malformed || result.isError) isError = true;
|
|
3136
|
+
completedToolExecution = true;
|
|
3137
|
+
executionStarted = true;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
if (!completedToolExecution) {
|
|
3141
|
+
// The cooperative steering signal rides the loop-owned
|
|
3142
|
+
// ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
|
|
3143
|
+
// AgentToolContext itself is app-built via declaration merging, so
|
|
3144
|
+
// the loop cannot construct or extend one structurally.
|
|
3145
|
+
const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
|
|
3146
|
+
const toolContext = getToolContext?.({
|
|
3147
|
+
batchId,
|
|
3148
|
+
index,
|
|
3149
|
+
total: toolCalls.length,
|
|
3150
|
+
toolCalls: toolCallInfos,
|
|
3151
|
+
steeringSignal: steeringSoftController.signal,
|
|
3152
|
+
providerMetadata: toolCall.providerMetadata,
|
|
3153
|
+
});
|
|
3154
|
+
if (streamSession && toolContext) {
|
|
3155
|
+
toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
|
|
3156
|
+
} else if (streamSession && !streamSession.contextIndependent) {
|
|
3157
|
+
await streamSession.discard("outer tool context cannot carry stream speculation");
|
|
3158
|
+
}
|
|
3159
|
+
executionStarted = true;
|
|
3160
|
+
let rawResult: unknown;
|
|
3161
|
+
try {
|
|
3162
|
+
rawResult = await tool.execute(
|
|
3163
|
+
toolCall.id,
|
|
3164
|
+
executionArgs,
|
|
3165
|
+
record.signal,
|
|
3166
|
+
partialResult => {
|
|
3167
|
+
stream.push({
|
|
3168
|
+
type: "tool_execution_update",
|
|
3169
|
+
toolCallId: toolCall.id,
|
|
3170
|
+
toolName: toolCall.name,
|
|
3171
|
+
args: executionArgs,
|
|
3172
|
+
partialResult: coerceToolResult(partialResult).result,
|
|
3173
|
+
});
|
|
3174
|
+
},
|
|
3175
|
+
toolContext,
|
|
3176
|
+
);
|
|
3177
|
+
} finally {
|
|
3178
|
+
await streamSession?.discard("outer tool completed without committing stream speculation");
|
|
3179
|
+
}
|
|
3180
|
+
completedToolExecution = true;
|
|
3181
|
+
const coerced = coerceToolResult(rawResult);
|
|
3182
|
+
result = coerced.result;
|
|
3183
|
+
if (coerced.malformed || result.isError) isError = true;
|
|
3184
|
+
}
|
|
2690
3185
|
} catch (e) {
|
|
2691
3186
|
caughtError = e;
|
|
2692
3187
|
result = {
|
|
@@ -2887,6 +3382,7 @@ async function executeToolCalls(
|
|
|
2887
3382
|
emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
|
|
2888
3383
|
}
|
|
2889
3384
|
}
|
|
3385
|
+
await speculationCoordinator?.discardAll("candidate was not dispatched");
|
|
2890
3386
|
|
|
2891
3387
|
return { toolResults: emittedToolResults };
|
|
2892
3388
|
}
|