@gajae-code/agent-core 0.11.1 → 0.11.3

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,
@@ -67,35 +69,58 @@ import type {
67
69
  export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
68
70
  export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
69
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
+ */
70
79
  class ManagedAttemptBufferOverflowError extends Error {
71
- readonly status = 503;
72
-
73
80
  constructor() {
74
81
  super("Managed fallback attempt exceeded the provisional event buffer limit");
75
82
  this.name = "ManagedAttemptBufferOverflowError";
76
83
  }
77
84
  }
78
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
+
79
100
  const managedAttemptTextEncoder = new TextEncoder();
80
101
 
81
102
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
82
- /**
83
- * Detect empty "successful" responses that indicate a proxy-level context
84
- * overflow (e.g. LiteLLM returning `content: []`, `stopReason: "stop"`, and a
85
- * fabricated near-zero usage). We delegate to {@link isContextOverflow} which
86
- * has the threshold constant, so the detection logic stays in one place.
87
- */
88
- function isEmptyResponseOverflow(message: AssistantMessage): boolean {
89
- 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);
90
109
  }
91
110
 
92
- /** Managed fallback owns retry policy; only typed transport facts may discard an attempt. */
93
- function managedTransportFailure(failure: unknown) {
94
- if (failure && typeof failure === "object" && "transportFailure" in failure) {
95
- const facts = (failure as { transportFailure?: unknown }).transportFailure;
96
- 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;
97
118
  }
98
- 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;
99
124
  }
100
125
 
101
126
  function managedRetryableFailure(failure: unknown): boolean {
@@ -150,16 +175,22 @@ function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome
150
175
  };
151
176
  }
152
177
 
178
+ function managedContextOverflowOutcome(message: AssistantMessage): ManagedAttemptOutcome {
179
+ return { type: "context_overflow_discarded", message };
180
+ }
181
+
153
182
  function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
154
- const details = error as { message?: unknown; errorStatus?: unknown; status?: unknown };
155
- const status =
156
- typeof details.errorStatus === "number"
157
- ? details.errorStatus
158
- : typeof details.status === "number"
159
- ? details.status
160
- : undefined;
161
- const transportFailure =
162
- 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
+ }
163
194
  return {
164
195
  role: "assistant",
165
196
  content: [],
@@ -175,8 +206,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
175
206
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
176
207
  },
177
208
  stopReason: "error",
178
- errorMessage: typeof details.message === "string" ? details.message : String(error),
179
- errorStatus: status,
209
+ errorMessage: fallbackMessage,
180
210
  ...(transportFailure ? { transportFailure } : {}),
181
211
  timestamp: Date.now(),
182
212
  };
@@ -259,7 +289,7 @@ export function agentLoop(
259
289
  messages: [...context.messages, ...prompts],
260
290
  };
261
291
  const transaction = config.fallbackManaged
262
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
292
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
263
293
  : undefined;
264
294
  const attemptStream = transaction ?? stream;
265
295
  if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
@@ -308,7 +338,7 @@ export function agentLoopContinue(
308
338
  const newMessages: AgentMessage[] = [];
309
339
  const currentContext: AgentContext = { ...context };
310
340
  const transaction = config.fallbackManaged
311
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
341
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
312
342
  : undefined;
313
343
  const attemptStream = transaction ?? stream;
314
344
  if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
@@ -331,9 +361,321 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
331
361
  );
332
362
  }
333
363
 
