@sayknow-cli/agent-core 0.3.16 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent-loop.ts CHANGED
@@ -2,19 +2,24 @@
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,
12
+ classifyFallbackTrigger,
9
13
  EventStream,
10
- isContextOverflow,
11
14
  isZodSchema,
12
15
  streamSimple,
13
16
  type ToolResultMessage,
14
17
  type TSchema,
18
+ transportFailureFacts,
15
19
  validateToolArguments,
16
20
  zodToWireSchema,
17
21
  } from "@sayknow-cli/ai";
22
+ import { isInvalidPromptError, neutralizeReservedControlTokens } from "@sayknow-cli/ai/utils";
18
23
  import { sanitizeText } from "@sayknow-cli/utils";
19
24
  import {
20
25
  createHarmonyAuditEvent,
@@ -51,19 +56,160 @@ import type {
51
56
  AgentMessage,
52
57
  AgentTool,
53
58
  AgentToolResult,
59
+ ManagedAttemptOutcome,
54
60
  StreamFn,
55
61
  } from "./types";
56
62
 
57
63
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
64
+ /**
65
+ * Defensive caps for a provisional managed attempt. These are intentionally
66
+ * well above ordinary streamed responses; they only bound memory when an
67
+ * upstream emits an unbounded event stream before the attempt can commit.
68
+ */
69
+ export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
70
+ export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
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
+ */
79
+ class ManagedAttemptBufferOverflowError extends Error {
80
+ constructor() {
81
+ super("Managed fallback attempt exceeded the provisional event buffer limit");
82
+ this.name = "ManagedAttemptBufferOverflowError";
83
+ }
84
+ }
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
+
100
+ const managedAttemptTextEncoder = new TextEncoder();
101
+
58
102
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
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);
109
+ }
110
+
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;
118
+ }
119
+ }
120
+
121
+ function managedTransportFailure(failure: unknown) {
122
+ const facts = managedProperty(failure, "transportFailure");
123
+ return facts && typeof facts === "object" ? transportFailureFacts(facts) : undefined;
124
+ }
125
+
126
+ function managedRetryableFailure(failure: unknown): boolean {
127
+ const facts = managedTransportFailure(failure);
128
+ if (!facts) return false;
129
+ const trigger = classifyFallbackTrigger(facts);
130
+ return (
131
+ trigger.class === "rate_limit" ||
132
+ trigger.class === "quota" ||
133
+ trigger.class === "auth" ||
134
+ trigger.class === "server"
135
+ );
136
+ }
137
+
59
138
  /**
60
- * Detect empty "successful" responses that indicate a proxy-level context
61
- * overflow (e.g. LiteLLM returning `content: []`, `stopReason: "stop"`, and a
62
- * fabricated near-zero usage). We delegate to {@link isContextOverflow} which
63
- * has the threshold constant, so the detection logic stays in one place.
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).
64
145
  */
65
- function isEmptyResponseOverflow(message: AssistantMessage): boolean {
66
- return isContextOverflow(message);
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
+
171
+ function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
172
+ return {
173
+ type: "retryable_discarded",
174
+ failure: { message, transportFailure: managedTransportFailure(message) },
175
+ };
176
+ }
177
+
178
+ function managedContextOverflowOutcome(message: AssistantMessage): ManagedAttemptOutcome {
179
+ return { type: "context_overflow_discarded", message };
180
+ }
181
+
182
+ function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
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
+ }
194
+ return {
195
+ role: "assistant",
196
+ content: [],
197
+ api: config.model.api,
198
+ provider: config.model.provider,
199
+ model: config.model.id,
200
+ usage: {
201
+ input: 0,
202
+ output: 0,
203
+ cacheRead: 0,
204
+ cacheWrite: 0,
205
+ totalTokens: 0,
206
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
207
+ },
208
+ stopReason: "error",
209
+ errorMessage: fallbackMessage,
210
+ ...(transportFailure ? { transportFailure } : {}),
211
+ timestamp: Date.now(),
212
+ };
67
213
  }
68
214
 
