@gajae-code/agent-core 0.13.2 → 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 &&
@@ -266,6 +334,7 @@ function managedContextOverflowOutcome(message: AssistantMessage, scope?: Attemp
266
334
  function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
267
335
  const errorMessage = managedProperty(error, "message");
268
336
  const transportFailure = managedTransportFailure(error);
337
+ const errorKind = managedProperty(error, "errorKind");
269
338
  let fallbackMessage = "Managed fallback attempt failed";
270
339
  if (typeof errorMessage === "string") fallbackMessage = errorMessage;
271
340
  else {
@@ -292,6 +361,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
292
361
  stopReason: "error",
293
362
  errorMessage: fallbackMessage,
294
363
  ...(transportFailure ? { transportFailure } : {}),
364
+ ...(errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow" ? { errorKind } : {}),
295
365
  timestamp: Date.now(),
296
366
  };
297
367
  }
@@ -583,6 +653,19 @@ function publishAgentEnd(
583
653
  */
584
654
  export const MANAGED_SNAPSHOT_MAX_NODES = 100_000;
585
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
+
586
669
  /**
587
670
  * Cycle-aware deep clone that always returns a detached, JSON-serializable
588
671
  * value. Used whenever a detached snapshot cannot be safely obtained or
@@ -717,12 +800,33 @@ export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_S
717
800
  * (e.g. a live `Headers` inside a provider error's `transportFailure` from a
718
801
  * legacy payload), and a thrown `DataCloneError` here would mask the real
719
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.
720
810
  */
721
- function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; degraded: boolean } {
811
+ function managedSnapshotJsonBytes(value: unknown): number | undefined {
722
812
  try {
723
- return { snapshot: structuredClone(value), degraded: false };
813
+ const serialized = JSON.stringify(value);
814
+ return serialized === undefined ? undefined : managedAttemptTextEncoder.encode(serialized).byteLength;
724
815
  } catch {
725
- 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) };
726
830
  }
727
831
  }
728
832
 
@@ -730,6 +834,93 @@ function managedAttemptSnapshot<T>(value: T): T {
730
834
  return managedAttemptSnapshotDetailed(value).snapshot;
731
835
  }
732
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
+
733
924
  /**
734
925
  * Recover the required assistant-message shell when a managed snapshot degrades
735
926
  * at its root (notably for Proxy-wrapped provider messages). Only known fields
@@ -738,14 +929,39 @@ function managedAttemptSnapshot<T>(value: T): T {
738
929
  */
739
930
  function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
740
931
  const detailed = managedAttemptSnapshotDetailed(value);
741
- const source = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : value;
742
- 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");
743
947
  const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
744
- if (!Array.isArray(rawContent)) throw new ManagedAttemptSnapshotError();
745
- const content = rawContent.flatMap(block => {
746
- const normalized = managedAssistantContent(block);
747
- return normalized ? [normalized] : [];
748
- });
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);
749
965
  const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
750
966
  const api = managedProperty(source, "api");
751
967
  const provider = managedProperty(source, "provider");
@@ -785,6 +1001,11 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
785
1001
  };
786
1002
  }
787
1003
 
1004
+ function managedContentBlock(block: unknown): AssistantMessage["content"] {
1005
+ const normalized = managedAssistantContent(block);
1006
+ return normalized ? [normalized] : [];
1007
+ }
1008
+
788
1009
  function managedAssistantContent(value: unknown): AssistantMessage["content"][number] | undefined {
789
1010
  if (!isManagedPlainRecord(value)) return undefined;
790
1011
  const type = managedProperty(value, "type");
@@ -809,6 +1030,8 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
809
1030
  const intent = managedProperty(value, "intent");
810
1031
  const customWireName = managedProperty(value, "customWireName");
811
1032
  const incompleteArguments = managedProperty(value, "incompleteArguments");
1033
+ const incompleteArgumentsReason = managedProperty(value, "incompleteArgumentsReason");
1034
+ const escapedNonAsciiArguments = managedProperty(value, "escapedNonAsciiArguments");
812
1035
  return {
813
1036
  type,
814
1037
  id,
@@ -818,6 +1041,16 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
818
1041
  ...(typeof intent === "string" ? { intent } : {}),
819
1042
  ...(typeof customWireName === "string" ? { customWireName } : {}),
820
1043
  ...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
1044
+ ...(typeof incompleteArgumentsReason === "string"
1045
+ ? {
1046
+ incompleteArgumentsReason: incompleteArgumentsReason as
1047
+ | "truncated"
1048
+ | "malformed"
1049
+ | "conflicting"
1050
+ | "ambiguous",
1051
+ }
1052
+ : {}),
1053
+ ...(typeof escapedNonAsciiArguments === "boolean" ? { escapedNonAsciiArguments } : {}),
821
1054
  };
822
1055
  }
