@linxiraos/pi-agent-core 1.1.26 → 1.2.0

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 (43) hide show
  1. package/CHANGELOG.md +6 -243
  2. package/README.md +1 -1
  3. package/dist/types/agent.d.ts +27 -7
  4. package/dist/types/compaction/anthropic.d.ts +14 -8
  5. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  6. package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
  7. package/dist/types/compaction/compaction.d.ts +24 -2
  8. package/dist/types/compaction/entries.d.ts +2 -2
  9. package/dist/types/compaction/messages.d.ts +6 -0
  10. package/dist/types/compaction/openai.d.ts +14 -2
  11. package/dist/types/compaction/pruning.d.ts +64 -16
  12. package/dist/types/compaction/tool-protection.d.ts +2 -1
  13. package/dist/types/compaction/utils.d.ts +5 -0
  14. package/dist/types/image-tokens.d.ts +29 -0
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/speculative-execution.d.ts +1 -1
  17. package/dist/types/telemetry.d.ts +70 -60
  18. package/dist/types/tokenizer.d.ts +9 -6
  19. package/dist/types/tool-arguments.d.ts +19 -0
  20. package/dist/types/types.d.ts +12 -1
  21. package/package.json +8 -8
  22. package/src/agent-loop.ts +191 -34
  23. package/src/agent.ts +126 -21
  24. package/src/append-only-context.ts +38 -29
  25. package/src/compaction/anthropic.ts +30 -12
  26. package/src/compaction/branch-summarization.ts +1 -1
  27. package/src/compaction/compaction-v2-streaming.ts +23 -16
  28. package/src/compaction/compaction.ts +163 -73
  29. package/src/compaction/entries.ts +2 -2
  30. package/src/compaction/message-cache.ts +1 -1
  31. package/src/compaction/messages.ts +25 -0
  32. package/src/compaction/openai.ts +65 -28
  33. package/src/compaction/pruning.ts +328 -96
  34. package/src/compaction/tool-protection.ts +8 -2
  35. package/src/compaction/utils.ts +9 -0
  36. package/src/image-tokens.ts +77 -0
  37. package/src/index.ts +2 -0
  38. package/src/prompts/dsml-tool-call-leak.md +19 -0
  39. package/src/speculative-execution.ts +5 -9
  40. package/src/telemetry.ts +100 -81
  41. package/src/tokenizer.ts +24 -17
  42. package/src/tool-arguments.ts +38 -0
  43. package/src/types.ts +12 -1
package/src/agent-loop.ts CHANGED
@@ -23,16 +23,17 @@ import {
23
23
  type TSchema,
24
24
  toolWireSchema,
25
25
  type UserMessage,
26
- validateToolArguments,
27
26
  } from "@linxiraos/pi-ai";
28
27
  import {
29
28
  type Dialect,
30
29
  encodeInbandToolHistory,
30
+ mintToolCallId,
31
31
  renderInbandToolPrompt,
32
32
  renderToolExamples,
33
33
  wrapInbandToolStream,
34
34
  } from "@linxiraos/pi-ai/dialect";
35
35
  import * as AIError from "@linxiraos/pi-ai/error";
