@oh-my-pi/pi-agent-core 15.10.4 → 15.10.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.5] - 2026-06-08
6
+ ### Removed
7
+
8
+ - Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls
9
+
10
+ ### Fixed
11
+
12
+ - Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly
13
+ - Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies
14
+ - Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves.
15
+ - Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures.
16
+ - Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block
17
+ - Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying
18
+ - Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors
19
+ - Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results
20
+ - Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay
21
+ - Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls
22
+
5
23
  ## [15.10.3] - 2026-06-08
6
24
 
7
25
  ### Added
@@ -31,11 +31,6 @@ export interface AgentOptions {
31
31
  * - "wait": defer steering until the current turn completes
32
32
  */
33
33
  interruptMode?: "immediate" | "wait";
34
- /**
35
- * Maximum completed tool calls to accept from one streamed assistant turn before
36
- * executing the batch. Undefined disables batching.
37
- */
38
- maxToolCallsPerTurn?: number;
39
34
  /**
40
35
  * API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic")
41
36
  */
@@ -281,8 +276,6 @@ export declare class Agent {
281
276
  * Set to 0 to disable the cap.
282
277
  */
283
278
  set maxRetryDelayMs(value: number | undefined);
284
- get maxToolCallsPerTurn(): number | undefined;
285
- set maxToolCallsPerTurn(value: number | undefined);
286
279
  get state(): AgentState;
287
280
  get appendOnlyContext(): AppendOnlyContextManager | undefined;
288
281
  setAppendOnlyContext(manager?: AppendOnlyContextManager): void;
@@ -527,7 +527,8 @@ export declare function finishInvokeAgentSpan(telemetry: AgentTelemetry | undefi
527
527
  } | undefined;
528
528
  /**
529
529
  * Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
530
- are caught and logged via `console.warn` telemetry callbacks NEVER turn a
530
+ * are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn`
531
+ * when no hook is set) — telemetry callbacks NEVER turn a
531
532
  * successful agent run into a failed one. Idempotent at the call site via
532
533
  * {@link AgentRunCollector.markRunEnded}; callers must check that before
533
534
  * calling this helper.
@@ -23,13 +23,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
23
23
  * - "wait" = defer steering until the current turn completes
24
24
  */
25
25
  interruptMode?: "immediate" | "wait";
26
- /**
27
- * Maximum completed tool calls to accept from one streamed assistant turn before
28
- * cutting the provider stream and executing that batch. The cap is enforced on
29
- * `toolcall_end` so every executed call has complete arguments. Undefined disables
30
- * batching.
31
- */
32
- maxToolCallsPerTurn?: number;
33
26
  /**
34
27
  * Optional session identifier forwarded to LLM providers.
35
28
  * Used by providers that support session-based caching (e.g., OpenAI Codex).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "15.10.4",
4
+ "version": "15.10.6",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.10.4",
39
- "@oh-my-pi/pi-natives": "15.10.4",
40
- "@oh-my-pi/pi-utils": "15.10.4",
38
+ "@oh-my-pi/pi-ai": "15.10.6",
39
+ "@oh-my-pi/pi-natives": "15.10.6",
40
+ "@oh-my-pi/pi-utils": "15.10.6",
41
41
  "@opentelemetry/api": "^1.9.1"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type HarmonyDetection,
24
24
  type HarmonyRecoveredToolCall,
25
25
  isHarmonyLeakMitigationTarget,
26
+ recoverHarmonyToolCall,
26
27
  signalListLabel,
27
28
  } from "./harmony-leak";
28
29
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
@@ -68,6 +69,76 @@ class HarmonyLeakInterruption extends Error {
68
69
  }
69
70
  }
70
71
 
72
+ type AssistantContentBlock = AssistantMessage["content"][number];
73
+ type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
74
+ type CloneableRecord = Record<string, unknown>;
75
+
76
+ function cloneUnknown(value: unknown): unknown {
77
+ if (Array.isArray(value)) return value.map(cloneUnknown);
78
+ if (!value || typeof value !== "object") return value;
79
+ const source = value as CloneableRecord;
80
+ const out: CloneableRecord = {};
81
+ for (const [key, child] of Object.entries(source)) {
82
+ out[key] = cloneUnknown(child);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] {
88
+ return cloneUnknown(args) as AssistantToolCallBlock["arguments"];
89
+ }
90
+
91
+ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
92
+ switch (block.type) {
93
+ case "text":
94
+ return { ...block };
95
+ case "thinking":
96
+ return { ...block };
97
+ case "redactedThinking":
98
+ return { ...block };
99
+ case "toolCall":
100
+ return { ...block, arguments: cloneToolArguments(block.arguments) };
101
+ }
102
+ }
103
+
104
+ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
105
+ return {
106
+ ...message,
107
+ content: message.content.map(snapshotAssistantContentBlock),
108
+ usage: {
109
+ ...message.usage,
110
+ cost: { ...message.usage.cost },
111
+ },
112
+ disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
113
+ };
114
+ }
115
+
116
+ function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent {
117
+ switch (event.type) {
118
+ case "start":
119
+ return { ...event, partial: snapshotAssistantMessage(event.partial) };
120
+ case "text_start":
121
+ case "text_delta":
122
+ case "text_end":
123
+ case "thinking_start":
124
+ case "thinking_delta":
125
+ case "thinking_end":
126
+ case "toolcall_start":
127
+ case "toolcall_delta":
128
+ return { ...event, partial: snapshotAssistantMessage(event.partial) };
129
+ case "toolcall_end":
130
+ return {
131
+ ...event,
132
+ toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
133
+ partial: snapshotAssistantMessage(event.partial),
134
+ };
135
+ case "done":
136
+ return { ...event, message: snapshotAssistantMessage(event.message) };
137
+ case "error":
138
+ return { ...event, error: snapshotAssistantMessage(event.error) };
139
+ }
140
+ }
141
+
71
142
  /**
72
143
  * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
73
144
  * into a structurally valid {@link AgentToolResult}.
@@ -77,7 +148,7 @@ class HarmonyLeakInterruption extends Error {
77
148
  * (missing `content` array → crash on reload). We coerce at the single boundary where untyped
78
149
  * results enter the agent loop, so every downstream consumer can rely on the type.
79
150
  */
80
- function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malformed: boolean } {
151
+ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; malformed: boolean } {
81
152
  const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
82
153
  const rawContent = rawObj?.content;
83
154
  const details = rawObj && "details" in rawObj ? rawObj.details : {};
@@ -98,8 +169,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
98
169
  }
99
170
 
100
171
  const content: AgentToolResult["content"] = [];
172
+ let invalidBlocks = 0;
101
173
  for (const block of rawContent) {
102
- if (!block || typeof block !== "object" || !("type" in block)) continue;
174
+ if (!block || typeof block !== "object" || !("type" in block)) {
175
+ invalidBlocks++;
176
+ continue;
177
+ }
103
178
  if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
104
179
  content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
105
180
  } else if (
@@ -108,9 +183,20 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
108
183
  typeof (block as { mimeType?: unknown }).mimeType === "string"
109
184
  ) {
110
185
  content.push(block as { type: "image"; data: string; mimeType: string });
186
+ } else {
187
+ invalidBlocks++;
111
188
  }
112
189
  }