823
1056
 
@@ -847,13 +1080,30 @@ function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
847
1080
  };
848
1081
  }
849
1082
 
850
- function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
851
- const snapshot = managedAttemptSnapshot(event);
852
- if (!isManagedPlainRecord(snapshot)) throw new ManagedAttemptSnapshotError();
853
- const type = managedProperty(snapshot, "type");
854
- 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");
855
1103
  const indexed = () => {
856
- 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
+ }
857
1107
  return contentIndex as number;
858
1108
  };
859
1109
  if (type === "start") return { type, partial: message };
@@ -870,31 +1120,36 @@ function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: As
870
1120
  type === "reasoning_summary_delta" ||
871
1121
  type === "toolcall_delta"
872
1122
  ) {
873
- const delta = managedProperty(snapshot, "delta");
874
- if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
1123
+ const delta = managedProperty(source, "delta");
1124
+ if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta");
875
1125
  return { type, contentIndex: indexed(), delta, partial: message };
876
1126
  }
877
1127
  if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") {
878
- const content = managedProperty(snapshot, "content");
879
- if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
1128
+ const content = managedProperty(source, "content");
1129
+ if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content");
880
1130
  return { type, contentIndex: indexed(), content, partial: message };
881
1131
  }
882
1132
  if (type === "toolcall_end") {
883
- const toolCall = managedAssistantContent(managedProperty(snapshot, "toolCall"));
884
- 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");
885
1135
  return { type, contentIndex: indexed(), toolCall, partial: message };
886
1136
  }
887
1137
  if (type === "done") {
888
- const reason = managedProperty(snapshot, "reason");
889
- if (reason !== "stop" && reason !== "length" && reason !== "toolUse") throw new ManagedAttemptSnapshotError();
890
- 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 };
891
1143
  }
892
1144
  if (type === "error") {
893
- const reason = managedProperty(snapshot, "reason");
894
- if (reason !== "aborted" && reason !== "error") throw new ManagedAttemptSnapshotError();
895
- 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 };
896
1148
  }
897
- 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");
898
1153
  }
899
1154
 
900
1155
  function isManagedPlainRecord(value: unknown): value is Record<string, unknown> {
@@ -902,17 +1157,62 @@ function isManagedPlainRecord(value: unknown): value is Record<string, unknown>
902
1157
  }
903
1158
 
904
1159
  /**
905
- * Holds managed-attempt assistant output above the public event stream. A
906
- * cancelled provider attempt is therefore unobservable to sessions and their
907
- * 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.
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.
908
1205
  */
1206
+ type ManagedAttemptBatchItem =
1207
+ | { type: "event"; event: AgentEvent }
1208
+ | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };
1209
+
909
1210
  class ManagedAttemptTransaction {
910
- #batch: Array<
911
- | { type: "event"; event: AgentEvent }
912
- | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
913
- > = [];
1211
+ #batch: ManagedAttemptBatchItem[] = [];
914
1212
  #stagedEventCount = 0;
915
1213
  #stagedBytes = 0;
1214
+ /** Shape snapshot retained across discard() for bounded failure diagnostics. */
1215
+ #lastStagedShape: { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } | undefined;
916
1216
  #discarded = false;
917
1217
  #committed = false;
918
1218
 
@@ -923,10 +1223,15 @@ class ManagedAttemptTransaction {
923
1223
  | undefined,
924
1224
  private readonly model: AgentLoopConfig["model"],
925
1225
  readonly scope?: AttemptScope,
1226
+ readonly snapshotMode: "managed" | "lossless" = "managed",
926
1227
  ) {}
927
1228
 
928
1229
  push(event: AgentEvent): void {
929
1230
  if (this.#committed) {
1231
+ if (event.type === "message_end" || event.type === "turn_end") {
1232
+ this.#batch.push({ type: "event", event });
1233
+ return;
1234
+ }
930
1235
  this.stream.push(event);
931
1236
  return;
932
1237
  }
@@ -938,16 +1243,20 @@ class ManagedAttemptTransaction {
938
1243
  }
939
1244
 
940
1245
  stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
941
- 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
+ }
942
1251
  this.#batch.push({
943
1252
  type: "assistant_event",
944
1253
  message: partial,
945
- event: managedAssistantEventSnapshot(event, partial),
1254
+ event: this.#assistantEventSnapshot(event, partial),
946
1255
  });
