@linxiraos/pi-agent-core 1.1.14 → 1.1.16

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.ts CHANGED
@@ -50,6 +50,8 @@ import type {
50
50
  AgentToolContext,
51
51
  AgentTurnEndContext,
52
52
  AsideMessage,
53
+ PrepareQueuedMessages,
54
+ SpeculativeToolExecutionConfig,
53
55
  StreamFn,
54
56
  ToolCallContext,
55
57
  ToolChoiceDirective,
@@ -242,13 +244,22 @@ export interface AgentOptions {
242
244
  * Use for deobfuscating secrets or rewriting arguments.
243
245
  */
244
246
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
247
+ /** Host authorization and telemetry for opt-in speculative tool execution. */
248
+ speculativeToolExecution?: SpeculativeToolExecutionConfig;
245
249
 
246
250
  /**
247
251
  * Resolve a tool call whose name matched no advertised tool. Lets hosts
248
252
  * route calls to tools exposed through side transports (e.g. `xd://`
249
253
  * device mounts) instead of failing with "Tool not found".
250
254
  */
251
- resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
255
+ resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
256
+
257
+ /**
258
+ * Names routable by {@link resolveFallbackTool} that the advertised set
259
+ * omits (e.g. `xd://` device mounts), used only to suggest a target when a
260
+ * call misses.
261
+ */
262
+ suggestFallbackToolNames?: () => Iterable<string>;
252
263
 
253
264
  /** Enable intent tracing schema injection/stripping in the harness. */
254
265
  intentTracing?: boolean;
@@ -352,6 +363,13 @@ interface CursorToolResultEntry {
352
363
  pending?: Promise<void>;
353
364
  }
354
365
 
366
+ type QueuedMessageQueue = "steering" | "followUp";
367
+
368
+ interface QueuedMessageClaim {
369
+ messages: AgentMessage[];
370
+ controller: AbortController;
371
+ }
372
+
355
373
  export class Agent {
356
374
  #state: AgentState = {
357
375
  systemPrompt: [],
@@ -373,6 +391,14 @@ export class Agent {
373
391
  #transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
374
392
  #steeringQueue: AgentMessage[] = [];
375
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
+ }>();
376
402
  #steeringWaiters = new Set<() => void>();
377
403
 
378
404
  #steeringMode: "all" | "one-at-a-time";
@@ -407,7 +433,9 @@ export class Agent {
407
433
  #kimiApiFormat?: "openai" | "anthropic";
408
434
  #preferWebsockets?: boolean;
409
435
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
410
- #resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
436
+ #speculativeToolExecution?: SpeculativeToolExecutionConfig;
437
+ #resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
438
+ #suggestFallbackToolNames?: () => Iterable<string>;
411
439
  #intentTracing: boolean;
412
440
  #pruneToolDescriptions: boolean;
413
441
  #dialect?: Dialect;
@@ -433,9 +461,12 @@ export class Agent {
433
461
 
434
462
  /** Buffered Cursor tool results with text length at time of call (for correct ordering) */
435
463
  #cursorToolResultBuffer: CursorToolResultEntry[] = [];
464
+ #cursorToolResultDrain: CursorToolResultEntry[] | undefined;
436
465
 
437
466
  streamFn: StreamFn;
438
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;
439
470
  /**
440
471
  * Hook invoked after tool arguments are validated and before execution.
441
472
  * Reassign at any time to swap the implementation (e.g. on extension reload).
@@ -496,7 +527,9 @@ export class Agent {
496
527
  this.#kimiApiFormat = opts.kimiApiFormat;
497
528
  this.#preferWebsockets = opts.preferWebsockets;
498
529
  this.#transformToolCallArguments = opts.transformToolCallArguments;
530
+ this.#speculativeToolExecution = opts.speculativeToolExecution;
499
531
  this.#resolveFallbackTool = opts.resolveFallbackTool;
532
+ this.#suggestFallbackToolNames = opts.suggestFallbackToolNames;
500
533
  this.#intentTracing = opts.intentTracing === true;
501
534
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
502
535
  this.#dialect = opts.dialect;
@@ -829,16 +862,90 @@ export class Agent {
829
862
  for (const hook of this.#beforeQueuedMessageDequeueHooks) await hook(signal);
830
863
  }
831
864
 
832
- async #dequeueSteeringMessagesAfterHooks(signal?: AbortSignal): Promise<AgentMessage[]> {
833
- if (signal?.aborted || this.#steeringQueue.length === 0) return [];
865
+ async #dequeueSteeringMessagesAfterHooks(signal: AbortSignal): Promise<AgentMessage[]> {
866
+ if (signal.aborted || this.#steeringQueue.length === 0) return [];
834
867
  await this.#runBeforeQueuedMessageDequeueHooks(signal);
835
- return signal?.aborted ? [] : this.#dequeueSteeringMessages();
868
+ return signal.aborted ? [] : this.#prepareQueuedMessageBatch("steering", signal);
836
869
  }
837
870
 
838
- async #dequeueFollowUpMessagesAfterHooks(signal?: AbortSignal): Promise<AgentMessage[]> {
839
- if (signal?.aborted || this.#followUpQueue.length === 0) return [];
871
+ async #dequeueFollowUpMessagesAfterHooks(signal: AbortSignal): Promise<AgentMessage[]> {
872
+ if (signal.aborted || this.#followUpQueue.length === 0) return [];
840
873
  await this.#runBeforeQueuedMessageDequeueHooks(signal);
841
- 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];
842
949
  }
843
950
 
844
951
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void {
@@ -973,11 +1080,18 @@ export class Agent {
973
1080
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
974
1081
  this.#steeringQueue = steering.slice();
975
1082
  this.#followUpQueue = followUp.slice();
1083
+ this.#cancelQueuedMessagePreparation("steering");
1084
+ this.#cancelQueuedMessagePreparation("followUp");
976
1085
  this.#notifySteeringWaiters();
977
1086
  }
978
1087
 
979
1088
  appendMessage(m: AgentMessage) {
980
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
+ }
981
1095
  }
982
1096
 
983
1097
  popMessage(): AgentMessage | undefined {
@@ -1007,11 +1121,13 @@ export class Agent {
1007
1121
 
1008
1122
  clearSteeringQueue() {
1009
1123
  this.#steeringQueue = [];
1124
+ this.#cancelQueuedMessagePreparation("steering");
1010
1125
  this.#notifySteeringWaiters();
1011
1126
  }
1012
1127
 
1013
1128
  clearFollowUpQueue() {
1014
1129
  this.#followUpQueue = [];
1130
+ this.#cancelQueuedMessagePreparation("followUp");
1015
1131
  }
1016
1132
 
1017
1133
  /**
@@ -1026,26 +1142,43 @@ export class Agent {
1026
1142
  clearAllQueues() {
1027
1143
  this.#steeringQueue = [];
1028
1144
  this.#followUpQueue = [];
1145
+ this.#cancelQueuedMessagePreparation("steering");
1146
+ this.#cancelQueuedMessagePreparation("followUp");
1029
1147
  this.#notifySteeringWaiters();
1030
1148
  this.clearDeferredToolDirectives();
1031
1149
  }
1032
1150
 
1033
1151
  hasQueuedMessages(): boolean {
1034
- 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
+ );
1035
1158
  }
1036
1159
 
1037
1160
  /** Non-consuming view of the pending steering queue (insertion order, newest
1038
1161
  * last). The session layer derives its queued-message display/count from
1039
1162
  * this live view instead of a mirror, so the agent-core queue stays the
1040
- * 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. */
1041
1165
  peekSteeringQueue(): readonly AgentMessage[] {
1042
- return this.#steeringQueue;
1166
+ const claim = this.#queuedMessageClaims.steering;
1167
+ return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
1043
1168
  }
1044
1169
 
1045
1170
  /** Non-consuming view of the pending follow-up queue. See
1046
1171
  * {@link peekSteeringQueue}. */
1047
1172
  peekFollowUpQueue(): readonly AgentMessage[] {
1048
- return this.#followUpQueue;
1173
+ const claim = this.#queuedMessageClaims.followUp;
1174
+ return claim ? [...claim.messages, ...this.#followUpQueue] : this.#followUpQueue;
1175
+ }
1176
+
1177
+ /** Nonblocking snapshot of the latest results, including provisional payloads while transforms are pending. */
1178
+ getPendingToolResults(): readonly ToolResultMessage[] {
1179
+ const results = this.#cursorToolResultDrain?.map(({ toolResult }) => toolResult) ?? [];
1180
+ for (const { toolResult } of this.#cursorToolResultBuffer) results.push(toolResult);
1181
+ return results;
1049
1182
  }
1050
1183
 
1051
1184
  get isAborting(): boolean {
@@ -1085,6 +1218,7 @@ export class Agent {
1085
1218
  * Used by dequeue keybinding.
1086
1219
  */
1087
1220
  popLastSteer(): AgentMessage | undefined {
1221
+ if (this.#steeringQueue.length === 0) this.#cancelQueuedMessagePreparation("steering", true);
1088
1222
  return this.#steeringQueue.pop();
1089
1223
  }
1090
1224
 
@@ -1093,6 +1227,7 @@ export class Agent {
1093
1227
  * Used by dequeue keybinding.
1094
1228
  */
1095
1229
  popLastFollowUp(): AgentMessage | undefined {
1230
+ if (this.#followUpQueue.length === 0) this.#cancelQueuedMessagePreparation("followUp", true);
1096
1231
  return this.#followUpQueue.pop();
1097
1232
  }
1098
1233
 
@@ -1132,15 +1267,19 @@ export class Agent {
1132
1267
  }
1133
1268
 
1134
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
+ }
1135
1277
  this.#state.messages.length = 0;
1136
1278
  this.#state.isStreaming = false;
1137
1279
  this.#state.streamMessage = null;
1138
1280
  this.#state.pendingToolCalls.clear();
1139
1281
  this.#state.error = undefined;
1140
- this.#steeringQueue = [];
1141
- this.#followUpQueue = [];
1142
- this.#notifySteeringWaiters();
1143
- this.clearDeferredToolDirectives();
1282
+ this.clearAllQueues();
1144
1283
  }
1145
1284
 
1146
1285
  /** Send a prompt with an AgentMessage */
@@ -1228,7 +1367,7 @@ export class Agent {
1228
1367
  this.#state.error = undefined;
1229
1368
 
1230
1369
  try {
1231
- const dequeueSignal = this.#continuationDequeueSignal(signal);
1370
+ const dequeueSignal = this.#continuationDequeueSignal(signal) ?? continuationAbortController.signal;
1232
1371
  const messages = this.#state.messages;
1233
1372
  if (messages.length === 0) {
1234
1373
  // An empty transcript has nothing to resume, but a queued steer/follow-up
@@ -1238,11 +1377,13 @@ export class Agent {
1238
1377
  // microtask because hasQueuedMessages() never clears, spinning an unbounded
1239
1378
  // allocation loop until OOM (issue #6344).
1240
1379
  const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal);
1380
+ if (this.#abortController !== continuationAbortController) return;
1241
1381
  if (queuedSteering.length > 0) {
1242
1382
  await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true);
1243
1383
  return;
1244
1384
  }
1245
1385
  const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal);
1386
+ if (this.#abortController !== continuationAbortController) return;
1246
1387
  if (queuedFollowUp.length > 0) {
1247
1388
  await this.#runLoop(queuedFollowUp, undefined, signal, true);
1248
1389
  return;
@@ -1260,12 +1401,14 @@ export class Agent {
1260
1401
  return;
1261
1402
  }
1262
1403
  const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal);
1404
+ if (this.#abortController !== continuationAbortController) return;
1263
1405
  if (queuedSteering.length > 0) {
1264
1406
  await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true);
1265
1407
  return;
1266
1408
  }
1267
1409
 
1268
1410
  const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal);
1411
+ if (this.#abortController !== continuationAbortController) return;
1269
1412
  if (queuedFollowUp.length > 0) {
1270
1413
  await this.#runLoop(queuedFollowUp, undefined, signal, true);
1271
1414
  return;
@@ -1276,6 +1419,7 @@ export class Agent {
1276
1419
 
1277
1420
  await this.#runLoop(undefined, undefined, signal, true);
1278
1421
  } finally {
1422
+ this.#restoreUndeliveredQueuedMessages(continuationAbortController);
1279
1423
  resolve();
1280
1424
  if (this.#abortController === continuationAbortController) {
1281
1425
  this.#state.isStreaming = false;
@@ -1461,7 +1605,9 @@ export class Agent {
1461
1605
  cwd: this.#cwd,
1462
1606
  getCwd: this.#cwdResolver,
1463
1607
  transformToolCallArguments: this.#transformToolCallArguments,
1608
+ speculativeToolExecution: this.#speculativeToolExecution,
1464
1609
  resolveFallbackTool: this.#resolveFallbackTool,
1610
+ suggestFallbackToolNames: this.#suggestFallbackToolNames,
1465
1611
  intentTracing: this.#intentTracing,
1466
1612
  pruneToolDescriptions: this.#pruneToolDescriptions,
1467
1613
  dialect: this.#dialect,
@@ -1489,7 +1635,7 @@ export class Agent {
1489
1635
  skipInitialSteeringPoll = false;
1490
1636
  return [];
1491
1637
  }
1492
- return this.#dequeueSteeringMessagesAfterHooks(signal);
1638
+ return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
1493
1639
  },
1494
1640
  hasSteeringMessages: () => {
1495
1641
  if (this.#steeringQueue.length === 0) {
@@ -1514,7 +1660,7 @@ export class Agent {
1514
1660
  },
1515
1661
  waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1516
1662
  hasIrcInterrupts: this.hasIrcInterrupts,
1517
- getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal),
1663
+ getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1518
1664
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1519
1665
  onBeforeYield: () => this.#onBeforeYield?.(),
1520
1666
  telemetry: this.#telemetry,
@@ -1530,6 +1676,7 @@ export class Agent {
1530
1676
  : agentLoopContinue(context, config, loopSignal, this.streamFn);
1531
1677
 
1532
1678
  for await (const event of stream) {
1679
+ if (this.#abortController !== loopAbortController) return;
1533
1680
  if (event.type === "turn_start") turnOpen = true;
1534
1681
  if (event.type === "turn_end") turnOpen = false;
1535
1682
  // Update internal state based on events
@@ -1600,6 +1747,7 @@ export class Agent {
1600
1747
  }
1601
1748
  }
1602
1749
  } catch (err) {
1750
+ if (this.#abortController !== loopAbortController) return;
1603
1751
  const stoppedForAbort = loopSignal.aborted;
1604
1752
  const errorMessage = stoppedForAbort
1605
1753
  ? abortReasonText(loopSignal)
@@ -1704,6 +1852,7 @@ export class Agent {
1704
1852
  this.#emit({ type: "agent_end", messages: [errorMsg] });
1705
1853
  }
1706
1854
  } finally {
1855
+ this.#restoreUndeliveredQueuedMessages(loopAbortController);
1707
1856
  resolveRun?.();
1708
1857
  if (this.#abortController === loopAbortController) {
1709
1858
  this.#state.isStreaming = false;
@@ -1751,29 +1900,25 @@ export class Agent {
1751
1900
  * multi-text turns, producing duplicated text on replay.
1752
1901
  */
1753
1902
  async #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): Promise<void> {
1754
- // Snapshot and detach immediately so a still-pending `cursorOnToolResult`
1755
- // cannot push into a drained buffer. Entries already reserved stay paired
1756
- // with their toolCall.
1757
1903
  const buffer = this.#cursorToolResultBuffer;
1758
1904
  this.#cursorToolResultBuffer = [];
1905
+ this.#cursorToolResultDrain = buffer;
1906
+ try {
1907
+ // Keep the detached batch observable while its transforms finish.
1908
+ const pending = buffer.filter(entry => entry.pending !== undefined).map(entry => entry.pending);
1909
+ if (pending.length > 0) await Promise.all(pending);
1759
1910
 
1760
- // Await any transformer still running for a reserved entry before reading
1761
- // its payload. The provider dispatches with `void handleServerMessage(…)`,
1762
- // so a `message_end` from the same chunk can reach this point while a
1763
- // transformer is mid-flight; without the await its rewrite would land on
1764
- // the detached entry after the original was already appended and emitted.
1765
- // Each `pending` swallows its own rejection, so this cannot throw.
1766
- const pending = buffer.filter(entry => entry.pending !== undefined).map(entry => entry.pending);
1767
- if (pending.length > 0) await Promise.all(pending);
1768
-
1769
- this.#state.streamMessage = null;
1770
- this.appendMessage(assistantMessage);
1771
- this.#emit({ type: "message_end", message: assistantMessage });
1911
+ this.#state.streamMessage = null;
1912
+ this.appendMessage(assistantMessage);
1913
+ this.#emit({ type: "message_end", message: assistantMessage });
1772
1914
 
1773
- for (const { toolResult } of buffer) {
1774
- this.#emit({ type: "message_start", message: toolResult });
1775
- this.appendMessage(toolResult);
1776
- this.#emit({ type: "message_end", message: toolResult });
1915
+ for (const { toolResult } of buffer) {
1916
+ this.#emit({ type: "message_start", message: toolResult });
1917
+ this.appendMessage(toolResult);
1918
+ this.#emit({ type: "message_end", message: toolResult });
1919
+ }
1920
+ } finally {
1921
+ this.#cursorToolResultDrain = undefined;
1777
1922
  }
1778
1923
  }
1779
1924
  }
@@ -14,7 +14,9 @@
14
14
  * message delta is a cache miss each turn.
15
15
  */
16
16
 
17
+ import type { AgentTool } from "./types";
17
18
  import type { Context, Message, Tool } from "@linxiraos/pi-ai";
19
+ import { toolWireSchema } from "@linxiraos/pi-ai";
18
20
  import { normalizeTools } from "./agent-loop";
19
21
  import { messageEstimateVersion } from "./compaction/message-cache";
20
22
  import type { AgentContext, AgentMessage } from "./types";
@@ -23,6 +25,52 @@ import type { AgentContext, AgentMessage } from "./types";
23
25
  // StablePrefix (formerly ImmutablePrefix)
24
26
  // ---------------------------------------------------------------------------
25
27
 
28
+ /**
29
+ * Cheap per-tool wire-identity key: every field normalizeTools and
30
+ * computeFingerprint read. The resolved parameters contribute by object
31
+ * identity — the wire-schema memo is stamp-keyed on the parameters object,
32
+ * so identity equals normalized bytes (modulo intent injection, a pure
33
+ * function of the mode flags compared separately). `customFormat` and
34
+ * `examples` contribute by reference: both are treated as immutable config
35
+ * (a registry swap replaces the object), matching how the snapshot consumes
36
+ * them. `intent` functions are compared by reference — same closure means
37
+ * same mode resolution.
38
+ */
39
+ function toolKeyForPrefix(tool: AgentTool): string {
40
+ const params = toolWireSchema(tool);
41
+ const customFormat = tool.customFormat;
42
+ const examples = (tool as { examples?: unknown }).examples;
43
+ // Identity strings for objects (Map-assigned ids, no string-build cost
44
+ // on the hot path beyond the first sighting per object).
45
+ return [
46
+ tool.name ?? "",
47
+ tool.description ?? "",
48
+ String(tool.strict ?? ""),
49
+ tool.customWireName ?? "",
50
+ typeof tool.intent === "function" ? `fn:${objectId(tool.intent)}` : `mode:${tool.intent ?? "require"}`,
51
+ objectId(params),
52
+ objectId(customFormat),
53
+ objectId(examples),
54
+ ].join("\u0000");
55
+ }
56
+
57
+ const objectIds = new WeakMap<object, number>();
58
+ let nextObjectId = 1;
59
+
60
+ function objectId(obj: unknown): number {
61
+ if (obj === null || obj === undefined || (typeof obj !== "object" && typeof obj !== "function")) {
62
+ // Primitives contribute by value below via String(); the id path is
63
+ // only for reference-identity comparison of config objects.
64
+ return 0;
65
+ }
66
+ let id = objectIds.get(obj);
67
+ if (id === undefined) {
68
+ id = nextObjectId++;
69
+ objectIds.set(obj, id);
70
+ }
71
+ return id;
72
+ }
73
+
26
74
  /** Frozen system prompt + tool spec snapshot. */
27
75
  export interface StablePrefixSnapshot {
28
76
  systemPrompt: string[];
@@ -63,10 +111,42 @@ export class StablePrefix {
63
111
  /**
64
112
  * Build or rebuild from live context.
65
113
  * Returns `true` if the prefix actually changed (cache miss imminent).
114
+ *
115
+ * Steady-state fast path: when the live prompt reference is unchanged
116
+ * AND every tool resolves to the same normalized parameters identity as
117
+ * last build, the fingerprint cannot have changed, so the full snapshot
118
+ * + stringify is skipped. Comparing resolved parameters (not the tool
119
+ * container) is load-bearing: tools like ReadTool expose `parameters` as
120
+ * a getter over live settings (`skillful`, `memory.backend`), so the
121
+ * schema can swap under stable tool references when a setting toggles.
122
+ * Any other in-place mutation must go through `invalidate()`.
66
123
  */
124
+ #lastPrompt: readonly string[] | undefined;
125
+ // Joined prompt bytes snapshot: the prompt array is caller-owned and
126
+ // mutable in place (Agent.setSystemPrompt stores the caller's array;
127
+ // anyone holding it can push/splice), so reference equality alone cannot
128
+ // prove the bytes are unchanged. Compared by value on the fast path.
129
+ #lastPromptText: string | undefined;
130
+ // Per-tool wire-identity snapshot: every field normalizeTools and
131
+ // computeFingerprint read (name, description, resolved parameters,
132
+ // strict, customFormat, customWireName, intent mode, examples
133
+ // reference). A registry swap that keeps name/description/parameters
134
+ // but changes any of these must miss the fast path.
135
+ #lastToolKey: readonly string[] | undefined;
136
+ #lastIntentTracing: boolean | undefined;
137
+ #lastPruneToolDescriptions: boolean | undefined;
138
+
67
139
  build(context: AgentContext, options: BuildOptions): boolean {
140
+ const prev = this.#snapshot;
141
+ if (prev !== null && this.#fastPathHit(context, options)) {
142
+ return false;
143
+ }
68
144
  const snapshot = takeSnapshot(context, options);
69
- if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
145
+ this.#recordFastPathKey(context, options, snapshot.tools);
146
+ if (prev && prev.fingerprint === snapshot.fingerprint) {
147
+ // Identity changed but bytes did not (e.g. equivalent rebuild):
148
+ // keep serving the cached snapshot so downstream memo identity
149
+ // (stamp-keyed schema caches) stays stable.
70
150
  return false;
71
151
  }
72
152
  this.#snapshot = snapshot;
@@ -74,9 +154,50 @@ export class StablePrefix {
74
154
  return true;
75
155
  }
76
156
 
157
+ /** True when the cheap key matches: prompt + per-tool resolved identity. */
158
+ #fastPathHit(context: AgentContext, options: BuildOptions): boolean {
159
+ if (
160
+ this.#lastIntentTracing !== options.intentTracing ||
161
+ this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
162
+ ) {
163
+ return false;
164
+ }
165
+ // Prompt by reference first (steady state), then by joined bytes so
166
+ // an in-place push/splice of the same array still misses.
167
+ if (this.#lastPrompt !== context.systemPrompt) return false;
168
+ if (this.#lastPromptText !== undefined) {
169
+ const text = context.systemPrompt.join("\u0000");
170
+ if (text !== this.#lastPromptText) return false;
171
+ }
172
+ const tools = context.tools ?? [];
173
+ if (this.#lastToolKey === undefined || this.#lastToolKey.length !== tools.length) {
174
+ return false;
175
+ }
176
+ for (let i = 0; i < tools.length; i++) {
177
+ if (this.#lastToolKey[i] !== toolKeyForPrefix(tools[i]!)) {
178
+ return false;
179
+ }
180
+ }
181
+ return true;
182
+ }
183
+
184
+ #recordFastPathKey(context: AgentContext, options: BuildOptions, normalized: Tool[]): void {
185
+ this.#lastPrompt = context.systemPrompt;
186
+ this.#lastPromptText = context.systemPrompt.join("\u0000");
187
+ this.#lastIntentTracing = options.intentTracing;
188
+ this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
189
+ this.#lastToolKey = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
190
+ void normalized;
191
+ }
192
+
77
193
  /** Force rebuild on the next `build()` call. */
78
194
  invalidate(): void {
79
195
  this.#snapshot = null;
196
+ this.#lastPrompt = undefined;
197
+ this.#lastPromptText = undefined;
198
+ this.#lastToolKey = undefined;
199
+ this.#lastIntentTracing = undefined;
200
+ this.#lastPruneToolDescriptions = undefined;
80
201
  }
81
202
 
82
203
  /**