@linxiraos/pi-agent-core 1.1.13 → 1.1.15
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 +2 -47
- package/dist/types/agent.d.ts +13 -3
- 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 +7 -1
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/compaction/openai.d.ts +7 -7
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +63 -0
- package/dist/types/types.d.ts +203 -2
- package/package.json +8 -8
- package/src/agent-loop.ts +455 -76
- package/src/agent.ts +41 -21
- package/src/compaction/anthropic.ts +310 -0
- package/src/compaction/compaction-v2-streaming.ts +2 -2
- package/src/compaction/compaction.ts +204 -68
- package/src/compaction/index.ts +1 -0
- package/src/compaction/openai.ts +7 -7
- package/src/compaction/prompts/anthropic-compaction-instructions.md +17 -0
- package/src/index.ts +2 -0
- package/src/proxy.ts +1 -5
- package/src/speculative-execution.ts +953 -0
- package/src/tokenizer.ts +10 -2
- package/src/types.ts +218 -3
package/src/agent-loop.ts
CHANGED
|
@@ -34,6 +34,7 @@ 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";
|
|
39
40
|
import {
|
|
@@ -50,6 +51,7 @@ import { logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
|
50
51
|
import { INTENT_FIELD } from "@linxiraos/pi-wire";
|
|
51
52
|
import { agentPauseGate } from "./pause";
|
|
52
53
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
54
|
+
import { SpeculativeOperationCoordinator } from "./speculative-execution";
|
|
53
55
|
import {
|
|
54
56
|
type AgentTelemetry,
|
|
55
57
|
failChatSpan,
|
|
@@ -85,9 +87,13 @@ import type {
|
|
|
85
87
|
SteeringQueueState,
|
|
86
88
|
StreamFn,
|
|
87
89
|
} from "./types";
|
|
88
|
-
import {
|
|
90
|
+
import {
|
|
91
|
+
ASIDE_MESSAGE_COMMIT,
|
|
92
|
+
ASIDE_MESSAGE_DISCARD,
|
|
93
|
+
isSoftToolRequirement,
|
|
94
|
+
SPECULATIVE_STREAM_SESSION,
|
|
95
|
+
} from "./types";
|
|
89
96
|
import { yieldIfDue } from "./utils/yield";
|
|
90
|
-
|
|
91
97
|
/** Stop-details marker for a provider error after assistant content/tool args already streamed. */
|
|
92
98
|
export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
|
|
93
99
|
|
|
@@ -1267,6 +1273,28 @@ async function runLoopBody(
|
|
|
1267
1273
|
hostToolChoice,
|
|
1268
1274
|
softRequirementState.forcedToolChoice,
|
|
1269
1275
|
preparedProviderCall,
|
|
1276
|
+
message => {
|
|
1277
|
+
const finalToolCalls = message.content.filter(
|
|
1278
|
+
(content): content is Extract<AssistantMessage["content"][number], { type: "toolCall" }> =>
|
|
1279
|
+
content.type === "toolCall" &&
|
|
1280
|
+
(content as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
1281
|
+
);
|
|
1282
|
+
if (
|
|
1283
|
+
finalToolCalls.length === 0 ||
|
|
1284
|
+
(message.stopReason !== "toolUse" && message.stopReason !== "stop") ||
|
|
1285
|
+
isDeadlineExceeded(config.deadline)
|
|
1286
|
+
) {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
const softGateActive =
|
|
1290
|
+
softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
|
|
1291
|
+
return (
|
|
1292
|
+
!softGateActive ||
|
|
1293
|
+
finalToolCalls.every(
|
|
1294
|
+
toolCall => softSatisfies?.(toolCall) ?? toolCall.name === softRequiredTool,
|
|
1295
|
+
)
|
|
1296
|
+
);
|
|
1297
|
+
},
|
|
1270
1298
|
);
|
|
1271
1299
|
harmonyRetryAttempt = 0;
|
|
1272
1300
|
harmonyTruncateResumeCount = 0;
|
|
@@ -1404,6 +1432,7 @@ async function runLoopBody(
|
|
|
1404
1432
|
|
|
1405
1433
|
const toolResults: ToolResultMessage[] = [];
|
|
1406
1434
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1435
|
+
SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
|
|
1407
1436
|
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
1408
1437
|
throw new Error(
|
|
1409
1438
|
`Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
|
|
@@ -1452,6 +1481,11 @@ async function runLoopBody(
|
|
|
1452
1481
|
newMessages.push(result);
|
|
1453
1482
|
}
|
|
1454
1483
|
} else if (toolCalls.length > 0) {
|
|
1484
|
+
SpeculativeOperationCoordinator.discardForMessage(
|
|
1485
|
+
message,
|
|
1486
|
+
deadlinePassed ? "deadline exceeded before dispatch" : "final message was not runnable",
|
|
1487
|
+
deadlinePassed ? "aborted" : "discarded",
|
|
1488
|
+
);
|
|
1455
1489
|
// Turn ended on a non-runnable reason (`length` truncation) or deadline was exceeded
|
|
1456
1490
|
// but left toolCall blocks behind. pair each with a placeholder result.
|
|
1457
1491
|
const skipReason = deadlinePassed ? "aborted" : message.stopReason === "length" ? "length" : "skipped";
|
|
@@ -1656,6 +1690,7 @@ async function streamAssistantResponse(
|
|
|
1656
1690
|
hostToolChoice?: ToolChoice,
|
|
1657
1691
|
forcedToolChoice?: ToolChoice,
|
|
1658
1692
|
prepared?: PreparedProviderCall,
|
|
1693
|
+
canDispatchFinalToolCalls?: (message: AssistantMessage) => boolean,
|
|
1659
1694
|
): Promise<AssistantMessage> {
|
|
1660
1695
|
const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
|
|
1661
1696
|
const { model, context: llmContext, promptToolWireTools, ownedDialect } = providerCall;
|
|
@@ -1790,7 +1825,18 @@ async function streamAssistantResponse(
|
|
|
1790
1825
|
}
|
|
1791
1826
|
argStreams.clear();
|
|
1792
1827
|
};
|
|
1793
|
-
|
|
1828
|
+
const speculationConfig =
|
|
1829
|
+
config.speculativeToolExecution?.enabled === true ? config.speculativeToolExecution : undefined;
|
|
1830
|
+
const speculationCoordinator = speculationConfig
|
|
1831
|
+
? new SpeculativeOperationCoordinator(speculationConfig, {
|
|
1832
|
+
context,
|
|
1833
|
+
loopConfig: config,
|
|
1834
|
+
signal: requestSignal,
|
|
1835
|
+
})
|
|
1836
|
+
: undefined;
|
|
1837
|
+
|
|
1838
|
+
let providerStreamSettled = false;
|
|
1839
|
+
let speculationSettled = false;
|
|
1794
1840
|
const responseIterator = response[Symbol.asyncIterator]();
|
|
1795
1841
|
const finishAbortedStream = async (): Promise<AssistantMessage> => {
|
|
1796
1842
|
try {
|
|
@@ -1799,6 +1845,8 @@ async function streamAssistantResponse(
|
|
|
1799
1845
|
} catch {
|
|
1800
1846
|
// Provider cancellation failures cannot change the committed aborted message.
|
|
1801
1847
|
}
|
|
1848
|
+
await speculationCoordinator?.discardAll("run aborted", "aborted");
|
|
1849
|
+
speculationSettled = true;
|
|
1802
1850
|
const aborted = emitAbortedAssistantMessage(
|
|
1803
1851
|
partialMessage,
|
|
1804
1852
|
addedPartial,
|
|
@@ -1840,7 +1888,10 @@ async function streamAssistantResponse(
|
|
|
1840
1888
|
} else {
|
|
1841
1889
|
next = await responseIterator.next();
|
|
1842
1890
|
}
|
|
1843
|
-
if (next.done)
|
|
1891
|
+
if (next.done) {
|
|
1892
|
+
providerStreamSettled = true;
|
|
1893
|
+
break;
|
|
1894
|
+
}
|
|
1844
1895
|
|
|
1845
1896
|
const event = next.value;
|
|
1846
1897
|
if (event.type === "done" || event.type === "error") {
|
|
@@ -1872,16 +1923,40 @@ async function streamAssistantResponse(
|
|
|
1872
1923
|
if (config.transformAssistantMessage) {
|
|
1873
1924
|
await config.transformAssistantMessage(finalMessage, requestSignal);
|
|
1874
1925
|
}
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
//
|
|
1878
|
-
//
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
finalMessage
|
|
1883
|
-
|
|
1884
|
-
|
|
1926
|
+
// A pre-dispatch hook may request approval or change external state, so
|
|
1927
|
+
// do not run it until the outer loop has established this tool turn can
|
|
1928
|
+
// actually dispatch. The same gate keeps host-deferred speculation from
|
|
1929
|
+
// being released for truncated, expired, or soft-tool-rejected turns.
|
|
1930
|
+
const finalToolCallsCanDispatch =
|
|
1931
|
+
!requestSignal?.aborted &&
|
|
1932
|
+
(canDispatchFinalToolCalls?.(finalMessage) ??
|
|
1933
|
+
(finalMessage.stopReason !== "error" &&
|
|
1934
|
+
finalMessage.stopReason !== "aborted" &&
|
|
1935
|
+
finalMessage.stopReason !== "length"));
|
|
1936
|
+
const preparedDispatch = finalToolCallsCanDispatch
|
|
1937
|
+
? await prepareToolCallDispatch(finalMessage, context, config, requestSignal)
|
|
1938
|
+
: undefined;
|
|
1939
|
+
if (preparedDispatch?.size) {
|
|
1940
|
+
preparedDispatchByMessage.set(finalMessage, preparedDispatch);
|
|
1941
|
+
}
|
|
1942
|
+
if (speculationCoordinator) {
|
|
1943
|
+
if (!finalToolCallsCanDispatch || !preparedDispatch) {
|
|
1944
|
+
await speculationCoordinator.discardAll(
|
|
1945
|
+
"final message cannot reach tool dispatch",
|
|
1946
|
+
requestSignal?.aborted ? "aborted" : "discarded",
|
|
1947
|
+
);
|
|
1948
|
+
} else {
|
|
1949
|
+
await speculationCoordinator.reconcileFinalCalls(
|
|
1950
|
+
await speculativeFinalCalls(
|
|
1951
|
+
finalMessage,
|
|
1952
|
+
preparedDispatch,
|
|
1953
|
+
config.transformToolCallArguments,
|
|
1954
|
+
speculationCoordinator,
|
|
1955
|
+
),
|
|
1956
|
+
);
|
|
1957
|
+
await speculationCoordinator.finalizeAdmissions();
|
|
1958
|
+
speculationCoordinator.attach(finalMessage);
|
|
1959
|
+
}
|
|
1885
1960
|
}
|
|
1886
1961
|
if (addedPartial) {
|
|
1887
1962
|
context.messages[context.messages.length - 1] = finalMessage;
|
|
@@ -1893,6 +1968,8 @@ async function streamAssistantResponse(
|
|
|
1893
1968
|
}
|
|
1894
1969
|
stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
|
|
1895
1970
|
await finishChat(finalMessage);
|
|
1971
|
+
speculationSettled = true;
|
|
1972
|
+
providerStreamSettled = true;
|
|
1896
1973
|
return finalMessage;
|
|
1897
1974
|
}
|
|
1898
1975
|
if (requestSignal?.aborted) {
|
|
@@ -1983,8 +2060,69 @@ async function streamAssistantResponse(
|
|
|
1983
2060
|
case "toolcall_delta":
|
|
1984
2061
|
case "toolcall_end":
|
|
1985
2062
|
if (partialMessage) {
|
|
2063
|
+
if (
|
|
2064
|
+
event.type === "toolcall_start" &&
|
|
2065
|
+
speculationCoordinator &&
|
|
2066
|
+
!config.transformAssistantMessage
|
|
2067
|
+
) {
|
|
2068
|
+
// Stream sessions plan from pre-transform arguments, exactly like
|
|
2069
|
+
// direct candidates (see admitFinalized below): with a transformer
|
|
2070
|
+
// installed the authoritative call may differ, so any speculative
|
|
2071
|
+
// work started from the original would be phantom I/O.
|
|
2072
|
+
speculationCoordinator.register(event.contentIndex);
|
|
2073
|
+
const toolCall = event.partial.content[event.contentIndex];
|
|
2074
|
+
if (toolCall?.type === "toolCall") {
|
|
2075
|
+
const tool = context.tools?.find(candidate => candidate.name === toolCall.name);
|
|
2076
|
+
try {
|
|
2077
|
+
const session = await tool?.speculation?.stream?.open({
|
|
2078
|
+
coordinator: speculationCoordinator,
|
|
2079
|
+
parentToolCallId: toolCall.id,
|
|
2080
|
+
});
|
|
2081
|
+
if (session) {
|
|
2082
|
+
if (!speculationCoordinator.registerStreamSession(toolCall.id, session)) {
|
|
2083
|
+
await session.discard("stream speculation coordinator rejected session");
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
} catch {
|
|
2087
|
+
await speculationCoordinator.discardStreamSession(
|
|
2088
|
+
toolCall.id,
|
|
2089
|
+
"stream speculation policy failed to open",
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (event.type === "toolcall_delta") {
|
|
2095
|
+
const toolCall = event.partial.content[event.contentIndex];
|
|
2096
|
+
if (toolCall?.type === "toolCall") {
|
|
2097
|
+
try {
|
|
2098
|
+
await speculationCoordinator
|
|
2099
|
+
?.streamSession(toolCall.id)
|
|
2100
|
+
?.update(toolCall, getStreamingPartialJson(toolCall));
|
|
2101
|
+
} catch {
|
|
2102
|
+
await speculationCoordinator?.discardStreamSession(
|
|
2103
|
+
toolCall.id,
|
|
2104
|
+
"stream speculation update failed",
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
1986
2109
|
if (event.type === "toolcall_end") {
|
|
1987
2110
|
completedToolCallIds.add(event.toolCall.id);
|
|
2111
|
+
const session = speculationCoordinator?.streamSession(event.toolCall.id);
|
|
2112
|
+
try {
|
|
2113
|
+
await session?.finalize({
|
|
2114
|
+
toolCall: event.toolCall,
|
|
2115
|
+
args:
|
|
2116
|
+
event.toolCall.arguments && typeof event.toolCall.arguments === "object"
|
|
2117
|
+
? (event.toolCall.arguments as Record<string, unknown>)
|
|
2118
|
+
: {},
|
|
2119
|
+
});
|
|
2120
|
+
} catch {
|
|
2121
|
+
await speculationCoordinator?.discardStreamSession(
|
|
2122
|
+
event.toolCall.id,
|
|
2123
|
+
"stream speculation finalize failed",
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
1988
2126
|
}
|
|
1989
2127
|
partialMessage = event.partial;
|
|
1990
2128
|
context.messages[context.messages.length - 1] = partialMessage;
|
|
@@ -2000,39 +2138,94 @@ async function streamAssistantResponse(
|
|
|
2000
2138
|
message: messageSnapshot,
|
|
2001
2139
|
});
|
|
2002
2140
|
}
|
|
2141
|
+
if (
|
|
2142
|
+
event.type === "toolcall_end" &&
|
|
2143
|
+
speculationCoordinator &&
|
|
2144
|
+
speculationConfig &&
|
|
2145
|
+
!config.transformAssistantMessage
|
|
2146
|
+
) {
|
|
2147
|
+
speculationCoordinator.admitFinalized(context, event.toolCall, config, requestSignal);
|
|
2148
|
+
}
|
|
2003
2149
|
break;
|
|
2004
2150
|
}
|
|
2005
2151
|
}
|
|
2006
2152
|
} finally {
|
|
2007
2153
|
detachAbortListener?.();
|
|
2008
2154
|
cancelArgStreams();
|
|
2155
|
+
if (!providerStreamSettled) {
|
|
2156
|
+
await speculationCoordinator?.discardAll("provider stream failed", "aborted");
|
|
2157
|
+
speculationSettled = true;
|
|
2158
|
+
}
|
|
2009
2159
|
}
|
|
2010
2160
|
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2161
|
+
try {
|
|
2162
|
+
let trailing = await response.result();
|
|
2163
|
+
if (harmonyMitigationEnabled) {
|
|
2164
|
+
const detection = detectHarmonyLeakInAssistantMessage(trailing);
|
|
2165
|
+
if (detection) {
|
|
2166
|
+
const recovered = recoverHarmonyToolCall(trailing, detection);
|
|
2167
|
+
const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
|
|
2168
|
+
if (addedPartial) {
|
|
2169
|
+
emitDiscardedHarmonyPartial(
|
|
2170
|
+
partialMessage,
|
|
2171
|
+
stream,
|
|
2172
|
+
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
|
|
2173
|
+
);
|
|
2174
|
+
context.messages.pop();
|
|
2175
|
+
addedPartial = false;
|
|
2176
|
+
}
|
|
2177
|
+
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (config.transformAssistantMessage) {
|
|
2181
|
+
await config.transformAssistantMessage(trailing, requestSignal);
|
|
2182
|
+
}
|
|
2183
|
+
trailing = snapshotAssistantMessage(trailing);
|
|
2184
|
+
const finalToolCallsCanDispatch =
|
|
2185
|
+
!requestSignal?.aborted &&
|
|
2186
|
+
(canDispatchFinalToolCalls?.(trailing) ??
|
|
2187
|
+
(trailing.stopReason !== "error" &&
|
|
2188
|
+
trailing.stopReason !== "aborted" &&
|
|
2189
|
+
trailing.stopReason !== "length"));
|
|
2190
|
+
const preparedDispatch = finalToolCallsCanDispatch
|
|
2191
|
+
? await prepareToolCallDispatch(trailing, context, config, requestSignal)
|
|
2192
|
+
: undefined;
|
|
2193
|
+
if (preparedDispatch?.size) {
|
|
2194
|
+
preparedDispatchByMessage.set(trailing, preparedDispatch);
|
|
2195
|
+
}
|
|
2196
|
+
if (speculationCoordinator) {
|
|
2197
|
+
if (!finalToolCallsCanDispatch || !preparedDispatch) {
|
|
2198
|
+
await speculationCoordinator.discardAll(
|
|
2199
|
+
"final message cannot reach tool dispatch",
|
|
2200
|
+
requestSignal?.aborted ? "aborted" : "discarded",
|
|
2201
|
+
);
|
|
2202
|
+
} else {
|
|
2203
|
+
await speculationCoordinator.reconcileFinalCalls(
|
|
2204
|
+
await speculativeFinalCalls(
|
|
2205
|
+
trailing,
|
|
2206
|
+
preparedDispatch,
|
|
2207
|
+
config.transformToolCallArguments,
|
|
2208
|
+
speculationCoordinator,
|
|
2209
|
+
),
|
|
2022
2210
|
);
|
|
2023
|
-
|
|
2024
|
-
|
|
2211
|
+
await speculationCoordinator.finalizeAdmissions();
|
|
2212
|
+
speculationCoordinator.attach(trailing);
|
|
2025
2213
|
}
|
|
2026
|
-
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
2027
2214
|
}
|
|
2215
|
+
if (addedPartial) {
|
|
2216
|
+
context.messages[context.messages.length - 1] = trailing;
|
|
2217
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
|
2218
|
+
}
|
|
2219
|
+
await finishChat(trailing);
|
|
2220
|
+
speculationSettled = true;
|
|
2221
|
+
providerStreamSettled = true;
|
|
2222
|
+
return trailing;
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
if (!speculationSettled) {
|
|
2225
|
+
await speculationCoordinator?.discardAll("provider stream finalization failed", "aborted");
|
|
2226
|
+
}
|
|
2227
|
+
throw error;
|
|
2028
2228
|
}
|
|
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
2229
|
});
|
|
2037
2230
|
} catch (err) {
|
|
2038
2231
|
failChatSpan(telemetry, chatSpan, {
|
|
@@ -2236,6 +2429,10 @@ interface PreparedToolCall {
|
|
|
2236
2429
|
tool: AgentTool<any> | undefined;
|
|
2237
2430
|
/** Validated (possibly hook-revised) execution args; raw args when validation failed. */
|
|
2238
2431
|
args: Record<string, unknown>;
|
|
2432
|
+
/** Transformed args shared by final reconciliation and eventual dispatch. */
|
|
2433
|
+
executionArgs?: Record<string, unknown>;
|
|
2434
|
+
/** Transform failure retained for execution's scheduled error result. */
|
|
2435
|
+
transformError?: unknown;
|
|
2239
2436
|
validationErrorMessage?: string;
|
|
2240
2437
|
blocked?: boolean;
|
|
2241
2438
|
blockReason?: string;
|
|
@@ -2264,11 +2461,87 @@ function resolveToolForCall(
|
|
|
2264
2461
|
tools?.find(t => t.name === toolCall.name) ??
|
|
2265
2462
|
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
|
|
2266
2463
|
// 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
|
-
|
|
2464
|
+
// (e.g. xd:// device mounts) called by their top-level name. It receives
|
|
2465
|
+
// the snapshot searched above, never the agent's live tools, so a
|
|
2466
|
+
// mid-stream roster change cannot widen what this request can reach.
|
|
2467
|
+
resolveFallbackTool?.(toolCall.name, tools ?? [])
|
|
2269
2468
|
);
|
|
2270
2469
|
}
|
|
2271
2470
|
|
|
2471
|
+
/** Shortest suggestable segment; below this the match is noise (`id`, `to`). */
|
|
2472
|
+
const MIN_TOOL_NAME_SUGGESTION_SEGMENT = 3;
|
|
2473
|
+
/** Cap on names listed for an ambiguous miss, so the error stays readable. */
|
|
2474
|
+
const MAX_TOOL_NAME_SUGGESTIONS = 3;
|
|
2475
|
+
|
|
2476
|
+
/**
|
|
2477
|
+
* Tool names sharing a trailing `_`-delimited segment with `name`.
|
|
2478
|
+
*
|
|
2479
|
+
* A model that mis-transcribes a long opaque tool name reliably keeps the
|
|
2480
|
+
* trailing verb — that segment is the only part carrying meaning, while any
|
|
2481
|
+
* leading id segments are high-entropy and mnemonic-free. Matching on it turns
|
|
2482
|
+
* an otherwise dead `not found` into a self-correcting one.
|
|
2483
|
+
*
|
|
2484
|
+
* Both the last `__` and last `_` boundary are tried, so a name that lost only
|
|
2485
|
+
* its separator (`…__resolve_library_id`) and one that lost a whole id segment
|
|
2486
|
+
* (`…__read`) both recover. `fallbackNames` adds targets the host can route but
|
|
2487
|
+
* never advertises (`xd://` device mounts); without them a corrupted device
|
|
2488
|
+
* call is the one miss with nothing to suggest, because the capability exists
|
|
2489
|
+
* in the session yet appears in no advertised name. Purely advisory: this only
|
|
2490
|
+
* builds an error string and never selects a tool, so dispatch semantics are
|
|
2491
|
+
* unchanged.
|
|
2492
|
+
*/
|
|
2493
|
+
function suggestToolNames(
|
|
2494
|
+
name: string,
|
|
2495
|
+
tools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">> | undefined,
|
|
2496
|
+
fallbackNames?: Iterable<string>,
|
|
2497
|
+
): string[] {
|
|
2498
|
+
const candidates: string[] = [];
|
|
2499
|
+
for (const tool of tools ?? []) {
|
|
2500
|
+
candidates.push(tool.name);
|
|
2501
|
+
if (tool.customWireName !== undefined) candidates.push(tool.customWireName);
|
|
2502
|
+
}
|
|
2503
|
+
// Devices rank after the advertised set: when one verb matches both, the
|
|
2504
|
+
// tool the model was actually offered is the better guess.
|
|
2505
|
+
if (fallbackNames !== undefined) for (const fallbackName of fallbackNames) candidates.push(fallbackName);
|
|
2506
|
+
if (candidates.length === 0) return [];
|
|
2507
|
+
const segments: string[] = [];
|
|
2508
|
+
for (const boundary of ["__", "_"]) {
|
|
2509
|
+
const idx = name.lastIndexOf(boundary);
|
|
2510
|
+
if (idx < 0) continue;
|
|
2511
|
+
const segment = name.slice(idx + boundary.length);
|
|
2512
|
+
if (segment.length >= MIN_TOOL_NAME_SUGGESTION_SEGMENT && !segments.includes(segment)) segments.push(segment);
|
|
2513
|
+
}
|
|
2514
|
+
if (segments.length === 0) return [];
|
|
2515
|
+
// Longest tail first. A distinctive `__` tail (`resolve_library_get`) is a
|
|
2516
|
+
// far stronger signal than the generic `_` tail it contains (`get`), and the
|
|
2517
|
+
// caller truncates the list — so the strongest match has to sort ahead of
|
|
2518
|
+
// however many tools happen to share the weak one.
|
|
2519
|
+
segments.sort((a, b) => b.length - a.length);
|
|
2520
|
+
const matches: string[] = [];
|
|
2521
|
+
for (const segment of segments) {
|
|
2522
|
+
for (const candidate of candidates) {
|
|
2523
|
+
if (candidate === name || matches.includes(candidate)) continue;
|
|
2524
|
+
if (candidate === segment || candidate.endsWith(`_${segment}`)) matches.push(candidate);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
return matches;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* `Tool <name> not found`, plus a suggestion when the session holds a plausible
|
|
2532
|
+
* intended target. Exact wording is not a contract; the model reads it.
|
|
2533
|
+
*/
|
|
2534
|
+
function formatToolNotFoundMessage(
|
|
2535
|
+
name: string,
|
|
2536
|
+
tools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">> | undefined,
|
|
2537
|
+
fallbackNames?: Iterable<string>,
|
|
2538
|
+
): string {
|
|
2539
|
+
const suggestions = suggestToolNames(name, tools, fallbackNames);
|
|
2540
|
+
if (suggestions.length === 0) return `Tool ${name} not found`;
|
|
2541
|
+
if (suggestions.length === 1) return `Tool ${name} not found. Did you mean ${suggestions[0]}?`;
|
|
2542
|
+
return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2272
2545
|
/**
|
|
2273
2546
|
* Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
|
|
2274
2547
|
* call order: intent extraction, argument validation, and the `beforeToolCall`
|
|
@@ -2285,7 +2558,7 @@ async function prepareToolCallDispatch(
|
|
|
2285
2558
|
config: AgentLoopConfig,
|
|
2286
2559
|
signal: AbortSignal | undefined,
|
|
2287
2560
|
): Promise<Map<string, PreparedToolCall>> {
|
|
2288
|
-
const { resolveFallbackTool, intentTracing, beforeToolCall } = config;
|
|
2561
|
+
const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
|
|
2289
2562
|
const prepared = new Map<string, PreparedToolCall>();
|
|
2290
2563
|
for (const toolCall of assistantMessage.content) {
|
|
2291
2564
|
if (toolCall.type !== "toolCall") continue;
|
|
@@ -2312,7 +2585,9 @@ async function prepareToolCallDispatch(
|
|
|
2312
2585
|
}
|
|
2313
2586
|
const validate = (args: Record<string, unknown>): Record<string, unknown> | undefined => {
|
|
2314
2587
|
try {
|
|
2315
|
-
if (!tool)
|
|
2588
|
+
if (!tool) {
|
|
2589
|
+
throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools, suggestFallbackToolNames?.()));
|
|
2590
|
+
}
|
|
2316
2591
|
return validateToolArguments(tool, { ...toolCall, arguments: args });
|
|
2317
2592
|
} catch (validationError) {
|
|
2318
2593
|
if (tool?.lenientArgValidation) {
|
|
@@ -2361,6 +2636,76 @@ async function prepareToolCallDispatch(
|
|
|
2361
2636
|
}
|
|
2362
2637
|
return prepared;
|
|
2363
2638
|
}
|
|
2639
|
+
|
|
2640
|
+
/**
|
|
2641
|
+
* Final calls that can still reach tool execution after pre-dispatch policy.
|
|
2642
|
+
* Omitting a call is deliberate: reconciliation discards its direct candidate
|
|
2643
|
+
* and streamed children before final admission can release deferred work.
|
|
2644
|
+
*/
|
|
2645
|
+
function transformedExecutionArgs(
|
|
2646
|
+
prepared: PreparedToolCall,
|
|
2647
|
+
toolCall: AgentToolCall,
|
|
2648
|
+
transformToolCallArguments: AgentLoopConfig["transformToolCallArguments"],
|
|
2649
|
+
): Record<string, unknown> | undefined {
|
|
2650
|
+
if (prepared.transformError !== undefined) return undefined;
|
|
2651
|
+
if (prepared.executionArgs !== undefined) return prepared.executionArgs;
|
|
2652
|
+
try {
|
|
2653
|
+
const args = transformToolCallArguments
|
|
2654
|
+
? transformToolCallArguments(prepared.args, toolCall.name)
|
|
2655
|
+
: prepared.args;
|
|
2656
|
+
prepared.executionArgs = args;
|
|
2657
|
+
return args;
|
|
2658
|
+
} catch (error) {
|
|
2659
|
+
prepared.transformError = error;
|
|
2660
|
+
return undefined;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
async function speculativeFinalCalls(
|
|
2665
|
+
assistantMessage: AssistantMessage,
|
|
2666
|
+
preparedDispatch: ReadonlyMap<string, PreparedToolCall>,
|
|
2667
|
+
transformToolCallArguments: AgentLoopConfig["transformToolCallArguments"],
|
|
2668
|
+
speculationCoordinator: SpeculativeOperationCoordinator | undefined,
|
|
2669
|
+
): Promise<Map<string, AgentToolCall>> {
|
|
2670
|
+
// Settle admissions first: a slow assessment would otherwise look like a
|
|
2671
|
+
// missing candidate and force a second transform application below.
|
|
2672
|
+
await speculationCoordinator?.settleAdmissions();
|
|
2673
|
+
const calls = new Map<string, AgentToolCall>();
|
|
2674
|
+
for (const content of assistantMessage.content) {
|
|
2675
|
+
if (content.type !== "toolCall" || (content as CursorExecResolvedCarrier)[kCursorExecResolved] === true) {
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2678
|
+
const prepared = preparedDispatch.get(content.id);
|
|
2679
|
+
if (
|
|
2680
|
+
!prepared ||
|
|
2681
|
+
prepared.blocked ||
|
|
2682
|
+
prepared.prepareError !== undefined ||
|
|
2683
|
+
prepared.validationErrorMessage !== undefined
|
|
2684
|
+
) {
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
// Reuse the admission-time transform when the finalized raw call is
|
|
2688
|
+
// unchanged, so a stateful transform runs exactly once end-to-end and
|
|
2689
|
+
// the reconciled path is the path already accessed. Changed raw args
|
|
2690
|
+
// (e.g. a beforeToolCall revision) fall through to a single fresh
|
|
2691
|
+
// transform, leaving the stale candidate for reconciliation to discard.
|
|
2692
|
+
const reused = speculationCoordinator?.directExecutionArgsFor(
|
|
2693
|
+
content.id,
|
|
2694
|
+
content.arguments as Record<string, unknown>,
|
|
2695
|
+
);
|
|
2696
|
+
let executionArgs: Record<string, unknown> | undefined;
|
|
2697
|
+
if (reused !== undefined) {
|
|
2698
|
+
prepared.executionArgs = reused;
|
|
2699
|
+
executionArgs = reused;
|
|
2700
|
+
} else {
|
|
2701
|
+
executionArgs = transformedExecutionArgs(prepared, content, transformToolCallArguments);
|
|
2702
|
+
}
|
|
2703
|
+
if (executionArgs === undefined) continue;
|
|
2704
|
+
calls.set(content.id, { ...content, arguments: executionArgs });
|
|
2705
|
+
}
|
|
2706
|
+
return calls;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2364
2709
|
/**
|
|
2365
2710
|
* Execute tool calls from an assistant message.
|
|
2366
2711
|
*/
|
|
@@ -2379,8 +2724,10 @@ async function executeToolCalls(
|
|
|
2379
2724
|
hasIrcInterrupts,
|
|
2380
2725
|
interruptMode = "immediate",
|
|
2381
2726
|
getToolContext,
|
|
2727
|
+
|
|
2382
2728
|
transformToolCallArguments,
|
|
2383
2729
|
resolveFallbackTool,
|
|
2730
|
+
suggestFallbackToolNames,
|
|
2384
2731
|
afterToolCall,
|
|
2385
2732
|
} = config;
|
|
2386
2733
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
@@ -2420,6 +2767,7 @@ async function executeToolCalls(
|
|
|
2420
2767
|
const preparedDispatch =
|
|
2421
2768
|
preparedDispatchByMessage.get(assistantMessage) ??
|
|
2422
2769
|
(await prepareToolCallDispatch(assistantMessage, currentContext, config, signal));
|
|
2770
|
+
const speculationCoordinator = SpeculativeOperationCoordinator.take(assistantMessage);
|
|
2423
2771
|
|
|
2424
2772
|
const records = toolCalls.map(toolCall => {
|
|
2425
2773
|
const prepared = preparedDispatch.get(toolCall.id) ?? {
|
|
@@ -2457,6 +2805,8 @@ async function executeToolCalls(
|
|
|
2457
2805
|
blocked: prepared.blocked === true,
|
|
2458
2806
|
blockReason: prepared.blockReason,
|
|
2459
2807
|
prepareError: prepared.prepareError,
|
|
2808
|
+
executionArgs: prepared.executionArgs,
|
|
2809
|
+
transformError: prepared.transformError,
|
|
2460
2810
|
};
|
|
2461
2811
|
});
|
|
2462
2812
|
|
|
@@ -2637,7 +2987,9 @@ async function executeToolCalls(
|
|
|
2637
2987
|
|
|
2638
2988
|
await runInActiveSpan(toolSpan, async () => {
|
|
2639
2989
|
try {
|
|
2640
|
-
if (!tool)
|
|
2990
|
+
if (!tool) {
|
|
2991
|
+
throw new Error(formatToolNotFoundMessage(toolCall.name, tools, suggestFallbackToolNames?.()));
|
|
2992
|
+
}
|
|
2641
2993
|
if (record.signal.aborted) {
|
|
2642
2994
|
result = createToolSignalAbortedResult(record.signal);
|
|
2643
2995
|
isError = true;
|
|
@@ -2648,45 +3000,71 @@ async function executeToolCalls(
|
|
|
2648
3000
|
if (record.blocked) {
|
|
2649
3001
|
throw new ToolCallBlockedError(record.blockReason);
|
|
2650
3002
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
3003
|
+
if (record.transformError !== undefined) throw record.transformError;
|
|
3004
|
+
const executionArgs =
|
|
3005
|
+
record.executionArgs ??
|
|
3006
|
+
(transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs);
|
|
2654
3007
|
record.args = executionArgs;
|
|
2655
3008
|
|
|
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
|
-
})
|
|
3009
|
+
const speculativeOutcome = speculationCoordinator
|
|
3010
|
+
? await speculationCoordinator.claim(tool, toolCall, executionArgs)
|
|
2669
3011
|
: undefined;
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
3012
|
+
if (speculativeOutcome) {
|
|
3013
|
+
// Normalize exactly like the ordinary execute path below: third-party
|
|
3014
|
+
// speculation policies/hosts may return malformed results (missing or
|
|
3015
|
+
// non-array content) that must never persist verbatim in history.
|
|
3016
|
+
const coerced = coerceToolResult(speculativeOutcome.result);
|
|
3017
|
+
result = coerced.result;
|
|
3018
|
+
if (coerced.malformed || result.isError) isError = true;
|
|
3019
|
+
completedToolExecution = true;
|
|
3020
|
+
executionStarted = true;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
if (!completedToolExecution) {
|
|
3024
|
+
// The cooperative steering signal rides the loop-owned
|
|
3025
|
+
// ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
|
|
3026
|
+
// AgentToolContext itself is app-built via declaration merging, so
|
|
3027
|
+
// the loop cannot construct or extend one structurally.
|
|
3028
|
+
const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
|
|
3029
|
+
const toolContext = getToolContext?.({
|
|
3030
|
+
batchId,
|
|
3031
|
+
index,
|
|
3032
|
+
total: toolCalls.length,
|
|
3033
|
+
toolCalls: toolCallInfos,
|
|
3034
|
+
steeringSignal: steeringSoftController.signal,
|
|
3035
|
+
providerMetadata: toolCall.providerMetadata,
|
|
3036
|
+
});
|
|
3037
|
+
if (streamSession && toolContext) {
|
|
3038
|
+
toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
|
|
3039
|
+
} else if (streamSession && !streamSession.contextIndependent) {
|
|
3040
|
+
await streamSession.discard("outer tool context cannot carry stream speculation");
|
|
3041
|
+
}
|
|
3042
|
+
executionStarted = true;
|
|
3043
|
+
let rawResult: unknown;
|
|
3044
|
+
try {
|
|
3045
|
+
rawResult = await tool.execute(
|
|
3046
|
+
toolCall.id,
|
|
3047
|
+
executionArgs,
|
|
3048
|
+
record.signal,
|
|
3049
|
+
partialResult => {
|
|
3050
|
+
stream.push({
|
|
3051
|
+
type: "tool_execution_update",
|
|
3052
|
+
toolCallId: toolCall.id,
|
|
3053
|
+
toolName: toolCall.name,
|
|
3054
|
+
args: executionArgs,
|
|
3055
|
+
partialResult: coerceToolResult(partialResult).result,
|
|
3056
|
+
});
|
|
3057
|
+
},
|
|
3058
|
+
toolContext,
|
|
3059
|
+
);
|
|
3060
|
+
} finally {
|
|
3061
|
+
await streamSession?.discard("outer tool completed without committing stream speculation");
|
|
3062
|
+
}
|
|
3063
|
+
completedToolExecution = true;
|
|
3064
|
+
const coerced = coerceToolResult(rawResult);
|
|
3065
|
+
result = coerced.result;
|
|
3066
|
+
if (coerced.malformed || result.isError) isError = true;
|
|
3067
|
+
}
|
|
2690
3068
|
} catch (e) {
|
|
2691
3069
|
caughtError = e;
|
|
2692
3070
|
result = {
|
|
@@ -2887,6 +3265,7 @@ async function executeToolCalls(
|
|
|
2887
3265
|
emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
|
|
2888
3266
|
}
|
|
2889
3267
|
}
|
|
3268
|
+
await speculationCoordinator?.discardAll("candidate was not dispatched");
|
|
2890
3269
|
|
|
2891
3270
|
return { toolResults: emittedToolResults };
|
|
2892
3271
|
}
|