334
- /** 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
+
335
516
  function managedAttemptSnapshot<T>(value: T): T {
336
- 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);
337
679
  }
338
680
 
339
681
  /**
@@ -353,7 +695,10 @@ class ManagedAttemptTransaction {
353
695
 
354
696
  constructor(
355
697
  private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
356
- 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"],
357
702
  ) {}
358
703
 
359
704
  push(event: AgentEvent): void {
@@ -369,10 +714,11 @@ class ManagedAttemptTransaction {
369
714
  }
370
715
 
371
716
  stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
717
+ const partial = managedAssistantShell(message, this.model);
372
718
  this.#batch.push({
373
719
  type: "assistant_event",
374
- message: managedAttemptSnapshot(message),
375
- event: managedAttemptSnapshot(event),
720
+ message: partial,
721
+ event: managedAssistantEventSnapshot(event, partial),
376
722
  });
377
723
  }
378
724
 
@@ -398,25 +744,90 @@ class ManagedAttemptTransaction {
398
744
  this.#discarded = true;
399
745
  }
400
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
+
401
754
  #stage(event: AgentEvent): void {
402
- 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;
403
762
  try {
404
763
  bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
405
764
  } catch {
406
- bytes = MANAGED_ATTEMPT_MAX_STAGED_BYTES + 1;
765
+ bytes = undefined;
407
766
  }
408
- if (
409
- this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
410
- this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
411
- ) {
767
+ if (bytes !== undefined && this.#wouldOverflow(bytes)) {
412
768
  this.discard();
413
769
  throw new ManagedAttemptBufferOverflowError();
414
770
  }
415
- 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 });
416
802
  this.#stagedEventCount += 1;
417
803
 
418
804
  this.#stagedBytes += bytes;
419
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
+ }
420
831
  }
421
832
 
422
833
  /**
@@ -836,7 +1247,7 @@ async function runLoopBody(
836
1247
  const transaction =
837
1248
  initialTransaction ??
838
1249
  (config.fallbackManaged
839
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
1250
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
840
1251
  : undefined);
841
1252
  initialTransaction = undefined;
842
1253
  const attemptStream = transaction ?? stream;
@@ -931,11 +1342,20 @@ async function runLoopBody(
931
1342
  harmonyTruncateResumeCount = 0;
932
1343
  } catch (err) {
933
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
+ }
934
1354
  if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
935
1355
  transaction.discard();
936
1356
  currentContext.messages.splice(contextMessageCount);
937
1357
  newMessages.splice(newMessageCount);
938
- await config.onManagedAttemptOutcome?.(managedFailureOutcome(managedFailureMessage(err, config)));
1358
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(failureMessage));
939
1359
  stream.end(newMessages);
940
1360
  return;
941
1361
  }
@@ -1009,17 +1429,23 @@ async function runLoopBody(
1009
1429
  continue;
1010
1430
  }
1011
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
+
1012
1443
  newMessages.push(message);
1013
1444
  modelHasResponded = true;
1014
1445
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
1015
1446
 
1016
- // Detect empty "successful" responses (stopReason "stop" + empty content).
1017
- // Some proxies (e.g. LiteLLM) return this when the upstream model's context
1018
- // window is exceeded, fabricating a near-zero usage instead of surfacing an
1019
- // error. Without this guard the agent loop treats the empty response as a
1020
- // natural turn completion and stops, leaving the user with a frozen session.
1021
- // Promote it to an error so the overflow/compaction recovery path can fire.
1022
- 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) {
1023
1449
  message.stopReason = "error";
1024
1450
  message.errorMessage = message.errorMessage
1025
1451
  ? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
@@ -1043,6 +1469,14 @@ async function runLoopBody(
1043
1469
  stream.end(newMessages);
1044
1470
  return;
1045
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
+ }
1046
1480
 
1047
1481
  // One provider invocation is committed before any tool can run.
1048
1482
  transaction?.flush();
@@ -1329,7 +1763,9 @@ async function streamAssistantResponse(
1329
1763
 
1330
1764
  switch (event.type) {
1331
1765
  case "start":
1332
- partialMessage = event.partial;
1766
+ partialMessage = config.fallbackManaged
1767
+ ? managedAssistantShell(event.partial, config.model)
1768
+ : event.partial;
1333
1769
  context.messages.push(partialMessage);
1334
1770
  addedPartial = true;
1335
1771
  stream.push({ type: "message_start", message: { ...partialMessage } });
@@ -1345,19 +1781,23 @@ async function streamAssistantResponse(
1345
1781
  case "thinking_start":
1346
1782
  case "thinking_delta":
1347
1783
  case "thinking_end":
1784
+ case "reasoning_summary_start":
1785
+ case "reasoning_summary_delta":
1786
+ case "reasoning_summary_end":
1348
1787
  case "toolcall_start":
1349
1788
  case "toolcall_delta":
1350
1789
  case "toolcall_end":
1351
1790
  if (partialMessage) {
1352
- 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;
1353
1795
  context.messages[context.messages.length - 1] = partialMessage;
1354
- config.onAssistantMessageEvent?.(partialMessage, event);
1355
- if (signal?.aborted) {
1356
- continue;
1357
- }
1796
+ config.onAssistantMessageEvent?.(partialMessage, partialEvent);
1797
+ if (signal?.aborted) continue;
1358
1798
  stream.push({
1359
1799
  type: "message_update",
1360
- assistantMessageEvent: event,
1800
+ assistantMessageEvent: partialEvent,
1361
1801
  message: { ...partialMessage },
1362
1802
  });
1363
1803
  }
@@ -1365,7 +1805,9 @@ async function streamAssistantResponse(
1365
1805
 
1366
1806
  case "done":
1367
1807
  case "error": {
1368
- const finalMessage = await response.result();
1808
+ const finalMessage = config.fallbackManaged
1809
+ ? managedAssistantShell(await response.result(), config.model)
1810
+ : await response.result();
1369
1811
  if (addedPartial) {
1370
1812
  context.messages[context.messages.length - 1] = finalMessage;
1371
1813
  } else {
@@ -1384,7 +1826,9 @@ async function streamAssistantResponse(
1384
1826
  detachAbortListener?.();
1385
1827
  }
1386
1828
 
1387
- const trailing = await response.result();
1829
+ const trailing = config.fallbackManaged
1830
+ ? managedAssistantShell(await response.result(), config.model)
1831
+ : await response.result();
1388
1832
  await finishChat(trailing);
1389
1833
  return trailing;
1390
1834
  });