@gajae-code/agent-core 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent-loop.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  EventStream,
15
15
  isZodSchema,
16
16
  streamSimple,
17
+ type ToolChoice,
17
18
  type ToolResultMessage,
18
19
  type TSchema,
19
20
  transportFailureFacts,
@@ -31,7 +32,7 @@ import {
31
32
  neutralizeReservedControlTokens,
32
33
  stripUnusableReasoningItems,
33
34
  } from "@gajae-code/ai/utils";
34
- import { sanitizeText } from "@gajae-code/utils";
35
+ import { logger, sanitizeText } from "@gajae-code/utils";
35
36
  import type { AttemptScope } from "./attempt-scope";
36
37
  import {
37
38
  createHarmonyAuditEvent,
@@ -62,6 +63,11 @@ import {
62
63
  startExecuteToolSpan,
63
64
  startInvokeAgentSpan,
64
65
  } from "./telemetry";
66
+ import {
67
+ activeToolForCallName,
68
+ bindDispatchedToolIdentity,
69
+ markNonDispatchedToolEvent,
70
+ } from "./tool-dispatch-identity";
65
71
  import type {
66
72
  AgentContext,
67
73
  AgentEvent,
@@ -75,6 +81,11 @@ import type {
75
81
  StreamFn,
76
82
  } from "./types";
77
83
 
84
+ // Capture the intrinsic before any tool/hook can replace `Reflect.apply`. Calling this
85
+ // local binding performs no user-controlled property lookup between publishing a dispatch
86
+ // start and entering the selected execute function.
87
+ const intrinsicReflectApply = Reflect.apply;
88
+
78
89
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
79
90
  /**
80
91
  * Defensive caps for a provisional managed attempt. These are intentionally
@@ -84,15 +95,45 @@ import type {
84
95
  export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
85
96
  export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
86
97
 
98
+ /**
99
+ * Closed set of local-failure sites. A bounded diagnostic may name only these
100
+ * literals: the log is shape-only, so no caller-supplied or provider-derived
101
+ * string may ever reach it.
102
+ */
103
+ const MANAGED_LOCAL_FAILURE_STAGES = [
104
+ "shell.role",
105
+ "shell.content",
106
+ "event.snapshot",
107
+ "event.contentIndex",
108
+ "event.delta",
109
+ "event.content",
110
+ "event.toolcall",
111
+ "event.done.reason",
112
+ "event.error.reason",
113
+ "event.unknownType",
114
+ "staging.losslessSnapshot",
115
+ "staging.measure",
116
+ "staging.sanitize",
117
+ "staging.preMeasure",
118
+ "staging.overflow",
119
+ "overflow.preMeasure",
120
+ "overflow.staged",
121
+ ] as const;
122
+ type ManagedLocalFailureStage = (typeof MANAGED_LOCAL_FAILURE_STAGES)[number];
123
+ const MANAGED_LOCAL_FAILURE_STAGE_SET: ReadonlySet<string> = new Set(MANAGED_LOCAL_FAILURE_STAGES);
124
+
87
125
  /**
88
126
  * Local staging failure: the provisional buffer limit was exceeded. Carries
89
127
  * NO transport facts or status by design — only original typed provider
90
128
  * transport facts may authorize provider fallback, so local buffer machinery
91
129
  * must never masquerade as provider evidence or consume the fallback chain.
92
- * It is therefore non-retryable and surfaces as an explicit local error.
130
+ * The typed `local_buffer_overflow` kind lets session retry policy surface it
131
+ * immediately without any retry: re-streaming the same request reproduces the
132
+ * same oversized response, so an automatic re-issue only burns tokens.
93
133
  */
94
134
  class ManagedAttemptBufferOverflowError extends Error {
95
- constructor() {
135
+ readonly errorKind = "local_buffer_overflow" as const;
136
+ constructor(readonly stage: ManagedLocalFailureStage) {
96
137
  super("Managed fallback attempt exceeded the provisional event buffer limit");
97
138
  this.name = "ManagedAttemptBufferOverflowError";
98
139
  }
@@ -101,10 +142,14 @@ class ManagedAttemptBufferOverflowError extends Error {
101
142
  /**
102
143
  * Local snapshot-machinery failure. Deliberately carries no transport facts
103
144
  * or status, so managed fallback classification never treats it as a provider
104
- * retry trigger — it fails fast instead of burning the fallback chain.
145
+ * retry trigger — it never burns the fallback chain, advances models, or
146
+ * mutates credentials. The typed `local_snapshot_failure` kind lets session
147
+ * policy surface the producer-boundary diagnostic immediately instead of
148
+ * amplifying one deterministic local defect across identical retries.
105
149
  */
106
150
  class ManagedAttemptSnapshotError extends Error {
107
- constructor() {
151
+ readonly errorKind = "local_snapshot_failure" as const;
152
+ constructor(readonly stage: ManagedLocalFailureStage) {
108
153
  super(
109
154
  "Managed fallback attempt could not produce a serializable event snapshot (local snapshot bug, not a provider failure)",
110
155
  );
@@ -134,6 +179,29 @@ const standaloneOwnershipStates = new WeakMap<StandaloneRunOwnership, Standalone
134
179
  */
135
180
  const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
136
181
 
182
+ /**
183
+ * How many times a single turn may be re-requested because its tool arguments
184
+ * arrived as `\uXXXX` escapes instead of literal UTF-8.
185
+ *
186
+ * The defect is a wire-format accident that resampling clears, so a small
187
+ * budget recovers the overwhelming majority of turns; past it the terminal
188
+ * per-call rejection takes over rather than spending the run on retries.
189
+ */
190
+ const MAX_ESCAPED_NONASCII_RESAMPLES = 2;
191
+
192
+ /** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
193
+ function hasEscapedNonAsciiToolCall(message: AssistantMessage): boolean {
194
+ return message.content.some(block => block.type === "toolCall" && block.escapedNonAsciiArguments === true);
195
+ }
196
+
197
+ /** Remove only the exact assistant response committed by its streaming attempt. */
198
+ function removeCommittedAssistantMessage(messages: AgentMessage[], message: AssistantMessage): boolean {
199
+ const index = messages.lastIndexOf(message);
200
+ if (index < 0) return false;
201
+ messages.splice(index, 1);
202
+ return true;
203
+ }
204
+
137
205
  function isComposerBashPolicyBlockedToolResult(result: ToolResultMessage): boolean {
138
206
  return (
139
207
  result.isError &&
@@ -263,18 +331,10 @@ function managedContextOverflowOutcome(message: AssistantMessage, scope?: Attemp
263
331
  return { type: "context_overflow_discarded", message, scope };
264
332
  }
265
333
 
266
- /** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
267
- function hasEscapedNonAsciiToolCall(message: AssistantMessage): boolean {
268
- return message.content.some(block => block.type === "toolCall" && block.escapedNonAsciiArguments === true);
269
- }
270
-
271
- function managedEscapedArgumentsOutcome(message: AssistantMessage, scope?: AttemptScope): ManagedAttemptOutcome {
272
- return { type: "escaped_arguments_discarded", message, scope };
273
- }
274
-
275
334
  function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
276
335
  const errorMessage = managedProperty(error, "message");
277
336
  const transportFailure = managedTransportFailure(error);
337
+ const errorKind = managedProperty(error, "errorKind");
278
338
  let fallbackMessage = "Managed fallback attempt failed";
279
339
  if (typeof errorMessage === "string") fallbackMessage = errorMessage;
280
340
  else {
@@ -301,6 +361,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
301
361
  stopReason: "error",
302
362
  errorMessage: fallbackMessage,
303
363
  ...(transportFailure ? { transportFailure } : {}),
364
+ ...(errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow" ? { errorKind } : {}),
304
365
  timestamp: Date.now(),
305
366
  };
306
367
  }
@@ -592,6 +653,19 @@ function publishAgentEnd(
592
653
  */
593
654
  export const MANAGED_SNAPSHOT_MAX_NODES = 100_000;
594
655
 
656
+ /**
657
+ * The sanitizer's closed set of placeholder strings. A degraded snapshot can
658
+ * never distinguish these from provider-sent strings by value alone, so
659
+ * downstream shape checks treat a sentinel-valued field as "original value was
660
+ * non-cloneable", never as benign provider variance.
661
+ */
662
+ const SANITIZER_SENTINELS: ReadonlySet<string> = new Set([
663
+ "[unserializable]",
664
+ "[accessor]",
665
+ "[truncated]",
666
+ "[Circular]",
667
+ ]);
668
+
595
669
  /**
596
670
  * Cycle-aware deep clone that always returns a detached, JSON-serializable
597
671
  * value. Used whenever a detached snapshot cannot be safely obtained or
@@ -726,12 +800,33 @@ export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_S
726
800
  * (e.g. a live `Headers` inside a provider error's `transportFailure` from a
727
801
  * legacy payload), and a thrown `DataCloneError` here would mask the real
728
802
  * provider outcome and burn the whole fallback chain.
803
+ *
804
+ * `structuredClone` success is not sufficient: it can erase a custom
805
+ * prototype `toJSON()` while retaining an own bigint field. The live value is
806
+ * JSON-safe, but the detached clone is not. Validate and measure the DETACHED
807
+ * value with the exact serialization operation used by staging; sanitize the
808
+ * detached clone when that validation fails so every accepted snapshot is
809
+ * both isolated and JSON-serializable.
729
810
  */
730
- function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; degraded: boolean } {
811
+ function managedSnapshotJsonBytes(value: unknown): number | undefined {
731
812
  try {
732
- return { snapshot: structuredClone(value), degraded: false };
813
+ const serialized = JSON.stringify(value);
814
+ return serialized === undefined ? undefined : managedAttemptTextEncoder.encode(serialized).byteLength;
733
815
  } catch {
734
- return { snapshot: sanitizedDetachedClone(value), degraded: true };
816
+ return undefined;
817
+ }
818
+ }
819
+
820
+ function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; jsonBytes?: number } {
821
+ try {
822
+ const snapshot = structuredClone(value);
823
+ const jsonBytes = managedSnapshotJsonBytes(snapshot);
824
+ if (jsonBytes !== undefined) return { snapshot, jsonBytes };
825
+ const sanitized = sanitizedDetachedClone(snapshot);
826
+ return { snapshot: sanitized, jsonBytes: managedSnapshotJsonBytes(sanitized) };
827
+ } catch {
828
+ const snapshot = sanitizedDetachedClone(value);
829
+ return { snapshot, jsonBytes: managedSnapshotJsonBytes(snapshot) };
735
830
  }
736
831
  }
737
832
 
@@ -739,6 +834,93 @@ function managedAttemptSnapshot<T>(value: T): T {
739
834
  return managedAttemptSnapshotDetailed(value).snapshot;
740
835
  }
741
836
 
837
+ const LOSSLESS_SNAPSHOT_KEYS = [
838
+ "role",
839
+ "content",
840
+ "api",
841
+ "provider",
842
+ "model",
843
+ "responseId",
844
+ "usage",
845
+ "stopReason",
846
+ "errorMessage",
847
+ "errorKind",
848
+ "errorStatus",
849
+ "transportFailure",
850
+ "disabledFeatures",
851
+ "providerPayload",
852
+ "timestamp",
853
+ "duration",
854
+ "ttft",
855
+ "type",
856
+ "contentIndex",
857
+ "delta",
858
+ "partial",
859
+ "toolCall",
860
+ "reason",
861
+ "message",
862
+ "error",
863
+ ] as const;
864
+
865
+ /**
866
+ * Detach unmanaged provider metadata without normalizing the cloneable parts.
867
+ * A failed subtree is removed at its own property boundary; siblings retain
868
+ * their exact structured-clone representation. The bounded recursive path is
869
+ * used only after cloning the complete value fails.
870
+ */
871
+ function losslessDetachedClone<T>(value: T): T {
872
+ try {
873
+ const snapshot = structuredClone(value);
874
+ // `structuredClone()` preserves own bigint fields while removing a
875
+ // payload class's prototype `toJSON()`. The live event may therefore
876
+ // serialize successfully while its detached clone cannot be staged.
877
+ // Lossless staging still preserves every JSON-safe clone verbatim; only
878
+ // the non-serializable detached form is sanitized.
879
+ return managedSnapshotJsonBytes(snapshot) !== undefined ? snapshot : sanitizedDetachedClone(snapshot);
880
+ } catch {
881
+ // The managed sanitizer is explicitly bounded and total. Use it only to
882
+ // identify which top-level assistant metadata surfaces are cloneable; the
883
+ // cloneable surfaces themselves still come from structuredClone and remain
884
+ // lossless. This avoids recursive/unbounded traversal of hostile provider
885
+ // metadata while stripping only the failed top-level surface.
886
+ if (!isManagedPlainRecord(value)) return sanitizedDetachedClone(value);
887
+ const output: Record<string, unknown> = {};
888
+ let remaining = MANAGED_SNAPSHOT_MAX_NODES;
889
+ for (const key of LOSSLESS_SNAPSHOT_KEYS) {
890
+ if (remaining-- <= 0) break;
891
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
892
+ if (!descriptor || !("value" in descriptor)) continue;
893
+ try {
894
+ output[key] = structuredClone(descriptor.value);
895
+ } catch {
896
+ if (key === "transportFailure" && isManagedPlainRecord(descriptor.value)) {
897
+ const transport: Record<string, unknown> = {};
898
+ for (const transportKey of [
899
+ "kind",
900
+ "status",
901
+ "code",
902
+ "providerCode",
903
+ "openaiErrorCode",
904
+ "anthropicErrorType",
905
+ "retryAfterMs",
906
+ "headers",
907
+ ] as const) {
908
+ const transportDescriptor = Object.getOwnPropertyDescriptor(descriptor.value, transportKey);
909
+ if (!transportDescriptor || !("value" in transportDescriptor)) continue;
910
+ try {
911
+ transport[transportKey] = structuredClone(transportDescriptor.value);
912
+ } catch {
913
+ // Strip only this non-cloneable transport fact.
914
+ }
915
+ }
916
+ output[key] = transport;
917
+ }
918
+ }
919
+ }
920
+ return managedSnapshotJsonBytes(output) !== undefined ? (output as T) : sanitizedDetachedClone(output as T);
921
+ }
922
+ }
923
+
742
924
  /**
743
925
  * Recover the required assistant-message shell when a managed snapshot degrades
744
926
  * at its root (notably for Proxy-wrapped provider messages). Only known fields
@@ -747,14 +929,39 @@ function managedAttemptSnapshot<T>(value: T): T {
747
929
  */
748
930
  function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
749
931
  const detailed = managedAttemptSnapshotDetailed(value);
750
- const source = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : value;
751
- if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError();
932
+ const snapshotRecord = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : undefined;
933
+ // Two benign root degradations are repaired by reading through the
934
+ // original object — the provider's own view — with every read guarded so
935
+ // a hostile trap can only degrade to undefined, never escape
936
+ // (`managedProperty` is exactly that guarded read):
937
+ // - a root that could not be snapshotted into a plain record is a live
938
+ // Proxy (the sanitizer collapses proxies to a placeholder);
939
+ // - a plain-record snapshot that lost `role: "assistant"` came from a
940
+ // payload class whose fields live on its prototype: `structuredClone`
941
+ // copies only own enumerable properties, so the caller's live
942
+ // `message.role === "assistant"` check passes while the detached
943
+ // snapshot retains none of the message identity.
944
+ const source =
945
+ snapshotRecord !== undefined && managedProperty(snapshotRecord, "role") === "assistant" ? snapshotRecord : value;
946
+ if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError("shell.role");
752
947
  const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
753
- if (!Array.isArray(rawContent)) throw new ManagedAttemptSnapshotError();
754
- const content = rawContent.flatMap(block => {
755
- const normalized = managedAssistantContent(block);
756
- return normalized ? [normalized] : [];
757
- });
948
+ // Benign providers occasionally deliver a string or missing content value.
949
+ // Degrade those to an empty content array — an empty assistant turn —
950
+ // instead of failing the whole managed run: the staged shell must stay
951
+ // schema-valid, and empty content is the neutral, side-effect-free
952
+ // degradation. A string is benign ONLY when the provider actually sent a
953
+ // string: when the whole-message snapshot degraded, the sanitizer replaces
954
+ // a non-cloneable content value (proxy, function, accessor) with one of
955
+ // its own sentinel strings, and mistaking that sentinel for provider
956
+ // variance would silently drop real content (tool calls) behind a
957
+ // successful empty turn. Sentinel-string content therefore stays
958
+ // fail-closed, as does every other non-array shape, so the named-site
959
+ // diagnostic can report shell.content.
960
+ const rawArray = Array.isArray(rawContent) ? rawContent : undefined;
961
+ const benignContent =
962
+ rawContent === undefined || (typeof rawContent === "string" && !SANITIZER_SENTINELS.has(rawContent));
963
+ if (rawArray === undefined && !benignContent) throw new ManagedAttemptSnapshotError("shell.content");
964
+ const content = rawArray === undefined ? [] : rawArray.flatMap(managedContentBlock);
758
965
  const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
759
966
  const api = managedProperty(source, "api");
760
967
  const provider = managedProperty(source, "provider");
@@ -794,6 +1001,11 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
794
1001
  };
795
1002
  }
796
1003
 
1004
+ function managedContentBlock(block: unknown): AssistantMessage["content"] {
1005
+ const normalized = managedAssistantContent(block);
1006
+ return normalized ? [normalized] : [];
1007
+ }
1008
+
797
1009
  function managedAssistantContent(value: unknown): AssistantMessage["content"][number] | undefined {
798
1010
  if (!isManagedPlainRecord(value)) return undefined;
799
1011
  const type = managedProperty(value, "type");
@@ -818,6 +1030,7 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
818
1030
  const intent = managedProperty(value, "intent");
819
1031
  const customWireName = managedProperty(value, "customWireName");
820
1032
  const incompleteArguments = managedProperty(value, "incompleteArguments");
1033
+ const incompleteArgumentsReason = managedProperty(value, "incompleteArgumentsReason");
821
1034
  const escapedNonAsciiArguments = managedProperty(value, "escapedNonAsciiArguments");
822
1035
  return {
823
1036
  type,
@@ -828,6 +1041,15 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
828
1041
  ...(typeof intent === "string" ? { intent } : {}),
829
1042
  ...(typeof customWireName === "string" ? { customWireName } : {}),
830
1043
  ...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
1044
+ ...(typeof incompleteArgumentsReason === "string"
1045
+ ? {
1046
+ incompleteArgumentsReason: incompleteArgumentsReason as
1047
+ | "truncated"
1048
+ | "malformed"
1049
+ | "conflicting"
1050
+ | "ambiguous",
1051
+ }
1052
+ : {}),
831
1053
  ...(typeof escapedNonAsciiArguments === "boolean" ? { escapedNonAsciiArguments } : {}),
832
1054
  };
833
1055
  }
@@ -858,13 +1080,30 @@ function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
858
1080
  };
859
1081
  }
860
1082
 
861
- function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
862
- const snapshot = managedAttemptSnapshot(event);
863
- if (!isManagedPlainRecord(snapshot)) throw new ManagedAttemptSnapshotError();
864
- const type = managedProperty(snapshot, "type");
865
- const contentIndex = managedProperty(snapshot, "contentIndex");
1083
+ export function managedAssistantEventSnapshot(
1084
+ event: AssistantMessageEvent,
1085
+ message: AssistantMessage,
1086
+ ): AssistantMessageEvent {
1087
+ const detached = managedAttemptSnapshot(event);
1088
+ const record = isManagedPlainRecord(detached) ? detached : undefined;
1089
+ // Root repair, mirroring the shell: two benign degradations are re-read
1090
+ // through the original event with guarded reads (`managedProperty`) —
1091
+ // - a proxy root (structuredClone rejects proxies; the sanitizer collapses
1092
+ // them to a placeholder) whose gets are readable, and
1093
+ // - a payload class whose event fields live on prototype getters
1094
+ // (`structuredClone` copies only own enumerable properties).
1095
+ // A hostile trap or throwing getter can only degrade a field to undefined,
1096
+ // which keeps the named fail-fast diagnostics below; a root that is
1097
+ // neither snapshottable nor readable as an event keeps the dedicated root
1098
+ // diagnostic.
1099
+ const source: unknown = record !== undefined && typeof managedProperty(record, "type") === "string" ? record : event;
1100
+ const type = managedProperty(source, "type");
1101
+ if (record === undefined && typeof type !== "string") throw new ManagedAttemptSnapshotError("event.snapshot");
1102
+ const contentIndex = managedProperty(source, "contentIndex");
866
1103
  const indexed = () => {
867
- if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) throw new ManagedAttemptSnapshotError();
1104
+ if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) {
1105
+ throw new ManagedAttemptSnapshotError("event.contentIndex");
1106
+ }
868
1107
  return contentIndex as number;
869
1108
  };
870
1109
  if (type === "start") return { type, partial: message };
@@ -881,31 +1120,36 @@ function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: As
881
1120
  type === "reasoning_summary_delta" ||
882
1121
  type === "toolcall_delta"
883
1122
  ) {
884
- const delta = managedProperty(snapshot, "delta");
885
- if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
1123
+ const delta = managedProperty(source, "delta");
1124
+ if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta");
886
1125
  return { type, contentIndex: indexed(), delta, partial: message };
887
1126
  }
888
1127
  if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") {
889
- const content = managedProperty(snapshot, "content");
890
- if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
1128
+ const content = managedProperty(source, "content");
1129
+ if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content");
891
1130
  return { type, contentIndex: indexed(), content, partial: message };
892
1131
  }
893
1132
  if (type === "toolcall_end") {
894
- const toolCall = managedAssistantContent(managedProperty(snapshot, "toolCall"));
895
- if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError();
1133
+ const toolCall = managedAssistantContent(managedAttemptSnapshot(managedProperty(source, "toolCall")));
1134
+ if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError("event.toolcall");
896
1135
  return { type, contentIndex: indexed(), toolCall, partial: message };
897
1136
  }
898
1137
  if (type === "done") {
899
- const reason = managedProperty(snapshot, "reason");
900
- if (reason !== "stop" && reason !== "length" && reason !== "toolUse") throw new ManagedAttemptSnapshotError();
901
- return { type, reason, message };
1138
+ const reason = managedProperty(source, "reason");
1139
+ // Degrade out-of-vocabulary done reasons to "stop", matching the closed
1140
+ // StopReason vocabulary already normalized by managedAssistantShell.
1141
+ const normalized = reason === "stop" || reason === "length" || reason === "toolUse" ? reason : "stop";
1142
+ return { type, reason: normalized, message };
902
1143
  }
903
1144
  if (type === "error") {
904
- const reason = managedProperty(snapshot, "reason");
905
- if (reason !== "aborted" && reason !== "error") throw new ManagedAttemptSnapshotError();
906
- return { type, reason, error: message };
1145
+ const reason = managedProperty(source, "reason");
1146
+ const normalized = reason === "aborted" || reason === "error" ? reason : "error";
1147
+ return { type, reason: normalized, error: message };
907
1148
  }
908
- throw new ManagedAttemptSnapshotError();
1149
+ // An unknown string event type degrades to a terminal done/stop; a
1150
+ // non-string type is malformed provider output that must fail fast.
1151
+ if (typeof type === "string") return { type: "done", reason: "stop", message } as AssistantMessageEvent;
1152
+ throw new ManagedAttemptSnapshotError("event.unknownType");
909
1153
  }
910
1154
 
911
1155
  function isManagedPlainRecord(value: unknown): value is Record<string, unknown> {
@@ -913,17 +1157,62 @@ function isManagedPlainRecord(value: unknown): value is Record<string, unknown>
913
1157
  }
914
1158
 
915
1159
  /**
916
- * Holds managed-attempt assistant output above the public event stream. A
917
- * cancelled provider attempt is therefore unobservable to sessions and their
918
- * side-effect consumers. Non-managed streams bypass this object entirely.
1160
+ * Emit ONE bounded, shape-only diagnostic for a local managed-attempt failure
1161
+ * (snapshot machinery or staging buffer). Names only the envelope shape — the
1162
+ * failure stage, model identity, snapshot mode, staged counters, and (for
1163
+ * content stages only) the content block count — never raw text, thinking,
1164
+ * tool arguments, or any provider payload content. Emitted only for the
1165
+ * module-private local error identities, so a foreign error cannot self-label
1166
+ * into the log. Scoped to this stream invocation: not latched across
1167
+ * invocations, matching the #4443 precedent.
919
1168
  */
1169
+ function warnManagedSnapshotFailure(
1170
+ error: unknown,
1171
+ config: AgentLoopConfig,
1172
+ transaction: ManagedAttemptTransaction | undefined,
1173
+ ): void {
1174
+ // Identity, never self-labeling: only the module-private local error classes
1175
+ // may produce this diagnostic. A foreign error that sets a matching
1176
+ // `errorKind` (and could smuggle provider or prompt text in `stage`) is
1177
+ // ignored here, so nothing outside this module can reach the log.
1178
+ if (!(error instanceof ManagedAttemptSnapshotError) && !(error instanceof ManagedAttemptBufferOverflowError)) {
1179
+ return;
1180
+ }
1181
+ // Defense in depth: even an in-module regression cannot widen the log past
1182
+ // the closed stage vocabulary.
1183
+ const stage = MANAGED_LOCAL_FAILURE_STAGE_SET.has(error.stage) ? error.stage : "unknown";
1184
+ const diagnostic: Record<string, unknown> = {
1185
+ stage,
1186
+ errorKind: error.errorKind,
1187
+ model: config.model.id,
1188
+ provider: config.model.provider,
1189
+ snapshotMode: transaction?.snapshotMode ?? "none",
1190
+ };
1191
+ const staged = transaction?.stagedShape() ?? { stagedEventCount: 0, stagedBytes: 0, contentBlockCount: 0 };
1192
+ diagnostic.stagedEventCount = staged.stagedEventCount;
1193
+ diagnostic.stagedBytes = staged.stagedBytes;
1194
+ if (stage === "shell.content") {
1195
+ diagnostic.contentBlockCount = staged.contentBlockCount;
1196
+ }
1197
+ logger.warn("agent: managed fallback attempt rejected a local snapshot", diagnostic);
1198
+ }
1199
+
1200
+ /**
1201
+ * Holds provisional assistant output above the public event stream. Managed
1202
+ * fallback keeps the whole attempt atomic; non-managed escaped-argument
1203
+ * detection uses lossless snapshots until visible output or the staging cap
1204
+ * commits the transaction.
1205
+ */
1206
+ type ManagedAttemptBatchItem =
1207
+ | { type: "event"; event: AgentEvent }
1208
+ | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };
1209
+
920
1210
  class ManagedAttemptTransaction {
921
- #batch: Array<
922
- | { type: "event"; event: AgentEvent }
923
- | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
924
- > = [];
1211
+ #batch: ManagedAttemptBatchItem[] = [];
925
1212
  #stagedEventCount = 0;
926
1213
  #stagedBytes = 0;
1214
+ /** Shape snapshot retained across discard() for bounded failure diagnostics. */
1215
+ #lastStagedShape: { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } | undefined;
927
1216
  #discarded = false;
928
1217
  #committed = false;
929
1218
 
@@ -934,10 +1223,15 @@ class ManagedAttemptTransaction {
934
1223
  | undefined,
935
1224
  private readonly model: AgentLoopConfig["model"],
936
1225
  readonly scope?: AttemptScope,
1226
+ readonly snapshotMode: "managed" | "lossless" = "managed",
937
1227
  ) {}
938
1228
 
939
1229
  push(event: AgentEvent): void {
940
1230
  if (this.#committed) {
1231
+ if (event.type === "message_end" || event.type === "turn_end") {
1232
+ this.#batch.push({ type: "event", event });
1233
+ return;
1234
+ }
941
1235
  this.stream.push(event);
942
1236
  return;
943
1237
  }
@@ -949,16 +1243,20 @@ class ManagedAttemptTransaction {
949
1243
  }
950
1244
 
951
1245
  stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
952
- const partial = managedAssistantShell(message, this.model);
1246
+ const partial = this.#assistantSnapshot(message);
1247
+ if (this.#committed) {
1248
+ this.onAssistantMessageEvent?.(partial, this.#assistantEventSnapshot(event, partial));
1249
+ return;
1250
+ }
953
1251
  this.#batch.push({
954
1252
  type: "assistant_event",
955
1253
  message: partial,
956
- event: managedAssistantEventSnapshot(event, partial),
1254
+ event: this.#assistantEventSnapshot(event, partial),
957
1255
  });
958
1256
  }