36
+ import { appendDuplicateSuffix, MAX_TOOL_CALL_ID_LENGTH } from "@linxiraos/pi-ai/providers/transform-messages";
36
37
  import {
37
38
  type CursorExecResolvedCarrier,
38
39
  copyCursorExecResolved,
@@ -51,6 +52,7 @@ import {
51
52
  recoverHarmonyToolCall,
52
53
  signalListLabel,
53
54
  } from "@linxiraos/pi-ai/utils/harmony-leak";
55
+ import { isDsmlLeakRecoveryTarget, removeDsmlToolMarkupLeak } from "@linxiraos/pi-ai/utils/dsml-leak";
54
56
  import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
55
57
  import { INTENT_FIELD } from "@linxiraos/pi-wire";
56
58
  import { LiveSteeringChannel } from "./live-steering";
@@ -73,7 +75,9 @@ import {
73
75
  startExecuteToolSpan,
74
76
  startInvokeAgentSpan,
75
77
  } from "./telemetry";
78
+ import { validateAgentToolArguments } from "./tool-arguments";
76
79
  import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
80
+ import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
77
81
  import type {
78
82
  AgentContext,
79
83
  AgentEvent,
@@ -114,6 +118,14 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
114
118
  */
115
119
  const MAX_PAUSED_TURN_CONTINUATIONS = 8;
116
120
 
121
+ /**
122
+ * Cap on consecutive "tool call failed" nudges for a turn that ended with raw
123
+ * DeepSeek DSML tool-call markup in its text and no structured call. A model
124
+ * that keeps leaking after this many corrections is left to yield. Resets
125
+ * whenever a turn carries tool calls.
126
+ */
127
+ const MAX_DSML_LEAK_NUDGES = 2;
128
+
117
129
  /**
118
130
  * Cap on consecutive forced escalations for a single soft tool requirement.
119
131
  * A forced `toolChoice` guarantees the call, so this is purely defensive: if a
@@ -998,7 +1010,8 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
998
1010
  const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
999
1011
  return tools?.map(t => {
1000
1012
  const intentMode = resolveIntentMode(t.intent);
1001
- const doInjectIntent = injectIntent && intentMode !== "omit";
1013
+ const doInjectIntent =
1014
+ injectIntent && intentMode !== "omit" && !schemaDefinesProperty(toolWireSchema(t), INTENT_FIELD);
1002
1015
  // When the full catalog is rendered into the system prompt, ship the tool
1003
1016
  // specs without their descriptions (top-level + nested schema annotations)
1004
1017
  // so they are not duplicated on the wire. Strip the STABLE wire schema (the
@@ -1219,6 +1232,7 @@ async function runLoopBody(
1219
1232
  let harmonyRetryAttempt = 0;
1220
1233
  let harmonyTruncateResumeCount = 0;
1221
1234
  let pausedTurnContinuations = 0;
1235
+ let dsmlLeakNudges = 0;
1222
1236
 
1223
1237
  // Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
1224
1238
  // The host-owned state survives only a gate stop between Agent.prompt calls.
@@ -1479,6 +1493,15 @@ async function runLoopBody(
1479
1493
  }
1480
1494
  }
1481
1495
  if (recovered) {
1496
+ // Mint/repair ids before any snapshot: the recovered message skipped
1497
+ // the streamed prepare (the leak interruption landed first), and the
1498
+ // `message_start`/`message_end` copies below are what persistence and
1499
+ // every consumer retain. Repairing only at executeToolCalls would
1500
+ // leave those copies under a never-materialized id while the result
1501
+ // carries the minted one. The later prepare stays idempotent — the
1502
+ // repaired mark rides the snapshot (object spread), and the minted
1503
+ // ids are already claimed at run scope.
1504
+ ensureUniqueToolCallIds(message, toolCallIdsDispatchedUnder(currentContext));
1482
1505
  message = snapshotAssistantMessage(message);
1483
1506
  currentContext.messages.push(message);
1484
1507
  stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
@@ -1673,6 +1696,7 @@ async function runLoopBody(
1673
1696
 
1674
1697
  if (toolCalls.length > 0) {
1675
1698
  pausedTurnContinuations = 0;
1699
+ dsmlLeakNudges = 0;
1676
1700
  } else if (
1677
1701
  !hasMoreToolCalls &&
1678
1702
  message.stopReason === "stop" &&
@@ -1686,6 +1710,25 @@ async function runLoopBody(
1686
1710
  // mid-work turn.
1687
1711
  pausedTurnContinuations++;
1688
1712
  hasMoreToolCalls = true;
1713
+ } else if (
1714
+ !hasMoreToolCalls &&
1715
+ message.stopReason === "stop" &&
1716
+ dsmlLeakMessages.has(message) &&
1717
+ dsmlLeakNudges < MAX_DSML_LEAK_NUDGES
1718
+ ) {
1719
+ // DeepSeek wrote its tool call as raw DSML text the healer could not
1720
+ // recover (often missing the opening envelope). The markup was
1721
+ // stripped before commit; without a nudge the loop would yield as if
1722
+ // the model were done, so tell it the call failed and re-sample.
1723
+ const nudge = injectExecutionAdditionalContext(
1724
+ currentContext,
1725
+ newMessages,
1726
+ stream,
1727
+ dsmlToolCallLeakPrompt,
1728
+ );
1729
+ if (nudge) additionalMessages.push(nudge);
1730
+ dsmlLeakNudges++;
1731
+ hasMoreToolCalls = true;
1689
1732
  }
1690
1733
 
1691
1734
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
@@ -1955,6 +1998,7 @@ async function streamAssistantResponse(
1955
1998
  // per model without touching the shared session `serviceTier`.
1956
1999
  const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(model) : config.serviceTier;
1957
2000
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(model);
2001
+ const dsmlLeakRecoveryEnabled = isDsmlLeakRecoveryTarget(model);
1958
2002
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1959
2003
  const requestSignal = harmonyAbortController
1960
2004
  ? signal
@@ -2031,6 +2075,7 @@ async function streamAssistantResponse(
2031
2075
  const finishChat = async (message: AssistantMessage): Promise<void> => {
2032
2076
  await finishChatSpan(telemetry, chatSpan, message, {
2033
2077
  stepNumber: chatStepNumber,
2078
+ modelId: model.id,
2034
2079
  serviceTier: effectiveServiceTier,
2035
2080
  responseHeaders: capturedHeaders,
2036
2081
  baseUrl: model.baseUrl,
@@ -2183,6 +2228,18 @@ async function streamAssistantResponse(
2183
2228
  }
2184
2229
  }
2185
2230
  finalMessage = snapshotAssistantMessage(finalMessage);
2231
+ // Unhealed DSML tool-call markup must never reach history: replaying
2232
+ // it teaches the model to keep writing calls as text (#10556). Strip
2233
+ // it before the context, the UI, and the session see the message.
2234
+ // Only DSML-speaking models qualify; from others it is prose.
2235
+ if (
2236
+ dsmlLeakRecoveryEnabled &&
2237
+ finalMessage.stopReason === "stop" &&
2238
+ !finalMessage.content.some(block => block.type === "toolCall") &&
2239
+ removeDsmlToolMarkupLeak(finalMessage)
2240
+ ) {
2241
+ dsmlLeakMessages.add(finalMessage);
2242
+ }
2186
2243
  // Expand inline macros (and any other registered rewrite) on the
2187
2244
  // finalized message before it reaches the context, the UI, or tool
2188
2245
  // dispatch — so a single mutation is the source of truth for all three.
@@ -2447,7 +2504,10 @@ async function streamAssistantResponse(
2447
2504
  }
2448
2505
 
2449
2506
  try {
2450
- let trailing = await response.result();
2507
+ let trailing = recoverTransientErrorToolTurn(
2508
+ retainCompletedToolCalls(await response.result(), completedToolCallIds),
2509
+ context.tools ?? [],
2510
+ );
2451
2511
  if (harmonyMitigationEnabled) {
2452
2512
  const detection = detectHarmonyLeakInAssistantMessage(trailing);
2453
2513
  if (detection) {
@@ -2736,6 +2796,9 @@ interface PreparedToolCall {
2736
2796
  prepareError?: unknown;
2737
2797
  }
2738
2798
 
2799
+ /** Committed assistant messages whose unhealed DSML tool-call markup was stripped; the loop answers them with a failure nudge. */
2800
+ const dsmlLeakMessages = new WeakSet<AssistantMessage>();
2801
+
2739
2802
  /**
2740
2803
  * Prepare results computed in the stream-done branch (before `message_start`/
2741
2804
  * `message_end`) so a `beforeToolCall` args revision is baked into the message
@@ -2839,15 +2902,110 @@ function formatToolNotFoundMessage(
2839
2902
  return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
2840
2903
  }
2841
2904
 
2905
+ /**
2906
+ * Enforce the invariant every downstream matcher keys on: `toolCall.id` is
2907
+ * unique and non-empty across the whole run — not just within one assistant
2908
+ * message. Providers can deliver a reused id (wire length truncation, Responses
2909
+ * `callId|itemId` composite collapse) or an id that never materialized (`""`) —
2910
+ * the same occurrences the replay pipeline repairs or drops
2911
+ * (`deduplicateToolCallIds` / `sanitizeMalformedToolCalls`). Id-keyed per-call
2912
+ * state — the prepare map below, every UI and persistence matcher — would
2913
+ * otherwise merge sibling calls: the later call's prepared entry overwrites the
2914
+ * earlier's, both calls execute the sibling's payload, and colliding result ids
2915
+ * merge or drop one sibling's output in every consumer. Message-scope
2916
+ * uniqueness is not enough for the session-wide consumers
2917
+ * (`formatSessionHistoryMarkdown`'s last-entry-wins map, the advisor delta
2918
+ * split, `EventController.#toolTimelineComponents`, persistence keys): a second
2919
+ * assistant turn whose ids never materialized used to re-mint `call_1`,
2920
+ * `call_2`, … and re-derive the same `_dup` suffixes, so those consumers merged
2921
+ * the cross-turn collisions. Every id kept or minted here is therefore claimed
2922
+ * in the run-scoped registry (`toolCallIdsDispatchedUnder`) and every candidate
2923
+ * is generated until unused at run scope. Never-materialized is the sanitizer's
2924
+ * trim test (`isMalformedToolCallId`): a whitespace-only id is minted like an
2925
+ * empty one, never kept — it would be dropped from provider replay together
2926
+ * with its result, the live/replay divergence this repair exists to close.
2927
+ * Cursor server-resolved blocks are never re-keyed (the provider correlates
2928
+ * their results out-of-band under its own ids) but their ids still join the
2929
+ * registry so no minted candidate collides with one. Idempotent: the message is
2930
+ * marked repaired and re-entry is a no-op, so the Harmony path can repair
2931
+ * before its snapshots while the dispatch-time prepare sees the already-minted
2932
+ * ids and leaves them alone.
2933
+ */
2934
+ const kToolCallIdsRepaired = Symbol("agent-loop.toolCallIdsRepaired");
2935
+
2936
+ /** Assistant message optionally carrying the {@link kToolCallIdsRepaired} mark. */
2937
+ type ToolCallIdsRepairedCarrier = AssistantMessage & { [kToolCallIdsRepaired]?: true };
2938
+
2939
+ /**
2940
+ * Run-scoped registry of every tool-call id {@link ensureUniqueToolCallIds} has
2941
+ * kept, minted, or seen on a server-resolved block under one
2942
+ * {@link AgentContext}. Keyed by the dispatch context (the same object every
2943
+ * `prepareToolCallDispatch` receives) so the uniqueness window spans every
2944
+ * assistant turn of the run rather than resetting per message. Each run gets a
2945
+ * fresh loop context, so minted ids use the process-unique `mintToolCallId`.
2946
+ */
2947
+ const dispatchedToolCallIdsByContext = new WeakMap<AgentContext, Set<string>>();
2948
+
2949
+ function toolCallIdsDispatchedUnder(context: AgentContext): Set<string> {
2950
+ let dispatched = dispatchedToolCallIdsByContext.get(context);
2951
+ if (!dispatched) {
2952
+ dispatched = new Set();
2953
+ dispatchedToolCallIdsByContext.set(context, dispatched);
2954
+ }
2955
+ return dispatched;
2956
+ }
2957
+
2958
+ function ensureUniqueToolCallIds(message: ToolCallIdsRepairedCarrier, dispatchedIds: Set<string>): void {
2959
+ if (message[kToolCallIdsRepaired] === true) return;
2960
+ const content = message.content;
2961
+ const reserved = new Set<string>();
2962
+ for (const block of content) {
2963
+ if (block.type === "toolCall" && block.id) reserved.add(block.id);
2964
+ }
2965
+ const seen = new Set<string>();
2966
+ for (const block of content) {
2967
+ if (block.type !== "toolCall") continue;
2968
+ if ((block as CursorExecResolvedCarrier)[kCursorExecResolved] === true) {
2969
+ if (block.id?.trim()) dispatchedIds.add(block.id);
2970
+ continue;
2971
+ }
2972
+ if (block.id?.trim() && !seen.has(block.id) && !dispatchedIds.has(block.id)) {
2973
+ seen.add(block.id);
2974
+ dispatchedIds.add(block.id);
2975
+ continue;
2976
+ }
2977
+ let candidate: string;
2978
+ if (block.id?.trim()) {
2979
+ let suffix = 1;
2980
+ candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
2981
+ while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate)) {
2982
+ suffix += 1;
2983
+ candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
2984
+ }
2985
+ } else {
2986
+ // Process-unique minter: a per-run counter would re-mint `call_1` in
2987
+ // every run and collide with earlier runs' calls in session history.
2988
+ do {
2989
+ candidate = mintToolCallId();
2990
+ } while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate));
2991
+ }
2992
+ block.id = candidate;
2993
+ seen.add(candidate);
2994
+ dispatchedIds.add(candidate);
2995
+ }
2996
+ message[kToolCallIdsRepaired] = true;
2997
+ }
2998
+
2842
2999
  /**
2843
3000
  * Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
2844
- * call order: intent extraction, argument validation, and the `beforeToolCall`
2845
- * hook. A hook `args` revision is revalidated against the tool schema and
2846
- * written back to `toolCall.arguments`; run before `message_start`/`message_end`
2847
- * (the streamed path) that makes the revision the single source of truth —
2848
- * history, execution events, persistence, provider replay, concurrency
2849
- * scheduling, and `tool.execute` all agree. Failures are recorded per call and
2850
- * surfaced by `executeToolCalls` at the record's scheduled slot.
3001
+ * call order: tool-call id repair, intent extraction, argument validation, and
3002
+ * the `beforeToolCall` hook. A hook `args` revision is revalidated against the
3003
+ * tool schema and written back to `toolCall.arguments`; run before
3004
+ * `message_start`/`message_end` (the streamed path) that makes the revision the
3005
+ * single source of truth — history, execution events, persistence, provider
3006
+ * replay, concurrency scheduling, and `tool.execute` all agree. Id repair in
3007
+ * {@link ensureUniqueToolCallIds} follows the same rule. Failures are recorded
3008
+ * per call and surfaced by `executeToolCalls` at the record's scheduled slot.
2851
3009
  */