113
- return { result: { content, details, ...(explicitError ? { isError: true } : {}) }, malformed: false };
190
+ if (invalidBlocks > 0) {
191
+ content.push({
192
+ type: "text",
193
+ text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
194
+ });
195
+ }
196
+ return {
197
+ result: { content, details, ...(explicitError || invalidBlocks > 0 ? { isError: true } : {}) },
198
+ malformed: invalidBlocks > 0,
199
+ };
114
200
  }
115
201
 
116
202
  /**
@@ -176,7 +262,7 @@ export function agentLoopContinue(
176
262
 
177
263
  (async () => {
178
264
  const newMessages: AgentMessage[] = [];
179
- const currentContext: AgentContext = { ...context };
265
+ const currentContext: AgentContext = { ...context, messages: [...context.messages] };
180
266
 
181
267
  stream.push({ type: "agent_start" });
182
268
  stream.push({ type: "turn_start" });
@@ -313,22 +399,26 @@ function normalizeMessagesForProvider(
313
399
  return messages;
314
400
  }
315
401
 
316
- let changed = false;
317
- const normalized = messages.map(message => {
318
- if (message.role !== "assistant" || !Array.isArray(message.content)) {
319
- return message;
402
+ let hasThinking = false;
403
+ for (const message of messages) {
404
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
405
+ for (const block of message.content) {
406
+ if (block.type === "thinking") {
407
+ hasThinking = true;
408
+ break;
409
+ }
320
410
  }
411
+ if (hasThinking) break;
412
+ }
413
+ if (!hasThinking) return messages;
321
414
 
322
- const filtered = message.content.filter(block => block.type !== "thinking");
323
- if (filtered.length === message.content.length) {
415
+ return messages.map(message => {
416
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
324
417
  return message;
325
418
  }
326
-
327
- changed = true;
328
- return { ...message, content: filtered };
419
+ const filtered = message.content.filter(block => block.type !== "thinking");
420
+ return filtered.length === message.content.length ? message : { ...message, content: filtered };
329
421
  });
330
-
331
- return changed ? normalized : messages;
332
422
  }
333
423
 
334
424
  export const INTENT_FIELD = "_i";
@@ -445,27 +535,6 @@ interface StepCounter {
445
535
  count: number;
446
536
  }
447
537
 
448
- function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undefined {
449
- if (value === undefined || !Number.isFinite(value)) return undefined;
450
- const normalized = Math.trunc(value);
451
- return normalized > 0 ? normalized : undefined;
452
- }
453
-
454
- function cloneAssistantMessageForToolCallCap(message: AssistantMessage): AssistantMessage {
455
- return {
456
- ...message,
457
- content: message.content.map(block => {
458
- if (block.type === "toolCall") {
459
- return { ...block, arguments: structuredClone(block.arguments) };
460
- }
461
- return { ...block };
462
- }),
463
- stopReason: "toolUse",
464
- errorMessage: undefined,
465
- errorStatus: undefined,
466
- };
467
- }
468
-
469
538
  /**
470
539
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
471
540
  * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -573,6 +642,12 @@ async function runLoopBody(
573
642
  continue;
574
643
  }
575
644
  }
645
+ if (recovered) {
646
+ message = snapshotAssistantMessage(message);
647
+ currentContext.messages.push(message);
648
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
649
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
650
+ }
576
651
  newMessages.push(message);
577
652
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
578
653
 
@@ -661,13 +736,24 @@ async function runLoopBody(
661
736
  status: "skipped",
662
737
  });
663
738
  }
739
+ if (message.stopReason === "length" && toolResults.length > 0) {
740
+ hasMoreToolCalls = true;
741
+ }
664
742
  }
665
743
 
666
744
  stream.push({ type: "turn_end", message, toolResults });
667
745
 
668
746
  const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
669
- const asides = resolveAsides(await config.getAsideMessages?.());
670
- pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
747
+ if (hasMoreToolCalls) {
748
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
749
+ const asides = resolveAsides(await config.getAsideMessages?.());
750
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
751
+ } else {
752
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
753
+ // asides for the outer drain below so a passive aside can't trigger an extra model
754
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
755
+ pendingMessages = steering;
756
+ }
671
757
  }
672
758
 
673
759
  // Agent would stop here. Drain non-interrupting asides + follow-up messages.
@@ -761,18 +847,11 @@ async function streamAssistantResponse(
761
847
  const dynamicReasoning = config.getReasoning?.();
762
848
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
763
849
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
764
- const maxToolCallsPerTurn = normalizeMaxToolCallsPerTurn(config.maxToolCallsPerTurn);
765
- const toolCallCapAbortController = maxToolCallsPerTurn === undefined ? undefined : new AbortController();
766
- const requestSignals: AbortSignal[] = [];
767
- if (signal) requestSignals.push(signal);
768
- if (harmonyAbortController) requestSignals.push(harmonyAbortController.signal);
769
- if (toolCallCapAbortController) requestSignals.push(toolCallCapAbortController.signal);
770
- const requestSignal =
771
- requestSignals.length === 0
772
- ? undefined
773
- : requestSignals.length === 1
774
- ? requestSignals[0]
775
- : AbortSignal.any(requestSignals);
850
+ const requestSignal = harmonyAbortController
851
+ ? signal
852
+ ? AbortSignal.any([signal, harmonyAbortController.signal])
853
+ : harmonyAbortController.signal
854
+ : signal;
776
855
  const effectiveTemperature =
777
856
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
778
857
  const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
@@ -844,27 +923,27 @@ async function streamAssistantResponse(
844
923
 
845
924
  let partialMessage: AssistantMessage | null = null;
846
925
  let addedPartial = false;
926
+ const completedToolCallIds = new Set<string>();
847
927
 
848
928
  const responseIterator = response[Symbol.asyncIterator]();
849
- let completedToolCalls = 0;
850
- let cappedMessage: AssistantMessage | undefined;
851
- let capFinalized = false;
852
-
853
- const finishCappedAssistantMessage = async (): Promise<AssistantMessage | undefined> => {
854
- if (!cappedMessage) return undefined;
855
- responseIterator.return?.()?.catch(() => {});
856
- if (!capFinalized) {
857
- if (addedPartial) {
858
- context.messages[context.messages.length - 1] = cappedMessage;
859
- } else {
860
- context.messages.push(cappedMessage);
861
- stream.push({ type: "message_start", message: { ...cappedMessage } });
862
- }
863
- stream.push({ type: "message_end", message: cappedMessage });
864
- await finishChat(cappedMessage);
865
- capFinalized = true;
929
+ const finishAbortedStream = async (): Promise<AssistantMessage> => {
930
+ try {
931
+ const cleanup = responseIterator.return?.();
932
+ if (cleanup) void cleanup.catch(() => {});
933
+ } catch {
934
+ // Provider cancellation failures cannot change the committed aborted message.
866
935
  }
867
- return cappedMessage;
936
+ const aborted = emitAbortedAssistantMessage(
937
+ partialMessage,
938
+ addedPartial,
939
+ completedToolCallIds,
940
+ context,
941
+ config,
942
+ stream,
943
+ requestSignal,
944
+ );
945
+ await finishChat(aborted);
946
+ return aborted;
868
947
  };
869
948
 
870
949
  // Set up a single abort race: register the abort listener once for the whole
@@ -874,16 +953,7 @@ async function streamAssistantResponse(
874
953
  let detachAbortListener: (() => void) | undefined;
875
954
  if (requestSignal) {
876
955
  if (requestSignal.aborted) {
877
- const aborted = emitAbortedAssistantMessage(
878
- partialMessage,
879
- addedPartial,
880
- context,
881
- config,
882
- stream,
883
- requestSignal,
884
- );
885
- await finishChat(aborted);
886
- return aborted;
956
+ return await finishAbortedStream();
887
957
  }
888
958
  const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
889
959
  const onAbort = () => resolve(ABORTED);
@@ -898,45 +968,51 @@ async function streamAssistantResponse(
898
968
  if (abortRacePromise) {
899
969
  const result = await Promise.race([responseIterator.next(), abortRacePromise]);
900
970
  if (result === ABORTED) {
901
- if (toolCallCapAbortController?.signal.aborted) {
902
- const capped = await finishCappedAssistantMessage();
903
- if (capped) return capped;
904
- }
905
- responseIterator.return?.()?.catch(() => {});
906
- const aborted = emitAbortedAssistantMessage(
907
- partialMessage,
908
- addedPartial,
909
- context,
910
- config,
911
- stream,
912
- requestSignal,
913
- );
914
- await finishChat(aborted);
915
- return aborted;
971
+ return await finishAbortedStream();
916
972
  }
917
973
  next = result;
918
974
  } else {
919
975
  next = await responseIterator.next();
920
976
  }
921
- if (requestSignal?.aborted) {
922
- if (toolCallCapAbortController?.signal.aborted) {
923
- const capped = await finishCappedAssistantMessage();
924
- if (capped) return capped;
925
- }
926
- const aborted = emitAbortedAssistantMessage(
927
- partialMessage,
928
- addedPartial,
929
- context,
930
- config,
931
- stream,
932
- requestSignal,
933
- );
934
- await finishChat(aborted);
935
- return aborted;
936
- }
937
977
  if (next.done) break;
938
978
 
939
979
  const event = next.value;
980
+ if (event.type === "done" || event.type === "error") {
981
+ let finalMessage = retainCompletedToolCalls(await response.result(), completedToolCallIds);
982
+ if (harmonyMitigationEnabled) {
983
+ const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
984
+ if (detection) {
985
+ const recovered = recoverHarmonyToolCall(finalMessage, detection);
986
+ const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection);
987
+ if (addedPartial) {
988
+ emitDiscardedHarmonyPartial(
989
+ partialMessage,
990
+ stream,
991
+ `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
992
+ );
993
+ context.messages.pop();
994
+ addedPartial = false;
995
+ }
996
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
997
+ }
998
+ }
999
+ finalMessage = snapshotAssistantMessage(finalMessage);
1000
+ if (addedPartial) {
1001
+ context.messages[context.messages.length - 1] = finalMessage;
1002
+ } else {
1003
+ context.messages.push(finalMessage);
1004
+ }
1005
+ if (!addedPartial) {
1006
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
1007
+ }
1008
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
1009
+ await finishChat(finalMessage);
1010
+ return finalMessage;
1011
+ }
1012
+ if (requestSignal?.aborted) {
1013
+ return await finishAbortedStream();
1014
+ }
1015
+
940
1016
  // Yield to the event loop periodically to prevent busy-wait
941
1017
  // when the LLM is streaming chunks faster than the loop can rest.
942
1018
  await yieldIfDue();
@@ -946,7 +1022,7 @@ async function streamAssistantResponse(
946
1022
  partialMessage = event.partial;
947
1023
  context.messages.push(partialMessage);
948
1024
  addedPartial = true;
949
- stream.push({ type: "message_start", message: { ...partialMessage } });
1025
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
950
1026
  break;
951
1027
 
952
1028
  case "text_start":
@@ -959,72 +1035,48 @@ async function streamAssistantResponse(
959
1035
  case "toolcall_delta":
960
1036
  case "toolcall_end":
961
1037
  if (partialMessage) {
1038
+ if (event.type === "toolcall_end") {
1039
+ completedToolCallIds.add(event.toolCall.id);
1040
+ }
962
1041
  partialMessage = event.partial;
963
1042
  context.messages[context.messages.length - 1] = partialMessage;
964
1043
  config.onAssistantMessageEvent?.(partialMessage, event);
965
- if (signal?.aborted) {
966
- continue;
967
- }
968
1044
  stream.push({
969
1045
  type: "message_update",
970
- assistantMessageEvent: event,
971
- message: { ...partialMessage },
1046
+ assistantMessageEvent: snapshotAssistantMessageEvent(event),
1047
+ message: snapshotAssistantMessage(partialMessage),
972
1048
  });
973
- if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) {
974
- completedToolCalls++;
975
- if (completedToolCalls >= maxToolCallsPerTurn) {
976
- cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage);
977
- toolCallCapAbortController?.abort();
978
- const capped = await finishCappedAssistantMessage();
979
- if (capped) return capped;
980
- }
981
- }
982
1049
  }
983
1050
  break;
984
-
985
- case "done":
986
- case "error": {
987
- const finalMessage = await response.result();
988
- if (harmonyMitigationEnabled) {
989
- const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
990
- if (detection) {
991
- const removed = extractHarmonyRemoved(finalMessage, detection);
992
- if (addedPartial) {
993
- context.messages.pop();
994
- addedPartial = false;
995
- }
996
- throw new HarmonyLeakInterruption(detection, removed);
997
- }
998
- }
999
- if (addedPartial) {
1000
- context.messages[context.messages.length - 1] = finalMessage;
1001
- } else {
1002
- context.messages.push(finalMessage);
1003
- }
1004
- if (!addedPartial) {
1005
- stream.push({ type: "message_start", message: { ...finalMessage } });
1006
- }
1007
- stream.push({ type: "message_end", message: finalMessage });
1008
- await finishChat(finalMessage);
1009
- return finalMessage;
1010
- }
1011
1051
  }
1012
1052
  }
1013
1053
  } finally {
1014
1054
  detachAbortListener?.();
1015
1055
  }
1016
1056
 
1017
- const trailing = await response.result();
1057
+ let trailing = await response.result();
1018
1058
  if (harmonyMitigationEnabled) {
1019
1059
  const detection = detectHarmonyLeakInAssistantMessage(trailing);
1020
1060
  if (detection) {
1061
+ const recovered = recoverHarmonyToolCall(trailing, detection);
1062
+ const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
1021
1063
  if (addedPartial) {
1064
+ emitDiscardedHarmonyPartial(
1065
+ partialMessage,
1066
+ stream,
1067
+ `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
1068
+ );
1022
1069
  context.messages.pop();
1023
1070
  addedPartial = false;
1024
1071
  }
1025
- throw new HarmonyLeakInterruption(detection, extractHarmonyRemoved(trailing, detection));
1072
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
1026
1073
  }
1027
1074
  }
1075
+ trailing = snapshotAssistantMessage(trailing);
1076
+ if (addedPartial) {
1077
+ context.messages[context.messages.length - 1] = trailing;
1078
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
1079
+ }
1028
1080
  await finishChat(trailing);
1029
1081
  return trailing;
1030
1082
  });
@@ -1038,6 +1090,33 @@ async function streamAssistantResponse(
1038
1090
  }
1039
1091
  }
1040
1092
 
1093
+ function retainCompletedToolCalls(
1094
+ message: AssistantMessage,
1095
+ completedToolCallIds: ReadonlySet<string>,
1096
+ ): AssistantMessage {
1097
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
1098
+ let changed = false;
1099
+ const content = message.content.filter(block => {
1100
+ if (block.type !== "toolCall") return true;
1101
+ const keep = completedToolCallIds.has(block.id);
1102
+ if (!keep) changed = true;
1103
+ return keep;
1104
+ });
1105
+ return changed ? { ...message, content } : message;
1106
+ }
1107
+
1108
+ function emitDiscardedHarmonyPartial(
1109
+ partialMessage: AssistantMessage | null,
1110
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1111
+ errorMessage: string,
1112
+ ): void {
1113
+ if (!partialMessage) return;
1114
+ stream.push({
1115
+ type: "message_end",
1116
+ message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }),
1117
+ });
1118
+ }
1119
+
1041
1120
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
1042
1121
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
1043
1122
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -1053,16 +1132,31 @@ export function abortReasonText(signal: AbortSignal | undefined): string {
1053
1132
  return "Request was aborted";
1054
1133
  }
1055
1134
 
1135
+ /** True when an abort carried a *deliberate*, human-meaningful reason — a string
1136
+ * reason or a non-`AbortError` `Error` (TTSR rule match, user-interrupt label).
1137
+ * A bare `abort()` (default `AbortError` `DOMException`) is anonymous and returns
1138
+ * false. Used to decide whether a mid-stream tool call survives the abort: a
1139
+ * deliberate interruption is a conscious decision made after the (partial) call
1140
+ * was observed, so the block is retained and paired with a labeled placeholder;
1141
+ * an anonymous abort drops incomplete calls whose args may be unsafe to replay. */
1142
+ function isExplicitAbortReason(signal: AbortSignal | undefined): boolean {
1143
+ const reason = signal?.reason;
1144
+ if (typeof reason === "string") return reason.trim().length > 0;
1145
+ if (reason instanceof Error) return reason.name !== "AbortError" && reason.message.trim().length > 0;
1146
+ return false;
1147
+ }
1148
+
1056
1149
  function emitAbortedAssistantMessage(
1057
1150
  partialMessage: AssistantMessage | null,
1058
1151
  addedPartial: boolean,
1152
+ completedToolCallIds: ReadonlySet<string>,
1059
1153
  context: AgentContext,
1060
1154
  config: AgentLoopConfig,
1061
1155
  stream: EventStream<AgentEvent, AgentMessage[]>,
1062
1156
  requestSignal: AbortSignal | undefined,
1063
1157
  ): AssistantMessage {
1064
1158
  const errorMessage = abortReasonText(requestSignal);
1065
- const abortedMessage: AssistantMessage = partialMessage
1159
+ const base: AssistantMessage = partialMessage
1066
1160
  ? { ...partialMessage, stopReason: "aborted", errorMessage }
1067
1161
  : {
1068
1162
  role: "assistant",
@@ -1082,13 +1176,19 @@ function emitAbortedAssistantMessage(
1082
1176
  errorMessage,
1083
1177
  timestamp: Date.now(),
1084
1178
  };
1179
+ // A deliberate, labeled abort (TTSR rule match, user interrupt) keeps every
1180
+ // committed tool-call block so the loop pairs it with a placeholder labeled by
1181
+ // `errorMessage`; an anonymous abort still drops calls that never completed
1182
+ // (no `toolcall_end`), whose partial args are unsafe to replay.
1183
+ const retained = isExplicitAbortReason(requestSignal) ? base : retainCompletedToolCalls(base, completedToolCallIds);
1184
+ const abortedMessage = snapshotAssistantMessage(retained);
1085
1185
  if (addedPartial) {
1086
1186
  context.messages[context.messages.length - 1] = abortedMessage;
1087
1187
  } else {
1088
1188
  context.messages.push(abortedMessage);
1089
- stream.push({ type: "message_start", message: { ...abortedMessage } });
1189
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) });
1090
1190
  }