959
1257
 
960
1258
  flush(): void {
961
- if (this.#discarded || this.#committed) return;
1259
+ if (this.#discarded) return;
962
1260
  for (const item of this.#batch) {
963
1261
  if (item.type === "assistant_event") {
964
1262
  this.onAssistantMessageEvent?.(item.message, item.event);
@@ -972,13 +1270,98 @@ class ManagedAttemptTransaction {
972
1270
  this.#committed = true;
973
1271
  }
974
1272
 
1273
+ flushNonTerminal(): void {
1274
+ if (this.#discarded || this.#committed) return;
1275
+ const retained: ManagedAttemptBatchItem[] = [];
1276
+ for (const item of this.#batch) {
1277
+ if (this.#isTerminalItem(item)) {
1278
+ retained.push(item);
1279
+ } else if (item.type === "assistant_event") {
1280
+ this.onAssistantMessageEvent?.(item.message, item.event);
1281
+ } else {
1282
+ this.stream.push(item.event);
1283
+ }
1284
+ }
1285
+ this.#batch = retained;
1286
+ }
1287
+
1288
+ commitCallbacksAndUpdates(): void {
1289
+ if (this.#discarded || this.#committed) return;
1290
+ for (const item of this.#batch) {
1291
+ if (item.type === "assistant_event") {
1292
+ this.onAssistantMessageEvent?.(item.message, item.event);
1293
+ } else if (item.event.type !== "message_end" && item.event.type !== "turn_end") {
1294
+ this.stream.push(item.event);
1295
+ }
1296
+ }
1297
+ this.#batch = this.#batch.filter(
1298
+ item => item.type === "event" && (item.event.type === "message_end" || item.event.type === "turn_end"),
1299
+ );
1300
+ this.#committed = true;
1301
+ }
1302
+
1303
+ replacePendingAssistantMessage(message: AssistantMessage): void {
1304
+ this.#batch = this.#batch.map(item => {
1305
+ if (item.type === "assistant_event") {
1306
+ return { ...item, message, event: this.#assistantEventSnapshot(item.event, message) };
1307
+ }
1308
+ if (item.event.type === "message_end") return { ...item, event: { ...item.event, message } };
1309
+ if (item.event.type === "turn_end") return { ...item, event: { ...item.event, message } };
1310
+ return item;
1311
+ });
1312
+ }
1313
+
1314
+ get committed(): boolean {
1315
+ return this.#committed;
1316
+ }
1317
+
1318
+ acceptedAssistantSnapshot(message: AssistantMessage): AssistantMessage {
1319
+ return this.#assistantSnapshot(message);
1320
+ }
1321
+
975
1322
  discard(): void {
1323
+ if (!this.#discarded) {
1324
+ this.#lastStagedShape = {
1325
+ stagedEventCount: this.#stagedEventCount,
1326
+ stagedBytes: this.#stagedBytes,
1327
+ contentBlockCount: this.#stagedContentBlockCount(),
1328
+ };
1329
+ }
976
1330
  this.#batch = [];
977
1331
  this.#stagedBytes = 0;
978
1332
  this.#stagedEventCount = 0;
979
1333
  this.#discarded = true;
980
1334
  }
981
1335
 
1336
+ /**
1337
+ * Shape-only view of what was staged when the attempt failed; never carries
1338
+ * content. Reports the retained pre-discard shape when the failing path
1339
+ * discarded the batch, and the live counters when it threw before discard.
1340
+ */
1341
+ stagedShape(): { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } {
1342
+ return (
1343
+ this.#lastStagedShape ?? {
1344
+ stagedEventCount: this.#stagedEventCount,
1345
+ stagedBytes: this.#stagedBytes,
1346
+ contentBlockCount: this.#stagedContentBlockCount(),
1347
+ }
1348
+ );
1349
+ }
1350
+
1351
+ #stagedContentBlockCount(): number {
1352
+ for (let i = this.#batch.length - 1; i >= 0; i--) {
1353
+ const item = this.#batch[i];
1354
+ const message =
1355
+ item.type === "assistant_event"
1356
+ ? item.message
1357
+ : "message" in item.event
1358
+ ? (item.event.message as unknown)
1359
+ : undefined;
1360
+ const content = managedProperty(message, "content");
1361
+ if (Array.isArray(content)) return content.length;
1362
+ }
1363
+ return 0;
1364
+ }
982
1365
  #wouldOverflow(bytes: number): boolean {
983
1366
  return (
984
1367
  this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
@@ -987,6 +1370,43 @@ class ManagedAttemptTransaction {
987
1370
  }
988
1371
 
989
1372
  #stage(event: AgentEvent): void {
1373
+ if (this.snapshotMode === "lossless") {
1374
+ const snapshot = this.#repairAssistantEvent(event);
1375
+ let rawBytes: number | undefined;
1376
+ try {
1377
+ rawBytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
1378
+ } catch {
1379
+ rawBytes = undefined;
1380
+ }
1381
+ if (rawBytes !== undefined && this.#wouldOverflow(rawBytes)) {
1382
+ this.flush();
1383
+ this.push(event);
1384
+ return;
1385
+ }
1386
+ let detached: AgentEvent;
1387
+ try {
1388
+ detached = this.#losslessAgentEventSnapshot(snapshot);
1389
+ } catch {
1390
+ this.discard();
1391
+ throw new ManagedAttemptSnapshotError("staging.losslessSnapshot");
1392
+ }
1393
+ let detachedBytes: number;
1394
+ try {
1395
+ detachedBytes = managedAttemptTextEncoder.encode(JSON.stringify(detached)).byteLength;
1396
+ } catch {
1397
+ this.discard();
1398
+ throw new ManagedAttemptSnapshotError("staging.measure");
1399
+ }
1400
+ if (this.#wouldOverflow(detachedBytes)) {
1401
+ this.flush();
1402
+ this.push(detached);
1403
+ return;
1404
+ }
1405
+ this.#batch.push({ type: "event", event: detached });
1406
+ this.#stagedEventCount++;
1407
+ this.#stagedBytes += detachedBytes;
1408
+ return;
1409
+ }
990
1410
  // Measure the raw event FIRST so an oversized payload is rejected
991
1411
  // before the snapshot duplicates it — the staged-byte cap exists to
992
1412
  // bound memory, so cloning ahead of the check would defeat it.
@@ -1001,37 +1421,28 @@ class ManagedAttemptTransaction {
1001
1421
  }
1002
1422
  if (bytes !== undefined && this.#wouldOverflow(bytes)) {
1003
1423
  this.discard();
1004
- throw new ManagedAttemptBufferOverflowError();
1424
+ throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
1005
1425
  }
1006
- const detailed = managedAttemptSnapshotDetailed(this.#repairAssistantEvent(event));
1007
- let snapshot = detailed.snapshot;
1008
- if (bytes === undefined || detailed.degraded) {
1009
- // Account the bytes of what is actually retained: a degraded
1010
- // snapshot replaces non-JSON leaves with placeholders, so the raw
1011
- // pre-measure (which omits e.g. function-valued properties) can
1012
- // undercount the staged form.
1013
- try {
1014
- bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
1015
- } catch {
1016
- try {
1017
- snapshot = sanitizedDetachedClone(snapshot);
1018
- bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
1019
- } catch {
1020
- bytes = undefined;
1021
- }
1022
- }
1023
- if (bytes === undefined) {
1024
- // The sanitizer's output is total (detached, JSON-safe), so this
1025
- // is unreachable unless the sanitizer itself regresses. Fail as a
1026
- // dedicated local error: it carries no transport facts, so it is
1027
- // non-retryable and can never be misattributed to the provider.
1028
- this.discard();
1029
- throw new ManagedAttemptSnapshotError();
1030
- }
1031
- if (this.#wouldOverflow(bytes)) {
1032
- this.discard();
1033
- throw new ManagedAttemptBufferOverflowError();
1034
- }
1426
+ const repaired = this.#repairAssistantEvent(event);
1427
+ const detailed = managedAttemptSnapshotDetailed(repaired);
1428
+ const snapshot = detailed.snapshot;
1429
+ // Always account the exact detached value. A live custom class can use
1430
+ // prototype `toJSON()` to serialize compactly while structuredClone
1431
+ // removes that serializer and exposes a larger or JSON-hostile own value.
1432
+ // Reusing the live pre-measure would therefore accept an unserializable
1433
+ // snapshot or undercount the retained bytes.
1434
+ bytes = detailed.jsonBytes;
1435
+ if (bytes === undefined) {
1436
+ // The sanitizer's output is total (detached, JSON-safe), so this is
1437
+ // unreachable unless the sanitizer itself regresses. Fail as a
1438
+ // dedicated local error: it carries no transport facts, so it is
1439
+ // non-retryable and can never be misattributed to the provider.
1440
+ this.discard();
1441
+ throw new ManagedAttemptSnapshotError("staging.sanitize");
1442
+ }
1443
+ if (this.#wouldOverflow(bytes)) {
1444
+ this.discard();
1445
+ throw new ManagedAttemptBufferOverflowError("overflow.staged");
1035
1446
  }
1036
1447
  this.#batch.push({ type: "event", event: snapshot });
1037
1448
  this.#stagedEventCount += 1;
@@ -1040,6 +1451,7 @@ class ManagedAttemptTransaction {
1040
1451
  }
1041
1452
 
1042
1453
  #repairAssistantEvent(event: AgentEvent): AgentEvent {
1454
+ if (this.snapshotMode === "lossless") return event;
1043
1455
  if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
1044
1456
  return event.message.role === "assistant"
1045
1457
  ? { ...event, message: managedAssistantShell(event.message, this.model) }
@@ -1063,6 +1475,52 @@ class ManagedAttemptTransaction {
1063
1475
  }
1064
1476
  return event;
1065
1477
  }
1478
+
1479
+ #losslessSnapshot<T>(value: T): T {
1480
+ return losslessDetachedClone(value);
1481
+ }
1482
+
1483
+ #losslessAgentEventSnapshot(event: AgentEvent): AgentEvent {
1484
+ switch (event.type) {
1485
+ case "message_start":
1486
+ case "message_end":
1487
+ return { ...event, message: this.#losslessSnapshot(event.message) };
1488
+ case "message_update": {
1489
+ const message = this.#losslessSnapshot(event.message);
1490
+ if (message.role !== "assistant") return { ...event, message };
1491
+ const assistantMessageEvent = this.#assistantEventSnapshot(event.assistantMessageEvent, message);
1492
+ return { ...event, message, assistantMessageEvent };
1493
+ }
1494
+ case "turn_end":
1495
+ return {
1496
+ ...event,
1497
+ message: this.#losslessSnapshot(event.message),
1498
+ toolResults: this.#losslessSnapshot(event.toolResults),
1499
+ };
1500
+ default:
1501
+ return this.#losslessSnapshot(event);
1502
+ }
1503
+ }
1504
+
1505
+ #assistantSnapshot(message: AssistantMessage): AssistantMessage {
1506
+ return this.snapshotMode === "lossless"
1507
+ ? this.#losslessSnapshot(message)
1508
+ : managedAssistantShell(message, this.model);
1509
+ }
1510
+
1511
+ #assistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
1512
+ if (this.snapshotMode === "managed") return managedAssistantEventSnapshot(event, message);
1513
+ const snapshot = this.#losslessSnapshot(event);
1514
+ if (snapshot.type === "done") return { ...snapshot, message };
1515
+ if (snapshot.type === "error") return { ...snapshot, error: message };
1516
+ if (snapshot.type === "toolChoiceIncapability") return snapshot;
1517
+ return { ...snapshot, partial: message };
1518
+ }
1519
+
1520
+ #isTerminalItem(item: ManagedAttemptBatchItem): boolean {
1521
+ if (item.type === "assistant_event") return item.event.type === "done" || item.event.type === "error";
1522
+ return item.event.type === "message_end" || item.event.type === "turn_end";
1523
+ }
1066
1524
  }
