@oh-my-pi/pi-agent-core 18.1.18 → 18.1.20

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/src/agent-loop.ts CHANGED
@@ -34,6 +34,7 @@ import * as AIError from "@oh-my-pi/pi-ai/error";
34
34
  import {
35
35
  type CursorExecResolvedCarrier,
36
36
  copyCursorExecResolved,
37
+ getStreamingPartialJson,
37
38
  kCursorExecResolved,
38
39
  } from "@oh-my-pi/pi-ai/utils/block-symbols";
39
40
  import {
@@ -50,6 +51,7 @@ import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
50
51
  import { INTENT_FIELD } from "@oh-my-pi/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 { ASIDE_MESSAGE_COMMIT, ASIDE_MESSAGE_DISCARD, isSoftToolRequirement } from "./types";
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) break;
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
- // Prepare tool dispatch (validation + the `beforeToolCall` hook)
1876
- // BEFORE the message is snapshotted for consumers: a hook args
1877
- // revision is written back into this message's toolCall blocks,
1878
- // so history, the UI, persistence, provider replay, scheduling,
1879
- // and execution all carry the revised arguments.
1880
- if (finalMessage.content.some(c => c.type === "toolCall")) {
1881
- preparedDispatchByMessage.set(
1882
- finalMessage,
1883
- await prepareToolCallDispatch(finalMessage, context, config, requestSignal),
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
- let trailing = await response.result();
2012
- if (harmonyMitigationEnabled) {
2013
- const detection = detectHarmonyLeakInAssistantMessage(trailing);
2014
- if (detection) {
2015
- const recovered = recoverHarmonyToolCall(trailing, detection);
2016
- const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
2017
- if (addedPartial) {
2018
- emitDiscardedHarmonyPartial(
2019
- partialMessage,
2020
- stream,
2021
- `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
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",
2022
2201
  );
2023
- context.messages.pop();
2024
- addedPartial = false;
2202
+ } else {
2203
+ await speculationCoordinator.reconcileFinalCalls(
2204
+ await speculativeFinalCalls(
2205
+ trailing,
2206
+ preparedDispatch,
2207
+ config.transformToolCallArguments,
2208
+ speculationCoordinator,
2209
+ ),
2210
+ );
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,8 +2461,10 @@ 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
- resolveFallbackTool?.(toolCall.name)
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
 
@@ -2275,7 +2474,7 @@ const MIN_TOOL_NAME_SUGGESTION_SEGMENT = 3;
2275
2474
  const MAX_TOOL_NAME_SUGGESTIONS = 3;
2276
2475
 
2277
2476
  /**
2278
- * Advertised tool names sharing a trailing `_`-delimited segment with `name`.
2477
+ * Tool names sharing a trailing `_`-delimited segment with `name`.
2279
2478
  *
2280
2479
  * A model that mis-transcribes a long opaque tool name reliably keeps the
2281
2480
  * trailing verb — that segment is the only part carrying meaning, while any
@@ -2284,14 +2483,27 @@ const MAX_TOOL_NAME_SUGGESTIONS = 3;
2284
2483
  *
2285
2484
  * Both the last `__` and last `_` boundary are tried, so a name that lost only
2286
2485
  * its separator (`…__resolve_library_id`) and one that lost a whole id segment
2287
- * (`…__read`) both recover. Purely advisory: this only builds an error string
2288
- * and never selects a tool, so dispatch semantics are unchanged.
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.
2289
2492
  */
2290
2493
  function suggestToolNames(
2291
2494
  name: string,
2292
2495
  tools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">> | undefined,
2496
+ fallbackNames?: Iterable<string>,
2293
2497
  ): string[] {
2294
- if (!tools || tools.length === 0) return [];
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 [];
2295
2507
  const segments: string[] = [];
2296
2508
  for (const boundary of ["__", "_"]) {
2297
2509
  const idx = name.lastIndexOf(boundary);
@@ -2307,25 +2519,24 @@ function suggestToolNames(
2307
2519
  segments.sort((a, b) => b.length - a.length);
2308
2520
  const matches: string[] = [];
2309
2521
  for (const segment of segments) {
2310
- for (const tool of tools) {
2311
- for (const candidate of [tool.name, tool.customWireName]) {
2312
- if (candidate === undefined || candidate === name || matches.includes(candidate)) continue;
2313
- if (candidate === segment || candidate.endsWith(`_${segment}`)) matches.push(candidate);
2314
- }
2522
+ for (const candidate of candidates) {
2523
+ if (candidate === name || matches.includes(candidate)) continue;
2524
+ if (candidate === segment || candidate.endsWith(`_${segment}`)) matches.push(candidate);
2315
2525
  }
2316
2526
  }
2317
2527
  return matches;
2318
2528
  }
2319
2529
 
2320
2530
  /**
2321
- * `Tool <name> not found`, plus a suggestion when the advertised set contains a
2322
- * plausible intended target. Exact wording is not a contract; the model reads it.
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.
2323
2533
  */
2324
2534
  function formatToolNotFoundMessage(
2325
2535
  name: string,
2326
2536
  tools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">> | undefined,
2537
+ fallbackNames?: Iterable<string>,
2327
2538
  ): string {
2328
- const suggestions = suggestToolNames(name, tools);
2539
+ const suggestions = suggestToolNames(name, tools, fallbackNames);
2329
2540
  if (suggestions.length === 0) return `Tool ${name} not found`;
2330
2541
  if (suggestions.length === 1) return `Tool ${name} not found. Did you mean ${suggestions[0]}?`;
2331
2542
  return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
@@ -2347,7 +2558,7 @@ async function prepareToolCallDispatch(
2347
2558
  config: AgentLoopConfig,
2348
2559
  signal: AbortSignal | undefined,
2349
2560
  ): Promise<Map<string, PreparedToolCall>> {
2350
- const { resolveFallbackTool, intentTracing, beforeToolCall } = config;
2561
+ const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
2351
2562
  const prepared = new Map<string, PreparedToolCall>();
2352
2563
  for (const toolCall of assistantMessage.content) {
2353
2564
  if (toolCall.type !== "toolCall") continue;
@@ -2374,7 +2585,9 @@ async function prepareToolCallDispatch(
2374
2585
  }
2375
2586
  const validate = (args: Record<string, unknown>): Record<string, unknown> | undefined => {
2376
2587
  try {
2377
- if (!tool) throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools));
2588
+ if (!tool) {
2589
+ throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools, suggestFallbackToolNames?.()));
2590
+ }
2378
2591
  return validateToolArguments(tool, { ...toolCall, arguments: args });
2379
2592
  } catch (validationError) {
2380
2593
  if (tool?.lenientArgValidation) {
@@ -2423,6 +2636,76 @@ async function prepareToolCallDispatch(
2423
2636
  }
2424
2637
  return prepared;
2425
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
+
2426
2709
  /**
2427
2710
  * Execute tool calls from an assistant message.
2428
2711
  */
@@ -2441,8 +2724,10 @@ async function executeToolCalls(
2441
2724
  hasIrcInterrupts,
2442
2725
  interruptMode = "immediate",
2443
2726
  getToolContext,
2727
+
2444
2728
  transformToolCallArguments,
2445
2729
  resolveFallbackTool,
2730
+ suggestFallbackToolNames,
2446
2731
  afterToolCall,
2447
2732
  } = config;
2448
2733
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
@@ -2482,6 +2767,7 @@ async function executeToolCalls(
2482
2767
  const preparedDispatch =
2483
2768
  preparedDispatchByMessage.get(assistantMessage) ??
2484
2769
  (await prepareToolCallDispatch(assistantMessage, currentContext, config, signal));
2770
+ const speculationCoordinator = SpeculativeOperationCoordinator.take(assistantMessage);
2485
2771
 
2486
2772
  const records = toolCalls.map(toolCall => {
2487
2773
  const prepared = preparedDispatch.get(toolCall.id) ?? {
@@ -2519,6 +2805,8 @@ async function executeToolCalls(
2519
2805
  blocked: prepared.blocked === true,
2520
2806
  blockReason: prepared.blockReason,
2521
2807
  prepareError: prepared.prepareError,
2808
+ executionArgs: prepared.executionArgs,
2809
+ transformError: prepared.transformError,
2522
2810
  };
2523
2811
  });
2524
2812
 
@@ -2699,7 +2987,9 @@ async function executeToolCalls(
2699
2987
 
2700
2988
  await runInActiveSpan(toolSpan, async () => {
2701
2989
  try {
2702
- if (!tool) throw new Error(formatToolNotFoundMessage(toolCall.name, tools));
2990
+ if (!tool) {
2991
+ throw new Error(formatToolNotFoundMessage(toolCall.name, tools, suggestFallbackToolNames?.()));
2992
+ }
2703
2993
  if (record.signal.aborted) {
2704
2994
  result = createToolSignalAbortedResult(record.signal);
2705
2995
  isError = true;
@@ -2710,45 +3000,71 @@ async function executeToolCalls(
2710
3000
  if (record.blocked) {
2711
3001
  throw new ToolCallBlockedError(record.blockReason);
2712
3002
  }
2713
- const executionArgs = transformToolCallArguments
2714
- ? transformToolCallArguments(effectiveArgs, toolCall.name)
2715
- : effectiveArgs;
3003
+ if (record.transformError !== undefined) throw record.transformError;
3004
+ const executionArgs =
3005
+ record.executionArgs ??
3006
+ (transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs);
2716
3007
  record.args = executionArgs;
2717
3008
 
2718
- // The cooperative steering signal rides the loop-owned
2719
- // ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
2720
- // AgentToolContext itself is app-built via declaration merging, so
2721
- // the loop cannot construct or extend one structurally.
2722
- const toolContext = getToolContext
2723
- ? getToolContext({
2724
- batchId,
2725
- index,
2726
- total: toolCalls.length,
2727
- toolCalls: toolCallInfos,
2728
- steeringSignal: steeringSoftController.signal,
2729
- providerMetadata: toolCall.providerMetadata,
2730
- })
3009
+ const speculativeOutcome = speculationCoordinator
3010
+ ? await speculationCoordinator.claim(tool, toolCall, executionArgs)
2731
3011
  : undefined;
2732
- executionStarted = true;
2733
- const rawResult = await tool.execute(
2734
- toolCall.id,
2735
- executionArgs,
2736
- record.signal,
2737
- partialResult => {
2738
- stream.push({
2739
- type: "tool_execution_update",
2740
- toolCallId: toolCall.id,
2741
- toolName: toolCall.name,
2742
- args: executionArgs,
2743
- partialResult: coerceToolResult(partialResult).result,
2744
- });
2745
- },
2746
- toolContext,
2747
- );
2748
- completedToolExecution = true;
2749
- const coerced = coerceToolResult(rawResult);
2750
- result = coerced.result;
2751
- if (coerced.malformed || result.isError) isError = true;
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
+ }
2752
3068
  } catch (e) {
2753
3069
  caughtError = e;
2754
3070
  result = {
@@ -2949,6 +3265,7 @@ async function executeToolCalls(
2949
3265
  emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
2950
3266
  }
2951
3267
  }
3268
+ await speculationCoordinator?.discardAll("candidate was not dispatched");
2952
3269
 
2953
3270
  return { toolResults: emittedToolResults };
2954
3271
  }