1091
- stream.push({ type: "message_end", message: abortedMessage });
1191
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) });
1092
1192
  return abortedMessage;
1093
1193
  }
1094
1194
 
@@ -1126,7 +1226,7 @@ async function executeToolCalls(
1126
1226
  : steeringAbortController.signal;
1127
1227
  const interruptState = { triggered: false };
1128
1228
  let steeringMessages: AgentMessage[] | undefined;
1129
- let steeringCheck: Promise<void> | null = null;
1229
+ let steeringCheckTail: Promise<void> = Promise.resolve();
1130
1230
 
1131
1231
  const records = toolCalls.map(toolCall => ({
1132
1232
  toolCall,
@@ -1150,21 +1250,17 @@ async function executeToolCalls(
1150
1250
  if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
1151
1251
  return;
1152
1252
  }
1153
- if (steeringCheck) {
1154
- await steeringCheck;
1155
- return;
1156
- }
1157
- steeringCheck = (async () => {
1253
+ const check = steeringCheckTail.then(async () => {
1254
+ if (interruptState.triggered) return;
1158
1255
  const steering = await getSteeringMessages();
1159
1256
  if (steering.length > 0) {
1160
1257
  steeringMessages = steering;
1161
1258
  interruptState.triggered = true;
1162
1259
  steeringAbortController.abort();
1163
1260
  }
1164
- })().finally(() => {
1165
- steeringCheck = null;
1166
1261
  });
1167
- await steeringCheck;
1262
+ steeringCheckTail = check.catch(() => {});
1263
+ await check;
1168
1264
  };
1169
1265
 
1170
1266
  const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
@@ -1236,6 +1332,16 @@ async function executeToolCalls(
1236
1332
  }
1237
1333
  }
1238
1334
  record.args = argsForExecution;
1335
+ if (toolSignal.aborted) {
1336
+ record.skipped = true;
1337
+ recordSkippedTool(telemetry, {
1338
+ toolCallId: toolCall.id,
1339
+ toolName: toolCall.name,
1340
+ status: "aborted",
1341
+ });
1342
+ emitToolResult(record, createToolSignalAbortedResult(toolSignal), true);
1343
+ return;
1344
+ }
1239
1345
  record.started = true;
1240
1346
  stream.push({
1241
1347
  type: "tool_execution_start",
@@ -1259,10 +1365,16 @@ async function executeToolCalls(
1259
1365
  let result: AgentToolResult<any> = { content: [], details: {} };
1260
1366
  let isError = false;
1261
1367
  let caughtError: unknown;
1368
+ let completedToolExecution = false;
1262
1369
 
1263
1370
  await runInActiveSpan(toolSpan, async () => {
1264
1371
  try {
1265
1372
  if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
1373
+ if (toolSignal.aborted) {
1374
+ result = createToolSignalAbortedResult(toolSignal);
1375
+ isError = true;
1376
+ return;
1377
+ }
1266
1378
 
1267
1379
  let effectiveArgs: Record<string, unknown>;
1268
1380
  try {
@@ -1289,8 +1401,15 @@ async function executeToolCalls(
1289
1401
  throw new ToolCallBlockedError(beforeResult.reason);
1290
1402
  }
1291
1403
  }
1292
- // Reflect post-hook args so emitted tool results / afterToolCall see what actually executed.
1293
- record.args = effectiveArgs;
1404
+ if (toolSignal.aborted) {
1405
+ result = createToolSignalAbortedResult(toolSignal);
1406
+ isError = true;
1407
+ return;
1408
+ }
1409
+ const executionArgs = transformToolCallArguments
1410
+ ? transformToolCallArguments(effectiveArgs, toolCall.name)
1411
+ : effectiveArgs;
1412
+ record.args = executionArgs;
1294
1413
 
1295
1414
  const toolContext = getToolContext
1296
1415
  ? getToolContext({
@@ -1302,19 +1421,20 @@ async function executeToolCalls(
1302
1421
  : undefined;
1303
1422
  const rawResult = await tool.execute(
1304
1423
  toolCall.id,
1305
- transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
1424
+ executionArgs,
1306
1425
  toolSignal,
1307
1426
  partialResult => {
1308
1427
  stream.push({
1309
1428
  type: "tool_execution_update",
1310
1429
  toolCallId: toolCall.id,
1311
1430
  toolName: toolCall.name,
1312
- args: effectiveArgs,
1431
+ args: executionArgs,
1313
1432
  partialResult: coerceToolResult(partialResult).result,
1314
1433
  });
1315
1434
  },
1316
1435
  toolContext,
1317
1436
  );
1437
+ completedToolExecution = true;
1318
1438
  const coerced = coerceToolResult(rawResult);
1319
1439
  result = coerced.result;
1320
1440
  if (coerced.malformed || result.isError) isError = true;
@@ -1327,7 +1447,7 @@ async function executeToolCalls(
1327
1447
  isError = true;
1328
1448
  }
1329
1449
 
1330
- if (afterToolCall) {
1450
+ if (afterToolCall && (!toolSignal.aborted || completedToolExecution)) {
1331
1451
  try {
1332
1452
  const after = await afterToolCall(
1333
1453
  {
@@ -1341,12 +1461,17 @@ async function executeToolCalls(
1341
1461
  toolSignal,
1342
1462
  );
1343
1463
  if (after) {
1344
- result = {
1464
+ // Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
1465
+ // code and may return malformed `content` (non-array / invalid blocks), which
1466
+ // would otherwise be persisted verbatim and corrupt the session — the same
1467
+ // hazard `coerceToolResult` guards on the execute path.
1468
+ const coerced = coerceToolResult({
1345
1469
  content: after.content ?? result.content,
1346
1470
  details: after.details ?? result.details,
1347
1471
  isError: after.isError ?? result.isError,
1348
- };
1349
- isError = after.isError ?? isError;
1472
+ });
1473
+ result = coerced.result;
1474
+ isError = coerced.malformed || (after.isError ?? isError);
1350
1475
  }
1351
1476
  } catch (e) {
1352
1477
  caughtError = e;
@@ -1360,23 +1485,30 @@ async function executeToolCalls(
1360
1485
  });
1361
1486
 
1362
1487
  const interrupted = interruptState.triggered;
1363
- if (interrupted) {
1488
+ const abortedDuringExecution = toolSignal.aborted && isError;
1489
+ if (interrupted && isError) {
1490
+ // Steering/abort fired AND this tool failed — it was cut off before producing a
1491
+ // usable result, so report it as skipped.
1364
1492
  record.skipped = true;
1365
1493
  emitToolResult(record, createSkippedToolResult(), true);
1366
1494
  } else {
1495
+ // No interrupt, or the tool finished (successfully or with a genuine error) before
1496
+ // the interrupt landed. Keep its real result: a completed tool already ran its side
1497
+ // effects, so the model must see what actually happened rather than a false "skipped".
1367
1498
  emitToolResult(record, result, isError);
1368
1499
  }
1369
1500
 
1370
1501
  const firstTextBlock = result.content?.[0];
1371
1502
  const errorMessageForSpan =
1372
1503
  caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
1373
- const status = interrupted
1374
- ? "aborted"
1375
- : caughtError instanceof ToolCallBlockedError
1376
- ? "blocked"
1377
- : isError
1378
- ? "error"
1379
- : "ok";
1504
+ const status =
1505
+ (interrupted && isError) || abortedDuringExecution
1506
+ ? "aborted"
1507
+ : caughtError instanceof ToolCallBlockedError
1508
+ ? "blocked"
1509
+ : isError
1510
+ ? "error"
1511
+ : "ok";
1380
1512
  finishExecuteToolSpan(telemetry, toolSpan, {
1381
1513
  result,
1382
1514
  isError,
@@ -1482,6 +1614,14 @@ function createAbortedToolResult(
1482
1614
  return toolResultMessage;
1483
1615
  }
1484
1616
 
1617
+ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unknown> {
1618
+ const reason = abortReasonText(signal);
1619
+ return {
1620
+ content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }],
1621
+ details: {},
1622
+ };
1623
+ }
1624
+
1485
1625
  function createSkippedToolResult(): AgentToolResult<any> {
1486
1626
  return {
1487
1627
  content: [{ type: "text", text: "Skipped due to queued user message." }],
package/src/agent.ts CHANGED
@@ -110,12 +110,6 @@ export interface AgentOptions {
110
110
  */
111
111
  interruptMode?: "immediate" | "wait";
112
112
 
113
- /**
114
- * Maximum completed tool calls to accept from one streamed assistant turn before
115
- * executing the batch. Undefined disables batching.
116
- */
117
- maxToolCallsPerTurn?: number;
118
-
119
113
  /**
120
114
  * API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic")
121
115
  */
@@ -288,7 +282,6 @@ export class Agent {
288
282
  #steeringMode: "all" | "one-at-a-time";
289
283
  #followUpMode: "all" | "one-at-a-time";
290
284
  #interruptMode: "immediate" | "wait";
291
- #maxToolCallsPerTurn?: number;
292
285
  #sessionId?: string;
293
286
  #promptCacheKey?: string;
294
287
  #metadata?: Record<string, unknown>;
@@ -350,7 +343,6 @@ export class Agent {
350
343
  this.#steeringMode = opts.steeringMode || "one-at-a-time";
351
344
  this.#followUpMode = opts.followUpMode || "one-at-a-time";
352
345
  this.#interruptMode = opts.interruptMode || "immediate";
353
- this.#maxToolCallsPerTurn = opts.maxToolCallsPerTurn;
354
346
  this.streamFn = opts.streamFn || streamSimple;
355
347
  this.#sessionId = opts.sessionId;
356
348
  this.#promptCacheKey = opts.promptCacheKey;
@@ -588,14 +580,6 @@ export class Agent {
588
580
  this.#maxRetryDelayMs = value;
589
581
  }
590
582
 
591
- get maxToolCallsPerTurn(): number | undefined {
592
- return this.#maxToolCallsPerTurn;
593
- }
594
-
595
- set maxToolCallsPerTurn(value: number | undefined) {
596
- this.#maxToolCallsPerTurn = value;
597
- }
598
-
599
583
  get state(): AgentState {
600
584
  return this.#state;
601
585
  }
@@ -967,7 +951,6 @@ export class Agent {
967
951
  serviceTier: this.#serviceTier,
968
952
  hideThinkingSummary: this.#hideThinkingSummary,
969
953
  interruptMode: this.#interruptMode,
970
- maxToolCallsPerTurn: this.#maxToolCallsPerTurn,
971
954
  sessionId: this.#sessionId,
972
955
  promptCacheKey: this.#promptCacheKey,
973
956
  metadata: this.#metadataResolver ? undefined : this.#metadata,
package/src/telemetry.ts CHANGED
@@ -1869,7 +1869,8 @@ export function finishInvokeAgentSpan(
1869
1869
 
1870
1870
  /**
1871
1871
  * Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
1872
- are caught and logged via `console.warn` telemetry callbacks NEVER turn a
1872
+ * are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn`
1873
+ * when no hook is set) — telemetry callbacks NEVER turn a
1873
1874
  * successful agent run into a failed one. Idempotent at the call site via
1874
1875
  * {@link AgentRunCollector.markRunEnded}; callers must check that before
1875
1876
  * calling this helper.
package/src/types.ts CHANGED
@@ -47,14 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
47
47
  */
48
48
  interruptMode?: "immediate" | "wait";
49
49
 
50
- /**
51
- * Maximum completed tool calls to accept from one streamed assistant turn before
52
- * cutting the provider stream and executing that batch. The cap is enforced on
53
- * `toolcall_end` so every executed call has complete arguments. Undefined disables
54
- * batching.
55
- */
56
- maxToolCallsPerTurn?: number;
57
-
58
50
  /**
59
51
  * Optional session identifier forwarded to LLM providers.
60
52
  * Used by providers that support session-based caching (e.g., OpenAI Codex).