@linxiraos/pi-agent-core 1.1.20 → 1.1.23

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +243 -2
  2. package/README.md +4 -3
  3. package/THIRD-PARTY-NOTICES.txt +3 -40
  4. package/dist/types/agent-loop.d.ts +14 -1
  5. package/dist/types/agent.d.ts +36 -0
  6. package/dist/types/compaction/anthropic.d.ts +22 -52
  7. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  8. package/dist/types/compaction/compaction.d.ts +4 -2
  9. package/dist/types/compaction/messages.d.ts +9 -0
  10. package/dist/types/compaction/openai.d.ts +11 -0
  11. package/dist/types/compaction/pruning.d.ts +9 -0
  12. package/dist/types/compaction/transcript-tokens.d.ts +25 -2
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/live-steering.d.ts +34 -0
  15. package/dist/types/output-budget.d.ts +54 -0
  16. package/dist/types/sent-tool-definitions.d.ts +17 -0
  17. package/dist/types/speculative-execution.d.ts +2 -1
  18. package/dist/types/telemetry.d.ts +103 -60
  19. package/dist/types/tool-context.d.ts +32 -0
  20. package/dist/types/types.d.ts +105 -7
  21. package/package.json +10 -10
  22. package/src/agent-loop.ts +386 -90
  23. package/src/agent.ts +175 -26
  24. package/src/compaction/anthropic.ts +62 -105
  25. package/src/compaction/branch-summarization.ts +1 -1
  26. package/src/compaction/compaction-v2-streaming.ts +13 -4
  27. package/src/compaction/compaction.ts +63 -51
  28. package/src/compaction/messages.ts +12 -2
  29. package/src/compaction/openai.ts +37 -9
  30. package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
  31. package/src/compaction/pruning.ts +1 -1
  32. package/src/compaction/transcript-tokens.ts +64 -3
  33. package/src/index.ts +6 -0
  34. package/src/live-steering.ts +89 -0
  35. package/src/output-budget.ts +142 -0
  36. package/src/sent-tool-definitions.ts +40 -0
  37. package/src/speculative-execution.ts +12 -0
  38. package/src/telemetry.ts +240 -117
  39. package/src/tokenizer.ts +45 -6
  40. package/src/tool-context.ts +55 -0
  41. package/src/types.ts +109 -7
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 "@linxiraos/pi-ai";
26
28
  import {
@@ -37,6 +39,7 @@ import {
37
39
  getStreamingPartialJson,
38
40
  kCursorExecResolved,
39
41
  } from "@linxiraos/pi-ai/utils/block-symbols";
42
+ import { schemaDefinesProperty } from "@linxiraos/pi-ai/utils/schema/json-schema-validator";
40
43
  import { stamp } from "@linxiraos/pi-ai/utils/schema/stamps";
41
44
  import {
42
45
  createHarmonyAuditEvent,
@@ -48,8 +51,9 @@ import {
48
51
  recoverHarmonyToolCall,
49
52
  signalListLabel,
50
53
  } from "@linxiraos/pi-ai/utils/harmony-leak";
51
- import { logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
54
+ import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
52
55
  import { INTENT_FIELD } from "@linxiraos/pi-wire";
56
+ import { LiveSteeringChannel } from "./live-steering";
53
57
  import { agentPauseGate } from "./pause";
54
58
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
55
59
  import { SpeculativeOperationCoordinator } from "./speculative-execution";
@@ -60,7 +64,7 @@ import {
60
64
  finishExecuteToolSpan,
61
65
  finishInvokeAgentSpan,
62
66
  fireOnRunEnd,
63
- PiGenAIAttr,
67
+ OmpGenAIAttr,
64
68
  recordSkippedTool,
65
69
  resolveTelemetry,
66
70
  runInActiveSpan,
@@ -69,6 +73,7 @@ import {
69
73
  startExecuteToolSpan,
70
74
  startInvokeAgentSpan,
71
75
  } from "./telemetry";
76
+ import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
72
77
  import type {
73
78
  AgentContext,
74
79
  AgentEvent,
@@ -164,6 +169,13 @@ export function createToolScopedAbortReason(
164
169
  */
165
170
  export const TERMINAL_TOOL_RESULT_ABORT_REASON = Symbol.for("pi-agent-core.terminal-tool-result");
166
171
 
172
+ /**
173
+ * Abort reason carried by an interruptible tool's signal when queued steering,
174
+ * a peer IRC, or a background completion cut it short. Lets a wait tell the
175
+ * designed wake path apart from an external/user abort of the run.
176
+ */
177
+ export const TOOL_INTERRUPT_ABORT_REASON = Symbol.for("pi-agent-core.tool-interrupt");
178
+
167
179
  const STEERING_INTERRUPT_POLL_MS = 250;
168
180
 
169
181
  class HarmonyLeakInterruption extends Error {
@@ -365,13 +377,17 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
365
377
  case "redactedThinking":
366
378
  return { ...block };
367
379
  case "anthropicServerTool":
368
- return { ...block, block: structuredCloneJSON(block.block) };
380
+ return { ...block, block: cloneJsonTree(block.block) };
369
381
  case "fallback":
370
382
  return { ...block, from: { ...block.from }, to: { ...block.to } };
371
383
  case "toolCall": {
372
384
  const snap = {
373
385
  ...block,
374
- arguments: structuredCloneJSON(block.arguments),
386
+ // Providers mutate streaming arguments in place (owned-stream, GLM)
387
+ // as well as replacing them, so containers are always copied; the
388
+ // strings inside are immutable and shared, keeping the per-delta
389
+ // cost independent of the argument payload size.
390
+ arguments: cloneJsonTree(block.arguments),
375
391
  providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata),
376
392
  };
377
393
  // Object spread copies enumerable symbols in Bun, but the Cursor
@@ -599,18 +615,18 @@ export function agentLoop(
599
615
  const stream = createAgentStream();
600
616
 
601
617
  (async () => {
602
- const newMessages: AgentMessage[] = [...prompts];
603
- const currentContext: AgentContext = {
604
- ...context,
605
- messages: [...context.messages, ...prompts],
606
- };
607
- for (const prompt of prompts) {
608
- (prompt as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
609
- }
618
+ try {
619
+ const newMessages: AgentMessage[] = [...prompts];
620
+ const currentContext: AgentContext = {
621
+ ...context,
622
+ messages: [...context.messages, ...prompts],
623
+ };
624
+ for (const prompt of prompts) {
625
+ (prompt as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
626
+ }
610
627
 
611
- stream.push({ type: "agent_start" });
628
+ stream.push({ type: "agent_start" });
612
629
 
613
- try {
614
630
  await runLoop(currentContext, newMessages, config, signal, stream, streamFn, prompts);
615
631
  } catch (err) {
616
632
  stream.fail(err);
@@ -668,12 +684,12 @@ export function agentLoopContinue(
668
684
  const stream = createAgentStream();
669
685
 
670
686
  (async () => {
671
- const newMessages: AgentMessage[] = [];
672
- const currentContext: AgentContext = { ...context, messages: [...context.messages] };
687
+ try {
688
+ const newMessages: AgentMessage[] = [];
689
+ const currentContext: AgentContext = { ...context, messages: [...context.messages] };
673
690
 
674
- stream.push({ type: "agent_start" });
691
+ stream.push({ type: "agent_start" });
675
692
 
676
- try {
677
693
  await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
678
694
  } catch (err) {
679
695
  stream.fail(err);
@@ -740,6 +756,7 @@ async function emitTurnEnd(
740
756
  await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
741
757
  message,
742
758
  toolResults,
759
+ additionalMessages: [],
743
760
  willContinue: false,
744
761
  ...context,
745
762
  });
@@ -1010,6 +1027,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
1010
1027
  return "require";
1011
1028
  }
1012
1029
 
1030
+ /**
1031
+ * Longest `i` value accepted as an intent. The injected field is described as
1032
+ * a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
1033
+ * payload the model put in the wrong field, not a label.
1034
+ */
1035
+ const MAX_INTENT_LENGTH = 200;
1036
+
1013
1037
  function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
1014
1038
  const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
1015
1039
  if (typeof intent !== "string") {
@@ -1088,6 +1112,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
1088
1112
  }
1089
1113
  }
1090
1114
 
1115
+ /**
1116
+ * Append passive tool-call context after its results as a developer message.
1117
+ * Returns the injected message for turn-end bookkeeping, or undefined when
1118
+ * there is nothing to inject. Shared by the normal tool-call path and the
1119
+ * resume-tail replay so replayed calls deliver context identically.
1120
+ */
1121
+ function injectExecutionAdditionalContext(
1122
+ currentContext: AgentContext,
1123
+ newMessages: AgentMessage[],
1124
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1125
+ additionalContext: string | undefined,
1126
+ ): AgentMessage | undefined {
1127
+ if (additionalContext === undefined) return undefined;
1128
+ const contextMessage = createAdditionalContextMessage(additionalContext);
1129
+ currentContext.messages.push(contextMessage);
1130
+ newMessages.push(contextMessage);
1131
+ emitInputMessages(stream, [contextMessage]);
1132
+ return contextMessage;
1133
+ }
1134
+
1091
1135
  /**
1092
1136
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
1093
1137
  * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -1112,7 +1156,11 @@ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
1112
1156
 
1113
1157
  function discardAsides(messages: readonly AgentMessage[], error: Error): void {
1114
1158
  for (const message of messages) {
1115
- (message as CommittableAsideMessage)[ASIDE_MESSAGE_DISCARD]?.(error);
1159
+ try {
1160
+ (message as CommittableAsideMessage)[ASIDE_MESSAGE_DISCARD]?.(error);
1161
+ } catch (discardError) {
1162
+ logger.error("Aside discard hook threw", { error: discardError });
1163
+ }
1116
1164
  }
1117
1165
  }
1118
1166
 
@@ -1147,6 +1195,10 @@ async function runLoopBody(
1147
1195
  let preserveSoftRequirementState = false;
1148
1196
 
1149
1197
  let pendingMessages: AgentMessage[] = [];
1198
+ // Steering the provider took from the queue during the last response:
1199
+ // `liveAccepted` reached the model inside it, `liveDeferred` did not.
1200
+ let liveAccepted: AgentMessage[] = [];
1201
+ let liveDeferred: AgentMessage[] = [];
1150
1202
  try {
1151
1203
  let messagesToEmit = [...initialMessages];
1152
1204
  if (isDeadlineExceeded(config.deadline)) {
@@ -1199,13 +1251,21 @@ async function runLoopBody(
1199
1251
  config,
1200
1252
  telemetry,
1201
1253
  invokeAgentSpan,
1254
+ [],
1202
1255
  );
1203
1256
  for (const result of executionResult.toolResults) {
1204
1257
  currentContext.messages.push(result);
1205
1258
  newMessages.push(result);
1206
1259
  }
1260
+ const resumeContextMessage = injectExecutionAdditionalContext(
1261
+ currentContext,
1262
+ newMessages,
1263
+ stream,
1264
+ executionResult.additionalContext,
1265
+ );
1207
1266
  await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
1208
1267
  willContinue: !isDeadlineExceeded(config.deadline),
1268
+ ...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
1209
1269
  });
1210
1270
  turnOpen = false;
1211
1271
  // A tool hook may mark its completed result as terminal (e.g. subagent
@@ -1284,6 +1344,7 @@ async function runLoopBody(
1284
1344
  }
1285
1345
 
1286
1346
  preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1347
+ preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
1287
1348
  gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1288
1349
  } catch (error) {
1289
1350
  if (!turnOpen) {
@@ -1410,6 +1471,12 @@ async function runLoopBody(
1410
1471
  harmonyRetryAttempt++;
1411
1472
  continue;
1412
1473
  }
1474
+ } finally {
1475
+ const channel = preparedProviderCall.liveSteering;
1476
+ if (channel) {
1477
+ liveAccepted.push(...channel.accepted);
1478
+ liveDeferred.push(...channel.deferred);
1479
+ }
1413
1480
  }
1414
1481
  if (recovered) {
1415
1482
  message = snapshotAssistantMessage(message);
@@ -1515,6 +1582,7 @@ async function runLoopBody(
1515
1582
  const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
1516
1583
 
1517
1584
  const toolResults: ToolResultMessage[] = [];
1585
+ const additionalMessages: AgentMessage[] = [];
1518
1586
  if (softNonCompliant && softRequiredTool !== undefined) {
1519
1587
  SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
1520
1588
  if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
@@ -1556,14 +1624,21 @@ async function runLoopBody(
1556
1624
  config,
1557
1625
  telemetry,
1558
1626
  invokeAgentSpan,
1627
+ [...liveAccepted, ...liveDeferred],
1559
1628
  );
1560
-
1561
1629
  toolResults.push(...executionResult.toolResults);
1562
1630
 
1563
1631
  for (const result of toolResults) {
1564
1632
  currentContext.messages.push(result);
1565
1633
  newMessages.push(result);
1566
1634
  }
1635
+ const injectedContext = injectExecutionAdditionalContext(
1636
+ currentContext,
1637
+ newMessages,
1638
+ stream,
1639
+ executionResult.additionalContext,
1640
+ );
1641
+ if (injectedContext) additionalMessages.push(injectedContext);
1567
1642
  } else if (toolCalls.length > 0) {
1568
1643
  SpeculativeOperationCoordinator.discardForMessage(
1569
1644
  message,
@@ -1614,6 +1689,7 @@ async function runLoopBody(
1614
1689
  }
1615
1690
 
1616
1691
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1692
+ additionalMessages,
1617
1693
  willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1618
1694
  });
1619
1695
  turnOpen = false;
@@ -1628,16 +1704,36 @@ async function runLoopBody(
1628
1704
  // instantly aborts — message lands in history, agent never responds. The
1629
1705
  // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
1630
1706
  // still owns every message until this dequeue.
1631
- const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1632
- if (hasMoreToolCalls) {
1633
- // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1634
- const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1635
- pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1707
+ // Aborted: live-taken steering stays unrecorded, so the agent returns
1708
+ // it to the queue for the continuation run.
1709
+ const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
1710
+ const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
1711
+ if (liveReachedModel) {
1712
+ for (const message of liveAccepted) {
1713
+ if (message.role === "user") message.liveSteered = true;
1714
+ }
1715
+ }
1716
+ liveAccepted = [];
1717
+ liveDeferred = [];
1718
+ if (liveReachedModel) {
1719
+ // The server continues from exactly this steering; anything else
1720
+ // queued now would not line up with its continuation, so it waits
1721
+ // for the next boundary (or is steered into that response).
1722
+ pendingMessages = live;
1636
1723
  } else {
1637
- // Stop boundary: only steering (live user input) forces another turn here. Leave
1638
- // asides for the outer drain below so a passive aside can't trigger an extra model
1639
- // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1640
- pendingMessages = steering;
1724
+ const steering = signal?.aborted
1725
+ ? []
1726
+ : [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
1727
+ if (hasMoreToolCalls) {
1728
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1729
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1730
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1731
+ } else {
1732
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
1733
+ // asides for the outer drain below so a passive aside can't trigger an extra model
1734
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1735
+ pendingMessages = steering;
1736
+ }
1641
1737
  }
1642
1738
  }
1643
1739
 
@@ -1706,6 +1802,70 @@ interface PreparedProviderCall {
1706
1802
  context: Context;
1707
1803
  promptToolWireTools: Context["tools"];
1708
1804
  ownedDialect: Dialect | undefined;
1805
+ /** Steering source offered to the provider for this call. */
1806
+ liveSteering?: LiveSteeringChannel;
1807
+ }
1808
+
1809
+ /**
1810
+ * Classify the first `count` steering messages for a tool-batch interrupt:
1811
+ * any user-authored message wins, then agent-attributed user messages, else
1812
+ * system steering (advisor cards, hidden directives). Shared by the agent's
1813
+ * queue peek and the loop's live-taken steering.
1814
+ */
1815
+ export function steeringQueueState(messages: readonly AgentMessage[], count = messages.length): SteeringQueueState {
1816
+ if (count === 0) return { queued: false };
1817
+ let hasAgentSteering = false;
1818
+ for (let i = 0; i < count; i++) {
1819
+ const message = messages[i];
1820
+ const role = "role" in message ? message.role : undefined;
1821
+ const attribution = "attribution" in message ? message.attribution : undefined;
1822
+ if (attribution === "user") return { queued: true, source: "user" };
1823
+ if (role !== "user") continue;
1824
+ if (attribution !== "agent") return { queued: true, source: "user" };
1825
+ hasAgentSteering = true;
1826
+ }
1827
+ return { queued: true, source: hasAgentSteering ? "agent" : "system" };
1828
+ }
1829
+
1830
+ /**
1831
+ * Offer queued steering to a provider that can deliver it into the response it
1832
+ * is streaming. Latency decides whether steering lands before the model commits
1833
+ * to its next output, so claims convert only the steering batch — message-level
1834
+ * transforms (steering envelope, redaction) — never the whole transcript.
1835
+ * Provider-context transforms rewrite images, so image-bearing steering waits
1836
+ * for the boundary rather than risk bytes the next request would not replay.
1837
+ */
1838
+ function openLiveSteering(
1839
+ config: AgentLoopConfig,
1840
+ loopSignal: AbortSignal | undefined,
1841
+ prepared: PreparedProviderCall,
1842
+ ): LiveSteeringChannel | undefined {
1843
+ const { getSteeringMessages, waitForSteeringMessages } = config;
1844
+ if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
1845
+ const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
1846
+ return new LiveSteeringChannel({
1847
+ wait: signal => waitForSteeringMessages(bound(signal)),
1848
+ take: async signal => {
1849
+ const messages = await getSteeringMessages(bound(signal));
1850
+ if (messages.length > 0) config.onLiveSteeringTaken?.(messages);
1851
+ return messages;
1852
+ },
1853
+ toProvider: async (messages, signal) => {
1854
+ const transformed = config.transformContext
1855
+ ? await config.transformContext(messages, bound(signal))
1856
+ : messages;
1857
+ const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
1858
+ const userMessages: UserMessage[] = [];
1859
+ for (const message of converted) {
1860
+ if (message.role !== "user") return undefined;
1861
+ if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
1862
+ return undefined;
1863
+ }
1864
+ userMessages.push(message);
1865
+ }
1866
+ return userMessages.length > 0 ? userMessages : undefined;
1867
+ },
1868
+ });
1709
1869
  }
1710
1870
 
1711
1871
  async function prepareProviderCall(
@@ -1721,7 +1881,8 @@ async function prepareProviderCall(
1721
1881
 
1722
1882
  const llmMessages = await config.convertToLlm(messages);
1723
1883
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1724
- const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1884
+ const ownedDialect: Dialect | undefined =
1885
+ (config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1725
1886
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1726
1887
  let llmContext: Context;
1727
1888
  if (config.appendOnlyContext) {
@@ -1754,6 +1915,12 @@ async function prepareProviderCall(
1754
1915
  tools: undefined,
1755
1916
  };
1756
1917
  }
1918
+ // After `transformProviderContext`, so the recorded definitions are exactly what the provider receives.
1919
+ if (config.sentToolDefinitions && llmContext.tools) {
1920
+ config.sentToolDefinitions.record(llmContext.tools);
1921
+ const inactiveTools = config.sentToolDefinitions.inactiveFor(llmContext.messages, llmContext.tools);
1922
+ if (inactiveTools) llmContext = { ...llmContext, inactiveTools };
1923
+ }
1757
1924
  return { model, context: llmContext, promptToolWireTools, ownedDialect };
1758
1925
  }
1759
1926
 
@@ -1810,8 +1977,13 @@ async function streamAssistantResponse(
1810
1977
  ? providerAbortSignals[0]!
1811
1978
  : AbortSignal.any(providerAbortSignals);
1812
1979
  const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
1813
- const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1814
- const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1980
+ let resolvedCredential: ApiKeyResolution;
1981
+ const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal, resolved => {
1982
+ resolvedCredential = resolved;
1983
+ });
1984
+ const apiKey = isApiKeyResolver(requestApiKey)
1985
+ ? seedApiKeyResolver(resolvedCredential ?? resolvedApiKey, requestApiKey)
1986
+ : requestApiKey;
1815
1987
 
1816
1988
  // Re-resolve metadata after credential selection so the per-request value
1817
1989
  // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
@@ -1879,6 +2051,7 @@ async function streamAssistantResponse(
1879
2051
  cwd: effectiveCwd,
1880
2052
  signal: finalRequestSignal,
1881
2053
  onResponse: captureOnResponse,
2054
+ liveSteering: providerCall.liveSteering,
1882
2055
  });
1883
2056
  if (promptToolWireTools && ownedDialect) {
1884
2057
  // Re-materialize in-band tool-call text as native toolCall content blocks
@@ -1925,6 +2098,8 @@ async function streamAssistantResponse(
1925
2098
  signal: requestSignal,
1926
2099
  })
1927
2100
  : undefined;
2101
+ const speculationPlansFromStream =
2102
+ !config.transformAssistantMessage || config.transformAssistantMessagePreservesToolCalls === true;
1928
2103
 
1929
2104
  let providerStreamSettled = false;
1930
2105
  let speculationSettled = false;
@@ -2154,15 +2329,11 @@ async function streamAssistantResponse(
2154
2329
  case "toolcall_delta":
2155
2330
  case "toolcall_end":
2156
2331
  if (partialMessage) {
2157
- if (
2158
- event.type === "toolcall_start" &&
2159
- speculationCoordinator &&
2160
- !config.transformAssistantMessage
2161
- ) {
2332
+ if (event.type === "toolcall_start" && speculationCoordinator && speculationPlansFromStream) {
2162
2333
  // Stream sessions plan from pre-transform arguments, exactly like
2163
2334
  // direct candidates (see admitFinalized below): with a transformer
2164
- // installed the authoritative call may differ, so any speculative
2165
- // work started from the original would be phantom I/O.
2335
+ // that may rewrite calls, the authoritative call may differ, so any
2336
+ // speculative work started from the original would be phantom I/O.
2166
2337
  speculationCoordinator.register(event.contentIndex);
2167
2338
  const toolCall = event.partial.content[event.contentIndex];
2168
2339
  if (toolCall?.type === "toolCall") {
@@ -2259,7 +2430,7 @@ async function streamAssistantResponse(
2259
2430
  event.type === "toolcall_end" &&
2260
2431
  speculationCoordinator &&
2261
2432
  speculationConfig &&
2262
- !config.transformAssistantMessage
2433
+ speculationPlansFromStream
2263
2434
  ) {
2264
2435
  speculationCoordinator.admitFinalized(context, event.toolCall, config, requestSignal);
2265
2436
  }
@@ -2331,8 +2502,11 @@ async function streamAssistantResponse(
2331
2502
  }
2332
2503
  if (addedPartial) {
2333
2504
  context.messages[context.messages.length - 1] = trailing;
2334
- stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
2505
+ } else {
2506
+ context.messages.push(trailing);
2507
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(trailing) });
2335
2508
  }
2509
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
2336
2510
  await finishChat(trailing);
2337
2511
  speculationSettled = true;
2338
2512
  providerStreamSettled = true;
@@ -2546,6 +2720,12 @@ interface PreparedToolCall {
2546
2720
  tool: AgentTool<any> | undefined;
2547
2721
  /** Validated (possibly hook-revised) execution args; raw args when validation failed. */
2548
2722
  args: Record<string, unknown>;
2723
+ /**
2724
+ * Passive context returned by `beforeToolCall`. Committed after the batch
2725
+ * settles only when the call's final result is not an error, so a call the
2726
+ * tool's own approval gate denies (or that otherwise fails) injects nothing.
2727
+ */
2728
+ additionalContext?: string;
2549
2729
  /** Transformed args shared by final reconciliation and eventual dispatch. */
2550
2730
  executionArgs?: Record<string, unknown>;
2551
2731
  /** Transform failure retained for execution's scheduled error result. */
@@ -2681,12 +2861,32 @@ async function prepareToolCallDispatch(
2681
2861
  if (toolCall.type !== "toolCall") continue;
2682
2862
  if ((toolCall as CursorExecResolvedCarrier)[kCursorExecResolved] === true) continue;
2683
2863
  const tool = resolveToolForCall(context.tools, toolCall, resolveFallbackTool);
2864
+ // A host fallback accepts aliases (`xd://recall`, a mis-separated MCP
2865
+ // name) that providers reject when replayed as a function-call name.
2866
+ // Record the call under the resolved tool's canonical name so history,
2867
+ // persistence, and replay agree; custom-wire calls keep their wire name.
2868
+ if (tool && toolCall.name !== tool.name && toolCall.name !== tool.customWireName) {
2869
+ toolCall.name = tool.name;
2870
+ }
2684
2871
  const entry: PreparedToolCall = { tool, args: toolCall.arguments as Record<string, unknown> };
2685
2872
  prepared.set(toolCall.id, entry);
2686
2873
  let argsForExecution = toolCall.arguments as Record<string, unknown>;
2687
2874
  if (intentTracing) {
2688
2875
  const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2689
2876
  argsForExecution = strippedArgs;
2877
+ // A payload in `i` would be stripped and the tool run with the leftover
2878
+ // args. Unknown tools fall through to the not-found error; a tool that
2879
+ // owns `i` as a real parameter has nowhere else to put the value.
2880
+ if (
2881
+ intent !== undefined &&
2882
+ intent.length > MAX_INTENT_LENGTH &&
2883
+ tool &&
2884
+ !schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
2885
+ ) {
2886
+ entry.args = strippedArgs;
2887
+ entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
2888
+ continue;
2889
+ }
2690
2890
  if (intent) {
2691
2891
  toolCall.intent = intent;
2692
2892
  } else if (typeof tool?.intent === "function") {
@@ -2740,6 +2940,9 @@ async function prepareToolCallDispatch(
2740
2940
  entry.blockReason = beforeResult.reason;
2741
2941
  continue;
2742
2942
  }
2943
+ if (isNonBlankContext(beforeResult?.additionalContext)) {
2944
+ entry.additionalContext = beforeResult.additionalContext;
2945
+ }
2743
2946
  if (beforeResult?.args !== undefined) {
2744
2947
  // Revalidate: a hook revision is untrusted input to the tool schema.
2745
2948
  const revised = validate(beforeResult.args);
@@ -2824,7 +3027,13 @@ async function speculativeFinalCalls(
2824
3027
  }
2825
3028
 
2826
3029
  /**
2827
- * Execute tool calls from an assistant message.
3030
+ * Execute tool calls from an assistant message. Returns model-visible context
3031
+ * only after every result has settled, preserving assistant call order.
3032
+ *
3033
+ * `tool_execution_end` fires as each call settles so live UI updates promptly;
3034
+ * result `message_start`/`message_end` events (which append to agent state and
3035
+ * the persisted session) are held until every earlier call has a result, so
3036
+ * history always pairs results in call order regardless of completion order.
2828
3037
  */
2829
3038
  async function executeToolCalls(
2830
3039
  currentContext: AgentContext,
@@ -2834,11 +3043,15 @@ async function executeToolCalls(
2834
3043
  config: AgentLoopConfig,
2835
3044
  telemetry: AgentTelemetry | undefined,
2836
3045
  invokeAgentSpan: Span | undefined,
2837
- ): Promise<{ toolResults: ToolResultMessage[] }> {
3046
+ // Steering the provider took off the queue during the response that emitted
3047
+ // this batch; it injects at this batch's boundary like queued steering.
3048
+ liveSteering: readonly AgentMessage[],
3049
+ ): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
2838
3050
  const tools = currentContext.tools;
2839
3051
  const {
2840
3052
  hasSteeringMessages,
2841
3053
  hasIrcInterrupts,
3054
+ hasBackgroundCompletions,
2842
3055
  interruptMode = "immediate",
2843
3056
  getToolContext,
2844
3057
 
@@ -2858,7 +3071,10 @@ async function executeToolCalls(
2858
3071
  const emittedToolResults: ToolResultMessage[] = [];
2859
3072
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
2860
3073
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
2861
- const shouldInterruptImmediately = interruptMode !== "wait";
3074
+ // `interruptMode: "wait"` only spares side-effecting work: interruptible
3075
+ // waits are always cut short, since a pure wait has nothing to finish and
3076
+ // would otherwise sit out its full window with a message already queued.
3077
+ const softInterrupts = interruptMode !== "wait";
2862
3078
  const steeringAbortController = new AbortController();
2863
3079
  const ircAbortController = new AbortController();
2864
3080
  // Cooperative channel: aborted when queued steering (or an interrupting
@@ -2867,7 +3083,7 @@ async function executeToolCalls(
2867
3083
  // backgrounds itself so the message injects promptly — but it never kills
2868
3084
  // anything; ignoring it is always safe.
2869
3085
  const steeringSoftController = new AbortController();
2870
- // Interruptible tools (pure waits: hub wait, vibe) observe steering +
3086
+ // Interruptible tools (pure waits: wait, vibe) observe steering +
2871
3087
  // external + IRC aborts. Every other tool sees ONLY the external signal:
2872
3088
  // neither queued steering nor a peer IRC ever hard-kills a partially
2873
3089
  // side-effecting foreground tool (e.g. `bash`) — those get the cooperative
@@ -2876,7 +3092,7 @@ async function executeToolCalls(
2876
3092
  const interruptibleSignal: AbortSignal = signal
2877
3093
  ? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
2878
3094
  : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
2879
- const interruptState: { triggered: boolean; source?: SteeringInterruptSource | "irc" } = { triggered: false };
3095
+ const interruptState: { triggered: boolean; source?: AsideInterruptSource } = { triggered: false };
2880
3096
 
2881
3097
  // Streamed messages were prepared (validation + `beforeToolCall`) before
2882
3098
  // `message_end`, so hook revisions are already part of the message; anything
@@ -2922,39 +3138,59 @@ async function executeToolCalls(
2922
3138
  blocked: prepared.blocked === true,
2923
3139
  blockReason: prepared.blockReason,
2924
3140
  prepareError: prepared.prepareError,
3141
+ preparedContext: prepared.additionalContext,
3142
+ reportedContext: [] as string[],
2925
3143
  executionArgs: prepared.executionArgs,
2926
3144
  transformError: prepared.transformError,
2927
3145
  };
2928
3146
  });
2929
3147
 
2930
- const checkIrcInterrupts = async (): Promise<void> => {
2931
- // IRC only fires once: a peer interrupt already recorded on interruptState
3148
+ const checkAsideInterrupts = async (): Promise<void> => {
3149
+ // Asides only fire once: an interrupt already recorded on interruptState
2932
3150
  // must not re-abort, and (unlike steering) never re-consumes a queue.
2933
- if (!shouldInterruptImmediately || signal?.aborted || interruptState.triggered) return;
2934
- if (hasIrcInterrupts && (await hasIrcInterrupts())) {
2935
- // Peer IRC hard-aborts interruptible waits only; foreground tools keep
2936
- // running (no partial side effects) but get the cooperative soft
2937
- // signal so backgroundable work can step aside for the peer message.
3151
+ // A completion-triggered record is the exception — it leaves the
3152
+ // cooperative signal down, so keep polling until a peer IRC escalates
3153
+ // (only when soft interrupts are enabled; otherwise nothing is left).
3154
+ if (signal?.aborted) return;
3155
+ if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
3156
+ // Peer IRC and background completions (finished jobs, exited supervised
3157
+ // processes) hard-abort interruptible waits only; foreground tools keep
3158
+ // running (no partial side effects).
3159
+ let source: AsideInterruptSource | undefined;
3160
+ if (hasIrcInterrupts && (await hasIrcInterrupts())) source = "irc";
3161
+ else if (!interruptState.triggered && hasBackgroundCompletions && (await hasBackgroundCompletions()))
3162
+ source = "background";
3163
+ if (!source) return;
3164
+ if (!interruptState.triggered) {
2938
3165
  interruptState.triggered = true;
2939
- interruptState.source = "irc";
2940
- ircAbortController.abort();
2941
- steeringSoftController.abort();
3166
+ interruptState.source = source;
3167
+ ircAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
2942
3168
  }
3169
+ // Only an urgent aside raises the cooperative signal that makes
3170
+ // backgroundable foreground work (auto-background bash/eval) detach
3171
+ // itself. A peer waiting on an IRC is blocked on this batch; a finished
3172
+ // background job is not — its notice is an aside that injects at the
3173
+ // batch boundary either way. Detaching ordinary foreground work for it
3174
+ // also cascades: the freshly detached job's own completion re-triggers
3175
+ // this check for the next command, so millisecond-long commands chain
3176
+ // into separate background deliveries (#12869).
3177
+ if (source !== "background" && softInterrupts) steeringSoftController.abort();
2943
3178
  };
2944
3179
 
2945
3180
  const checkSteering = async (): Promise<void> => {
2946
3181
  // `signal` (external/user abort) is checked separately from the internal
2947
3182
  // abort controllers: once the run is externally aborted it is unwinding
2948
3183
  // and the interrupt would be redundant.
2949
- if (!shouldInterruptImmediately || signal?.aborted) {
2950
- return;
2951
- }
3184
+ if (signal?.aborted) return;
2952
3185
  // Mid-batch steering detection must be non-consuming. If a direct
2953
3186
  // integration only provides getSteeringMessages(), the queue drains at the
2954
3187
  // injection boundary below; polling it here would strand or drop messages.
2955
3188
  let steeringQueued = false;
2956
3189
  let steeringSource: SteeringInterruptSource | undefined;
2957
- if (hasSteeringMessages) {
3190
+ if (liveSteering.length > 0) {
3191
+ steeringQueued = true;
3192
+ steeringSource = steeringQueueState(liveSteering).source;
3193
+ } else if (hasSteeringMessages) {
2958
3194
  const queuedState = await hasSteeringMessages();
2959
3195
  if (typeof queuedState === "boolean") {
2960
3196
  steeringQueued = queuedState;
@@ -2966,8 +3202,9 @@ async function executeToolCalls(
2966
3202
  }
2967
3203
  }
2968
3204
  if (steeringQueued) {
2969
- // Queued steering hard-aborts only interruptible waits and raises the
2970
- // cooperative soft signal for everything else: the boundary dequeue
3205
+ // Queued steering hard-aborts only interruptible waits and (unless
3206
+ // interruptMode is "wait") raises the cooperative soft signal for
3207
+ // everything else: the boundary dequeue
2971
3208
  // below injects the message as soon as running tools finish (or
2972
3209
  // background themselves), and not-yet-started interruptible waits
2973
3210
  // are skipped. Idempotent — a second steer poll after the abort is
@@ -2975,12 +3212,24 @@ async function executeToolCalls(
2975
3212
  if (!steeringAbortController.signal.aborted) {
2976
3213
  interruptState.triggered = true;
2977
3214
  interruptState.source = steeringSource ?? "unknown";
2978
- steeringAbortController.abort();
2979
- steeringSoftController.abort();
3215
+ steeringAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
3216
+ if (softInterrupts) steeringSoftController.abort();
2980
3217
  }
2981
3218
  return;
2982
3219
  }
2983
- await checkIrcInterrupts();
3220
+ await checkAsideInterrupts();
3221
+ };
3222
+
3223
+ // Index of the first record whose result message has not been emitted yet.
3224
+ let nextResultIndex = 0;
3225
+ const flushResultMessages = (): void => {
3226
+ for (; nextResultIndex < records.length; nextResultIndex++) {
3227
+ const message = records[nextResultIndex].toolResultMessage;
3228
+ if (!message) return;
3229
+ emittedToolResults.push(message);
3230
+ stream.push({ type: "message_start", message });
3231
+ stream.push({ type: "message_end", message });
3232
+ }
2984
3233
  };
2985
3234
 
2986
3235
  const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
@@ -3018,15 +3267,12 @@ async function executeToolCalls(
3018
3267
  record.isError = isError;
3019
3268
  record.toolResultMessage = toolResultMessage;
3020
3269
  record.resultEmitted = true;
3021
- emittedToolResults.push(toolResultMessage);
3022
-
3023
- stream.push({ type: "message_start", message: toolResultMessage });
3024
- stream.push({ type: "message_end", message: toolResultMessage });
3270
+ flushResultMessages();
3025
3271
  };
3026
3272
 
3027
3273
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
3028
3274
  // A pending interrupt preempts not-yet-started *interruptible* waits so
3029
- // the message injects promptly instead of sitting out a `hub wait`.
3275
+ // the message injects promptly instead of sitting out a `wait`.
3030
3276
  // Non-interruptible work is never skipped, whatever the source: the
3031
3277
  // expensive part — generating the call — is already paid, the tool
3032
3278
  // itself is cheap, and a skip only makes the model re-emit the same
@@ -3093,7 +3339,7 @@ async function executeToolCalls(
3093
3339
  parent: invokeAgentSpan,
3094
3340
  });
3095
3341
  if (toolSpan && toolCall.intent) {
3096
- toolSpan.setAttribute(PiGenAIAttr.ToolCallIntent, toolCall.intent);
3342
+ toolSpan.setAttribute(OmpGenAIAttr.ToolCallIntent, toolCall.intent);
3097
3343
  }
3098
3344
 
3099
3345
  let result: AgentToolResult<any> = { content: [], details: {} };
@@ -3138,11 +3384,13 @@ async function executeToolCalls(
3138
3384
  }
3139
3385
 
3140
3386
  if (!completedToolExecution) {
3141
- // The cooperative steering signal rides the loop-owned
3142
- // ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
3143
- // AgentToolContext itself is app-built via declaration merging, so
3144
- // the loop cannot construct or extend one structurally.
3145
- const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3387
+ // The cooperative steering signal and the passive-context sink
3388
+ // ride the loop-owned ToolCallContext (surfacing as
3389
+ // `ctx.toolCall.*`); the host surfaces the sink on the context it
3390
+ // builds, and the loop hands that object to the tool untouched.
3391
+ // Wrapper-dispatched nested calls (for example `write xd://…`)
3392
+ // inherit the context, so their passive hook context joins this
3393
+ // root call at the batch boundary.
3146
3394
  const toolContext = getToolContext?.({
3147
3395
  batchId,
3148
3396
  index,
@@ -3150,7 +3398,11 @@ async function executeToolCalls(
3150
3398
  toolCalls: toolCallInfos,
3151
3399
  steeringSignal: steeringSoftController.signal,
3152
3400
  providerMetadata: toolCall.providerMetadata,
3401
+ addAdditionalContext: context => {
3402
+ if (isNonBlankContext(context)) record.reportedContext.push(context);
3403
+ },
3153
3404
  });
3405
+ const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3154
3406
  if (streamSession && toolContext) {
3155
3407
  toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
3156
3408
  } else if (streamSession && !streamSession.contextIndependent) {
@@ -3270,7 +3522,13 @@ async function executeToolCalls(
3270
3522
  toolName: toolCall.name,
3271
3523
  });
3272
3524
 
3273
- await checkSteering();
3525
+ // Best-effort steering probe: its own failure is surfaced by the
3526
+ // dedicated watch path (which guards the identical call), so a rejecting
3527
+ // host `hasSteeringMessages`/`hasIrcInterrupts` callback must not reject
3528
+ // this task. An unguarded rejection here fires after the tool already
3529
+ // ran and poisons the `start.then(runTool)` ordering chain, skipping
3530
+ // every later chained record with a phantom "pending steering" result.
3531
+ await checkSteering().catch(() => undefined);
3274
3532
  };
3275
3533
 
3276
3534
  let lastExclusive: Promise<void> = Promise.resolve();
@@ -3279,12 +3537,18 @@ async function executeToolCalls(
3279
3537
 
3280
3538
  // While tool calls are in flight, queued steering or interrupting IRC would
3281
3539
  // otherwise wait out the tools' own window. Poll only non-consuming queues:
3282
- // detection hard-aborts interruptible waits (running or not yet started)
3283
- // and soft-signals cooperative tools (auto-background bash), so the boundary
3284
- // dequeue below injects the message promptly. Gated on immediate-interrupt
3285
- // mode; checkSteering is idempotent (no-op once triggered).
3540
+ // detection hard-aborts interruptible waits (running or not yet started),
3541
+ // and steering/IRC additionally soft-signal cooperative tools
3542
+ // (auto-background bash), so the boundary dequeue below injects the message
3543
+ // promptly. In "wait" mode only a batch holding an interruptible wait needs
3544
+ // the watch; checkSteering is idempotent (no-op once triggered).
3545
+ const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
3286
3546
  const watchSteeringWhileRunning =
3287
- shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
3547
+ (softInterrupts || records.some(record => record.interruptible)) &&
3548
+ (hasSteeringMessages !== undefined || hasAsidePeek);
3549
+ // Live-taken steering is already pending: interrupt before any call starts
3550
+ // so not-yet-started interruptible waits are skipped outright.
3551
+ if (liveSteering.length > 0) await checkSteering();
3288
3552
  const eventDrivenSteeringWatch =
3289
3553
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
3290
3554
  const steeringWatchAbortController = new AbortController();
@@ -3318,18 +3582,28 @@ async function executeToolCalls(
3318
3582
  () => false,
3319
3583
  );
3320
3584
  if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
3321
- if (steeringWatchSignal.aborted || interruptState.triggered) return;
3585
+ // Stop once nothing is left to escalate: the cooperative signal
3586
+ // is up, or (without soft interrupts) the waits are cut. A
3587
+ // completion-only trigger leaves the soft signal down, so keep
3588
+ // watching: a genuine steer arriving afterwards must still
3589
+ // reach foreground tools.
3590
+ if (
3591
+ steeringWatchSignal.aborted ||
3592
+ steeringSoftController.signal.aborted ||
3593
+ (!softInterrupts && interruptState.triggered)
3594
+ )
3595
+ return;
3322
3596
  if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
3323
3597
  }
3324
3598
  })()
3325
3599
  : undefined;
3326
- // IRC interrupt records have a separate session-owned queue and no wake
3327
- // callback. Keep its established timer fallback when that queue is present;
3328
- // system steering uses the event-driven path above and does not poll.
3600
+ // IRC interrupt records and background completions live in session-owned
3601
+ // queues with no wake callback. Keep the timer fallback when either peek is
3602
+ // present; system steering uses the event-driven path above and does not poll.
3329
3603
  const steeringWatchTimer =
3330
- watchSteeringWhileRunning && (!eventDrivenSteeringWatch || hasIrcInterrupts !== undefined)
3604
+ watchSteeringWhileRunning && (!eventDrivenSteeringWatch || hasAsidePeek)
3331
3605
  ? setInterval(
3332
- () => void (eventDrivenSteeringWatch ? checkIrcInterrupts() : checkSteering()),
3606
+ () => void (eventDrivenSteeringWatch ? checkAsideInterrupts() : checkSteering()),
3333
3607
  STEERING_INTERRUPT_POLL_MS,
3334
3608
  )
3335
3609
  : undefined;
@@ -3384,7 +3658,23 @@ async function executeToolCalls(
3384
3658
  }
3385
3659
  await speculationCoordinator?.discardAll("candidate was not dispatched");
3386
3660
 
3387
- return { toolResults: emittedToolResults };
3661
+ // Skipped calls never ran. Hook-prepared context also requires a non-error
3662
+ // final result; context the tool itself reported during execution stands.
3663
+ // Within a call, tool-reported context (including nested `xd://` dispatch)
3664
+ // precedes the hook's: wrappers release hook context only after the call
3665
+ // succeeds, so this is the one order every dispatch path can produce.
3666
+ const additionalContext = joinAdditionalContext(
3667
+ records
3668
+ .filter(record => !record.skipped)
3669
+ .flatMap(record => [
3670
+ ...record.reportedContext,
3671
+ record.toolResultMessage?.isError ? undefined : record.preparedContext,
3672
+ ]),
3673
+ );
3674
+ return {
3675
+ toolResults: emittedToolResults,
3676
+ ...(additionalContext !== undefined ? { additionalContext } : {}),
3677
+ };
3388
3678
  }
3389
3679
 
3390
3680
  /**
@@ -3538,8 +3828,11 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
3538
3828
  };
3539
3829
  }
3540
3830
 
3831
+ /** Origin of a mid-batch interrupt: queued steering, a peer IRC, or a background completion notice. */
3832
+ type AsideInterruptSource = SteeringInterruptSource | "irc" | "background";
3833
+
3541
3834
  function createSkippedToolResult(
3542
- source: SteeringInterruptSource | "irc" | undefined,
3835
+ source: AsideInterruptSource | undefined,
3543
3836
  executionStarted: boolean,
3544
3837
  ): AgentToolResult<SyntheticToolResultDetails | InterruptedToolResultDetails> {
3545
3838
  let reason = "pending steering message";
@@ -3556,6 +3849,9 @@ function createSkippedToolResult(
3556
3849
  } else if (source === "irc") {
3557
3850
  reason = "pending peer interrupt";
3558
3851
  blocker = "interrupt";
3852
+ } else if (source === "background") {
3853
+ reason = "a queued background completion (job or supervised process)";
3854
+ blocker = "completion notice";
3559
3855
  }
3560
3856
  return {
3561
3857
  content: [