@gajae-code/agent-core 0.14.2 → 0.15.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
@@ -32,7 +32,8 @@ import {
32
32
  neutralizeReservedControlTokens,
33
33
  stripUnusableReasoningItems,
34
34
  } from "@gajae-code/ai/utils";
35
- import { logger, sanitizeText } from "@gajae-code/utils";
35
+ import { isCursorExecResolved } from "@gajae-code/ai/utils/block-symbols";
36
+ import { $credentialEnv, logger, sanitizeText } from "@gajae-code/utils";
36
37
  import type { AttemptScope } from "./attempt-scope";
37
38
  import {
38
39
  createHarmonyAuditEvent,
@@ -45,6 +46,7 @@ import {
45
46
  shouldMitigateHarmonyLeak,
46
47
  signalListLabel,
47
48
  } from "./harmony-leak";
49
+ import escapedNonAsciiRecoveryPrompt from "./prompts/escaped-nonascii-recovery.md" with { type: "text" };
48
50
  import repeatedToolFailureRecoveryPrompt from "./prompts/repeated-tool-failure-recovery.md" with { type: "text" };
49
51
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
50
52
  import {
@@ -96,6 +98,154 @@ const intrinsicReflectApply = Reflect.apply;
96
98
  export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
97
99
  export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
98
100
 
101
+ /**
102
+ * Hard ceilings for the operator overrides. The caps exist to bound memory, so
103
+ * an override may raise them only within a range that still leaves the guard
104
+ * meaningful — near-`MAX_SAFE_INTEGER` values would trade a typed, bounded
105
+ * `local_buffer_overflow` for a process OOM, which is strictly harder to
106
+ * diagnose. Above-ceiling overrides clamp to the ceiling with a warning
107
+ * instead of being honored.
108
+ *
109
+ * The ceilings are derived from a survivable PEAK-RSS budget, not from the
110
+ * counted-bytes number: peak resident memory holds the live payload, its
111
+ * detached snapshot, and the retained batch simultaneously, so it is a
112
+ * multiple of the counted bytes. Sizing itself is walk-based (no JSON string
113
+ * or UTF-8 copy is materialized to measure), which is why the factor below
114
+ * covers the live value plus one detached copy plus batch retention with
115
+ * headroom. The bytes ceiling is the peak budget divided by that multiplier,
116
+ * so an override at the ceiling still fits an ordinary host. The events
117
+ * ceiling is the object-count equivalent for the same budget at a
118
+ * conservative per-item floor.
119
+ */
120
+ export const MANAGED_STAGED_PEAK_RSS_BUDGET_BYTES = 4 * 1024 * 1024 * 1024;
121
+ export const MANAGED_STAGED_PEAK_RSS_FACTOR = 4;
122
+ export const MANAGED_ATTEMPT_STAGED_EVENTS_CEILING = 2_000_000;
123
+ export const MANAGED_ATTEMPT_STAGED_BYTES_CEILING = Math.floor(
124
+ MANAGED_STAGED_PEAK_RSS_BUDGET_BYTES / MANAGED_STAGED_PEAK_RSS_FACTOR,
125
+ );
126
+
127
+ /**
128
+ * Warn once per distinct (knob, requested value) per process. The caps are
129
+ * re-read for every managed transaction — every streaming turn — so an
130
+ * unmemoized warning would re-log the same oversized operator value once per
131
+ * turn for the life of the process, embedding the full requested string in
132
+ * every record (log amplification). A bounded digest is logged instead of
133
+ * the raw value for the same reason.
134
+ */
135
+ const clampedCapWarnings = new Set<string>();
136
+
137
+ function warnClampedStagedCap(
138
+ name: "GJC_FALLBACK_MAX_STAGED_EVENTS" | "GJC_FALLBACK_MAX_STAGED_BYTES",
139
+ requested: number | string,
140
+ ceiling: number,
141
+ ): void {
142
+ // A parsed number is already bounded; only the raw decimal string (which
143
+ // the beyond-safe-integer path can supply at arbitrary length) is reduced
144
+ // to a length-and-prefix digest before it is embedded in a log record.
145
+ const requestedPayload =
146
+ typeof requested === "number" ? requested : `${requested.length} digits (starts ${requested.slice(0, 8)})`;
147
+ const key = `${name}:${String(requestedPayload)}`;
148
+ if (clampedCapWarnings.has(key)) return;
149
+ clampedCapWarnings.add(key);
150
+ logger.warn(`${name} clamped to ${ceiling}: the provisional staging guard must stay bounded`, {
151
+ requested: requestedPayload,
152
+ ceiling,
153
+ });
154
+ }
155
+
156
+ function clampedStagedCap(
157
+ name: "GJC_FALLBACK_MAX_STAGED_EVENTS" | "GJC_FALLBACK_MAX_STAGED_BYTES",
158
+ fallback: number,
159
+ ceiling: number,
160
+ ): number {
161
+ // Resolve from TRUSTED environment sources only ($credentialEnv excludes the
162
+ // caller's cwd/.env overlay): these knobs ARE a defensive resource guard, so
163
+ // a repository-controlled .env must not be able to weaken (or tighten into
164
+ // failure) the staging bound. Values must be positive integers (digits only
165
+ // after the trusted resolver's surrounding-whitespace normalization);
166
+ // anything else falls back to the default. Any digits-only positive
167
+ // decimal that is at or below the ceiling is honored verbatim, and any
168
+ // digits-only positive decimal above the ceiling — including ones beyond
169
+ // Number.MAX_SAFE_INTEGER, which a numeric parse would misclassify — clamps
170
+ // to the ceiling with a warning, exactly as documented.
171
+ const raw = $credentialEnv(name)?.trim();
172
+ if (raw === undefined) return fallback;
173
+ const parsed = parsePositiveEnvInt(raw);
174
+ if (parsed !== undefined) {
175
+ if (parsed <= ceiling) return parsed;
176
+ warnClampedStagedCap(name, parsed, ceiling);
177
+ return ceiling;
178
+ }
179
+ if (isPositiveDecimalDigits(raw) && decimalAtLeast(raw, ceiling + 1)) {
180
+ warnClampedStagedCap(name, raw, ceiling);
181
+ return ceiling;
182
+ }
183
+ return fallback;
184
+ }
185
+
186
+ function parsePositiveEnvInt(raw: string): number | undefined {
187
+ if (!raw || !/^\d+$/.test(raw)) return undefined;
188
+ const parsed = Number(raw);
189
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
190
+ }
191
+
192
+ /** True when the value is a digits-only positive decimal string (no sign). */
193
+ function isPositiveDecimalDigits(raw: string): boolean {
194
+ return raw.length > 0 && /^\d+$/.test(raw) && raw.replace(/^0+/, "") !== "";
195
+ }
196
+
197
+ /**
198
+ * Lexical comparison of a digits-only decimal against a numeric threshold,
199
+ * valid past Number.MAX_SAFE_INTEGER: compare stripped-leading-zero digit
200
+ * length first, then digit by digit.
201
+ */
202
+ function decimalAtLeast(raw: string, threshold: number): boolean {
203
+ const digits = raw.replace(/^0+/, "");
204
+ const thresholdDigits = String(threshold).replace(/^0+/, "");
205
+ if (digits.length !== thresholdDigits.length) return digits.length > thresholdDigits.length;
206
+ return digits >= thresholdDigits;
207
+ }
208
+
209
+ /**
210
+ * Max events staged by a provisional managed-attempt transaction before it is
211
+ * rejected. Configurable via `GJC_FALLBACK_MAX_STAGED_EVENTS` (default
212
+ * `MANAGED_ATTEMPT_MAX_STAGED_EVENTS`, ceiling
213
+ * `MANAGED_ATTEMPT_STAGED_EVENTS_CEILING`). Read once per transaction so
214
+ * operators can raise the cap without a rebuild and tests can exercise the
215
+ * knob in-process. Values must be positive integers after the trusted
216
+ * resolver ignores surrounding whitespace; invalid or
217
+ * non-positive values fall back to the default, and values above the ceiling
218
+ * clamp to it with a warning.
219
+ *
220
+ * @internal
221
+ */
222
+ export function managedAttemptMaxStagedEvents(): number {
223
+ return clampedStagedCap(
224
+ "GJC_FALLBACK_MAX_STAGED_EVENTS",
225
+ MANAGED_ATTEMPT_MAX_STAGED_EVENTS,
226
+ MANAGED_ATTEMPT_STAGED_EVENTS_CEILING,
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Max bytes staged by a provisional managed-attempt transaction before it is
232
+ * rejected. Configurable via `GJC_FALLBACK_MAX_STAGED_BYTES` (default
233
+ * `MANAGED_ATTEMPT_MAX_STAGED_BYTES`, ceiling
234
+ * `MANAGED_ATTEMPT_STAGED_BYTES_CEILING`). Read once per transaction; values
235
+ * must be positive integers after the trusted resolver ignores surrounding
236
+ * whitespace, anything else falls back to the
237
+ * default, and values above the ceiling clamp to it with a warning.
238
+ *
239
+ * @internal
240
+ */
241
+ export function managedAttemptMaxStagedBytes(): number {
242
+ return clampedStagedCap(
243
+ "GJC_FALLBACK_MAX_STAGED_BYTES",
244
+ MANAGED_ATTEMPT_MAX_STAGED_BYTES,
245
+ MANAGED_ATTEMPT_STAGED_BYTES_CEILING,
246
+ );
247
+ }
248
+
99
249
  /**
100
250
  * Closed set of local-failure sites. A bounded diagnostic may name only these
101
251
  * literals: the log is shape-only, so no caller-supplied or provider-derived
@@ -134,12 +284,62 @@ const MANAGED_LOCAL_FAILURE_STAGE_SET: ReadonlySet<string> = new Set(MANAGED_LOC
134
284
  */
135
285
  class ManagedAttemptBufferOverflowError extends Error {
136
286
  readonly errorKind = "local_buffer_overflow" as const;
137
- constructor(readonly stage: ManagedLocalFailureStage) {
138
- super("Managed fallback attempt exceeded the provisional event buffer limit");
287
+ /**
288
+ * Shape-only overflow diagnostics: the rejecting stage, which cap was
289
+ * exceeded, the staged counters at rejection, the incoming event's size
290
+ * (so a single oversized event explains itself), and the limits. Every
291
+ * field is synthesized locally (closed stage/cap vocabulary, numeric
292
+ * counters, numeric limits), so no provider or prompt text can reach a
293
+ * downstream surface through this object.
294
+ *
295
+ * `exceeded` names the cap that tripped: `events`, `bytes`, or `both`.
296
+ * The staged counters alone cannot say which — after #4610's compaction
297
+ * they describe the retained batch, which is at or below both caps; the
298
+ * projected values (`stagedBytes + incomingEventBytes`,
299
+ * `stagedEventCount + 1`) are what crossed a limit.
300
+ *
301
+ * The `.message` keeps its stable prefix (session retry policy
302
+ * prefix-classifies on it) and appends the same shape, because the thrown
303
+ * error itself — not the `managedFailureMessage` wrapper — is what surfaces
304
+ * on the non-retryable local exit path and issue reports (#4618). Parent
305
+ * task receipts consume the structured shape, never this string.
306
+ */
307
+ constructor(
308
+ readonly stage: ManagedLocalFailureStage,
309
+ readonly overflow: {
310
+ stage: ManagedLocalFailureStage;
311
+ exceeded: "events" | "bytes" | "both";
312
+ stagedEventCount: number;
313
+ stagedBytes: number;
314
+ incomingEventBytes: number;
315
+ maxStagedEvents: number;
316
+ maxStagedBytes: number;
317
+ },
318
+ ) {
319
+ super(managedBufferOverflowMessage(overflow));
139
320
  this.name = "ManagedAttemptBufferOverflowError";
140
321
  }
141
322
  }
142
323
 
324
+ /**
325
+ * Stable, prefix-anchored, shape-only message for a provisional staging-buffer
326
+ * overflow. The leading sentence is load-bearing (the session prefix-classifies
327
+ * legacy messages on it); the parenthetical names the stage, the exceeded cap,
328
+ * the projected counters, and the limits so the failure reads as the local,
329
+ * reproducible staging condition it is — not a provider or context-window
330
+ * problem.
331
+ */
332
+ function managedBufferOverflowMessage(overflow: ManagedAttemptBufferOverflowError["overflow"]): string {
333
+ return (
334
+ "Managed fallback attempt exceeded the provisional event buffer limit " +
335
+ `(stage=${overflow.stage}; exceeded=${overflow.exceeded}; staged ${overflow.stagedEventCount}/${
336
+ overflow.maxStagedEvents
337
+ } events, ${overflow.stagedBytes} staged bytes + ${overflow.incomingEventBytes} incoming = ` +
338
+ `${overflow.stagedBytes + overflow.incomingEventBytes}/${overflow.maxStagedBytes} projected bytes; local staging ` +
339
+ "buffer limit, not a provider or context-window failure; re-issuing the same request will reproduce it)"
340
+ );
341
+ }
342
+
143
343
  /**
144
344
  * Local snapshot-machinery failure. Deliberately carries no transport facts
145
345
  * or status, so managed fallback classification never treats it as a provider
@@ -188,6 +388,8 @@ const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
188
388
  * budget recovers the overwhelming majority of turns; past it the terminal
189
389
  * per-call rejection takes over rather than spending the run on retries.
190
390
  */
391
+ export const ESCAPED_NONASCII_RECOVERY_PROMPT = escapedNonAsciiRecoveryPrompt;
392
+
191
393
  const MAX_ESCAPED_NONASCII_RESAMPLES = 2;
192
394
 
193
395
  /** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
@@ -404,7 +606,9 @@ function managedContextOverflowOutcome(message: AssistantMessage, scope?: Attemp
404
606
  function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
405
607
  const errorMessage = managedProperty(error, "message");
406
608
  const transportFailure = managedTransportFailure(error);
407
- const errorKind = managedProperty(error, "errorKind");
609
+ // One identity-checked source for BOTH local-diagnostic fields: a foreign
610
+ // error that self-labels `errorKind` gets neither (#4618).
611
+ const localDiagnostic = managedLocalErrorDiagnostic(error);
408
612
  let fallbackMessage = "Managed fallback attempt failed";
409
613
  if (typeof errorMessage === "string") fallbackMessage = errorMessage;
410
614
  else {
@@ -414,6 +618,9 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
414
618
  // Keep the stable local message for hostile wrappers.
415
619
  }
416
620
  }
621
+ // The overflow error's own message already carries the stable prefix plus
622
+ // the shape-only stage/counters/limits diagnostic, so nothing needs to be
623
+ // appended here; the prefix keeps the legacy prefix-classification stable.
417
624
  return {
418
625
  role: "assistant",
419
626
  content: [],
@@ -431,7 +638,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
431
638
  stopReason: "error",
432
639
  errorMessage: fallbackMessage,
433
640
  ...(transportFailure ? { transportFailure } : {}),
434
- ...(errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow" ? { errorKind } : {}),
641
+ ...(localDiagnostic ?? {}),
435
642
  timestamp: Date.now(),
436
643
  };
437
644
  }
@@ -713,6 +920,66 @@ function publishAgentEnd(
713
920
  config.resourceLedger.seal(config.resourceRunId);
714
921
  }
715
922
  }
923
+ /**
924
+ * Structured, shape-only overflow diagnostic carried on the terminal
925
+ * `AssistantMessage` of a managed run that died of a staging-buffer overflow.
926
+ * Every field is closed-vocabulary or numeric, so parent surfaces can render a
927
+ * trustworthy summary WITHOUT trusting the free-form `errorMessage` string
928
+ * (which a foreign, self-labeled error can still fill with arbitrary text).
929
+ */
930
+ export interface ManagedBufferOverflowDiagnostic {
931
+ stage: ManagedLocalFailureStage | "unknown";
932
+ exceeded: "events" | "bytes" | "both";
933
+ stagedEventCount: number;
934
+ stagedBytes: number;
935
+ incomingEventBytes: number;
936
+ maxStagedEvents: number;
937
+ maxStagedBytes: number;
938
+ }
939
+
940
+ /**
941
+ * The complete set of local-diagnostic authority fields a terminal
942
+ * `AssistantMessage` may carry. Produced only by
943
+ * {@link managedLocalErrorDiagnostic}, so `errorKind` and `bufferOverflow`
944
+ * always travel together from one identity check.
945
+ */
946
+ export interface ManagedLocalErrorDiagnostic {
947
+ errorKind: "local_snapshot_failure" | "local_buffer_overflow";
948
+ bufferOverflow?: ManagedBufferOverflowDiagnostic;
949
+ }
950
+
951
+ /**
952
+ * Single identity-checked source of local-failure authority. Returns
953
+ * `undefined` unless the error is genuinely `instanceof` one of this module's
954
+ * private local-failure classes — a foreign error that merely sets
955
+ * `errorKind: "local_buffer_overflow"` fails the identity check and receives
956
+ * NEITHER the kind nor the structured shape, so a provider or custom-stream
957
+ * failure can never be reported to the parent as a local staging-buffer
958
+ * overflow (#4618).
959
+ *
960
+ * Every producer of a terminal assistant message (`managedFailureMessage` and
961
+ * the `Agent` run catch) MUST derive both fields from this function instead of
962
+ * reading `errorKind`/`errorMessage` off the thrown value.
963
+ */
964
+ export function managedLocalErrorDiagnostic(error: unknown): ManagedLocalErrorDiagnostic | undefined {
965
+ if (error instanceof ManagedAttemptBufferOverflowError) {
966
+ const overflow = error.overflow;
967
+ return {
968
+ errorKind: "local_buffer_overflow",
969
+ bufferOverflow: {
970
+ stage: MANAGED_LOCAL_FAILURE_STAGE_SET.has(overflow.stage) ? overflow.stage : "unknown",
971
+ exceeded: overflow.exceeded === "events" || overflow.exceeded === "bytes" ? overflow.exceeded : "both",
972
+ stagedEventCount: overflow.stagedEventCount,
973
+ stagedBytes: overflow.stagedBytes,
974
+ incomingEventBytes: overflow.incomingEventBytes,
975
+ maxStagedEvents: overflow.maxStagedEvents,
976
+ maxStagedBytes: overflow.maxStagedBytes,
977
+ },
978
+ };
979
+ }
980
+ if (error instanceof ManagedAttemptSnapshotError) return { errorKind: "local_snapshot_failure" };
981
+ return undefined;
982
+ }
716
983
 
717
984
  /**
718
985
  * Hard work budget for one degraded snapshot: every visited node AND every
@@ -735,6 +1002,26 @@ const SANITIZER_SENTINELS: ReadonlySet<string> = new Set([
735
1002
  "[truncated]",
736
1003
  "[Circular]",
737
1004
  ]);
1005
+ /**
1006
+ * Bounded diagnostic for a degraded primitive at the shared managed-snapshot
1007
+ * boundary. Every provider/custom stream that still forwards a malformed
1008
+ * primitive increment degrades here (to "" / []), so the degradation stays
1009
+ * observable. The caller supplies a run-scoped set so repeated malformed
1010
+ * increments emit at most one payload-free warning per field name, naming
1011
+ * only the field and the received typeof — never the payload.
1012
+ */
1013
+ function warnManagedDegradedPrimitive(
1014
+ field: string,
1015
+ received: unknown,
1016
+ diagnostics: Set<string> = new Set<string>(),
1017
+ ): void {
1018
+ if (diagnostics.has(field)) return;
1019
+ diagnostics.add(field);
1020
+ logger.warn("agent: managed snapshot degraded a non-string primitive to an empty value", {
1021
+ field,
1022
+ receivedType: received === null ? "null" : typeof received,
1023
+ });
1024
+ }
738
1025
 
739
1026
  /**
740
1027
  * Cycle-aware deep clone that always returns a detached, JSON-serializable
@@ -878,25 +1165,418 @@ export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_S
878
1165
  * detached clone when that validation fails so every accepted snapshot is
879
1166
  * both isolated and JSON-serializable.
880
1167
  */
881
- function managedSnapshotJsonBytes(value: unknown): number | undefined {
1168
+ /**
1169
+ * Sentinel thrown from inside a size walk the moment the projected size
1170
+ * crosses the budget. Returning a substituted value (e.g. "") would not
1171
+ * abort a `JSON.stringify` walk (review finding at 2efaf269cd); throwing is
1172
+ * the only way to terminate a traversal, and the walk-based oracles below
1173
+ * rely on the same mechanism to stop before doing unbounded work.
1174
+ */
1175
+ const MANAGED_SIZE_SENTINEL = Symbol("gjc.managed-staging-size-exceeded");
1176
+
1177
+ /**
1178
+ * Walk `value`'s JSON surface — exactly the surface `JSON.stringify` sees,
1179
+ * including `toJSON` dispatch — and return the exact UTF-8 byte length of
1180
+ * its serialization WITHOUT materializing the JSON string or its UTF-8
1181
+ * encoding. Every serialized token is charged: quotes, escapes, separators,
1182
+ * delimiters, nulls, array holes, and keys.
1183
+ *
1184
+ * A LONE surrogate (an unpaired UTF-16 unit; `codePointAt` yields the unit
1185
+ * itself only when it is unpaired) is charged as the six-byte `\udXXX`
1186
+ * escape `JSON.stringify` emits for it, not as its 3-byte UTF-8 encoding —
1187
+ * the previous BMP charge undercounted surrogate-heavy strings by ~2x
1188
+ * (exact-head 078e22c0 finding 2).
1189
+ *
1190
+ * Returns the byte count, `undefined` when the value cannot be serialized
1191
+ * (cyclic or JSON-hostile), or throws {@link MANAGED_SIZE_SENTINEL} once
1192
+ * the projected count crosses `limit`.
1193
+ */
1194
+ /**
1195
+ * Charge a string's exact serialized UTF-8 byte length: opening/closing
1196
+ * quotes, per-code-point escaping, and lone-surrogate six-byte escapes.
1197
+ * Printable-ASCII strings without `"` or `\` — the dominant case for
1198
+ * streamed text, thinking, and tool-argument content — encode one byte per
1199
+ * UTF-16 unit with no escapes, so they take a single native scan instead of
1200
+ * a per-code-point JS loop. This keeps the walk-based oracles at native
1201
+ * `JSON.stringify` cost for ordinary payloads instead of paying the slow
1202
+ * path on every streaming delta.
1203
+ */
1204
+ const MANAGED_PLAIN_ASCII = /[^\x20-\x21\x23-\x5b\x5d-\x7e]/;
1205
+ function managedChargeStringBytes(text: string, add: (bytes: number) => void): void {
1206
+ add(1);
1207
+ if (!MANAGED_PLAIN_ASCII.test(text)) {
1208
+ add(text.length);
1209
+ add(1);
1210
+ return;
1211
+ }
1212
+ for (let index = 0; index < text.length; ) {
1213
+ const codePoint = text.codePointAt(index);
1214
+ if (codePoint === undefined) throw new Error("missing string code point");
1215
+ if (codePoint === 0x22 || codePoint === 0x5c) add(2);
1216
+ else if (
1217
+ codePoint === 0x08 ||
1218
+ codePoint === 0x09 ||
1219
+ codePoint === 0x0a ||
1220
+ codePoint === 0x0c ||
1221
+ codePoint === 0x0d
1222
+ )
1223
+ add(2);
1224
+ else if (codePoint <= 0x1f) add(6);
1225
+ else if (codePoint >= 0xd800 && codePoint <= 0xdfff) add(6);
1226
+ else if (codePoint <= 0x7f) add(1);
1227
+ else if (codePoint <= 0x7ff) add(2);
1228
+ else if (codePoint <= 0xffff) add(3);
1229
+ else add(4);
1230
+ index += codePoint > 0xffff ? 2 : 1;
1231
+ }
1232
+ add(1);
1233
+ }
1234
+
1235
+ function managedJsonByteLengthWithin(value: unknown, limit: number): number | undefined {
1236
+ let seen = 0;
1237
+ const add = (bytes: number): void => {
1238
+ seen += bytes;
1239
+ if (seen > limit) throw MANAGED_SIZE_SENTINEL;
1240
+ };
1241
+ const addString = (text: string): void => managedChargeStringBytes(text, add);
1242
+ const seenObjects = new WeakSet<object>();
1243
+ const prepare = (input: unknown, key: string): { omitted: boolean; value?: unknown } => {
1244
+ if ((typeof input !== "object" || input === null) && typeof input !== "function") {
1245
+ return { omitted: false, value: input };
1246
+ }
1247
+ try {
1248
+ const toJSON = (input as { toJSON?: unknown }).toJSON;
1249
+ const value = typeof toJSON === "function" ? toJSON.call(input, key) : input;
1250
+ return {
1251
+ omitted: value === undefined || typeof value === "function" || typeof value === "symbol",
1252
+ value,
1253
+ };
1254
+ } catch {
1255
+ throw new Error("JSON toJSON failed");
1256
+ }
1257
+ };
1258
+ const walkPrepared = (input: unknown, inArray: boolean): boolean => {
1259
+ if (input === null) {
1260
+ add(4);
1261
+ return true;
1262
+ }
1263
+ if (input === undefined || typeof input === "function" || typeof input === "symbol") {
1264
+ if (inArray) add(4);
1265
+ return inArray;
1266
+ }
1267
+ if (typeof input === "string") {
1268
+ addString(input);
1269
+ return true;
1270
+ }
1271
+ if (typeof input === "boolean") {
1272
+ add(input ? 4 : 5);
1273
+ return true;
1274
+ }
1275
+ if (typeof input === "number") {
1276
+ const encoded = JSON.stringify(input);
1277
+ if (encoded === undefined) throw new Error("JSON number failed");
1278
+ add(managedAttemptTextEncoder.encode(encoded).byteLength);
1279
+ return true;
1280
+ }
1281
+ if (typeof input === "bigint") throw new Error("JSON bigint failed");
1282
+ if (typeof input !== "object") throw new Error("JSON value failed");
1283
+ if (seenObjects.has(input)) throw new Error("JSON cycle detected");
1284
+ seenObjects.add(input);
1285
+ try {
1286
+ if (Array.isArray(input)) {
1287
+ add(1);
1288
+ for (let index = 0; index < input.length; index++) {
1289
+ if (index > 0) add(1);
1290
+ const prepared = prepare(input[index], String(index));
1291
+ if (prepared.omitted) add(4);
1292
+ else walkPrepared(prepared.value, true);
1293
+ }
1294
+ add(1);
1295
+ return true;
1296
+ }
1297
+ add(1);
1298
+ let emitted = 0;
1299
+ const record = input as Record<string, unknown>;
1300
+ for (const property of Object.keys(input)) {
1301
+ const prepared = prepare(record[property], property);
1302
+ // `JSON.stringify` omits undefined-valued record properties
1303
+ // entirely (key, colon, and separator); charging them would
1304
+ // overestimate and falsely reject healthy payloads.
1305
+ if (prepared.omitted || prepared.value === undefined) continue;
1306
+ if (emitted > 0) add(1);
1307
+ emitted++;
1308
+ addString(property);
1309
+ add(1);
1310
+ walkPrepared(prepared.value, false);
1311
+ }
1312
+ add(1);
1313
+ return true;
1314
+ } finally {
1315
+ seenObjects.delete(input);
1316
+ }
1317
+ };
882
1318
  try {
883
- const serialized = JSON.stringify(value);
884
- return serialized === undefined ? undefined : managedAttemptTextEncoder.encode(serialized).byteLength;
885
- } catch {
1319
+ const prepared = prepare(value, "");
1320
+ if (prepared.omitted) return undefined;
1321
+ walkPrepared(prepared.value, false);
1322
+ return seen;
1323
+ } catch (error) {
1324
+ if (error === MANAGED_SIZE_SENTINEL) throw error;
886
1325
  return undefined;
887
1326
  }
888
1327
  }
889
1328
 
890
- function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; jsonBytes?: number } {
1329
+ /**
1330
+ * Pre-allocation size guard: reports whether serializing `value` as JSON
1331
+ * would exceed `limit` bytes WITHOUT materializing the full JSON string or
1332
+ * cloning the value. Walks the JSON surface directly and charges every
1333
+ * token, including quotes, escapes, separators, delimiters, nulls, and
1334
+ * array holes. Strings are charged by code point, so a large string never
1335
+ * needs a second full-size escaped copy just to measure it.
1336
+ *
1337
+ * Returns "over" when the limit would be exceeded, "under" when it
1338
+ * definitely is not, and "unknown" when the value cannot be serialized at
1339
+ * all (cyclic or JSON-hostile), which callers treat exactly like the
1340
+ * existing `undefined` measurement results.
1341
+ */
1342
+ function managedSnapshotExceedsBytes(value: unknown, limit: number): "over" | "under" | "unknown" {
1343
+ try {
1344
+ const bytes = managedJsonByteLengthWithin(value, limit);
1345
+ return bytes === undefined ? "unknown" : "under";
1346
+ } catch (error) {
1347
+ if (error === MANAGED_SIZE_SENTINEL) return "over";
1348
+ return "unknown";
1349
+ }
1350
+ }
1351
+
1352
+ /**
1353
+ * Per-node minimum charge for the structuredClone preflight. Cloning
1354
+ * duplicates the object GRAPH — per-node headers, Map/Set entries, buffer
1355
+ * contents — while immutable strings are only ever referenced, so a graph
1356
+ * of millions of tiny nodes is cheap in counted JSON bytes yet allocates a
1357
+ * large duplicate. Charging a per-node floor (a conservative minimum object
1358
+ * size, far above the few JSON bytes such nodes serialize to) keeps the
1359
+ * preflight an allocation bound, not just a serialization bound.
1360
+ */
1361
+ const MANAGED_CLONE_NODE_OVERHEAD_BYTES = 64;
1362
+
1363
+ /** Widest JSON literal any element of this typed-array kind can produce. */
1364
+ function managedTypedArrayJsonDigits(view: unknown): number {
1365
+ if (view instanceof Uint8Array || view instanceof Int8Array || view instanceof Uint8ClampedArray) return 4;
1366
+ if (view instanceof Uint16Array || view instanceof Int16Array) return 6;
1367
+ if (view instanceof Uint32Array || view instanceof Int32Array || view instanceof Float32Array) return 11;
1368
+ return 24;
1369
+ }
1370
+
1371
+ /**
1372
+ * Preflight the CLONE-VISIBLE surface — the graph `structuredClone` would
1373
+ * actually duplicate — against `limit` WITHOUT cloning. The JSON-surface
1374
+ * walk dispatches `toJSON`, so a live class can serialize compactly while
1375
+ * `structuredClone` (which drops the prototype serializer) would copy a
1376
+ * large own payload; this walk never dispatches `toJSON` and charges the
1377
+ * clone-visible graph instead. It also charges clone-only allocations the
1378
+ * JSON walk cannot see — Map/Set entries, ArrayBuffer/TypedArray bytes,
1379
+ * bigint magnitudes — plus the per-node header floor.
1380
+ *
1381
+ * Reads go through own-property descriptors only, so hostile accessors are
1382
+ * never invoked: an accessor's cloned size cannot be known without invoking
1383
+ * it, and a value `structuredClone` cannot duplicate at all (functions,
1384
+ * symbols) fails the clone anyway. Both surface as `"degrade"`, which
1385
+ * callers divert to the bounded sanitizer walk.
1386
+ *
1387
+ * Returns `"over"` when the clone-visible surface exceeds `limit`,
1388
+ * `"degrade"` when the surface cannot be bounded by walking it, and
1389
+ * `"under"` when the clone allocation is bounded.
1390
+ */
1391
+ function managedCloneSurfaceExceedsBudget(value: unknown, limit: number): "over" | "under" | "degrade" {
1392
+ let seen = 0;
1393
+ const add = (bytes: number): void => {
1394
+ seen += bytes;
1395
+ if (seen > limit) throw MANAGED_SIZE_SENTINEL;
1396
+ };
1397
+ const addString = (text: string): void => managedChargeStringBytes(text, add);
1398
+ const readOwnValue = (input: object, key: string): { accessor: boolean; value?: unknown } => {
1399
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
1400
+ if (descriptor === undefined) return { accessor: false, value: undefined };
1401
+ if (!("value" in descriptor)) return { accessor: true };
1402
+ return { accessor: false, value: descriptor.value };
1403
+ };
1404
+ const seenObjects = new WeakSet<object>();
1405
+ const walk = (input: unknown): void => {
1406
+ if (input === null) {
1407
+ add(4);
1408
+ return;
1409
+ }
1410
+ if (typeof input === "function" || typeof input === "symbol") {
1411
+ // structuredClone cannot duplicate these; degrade to the bounded
1412
+ // sanitizer instead of discovering the failure by cloning.
1413
+ throw new Error("clone surface uncloneable");
1414
+ }
1415
+ if (typeof input === "undefined") return;
1416
+ if (typeof input === "string") {
1417
+ addString(input);
1418
+ return;
1419
+ }
1420
+ if (typeof input === "number") {
1421
+ add(managedAttemptTextEncoder.encode(JSON.stringify(input)).byteLength);
1422
+ return;
1423
+ }
1424
+ if (typeof input === "boolean") {
1425
+ add(input ? 4 : 5);
1426
+ return;
1427
+ }
1428
+ if (typeof input === "bigint") {
1429
+ add(MANAGED_CLONE_NODE_OVERHEAD_BYTES + input.toString().length);
1430
+ return;
1431
+ }
1432
+ if (typeof input !== "object") return;
1433
+ // Refuse proxies by internal-slot brand BEFORE any reflective operation:
1434
+ // `Object.keys`/`getOwnPropertyDescriptor` on a live proxy would dispatch
1435
+ // its `ownKeys`/descriptor traps, and on a revoked proxy would throw —
1436
+ // either way the walk must not run payload-controlled code. The bounded
1437
+ // sanitizer collapses proxies to a placeholder without dispatching traps.
1438
+ if (nodeUtilTypes.isProxy(input)) throw new Error("clone surface proxy");
1439
+ if (seenObjects.has(input)) throw new Error("clone surface cycle");
1440
+ seenObjects.add(input);
1441
+ try {
1442
+ add(MANAGED_CLONE_NODE_OVERHEAD_BYTES);
1443
+ if (nodeUtilTypes.isDate(input)) {
1444
+ add(32);
1445
+ return;
1446
+ }
1447
+ if (nodeUtilTypes.isRegExp(input)) {
1448
+ add(2 + (input as RegExp).source.length);
1449
+ return;
1450
+ }
1451
+ if (nodeUtilTypes.isMap(input)) {
1452
+ for (const [entryKey, entryValue] of Map.prototype.entries.call(input as Map<unknown, unknown>)) {
1453
+ add(MANAGED_CLONE_NODE_OVERHEAD_BYTES);
1454
+ walk(entryKey);
1455
+ walk(entryValue);
1456
+ }
1457
+ return;
1458
+ }
1459
+ if (nodeUtilTypes.isSet(input)) {
1460
+ for (const element of Set.prototype.values.call(input as Set<unknown>)) {
1461
+ add(MANAGED_CLONE_NODE_OVERHEAD_BYTES);
1462
+ walk(element);
1463
+ }
1464
+ return;
1465
+ }
1466
+ if (nodeUtilTypes.isArrayBuffer(input)) {
1467
+ add((input as ArrayBuffer).byteLength);
1468
+ return;
1469
+ }
1470
+ if (nodeUtilTypes.isTypedArray(input)) {
1471
+ const view = input as unknown as Uint8Array;
1472
+ add(view.byteLength + view.length * managedTypedArrayJsonDigits(view));
1473
+ return;
1474
+ }
1475
+ if (nodeUtilTypes.isDataView(input)) {
1476
+ add((input as DataView).byteLength);
1477
+ return;
1478
+ }
1479
+ if (Array.isArray(input)) {
1480
+ add(2);
1481
+ for (let index = 0; index < input.length; index++) {
1482
+ if (index > 0) add(1);
1483
+ const read = readOwnValue(input, String(index));
1484
+ if (read.accessor) throw new Error("clone surface accessor");
1485
+ if (!Object.hasOwn(input, String(index)))
1486
+ add(4); // array hole
1487
+ else walk(read.value);
1488
+ }
1489
+ // Non-index own enumerable keys are cloned (allocation) even
1490
+ // though JSON.stringify omits them from arrays; charge their
1491
+ // graph so the preflight stays an allocation bound.
1492
+ for (const key of Object.keys(input)) {
1493
+ if (String(Number(key)) === key && Number(key) >= 0) continue;
1494
+ const read = readOwnValue(input, key);
1495
+ if (read.accessor) throw new Error("clone surface accessor");
1496
+ addString(key);
1497
+ walk(read.value);
1498
+ }
1499
+ return;
1500
+ }
1501
+ add(2);
1502
+ let emitted = 0;
1503
+ for (const key of Object.keys(input)) {
1504
+ const read = readOwnValue(input, key);
1505
+ if (read.accessor) throw new Error("clone surface accessor");
1506
+ if (emitted > 0) add(1);
1507
+ emitted++;
1508
+ addString(key);
1509
+ add(1);
1510
+ walk(read.value);
1511
+ }
1512
+ return;
1513
+ } finally {
1514
+ seenObjects.delete(input);
1515
+ }
1516
+ };
1517
+ try {
1518
+ walk(value);
1519
+ return "under";
1520
+ } catch (error) {
1521
+ if (error === MANAGED_SIZE_SENTINEL) return "over";
1522
+ return "degrade";
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * Ceiling for unbounded standalone snapshot sizing (the shell paths outside
1528
+ * a transaction). Shared by the transaction-cap default so a standalone
1529
+ * snapshot is never larger than the default transaction budget.
1530
+ */
1531
+ function currentStagedBytesCap(): number {
1532
+ return managedAttemptMaxStagedBytes();
1533
+ }
1534
+
1535
+ /**
1536
+ * Exact serialized size of a staged snapshot, computed by walking the JSON
1537
+ * surface. Replaces the previous `JSON.stringify` + `TextEncoder.encode`
1538
+ * measurement, which materialized a full copy of the serialized value — and
1539
+ * a second copy of its UTF-8 encoding — BEFORE the cap check could reject
1540
+ * it: the budget-sized transient allocation the memory guard exists to
1541
+ * prevent (exact-head 078e22c0 finding 1). Returns `undefined` when the
1542
+ * value cannot be serialized, matching the previous measurement's failure
1543
+ * mode so callers keep their sanitize fallbacks.
1544
+ *
1545
+ * @internal
1546
+ */
1547
+ export function managedSnapshotJsonByteLength(value: unknown): number | undefined {
1548
+ return managedJsonByteLengthWithin(value, Number.POSITIVE_INFINITY);
1549
+ }
1550
+
1551
+ function managedAttemptSnapshotDetailed<T>(
1552
+ value: T,
1553
+ byteLimit?: number,
1554
+ ): {
1555
+ snapshot: T;
1556
+ jsonBytes?: number;
1557
+ sanitized: boolean;
1558
+ } {
1559
+ // Preflight the CLONE-VISIBLE surface so the structuredClone allocation
1560
+ // itself is bounded: a live class can serialize compactly through a
1561
+ // prototype `toJSON()` that structuredClone drops, so a JSON-surface
1562
+ // precheck alone cannot bound what the clone would duplicate — the clone
1563
+ // allocated the over-cap duplicate before the typed overflow could run
1564
+ // (exact-head 078e22c0 finding 1). Degrade through the bounded sanitizer
1565
+ // walk, which never clones, never dispatches accessors, and is
1566
+ // node-budgeted, instead of allocating the duplicate.
1567
+ if (managedCloneSurfaceExceedsBudget(value, byteLimit ?? currentStagedBytesCap()) !== "under") {
1568
+ const bounded = sanitizedDetachedClone(value);
1569
+ return { snapshot: bounded, jsonBytes: managedSnapshotJsonByteLength(bounded), sanitized: true };
1570
+ }
891
1571
  try {
892
1572
  const snapshot = structuredClone(value);
893
- const jsonBytes = managedSnapshotJsonBytes(snapshot);
894
- if (jsonBytes !== undefined) return { snapshot, jsonBytes };
1573
+ const jsonBytes = managedSnapshotJsonByteLength(snapshot);
1574
+ if (jsonBytes !== undefined) return { snapshot, jsonBytes, sanitized: false };
895
1575
  const sanitized = sanitizedDetachedClone(snapshot);
896
- return { snapshot: sanitized, jsonBytes: managedSnapshotJsonBytes(sanitized) };
1576
+ return { snapshot: sanitized, jsonBytes: managedSnapshotJsonByteLength(sanitized), sanitized: true };
897
1577
  } catch {
898
1578
  const snapshot = sanitizedDetachedClone(value);
899
- return { snapshot, jsonBytes: managedSnapshotJsonBytes(snapshot) };
1579
+ return { snapshot, jsonBytes: managedSnapshotJsonByteLength(snapshot), sanitized: true };
900
1580
  }
901
1581
  }
902
1582
 
@@ -918,6 +1598,7 @@ const LOSSLESS_SNAPSHOT_KEYS = [
918
1598
  "errorStatus",
919
1599
  "transportFailure",
920
1600
  "disabledFeatures",
1601
+ "bufferOverflow",
921
1602
  "providerPayload",
922
1603
  "timestamp",
923
1604
  "duration",
@@ -937,8 +1618,19 @@ const LOSSLESS_SNAPSHOT_KEYS = [
937
1618
  * A failed subtree is removed at its own property boundary; siblings retain
938
1619
  * their exact structured-clone representation. The bounded recursive path is
939
1620
  * used only after cloning the complete value fails.
1621
+ *
1622
+ * The `structuredClone` allocation is preflighted against the staged-bytes
1623
+ * cap: a live payload class can serialize compactly through a prototype
1624
+ * `toJSON()` the clone drops, so the JSON-surface budget alone does not
1625
+ * bound what the clone duplicates. An over-budget clone surface collapses to
1626
+ * the bounded sanitizer instead of allocating the duplicate — ordinary
1627
+ * sessions never see this, because their transaction flushes and streams the
1628
+ * live event through before reaching this clone.
940
1629
  */
941
1630
  function losslessDetachedClone<T>(value: T): T {
1631
+ if (managedCloneSurfaceExceedsBudget(value, currentStagedBytesCap()) === "over") {
1632
+ return sanitizedDetachedClone(value);
1633
+ }
942
1634
  try {
943
1635
  const snapshot = structuredClone(value);
944
1636
  // `structuredClone()` preserves own bigint fields while removing a
@@ -946,7 +1638,7 @@ function losslessDetachedClone<T>(value: T): T {
946
1638
  // serialize successfully while its detached clone cannot be staged.
947
1639
  // Lossless staging still preserves every JSON-safe clone verbatim; only
948
1640
  // the non-serializable detached form is sanitized.
949
- return managedSnapshotJsonBytes(snapshot) !== undefined ? snapshot : sanitizedDetachedClone(snapshot);
1641
+ return managedSnapshotJsonByteLength(snapshot) !== undefined ? snapshot : sanitizedDetachedClone(snapshot);
950
1642
  } catch {
951
1643
  // The managed sanitizer is explicitly bounded and total. Use it only to
952
1644
  // identify which top-level assistant metadata surfaces are cloneable; the
@@ -987,7 +1679,7 @@ function losslessDetachedClone<T>(value: T): T {
987
1679
  }
988
1680
  }
989
1681
  }
990
- return managedSnapshotJsonBytes(output) !== undefined ? (output as T) : sanitizedDetachedClone(output as T);
1682
+ return managedSnapshotJsonByteLength(output) !== undefined ? (output as T) : sanitizedDetachedClone(output as T);
991
1683
  }
992
1684
  }
993
1685
 
@@ -997,7 +1689,11 @@ function losslessDetachedClone<T>(value: T): T {
997
1689
  * are read, and executable content is retained only when it has its complete
998
1690
  * discriminant shape.
999
1691
  */
1000
- function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
1692
+ function managedAssistantShell(
1693
+ value: unknown,
1694
+ model: AgentLoopConfig["model"],
1695
+ degradedFieldDiagnostics: Set<string> = new Set<string>(),
1696
+ ): AssistantMessage {
1001
1697
  const detailed = managedAttemptSnapshotDetailed(value);
1002
1698
  const snapshotRecord = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : undefined;
1003
1699
  // Two benign root degradations are repaired by reading through the
@@ -1015,22 +1711,26 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
1015
1711
  snapshotRecord !== undefined && managedProperty(snapshotRecord, "role") === "assistant" ? snapshotRecord : value;
1016
1712
  if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError("shell.role");
1017
1713
  const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
1018
- // Benign providers occasionally deliver a string or missing content value.
1019
- // Degrade those to an empty content array — an empty assistant turn —
1020
- // instead of failing the whole managed run: the staged shell must stay
1021
- // schema-valid, and empty content is the neutral, side-effect-free
1022
- // degradation. A string is benign ONLY when the provider actually sent a
1023
- // string: when the whole-message snapshot degraded, the sanitizer replaces
1024
- // a non-cloneable content value (proxy, function, accessor) with one of
1025
- // its own sentinel strings, and mistaking that sentinel for provider
1026
- // variance would silently drop real content (tool calls) behind a
1027
- // successful empty turn. Sentinel-string content therefore stays
1028
- // fail-closed, as does every other non-array shape, so the named-site
1029
- // diagnostic can report shell.content.
1714
+ // Providers may deliver `content` as a string, missing value, or a primitive
1715
+ // scalar — all benign variance that degrades to an empty content array
1716
+ // (an empty, side-effect-free assistant turn). A plain-object `content`
1717
+ // is NOT degraded: it can carry array-like toolCall payloads
1718
+ // (`{0:{type:"toolCall"}}`) and silently dropping them would lose
1719
+ // executable content behind a successful empty turn. Only sentinel
1720
+ // strings produced by the sanitizer itself (`[unserializable]` etc.)
1721
+ // also stay fail-closed for the same reason, plus any plain object.
1030
1722
  const rawArray = Array.isArray(rawContent) ? rawContent : undefined;
1031
- const benignContent =
1032
- rawContent === undefined || (typeof rawContent === "string" && !SANITIZER_SENTINELS.has(rawContent));
1033
- if (rawArray === undefined && !benignContent) throw new ManagedAttemptSnapshotError("shell.content");
1723
+ if (rawArray === undefined) {
1724
+ if (typeof rawContent === "string" && SANITIZER_SENTINELS.has(rawContent)) {
1725
+ throw new ManagedAttemptSnapshotError("shell.content");
1726
+ }
1727
+ if (rawContent !== null && typeof rawContent === "object") {
1728
+ throw new ManagedAttemptSnapshotError("shell.content");
1729
+ }
1730
+ if (rawArray === undefined && rawContent !== undefined && !SANITIZER_SENTINELS.has(rawContent as string)) {
1731
+ warnManagedDegradedPrimitive("shell.content", rawContent, degradedFieldDiagnostics);
1732
+ }
1733
+ }
1034
1734
  const content = rawArray === undefined ? [] : rawArray.flatMap(managedContentBlock);
1035
1735
  const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
1036
1736
  const api = managedProperty(source, "api");
@@ -1049,12 +1749,27 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
1049
1749
  const transportFailure = managedTransportFailure(value);
1050
1750
  const errorMessage = managedProperty(source, "errorMessage");
1051
1751
  const errorStatus = managedProperty(source, "errorStatus");
1752
+ // `provider_safety_stop` is the one provider-owned diagnostic that must cross
1753
+ // this managed snapshot boundary: AgentSession uses it to keep the terminal
1754
+ // stop terminal and to render the manual model-switch hint. Read only the
1755
+ // closed literal, and only on an errored assistant turn; local diagnostic
1756
+ // kinds remain runtime-owned and are never copied from provider data.
1757
+ const errorKind =
1758
+ stopReason === "error" && managedProperty(source, "errorKind") === "provider_safety_stop"
1759
+ ? ("provider_safety_stop" as const)
1760
+ : undefined;
1052
1761
  const safeMetadata: Record<string, unknown> = isManagedPlainRecord(detailed.snapshot)
1053
1762
  ? { ...detailed.snapshot }
1054
1763
  : {};
1055
1764
  delete safeMetadata.errorMessage;
1056
1765
  delete safeMetadata.errorStatus;
1057
1766
  delete safeMetadata.transportFailure;
1767
+ // Local diagnostic authority fields are never foreign-provider-settable.
1768
+ // `provider_safety_stop` was read explicitly above; all other errorKind values
1769
+ // are stripped here so a provider/stream payload cannot self-label a local
1770
+ // runtime failure in the executor's parent-facing summary (#4618).
1771
+ delete safeMetadata.errorKind;
1772
+ delete safeMetadata.bufferOverflow;
1058
1773
  return {
1059
1774
  ...safeMetadata,
1060
1775
  role: "assistant",
@@ -1067,6 +1782,7 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
1067
1782
  timestamp: typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : Date.now(),
1068
1783
  ...(transportFailure ? { transportFailure } : {}),
1069
1784
  ...(typeof errorMessage === "string" ? { errorMessage } : {}),
1785
+ ...(errorKind ? { errorKind } : {}),
1070
1786
  ...(typeof errorStatus === "number" && Number.isFinite(errorStatus) ? { errorStatus } : {}),
1071
1787
  };
1072
1788
  }
@@ -1153,8 +1869,45 @@ function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
1153
1869
  export function managedAssistantEventSnapshot(
1154
1870
  event: AssistantMessageEvent,
1155
1871
  message: AssistantMessage,
1872
+ degradedFieldDiagnostics: Set<string> = new Set<string>(),
1156
1873
  ): AssistantMessageEvent {
1157
- const detached = managedAttemptSnapshot(event);
1874
+ const directType = managedProperty(event, "type");
1875
+ if (
1876
+ directType === "text_delta" ||
1877
+ directType === "thinking_delta" ||
1878
+ directType === "reasoning_summary_delta" ||
1879
+ directType === "toolcall_delta"
1880
+ ) {
1881
+ // Delta events are snapshotted field-by-field so unrelated event metadata
1882
+ // cannot erase provenance. The delta is read once, detached once, then the
1883
+ // same captured value is both validated and emitted.
1884
+ const contentIndex = managedAttemptSnapshot(managedProperty(event, "contentIndex"));
1885
+ if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) {
1886
+ throw new ManagedAttemptSnapshotError("event.contentIndex");
1887
+ }
1888
+ const deltaSnapshot = managedAttemptSnapshotDetailed(managedProperty(event, "delta"));
1889
+ const delta = deltaSnapshot.snapshot;
1890
+ if (directType === "toolcall_delta" && (deltaSnapshot.sanitized || typeof delta !== "string")) {
1891
+ throw new ManagedAttemptSnapshotError("event.delta");
1892
+ }
1893
+ if (deltaSnapshot.sanitized && typeof delta === "string" && SANITIZER_SENTINELS.has(delta)) {
1894
+ throw new ManagedAttemptSnapshotError("event.delta");
1895
+ }
1896
+ if (delta !== undefined && delta !== null && typeof delta === "object") {
1897
+ throw new ManagedAttemptSnapshotError("event.delta");
1898
+ }
1899
+ if (typeof delta !== "string") {
1900
+ warnManagedDegradedPrimitive("event.delta", delta, degradedFieldDiagnostics);
1901
+ }
1902
+ return {
1903
+ type: directType,
1904
+ contentIndex: contentIndex as number,
1905
+ delta: typeof delta === "string" ? delta : "",
1906
+ partial: message,
1907
+ };
1908
+ }
1909
+ const eventSnapshot = managedAttemptSnapshotDetailed(event);
1910
+ const detached = eventSnapshot.snapshot;
1158
1911
  const record = isManagedPlainRecord(detached) ? detached : undefined;
1159
1912
  // Root repair, mirroring the shell: two benign degradations are re-read
1160
1913
  // through the original event with guarded reads (`managedProperty`) —
@@ -1184,20 +1937,20 @@ export function managedAssistantEventSnapshot(
1184
1937
  type === "toolcall_start"
1185
1938
  )
1186
1939
  return { type, contentIndex: indexed(), partial: message };
1187
- if (
1188
- type === "text_delta" ||
1189
- type === "thinking_delta" ||
1190
- type === "reasoning_summary_delta" ||
1191
- type === "toolcall_delta"
1192
- ) {
1193
- const delta = managedProperty(source, "delta");
1194
- if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta");
1195
- return { type, contentIndex: indexed(), delta, partial: message };
1196
- }
1197
1940
  if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") {
1198
- const content = managedProperty(source, "content");
1199
- if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content");
1200
- return { type, contentIndex: indexed(), content, partial: message };
1941
+ const contentSnapshot = managedAttemptSnapshotDetailed(managedProperty(source, "content"));
1942
+ const content = contentSnapshot.snapshot;
1943
+ if (contentSnapshot.sanitized && typeof content === "string" && SANITIZER_SENTINELS.has(content)) {
1944
+ throw new ManagedAttemptSnapshotError("event.content");
1945
+ }
1946
+ if (content !== undefined && content !== null && typeof content === "object") {
1947
+ throw new ManagedAttemptSnapshotError("event.content");
1948
+ }
1949
+ if (typeof content !== "string") {
1950
+ warnManagedDegradedPrimitive("event.content", content, degradedFieldDiagnostics);
1951
+ }
1952
+ const safeContent = typeof content === "string" ? content : "";
1953
+ return { type, contentIndex: indexed(), content: safeContent, partial: message };
1201
1954
  }
1202
1955
  if (type === "toolcall_end") {
1203
1956
  const toolCall = managedAssistantContent(managedAttemptSnapshot(managedProperty(source, "toolCall")));
@@ -1275,7 +2028,7 @@ function warnManagedSnapshotFailure(
1275
2028
  */
1276
2029
  type ManagedAttemptBatchItem =
1277
2030
  | { type: "event"; event: AgentEvent; bytes?: number }
1278
- | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };
2031
+ | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent; bytes?: number };
1279
2032
 
1280
2033
  /**
1281
2034
  * Streaming increments whose complete value is re-published by the block's own
@@ -1300,10 +2053,14 @@ class ManagedAttemptTransaction {
1300
2053
  #batch: ManagedAttemptBatchItem[] = [];
1301
2054
  #stagedEventCount = 0;
1302
2055
  #stagedBytes = 0;
2056
+ /** Caps for this transaction, read once from the operator env knobs. */
2057
+ readonly #maxStagedEvents = managedAttemptMaxStagedEvents();
2058
+ readonly #maxStagedBytes = managedAttemptMaxStagedBytes();
1303
2059
  /** Shape snapshot retained across discard() for bounded failure diagnostics. */
1304
2060
  #lastStagedShape: { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } | undefined;
1305
2061
  #discarded = false;
1306
2062
  #committed = false;
2063
+ #degradedFieldDiagnostics = new Set<string>();
1307
2064
 
1308
2065
  constructor(
1309
2066
  private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
@@ -1332,16 +2089,103 @@ class ManagedAttemptTransaction {
1332
2089
  }
1333
2090
 
1334
2091
  stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
1335
- const partial = this.#assistantSnapshot(message);
1336
2092
  if (this.#committed) {
1337
- this.onAssistantMessageEvent?.(partial, this.#assistantEventSnapshot(event, partial));
2093
+ // Already published: nothing is retained, so the live pair can go
2094
+ // straight to the consumer without a staging measurement. One
2095
+ // snapshot serves as BOTH the callback message and the event's
2096
+ // `partial`, preserving the paired-snapshot identity the direct
2097
+ // callbacks were built on and avoiding a second full clone of the
2098
+ // growing message.
2099
+ const committedPartial = this.#assistantSnapshot(message);
2100
+ this.onAssistantMessageEvent?.(committedPartial, this.#assistantEventSnapshot(event, committedPartial));
1338
2101
  return;
1339
2102
  }
2103
+ // Every retained batch item must be charged against the caps BEFORE it
2104
+ // is retained, including the assistant message/event pair: an uncharged
2105
+ // snapshot would let actual retention exceed the caps while the counters
2106
+ // still read under them. Two-phase guard so the allocation that could
2107
+ // OOM never happens ahead of the check:
2108
+ // 1. INCREMENTAL pre-check on the LIVE pair — a replacer walk that
2109
+ // stops as soon as the projected size crosses the cap, without
2110
+ // materializing the full JSON string or its UTF-8 encoding (review:
2111
+ // "size incrementally so serialization can stop before
2112
+ // materializing the whole value"). Only if the walk completes under
2113
+ // the cap is any snapshot taken.
2114
+ // 2. EXACT accounting of the retained detached pair — a live class can
2115
+ // serialize compactly through a prototype `toJSON()` that
2116
+ // `structuredClone` drops, so the live measurement may undercount
2117
+ // what the retained snapshot actually holds (same convention as
2118
+ // `#stage`).
2119
+ const liveBudget = this.#maxStagedBytes - this.#stagedBytes;
2120
+ const liveExcess = managedSnapshotExceedsBytes([message, event], liveBudget);
2121
+ if (liveExcess === "over") {
2122
+ this.#compactSupersededFrames();
2123
+ if (managedSnapshotExceedsBytes([message, event], this.#maxStagedBytes - this.#stagedBytes) === "over") {
2124
+ if (this.snapshotMode === "lossless") {
2125
+ this.flush();
2126
+ // One snapshot for the whole callback pair (see the committed
2127
+ // branch above): the callback message and `event.partial`
2128
+ // must be the same object.
2129
+ const flushedPartial = this.#assistantSnapshot(message);
2130
+ this.onAssistantMessageEvent?.(flushedPartial, this.#assistantEventSnapshot(event, flushedPartial));
2131
+ return;
2132
+ }
2133
+ // Report the POST-compaction remaining budget + 1 (a valid lower
2134
+ // bound for the live pair's size, and arithmetically consistent
2135
+ // with the post-compaction retained shape #overflowShape reports).
2136
+ this.discard();
2137
+ throw new ManagedAttemptBufferOverflowError(
2138
+ "overflow.staged",
2139
+ this.#overflowShape("overflow.staged", this.#maxStagedBytes - this.#stagedBytes + 1),
2140
+ );
2141
+ }
2142
+ }
2143
+ const partial = this.#assistantSnapshot(message);
2144
+ const snapshotEvent = this.#assistantEventSnapshot(event, partial);
2145
+ // Walk-based exact measure (no JSON string or UTF-8 copy materialized).
2146
+ const retainedBytes = managedSnapshotJsonByteLength([partial, snapshotEvent]);
2147
+ if (retainedBytes === undefined) {
2148
+ // Fail CLOSED, exactly like the #stage twin on the same condition: an
2149
+ // unmeasurable retained pair must not be retained uncharged (a 0-byte
2150
+ // charge plus the skipped byte-cap gate would let actual retention
2151
+ // exceed the caps while the counters still read under them). The
2152
+ // snapshot forms are JSON-safe by construction, so this is only
2153
+ // reachable if that construction regresses; it carries no transport
2154
+ // facts and never burns the fallback chain. Lossless mode cannot
2155
+ // fail the attempt: flush what is staged and publish the live pair.
2156
+ if (this.snapshotMode === "lossless") {
2157
+ this.flush();
2158
+ this.onAssistantMessageEvent?.(partial, snapshotEvent);
2159
+ return;
2160
+ }
2161
+ this.discard();
2162
+ throw new ManagedAttemptSnapshotError("staging.measure");
2163
+ }
2164
+ if (this.#wouldOverflow(retainedBytes)) {
2165
+ this.#compactSupersededFrames();
2166
+ if (this.#wouldOverflow(retainedBytes)) {
2167
+ if (this.snapshotMode === "lossless") {
2168
+ this.flush();
2169
+ this.onAssistantMessageEvent?.(partial, snapshotEvent);
2170
+ return;
2171
+ }
2172
+ this.discard();
2173
+ throw new ManagedAttemptBufferOverflowError(
2174
+ "overflow.staged",
2175
+ this.#overflowShape("overflow.staged", retainedBytes),
2176
+ );
2177
+ }
2178
+ }
2179
+ // Each frame's exact accounted size is retained so compaction can debit
2180
+ // exactly what it reclaims instead of re-measuring the whole batch.
1340
2181
  this.#batch.push({
1341
2182
  type: "assistant_event",
1342
2183
  message: partial,
1343
- event: this.#assistantEventSnapshot(event, partial),
2184
+ event: snapshotEvent,
2185
+ bytes: retainedBytes,
1344
2186
  });
2187
+ this.#stagedEventCount += 1;
2188
+ this.#stagedBytes += retainedBytes;
1345
2189
  }
1346
2190
 
1347
2191
  flush(): void {
@@ -1452,10 +2296,39 @@ class ManagedAttemptTransaction {
1452
2296
  return 0;
1453
2297
  }
1454
2298
  #wouldOverflow(bytes: number): boolean {
1455
- return (
1456
- this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
1457
- this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
1458
- );
2299
+ return this.#stagedEventCount + 1 > this.#maxStagedEvents || this.#stagedBytes + bytes > this.#maxStagedBytes;
2300
+ }
2301
+ /**
2302
+ * Shape snapshot for a buffer-overflow diagnostic: the rejecting stage,
2303
+ * which cap tripped, the retained staged counters (post-#4610-compaction),
2304
+ * the incoming event's own size, and the limits. Must be called AFTER
2305
+ * `discard()` so the staged counters report the retained batch shape — the
2306
+ * volume that still could not fit after superseded-delta compaction, not
2307
+ * zeroes. `exceeded` is derived from the projected values, not the
2308
+ * retained ones, because the retained batch is by definition within both
2309
+ * caps; `incomingEventBytes` lets the parent render why a single event
2310
+ * alone blew a cap.
2311
+ */
2312
+ #overflowShape(
2313
+ stage: ManagedLocalFailureStage,
2314
+ incomingEventBytes: number,
2315
+ ): ManagedAttemptBufferOverflowError["overflow"] {
2316
+ const staged = this.stagedShape();
2317
+ // Derive from the transaction's effective (operator-configurable) caps,
2318
+ // not the module constants: the diagnostic must name the limits that
2319
+ // actually tripped, which can differ from the defaults when an override
2320
+ // is active.
2321
+ const eventsExceeded = staged.stagedEventCount + 1 > this.#maxStagedEvents;
2322
+ const bytesExceeded = staged.stagedBytes + incomingEventBytes > this.#maxStagedBytes;
2323
+ return {
2324
+ stage,
2325
+ exceeded: eventsExceeded && bytesExceeded ? "both" : eventsExceeded ? "events" : "bytes",
2326
+ stagedEventCount: staged.stagedEventCount,
2327
+ stagedBytes: staged.stagedBytes,
2328
+ incomingEventBytes,
2329
+ maxStagedEvents: this.#maxStagedEvents,
2330
+ maxStagedBytes: this.#maxStagedBytes,
2331
+ };
1459
2332
  }
1460
2333
 
1461
2334
  /**
@@ -1488,10 +2361,8 @@ class ManagedAttemptTransaction {
1488
2361
  retained.push(item);
1489
2362
  continue;
1490
2363
  }
1491
- if (item.type === "event") {
1492
- reclaimedBytes += item.bytes ?? 0;
1493
- reclaimedEvents += 1;
1494
- }
2364
+ reclaimedBytes += item.bytes ?? 0;
2365
+ reclaimedEvents += 1;
1495
2366
  }
1496
2367
  if (retained.length === this.#batch.length) return false;
1497
2368
  this.#batch = retained;
@@ -1503,17 +2374,31 @@ class ManagedAttemptTransaction {
1503
2374
  #stage(event: AgentEvent): void {
1504
2375
  if (this.snapshotMode === "lossless") {
1505
2376
  const snapshot = this.#repairAssistantEvent(event);
1506
- let rawBytes: number | undefined;
1507
- try {
1508
- rawBytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
1509
- } catch {
1510
- rawBytes = undefined;
2377
+ const rawExcess = managedSnapshotExceedsBytes(snapshot, this.#maxStagedBytes - this.#stagedBytes);
2378
+ if (rawExcess === "over") {
2379
+ this.flush();
2380
+ this.push(event);
2381
+ return;
1511
2382
  }
2383
+ // Walk-based exact measure: no full JSON string or UTF-8 encoding is
2384
+ // materialized just to size the candidate (exact-head 078e22c0
2385
+ // finding 1 applies to the lossless exact measure too).
2386
+ const rawBytes = managedSnapshotJsonByteLength(snapshot);
1512
2387
  if (rawBytes !== undefined && this.#wouldOverflow(rawBytes)) {
1513
2388
  this.flush();
1514
2389
  this.push(event);
1515
2390
  return;
1516
2391
  }
2392
+ // Bound the structuredClone allocation inside the snapshot forms the
2393
+ // same way the managed path does: a compact `toJSON()` surface can hide
2394
+ // an over-budget clone-visible payload. Ordinary sessions flush and
2395
+ // stream the LIVE event through rather than degrading it — the
2396
+ // documented lossless contract — instead of staging a sanitized copy.
2397
+ if (managedCloneSurfaceExceedsBudget(event, this.#maxStagedBytes - this.#stagedBytes) === "over") {
2398
+ this.flush();
2399
+ this.push(event);
2400
+ return;
2401
+ }
1517
2402
  let detached: AgentEvent;
1518
2403
  try {
1519
2404
  detached = this.#losslessAgentEventSnapshot(snapshot);
@@ -1521,10 +2406,8 @@ class ManagedAttemptTransaction {
1521
2406
  this.discard();
1522
2407
  throw new ManagedAttemptSnapshotError("staging.losslessSnapshot");
1523
2408
  }
1524
- let detachedBytes: number;
1525
- try {
1526
- detachedBytes = managedAttemptTextEncoder.encode(JSON.stringify(detached)).byteLength;
1527
- } catch {
2409
+ const detachedBytes = managedSnapshotJsonByteLength(detached);
2410
+ if (detachedBytes === undefined) {
1528
2411
  this.discard();
1529
2412
  throw new ManagedAttemptSnapshotError("staging.measure");
1530
2413
  }
@@ -1538,37 +2421,72 @@ class ManagedAttemptTransaction {
1538
2421
  this.#stagedBytes += detachedBytes;
1539
2422
  return;
1540
2423
  }
1541
- // Measure the raw event FIRST so an oversized payload is rejected
1542
- // before the snapshot duplicates it — the staged-byte cap exists to
1543
- // bound memory, so cloning ahead of the check would defeat it.
1544
- // Cyclic/JSON-hostile events cannot be pre-measured; only those fall
1545
- // through to snapshot-then-measure, where the sanitized detached form
1546
- // is the cycle-safe estimator.
1547
- let bytes: number | undefined;
1548
- try {
1549
- bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
1550
- } catch {
1551
- bytes = undefined;
1552
- }
1553
- if (bytes !== undefined && this.#wouldOverflow(bytes)) {
2424
+ // Walk the raw event FIRST so an oversized payload is rejected before the
2425
+ // managed snapshot duplicates it. Both oracles run against the REMAINING
2426
+ // budget: the JSON-surface walk bounds the serialized charge, and the
2427
+ // clone-surface walk bounds the structuredClone ALLOCATION — a live class
2428
+ // can serialize compactly through `toJSON()` while carrying a large own
2429
+ // payload the clone would duplicate (exact-head 078e22c0 finding 1).
2430
+ // Cyclic/JSON-hostile events fall through to the sanitized detached form
2431
+ // below, which is the cycle-safe estimator.
2432
+ const preflightOver = (): boolean => {
2433
+ const remaining = this.#maxStagedBytes - this.#stagedBytes;
2434
+ return (
2435
+ managedSnapshotExceedsBytes(event, remaining) === "over" ||
2436
+ managedCloneSurfaceExceedsBudget(event, remaining) === "over"
2437
+ );
2438
+ };
2439
+ if (preflightOver()) {
1554
2440
  // A long turn reaches the cap through accumulated streaming increments,
1555
2441
  // not through one oversized payload. Reclaim the superseded increments
1556
2442
  // first; only a batch that still cannot fit is a real local overflow.
2443
+ // The overflow shape snapshots the retained POST-COMPACTION batch (the
2444
+ // volume that still cannot fit even after reclamation), because #4610
2445
+ // made the pre-compaction shape describe deltas it already reclaimed.
1557
2446
  this.#compactSupersededFrames();
1558
- if (this.#wouldOverflow(bytes)) {
2447
+ if (preflightOver()) {
2448
+ // Report the incoming event's real size (a bounded walk — the
2449
+ // sentinel caps the count at twice the cap, so a hostile shared
2450
+ // DAG cannot turn the diagnostic itself into unbounded work).
2451
+ // The previous form passed `remaining + 1` evaluated AFTER
2452
+ // discard() zeroed the counters, which fabricated the constant
2453
+ // `maxStagedBytes + 1` and mislabeled every mixed overflow as a
2454
+ // single-event blowout.
2455
+ const remainingBytes = this.#maxStagedBytes - this.#stagedBytes;
2456
+ const incomingBytes = (() => {
2457
+ try {
2458
+ // Bounded walk: the sentinel caps even this diagnostic's
2459
+ // work at twice the cap. The remaining budget + 1 is the
2460
+ // honest floor either way — the event demonstrably does
2461
+ // not fit in what remains (that is why it is rejected),
2462
+ // including when only the clone-visible surface tripped
2463
+ // while the compact JSON surface reads small.
2464
+ return Math.max(
2465
+ managedJsonByteLengthWithin(event, this.#maxStagedBytes * 2) ?? 0,
2466
+ remainingBytes + 1,
2467
+ );
2468
+ } catch {
2469
+ // Unserializable or beyond twice the cap: the floor alone
2470
+ // still arithmetically explains the rejection.
2471
+ return remainingBytes + 1;
2472
+ }
2473
+ })();
1559
2474
  this.discard();
1560
- throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
2475
+ throw new ManagedAttemptBufferOverflowError(
2476
+ "overflow.preMeasure",
2477
+ this.#overflowShape("overflow.preMeasure", incomingBytes),
2478
+ );
1561
2479
  }
1562
2480
  }
1563
2481
  const repaired = this.#repairAssistantEvent(event);
1564
- const detailed = managedAttemptSnapshotDetailed(repaired);
2482
+ const detailed = managedAttemptSnapshotDetailed(repaired, this.#maxStagedBytes - this.#stagedBytes);
1565
2483
  const snapshot = detailed.snapshot;
1566
2484
  // Always account the exact detached value. A live custom class can use
1567
2485
  // prototype `toJSON()` to serialize compactly while structuredClone
1568
2486
  // removes that serializer and exposes a larger or JSON-hostile own value.
1569
2487
  // Reusing the live pre-measure would therefore accept an unserializable
1570
2488
  // snapshot or undercount the retained bytes.
1571
- bytes = detailed.jsonBytes;
2489
+ const bytes = detailed.jsonBytes;
1572
2490
  if (bytes === undefined) {
1573
2491
  // The sanitizer's output is total (detached, JSON-safe), so this is
1574
2492
  // unreachable unless the sanitizer itself regresses. Fail as a
@@ -1578,10 +2496,16 @@ class ManagedAttemptTransaction {
1578
2496
  throw new ManagedAttemptSnapshotError("staging.sanitize");
1579
2497
  }
1580
2498
  if (this.#wouldOverflow(bytes)) {
2499
+ // Same ordering as the pre-measure path: compact first, then fail only
2500
+ // if the retained post-compaction batch still cannot fit — and report
2501
+ // that retained shape, not the pre-compaction one.
1581
2502
  this.#compactSupersededFrames();
1582
2503
  if (this.#wouldOverflow(bytes)) {
1583
2504
  this.discard();
1584
- throw new ManagedAttemptBufferOverflowError("overflow.staged");
2505
+ throw new ManagedAttemptBufferOverflowError(
2506
+ "overflow.staged",
2507
+ this.#overflowShape("overflow.staged", bytes),
2508
+ );
1585
2509
  }
1586
2510
  }
1587
2511
  // Retain each frame's accounted size so compaction can debit exactly what
@@ -1596,22 +2520,28 @@ class ManagedAttemptTransaction {
1596
2520
  if (this.snapshotMode === "lossless") return event;
1597
2521
  if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
1598
2522
  return event.message.role === "assistant"
1599
- ? { ...event, message: managedAssistantShell(event.message, this.model) }
2523
+ ? { ...event, message: managedAssistantShell(event.message, this.model, this.#degradedFieldDiagnostics) }
1600
2524
  : event;
1601
2525
  }
1602
2526
  if (event.type === "message_update") {
1603
- const message = managedAssistantShell(event.message, this.model);
2527
+ const message = managedAssistantShell(event.message, this.model, this.#degradedFieldDiagnostics);
1604
2528
  return {
1605
2529
  ...event,
1606
2530
  message,
1607
- assistantMessageEvent: managedAssistantEventSnapshot(event.assistantMessageEvent, message),
2531
+ assistantMessageEvent: managedAssistantEventSnapshot(
2532
+ event.assistantMessageEvent,
2533
+ message,
2534
+ this.#degradedFieldDiagnostics,
2535
+ ),
1608
2536
  };
1609
2537
  }
1610
2538
  if (event.type === "agent_end") {
1611
2539
  return {
1612
2540
  ...event,
1613
2541
  messages: event.messages.map(message =>
1614
- message.role === "assistant" ? managedAssistantShell(message, this.model) : message,
2542
+ message.role === "assistant"
2543
+ ? managedAssistantShell(message, this.model, this.#degradedFieldDiagnostics)
2544
+ : message,
1615
2545
  ),
1616
2546
  };
1617
2547
  }
@@ -1647,11 +2577,13 @@ class ManagedAttemptTransaction {
1647
2577
  #assistantSnapshot(message: AssistantMessage): AssistantMessage {
1648
2578
  return this.snapshotMode === "lossless"
1649
2579
  ? this.#losslessSnapshot(message)
1650
- : managedAssistantShell(message, this.model);
2580
+ : managedAssistantShell(message, this.model, this.#degradedFieldDiagnostics);
1651
2581
  }
1652
2582
 
1653
2583
  #assistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
1654
- if (this.snapshotMode === "managed") return managedAssistantEventSnapshot(event, message);
2584
+ if (this.snapshotMode === "managed") {
2585
+ return managedAssistantEventSnapshot(event, message, this.#degradedFieldDiagnostics);
2586
+ }
1655
2587
  const snapshot = this.#losslessSnapshot(event);
1656
2588
  if (snapshot.type === "done") return { ...snapshot, message };
1657
2589
  if (snapshot.type === "error") return { ...snapshot, error: message };
@@ -2090,14 +3022,16 @@ async function runLoopBody(
2090
3022
  let escapedNonAsciiToolChoiceCaptured = false;
2091
3023
  let escapedNonAsciiToolChoice: ToolChoice | undefined;
2092
3024
  let previousMalformedToolSignatures = new Set<string>();
2093
- type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
3025
+ type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider" | "escaped-nonascii";
2094
3026
  let pendingRecovery:
2095
3027
  | {
2096
3028
  kind: SyntheticRecoveryKind;
2097
3029
  inserted: boolean;
2098
3030
  syntheticMessage?: UserMessage;
2099
3031
  }
2100
- | undefined;
3032
+ | undefined = config.transientRecoveryMessage
3033
+ ? { kind: "escaped-nonascii", inserted: true, syntheticMessage: config.transientRecoveryMessage }
3034
+ : undefined;
2101
3035
  let malformedToolRecoveryAttempted = false;
2102
3036
  let composerBashPolicyRecoveryAttempted = false;
2103
3037
  // Deterministic terminal circuit breaker for argument-validation loops.
@@ -2207,6 +3141,11 @@ async function runLoopBody(
2207
3141
  const attemptTransaction = managedTransaction;
2208
3142
  const recoveryAttempt = pendingRecovery;
2209
3143
  const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
3144
+ // An escaped-non-ASCII steering resample is a re-request of the SAME
3145
+ // logical turn, not a diagnostic detour: tools stay enabled and the
3146
+ // captured logical-turn tool choice is replayed, so a queue-backed
3147
+ // "required" still lands on the accepted attempt.
3148
+ const wasEscapedNonAsciiRecoveryAttempt = recoveryAttempt?.kind === "escaped-nonascii";
2210
3149
  try {
2211
3150
  const getLogicalTurnToolChoice = (): ToolChoice | undefined => {
2212
3151
  if (escapedNonAsciiToolChoiceCaptured) return escapedNonAsciiToolChoice;
@@ -2227,7 +3166,9 @@ async function runLoopBody(
2227
3166
  ? COMPOSER_BASH_POLICY_RECOVERY_PROMPT
2228
3167
  : recoveryAttempt.kind === "malformed-tool-call"
2229
3168
  ? repeatedToolFailureRecoveryPrompt
2230
- : undefined;
3169
+ : recoveryAttempt.kind === "escaped-nonascii"
3170
+ ? escapedNonAsciiRecoveryPrompt
3171
+ : undefined;
2231
3172
  if (recoveryContent) {
2232
3173
  recoveryAttempt.syntheticMessage = {
2233
3174
  role: "user",
@@ -2253,11 +3194,13 @@ async function runLoopBody(
2253
3194
  ? {
2254
3195
  syntheticMessage: recoveryAttempt.syntheticMessage,
2255
3196
  disableTools: wasMalformedToolRecoveryAttempt,
2256
- forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
3197
+ forceAutoToolChoice: !wasMalformedToolRecoveryAttempt && !wasEscapedNonAsciiRecoveryAttempt,
2257
3198
  }
2258
3199
  : undefined,
2259
3200
  escapedToolTransaction,
2260
- recoveryAttempt ? undefined : { value: getLogicalTurnToolChoice() },
3201
+ recoveryAttempt && !wasEscapedNonAsciiRecoveryAttempt
3202
+ ? undefined
3203
+ : { value: getLogicalTurnToolChoice() },
2261
3204
  );
2262
3205
  const detection = detectHarmonyLeakInAssistantMessage(message);
2263
3206
  if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
@@ -2418,18 +3361,21 @@ async function runLoopBody(
2418
3361
  }
2419
3362
  }
2420
3363
 
2421
- // Escaped-non-ASCII tool arguments: bounded turn resample.
3364
+ // Escaped-non-ASCII tool arguments: bounded steered turn resample.
2422
3365
  //
2423
3366
  // Arguments that spell a printable non-ASCII character as `\uXXXX`
2424
- // instead of literal UTF-8 are a wire-format defect, not a decision the
2425
- // model needs to be told about. The payload parses cleanly, but one
2426
- // mistyped nibble decodes to a different, equally valid character, so it
2427
- // can never be verified or repaired after the fact. Reporting it as a
2428
- // tool error spends the whole turn and writes the literal escape syntax
2429
- // back into the context the model samples from next. Drop the defective
2430
- // turn and re-request instead; the per-call rejection in
2431
- // `executeToolCalls` stays as the terminal answer once this budget is
2432
- // spent. Managed fallback reports the discarded attempt through the
3367
+ // instead of literal UTF-8 are a wire-format defect. The payload parses
3368
+ // cleanly, but one mistyped nibble decodes to a different, equally valid
3369
+ // character, so it can never be verified or repaired after the fact.
3370
+ // Reporting it as a tool error spends the whole turn and writes the
3371
+ // literal escape syntax back into the context the model samples from
3372
+ // next. Drop the defective turn and re-request with a transient
3373
+ // steering instruction instead: models that escape deterministically
3374
+ // (rather than as a sampling accident) reproduce the identical defect
3375
+ // on a blind resample, so the retry names the defect without ever
3376
+ // committing the escape syntax — or the instruction — to durable
3377
+ // history. The per-call rejection in `executeToolCalls` stays as the
3378
+ // terminal answer once this budget is spent. Managed fallback reports the discarded attempt through the
2433
3379
  // typed `escaped_arguments_discarded` outcome so the session policy
2434
3380
  // owns a bounded same-model retry; the defect is never treated as
2435
3381
  // provider evidence, so the fallback chain never advances on it.
@@ -2458,7 +3404,10 @@ async function runLoopBody(
2458
3404
  // outcome below; the policy owns the same-model bounded retry and
2459
3405
  // only falls back once it declines. The wire defect is not provider
2460
3406
  // evidence, so the outcome deliberately carries no transport facts
2461
- // and the fallback chain never advances on it.
3407
+ // and the fallback chain never advances on it. The outcome names
3408
+ // whether a steering instruction already rode this attempt, so the
3409
+ // policy's retry continuation can carry it exactly once instead of
3410
+ // blindly re-requesting the same defective spelling.
2462
3411
  if (config.fallbackManaged) {
2463
3412
  transaction?.discard();
2464
3413
  currentContext.messages.splice(contextMessageCount);
@@ -2466,11 +3415,20 @@ async function runLoopBody(
2466
3415
  await config.onManagedAttemptOutcome?.({
2467
3416
  type: "escaped_arguments_discarded",
2468
3417
  message,
3418
+ steeringPending: recoveryAttempt?.kind !== "escaped-nonascii",
2469
3419
  scope: transaction?.scope,
2470
3420
  });
2471
3421
  stream.end(newMessages);
2472
3422
  return;
2473
3423
  }
3424
+ // Steer the in-loop retry: name the defect in a transient synthetic
3425
+ // message so a deterministic escaper has a reason to change its
3426
+ // spelling. Never displace a different pending recovery (e.g. the
3427
+ // one-shot malformed-tool-call turn): its mode and one-shot
3428
+ // accounting must survive an escaped resample inside it.
3429
+ if (!pendingRecovery || pendingRecovery.kind === "escaped-nonascii") {
3430
+ pendingRecovery = { kind: "escaped-nonascii", inserted: false };
3431
+ }
2474
3432
  continue;
2475
3433
  }
2476
3434
  escapedNonAsciiResampleAttempt = 0;
@@ -2559,7 +3517,9 @@ async function runLoopBody(
2559
3517
  // Create placeholder tool results for any tool calls in the aborted message
2560
3518
  // This maintains the tool_use/tool_result pairing that the API requires
2561
3519
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
2562
- const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
3520
+ const toolCalls = message.content.filter(
3521
+ (c): c is ToolCallContent => c.type === "toolCall" && !isCursorExecResolved(c),
3522
+ );
2563
3523
  const toolResults: ToolResultMessage[] = [];
2564
3524
  for (const toolCall of toolCalls) {
2565
3525
  const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
@@ -2589,7 +3549,10 @@ async function runLoopBody(
2589
3549
  }
2590
3550
 
2591
3551
  // Check for tool calls
2592
- const toolCalls = message.content.filter(c => c.type === "toolCall");
3552
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
3553
+ const toolCalls = message.content.filter(
3554
+ (c): c is ToolCallContent => c.type === "toolCall" && !isCursorExecResolved(c),
3555
+ );
2593
3556
  hasMoreToolCalls = toolCalls.length > 0;
2594
3557
 
2595
3558
  const toolResults: ToolResultMessage[] = [];
@@ -2819,6 +3782,7 @@ async function streamAssistantResponse(
2819
3782
  provisionalToolTransaction?: ManagedAttemptTransaction,
2820
3783
  toolChoiceOverride?: { value: ToolChoice | undefined },
2821
3784
  ): Promise<AssistantMessage> {
3785
+ const managedDegradedFieldDiagnostics = new Set<string>();
2822
3786
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
2823
3787
  let messages = context.messages;
2824
3788
  if (config.transformContext) {
@@ -2880,10 +3844,13 @@ async function streamAssistantResponse(
2880
3844
 
2881
3845
  // Synthetic recovery requests choose their tool mode explicitly below and
2882
3846
  // must never consume a queued dynamic choice intended for an ordinary turn.
2883
- const dynamicToolChoice = recoveryMode
2884
- ? undefined
2885
- : toolChoiceOverride
2886
- ? toolChoiceOverride.value
3847
+ // An explicit toolChoiceOverride is the exception: it carries the already-
3848
+ // captured logical-turn choice for a steering resample of that same turn,
3849
+ // so replaying it never double-consumes the queue.
3850
+ const dynamicToolChoice = toolChoiceOverride
3851
+ ? toolChoiceOverride.value
3852
+ : recoveryMode
3853
+ ? undefined
2887
3854
  : config.getToolChoice?.();
2888
3855
  const dynamicReasoning = config.getReasoning?.();
2889
3856
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
@@ -3143,7 +4110,7 @@ async function streamAssistantResponse(
3143
4110
  switch (event.type) {
3144
4111
  case "start":
3145
4112
  partialMessage = config.fallbackManaged
3146
- ? managedAssistantShell(event.partial, config.model)
4113
+ ? managedAssistantShell(event.partial, config.model, managedDegradedFieldDiagnostics)
3147
4114
  : event.partial;
3148
4115
  context.messages.push(partialMessage);
3149
4116
  addedPartial = true;
@@ -3171,7 +4138,7 @@ async function streamAssistantResponse(
3171
4138
  case "toolcall_end":
3172
4139
  if (partialMessage) {
3173
4140
  partialMessage = config.fallbackManaged
3174
- ? managedAssistantShell(event.partial, config.model)
4141
+ ? managedAssistantShell(event.partial, config.model, managedDegradedFieldDiagnostics)
3175
4142
  : event.partial;
3176
4143
  // Normalize through the managed event snapshot instead of a
3177
4144
  // naive `{ ...event }` spread: spreading copies only own
@@ -3181,7 +4148,7 @@ async function streamAssistantResponse(
3181
4148
  // downstream. The snapshot repairs benign class/prototype shapes
3182
4149
  // and keeps the named fail-fast diagnostics for hostile ones.
3183
4150
  const partialEvent = config.fallbackManaged
3184
- ? managedAssistantEventSnapshot(event, partialMessage)
4151
+ ? managedAssistantEventSnapshot(event, partialMessage, managedDegradedFieldDiagnostics)
3185
4152
  : event;
3186
4153
  context.messages[context.messages.length - 1] = partialMessage;
3187
4154
  if (provisionalToolTransaction) {
@@ -3210,7 +4177,7 @@ async function streamAssistantResponse(
3210
4177
  case "done":
3211
4178
  case "error": {
3212
4179
  const finalMessage = config.fallbackManaged
3213
- ? managedAssistantShell(await finishResponse(), config.model)
4180
+ ? managedAssistantShell(await finishResponse(), config.model, managedDegradedFieldDiagnostics)
3214
4181
  : await finishResponse();
3215
4182
  promoteTypedEmptyResponseStop(finalMessage);
3216
4183
  if (addedPartial) {
@@ -3233,7 +4200,7 @@ async function streamAssistantResponse(
3233
4200
  }
3234
4201
 
3235
4202
  const trailing = config.fallbackManaged
3236
- ? managedAssistantShell(await finishResponse(), config.model)
4203
+ ? managedAssistantShell(await finishResponse(), config.model, managedDegradedFieldDiagnostics)
3237
4204
  : await finishResponse();
3238
4205
  await finishChat(trailing);
3239
4206
  return trailing;
@@ -3356,7 +4323,9 @@ async function executeToolCalls(
3356
4323
  afterToolCall,
3357
4324
  } = config;
3358
4325
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
3359
- const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
4326
+ const toolCalls = assistantMessage.content.filter(
4327
+ (c): c is ToolCallContent => c.type === "toolCall" && !isCursorExecResolved(c),
4328
+ );
3360
4329
  const emittedToolResults: ToolResultMessage[] = [];
3361
4330
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
3362
4331
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;