@linxiraos/pi-agent-core 1.1.15 → 1.1.17

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
@@ -37,6 +37,7 @@ import {
37
37
  getStreamingPartialJson,
38
38
  kCursorExecResolved,
39
39
  } from "@linxiraos/pi-ai/utils/block-symbols";
40
+ import { stamp } from "@linxiraos/pi-ai/utils/schema/stamps";
40
41
  import {
41
42
  createHarmonyAuditEvent,
42
43
  detectHarmonyLeakInAssistantMessage,
@@ -395,6 +396,64 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
395
396
  };
396
397
  }
397
398
 
399
+ /**
400
+ * Incremental variant of `snapshotAssistantMessage` for per-delta
401
+ * `message_update` events.
402
+ *
403
+ * The stream contract guarantees that every content-block mutation a provider
404
+ * makes is paired with an event carrying that block's `contentIndex` (every
405
+ * provider mutates then pushes), and that `output.content` is append-only
406
+ * within a turn. A fresh snapshot therefore only needs to re-clone:
407
+ *
408
+ * - the block the current event targets (`changedIndex`),
409
+ * - blocks still open (started but not ended) — Cursor's edit block merges
410
+ * `path`/`stream_content` into a live block without an event, so open blocks
411
+ * are re-cloned on every delta,
412
+ * - blocks appended since the previous snapshot.
413
+ *
414
+ * Every other (finalized) block is carried over from the previous snapshot by
415
+ * reference: finalized blocks are never mutated after their end event, so
416
+ * sharing them is exact and turns the per-delta cost from O(turn content) into
417
+ * O(open blocks) — the difference between quadratic and linear streaming cost
418
+ * on long turns (issue #10605).
419
+ */
420
+ function snapshotAssistantMessageIncremental(
421
+ live: AssistantMessage,
422
+ prev: AssistantMessage,
423
+ changedIndex: number,
424
+ openBlocks: ReadonlySet<number>,
425
+ ): AssistantMessage {
426
+ const liveContent = live.content;
427
+ const prevContent = prev.content;
428
+ const prevLen = prevContent.length;
429
+ // Reference-copy the previous snapshot's block array (native-speed), then
430
+ // patch in fresh clones only where the live state moved: the block this
431
+ // delta targeted, blocks still streaming, and blocks appended since the
432
+ // previous snapshot. Finalized blocks keep their existing snapshot clone.
433
+ const content = prevContent.slice();
434
+ for (let i = prevLen; i < liveContent.length; i++) {
435
+ content.push(snapshotAssistantContentBlock(liveContent[i]!));
436
+ }
437
+ if (changedIndex >= 0 && changedIndex < prevLen && changedIndex < liveContent.length) {
438
+ content[changedIndex] = snapshotAssistantContentBlock(liveContent[changedIndex]!);
439
+ }
440
+ for (const openIndex of openBlocks) {
441
+ if (openIndex !== changedIndex && openIndex >= 0 && openIndex < prevLen && openIndex < liveContent.length) {
442
+ content[openIndex] = snapshotAssistantContentBlock(liveContent[openIndex]!);
443
+ }
444
+ }
445
+ return {
446
+ ...live,
447
+ content,
448
+ usage: {
449
+ ...live.usage,
450
+ cost: { ...live.usage.cost },
451
+ },
452
+ disabledFeatures: live.disabledFeatures ? [...live.disabledFeatures] : undefined,
453
+ toolCallAbortMessages: live.toolCallAbortMessages ? { ...live.toolCallAbortMessages } : undefined,
454
+ };
455
+ }
456
+
398
457
  /**
399
458
  * Deep-clone an assistant streaming event so subscribers get an immutable view.
400
459
  * Pass `partialSnapshot` when the caller has already snapshotted `event.partial`
@@ -828,6 +887,29 @@ export function normalizeMessagesForProvider(
828
887
  const INTENT_FIELD_DESCRIPTION = "concise intent";
829
888
  const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
830
889
 
890
+ // Memoize injection per input schema identity: normalizeTools runs on every
891
+ // model call and injectIntentIntoSchema mints a fresh root object each time,
892
+ // which defeats the stamp-keyed schema memos downstream (toolWireSchema,
893
+ // stripSchemaDescriptions, tryEnforceStrictSchema each deep-clone + re-walk
894
+ // the whole catalog per request). The injected object is shared across
895
+ // requests — the same profile as the intent-off path, where parameters IS the
896
+ // shared memoized wire schema (see schema-immutability.test.ts). One stamp
897
+ // key per (mode, describeIntent) variant; index 0 = bare, 1 = described.
898
+ const INTENT_STAMPS = {
899
+ require: [Symbol("intent:require"), Symbol("intent:require:described")],
900
+ optional: [Symbol("intent:optional"), Symbol("intent:optional:described")],
901
+ } as const;
902
+
903
+ function memoizedInjectIntentIntoSchema(
904
+ schema: Record<string, unknown>,
905
+ mode: "require" | "optional",
906
+ describeIntent: boolean,
907
+ ): unknown {
908
+ return stamp(schema, INTENT_STAMPS[mode][describeIntent ? 1 : 0], host =>
909
+ injectIntentIntoSchema(host, mode, describeIntent),
910
+ );
911
+ }
912
+
831
913
  function injectIntentIntoSchema(
832
914
  schema: unknown,
833
915
  mode: "require" | "optional" = "require",
@@ -907,12 +989,14 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
907
989
  // re-inject `i` (without its hint, which `describeIntent: false` omits) so
908
990
  // intent tracing keeps the field while no descriptions ride the wire.
909
991
  if (pruneDescriptions) {
910
- let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
911
- if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema;
992
+ const stripped = stripSchemaDescriptions(toolWireSchema(t));
993
+ const parameters = (
994
+ doInjectIntent ? memoizedInjectIntentIntoSchema(stripped, intentMode, false) : stripped
995
+ ) as TSchema;
912
996
  return { ...t, parameters, description: "" };
913
997
  }
914
- let parameters = toolWireSchema(t) as TSchema;
915
- if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
998
+ const wire = toolWireSchema(t);
999
+ const parameters = (doInjectIntent ? memoizedInjectIntentIntoSchema(wire, intentMode, true) : wire) as TSchema;
916
1000
  const description = t.description ?? "";
917
1001
  const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
918
1002
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
@@ -1813,6 +1897,13 @@ async function streamAssistantResponse(
1813
1897
 
1814
1898
  let partialMessage: AssistantMessage | null = null;
1815
1899
  let addedPartial = false;
1900
+ // Previous `message_update` snapshot for the incremental rebuild below;
1901
+ // null until the turn's `start` event seeds it.
1902
+ let turnSnapshot: AssistantMessage | null = null;
1903
+ // Content indices of blocks that started streaming but have not ended
1904
+ // yet — re-cloned on every delta because live blocks may be patched
1905
+ // without a paired event (Cursor's silent edit-block merge).
1906
+ const openBlocks = new Set<number>();
1816
1907
  const completedToolCallIds = new Set<string>();
1817
1908
  const argStreams = new Map<number, { id: string; stream: AgentToolArgStream }>();
1818
1909
  const cancelArgStreams = (): void => {
@@ -2037,6 +2128,8 @@ async function streamAssistantResponse(
2037
2128
  // consumer treats both as read-only, so cloning the identical partial
2038
2129
  // twice per delta was pure waste.
2039
2130
  const messageSnapshot = snapshotAssistantMessage(partialMessage);
2131
+ turnSnapshot = messageSnapshot;
2132
+ openBlocks.clear();
2040
2133
  stream.push({
2041
2134
  type: "message_update",
2042
2135
  assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
@@ -2045,7 +2138,8 @@ async function streamAssistantResponse(
2045
2138
  } else {
2046
2139
  context.messages.push(partialMessage);
2047
2140
  addedPartial = true;
2048
- stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
2141
+ turnSnapshot = snapshotAssistantMessage(partialMessage);
2142
+ stream.push({ type: "message_start", message: turnSnapshot });
2049
2143
  }
2050
2144
  break;
2051
2145
 
@@ -2127,11 +2221,34 @@ async function streamAssistantResponse(
2127
2221
  partialMessage = event.partial;
2128
2222
  context.messages[context.messages.length - 1] = partialMessage;
2129
2223
  config.onAssistantMessageEvent?.(partialMessage, event);
2130
- // `message` and `assistantMessageEvent.partial` intentionally share one
2131
- // immutable snapshot of the streaming partial: every message_update
2132
- // consumer treats both as read-only, so cloning the identical partial
2133
- // twice per delta was pure waste.
2134
- const messageSnapshot = snapshotAssistantMessage(partialMessage);
2224
+ // Track which blocks are still streaming: open blocks are
2225
+ // re-cloned on every delta, finalized blocks are shared.
2226
+ const contentIndex = (event as { contentIndex?: number }).contentIndex;
2227
+ if (contentIndex !== undefined) {
2228
+ if (event.type.endsWith("_start")) openBlocks.add(contentIndex);
2229
+ else if (event.type.endsWith("_end")) openBlocks.delete(contentIndex);
2230
+ }
2231
+ // READ-ONLY-CONSUMER INVARIANT: `message` and
2232
+ // `assistantMessageEvent.partial` intentionally share one snapshot,
2233
+ // and the snapshot is rebuilt incrementally — only the delta's
2234
+ // block, open blocks, and newly appended blocks are deep-cloned;
2235
+ // finalized blocks are carried over from the previous snapshot by
2236
+ // reference (see `snapshotAssistantMessageIncremental`), so they are
2237
+ // shared across ALL message_update snapshots of the turn.
2238
+ // Consumers MUST treat both fields — and every content block inside
2239
+ // them — as read-only: mutating a snapshot would corrupt every
2240
+ // earlier and later snapshot of the turn, not just this one. In
2241
+ // exchange, per-delta work is proportional to the live stream
2242
+ // instead of the whole turn (issue #10605).
2243
+ const messageSnapshot: AssistantMessage = turnSnapshot
2244
+ ? snapshotAssistantMessageIncremental(
2245
+ partialMessage,
2246
+ turnSnapshot,
2247
+ contentIndex ?? -1,
2248
+ openBlocks,
2249
+ )
2250
+ : snapshotAssistantMessage(partialMessage);
2251
+ turnSnapshot = messageSnapshot;
2135
2252
  stream.push({
2136
2253
  type: "message_update",
2137
2254
  assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
package/src/agent.ts CHANGED
@@ -50,6 +50,7 @@ import type {
50
50
  AgentToolContext,
51
51
  AgentTurnEndContext,
52
52
  AsideMessage,
53
+ PrepareQueuedMessages,
53
54
  SpeculativeToolExecutionConfig,
54
55
  StreamFn,
55
56
  ToolCallContext,
@@ -362,6 +363,13 @@ interface CursorToolResultEntry {
362
363
  pending?: Promise<void>;
363
364
  }
364
365
 
366
+ type QueuedMessageQueue = "steering" | "followUp";
367
+
368
+ interface QueuedMessageClaim {
369
+ messages: AgentMessage[];
370
+ controller: AbortController;
371
+ }
372
+
365
373
  export class Agent {
366
374
  #state: AgentState = {
367
375
  systemPrompt: [],
@@ -383,6 +391,14 @@ export class Agent {
383
391
  #transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
384
392
  #steeringQueue: AgentMessage[] = [];
385
393
  #followUpQueue: AgentMessage[] = [];
394
+ #queuedMessageClaims: Partial<Record<QueuedMessageQueue, QueuedMessageClaim>> = {};
395
+ /** Dequeued originals remain recoverable until their transcript events arrive. */
396
+ #queuedMessageDeliveries = new Set<{
397
+ queue: QueuedMessageQueue;
398
+ controller: AbortController | undefined;
399
+ messages: AgentMessage[];
400
+ next: number;
401
+ }>();
386
402
  #steeringWaiters = new Set<() => void>();