947
1256
  }
948
1257
 
949
1258
  flush(): void {
950
- if (this.#discarded || this.#committed) return;
1259
+ if (this.#discarded) return;
951
1260
  for (const item of this.#batch) {
952
1261
  if (item.type === "assistant_event") {
953
1262
  this.onAssistantMessageEvent?.(item.message, item.event);
@@ -961,13 +1270,98 @@ class ManagedAttemptTransaction {
961
1270
  this.#committed = true;
962
1271
  }
963
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
+
964
1322
  discard(): void {
1323
+ if (!this.#discarded) {
1324
+ this.#lastStagedShape = {
1325
+ stagedEventCount: this.#stagedEventCount,
1326
+ stagedBytes: this.#stagedBytes,
1327
+ contentBlockCount: this.#stagedContentBlockCount(),
1328
+ };
1329
+ }
965
1330
  this.#batch = [];
966
1331
  this.#stagedBytes = 0;
967
1332
  this.#stagedEventCount = 0;
968
1333
  this.#discarded = true;
969
1334
  }
970
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
+ }
971
1365
  #wouldOverflow(bytes: number): boolean {
972
1366
  return (
973
1367
  this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
@@ -976,6 +1370,43 @@ class ManagedAttemptTransaction {
976
1370
  }
977
1371
 
978
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
+ }
979
1410
  // Measure the raw event FIRST so an oversized payload is rejected
980
1411
  // before the snapshot duplicates it — the staged-byte cap exists to
981
1412
  // bound memory, so cloning ahead of the check would defeat it.
@@ -990,37 +1421,28 @@ class ManagedAttemptTransaction {
990
1421
  }
991
1422
  if (bytes !== undefined && this.#wouldOverflow(bytes)) {
992
1423
  this.discard();
993
- throw new ManagedAttemptBufferOverflowError();
1424
+ throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
994
1425
  }
995
- const detailed = managedAttemptSnapshotDetailed(this.#repairAssistantEvent(event));
996
- let snapshot = detailed.snapshot;
997
- if (bytes === undefined || detailed.degraded) {
998
- // Account the bytes of what is actually retained: a degraded
999
- // snapshot replaces non-JSON leaves with placeholders, so the raw
1000
- // pre-measure (which omits e.g. function-valued properties) can
1001
- // undercount the staged form.
1002
- try {
1003
- bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
1004
- } catch {
1005
- try {
1006
- snapshot = sanitizedDetachedClone(snapshot);
1007
- bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
1008
- } catch {
1009
- bytes = undefined;
1010
- }
1011
- }
1012
- if (bytes === undefined) {
1013
- // The sanitizer's output is total (detached, JSON-safe), so this
1014
- // is unreachable unless the sanitizer itself regresses. Fail as a
1015
- // dedicated local error: it carries no transport facts, so it is
1016
- // non-retryable and can never be misattributed to the provider.
1017
- this.discard();
1018
- throw new ManagedAttemptSnapshotError();
1019
- }
1020
- if (this.#wouldOverflow(bytes)) {
1021
- this.discard();
1022
- throw new ManagedAttemptBufferOverflowError();
1023
- }
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");
1024
1446
  }
1025
1447
  this.#batch.push({ type: "event", event: snapshot });
1026
1448
  this.#stagedEventCount += 1;
@@ -1029,6 +1451,7 @@ class ManagedAttemptTransaction {
1029
1451
  }
1030
1452
 
1031
1453
  #repairAssistantEvent(event: AgentEvent): AgentEvent {
1454
+ if (this.snapshotMode === "lossless") return event;
1032
1455
  if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
1033
1456
  return event.message.role === "assistant"
1034
1457
  ? { ...event, message: managedAssistantShell(event.message, this.model) }
@@ -1052,6 +1475,52 @@ class ManagedAttemptTransaction {
1052
1475
  }
1053
1476
  return event;
1054
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
+ }
1055
1524
  }
1056
1525
 
1057
1526
  /**
@@ -1469,6 +1938,15 @@ async function runLoopBody(
1469
1938
  // Fires at most one repaired resend per run for the reasoning-content replay
1470
1939
  // breaker below (DeepSeek "reasoning_content ... must be passed back").
1471
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;
1472
1950
  let previousMalformedToolSignatures = new Set<string>();
1473
1951
  type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
1474
1952
  let pendingRecovery:
@@ -1498,11 +1976,15 @@ async function runLoopBody(
1498
1976
  const scope =
1499
1977
  initialScope ?? (firstTurn ? config.initialScope : undefined) ?? config.attemptMinter?.mint("main");
1500
1978
  initialScope = undefined;
1501
- const transaction =
1979
+ const managedTransaction =
1502
1980
  initialTransaction ??
1503
1981
  (config.fallbackManaged
1504
1982
  ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
1505
1983
  : undefined);
1984
+ const escapedToolTransaction = config.fallbackManaged
1985
+ ? undefined
1986
+ : new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope, "lossless");
1987
+ const transaction = managedTransaction ?? escapedToolTransaction;
1506
1988
  initialTransaction = undefined;
1507
1989
  const attemptScope = transaction?.scope ?? scope;
1508
1990
  lastAttemptScope = attemptScope;
@@ -1580,10 +2062,16 @@ async function runLoopBody(
1580
2062
  // Stream assistant response
1581
2063
  let recovered: HarmonyRecoveredToolCall | undefined;
1582
2064
  let message: AssistantMessage;
1583
- const attemptTransaction = transaction;
2065
+ const attemptTransaction = managedTransaction;
1584
2066
  const recoveryAttempt = pendingRecovery;
1585
2067
  const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
1586
2068
  try {
2069
+ const getLogicalTurnToolChoice = (): ToolChoice | undefined => {
2070
+ if (escapedNonAsciiToolChoiceCaptured) return escapedNonAsciiToolChoice;
2071
+ escapedNonAsciiToolChoice = config.getToolChoice?.();
2072
+ escapedNonAsciiToolChoiceCaptured = true;
2073
+ return escapedNonAsciiToolChoice;
2074
+ };
1587
2075
  const attemptConfig = attemptTransaction
1588
2076
  ? {
1589
2077
  ...config,
@@ -1612,7 +2100,7 @@ async function runLoopBody(
1612
2100
  currentContext,
1613
2101
  attemptConfig,
1614
2102
  loopSignal,
1615
- attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
2103
+ transaction ? (transaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
1616
2104
  telemetry,
1617
2105
  invokeAgentSpan,
1618
2106
  stepCounter,
@@ -1626,6 +2114,8 @@ async function runLoopBody(
1626
2114
  forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
1627
2115
  }
1628
2116
  : undefined,
2117
+ escapedToolTransaction,
2118
+ recoveryAttempt ? undefined : { value: getLogicalTurnToolChoice() },
1629
2119
  );
1630
2120
  const detection = detectHarmonyLeakInAssistantMessage(message);
1631
2121
  if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
@@ -1638,6 +2128,7 @@ async function runLoopBody(
1638
2128
  } catch (err) {
1639
2129
  if (!(err instanceof HarmonyLeakInterruption)) {
1640
2130
  const failureMessage = managedFailureMessage(err, config);
2131
+ if (config.fallbackManaged) warnManagedSnapshotFailure(err, config, transaction);
1641
2132
  if (config.fallbackManaged && transaction && managedContextOverflow(failureMessage, config)) {
1642
2133
  transaction.discard();
1643
2134
  currentContext.messages.splice(contextMessageCount);
@@ -1683,6 +2174,18 @@ async function runLoopBody(
1683
2174
  }
1684
2175
  await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
1685
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
+ }
1686
2189
  if (harmonyRetryAttempt >= 2) {
1687
2190
  await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
1688
2191
  throw new Error(
@@ -1773,6 +2276,59 @@ async function runLoopBody(
1773
2276
  }
1774
2277
  }
1775
2278
 
2279
+ // Escaped-non-ASCII tool arguments: bounded turn resample.
2280
+ //
2281
+ // Arguments that spell a printable non-ASCII character as `\uXXXX`
2282
+ // instead of literal UTF-8 are a wire-format defect, not a decision the
2283
+ // model needs to be told about. The payload parses cleanly, but one
2284
+ // mistyped nibble decodes to a different, equally valid character, so it
2285
+ // can never be verified or repaired after the fact. Reporting it as a
2286
+ // tool error spends the whole turn and writes the literal escape syntax
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.
2294
+ if (
2295
+ message.stopReason !== "error" &&
2296
+ message.stopReason !== "aborted" &&
2297
+ escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
2298
+ !escapedToolTransaction?.committed &&
2299
+ hasEscapedNonAsciiToolCall(message)
2300
+ ) {
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;
2327
+ }
2328
+ escapedNonAsciiResampleAttempt = 0;
2329
+ escapedNonAsciiToolChoiceCaptured = false;
2330
+ escapedNonAsciiToolChoice = undefined;
2331
+
1776
2332
  const overflow = managedContextOverflow(message, config);
1777
2333
  if (config.fallbackManaged && overflow) {
1778
2334
  transaction?.discard();
@@ -1826,7 +2382,28 @@ async function runLoopBody(
1826
2382
  }
1827
2383
 
1828
2384
  // One provider invocation is committed before any tool can run.
1829
- 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
+ }
1830
2407
  if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1831
2408
  await config.onManagedAttemptAccepted?.();
1832
2409
  }
@@ -2072,6 +2649,8 @@ async function streamAssistantResponse(
2072
2649
  disableTools?: boolean;
2073
2650
  forceAutoToolChoice?: boolean;
2074
2651
  },
2652
+ provisionalToolTransaction?: ManagedAttemptTransaction,
2653
+ toolChoiceOverride?: { value: ToolChoice | undefined },
2075
2654
  ): Promise<AssistantMessage> {
2076
2655
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
2077
2656
  let messages = context.messages;
@@ -2134,7 +2713,11 @@ async function streamAssistantResponse(
2134
2713
 
2135
2714
  // Synthetic recovery requests choose their tool mode explicitly below and
2136
2715
  // must never consume a queued dynamic choice intended for an ordinary turn.
2137
- const dynamicToolChoice = recoveryMode ? undefined : config.getToolChoice?.();
2716
+ const dynamicToolChoice = recoveryMode
2717
+ ? undefined
2718
+ : toolChoiceOverride
2719
+ ? toolChoiceOverride.value
2720
+ : config.getToolChoice?.();
2138
2721
  const dynamicReasoning = config.getReasoning?.();
2139
2722
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
2140
2723
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
@@ -2397,6 +2980,9 @@ async function streamAssistantResponse(
2397
2980
  : event.partial;
2398
2981
  context.messages.push(partialMessage);
2399
2982
  addedPartial = true;
2983
+ if (provisionalToolTransaction) {
2984
+ config.onProvisionalAssistantMessageEvent?.(partialMessage, event);
2985
+ }
2400
2986
  stream.push({ type: "message_start", message: { ...partialMessage }, scope });
2401
2987
  break;
2402
2988
 
@@ -2420,9 +3006,23 @@ async function streamAssistantResponse(
2420
3006
  partialMessage = config.fallbackManaged
2421
3007
  ? managedAssistantShell(event.partial, config.model)
2422
3008
  : event.partial;
2423
- 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;
2424
3019
  context.messages[context.messages.length - 1] = partialMessage;
2425
- 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
+ }
2426
3026
  if (signal?.aborted) continue;
2427
3027
  stream.push({
2428
3028
  type: "message_update",
@@ -2430,6 +3030,13 @@ async function streamAssistantResponse(
2430
3030
  message: { ...partialMessage },
2431
3031
  scope,
2432
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
+ }
2433
3040
  }
2434
3041
  break;
2435
3042
 
@@ -2531,19 +3138,16 @@ function toolCallNames(tool: { name: string; customWireName?: string }): string[
2531
3138
  const TOOL_DISCOVERY_NAME = "search_tool_bm25";
2532
3139
 
2533
3140
  /**
2534
- * Active tool a call name dispatches to. Tools emitted via OpenAI's custom-tool
2535
- * path (e.g. `apply_patch` on GPT-5) come back under their wire-level name,
2536
- * which may differ from the harness-internal `name`. Match on either, preferring
2537
- * `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.
2538
3145
  */
2539
3146
  function findActiveTool<T extends { name: string; customWireName?: string }>(
2540
3147
  tools: ReadonlyArray<T> | undefined,
2541
3148
  callName: string,
2542
3149
  ): T | undefined {
2543
- return (
2544
- tools?.find(tool => tool.name === callName) ??
2545
- tools?.find(tool => tool.customWireName !== undefined && tool.customWireName === callName)
2546
- );
3150
+ return activeToolForCallName(tools, callName);
2547
3151
  }
2548
3152
 
2549
3153
  /**
@@ -2605,6 +3209,7 @@ async function executeToolCalls(
2605
3209
  toolCall,
2606
3210
  tool: findActiveTool(tools, toolCall.name),
2607
3211
  args: toolCall.arguments as Record<string, unknown>,
3212
+ eventFields: undefined as { toolCallId: string; toolName: string; intent: string | undefined } | undefined,
2608
3213
  started: false,
2609
3214
  result: undefined as AgentToolResult<any> | undefined,
2610
3215
  isError: false,
@@ -2638,29 +3243,44 @@ async function executeToolCalls(
2638
3243
  const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
2639
3244
  if (record.resultEmitted) return;
2640
3245
  const { toolCall } = record;
2641
- if (!record.started) {
2642
- 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 = {
2643
3259
  type: "tool_execution_start",
2644
- toolCallId: toolCall.id,
2645
- toolName: toolCall.name,
3260
+ toolCallId: eventFields.toolCallId,
3261
+ toolName: eventFields.toolName,
2646
3262
  args: record.args,
2647
- intent: toolCall.intent,
3263
+ intent: eventFields.intent,
2648
3264
  scope,
2649
- });
3265
+ };
3266
+ markNonDispatchedToolEvent(startEvent);
3267
+ stream.push(startEvent);
2650
3268
  }
2651
- stream.push({
3269
+ const endEvent: AgentEvent = {
2652
3270
  type: "tool_execution_end",
2653
- toolCallId: toolCall.id,
2654
- toolName: toolCall.name,
3271
+ toolCallId: eventFields.toolCallId,
3272
+ toolName: eventFields.toolName,
2655
3273
  result,
2656
3274
  isError,
2657
3275
  scope,
2658
- });
3276
+ };
3277
+ if (!dispatched) markNonDispatchedToolEvent(endEvent);
3278
+ stream.push(endEvent);
2659
3279
 
2660
3280
  const toolResultMessage: ToolResultMessage = {
2661
3281
  role: "toolResult",
2662
- toolCallId: toolCall.id,
2663
- toolName: toolCall.name,
3282
+ toolCallId: eventFields.toolCallId,
3283
+ toolName: eventFields.toolName,
2664
3284
  content: result.content,
2665
3285
  details: result.details,
2666
3286
  isError,
@@ -2676,6 +3296,62 @@ async function executeToolCalls(
2676
3296
  stream.push({ type: "message_end", message: toolResultMessage, scope });
2677
3297
  };
2678
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
+
2679
3355
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
2680
3356
  if (interruptState.triggered) {
2681
3357
  // Skip both span emission and the collector orphan record here. The
@@ -2704,15 +3380,6 @@ async function executeToolCalls(
2704
3380
  }
2705
3381
  }
2706
3382
  record.args = argsForExecution;
2707
- record.started = true;
2708
- stream.push({
2709
- type: "tool_execution_start",
2710
- toolCallId: toolCall.id,
2711
- toolName: toolCall.name,
2712
- args: argsForExecution,
2713
- intent: toolCall.intent,
2714
- scope,
2715
- });
2716
3383
 
2717
3384
  const toolSpan = startExecuteToolSpan(telemetry, {
2718
3385
  tool,
@@ -2733,14 +3400,32 @@ async function executeToolCalls(
2733
3400
  try {
2734
3401
  if (toolCall.incompleteArguments) {
2735
3402
  record.argumentValidationFailed = true;
2736
- // The provider flagged this call's argument JSON as truncated
2737
- // (the model hit its output-token limit mid-call). Executing the
2738
- // best-effort partial parse would run the tool on wrong input, so
2739
- // reject with a retryable, actionable error instead.
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);
3416
+ }
3417
+ if (toolCall.escapedNonAsciiArguments) {
3418
+ record.argumentValidationFailed = true;
3419
+ // The arguments decoded cleanly, but they were spelled as `\uXXXX`
3420
+ // escapes rather than literal UTF-8. Hand-written hex is where models
3421
+ // mistype digits, and every mistyped nibble decodes to a different but
3422
+ // equally valid character — the payload is unverifiable and cannot be
3423
+ // repaired after parsing, so it is rejected rather than executed on
3424
+ // silently corrupted text.
2740
3425
  throw new Error(
2741
- `Tool call "${toolCall.name}" was cut off before its arguments finished streaming ` +
2742
- `(the response hit its output token limit). The partial arguments cannot be executed. ` +
2743
- `Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`,
3426
+ `Tool call "${toolCall.name}" spelled non-ASCII text as \\uXXXX escapes instead of literal UTF-8. ` +
3427
+ `Escaped text cannot be verified — a single wrong hex digit silently becomes a different character — ` +
3428
+ `so the call was not executed. Re-issue it writing every non-ASCII character literally.`,
2744
3429
  );
2745
3430
  }
2746
3431
  if (!tool) {
@@ -2806,22 +3491,25 @@ async function executeToolCalls(
2806
3491
  const toolContext = scope
2807
3492
  ? (Object.assign(baseToolContext ?? {}, { attemptScope: scope }) as AgentToolContext)
2808
3493
  : baseToolContext;
2809
- const execution = tool.execute(
2810
- toolCall.id,
2811
- transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
2812
- tool.nonAbortable ? undefined : toolSignal,
2813
- partialResult => {
2814
- stream.push({
2815
- type: "tool_execution_update",
2816
- toolCallId: toolCall.id,
2817
- toolName: toolCall.name,
2818
- args: effectiveArgs,
2819
- partialResult: coerceToolResult(partialResult).result,
2820
- scope,
2821
- });
2822
- },
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,
2823
3507
  toolContext,
2824
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);
2825
3513
  const rawResult = await execution;
2826
3514
  const coerced = coerceToolResult(rawResult);
2827
3515
  result = coerced.result;
@@ -3013,20 +3701,30 @@ function createAbortedToolResult(
3013
3701
  details: {},
3014
3702
  };
3015
3703
 
3016
- 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 = {
3017
3711
  type: "tool_execution_start",
3018
3712
  toolCallId: toolCall.id,
3019
3713
  toolName: toolCall.name,
3020
3714
  args: toolCall.arguments,
3021
3715
  intent: toolCall.intent,
3022
- });
3023
- stream.push({
3716
+ };
3717
+ markNonDispatchedToolEvent(startEvent);
3718
+ stream.push(startEvent);
3719
+ const endEvent: AgentEvent = {
3024
3720
  type: "tool_execution_end",
3025
3721
  toolCallId: toolCall.id,
3026
3722
  toolName: toolCall.name,
3027
3723
  result,
3028
3724
  isError: true,
3029
- });
3725
+ };
3726
+ markNonDispatchedToolEvent(endEvent);
3727
+ stream.push(endEvent);
3030
3728
 
3031
3729
  const toolResultMessage: ToolResultMessage = {
3032
3730
  role: "toolResult",