1067
1525
 
1068
1526
  /**
@@ -1480,6 +1938,15 @@ async function runLoopBody(
1480
1938
  // Fires at most one repaired resend per run for the reasoning-content replay
1481
1939
  // breaker below (DeepSeek "reasoning_content ... must be passed back").
1482
1940
  let reasoningContentRepairAttempted = false;
1941
+ // Consecutive resamples spent on the current turn because its tool arguments
1942
+ // arrived `\uXXXX`-escaped. Reset once a turn gets past the check, so every
1943
+ // turn is judged on its own wire bytes.
1944
+ let escapedNonAsciiResampleAttempt = 0;
1945
+ // A queue-backed dynamic tool choice belongs to the logical turn, not to one
1946
+ // wire attempt. Capture it once and replay it across escaped-argument
1947
+ // resamples; reset only after a response is accepted for normal processing.
1948
+ let escapedNonAsciiToolChoiceCaptured = false;
1949
+ let escapedNonAsciiToolChoice: ToolChoice | undefined;
1483
1950
  let previousMalformedToolSignatures = new Set<string>();
1484
1951
  type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
1485
1952
  let pendingRecovery:
@@ -1509,11 +1976,15 @@ async function runLoopBody(
1509
1976
  const scope =
1510
1977
  initialScope ?? (firstTurn ? config.initialScope : undefined) ?? config.attemptMinter?.mint("main");
1511
1978
  initialScope = undefined;
1512
- const transaction =
1979
+ const managedTransaction =
1513
1980
  initialTransaction ??
1514
1981
  (config.fallbackManaged
1515
1982
  ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
1516
1983
  : undefined);
1984
+ const escapedToolTransaction = config.fallbackManaged
1985
+ ? undefined
1986
+ : new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope, "lossless");
1987
+ const transaction = managedTransaction ?? escapedToolTransaction;
1517
1988
  initialTransaction = undefined;
1518
1989
  const attemptScope = transaction?.scope ?? scope;
1519
1990
  lastAttemptScope = attemptScope;
@@ -1591,10 +2062,16 @@ async function runLoopBody(
1591
2062
  // Stream assistant response
1592
2063
  let recovered: HarmonyRecoveredToolCall | undefined;
1593
2064
  let message: AssistantMessage;
1594
- const attemptTransaction = transaction;
2065
+ const attemptTransaction = managedTransaction;
1595
2066
  const recoveryAttempt = pendingRecovery;
1596
2067
  const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
1597
2068
  try {
2069
+ const getLogicalTurnToolChoice = (): ToolChoice | undefined => {
2070
+ if (escapedNonAsciiToolChoiceCaptured) return escapedNonAsciiToolChoice;
2071
+ escapedNonAsciiToolChoice = config.getToolChoice?.();
2072
+ escapedNonAsciiToolChoiceCaptured = true;
2073
+ return escapedNonAsciiToolChoice;
2074
+ };
1598
2075
  const attemptConfig = attemptTransaction
1599
2076
  ? {
1600
2077
  ...config,
@@ -1623,7 +2100,7 @@ async function runLoopBody(
1623
2100
  currentContext,
1624
2101
  attemptConfig,
1625
2102
  loopSignal,
1626
- attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
2103
+ transaction ? (transaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
1627
2104
  telemetry,
1628
2105
  invokeAgentSpan,
1629
2106
  stepCounter,
@@ -1637,6 +2114,8 @@ async function runLoopBody(
1637
2114
  forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
1638
2115
  }
1639
2116
  : undefined,
2117
+ escapedToolTransaction,
2118
+ recoveryAttempt ? undefined : { value: getLogicalTurnToolChoice() },
1640
2119
  );
1641
2120
  const detection = detectHarmonyLeakInAssistantMessage(message);
1642
2121
  if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
@@ -1649,6 +2128,7 @@ async function runLoopBody(
1649
2128
  } catch (err) {
1650
2129
  if (!(err instanceof HarmonyLeakInterruption)) {
1651
2130
  const failureMessage = managedFailureMessage(err, config);
2131
+ if (config.fallbackManaged) warnManagedSnapshotFailure(err, config, transaction);
1652
2132
  if (config.fallbackManaged && transaction && managedContextOverflow(failureMessage, config)) {
1653
2133
  transaction.discard();
1654
2134
  currentContext.messages.splice(contextMessageCount);
@@ -1694,6 +2174,18 @@ async function runLoopBody(
1694
2174
  }
1695
2175
  await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
1696
2176
  } else {
2177
+ if (escapedToolTransaction?.committed) {
2178
+ const contaminated = currentContext.messages.at(-1);
2179
+ if (contaminated?.role !== "assistant") throw err;
2180
+ const sanitized = escapedToolTransaction.acceptedAssistantSnapshot({
2181
+ ...contaminated,
2182
+ content: [],
2183
+ stopReason: "aborted",
2184
+ providerPayload: undefined,
2185
+ });
2186
+ escapedToolTransaction.replacePendingAssistantMessage(sanitized);
2187
+ escapedToolTransaction.flush();
2188
+ }
1697
2189
  if (harmonyRetryAttempt >= 2) {
1698
2190
  await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
1699
2191
  throw new Error(
@@ -1784,7 +2276,7 @@ async function runLoopBody(
1784
2276
  }
1785
2277
  }
1786
2278
 
1787
- // Escaped-non-ASCII tool arguments: managed bounded turn resample.
2279
+ // Escaped-non-ASCII tool arguments: bounded turn resample.
1788
2280
  //
1789
2281
  // Arguments that spell a printable non-ASCII character as `\uXXXX`
1790
2282
  // instead of literal UTF-8 are a wire-format defect, not a decision the
@@ -1792,26 +2284,50 @@ async function runLoopBody(
1792
2284
  // mistyped nibble decodes to a different, equally valid character, so it
1793
2285
  // can never be verified or repaired after the fact. Reporting it as a
1794
2286
  // tool error spends the whole turn and writes the literal escape syntax
1795
- // back into the context the model samples from next. A managed
1796
- // invocation instead drops the defective turn and reports it through the
1797
- // typed `escaped_arguments_discarded` outcome so the session policy owns
1798
- // a bounded same-model retry; the defect is never treated as provider
1799
- // evidence, so the fallback chain never advances on it. Unmanaged runs
1800
- // keep the per-call rejection in `executeToolCalls` as the terminal
1801
- // answer.
2287
+ // back into the context the model samples from next. Drop the defective
2288
+ // turn and re-request instead; the per-call rejection in
2289
+ // `executeToolCalls` stays as the terminal answer once this budget is
2290
+ // spent. Managed fallback reports the discarded attempt through the
2291
+ // typed `escaped_arguments_discarded` outcome so the session policy
2292
+ // owns a bounded same-model retry; the defect is never treated as
2293
+ // provider evidence, so the fallback chain never advances on it.
1802
2294
  if (
1803
- config.fallbackManaged &&
1804
2295
  message.stopReason !== "error" &&
1805
2296
  message.stopReason !== "aborted" &&
2297
+ escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
2298
+ !escapedToolTransaction?.committed &&
1806
2299
  hasEscapedNonAsciiToolCall(message)
1807
2300
  ) {
1808
- transaction?.discard();
1809
- currentContext.messages.splice(contextMessageCount);
1810
- newMessages.splice(newMessageCount);
1811
- await config.onManagedAttemptOutcome?.(managedEscapedArgumentsOutcome(message, transaction?.scope));
1812
- stream.end(newMessages);
1813
- return;
2301
+ escapedNonAsciiResampleAttempt++;
2302
+ escapedToolTransaction?.discard();
2303
+ // The defective turn was already committed to the context by the
2304
+ // streaming path. Remove that exact object rather than assuming it is
2305
+ // still the tail: callbacks may append user/system history while the
2306
+ // response settles, and none of that history belongs to this retry.
2307
+ removeCommittedAssistantMessage(currentContext.messages, message);
2308
+ // A managed invocation ends the run here and reports the discarded
2309
+ // attempt to the session's fallback policy through the typed
2310
+ // outcome below; the policy owns the same-model bounded retry and
2311
+ // only falls back once it declines. The wire defect is not provider
2312
+ // evidence, so the outcome deliberately carries no transport facts
2313
+ // and the fallback chain never advances on it.
2314
+ if (config.fallbackManaged) {
2315
+ transaction?.discard();
2316
+ currentContext.messages.splice(contextMessageCount);
2317
+ newMessages.splice(newMessageCount);
2318
+ await config.onManagedAttemptOutcome?.({
2319
+ type: "escaped_arguments_discarded",
2320
+ message,
2321
+ scope: transaction?.scope,
2322
+ });
2323
+ stream.end(newMessages);
2324
+ return;
2325
+ }
2326
+ continue;
1814
2327
  }
2328
+ escapedNonAsciiResampleAttempt = 0;
2329
+ escapedNonAsciiToolChoiceCaptured = false;
2330
+ escapedNonAsciiToolChoice = undefined;
1815
2331
 
1816
2332
  const overflow = managedContextOverflow(message, config);
1817
2333
  if (config.fallbackManaged && overflow) {
@@ -1866,7 +2382,28 @@ async function runLoopBody(
1866
2382
  }
1867
2383
 
1868
2384
  // One provider invocation is committed before any tool can run.
1869
- transaction?.flush();
2385
+ if (escapedToolTransaction) {
2386
+ const acceptedMessage = escapedToolTransaction.acceptedAssistantSnapshot(message);
2387
+ const contextIndex = currentContext.messages.lastIndexOf(message);
2388
+ if (contextIndex >= 0) currentContext.messages[contextIndex] = acceptedMessage;
2389
+ const producedIndex = newMessages.lastIndexOf(message);
2390
+ if (producedIndex >= 0) newMessages[producedIndex] = acceptedMessage;
2391
+ message = acceptedMessage;
2392
+ escapedToolTransaction.flushNonTerminal();
2393
+ // Tool-call updates are staged so an escaped turn can disappear
2394
+ // atomically. Once accepted, drain every published update through the
2395
+ // Agent/AgentSession consumers before dispatch: streaming edit guards
2396
+ // can then abort the run before any tool execute() is entered.
2397
+ if (message.stopReason !== "aborted" && message.stopReason !== "error") {
2398
+ if (loopSignal.aborted) message.stopReason = "aborted";
2399
+ if (stream.hasActiveConsumer) await stream.waitForConsumerDrain(new AbortController().signal);
2400
+ if (loopSignal.aborted) message.stopReason = "aborted";
2401
+ }
2402
+ escapedToolTransaction.replacePendingAssistantMessage(message);
2403
+ escapedToolTransaction.flush();
2404
+ } else {
2405
+ transaction?.flush();
2406
+ }
1870
2407
  if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1871
2408
  await config.onManagedAttemptAccepted?.();
1872
2409
  }
@@ -2112,6 +2649,8 @@ async function streamAssistantResponse(
2112
2649
  disableTools?: boolean;
2113
2650
  forceAutoToolChoice?: boolean;
2114
2651
  },
2652
+ provisionalToolTransaction?: ManagedAttemptTransaction,
2653
+ toolChoiceOverride?: { value: ToolChoice | undefined },
2115
2654
  ): Promise<AssistantMessage> {
2116
2655
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
2117
2656
  let messages = context.messages;
@@ -2174,7 +2713,11 @@ async function streamAssistantResponse(
2174
2713
 
2175
2714
  // Synthetic recovery requests choose their tool mode explicitly below and
2176
2715
  // must never consume a queued dynamic choice intended for an ordinary turn.
2177
- const dynamicToolChoice = recoveryMode ? undefined : config.getToolChoice?.();
2716
+ const dynamicToolChoice = recoveryMode
2717
+ ? undefined
2718
+ : toolChoiceOverride
2719
+ ? toolChoiceOverride.value
2720
+ : config.getToolChoice?.();
2178
2721
  const dynamicReasoning = config.getReasoning?.();
2179
2722
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
2180
2723
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
@@ -2437,6 +2980,9 @@ async function streamAssistantResponse(
2437
2980
  : event.partial;
2438
2981
  context.messages.push(partialMessage);
2439
2982
  addedPartial = true;
2983
+ if (provisionalToolTransaction) {
2984
+ config.onProvisionalAssistantMessageEvent?.(partialMessage, event);
2985
+ }
2440
2986
  stream.push({ type: "message_start", message: { ...partialMessage }, scope });
2441
2987
  break;
2442
2988
 
@@ -2460,9 +3006,23 @@ async function streamAssistantResponse(
2460
3006
  partialMessage = config.fallbackManaged
2461
3007
  ? managedAssistantShell(event.partial, config.model)
2462
3008
  : event.partial;
2463
- const partialEvent = config.fallbackManaged ? { ...event, partial: partialMessage } : event;
3009
+ // Normalize through the managed event snapshot instead of a
3010
+ // naive `{ ...event }` spread: spreading copies only own
3011
+ // enumerable properties, so a payload-class event carrying its
3012
+ // fields on prototype getters would lose `type`/`delta` here and
3013
+ // deterministically fail the whole run as `event.unknownType`
3014
+ // downstream. The snapshot repairs benign class/prototype shapes
3015
+ // and keeps the named fail-fast diagnostics for hostile ones.
3016
+ const partialEvent = config.fallbackManaged
3017
+ ? managedAssistantEventSnapshot(event, partialMessage)
3018
+ : event;
2464
3019
  context.messages[context.messages.length - 1] = partialMessage;
2465
- config.onAssistantMessageEvent?.(partialMessage, partialEvent);
3020
+ if (provisionalToolTransaction) {
3021
+ config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent);
3022
+ provisionalToolTransaction.stageAssistantMessageEvent(partialMessage, partialEvent);
3023
+ } else {
3024
+ config.onAssistantMessageEvent?.(partialMessage, partialEvent);
3025
+ }
2466
3026
  if (signal?.aborted) continue;
2467
3027
  stream.push({
2468
3028
  type: "message_update",
@@ -2470,6 +3030,13 @@ async function streamAssistantResponse(
2470
3030
  message: { ...partialMessage },
2471
3031
  scope,
2472
3032
  });
3033
+ // Preserve ordinary text streaming. Once visible text is
3034
+ // published, this response can no longer be silently resampled;
3035
+ // a later escaped tool call therefore falls through to the
3036
+ // existing terminal per-call rejection instead.
3037
+ if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
3038
+ provisionalToolTransaction?.commitCallbacksAndUpdates();
3039
+ }
2473
3040
  }
2474
3041
  break;
2475
3042
 
@@ -2571,19 +3138,16 @@ function toolCallNames(tool: { name: string; customWireName?: string }): string[
2571
3138
  const TOOL_DISCOVERY_NAME = "search_tool_bm25";
2572
3139
 
2573
3140
  /**
2574
- * Active tool a call name dispatches to. Tools emitted via OpenAI's custom-tool
2575
- * path (e.g. `apply_patch` on GPT-5) come back under their wire-level name,
2576
- * which may differ from the harness-internal `name`. Match on either, preferring
2577
- * `name` for determinism if both somehow collide.
3141
+ * Active tool a call name dispatches to.
3142
+ *
3143
+ * The rule itself lives with the dispatch-identity binding so execution and the identity
3144
+ * bound onto the emitted event can never diverge.
2578
3145
  */
