@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/CHANGELOG.md +15 -3
- package/dist/types/agent-loop.d.ts +120 -1
- package/dist/types/agent.d.ts +3 -1
- package/dist/types/types.d.ts +16 -1
- package/package.json +5 -5
- package/src/agent-loop.ts +1088 -119
- package/src/agent.ts +11 -4
- package/src/prompts/escaped-nonascii-recovery.md +3 -0
- package/src/types.ts +16 -1
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 {
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
1576
|
+
return { snapshot: sanitized, jsonBytes: managedSnapshotJsonByteLength(sanitized), sanitized: true };
|
|
897
1577
|
} catch {
|
|
898
1578
|
const snapshot = sanitizedDetachedClone(value);
|
|
899
|
-
return { snapshot, jsonBytes:
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
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
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
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
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
// through
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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"
|
|
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")
|
|
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
|
-
:
|
|
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
|
|
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
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
//
|
|
2432
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
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(
|
|
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"}`;
|