@oh-my-pi/pi-agent-core 18.2.11 → 18.3.1

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
@@ -5,6 +5,7 @@
5
5
  import {
6
6
  type AssistantMessage,
7
7
  type AssistantMessageEvent,
8
+ type ApiKeyResolution,
8
9
  type ComputerAction,
9
10
  type ComputerSafetyCheck,
10
11
  type Context,
@@ -21,6 +22,7 @@ import {
21
22
  type ToolResultProviderMetadata,
22
23
  type TSchema,
23
24
  toolWireSchema,
25
+ type UserMessage,
24
26
  validateToolArguments,
25
27
  } from "@oh-my-pi/pi-ai";
26
28
  import {
@@ -50,6 +52,7 @@ import {
50
52
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
51
53
  import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
52
54
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
55
+ import { LiveSteeringChannel } from "./live-steering";
53
56
  import { agentPauseGate } from "./pause";
54
57
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
55
58
  import { SpeculativeOperationCoordinator } from "./speculative-execution";
@@ -69,6 +72,7 @@ import {
69
72
  startExecuteToolSpan,
70
73
  startInvokeAgentSpan,
71
74
  } from "./telemetry";
75
+ import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
72
76
  import type {
73
77
  AgentContext,
74
78
  AgentEvent,
@@ -164,6 +168,13 @@ export function createToolScopedAbortReason(
164
168
  */
165
169
  export const TERMINAL_TOOL_RESULT_ABORT_REASON = Symbol.for("pi-agent-core.terminal-tool-result");
166
170
 
171
+ /**
172
+ * Abort reason carried by an interruptible tool's signal when queued steering,
173
+ * a peer IRC, or a background completion cut it short. Lets a wait tell the
174
+ * designed wake path apart from an external/user abort of the run.
175
+ */
176
+ export const TOOL_INTERRUPT_ABORT_REASON = Symbol.for("pi-agent-core.tool-interrupt");
177
+
167
178
  const STEERING_INTERRUPT_POLL_MS = 250;
168
179
 
169
180
  class HarmonyLeakInterruption extends Error {
@@ -740,6 +751,7 @@ async function emitTurnEnd(
740
751
  await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
741
752
  message,
742
753
  toolResults,
754
+ additionalMessages: [],
743
755
  willContinue: false,
744
756
  ...context,
745
757
  });
@@ -1088,6 +1100,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
1088
1100
  }
1089
1101
  }
1090
1102
 
1103
+ /**
1104
+ * Append passive tool-call context after its results as a developer message.
1105
+ * Returns the injected message for turn-end bookkeeping, or undefined when
1106
+ * there is nothing to inject. Shared by the normal tool-call path and the
1107
+ * resume-tail replay so replayed calls deliver context identically.
1108
+ */
1109
+ function injectExecutionAdditionalContext(
1110
+ currentContext: AgentContext,
1111
+ newMessages: AgentMessage[],
1112
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1113
+ additionalContext: string | undefined,
1114
+ ): AgentMessage | undefined {
1115
+ if (additionalContext === undefined) return undefined;
1116
+ const contextMessage = createAdditionalContextMessage(additionalContext);
1117
+ currentContext.messages.push(contextMessage);
1118
+ newMessages.push(contextMessage);
1119
+ emitInputMessages(stream, [contextMessage]);
1120
+ return contextMessage;
1121
+ }
1122
+
1091
1123
  /**
1092
1124
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
1093
1125
  * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -1151,6 +1183,10 @@ async function runLoopBody(
1151
1183
  let preserveSoftRequirementState = false;
1152
1184
 
1153
1185
  let pendingMessages: AgentMessage[] = [];
1186
+ // Steering the provider took from the queue during the last response:
1187
+ // `liveAccepted` reached the model inside it, `liveDeferred` did not.
1188
+ let liveAccepted: AgentMessage[] = [];
1189
+ let liveDeferred: AgentMessage[] = [];
1154
1190
  try {
1155
1191
  let messagesToEmit = [...initialMessages];
1156
1192
  if (isDeadlineExceeded(config.deadline)) {
@@ -1208,8 +1244,15 @@ async function runLoopBody(
1208
1244
  currentContext.messages.push(result);
1209
1245
  newMessages.push(result);
1210
1246
  }
1247
+ const resumeContextMessage = injectExecutionAdditionalContext(
1248
+ currentContext,
1249
+ newMessages,
1250
+ stream,
1251
+ executionResult.additionalContext,
1252
+ );
1211
1253
  await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
1212
1254
  willContinue: !isDeadlineExceeded(config.deadline),
1255
+ ...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
1213
1256
  });
1214
1257
  turnOpen = false;
1215
1258
  // A tool hook may mark its completed result as terminal (e.g. subagent
@@ -1288,6 +1331,7 @@ async function runLoopBody(
1288
1331
  }
1289
1332
 
1290
1333
  preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1334
+ preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
1291
1335
  gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1292
1336
  } catch (error) {
1293
1337
  if (!turnOpen) {
@@ -1414,6 +1458,12 @@ async function runLoopBody(
1414
1458
  harmonyRetryAttempt++;
1415
1459
  continue;
1416
1460
  }
1461
+ } finally {
1462
+ const channel = preparedProviderCall.liveSteering;
1463
+ if (channel) {
1464
+ liveAccepted.push(...channel.accepted);
1465
+ liveDeferred.push(...channel.deferred);
1466
+ }
1417
1467
  }
1418
1468
  if (recovered) {
1419
1469
  message = snapshotAssistantMessage(message);
@@ -1519,6 +1569,7 @@ async function runLoopBody(
1519
1569
  const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
1520
1570
 
1521
1571
  const toolResults: ToolResultMessage[] = [];
1572
+ const additionalMessages: AgentMessage[] = [];
1522
1573
  if (softNonCompliant && softRequiredTool !== undefined) {
1523
1574
  SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
1524
1575
  if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
@@ -1561,13 +1612,19 @@ async function runLoopBody(
1561
1612
  telemetry,
1562
1613
  invokeAgentSpan,
1563
1614
  );
1564
-
1565
1615
  toolResults.push(...executionResult.toolResults);
1566
1616
 
1567
1617
  for (const result of toolResults) {
1568
1618
  currentContext.messages.push(result);
1569
1619
  newMessages.push(result);
1570
1620
  }
1621
+ const injectedContext = injectExecutionAdditionalContext(
1622
+ currentContext,
1623
+ newMessages,
1624
+ stream,
1625
+ executionResult.additionalContext,
1626
+ );
1627
+ if (injectedContext) additionalMessages.push(injectedContext);
1571
1628
  } else if (toolCalls.length > 0) {
1572
1629
  SpeculativeOperationCoordinator.discardForMessage(
1573
1630
  message,
@@ -1618,6 +1675,7 @@ async function runLoopBody(
1618
1675
  }
1619
1676
 
1620
1677
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1678
+ additionalMessages,
1621
1679
  willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1622
1680
  });
1623
1681
  turnOpen = false;
@@ -1632,16 +1690,36 @@ async function runLoopBody(
1632
1690
  // instantly aborts — message lands in history, agent never responds. The
1633
1691
  // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
1634
1692
  // still owns every message until this dequeue.
1635
- const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1636
- if (hasMoreToolCalls) {
1637
- // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1638
- const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1639
- pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1693
+ // Aborted: live-taken steering stays unrecorded, so the agent returns
1694
+ // it to the queue for the continuation run.
1695
+ const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
1696
+ const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
1697
+ if (liveReachedModel) {
1698
+ for (const message of liveAccepted) {
1699
+ if (message.role === "user") message.liveSteered = true;
1700
+ }
1701
+ }
1702
+ liveAccepted = [];
1703
+ liveDeferred = [];
1704
+ if (liveReachedModel) {
1705
+ // The server continues from exactly this steering; anything else
1706
+ // queued now would not line up with its continuation, so it waits
1707
+ // for the next boundary (or is steered into that response).
1708
+ pendingMessages = live;
1640
1709
  } else {
1641
- // Stop boundary: only steering (live user input) forces another turn here. Leave
1642
- // asides for the outer drain below so a passive aside can't trigger an extra model
1643
- // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1644
- pendingMessages = steering;
1710
+ const steering = signal?.aborted
1711
+ ? []
1712
+ : [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
1713
+ if (hasMoreToolCalls) {
1714
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1715
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1716
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1717
+ } else {
1718
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
1719
+ // asides for the outer drain below so a passive aside can't trigger an extra model
1720
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1721
+ pendingMessages = steering;
1722
+ }
1645
1723
  }
1646
1724
  }
1647
1725
 
@@ -1710,6 +1788,45 @@ interface PreparedProviderCall {
1710
1788
  context: Context;
1711
1789
  promptToolWireTools: Context["tools"];
1712
1790
  ownedDialect: Dialect | undefined;
1791
+ /** Steering source offered to the provider for this call. */
1792
+ liveSteering?: LiveSteeringChannel;
1793
+ }
1794
+
1795
+ /**
1796
+ * Offer queued steering to a provider that can deliver it into the response it
1797
+ * is streaming. Latency decides whether steering lands before the model commits
1798
+ * to its next output, so claims convert only the steering batch — message-level
1799
+ * transforms (steering envelope, redaction) — never the whole transcript.
1800
+ * Provider-context transforms rewrite images, so image-bearing steering waits
1801
+ * for the boundary rather than risk bytes the next request would not replay.
1802
+ */
1803
+ function openLiveSteering(
1804
+ config: AgentLoopConfig,
1805
+ loopSignal: AbortSignal | undefined,
1806
+ prepared: PreparedProviderCall,
1807
+ ): LiveSteeringChannel | undefined {
1808
+ const { getSteeringMessages, waitForSteeringMessages } = config;
1809
+ if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
1810
+ const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
1811
+ return new LiveSteeringChannel({
1812
+ wait: signal => waitForSteeringMessages(bound(signal)),
1813
+ take: signal => getSteeringMessages(bound(signal)),
1814
+ toProvider: async (messages, signal) => {
1815
+ const transformed = config.transformContext
1816
+ ? await config.transformContext(messages, bound(signal))
1817
+ : messages;
1818
+ const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
1819
+ const userMessages: UserMessage[] = [];
1820
+ for (const message of converted) {
1821
+ if (message.role !== "user") return undefined;
1822
+ if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
1823
+ return undefined;
1824
+ }
1825
+ userMessages.push(message);
1826
+ }
1827
+ return userMessages.length > 0 ? userMessages : undefined;
1828
+ },
1829
+ });
1713
1830
  }
1714
1831
 
1715
1832
  async function prepareProviderCall(
@@ -1725,7 +1842,8 @@ async function prepareProviderCall(
1725
1842
 
1726
1843
  const llmMessages = await config.convertToLlm(messages);
1727
1844
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1728
- const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1845
+ const ownedDialect: Dialect | undefined =
1846
+ (config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1729
1847
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1730
1848
  let llmContext: Context;
1731
1849
  if (config.appendOnlyContext) {
@@ -1758,6 +1876,12 @@ async function prepareProviderCall(
1758
1876
  tools: undefined,
1759
1877
  };
1760
1878
  }
1879
+ // After `transformProviderContext`, so the recorded definitions are exactly what the provider receives.
1880
+ if (config.sentToolDefinitions && llmContext.tools) {
1881
+ config.sentToolDefinitions.record(llmContext.tools);
1882
+ const inactiveTools = config.sentToolDefinitions.inactiveFor(llmContext.messages, llmContext.tools);
1883
+ if (inactiveTools) llmContext = { ...llmContext, inactiveTools };
1884
+ }
1761
1885
  return { model, context: llmContext, promptToolWireTools, ownedDialect };
1762
1886
  }
1763
1887
 
@@ -1814,8 +1938,13 @@ async function streamAssistantResponse(
1814
1938
  ? providerAbortSignals[0]!
1815
1939
  : AbortSignal.any(providerAbortSignals);
1816
1940
  const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
1817
- const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1818
- const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1941
+ let resolvedCredential: ApiKeyResolution;
1942
+ const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal, resolved => {
1943
+ resolvedCredential = resolved;
1944
+ });
1945
+ const apiKey = isApiKeyResolver(requestApiKey)
1946
+ ? seedApiKeyResolver(resolvedCredential ?? resolvedApiKey, requestApiKey)
1947
+ : requestApiKey;
1819
1948
 
1820
1949
  // Re-resolve metadata after credential selection so the per-request value
1821
1950
  // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
@@ -1883,6 +2012,7 @@ async function streamAssistantResponse(
1883
2012
  cwd: effectiveCwd,
1884
2013
  signal: finalRequestSignal,
1885
2014
  onResponse: captureOnResponse,
2015
+ liveSteering: providerCall.liveSteering,
1886
2016
  });
1887
2017
  if (promptToolWireTools && ownedDialect) {
1888
2018
  // Re-materialize in-band tool-call text as native toolCall content blocks
@@ -2553,6 +2683,12 @@ interface PreparedToolCall {
2553
2683
  tool: AgentTool<any> | undefined;
2554
2684
  /** Validated (possibly hook-revised) execution args; raw args when validation failed. */
2555
2685
  args: Record<string, unknown>;
2686
+ /**
2687
+ * Passive context returned by `beforeToolCall`. Committed after the batch
2688
+ * settles only when the call's final result is not an error, so a call the
2689
+ * tool's own approval gate denies (or that otherwise fails) injects nothing.
2690
+ */
2691
+ additionalContext?: string;
2556
2692
  /** Transformed args shared by final reconciliation and eventual dispatch. */
2557
2693
  executionArgs?: Record<string, unknown>;
2558
2694
  /** Transform failure retained for execution's scheduled error result. */
@@ -2747,6 +2883,9 @@ async function prepareToolCallDispatch(
2747
2883
  entry.blockReason = beforeResult.reason;
2748
2884
  continue;
2749
2885
  }
2886
+ if (isNonBlankContext(beforeResult?.additionalContext)) {
2887
+ entry.additionalContext = beforeResult.additionalContext;
2888
+ }
2750
2889
  if (beforeResult?.args !== undefined) {
2751
2890
  // Revalidate: a hook revision is untrusted input to the tool schema.
2752
2891
  const revised = validate(beforeResult.args);
@@ -2831,7 +2970,8 @@ async function speculativeFinalCalls(
2831
2970
  }
2832
2971
 
2833
2972
  /**
2834
- * Execute tool calls from an assistant message.
2973
+ * Execute tool calls from an assistant message. Returns model-visible context
2974
+ * only after every result has settled, preserving assistant call order.
2835
2975
  */
2836
2976
  async function executeToolCalls(
2837
2977
  currentContext: AgentContext,
@@ -2841,7 +2981,7 @@ async function executeToolCalls(
2841
2981
  config: AgentLoopConfig,
2842
2982
  telemetry: AgentTelemetry | undefined,
2843
2983
  invokeAgentSpan: Span | undefined,
2844
- ): Promise<{ toolResults: ToolResultMessage[] }> {
2984
+ ): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
2845
2985
  const tools = currentContext.tools;
2846
2986
  const {
2847
2987
  hasSteeringMessages,
@@ -2866,7 +3006,10 @@ async function executeToolCalls(
2866
3006
  const emittedToolResults: ToolResultMessage[] = [];
2867
3007
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
2868
3008
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
2869
- const shouldInterruptImmediately = interruptMode !== "wait";
3009
+ // `interruptMode: "wait"` only spares side-effecting work: interruptible
3010
+ // waits are always cut short, since a pure wait has nothing to finish and
3011
+ // would otherwise sit out its full window with a message already queued.
3012
+ const softInterrupts = interruptMode !== "wait";
2870
3013
  const steeringAbortController = new AbortController();
2871
3014
  const ircAbortController = new AbortController();
2872
3015
  // Cooperative channel: aborted when queued steering (or an interrupting
@@ -2875,7 +3018,7 @@ async function executeToolCalls(
2875
3018
  // backgrounds itself so the message injects promptly — but it never kills
2876
3019
  // anything; ignoring it is always safe.
2877
3020
  const steeringSoftController = new AbortController();
2878
- // Interruptible tools (pure waits: hub wait, vibe) observe steering +
3021
+ // Interruptible tools (pure waits: wait, vibe) observe steering +
2879
3022
  // external + IRC aborts. Every other tool sees ONLY the external signal:
2880
3023
  // neither queued steering nor a peer IRC ever hard-kills a partially
2881
3024
  // side-effecting foreground tool (e.g. `bash`) — those get the cooperative
@@ -2930,6 +3073,8 @@ async function executeToolCalls(
2930
3073
  blocked: prepared.blocked === true,
2931
3074
  blockReason: prepared.blockReason,
2932
3075
  prepareError: prepared.prepareError,
3076
+ preparedContext: prepared.additionalContext,
3077
+ reportedContext: [] as string[],
2933
3078
  executionArgs: prepared.executionArgs,
2934
3079
  transformError: prepared.transformError,
2935
3080
  };
@@ -2939,9 +3084,10 @@ async function executeToolCalls(
2939
3084
  // Asides only fire once: an interrupt already recorded on interruptState
2940
3085
  // must not re-abort, and (unlike steering) never re-consumes a queue.
2941
3086
  // A completion-triggered record is the exception — it leaves the
2942
- // cooperative signal down, so keep polling until a peer IRC escalates.
2943
- if (!shouldInterruptImmediately || signal?.aborted) return;
2944
- if (interruptState.triggered && steeringSoftController.signal.aborted) return;
3087
+ // cooperative signal down, so keep polling until a peer IRC escalates
3088
+ // (only when soft interrupts are enabled; otherwise nothing is left).
3089
+ if (signal?.aborted) return;
3090
+ if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
2945
3091
  // Peer IRC and background completions (finished jobs, exited supervised
2946
3092
  // processes) hard-abort interruptible waits only; foreground tools keep
2947
3093
  // running (no partial side effects).
@@ -2953,7 +3099,7 @@ async function executeToolCalls(
2953
3099
  if (!interruptState.triggered) {
2954
3100
  interruptState.triggered = true;
2955
3101
  interruptState.source = source;
2956
- ircAbortController.abort();
3102
+ ircAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
2957
3103
  }
2958
3104
  // Only an urgent aside raises the cooperative signal that makes
2959
3105
  // backgroundable foreground work (auto-background bash/eval) detach
@@ -2963,16 +3109,14 @@ async function executeToolCalls(
2963
3109
  // also cascades: the freshly detached job's own completion re-triggers
2964
3110
  // this check for the next command, so millisecond-long commands chain
2965
3111
  // into separate background deliveries (#12869).
2966
- if (source !== "background") steeringSoftController.abort();
3112
+ if (source !== "background" && softInterrupts) steeringSoftController.abort();
2967
3113
  };
2968
3114
 
2969
3115
  const checkSteering = async (): Promise<void> => {
2970
3116
  // `signal` (external/user abort) is checked separately from the internal
2971
3117
  // abort controllers: once the run is externally aborted it is unwinding
2972
3118
  // and the interrupt would be redundant.
2973
- if (!shouldInterruptImmediately || signal?.aborted) {
2974
- return;
2975
- }
3119
+ if (signal?.aborted) return;
2976
3120
  // Mid-batch steering detection must be non-consuming. If a direct
2977
3121
  // integration only provides getSteeringMessages(), the queue drains at the
2978
3122
  // injection boundary below; polling it here would strand or drop messages.
@@ -2990,8 +3134,9 @@ async function executeToolCalls(
2990
3134
  }
2991
3135
  }
2992
3136
  if (steeringQueued) {
2993
- // Queued steering hard-aborts only interruptible waits and raises the
2994
- // cooperative soft signal for everything else: the boundary dequeue
3137
+ // Queued steering hard-aborts only interruptible waits and (unless
3138
+ // interruptMode is "wait") raises the cooperative soft signal for
3139
+ // everything else: the boundary dequeue
2995
3140
  // below injects the message as soon as running tools finish (or
2996
3141
  // background themselves), and not-yet-started interruptible waits
2997
3142
  // are skipped. Idempotent — a second steer poll after the abort is
@@ -2999,8 +3144,8 @@ async function executeToolCalls(
2999
3144
  if (!steeringAbortController.signal.aborted) {
3000
3145
  interruptState.triggered = true;
3001
3146
  interruptState.source = steeringSource ?? "unknown";
3002
- steeringAbortController.abort();
3003
- steeringSoftController.abort();
3147
+ steeringAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
3148
+ if (softInterrupts) steeringSoftController.abort();
3004
3149
  }
3005
3150
  return;
3006
3151
  }
@@ -3050,7 +3195,7 @@ async function executeToolCalls(
3050
3195
 
3051
3196
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
3052
3197
  // A pending interrupt preempts not-yet-started *interruptible* waits so
3053
- // the message injects promptly instead of sitting out a `hub wait`.
3198
+ // the message injects promptly instead of sitting out a `wait`.
3054
3199
  // Non-interruptible work is never skipped, whatever the source: the
3055
3200
  // expensive part — generating the call — is already paid, the tool
3056
3201
  // itself is cheap, and a skip only makes the model re-emit the same
@@ -3162,11 +3307,13 @@ async function executeToolCalls(
3162
3307
  }
3163
3308
 
3164
3309
  if (!completedToolExecution) {
3165
- // The cooperative steering signal rides the loop-owned
3166
- // ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
3167
- // AgentToolContext itself is app-built via declaration merging, so
3168
- // the loop cannot construct or extend one structurally.
3169
- const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3310
+ // The cooperative steering signal and the passive-context sink
3311
+ // ride the loop-owned ToolCallContext (surfacing as
3312
+ // `ctx.toolCall.*`); the host surfaces the sink on the context it
3313
+ // builds, and the loop hands that object to the tool untouched.
3314
+ // Wrapper-dispatched nested calls (for example `write xd://…`)
3315
+ // inherit the context, so their passive hook context joins this
3316
+ // root call at the batch boundary.
3170
3317
  const toolContext = getToolContext?.({
3171
3318
  batchId,
3172
3319
  index,
@@ -3174,7 +3321,11 @@ async function executeToolCalls(
3174
3321
  toolCalls: toolCallInfos,
3175
3322
  steeringSignal: steeringSoftController.signal,
3176
3323
  providerMetadata: toolCall.providerMetadata,
3324
+ addAdditionalContext: context => {
3325
+ if (isNonBlankContext(context)) record.reportedContext.push(context);
3326
+ },
3177
3327
  });
3328
+ const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3178
3329
  if (streamSession && toolContext) {
3179
3330
  toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
3180
3331
  } else if (streamSession && !streamSession.contextIndependent) {
@@ -3312,10 +3463,12 @@ async function executeToolCalls(
3312
3463
  // detection hard-aborts interruptible waits (running or not yet started),
3313
3464
  // and steering/IRC additionally soft-signal cooperative tools
3314
3465
  // (auto-background bash), so the boundary dequeue below injects the message
3315
- // promptly. Gated on immediate-interrupt mode; checkSteering is idempotent
3316
- // (no-op once triggered).
3466
+ // promptly. In "wait" mode only a batch holding an interruptible wait needs
3467
+ // the watch; checkSteering is idempotent (no-op once triggered).
3317
3468
  const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
3318
- const watchSteeringWhileRunning = shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasAsidePeek);
3469
+ const watchSteeringWhileRunning =
3470
+ (softInterrupts || records.some(record => record.interruptible)) &&
3471
+ (hasSteeringMessages !== undefined || hasAsidePeek);
3319
3472
  const eventDrivenSteeringWatch =
3320
3473
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
3321
3474
  const steeringWatchAbortController = new AbortController();
@@ -3349,11 +3502,17 @@ async function executeToolCalls(
3349
3502
  () => false,
3350
3503
  );
3351
3504
  if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
3352
- // Stop once the cooperative signal is up — steering and IRC
3353
- // have nothing left to escalate. A completion-only trigger
3354
- // leaves it down, so keep watching: a genuine steer arriving
3355
- // afterwards must still reach foreground tools.
3356
- if (steeringWatchSignal.aborted || steeringSoftController.signal.aborted) return;
3505
+ // Stop once nothing is left to escalate: the cooperative signal
3506
+ // is up, or (without soft interrupts) the waits are cut. A
3507
+ // completion-only trigger leaves the soft signal down, so keep
3508
+ // watching: a genuine steer arriving afterwards must still
3509
+ // reach foreground tools.
3510
+ if (
3511
+ steeringWatchSignal.aborted ||
3512
+ steeringSoftController.signal.aborted ||
3513
+ (!softInterrupts && interruptState.triggered)
3514
+ )
3515
+ return;
3357
3516
  if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
3358
3517
  }
3359
3518
  })()
@@ -3419,7 +3578,23 @@ async function executeToolCalls(
3419
3578
  }
3420
3579
  await speculationCoordinator?.discardAll("candidate was not dispatched");
3421
3580
 
3422
- return { toolResults: emittedToolResults };
3581
+ // Skipped calls never ran. Hook-prepared context also requires a non-error
3582
+ // final result; context the tool itself reported during execution stands.
3583
+ // Within a call, tool-reported context (including nested `xd://` dispatch)
3584
+ // precedes the hook's: wrappers release hook context only after the call
3585
+ // succeeds, so this is the one order every dispatch path can produce.
3586
+ const additionalContext = joinAdditionalContext(
3587
+ records
3588
+ .filter(record => !record.skipped)
3589
+ .flatMap(record => [
3590
+ ...record.reportedContext,
3591
+ record.toolResultMessage?.isError ? undefined : record.preparedContext,
3592
+ ]),
3593
+ );
3594
+ return {
3595
+ toolResults: emittedToolResults,
3596
+ ...(additionalContext !== undefined ? { additionalContext } : {}),
3597
+ };
3423
3598
  }
3424
3599
 
3425
3600
  /**