2579
3146
  function findActiveTool<T extends { name: string; customWireName?: string }>(
2580
3147
  tools: ReadonlyArray<T> | undefined,
2581
3148
  callName: string,
2582
3149
  ): T | undefined {
2583
- return (
2584
- tools?.find(tool => tool.name === callName) ??
2585
- tools?.find(tool => tool.customWireName !== undefined && tool.customWireName === callName)
2586
- );
3150
+ return activeToolForCallName(tools, callName);
2587
3151
  }
2588
3152
 
2589
3153
  /**
@@ -2645,6 +3209,7 @@ async function executeToolCalls(
2645
3209
  toolCall,
2646
3210
  tool: findActiveTool(tools, toolCall.name),
2647
3211
  args: toolCall.arguments as Record<string, unknown>,
3212
+ eventFields: undefined as { toolCallId: string; toolName: string; intent: string | undefined } | undefined,
2648
3213
  started: false,
2649
3214
  result: undefined as AgentToolResult<any> | undefined,
2650
3215
  isError: false,
@@ -2678,29 +3243,44 @@ async function executeToolCalls(
2678
3243
  const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
2679
3244
  if (record.resultEmitted) return;
2680
3245
  const { toolCall } = record;
2681
- if (!record.started) {
2682
- stream.push({
3246
+ const eventFields =
3247
+ record.eventFields ?? ({ toolCallId: toolCall.id, toolName: toolCall.name, intent: toolCall.intent } as const);
3248
+ // A call that was skipped or aborted before dispatch still owes the stream a
3249
+ // start/end PAIR, because every consumer downstream is built around results
3250
+ // arriving in pairs. Both halves are marked as what they are — pairing only — so a
3251
+ // consumer that publishes "this tool is running" can leave them out while relays,
3252
+ // history, and result handling keep seeing the same events they always did.
3253
+ const dispatched = record.started;
3254
+ if (!dispatched) {
3255
+ // No dispatch provenance is bound here. `record.tool` is the object this call
3256
+ // WOULD have run, and binding it would let a consumer resolve a canonical
3257
+ // built-in label for a tool whose `execute` was never entered.
3258
+ const startEvent: AgentEvent = {
2683
3259
  type: "tool_execution_start",
2684
- toolCallId: toolCall.id,
2685
- toolName: toolCall.name,
3260
+ toolCallId: eventFields.toolCallId,
3261
+ toolName: eventFields.toolName,
2686
3262
  args: record.args,
2687
- intent: toolCall.intent,
3263
+ intent: eventFields.intent,
2688
3264
  scope,
2689
- });
3265
+ };
3266
+ markNonDispatchedToolEvent(startEvent);
3267
+ stream.push(startEvent);
2690
3268
  }
2691
- stream.push({
3269
+ const endEvent: AgentEvent = {
2692
3270
  type: "tool_execution_end",
2693
- toolCallId: toolCall.id,
2694
- toolName: toolCall.name,
3271
+ toolCallId: eventFields.toolCallId,
3272
+ toolName: eventFields.toolName,
2695
3273
  result,
2696
3274
  isError,
2697
3275
  scope,
2698
- });
3276
+ };
3277
+ if (!dispatched) markNonDispatchedToolEvent(endEvent);
3278
+ stream.push(endEvent);
2699
3279
 
2700
3280
  const toolResultMessage: ToolResultMessage = {
2701
3281
  role: "toolResult",
2702
- toolCallId: toolCall.id,
2703
- toolName: toolCall.name,
3282
+ toolCallId: eventFields.toolCallId,
3283
+ toolName: eventFields.toolName,
2704
3284
  content: result.content,
2705
3285
  details: result.details,
2706
3286
  isError,
@@ -2716,6 +3296,62 @@ async function executeToolCalls(
2716
3296
  stream.push({ type: "message_end", message: toolResultMessage, scope });
2717
3297
  };
2718
3298
 
3299
+ /**
3300
+ * Prepare every value needed to publish and invoke one dispatch before claiming that it
3301
+ * started. Once this returns, the path from a successful start publication to intrinsic
3302
+ * invocation contains only trusted local bindings and values.
3303
+ */
3304
+ const prepareToolDispatch = (
3305
+ record: (typeof records)[number],
3306
+ startArgs: Record<string, unknown>,
3307
+ executionArgs: Record<string, unknown>,
3308
+ executionSignal: AbortSignal | undefined,
3309
+ effectiveArgs: Record<string, unknown>,
3310
+ toolContext: AgentToolContext | undefined,
3311
+ ): { startEvent: AgentEvent; invocationArguments: Parameters<AgentTool["execute"]> } => {
3312
+ const eventFields = {
3313
+ toolCallId: record.toolCall.id,
3314
+ toolName: record.toolCall.name,
3315
+ intent: record.toolCall.intent,
3316
+ };
3317
+ const startEvent: AgentEvent = {
3318
+ type: "tool_execution_start",
3319
+ toolCallId: eventFields.toolCallId,
3320
+ toolName: eventFields.toolName,
3321
+ args: startArgs,
3322
+ intent: eventFields.intent,
3323
+ scope,
3324
+ };
3325
+ // Retain the exact values the start/end/result pair must share. No later event in
3326
+ // this dispatch needs to re-read a stateful ToolCall property.
3327
+ record.eventFields = eventFields;
3328
+ bindDispatchedToolIdentity(startEvent, record.tool);
3329
+ const onUpdate: NonNullable<Parameters<AgentTool["execute"]>[3]> = partialResult => {
3330
+ stream.push({
3331
+ type: "tool_execution_update",
3332
+ toolCallId: eventFields.toolCallId,
3333
+ toolName: eventFields.toolName,
3334
+ args: effectiveArgs,
3335
+ partialResult: coerceToolResult(partialResult).result,
3336
+ scope,
3337
+ });
3338
+ };
3339
+ const invocationArguments = [
3340
+ eventFields.toolCallId,
3341
+ executionArgs,
3342
+ executionSignal,
3343
+ onUpdate,
3344
+ toolContext,
3345
+ ] as Parameters<AgentTool["execute"]>;
3346
+ return { startEvent, invocationArguments };
3347
+ };
3348
+
3349
+ /** Mark dispatch only after the fully prepared start was successfully published. */
3350
+ const publishToolDispatch = (record: (typeof records)[number], startEvent: AgentEvent): void => {
3351
+ stream.push(startEvent);
3352
+ record.started = true;
3353
+ };
3354
+
2719
3355
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
2720
3356
  if (interruptState.triggered) {
2721
3357
  // Skip both span emission and the collector orphan record here. The
@@ -2744,15 +3380,6 @@ async function executeToolCalls(
2744
3380
  }
2745
3381
  }