387
403
 
388
404
  #steeringMode: "all" | "one-at-a-time";
@@ -449,6 +465,8 @@ export class Agent {
449
465
 
450
466
  streamFn: StreamFn;
451
467
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
468
+ /** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
469
+ prepareQueuedMessages?: PrepareQueuedMessages;
452
470
  /**
453
471
  * Hook invoked after tool arguments are validated and before execution.
454
472
  * Reassign at any time to swap the implementation (e.g. on extension reload).
@@ -844,16 +862,90 @@ export class Agent {
844
862
  for (const hook of this.#beforeQueuedMessageDequeueHooks) await hook(signal);
845
863
  }
846
864
 
847
- async #dequeueSteeringMessagesAfterHooks(signal?: AbortSignal): Promise<AgentMessage[]> {
848
- if (signal?.aborted || this.#steeringQueue.length === 0) return [];
865
+ async #dequeueSteeringMessagesAfterHooks(signal: AbortSignal): Promise<AgentMessage[]> {
866
+ if (signal.aborted || this.#steeringQueue.length === 0) return [];
849
867
  await this.#runBeforeQueuedMessageDequeueHooks(signal);
850
- return signal?.aborted ? [] : this.#dequeueSteeringMessages();
868
+ return signal.aborted ? [] : this.#prepareQueuedMessageBatch("steering", signal);
851
869
  }
852
870
 
853
- async #dequeueFollowUpMessagesAfterHooks(signal?: AbortSignal): Promise<AgentMessage[]> {
854
- if (signal?.aborted || this.#followUpQueue.length === 0) return [];
871
+ async #dequeueFollowUpMessagesAfterHooks(signal: AbortSignal): Promise<AgentMessage[]> {
872
+ if (signal.aborted || this.#followUpQueue.length === 0) return [];
855
873
  await this.#runBeforeQueuedMessageDequeueHooks(signal);
856
- return signal?.aborted ? [] : this.#dequeueFollowUpMessages();
874
+ return signal.aborted ? [] : this.#prepareQueuedMessageBatch("followUp", signal);
875
+ }
876
+
877
+ async #prepareQueuedMessageBatch(queue: QueuedMessageQueue, signal: AbortSignal): Promise<AgentMessage[]> {
878
+ if (this.#queuedMessageClaims[queue]) return [];
879
+ const messages = queue === "steering" ? this.#dequeueSteeringMessages() : this.#dequeueFollowUpMessages();
880
+ const prepare = this.prepareQueuedMessages;
881
+ if (messages.length === 0) return messages;
882
+ const runController = this.#abortController;
883
+ if (!prepare) {
884
+ this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
885
+ return messages;
886
+ }
887
+
888
+ const claim: QueuedMessageClaim = { messages, controller: new AbortController() };
889
+ this.#queuedMessageClaims[queue] = claim;
890
+ const preparationSignal = AbortSignal.any([signal, claim.controller.signal]);
891
+ try {
892
+ const preparation = await prepare(messages, preparationSignal);
893
+ signal.throwIfAborted();
894
+ if (preparationSignal.aborted || this.#queuedMessageClaims[queue] !== claim) return [];
895
+ const additional = preparation?.commit();
896
+ if (preparationSignal.aborted || this.#queuedMessageClaims[queue] !== claim) return [];
897
+ if (preparation && additional === undefined) {
898
+ // Stop this attempt before the loop can immediately reclaim the restored batch.
899
+ runController?.abort();
900
+ throw new DOMException("Queued message preparation cancelled", "AbortError");
901
+ }
902
+ delete this.#queuedMessageClaims[queue];
903
+ this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
904
+ return additional?.length ? [...messages, ...additional] : messages;
905
+ } catch (error) {
906
+ if (signal.aborted) throw error;
907
+ if (preparationSignal.aborted || this.#queuedMessageClaims[queue] !== claim) return [];
908
+ throw error;
909
+ } finally {
910
+ if (this.#queuedMessageClaims[queue] === claim) this.#cancelQueuedMessagePreparation(queue, true);
911
+ }
912
+ }
913
+
914
+ #cancelQueuedMessagePreparation(queue: QueuedMessageQueue, restore = false): void {
915
+ if (!restore) {
916
+ for (const delivery of this.#queuedMessageDeliveries) {
917
+ if (delivery.queue === queue) this.#queuedMessageDeliveries.delete(delivery);
918
+ }
919
+ }
920
+ const claim = this.#queuedMessageClaims[queue];
921
+ if (!claim) return;
922
+ delete this.#queuedMessageClaims[queue];
923
+ if (restore) {
924
+ if (queue === "steering") {
925
+ this.#steeringQueue = [...claim.messages, ...this.#steeringQueue];
926
+ this.#notifySteeringWaiters();
927
+ } else {
928
+ this.#followUpQueue = [...claim.messages, ...this.#followUpQueue];
929
+ }
930
+ }
931
+ claim.controller.abort();
932
+ }
933
+
934
+ #restoreUndeliveredQueuedMessages(controller: AbortController): void {
935
+ if (this.#queuedMessageDeliveries.size === 0) return;
936
+ const restored: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
937
+ for (const delivery of this.#queuedMessageDeliveries) {
938
+ if (delivery.controller !== controller) continue;
939
+ this.#queuedMessageDeliveries.delete(delivery);
940
+ for (let i = delivery.next; i < delivery.messages.length; i++) {
941
+ restored[delivery.queue].push(delivery.messages[i]);
942
+ }
943
+ }
944
+ if (restored.steering.length > 0) {
945
+ this.#steeringQueue = [...restored.steering, ...this.#steeringQueue];
946
+ this.#notifySteeringWaiters();
947
+ }
948
+ if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
857
949
  }
858
950
 
859
951
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void {
@@ -988,11 +1080,18 @@ export class Agent {
988
1080
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
989
1081
  this.#steeringQueue = steering.slice();
990
1082
  this.#followUpQueue = followUp.slice();
1083
+ this.#cancelQueuedMessagePreparation("steering");
1084
+ this.#cancelQueuedMessagePreparation("followUp");
991
1085
  this.#notifySteeringWaiters();
992
1086
  }
993
1087
 
994
1088
  appendMessage(m: AgentMessage) {
995
1089
  this.#state.messages.push(m);
1090
+ for (const delivery of this.#queuedMessageDeliveries) {
1091
+ if (delivery.messages[delivery.next] !== m) continue;
1092
+ if (++delivery.next === delivery.messages.length) this.#queuedMessageDeliveries.delete(delivery);
1093
+ break;
1094
+ }
996
1095
  }
997
1096
 
998
1097
  popMessage(): AgentMessage | undefined {
@@ -1022,11 +1121,13 @@ export class Agent {
1022
1121
 
1023
1122
  clearSteeringQueue() {
1024
1123
  this.#steeringQueue = [];
1124
+ this.#cancelQueuedMessagePreparation("steering");
1025
1125
  this.#notifySteeringWaiters();
1026
1126
  }
1027
1127
 
1028
1128
  clearFollowUpQueue() {
1029
1129
  this.#followUpQueue = [];
1130
+ this.#cancelQueuedMessagePreparation("followUp");
1030
1131
  }
1031
1132
 
1032
1133
  /**
@@ -1041,26 +1142,36 @@ export class Agent {
1041
1142
  clearAllQueues() {
1042
1143
  this.#steeringQueue = [];
1043
1144
  this.#followUpQueue = [];
1145
+ this.#cancelQueuedMessagePreparation("steering");
1146
+ this.#cancelQueuedMessagePreparation("followUp");
1044
1147
  this.#notifySteeringWaiters();
1045
1148
  this.clearDeferredToolDirectives();
1046
1149
  }
1047
1150
 
1048
1151
  hasQueuedMessages(): boolean {
1049
- return this.#steeringQueue.length > 0 || this.#followUpQueue.length > 0;
1152
+ return (
1153
+ this.#steeringQueue.length > 0 ||
1154
+ this.#followUpQueue.length > 0 ||
1155
+ this.#queuedMessageClaims.steering !== undefined ||
1156
+ this.#queuedMessageClaims.followUp !== undefined
1157
+ );
1050
1158
  }
1051
1159
 
1052
1160
  /** Non-consuming view of the pending steering queue (insertion order, newest
1053
1161
  * last). The session layer derives its queued-message display/count from
1054
1162
  * this live view instead of a mirror, so the agent-core queue stays the
1055
- * single source of truth. */
1163
+ * single source of truth. Includes exclusively claimed originals while
1164
+ * preparation is pending, so editor restoration can cancel their delivery. */
1056
1165
  peekSteeringQueue(): readonly AgentMessage[] {
1057
- return this.#steeringQueue;
1166
+ const claim = this.#queuedMessageClaims.steering;
1167
+ return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
1058
1168
  }
1059
1169
 
1060
1170
  /** Non-consuming view of the pending follow-up queue. See
1061
1171
  * {@link peekSteeringQueue}. */
1062
1172
  peekFollowUpQueue(): readonly AgentMessage[] {
1063
- return this.#followUpQueue;
1173
+ const claim = this.#queuedMessageClaims.followUp;
1174
+ return claim ? [...claim.messages, ...this.#followUpQueue] : this.#followUpQueue;
1064
1175
  }
1065
1176
 
1066
1177
  /** Nonblocking snapshot of the latest results, including provisional payloads while transforms are pending. */
@@ -1107,6 +1218,7 @@ export class Agent {
1107
1218
  * Used by dequeue keybinding.
1108
1219
  */
1109
1220
  popLastSteer(): AgentMessage | undefined {
1221
+ if (this.#steeringQueue.length === 0) this.#cancelQueuedMessagePreparation("steering", true);
1110
1222
  return this.#steeringQueue.pop();
1111
1223
  }
1112
1224
 
@@ -1115,6 +1227,7 @@ export class Agent {
1115
1227
  * Used by dequeue keybinding.
1116
1228
  */
1117
1229
  popLastFollowUp(): AgentMessage | undefined {
1230
+ if (this.#followUpQueue.length === 0) this.#cancelQueuedMessagePreparation("followUp", true);
1118
1231
  return this.#followUpQueue.pop();
1119
1232
  }
1120
1233
 
@@ -1154,15 +1267,19 @@ export class Agent {
1154
1267
  }
1155
1268
 
1156
1269
  reset() {
1270
+ if (this.#queuedMessageClaims.steering || this.#queuedMessageClaims.followUp) {
1271
+ this.#abortController?.abort();
1272
+ this.#abortController = undefined;
1273
+ this.#resolveRunningPrompt?.();
1274
+ this.#runningPrompt = undefined;
1275
+ this.#resolveRunningPrompt = undefined;
1276
+ }
1157
1277
  this.#state.messages.length = 0;
1158
1278
  this.#state.isStreaming = false;
1159
1279
  this.#state.streamMessage = null;
1160
1280
  this.#state.pendingToolCalls.clear();
1161
1281
  this.#state.error = undefined;
1162
- this.#steeringQueue = [];
1163
- this.#followUpQueue = [];
1164
- this.#notifySteeringWaiters();
1165
- this.clearDeferredToolDirectives();
1282
+ this.clearAllQueues();
1166
1283
  }
1167
1284
 
1168
1285
  /** Send a prompt with an AgentMessage */
@@ -1250,7 +1367,7 @@ export class Agent {
1250
1367
  this.#state.error = undefined;
1251
1368
 
1252
1369
  try {
1253
- const dequeueSignal = this.#continuationDequeueSignal(signal);
1370
+ const dequeueSignal = this.#continuationDequeueSignal(signal) ?? continuationAbortController.signal;
1254
1371
  const messages = this.#state.messages;
1255
1372
  if (messages.length === 0) {
1256
1373
  // An empty transcript has nothing to resume, but a queued steer/follow-up
@@ -1260,11 +1377,13 @@ export class Agent {
1260
1377
  // microtask because hasQueuedMessages() never clears, spinning an unbounded
1261
1378
  // allocation loop until OOM (issue #6344).
1262
1379
  const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal);
1380
+ if (this.#abortController !== continuationAbortController) return;
1263
1381
  if (queuedSteering.length > 0) {
1264
1382
  await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true);
1265
1383
  return;
1266
1384
  }
1267
1385
  const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal);
1386
+ if (this.#abortController !== continuationAbortController) return;
1268
1387
  if (queuedFollowUp.length > 0) {
1269
1388
  await this.#runLoop(queuedFollowUp, undefined, signal, true);
1270
1389
  return;
@@ -1282,12 +1401,14 @@ export class Agent {
1282
1401
  return;
1283
1402
  }
1284
1403
  const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal);
1404
+ if (this.#abortController !== continuationAbortController) return;
1285
1405
  if (queuedSteering.length > 0) {
1286
1406
  await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true);
1287
1407
  return;
1288
1408
  }
1289
1409
 
1290
1410
  const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal);
1411
+ if (this.#abortController !== continuationAbortController) return;
1291
1412
  if (queuedFollowUp.length > 0) {
1292
1413
  await this.#runLoop(queuedFollowUp, undefined, signal, true);
1293
1414
  return;
@@ -1298,6 +1419,7 @@ export class Agent {
1298
1419
 
1299
1420
  await this.#runLoop(undefined, undefined, signal, true);
1300
1421
  } finally {
1422
+ this.#restoreUndeliveredQueuedMessages(continuationAbortController);
1301
1423
  resolve();
1302
1424
  if (this.#abortController === continuationAbortController) {
1303
1425
  this.#state.isStreaming = false;
@@ -1513,7 +1635,7 @@ export class Agent {
1513
1635
  skipInitialSteeringPoll = false;
1514
1636
  return [];
1515
1637
  }
1516
- return this.#dequeueSteeringMessagesAfterHooks(signal);
1638
+ return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
1517
1639
  },
1518
1640
  hasSteeringMessages: () => {
1519
1641
  if (this.#steeringQueue.length === 0) {
@@ -1538,7 +1660,7 @@ export class Agent {
1538
1660
  },
1539
1661
  waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1540
1662
  hasIrcInterrupts: this.hasIrcInterrupts,
1541
- getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal),
1663
+ getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1542
1664
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1543
1665
  onBeforeYield: () => this.#onBeforeYield?.(),
1544
1666
  telemetry: this.#telemetry,
@@ -1554,6 +1676,7 @@ export class Agent {
1554
1676
  : agentLoopContinue(context, config, loopSignal, this.streamFn);
1555
1677
 
1556
1678
  for await (const event of stream) {
1679
+ if (this.#abortController !== loopAbortController) return;
1557
1680
  if (event.type === "turn_start") turnOpen = true;
1558
1681
  if (event.type === "turn_end") turnOpen = false;
1559
1682
  // Update internal state based on events
@@ -1624,6 +1747,7 @@ export class Agent {
1624
1747
  }
1625
1748
  }
1626
1749
  } catch (err) {
1750
+ if (this.#abortController !== loopAbortController) return;
1627
1751
  const stoppedForAbort = loopSignal.aborted;
1628
1752
  const errorMessage = stoppedForAbort
1629
1753
  ? abortReasonText(loopSignal)
@@ -1728,6 +1852,7 @@ export class Agent {
1728
1852
  this.#emit({ type: "agent_end", messages: [errorMsg] });
1729
1853
  }
1730
1854
  } finally {
1855
+ this.#restoreUndeliveredQueuedMessages(loopAbortController);
1731
1856
  resolveRun?.();
1732
1857
  if (this.#abortController === loopAbortController) {
1733
1858
  this.#state.isStreaming = false;