69
215
  class HarmonyLeakInterruption extends Error {
@@ -132,6 +278,7 @@ export function agentLoop(
132
278
  config: AgentLoopConfig,
133
279
  signal?: AbortSignal,
134
280
  streamFn?: StreamFn,
281
+ emitManagedAgentStart = true,
135
282
  ): EventStream<AgentEvent, AgentMessage[]> {
136
283
  const stream = createAgentStream();
137
284
 
@@ -141,16 +288,19 @@ export function agentLoop(
141
288
  ...context,
142
289
  messages: [...context.messages, ...prompts],
143
290
  };
144
-
145
- stream.push({ type: "agent_start" });
146
- stream.push({ type: "turn_start" });
291
+ const transaction = config.fallbackManaged
292
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
293
+ : undefined;
294
+ const attemptStream = transaction ?? stream;
295
+ if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
296
+ attemptStream.push({ type: "turn_start" });
147
297
  for (const prompt of prompts) {
148
298
  stream.push({ type: "message_start", message: prompt });
149
299
  stream.push({ type: "message_end", message: prompt });
150
300
  }
151
301
 
152
302
  try {
153
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
303
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
154
304
  } catch (err) {
155
305
  stream.fail(err);
156
306
  }
@@ -172,6 +322,7 @@ export function agentLoopContinue(
172
322
  config: AgentLoopConfig,
173
323
  signal?: AbortSignal,
174
324
  streamFn?: StreamFn,
325
+ emitManagedAgentStart = true,
175
326
  ): EventStream<AgentEvent, AgentMessage[]> {
176
327
  if (context.messages.length === 0) {
177
328
  throw new Error("Cannot continue: no messages in context");
@@ -186,12 +337,15 @@ export function agentLoopContinue(
186
337
  (async () => {
187
338
  const newMessages: AgentMessage[] = [];
188
339
  const currentContext: AgentContext = { ...context };
189
-
190
- stream.push({ type: "agent_start" });
191
- stream.push({ type: "turn_start" });
340
+ const transaction = config.fallbackManaged
341
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
342
+ : undefined;
343
+ const attemptStream = transaction ?? stream;
344
+ if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
345
+ attemptStream.push({ type: "turn_start" });
192
346
 
193
347
  try {
194
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
348
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
195
349
  } catch (err) {
196
350
  stream.fail(err);
197
351
  }
@@ -207,6 +361,475 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
207
361
  );
208
362
  }
209
363
 
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
+
516
+ function managedAttemptSnapshot<T>(value: T): T {
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);
679
+ }
680
+
681
+ /**
682
+ * Holds managed-attempt assistant output above the public event stream. A
683
+ * cancelled provider attempt is therefore unobservable to sessions and their
684
+ * side-effect consumers. Non-managed streams bypass this object entirely.
685
+ */
686
+ class ManagedAttemptTransaction {
687
+ #batch: Array<
688
+ | { type: "event"; event: AgentEvent }
689
+ | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
690
+ > = [];
691
+ #stagedEventCount = 0;
692
+ #stagedBytes = 0;
693
+ #discarded = false;
694
+ #committed = false;
695
+
696
+ constructor(
697
+ private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
698
+ private readonly onAssistantMessageEvent:
699
+ | ((message: AssistantMessage, event: AssistantMessageEvent) => void)
700
+ | undefined,
701
+ private readonly model: AgentLoopConfig["model"],
702
+ ) {}
703
+
704
+ push(event: AgentEvent): void {
705
+ if (this.#committed) {
706
+ this.stream.push(event);
707
+ return;
708
+ }
709
+ this.#stage(event);
710
+ }
711
+
712
+ end(messages: AgentMessage[]): void {
713
+ this.stream.end(messages);
714
+ }
715
+
716
+ stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
717
+ const partial = managedAssistantShell(message, this.model);
718
+ this.#batch.push({
719
+ type: "assistant_event",
720
+ message: partial,
721
+ event: managedAssistantEventSnapshot(event, partial),
722
+ });
723
+ }
724
+
725
+ flush(): void {
726
+ if (this.#discarded || this.#committed) return;
727
+ for (const item of this.#batch) {
728
+ if (item.type === "assistant_event") {
729
+ this.onAssistantMessageEvent?.(item.message, item.event);
730
+ } else {
731
+ this.stream.push(item.event);
732
+ }
733
+ }
734
+ this.#batch = [];
735
+ this.#stagedBytes = 0;
736
+ this.#stagedEventCount = 0;
737
+ this.#committed = true;
738
+ }
739
+
740
+ discard(): void {
741
+ this.#batch = [];
742
+ this.#stagedBytes = 0;
743
+ this.#stagedEventCount = 0;
744
+ this.#discarded = true;
745
+ }
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
+
754
+ #stage(event: AgentEvent): void {
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;
762
+ try {
763
+ bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
764
+ } catch {
765
+ bytes = undefined;
766
+ }
767
+ if (bytes !== undefined && this.#wouldOverflow(bytes)) {
768
+ this.discard();
769
+ throw new ManagedAttemptBufferOverflowError();
770
+ }
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 });
802
+ this.#stagedEventCount += 1;
803
+
804
+ this.#stagedBytes += bytes;
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
+ }
831
+ }
832
+
210
833
  /**
211
834
  * Build the `agent_end` event payload. When telemetry is enabled, snapshots
212
835
  * the run collector so consumers receive {@link AgentRunSummary} +
@@ -549,7 +1172,10 @@ async function runLoop(
549
1172
  signal: AbortSignal | undefined,
550
1173
  stream: EventStream<AgentEvent, AgentMessage[]>,
551
1174
  streamFn?: StreamFn,
1175
+ initialTransaction?: ManagedAttemptTransaction,
552
1176
  ): Promise<void> {
1177
+ const loopSignal = signal ?? new AbortController().signal;
1178
+
553
1179
  const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
554
1180
  const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
555
1181
  const stepCounter = { count: 0 };
@@ -560,12 +1186,14 @@ async function runLoop(
560
1186
  currentContext,
561
1187
  newMessages,
562
1188
  config,
563
- signal,
1189
+ loopSignal,
1190
+
564
1191
  stream,
565
1192
  telemetry,
566
1193
  invokeAgentSpan,
567
1194
  stepCounter,
568
1195
  streamFn,
1196
+ initialTransaction,
569
1197
  ),
570
1198
  );
571
1199
  } catch (err) {
@@ -587,18 +1215,28 @@ async function runLoopBody(
587
1215
  currentContext: AgentContext,
588
1216
  newMessages: AgentMessage[],
589
1217
  config: AgentLoopConfig,
590
- signal: AbortSignal | undefined,
1218
+ loopSignal: AbortSignal,
1219
+
591
1220
  stream: EventStream<AgentEvent, AgentMessage[]>,
592
1221
  telemetry: AgentTelemetry | undefined,
593
1222
  invokeAgentSpan: Span | undefined,
594
1223
  stepCounter: StepCounter,
595
1224
  streamFn?: StreamFn,
1225
+ initialTransaction?: ManagedAttemptTransaction,
596
1226
  ): Promise<void> {
597
1227
  let firstTurn = true;
598
1228
  // Check for steering messages at start (user may have typed while waiting)
599
1229
  let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
600
1230
  let harmonyRetryAttempt = 0;
1231
+ // Whether at least one assistant response has been produced in THIS run. The
1232
+ // mid-run maintenance checkpoint only fires between tool iterations (after a
1233
+ // model response); pre-turn maintenance is the pre-prompt check's job, so the
1234
+ // first iteration is skipped to avoid duplicating/racing it.
1235
+ let modelHasResponded = false;
601
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;
602
1240
 
603
1241
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
604
1242
  while (true) {
@@ -606,13 +1244,21 @@ async function runLoopBody(
606
1244
 
607
1245
  // Inner loop: process tool calls and steering messages
608
1246
  while (hasMoreToolCalls || pendingMessages.length > 0) {
1247
+ const transaction =
1248
+ initialTransaction ??
1249
+ (config.fallbackManaged
1250
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
1251
+ : undefined);
1252
+ initialTransaction = undefined;
1253
+ const attemptStream = transaction ?? stream;
609
1254
  if (!firstTurn) {
610
- stream.push({ type: "turn_start" });
1255
+ attemptStream.push({ type: "turn_start" });
611
1256
  } else {
612
1257
  firstTurn = false;
613
1258
  }
614
1259
 
615
- // Process pending messages (inject before next assistant response)
1260
+ // Commit queued user input outside the provisional assistant transaction so a
1261
+ // discarded managed attempt cannot lose it before its retry continuation.
616
1262
  if (pendingMessages.length > 0) {
617
1263
  for (const message of pendingMessages) {
618
1264
  stream.push({ type: "message_start", message });
@@ -623,20 +1269,63 @@ async function runLoopBody(
623
1269
  pendingMessages = [];
624
1270
  }
625
1271
 
1272
+ // Cooperative mid-run context maintenance. Runs after pending
1273
+ // tool/steering messages are materialized into durable context and
1274
+ // before syncContextBeforeModelCall / the model call — the only
1275
+ // boundary where the full unsent context is already durable. A
1276
+ // non-"not-needed" outcome means context was (or was attempted to be)
1277
+ // rewritten, so end the run WITHOUT the lossy agent_end finalization;
1278
+ // the maintenance owner resumes the run on the rewritten context.
1279
+ // "not-needed" falls through to the model call.
1280
+ if (config.maintainContext && modelHasResponded && !loopSignal.aborted) {
1281
+ const lifecycle = {
1282
+ signal: loopSignal,
1283
+ awaitEventDrain: (invocationSignal: AbortSignal) =>
1284
+ stream.waitForConsumerDrain(AbortSignal.any([loopSignal, invocationSignal])),
1285
+ };
1286
+ const maintenanceOutcome = await config.maintainContext(currentContext, lifecycle);
1287
+ // A callback can settle after its loop has been cancelled. Never let a
1288
+ // stale "not-needed" fall through to streamAssistantResponse, which
1289
+ // invokes the provider before it observes the aborted signal.
1290
+ const outcome = loopSignal.aborted ? "aborted" : maintenanceOutcome;
1291
+
1292
+ if (outcome !== "not-needed") {
1293
+ stream.push({
1294
+ type: "agent_end",
1295
+ messages: newMessages,
1296
+ stopReason: "maintenance",
1297
+ maintenanceOutcome: outcome,
1298
+ });
1299
+ stream.end(newMessages);
1300
+ return;
1301
+ }
1302
+ }
1303
+
626
1304
  // Refresh prompt/tool context from live state before each model call
627
1305
  if (config.syncContextBeforeModelCall) {
628
1306
  await config.syncContextBeforeModelCall(currentContext);
629
1307
  }
630
1308
 
1309
+ const contextMessageCount = currentContext.messages.length;
1310
+ const newMessageCount = newMessages.length;
1311
+
631
1312
  // Stream assistant response
632
1313
  let recovered: HarmonyRecoveredToolCall | undefined;
633
1314
  let message: AssistantMessage;
1315
+ const attemptTransaction = transaction;
634
1316
  try {
1317
+ const attemptConfig = attemptTransaction
1318
+ ? {
1319
+ ...config,
1320
+ onAssistantMessageEvent: (partial: AssistantMessage, event: AssistantMessageEvent) =>
1321
+ attemptTransaction.stageAssistantMessageEvent(partial, event),
1322
+ }
1323
+ : config;
635
1324
  message = await streamAssistantResponse(
636
1325
  currentContext,
637
- config,
638
- signal,
639
- stream,
1326
+ attemptConfig,
1327
+ loopSignal,
1328
+ attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
640
1329
  telemetry,
641
1330
  invokeAgentSpan,
642
1331
  stepCounter,
@@ -652,7 +1341,30 @@ async function runLoopBody(
652
1341
  harmonyRetryAttempt = 0;
653
1342
  harmonyTruncateResumeCount = 0;
654
1343
  } catch (err) {
655
- if (!(err instanceof HarmonyLeakInterruption)) throw err;
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
+ }
1354
+ if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
1355
+ transaction.discard();
1356
+ currentContext.messages.splice(contextMessageCount);
1357
+ newMessages.splice(newMessageCount);
1358
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(failureMessage));
1359
+ stream.end(newMessages);
1360
+ return;
1361
+ }
1362
+ throw err;
1363
+ }
1364
+ if (config.fallbackManaged) {
1365
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
1366
+ throw err;
1367
+ }
656
1368
  if (err.recovered) {
657
1369
  if (harmonyTruncateResumeCount >= 2) {
658
1370
  await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
@@ -694,22 +1406,84 @@ async function runLoopBody(
694
1406
  continue;
695
1407
  }
696
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
+
697
1443
  newMessages.push(message);
1444
+ modelHasResponded = true;
698
1445
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
699
1446
 
700
- // Detect empty "successful" responses (stopReason "stop" + empty content).
701
- // Some proxies (e.g. LiteLLM) return this when the upstream model's context
702
- // window is exceeded, fabricating a near-zero usage instead of surfacing an
703
- // error. Without this guard the agent loop treats the empty response as a
704
- // natural turn completion and stops, leaving the user with a frozen session.
705
- // Promote it to an error so the overflow/compaction recovery path can fire.
706
- 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) {
707
1449
  message.stopReason = "error";
708
1450
  message.errorMessage = message.errorMessage
709
1451
  ? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
710
1452
  : "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
711
1453
  }
712
1454
 
1455
+ if (config.fallbackManaged && message.stopReason === "error" && managedRetryableFailure(message)) {
1456
+ transaction?.discard();
1457
+ currentContext.messages.splice(contextMessageCount);
1458
+ newMessages.splice(newMessageCount);
1459
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(message));
1460
+ stream.end(newMessages);
1461
+ return;
1462
+ }
1463
+
1464
+ if (config.fallbackManaged && message.stopReason === "aborted") {
1465
+ transaction?.discard();
1466
+ currentContext.messages.splice(contextMessageCount);
1467
+ newMessages.splice(newMessageCount);
1468
+ await config.onManagedAttemptOutcome?.({ type: "run_terminal", reason: "cancelled" });
1469
+ stream.end(newMessages);
1470
+ return;
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
+ }
1480
+
1481
+ // One provider invocation is committed before any tool can run.
1482
+ transaction?.flush();
1483
+ if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1484
+ await config.onManagedAttemptAccepted?.();
1485
+ }
1486
+
713
1487
  if (message.stopReason === "error" || message.stopReason === "aborted") {
714
1488
  // Create placeholder tool results for any tool calls in the aborted message
715
1489
  // This maintains the tool_use/tool_result pairing that the API requires
@@ -747,7 +1521,7 @@ async function runLoopBody(
747
1521
  const executionResult = await executeToolCalls(
748
1522
  currentContext,
749
1523
  message,
750
- signal,
1524
+ loopSignal,
751
1525
  stream,
752
1526
  config,
753
1527
  telemetry,
@@ -925,8 +1699,10 @@ async function streamAssistantResponse(
925
1699
 
926
1700
  try {
927
1701
  return await runInActiveSpan(chatSpan, async () => {
1702
+ const fallbackAttempt = config.fallbackManaged ? config.nextFallbackAttempt?.(config.model) : undefined;
928
1703
  const response = await streamFunction(config.model, llmContext, {
929
1704
  ...config,
1705
+ fallbackAttempt,
930
1706
  apiKey: resolvedApiKey,
931
1707
  authCredentialType,
932
1708
  metadata: resolvedMetadata,
@@ -987,7 +1763,9 @@ async function streamAssistantResponse(
987
1763
 
988
1764
  switch (event.type) {
989
1765
  case "start":
990
- partialMessage = event.partial;
1766
+ partialMessage = config.fallbackManaged
1767
+ ? managedAssistantShell(event.partial, config.model)
1768
+ : event.partial;
991
1769
  context.messages.push(partialMessage);
992
1770
  addedPartial = true;
993
1771
  stream.push({ type: "message_start", message: { ...partialMessage } });
@@ -1003,19 +1781,23 @@ async function streamAssistantResponse(
1003
1781
  case "thinking_start":
1004
1782
  case "thinking_delta":
1005
1783
  case "thinking_end":
1784
+ case "reasoning_summary_start":
1785
+ case "reasoning_summary_delta":
1786
+ case "reasoning_summary_end":
1006
1787
  case "toolcall_start":
1007
1788
  case "toolcall_delta":
1008
1789
  case "toolcall_end":
1009
1790
  if (partialMessage) {
1010
- 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;
1011
1795
  context.messages[context.messages.length - 1] = partialMessage;
1012
- config.onAssistantMessageEvent?.(partialMessage, event);
1013
- if (signal?.aborted) {
1014
- continue;
1015
- }
1796
+ config.onAssistantMessageEvent?.(partialMessage, partialEvent);
1797
+ if (signal?.aborted) continue;
1016
1798
  stream.push({
1017
1799
  type: "message_update",
1018
- assistantMessageEvent: event,
1800
+ assistantMessageEvent: partialEvent,
1019
1801
  message: { ...partialMessage },
1020
1802
  });
1021
1803
  }
@@ -1023,7 +1805,9 @@ async function streamAssistantResponse(
1023
1805
 
1024
1806
  case "done":
1025
1807
  case "error": {
1026
- const finalMessage = await response.result();
1808
+ const finalMessage = config.fallbackManaged
1809
+ ? managedAssistantShell(await response.result(), config.model)
1810
+ : await response.result();
1027
1811
  if (addedPartial) {
1028
1812
  context.messages[context.messages.length - 1] = finalMessage;
1029
1813
  } else {
@@ -1042,7 +1826,9 @@ async function streamAssistantResponse(
1042
1826
  detachAbortListener?.();
1043
1827
  }
1044
1828
 
1045
- const trailing = await response.result();
1829
+ const trailing = config.fallbackManaged
1830
+ ? managedAssistantShell(await response.result(), config.model)
1831
+ : await response.result();
1046
1832
  await finishChat(trailing);
1047
1833
  return trailing;
1048
1834
  });