@gajae-code/agent-core 0.11.0 → 0.11.2

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
@@ -2,13 +2,15 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
+
6
+ import { types as nodeUtilTypes } from "node:util";
5
7
  import {
6
8
  type AssistantMessage,
7
9
  type AssistantMessageEvent,
8
10
  type Context,
11
+ classifyContextOverflow,
9
12
  classifyFallbackTrigger,
10
13
  EventStream,
11
- isContextOverflow,
12
14
  isZodSchema,
13
15
  streamSimple,
14
16
  type ToolResultMessage,
@@ -17,6 +19,7 @@ import {
17
19
  validateToolArguments,
18
20
  zodToWireSchema,
19
21
  } from "@gajae-code/ai";
22
+ import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
20
23
  import { sanitizeText } from "@gajae-code/utils";
21
24
  import {
22
25
  createHarmonyAuditEvent,
@@ -66,35 +69,58 @@ import type {
66
69
  export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
67
70
  export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
68
71
 
72
+ /**
73
+ * Local staging failure: the provisional buffer limit was exceeded. Carries
74
+ * NO transport facts or status by design — only original typed provider
75
+ * transport facts may authorize provider fallback, so local buffer machinery
76
+ * must never masquerade as provider evidence or consume the fallback chain.
77
+ * It is therefore non-retryable and surfaces as an explicit local error.
78
+ */
69
79
  class ManagedAttemptBufferOverflowError extends Error {
70
- readonly status = 503;
71
-
72
80
  constructor() {
73
81
  super("Managed fallback attempt exceeded the provisional event buffer limit");
74
82
  this.name = "ManagedAttemptBufferOverflowError";
75
83
  }
76
84
  }
77
85
 
86
+ /**
87
+ * Local snapshot-machinery failure. Deliberately carries no transport facts
88
+ * or status, so managed fallback classification never treats it as a provider
89
+ * retry trigger — it fails fast instead of burning the fallback chain.
90
+ */
91
+ class ManagedAttemptSnapshotError extends Error {
92
+ constructor() {
93
+ super(
94
+ "Managed fallback attempt could not produce a serializable event snapshot (local snapshot bug, not a provider failure)",
95
+ );
96
+ this.name = "ManagedAttemptSnapshotError";
97
+ }
98
+ }
99
+
78
100
  const managedAttemptTextEncoder = new TextEncoder();
79
101
 
80
102
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
81
- /**
82
- * Detect empty "successful" responses that indicate a proxy-level context
83
- * overflow (e.g. LiteLLM returning `content: []`, `stopReason: "stop"`, and a
84
- * fabricated near-zero usage). We delegate to {@link isContextOverflow} which
85
- * has the threshold constant, so the detection logic stays in one place.
86
- */
87
- function isEmptyResponseOverflow(message: AssistantMessage): boolean {
88
- return isContextOverflow(message);
103
+ function managedContextOverflow(message: AssistantMessage, config: AgentLoopConfig): boolean {
104
+ const transportFailure = managedTransportFailure(message);
105
+ // Managed empty-stop responses may be repaired by the managed shell below; only
106
+ // typed/error overflows are discardable before that normalization boundary.
107
+ if (config.fallbackManaged && message.stopReason !== "error") return false;
108
+ return classifyContextOverflow(message, transportFailure, config.model.contextWindow);
89
109
  }
90
110
 
91
- /** Managed fallback owns retry policy; only typed transport facts may discard an attempt. */
92
- function managedTransportFailure(failure: unknown) {
93
- if (failure && typeof failure === "object" && "transportFailure" in failure) {
94
- const facts = (failure as { transportFailure?: unknown }).transportFailure;
95
- if (facts && typeof facts === "object") return transportFailureFacts(facts);
111
+ /** Managed fallback owns retry policy; only attached typed transport facts may discard an attempt. */
112
+ function managedProperty(value: unknown, key: string): unknown {
113
+ if (!value || typeof value !== "object") return undefined;
114
+ try {
115
+ return Reflect.get(value, key);
116
+ } catch {
117
+ return undefined;
96
118
  }
97
- return transportFailureFacts(failure);
119
+ }
120
+
121
+ function managedTransportFailure(failure: unknown) {
122
+ const facts = managedProperty(failure, "transportFailure");
123
+ return facts && typeof facts === "object" ? transportFailureFacts(facts) : undefined;
98
124
  }
99
125
 
100
126
  function managedRetryableFailure(failure: unknown): boolean {
@@ -109,6 +135,39 @@ function managedRetryableFailure(failure: unknown): boolean {
109
135
  );
110
136
  }
111
137
 
138
+ /**
139
+ * Neutralize leaked reserved control tokens in-place across the outgoing
140
+ * history so a re-send no longer carries the poison that triggered
141
+ * `Request blocked (code=invalid_prompt)`. Only string text fields are
142
+ * rewritten; no history item is ever dropped or reordered. Returns whether any
143
+ * byte actually changed — the circuit breaker uses this to decide between a
144
+ * single repaired resend (changed) and immediate fail-fast (unchanged).
145
+ */
146
+ function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
147
+ let changed = false;
148
+ const repairString = (value: string): string => {
149
+ const next = neutralizeReservedControlTokens(value);
150
+ if (next !== value) changed = true;
151
+ return next;
152
+ };
153
+ for (const message of messages) {
154
+ const content = (message as { content?: unknown }).content;
155
+ if (typeof content === "string") {
156
+ (message as { content: string }).content = repairString(content);
157
+ } else if (Array.isArray(content)) {
158
+ for (const block of content) {
159
+ if (!block || typeof block !== "object") continue;
160
+ const record = block as Record<string, unknown>;
161
+ for (const key of ["text", "thinking"]) {
162
+ const value = record[key];
163
+ if (typeof value === "string") record[key] = repairString(value);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return changed;
169
+ }
170
+
112
171
  function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
113
172
  return {
114
173
  type: "retryable_discarded",
@@ -116,16 +175,22 @@ function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome
116
175
  };
117
176
  }
118
177
 
178
+ function managedContextOverflowOutcome(message: AssistantMessage): ManagedAttemptOutcome {
179
+ return { type: "context_overflow_discarded", message };
180
+ }
181
+
119
182
  function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
120
- const details = error as { message?: unknown; errorStatus?: unknown; status?: unknown };
121
- const status =
122
- typeof details.errorStatus === "number"
123
- ? details.errorStatus
124
- : typeof details.status === "number"
125
- ? details.status
126
- : undefined;
127
- const transportFailure =
128
- managedTransportFailure(error) ?? (status === undefined ? undefined : { kind: "transport" as const, status });
183
+ const errorMessage = managedProperty(error, "message");
184
+ const transportFailure = managedTransportFailure(error);
185
+ let fallbackMessage = "Managed fallback attempt failed";
186
+ if (typeof errorMessage === "string") fallbackMessage = errorMessage;
187
+ else {
188
+ try {
189
+ fallbackMessage = String(error);
190
+ } catch {
191
+ // Keep the stable local message for hostile wrappers.
192
+ }
193
+ }
129
194
  return {
130
195
  role: "assistant",
131
196
  content: [],
@@ -141,8 +206,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
141
206
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
142
207
  },
143
208
  stopReason: "error",
144
- errorMessage: typeof details.message === "string" ? details.message : String(error),
145
- errorStatus: status,
209
+ errorMessage: fallbackMessage,
146
210
  ...(transportFailure ? { transportFailure } : {}),
147
211
  timestamp: Date.now(),
148
212
  };
@@ -225,7 +289,7 @@ export function agentLoop(
225
289
  messages: [...context.messages, ...prompts],
226
290
  };
227
291
  const transaction = config.fallbackManaged
228
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
292
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
229
293
  : undefined;
230
294
  const attemptStream = transaction ?? stream;
231
295
  if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
@@ -274,7 +338,7 @@ export function agentLoopContinue(
274
338
  const newMessages: AgentMessage[] = [];
275
339
  const currentContext: AgentContext = { ...context };
276
340
  const transaction = config.fallbackManaged
277
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
341
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
278
342
  : undefined;
279
343
  const attemptStream = transaction ?? stream;
280
344
  if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
@@ -297,9 +361,321 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
297
361
  );
298
362
  }
299
363
 
300
- /** Capture an event-time value because providers commonly mutate partial messages in place. */
364
+ /**
365
+ * Hard work budget for one degraded snapshot: every visited node AND every
366
+ * enumerated own key is debited against this budget before it is processed
367
+ * (accessor keys and re-visits of shared objects included), and any remainder
368
+ * collapses to the deterministic `"[truncated]"` placeholder. Well above
369
+ * ordinary streamed events; it only bounds hostile graphs.
370
+ */
371
+ export const MANAGED_SNAPSHOT_MAX_NODES = 100_000;
372
+
373
+ /**
374
+ * Cycle-aware deep clone that always returns a detached, JSON-serializable
375
+ * value. Used whenever a detached snapshot cannot be safely obtained or
376
+ * measured: after `structuredClone` fails, and again when a (successfully
377
+ * cloned) snapshot cannot be serialized for byte accounting.
378
+ *
379
+ * Totality rules — the walk must never dispatch through payload-controlled
380
+ * code, throw, or do unbounded work:
381
+ * - proxies (revoked or live) are collapsed to `"[unserializable]"` BEFORE
382
+ * any reflective operation, so `ownKeys`/descriptor traps are never
383
+ * dispatched (`util.types.isProxy` identifies proxies without touching
384
+ * their handlers);
385
+ * - only intrinsics are used on the remaining ordinary objects (no
386
+ * `input.map`, no `input.getTime()`, no `input.length` reads);
387
+ * - arrays are enumerated through their own present keys, never their
388
+ * declared length, so a sparse array cannot force a dense allocation
389
+ * proportional to `length`; sparse/exotic arrays degrade to a null-proto
390
+ * record of their present indices, and the dense-shape decision verifies
391
+ * every index against its ordinal;
392
+ * - the walk debits `maxNodes` budget per visited node and per enumerated
393
+ * key before processing it; anything beyond the budget becomes
394
+ * `"[truncated]"` (the one linear primitive per visited node is a single
395
+ * `Object.keys` call on a non-proxy object the process already holds);
396
+ * - property values are read via own-property descriptors, so accessors are
397
+ * never invoked (a snapshot must not cause observable side effects) and are
398
+ * replaced with `"[accessor]"`;
399
+ * - functions/symbols and any property that cannot be read safely become
400
+ * short placeholders, `bigint` becomes its decimal string, and references
401
+ * back into the current path collapse to `"[Circular]"`;
402
+ * - records are built on a null prototype so a `__proto__` key cannot mutate
403
+ * the clone's prototype chain.
404
+ *
405
+ * Exported for direct regression coverage of the budget accounting; runtime
406
+ * callers use the default budget via {@link managedAttemptSnapshot}.
407
+ */
408
+ export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_SNAPSHOT_MAX_NODES): T {
409
+ const path = new Set<object>();
410
+ let budget = maxNodes;
411
+ const takeBudget = (units: number): boolean => {
412
+ if (budget < units) {
413
+ budget = 0;
414
+ return false;
415
+ }
416
+ budget -= units;
417
+ return true;
418
+ };
419
+ const walk = (input: unknown): unknown => {
420
+ if (!takeBudget(1)) return "[truncated]";
421
+ if (typeof input === "bigint") return String(input);
422
+ if (typeof input === "function" || typeof input === "symbol") return "[unserializable]";
423
+ if (input === null || typeof input !== "object") return input;
424
+ if (nodeUtilTypes.isProxy(input)) return "[unserializable]";
425
+ if (path.has(input)) return "[Circular]";
426
+ path.add(input);
427
+ const readOwnValue = (key: string): unknown => {
428
+ try {
429
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
430
+ return descriptor === undefined
431
+ ? "[unserializable]"
432
+ : "value" in descriptor
433
+ ? walk(descriptor.value)
434
+ : "[accessor]";
435
+ } catch {
436
+ return "[unserializable]";
437
+ }
438
+ };
439
+ try {
440
+ if (Array.isArray(input)) {
441
+ // Own present keys only: iterating the declared length would
442
+ // densify holes, and `Object.keys` is proportional to the
443
+ // elements that actually exist.
444
+ const keys = Object.keys(input);
445
+ if (!takeBudget(keys.length)) return "[truncated]";
446
+ const indexKeys: string[] = [];
447
+ let hasExtraProps = false;
448
+ for (const key of keys) {
449
+ const index = Number(key);
450
+ if (String(index) === key && index >= 0) indexKeys.push(key);
451
+ else hasExtraProps = true;
452
+ }
453
+ let dense = !hasExtraProps;
454
+ if (dense) {
455
+ for (let ordinal = 0; ordinal < indexKeys.length; ordinal++) {
456
+ if (Number(indexKeys[ordinal]) !== ordinal) {
457
+ dense = false;
458
+ break;
459
+ }
460
+ }
461
+ }
462
+ if (dense) {
463
+ const out: unknown[] = [];
464
+ for (const key of indexKeys) out.push(readOwnValue(key));
465
+ return out;
466
+ }
467
+ const sparse: Record<string, unknown> = Object.create(null);
468
+ for (const key of indexKeys) sparse[key] = readOwnValue(key);
469
+ return sparse;
470
+ }
471
+ let dateTime: number | undefined;
472
+ try {
473
+ // `isDate` checks the [[DateValue]] internal slot without walking
474
+ // the prototype chain — `instanceof Date` would dispatch a proxy
475
+ // prototype's getPrototypeOf trap and do unbudgeted linear work
476
+ // on deep ordinary chains.
477
+ dateTime = nodeUtilTypes.isDate(input) ? Date.prototype.getTime.call(input) : undefined;
478
+ } catch {
479
+ dateTime = undefined;
480
+ }
481
+ if (dateTime !== undefined) return new Date(dateTime);
482
+ const keys = Object.keys(input);
483
+ if (!takeBudget(keys.length)) return "[truncated]";
484
+ const record: Record<string, unknown> = Object.create(null);
485
+ for (const key of keys) record[key] = readOwnValue(key);
486
+ return record;
487
+ } catch {
488
+ // Brand checks / key enumeration on exotic objects can throw;
489
+ // collapse only this node, not its ancestors.
490
+ return "[unserializable]";
491
+ } finally {
492
+ path.delete(input);
493
+ }
494
+ };
495
+ return walk(value) as T;
496
+ }
497
+
498
+ /**
499
+ * Capture an event-time value because providers commonly mutate partial
500
+ * messages in place. The snapshot MUST always be detached from the caller's
501
+ * object graph — replaying a live reference would surface the final mutation
502
+ * instead of the event-time value. It must also never throw: staged payloads
503
+ * can carry non-cloneable objects during provisional assistant streaming
504
+ * (e.g. a live `Headers` inside a provider error's `transportFailure` from a
505
+ * legacy payload), and a thrown `DataCloneError` here would mask the real
506
+ * provider outcome and burn the whole fallback chain.
507
+ */
508
+ function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; degraded: boolean } {
509
+ try {
510
+ return { snapshot: structuredClone(value), degraded: false };
511
+ } catch {
512
+ return { snapshot: sanitizedDetachedClone(value), degraded: true };
513
+ }
514
+ }
515
+
301
516
  function managedAttemptSnapshot<T>(value: T): T {
302
- return structuredClone(value);
517
+ return managedAttemptSnapshotDetailed(value).snapshot;
518
+ }
519
+
520
+ /**
521
+ * Recover the required assistant-message shell when a managed snapshot degrades
522
+ * at its root (notably for Proxy-wrapped provider messages). Only known fields
523
+ * are read, and executable content is retained only when it has its complete
524
+ * discriminant shape.
525
+ */
526
+ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
527
+ const detailed = managedAttemptSnapshotDetailed(value);
528
+ const source = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : value;
529
+ if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError();
530
+ const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
531
+ if (!Array.isArray(rawContent)) throw new ManagedAttemptSnapshotError();
532
+ const content = rawContent.flatMap(block => {
533
+ const normalized = managedAssistantContent(block);
534
+ return normalized ? [normalized] : [];
535
+ });
536
+ const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
537
+ const api = managedProperty(source, "api");
538
+ const provider = managedProperty(source, "provider");
539
+ const messageModel = managedProperty(source, "model");
540
+ const stopReasonValue = managedProperty(source, "stopReason");
541
+ const stopReason =
542
+ stopReasonValue === "stop" ||
543
+ stopReasonValue === "length" ||
544
+ stopReasonValue === "toolUse" ||
545
+ stopReasonValue === "error" ||
546
+ stopReasonValue === "aborted"
547
+ ? stopReasonValue
548
+ : "stop";
549
+ const timestamp = managedProperty(source, "timestamp");
550
+ const transportFailure = managedTransportFailure(value);
551
+ const errorMessage = managedProperty(source, "errorMessage");
552
+ const errorStatus = managedProperty(source, "errorStatus");
553
+ const safeMetadata: Record<string, unknown> = isManagedPlainRecord(detailed.snapshot)
554
+ ? { ...detailed.snapshot }
555
+ : {};
556
+ delete safeMetadata.errorMessage;
557
+ delete safeMetadata.errorStatus;
558
+ delete safeMetadata.transportFailure;
559
+ return {
560
+ ...safeMetadata,
561
+ role: "assistant",
562
+ content,
563
+ api: typeof api === "string" ? (api as AssistantMessage["api"]) : model.api,
564
+ provider: typeof provider === "string" ? (provider as AssistantMessage["provider"]) : model.provider,
565
+ model: typeof messageModel === "string" ? messageModel : model.id,
566
+ usage,
567
+ stopReason,
568
+ timestamp: typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : Date.now(),
569
+ ...(transportFailure ? { transportFailure } : {}),
570
+ ...(typeof errorMessage === "string" ? { errorMessage } : {}),
571
+ ...(typeof errorStatus === "number" && Number.isFinite(errorStatus) ? { errorStatus } : {}),
572
+ };
573
+ }
574
+
575
+ function managedAssistantContent(value: unknown): AssistantMessage["content"][number] | undefined {
576
+ if (!isManagedPlainRecord(value)) return undefined;
577
+ const type = managedProperty(value, "type");
578
+ if (type === "text") {
579
+ const text = managedProperty(value, "text");
580
+ return typeof text === "string" ? { type, text } : undefined;
581
+ }
582
+ if (type === "thinking") {
583
+ const thinking = managedProperty(value, "thinking");
584
+ return typeof thinking === "string" ? { type, thinking } : undefined;
585
+ }
586
+ if (type === "redactedThinking") {
587
+ const data = managedProperty(value, "data");
588
+ return typeof data === "string" ? { type, data } : undefined;
589
+ }
590
+ if (type !== "toolCall") return undefined;
591
+ const id = managedProperty(value, "id");
592
+ const name = managedProperty(value, "name");
593
+ const argumentsValue = managedProperty(value, "arguments");
594
+ if (typeof id !== "string" || typeof name !== "string" || !isManagedPlainRecord(argumentsValue)) return undefined;
595
+ const thoughtSignature = managedProperty(value, "thoughtSignature");
596
+ const intent = managedProperty(value, "intent");
597
+ const customWireName = managedProperty(value, "customWireName");
598
+ const incompleteArguments = managedProperty(value, "incompleteArguments");
599
+ return {
600
+ type,
601
+ id,
602
+ name,
603
+ arguments: argumentsValue,
604
+ ...(typeof thoughtSignature === "string" ? { thoughtSignature } : {}),
605
+ ...(typeof intent === "string" ? { intent } : {}),
606
+ ...(typeof customWireName === "string" ? { customWireName } : {}),
607
+ ...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
608
+ };
609
+ }
610
+
611
+ function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
612
+ const number = (key: string): number => {
613
+ const candidate = managedProperty(value, key);
614
+ return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : 0;
615
+ };
616
+ const costValue = managedProperty(value, "cost");
617
+ const costNumber = (key: string): number => {
618
+ const candidate = managedProperty(costValue, key);
619
+ return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : 0;
620
+ };
621
+ return {
622
+ input: number("input"),
623
+ output: number("output"),
624
+ cacheRead: number("cacheRead"),
625
+ cacheWrite: number("cacheWrite"),
626
+ totalTokens: number("totalTokens"),
627
+ cost: {
628
+ input: costNumber("input"),
629
+ output: costNumber("output"),
630
+ cacheRead: costNumber("cacheRead"),
631
+ cacheWrite: costNumber("cacheWrite"),
632
+ total: costNumber("total"),
633
+ },
634
+ };
635
+ }
636
+
637
+ function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
638
+ const snapshot = managedAttemptSnapshot(event);
639
+ if (!isManagedPlainRecord(snapshot)) throw new ManagedAttemptSnapshotError();
640
+ const type = managedProperty(snapshot, "type");
641
+ const contentIndex = managedProperty(snapshot, "contentIndex");
642
+ const indexed = () => {
643
+ if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) throw new ManagedAttemptSnapshotError();
644
+ return contentIndex as number;
645
+ };
646
+ if (type === "start") return { type, partial: message };
647
+ if (type === "text_start" || type === "thinking_start" || type === "toolcall_start")
648
+ return { type, contentIndex: indexed(), partial: message };
649
+ if (type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta") {
650
+ const delta = managedProperty(snapshot, "delta");
651
+ if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
652
+ return { type, contentIndex: indexed(), delta, partial: message };
653
+ }
654
+ if (type === "text_end" || type === "thinking_end") {
655
+ const content = managedProperty(snapshot, "content");
656
+ if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
657
+ return { type, contentIndex: indexed(), content, partial: message };
658
+ }
659
+ if (type === "toolcall_end") {
660
+ const toolCall = managedAssistantContent(managedProperty(snapshot, "toolCall"));
661
+ if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError();
662
+ return { type, contentIndex: indexed(), toolCall, partial: message };
663
+ }
664
+ if (type === "done") {
665
+ const reason = managedProperty(snapshot, "reason");
666
+ if (reason !== "stop" && reason !== "length" && reason !== "toolUse") throw new ManagedAttemptSnapshotError();
667
+ return { type, reason, message };
668
+ }
669
+ if (type === "error") {
670
+ const reason = managedProperty(snapshot, "reason");
671
+ if (reason !== "aborted" && reason !== "error") throw new ManagedAttemptSnapshotError();
672
+ return { type, reason, error: message };
673
+ }
674
+ throw new ManagedAttemptSnapshotError();
675
+ }
676
+
677
+ function isManagedPlainRecord(value: unknown): value is Record<string, unknown> {
678
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !nodeUtilTypes.isProxy(value);
303
679
  }