2746
3382
  record.args = argsForExecution;
2747
- record.started = true;
2748
- stream.push({
2749
- type: "tool_execution_start",
2750
- toolCallId: toolCall.id,
2751
- toolName: toolCall.name,
2752
- args: argsForExecution,
2753
- intent: toolCall.intent,
2754
- scope,
2755
- });
2756
3383
 
2757
3384
  const toolSpan = startExecuteToolSpan(telemetry, {
2758
3385
  tool,
@@ -2773,15 +3400,19 @@ async function executeToolCalls(
2773
3400
  try {
2774
3401
  if (toolCall.incompleteArguments) {
2775
3402
  record.argumentValidationFailed = true;
2776
- // The provider flagged this call's argument JSON as truncated
2777
- // (the model hit its output-token limit mid-call). Executing the
2778
- // best-effort partial parse would run the tool on wrong input, so
2779
- // reject with a retryable, actionable error instead.
2780
- throw new Error(
2781
- `Tool call "${toolCall.name}" was cut off before its arguments finished streaming ` +
2782
- `(the response hit its output token limit). The partial arguments cannot be executed. ` +
2783
- `Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`,
2784
- );
3403
+ // The provider flagged this call's arguments as unsafe to execute.
3404
+ // The typed reason selects accurate recovery guidance; callers that
3405
+ // only read the boolean still get a safe, actionable rejection.
3406
+ const reason = toolCall.incompleteArgumentsReason;
3407
+ const detail =
3408
+ reason === "malformed"
3409
+ ? `The terminal arguments for tool call "${toolCall.name}" did not decode to a valid JSON object. The arguments cannot be executed. Re-issue the call with valid, complete arguments.`
3410
+ : reason === "conflicting"
3411
+ ? `The streamed and terminal arguments for tool call "${toolCall.name}" disagree. The arguments cannot be executed safely. Re-issue the call with consistent arguments.`
3412
+ : reason === "ambiguous"
3413
+ ? `The identity of tool call "${toolCall.name}" was ambiguous on the wire (duplicate call id or id/call_id collision), so its arguments cannot be safely attributed. Re-issue the call.`
3414
+ : `Tool call "${toolCall.name}" was cut off before its arguments finished streaming (the response hit its output token limit). The partial arguments cannot be executed. Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`;
3415
+ throw new Error(detail);
2785
3416
  }
2786
3417
  if (toolCall.escapedNonAsciiArguments) {
2787
3418
  record.argumentValidationFailed = true;
@@ -2860,22 +3491,25 @@ async function executeToolCalls(
2860
3491
  const toolContext = scope
2861
3492
  ? (Object.assign(baseToolContext ?? {}, { attemptScope: scope }) as AgentToolContext)
2862
3493
  : baseToolContext;
2863
- const execution = tool.execute(
2864
- toolCall.id,
2865
- transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
2866
- tool.nonAbortable ? undefined : toolSignal,
2867
- partialResult => {
2868
- stream.push({
2869
- type: "tool_execution_update",
2870
- toolCallId: toolCall.id,
2871
- toolName: toolCall.name,
2872
- args: effectiveArgs,
2873
- partialResult: coerceToolResult(partialResult).result,
2874
- scope,
2875
- });
2876
- },
3494
+ const executionArgs = transformToolCallArguments
3495
+ ? transformToolCallArguments(effectiveArgs, toolCall.name)
3496
+ : effectiveArgs;
3497
+ const executionSignal = tool.nonAbortable ? undefined : toolSignal;
3498
+ const execute = tool.execute;
3499
+ if (typeof execute !== "function")
3500
+ throw new Error(`Tool ${toolCall.name} has no executable implementation`);
3501
+ const { startEvent, invocationArguments } = prepareToolDispatch(
3502
+ record,
3503
+ argsForExecution,
3504
+ executionArgs,
3505
+ executionSignal,
3506
+ effectiveArgs,
2877
3507
  toolContext,
2878
3508
  );
3509
+ // Preparation is complete. A successful publication is the only transition
3510
+ // that marks this record dispatched; intrinsic invocation then consumes locals.
3511
+ publishToolDispatch(record, startEvent);
3512
+ const execution = intrinsicReflectApply(execute, tool, invocationArguments);
2879
3513
  const rawResult = await execution;
2880
3514
  const coerced = coerceToolResult(rawResult);
2881
3515
  result = coerced.result;
@@ -3067,20 +3701,30 @@ function createAbortedToolResult(
3067
3701
  details: {},
3068
3702
  };
3069
3703
 
3070
- stream.push({
3704
+ // Nothing was dispatched for this call: the turn errored or was aborted before any
3705
+ // `Tool.execute` could be entered, and this pair exists only so the stream keeps
3706
+ // delivering results in start/end PAIRS. Both halves say so, and neither binds the
3707
+ // tool the call would have run, so a consumer that publishes "this tool is running"
3708
+ // can leave them out while relays, history, and result handling see what they always
3709
+ // did.
3710
+ const startEvent: AgentEvent = {
3071
3711
  type: "tool_execution_start",
3072
3712
  toolCallId: toolCall.id,
3073
3713
  toolName: toolCall.name,
3074
3714
  args: toolCall.arguments,
3075
3715
  intent: toolCall.intent,
3076
- });
3077
- stream.push({
3716
+ };
3717
+ markNonDispatchedToolEvent(startEvent);
3718
+ stream.push(startEvent);
3719
+ const endEvent: AgentEvent = {
3078
3720
  type: "tool_execution_end",
3079
3721
  toolCallId: toolCall.id,
3080
3722
  toolName: toolCall.name,
3081
3723
  result,
3082
3724
  isError: true,
3083
- });
3725
+ };
3726
+ markNonDispatchedToolEvent(endEvent);
3727
+ stream.push(endEvent);
3084
3728
 
3085
3729
  const toolResultMessage: ToolResultMessage = {
3086
3730
  role: "toolResult",