2852
3010
  async function prepareToolCallDispatch(
2853
3011
  assistantMessage: AssistantMessage,
@@ -2856,6 +3014,9 @@ async function prepareToolCallDispatch(
2856
3014
  signal: AbortSignal | undefined,
2857
3015
  ): Promise<Map<string, PreparedToolCall>> {
2858
3016
  const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
3017
+ // Unique, non-empty ids first: everything below and every consumer of this
3018
+ // message keys per-call state on `toolCall.id`.
3019
+ ensureUniqueToolCallIds(assistantMessage, toolCallIdsDispatchedUnder(context));
2859
3020
  const prepared = new Map<string, PreparedToolCall>();
2860
3021
  for (const toolCall of assistantMessage.content) {
2861
3022
  if (toolCall.type !== "toolCall") continue;
@@ -2872,17 +3033,13 @@ async function prepareToolCallDispatch(
2872
3033
  prepared.set(toolCall.id, entry);
2873
3034
  let argsForExecution = toolCall.arguments as Record<string, unknown>;
2874
3035
  if (intentTracing) {
2875
- const { intent, strippedArgs } = extractIntent(toolCall.arguments);
3036
+ // A schema-owned `i` is a tool argument, never a harness intent.
3037
+ const ownsIntent = tool !== undefined && schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD);
3038
+ const { intent, strippedArgs } = ownsIntent
3039
+ ? { intent: undefined, strippedArgs: argsForExecution }
3040
+ : extractIntent(toolCall.arguments);
2876
3041
  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
- ) {
3042
+ if (intent !== undefined && intent.length > MAX_INTENT_LENGTH && tool) {
2886
3043
  entry.args = strippedArgs;
2887
3044
  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
3045
  continue;
@@ -2905,15 +3062,9 @@ async function prepareToolCallDispatch(
2905
3062
  if (!tool) {
2906
3063
  throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools, suggestFallbackToolNames?.()));
2907
3064
  }
2908
- return validateToolArguments(tool, { ...toolCall, arguments: args });
3065
+ return validateAgentToolArguments(tool, { ...toolCall, arguments: args });
2909
3066
  } catch (validationError) {
2910
- // Lenience covers schema mismatches; a parse failure has no args to hand over.
2911
3067
  const parseFailed = "__parseError" in args;
2912
- if (tool?.lenientArgValidation && !parseFailed) {
2913
- const fallback = { ...args };
2914
- delete fallback.__rawJson;
2915
- return fallback;
2916
- }
2917
3068
  entry.args = parseFailed ? { __parseError: args.__parseError } : args;
2918
3069
  entry.validationErrorMessage =
2919
3070
  validationError instanceof Error ? validationError.message : String(validationError);
@@ -3053,6 +3204,7 @@ async function executeToolCalls(
3053
3204
  hasSteeringMessages,
3054
3205
  hasIrcInterrupts,
3055
3206
  hasBackgroundCompletions,
3207
+ hasQueuedAsides,
3056
3208
  interruptMode = "immediate",
3057
3209
  getToolContext,
3058
3210
 
@@ -3154,11 +3306,12 @@ async function executeToolCalls(
3154
3306
  // (only when soft interrupts are enabled; otherwise nothing is left).
3155
3307
  if (signal?.aborted) return;
3156
3308
  if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
3157
- // Peer IRC and background completions (finished jobs, exited supervised
3158
- // processes) hard-abort interruptible waits only; foreground tools keep
3159
- // running (no partial side effects).
3309
+ // Peer IRC, queued asides and background completions (finished jobs,
3310
+ // exited supervised processes) hard-abort interruptible waits only;
3311
+ // foreground tools keep running (no partial side effects).
3160
3312
  let source: AsideInterruptSource | undefined;
3161
3313
  if (hasIrcInterrupts && (await hasIrcInterrupts())) source = "irc";
3314
+ else if (!interruptState.triggered && hasQueuedAsides && (await hasQueuedAsides())) source = "aside";
3162
3315
  else if (!interruptState.triggered && hasBackgroundCompletions && (await hasBackgroundCompletions()))
3163
3316
  source = "background";
3164
3317
  if (!source) return;
@@ -3175,7 +3328,7 @@ async function executeToolCalls(
3175
3328
  // also cascades: the freshly detached job's own completion re-triggers
3176
3329
  // this check for the next command, so millisecond-long commands chain
3177
3330
  // into separate background deliveries (#12869).
3178
- if (source !== "background" && softInterrupts) steeringSoftController.abort();
3331
+ if (source === "irc" && softInterrupts) steeringSoftController.abort();
3179
3332
  };
3180
3333
 
3181
3334
  const checkSteering = async (): Promise<void> => {
@@ -3546,7 +3699,8 @@ async function executeToolCalls(
3546
3699
  // (auto-background bash), so the boundary dequeue below injects the message
3547
3700
  // promptly. In "wait" mode only a batch holding an interruptible wait needs
3548
3701
  // the watch; checkSteering is idempotent (no-op once triggered).
3549
- const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
3702
+ const hasAsidePeek =
3703
+ hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined || hasQueuedAsides !== undefined;
3550
3704
  const watchSteeringWhileRunning =
3551
3705
  (softInterrupts || records.some(record => record.interruptible)) &&
3552
3706
  (hasSteeringMessages !== undefined || hasAsidePeek);
@@ -3832,8 +3986,8 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
3832
3986
  };
3833
3987
  }
3834
3988
 
3835
- /** Origin of a mid-batch interrupt: queued steering, a peer IRC, or a background completion notice. */
3836
- type AsideInterruptSource = SteeringInterruptSource | "irc" | "background";
3989
+ /** Origin of a mid-batch interrupt: queued steering, a peer IRC, a queued aside, or a background completion notice. */
3990
+ type AsideInterruptSource = SteeringInterruptSource | "irc" | "aside" | "background";
3837
3991
 
3838
3992
  function createSkippedToolResult(
3839
3993
  source: AsideInterruptSource | undefined,
@@ -3853,6 +4007,9 @@ function createSkippedToolResult(
3853
4007
  } else if (source === "irc") {
3854
4008
  reason = "pending peer interrupt";
3855
4009
  blocker = "interrupt";
4010
+ } else if (source === "aside") {
4011
+ reason = "a queued aside message";
4012
+ blocker = "aside";
3856
4013
  } else if (source === "background") {
3857
4014
  reason = "a queued background completion (job or supervised process)";
3858
4015
  blocker = "completion notice";