304
680
 
305
681
  /**
@@ -319,7 +695,10 @@ class ManagedAttemptTransaction {
319
695
 
320
696
  constructor(
321
697
  private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
322
- private readonly onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void,
698
+ private readonly onAssistantMessageEvent:
699
+ | ((message: AssistantMessage, event: AssistantMessageEvent) => void)
700
+ | undefined,
701
+ private readonly model: AgentLoopConfig["model"],
323
702
  ) {}
324
703
 
325
704
  push(event: AgentEvent): void {
@@ -335,10 +714,11 @@ class ManagedAttemptTransaction {
335
714
  }
336
715
 
337
716
  stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
717
+ const partial = managedAssistantShell(message, this.model);
338
718
  this.#batch.push({
339
719
  type: "assistant_event",
340
- message: managedAttemptSnapshot(message),
341
- event: managedAttemptSnapshot(event),
720
+ message: partial,
721
+ event: managedAssistantEventSnapshot(event, partial),
342
722
  });
343
723
  }
344
724
 
@@ -364,25 +744,90 @@ class ManagedAttemptTransaction {
364
744
  this.#discarded = true;
365
745
  }
366
746
 
747
+ #wouldOverflow(bytes: number): boolean {
748
+ return (
749
+ this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
750
+ this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
751
+ );
752
+ }
753
+
367
754
  #stage(event: AgentEvent): void {
368
- let bytes: number;
755
+ // Measure the raw event FIRST so an oversized payload is rejected
756
+ // before the snapshot duplicates it — the staged-byte cap exists to
757
+ // bound memory, so cloning ahead of the check would defeat it.
758
+ // Cyclic/JSON-hostile events cannot be pre-measured; only those fall
759
+ // through to snapshot-then-measure, where the sanitized detached form
760
+ // is the cycle-safe estimator.
761
+ let bytes: number | undefined;
369
762
  try {
370
763
  bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
371
764
  } catch {
372
- bytes = MANAGED_ATTEMPT_MAX_STAGED_BYTES + 1;
765
+ bytes = undefined;
373
766
  }
374
- if (
375
- this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
376
- this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
377
- ) {
767
+ if (bytes !== undefined && this.#wouldOverflow(bytes)) {
378
768
  this.discard();
379
769
  throw new ManagedAttemptBufferOverflowError();
380
770
  }
381
- this.#batch.push({ type: "event", event: managedAttemptSnapshot(event) });
771
+ const detailed = managedAttemptSnapshotDetailed(this.#repairAssistantEvent(event));
772
+ let snapshot = detailed.snapshot;
773
+ if (bytes === undefined || detailed.degraded) {
774
+ // Account the bytes of what is actually retained: a degraded
775
+ // snapshot replaces non-JSON leaves with placeholders, so the raw
776
+ // pre-measure (which omits e.g. function-valued properties) can
777
+ // undercount the staged form.
778
+ try {
779
+ bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
780
+ } catch {
781
+ try {
782
+ snapshot = sanitizedDetachedClone(snapshot);
783
+ bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
784
+ } catch {
785
+ bytes = undefined;
786
+ }
787
+ }
788
+ if (bytes === undefined) {
789
+ // The sanitizer's output is total (detached, JSON-safe), so this
790
+ // is unreachable unless the sanitizer itself regresses. Fail as a
791
+ // dedicated local error: it carries no transport facts, so it is
792
+ // non-retryable and can never be misattributed to the provider.
793
+ this.discard();
794
+ throw new ManagedAttemptSnapshotError();
795
+ }
796
+ if (this.#wouldOverflow(bytes)) {
797
+ this.discard();
798
+ throw new ManagedAttemptBufferOverflowError();
799
+ }
800
+ }
801
+ this.#batch.push({ type: "event", event: snapshot });
382
802
  this.#stagedEventCount += 1;
383
803
 
384
804
  this.#stagedBytes += bytes;
385
805
  }
806
+
807
+ #repairAssistantEvent(event: AgentEvent): AgentEvent {
808
+ if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
809
+ return event.message.role === "assistant"
810
+ ? { ...event, message: managedAssistantShell(event.message, this.model) }
811
+ : event;
812
+ }
813
+ if (event.type === "message_update") {
814
+ const message = managedAssistantShell(event.message, this.model);
815
+ return {
816
+ ...event,
817
+ message,
818
+ assistantMessageEvent: managedAssistantEventSnapshot(event.assistantMessageEvent, message),
819
+ };
820
+ }
821
+ if (event.type === "agent_end") {
822
+ return {
823
+ ...event,
824
+ messages: event.messages.map(message =>
825
+ message.role === "assistant" ? managedAssistantShell(message, this.model) : message,
826
+ ),
827
+ };
828
+ }
829
+ return event;
830
+ }
386
831
  }
387
832
 
388
833
  /**
@@ -789,6 +1234,9 @@ async function runLoopBody(
789
1234
  // first iteration is skipped to avoid duplicating/racing it.
790
1235
  let modelHasResponded = false;
791
1236
  let harmonyTruncateResumeCount = 0;
1237
+ // Fires at most one repaired resend per run for the poisoned-history
1238
+ // `invalid_prompt` circuit breaker below.
1239
+ let invalidPromptRepairAttempted = false;
792
1240
 
793
1241
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
794
1242
  while (true) {
@@ -799,7 +1247,7 @@ async function runLoopBody(
799
1247
  const transaction =
800
1248
  initialTransaction ??
801
1249
  (config.fallbackManaged
802
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
1250
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
803
1251
  : undefined);
804
1252
  initialTransaction = undefined;
805
1253
  const attemptStream = transaction ?? stream;
@@ -894,11 +1342,20 @@ async function runLoopBody(
894
1342
  harmonyTruncateResumeCount = 0;
895
1343
  } catch (err) {
896
1344
  if (!(err instanceof HarmonyLeakInterruption)) {
1345
+ const failureMessage = managedFailureMessage(err, config);
1346
+ if (config.fallbackManaged && transaction && managedContextOverflow(failureMessage, config)) {
1347
+ transaction.discard();
1348
+ currentContext.messages.splice(contextMessageCount);
1349
+ newMessages.splice(newMessageCount);
1350
+ await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(failureMessage));
1351
+ stream.end(newMessages);
1352
+ return;
1353
+ }
897
1354
  if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
898
1355
  transaction.discard();
899
1356
  currentContext.messages.splice(contextMessageCount);
900
1357
  newMessages.splice(newMessageCount);
901
- await config.onManagedAttemptOutcome?.(managedFailureOutcome(managedFailureMessage(err, config)));
1358
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(failureMessage));
902
1359
  stream.end(newMessages);
903
1360
  return;
904
1361
  }
@@ -949,17 +1406,46 @@ async function runLoopBody(
949
1406
  continue;
950
1407
  }
951
1408
  }
1409
+ // Session-level invalid_prompt circuit breaker (bounded, neutralize-only).
1410
+ // A poisoned-history rejection (`Request blocked (code=invalid_prompt)`) is
1411
+ // a deterministic content fault: re-sending the same history re-triggers it,
1412
+ // so naive session auto-retry would burn its whole budget re-poisoning the
1413
+ // model. On the first invalid_prompt of this run, neutralize leaked control
1414
+ // tokens in history IN PLACE (never dropping items). If that changed the
1415
+ // outgoing bytes, resend exactly once with the repaired history; if
1416
+ // neutralization cannot change anything (nothing left to repair), fall
1417
+ // through to terminal handling and fail fast. Budget = one repaired resend.
1418
+ // Runs before the response is committed so the resend is a clean retry;
1419
+ // managed fallback owns its own retry policy, so this is scoped to the
1420
+ // non-managed session path where uncontrolled auto-retry would recur.
1421
+ if (
1422
+ !config.fallbackManaged &&
1423
+ message.stopReason === "error" &&
1424
+ !invalidPromptRepairAttempted &&
1425
+ isInvalidPromptError(message)
1426
+ ) {
1427
+ invalidPromptRepairAttempted = true;
1428
+ if (repairInvalidPromptHistory(currentContext.messages)) {
1429
+ continue;
1430
+ }
1431
+ }
1432
+
1433
+ const overflow = managedContextOverflow(message, config);
1434
+ if (config.fallbackManaged && overflow) {
1435
+ transaction?.discard();
1436
+ currentContext.messages.splice(contextMessageCount);
1437
+ newMessages.splice(newMessageCount);
1438
+ await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(message));
1439
+ stream.end(newMessages);
1440
+ return;
1441
+ }
1442
+
952
1443
  newMessages.push(message);
953
1444
  modelHasResponded = true;
954
1445
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
955
1446
 
956
- // Detect empty "successful" responses (stopReason "stop" + empty content).
957
- // Some proxies (e.g. LiteLLM) return this when the upstream model's context
958
- // window is exceeded, fabricating a near-zero usage instead of surfacing an
959
- // error. Without this guard the agent loop treats the empty response as a
960
- // natural turn completion and stops, leaving the user with a frozen session.
961
- // Promote it to an error so the overflow/compaction recovery path can fire.
962
- if (message.stopReason === "stop" && message.content.length === 0 && isEmptyResponseOverflow(message)) {
1447
+ // Preserve the historical public error conversion for unmanaged proxy overflows.
1448
+ if (!config.fallbackManaged && message.stopReason === "stop" && message.content.length === 0 && overflow) {
963
1449
  message.stopReason = "error";
964
1450
  message.errorMessage = message.errorMessage
965
1451
  ? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
@@ -983,6 +1469,14 @@ async function runLoopBody(
983
1469
  stream.end(newMessages);
984
1470
  return;
985
1471
  }
1472
+ if (attemptTransaction) {
1473
+ message = managedAssistantShell(message, config.model);
1474
+ const index = currentContext.messages.length - 1;
1475
+ if (index >= 0 && currentContext.messages[index]?.role === "assistant") {
1476
+ currentContext.messages[index] = message;
1477
+ }
1478
+ newMessages[newMessages.length - 1] = message;
1479
+ }
986
1480
 
987
1481
  // One provider invocation is committed before any tool can run.
988
1482
  transaction?.flush();
@@ -1269,7 +1763,9 @@ async function streamAssistantResponse(
1269
1763
 
1270
1764
  switch (event.type) {
1271
1765
  case "start":
1272
- partialMessage = event.partial;
1766
+ partialMessage = config.fallbackManaged
1767
+ ? managedAssistantShell(event.partial, config.model)
1768
+ : event.partial;
1273
1769
  context.messages.push(partialMessage);
1274
1770
  addedPartial = true;
1275
1771
  stream.push({ type: "message_start", message: { ...partialMessage } });
@@ -1285,19 +1781,23 @@ async function streamAssistantResponse(
1285
1781
  case "thinking_start":
1286
1782
  case "thinking_delta":
1287
1783
  case "thinking_end":
1784
+ case "reasoning_summary_start":
1785
+ case "reasoning_summary_delta":
1786
+ case "reasoning_summary_end":
1288
1787
  case "toolcall_start":
1289
1788
  case "toolcall_delta":
1290
1789
  case "toolcall_end":
1291
1790
  if (partialMessage) {
1292
- partialMessage = event.partial;
1791
+ partialMessage = config.fallbackManaged
1792
+ ? managedAssistantShell(event.partial, config.model)
1793
+ : event.partial;
1794
+ const partialEvent = config.fallbackManaged ? { ...event, partial: partialMessage } : event;
1293
1795
  context.messages[context.messages.length - 1] = partialMessage;
1294
- config.onAssistantMessageEvent?.(partialMessage, event);
1295
- if (signal?.aborted) {
1296
- continue;
1297
- }
1796
+ config.onAssistantMessageEvent?.(partialMessage, partialEvent);
1797
+ if (signal?.aborted) continue;
1298
1798
  stream.push({
1299
1799
  type: "message_update",
1300
- assistantMessageEvent: event,
1800
+ assistantMessageEvent: partialEvent,
1301
1801
  message: { ...partialMessage },
1302
1802
  });
1303
1803
  }
@@ -1305,7 +1805,9 @@ async function streamAssistantResponse(
1305
1805
 
1306
1806
  case "done":
1307
1807
  case "error": {
1308
- const finalMessage = await response.result();
1808
+ const finalMessage = config.fallbackManaged
1809
+ ? managedAssistantShell(await response.result(), config.model)
1810
+ : await response.result();
1309
1811
  if (addedPartial) {
1310
1812
  context.messages[context.messages.length - 1] = finalMessage;
1311
1813
  } else {
@@ -1324,7 +1826,9 @@ async function streamAssistantResponse(
1324
1826
  detachAbortListener?.();
1325
1827
  }
1326
1828
 
1327
- const trailing = await response.result();
1829
+ const trailing = config.fallbackManaged
1830
+ ? managedAssistantShell(await response.result(), config.model)
1831
+ : await response.result();
1328
1832
  await finishChat(trailing);
1329
1833
  return trailing;
1330
1834
  });