Files
oh-my-pi/packages/coding-agent/src/advisor/runtime.ts
T
can1357 12238f55ca feat: implemented native ctok tokenization engine with model scopes
- Implemented the `ctok` Rust native tokenization engine with offline support for Claude V3, V47, V5, and V5Sonnet families.
- Replaced global token estimation with model-scoped `Tokenizer` instances and provider-anchored transcript accounting across packages.
- Added vocabulary generation scripts, test fixtures, and comprehensive unit tests for tokenizer routing and matching modes.
2026-08-19 23:27:29 +02:00

1674 lines
68 KiB
TypeScript

import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { raceWithSignal } from "@oh-my-pi/pi-ai/utils/abort";
import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { logger } from "@oh-my-pi/pi-utils";
import { obfuscateToolArguments } from "../secrets/message-transform";
import type { SecretObfuscator } from "../secrets/obfuscator";
import {
formatExecutionSourcePreview,
formatSessionHistoryMarkdown,
formatToolResultErrorPreview,
PRIMARY_CONTEXT_CUSTOM_TYPES,
} from "../session/session-history-format";
import { ADVISOR_RENDER_OPTIONS, renderAdvisorDeltaChunks } from "./delta-split";
/**
* Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core
* `Agent`. `state.error` mirrors `Agent.state.error`: provider/stream failures
* the loop catches internally never reject `prompt()`, so the runtime reads
* this field after every prompt to detect a failed turn.
*/
export interface AdvisorAgent {
prompt(input: string | AgentMessage[]): Promise<void>;
abort(reason?: unknown): void;
reset(): void;
/**
* Drop messages appended past `count`. Called after a failed `prompt()` so a
* retry doesn't replay the failed user batch + synthetic assistant-error
* turn `Agent.#runLoop` records on its internal state.
*/
rollbackTo?(count: number): void;
readonly state: { messages: AgentMessage[]; error?: string };
}
export interface AdvisorRuntimeHost {
/** Live primary transcript (use `agent.state.messages`). */
snapshotMessages(): AgentMessage[];
/** Surface one advice note to the primary (enqueues into the session YieldQueue). */
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
/** Redact primary transcript bytes before they reach the advisor model. */
obfuscator?: SecretObfuscator;
/**
* Pre-prompt context maintenance for the advisor's own append-only context.
* Promotes the advisor model to a larger sibling when its context nears the
* window (mirroring the primary's promote-first policy) and resolves `true`
* when the advisor must clear its own context before sending the current
* incremental update. The cursor stays at the current primary position: this
* recovery path must never replay the full primary transcript.
*
* Takes the pending update as a message rather than a token count: sizing it
* needs the advisor model's tokenizer, which the host owns.
* Optional: hosts that omit it get no proactive maintenance.
*/
maintainContext?(incoming: AgentMessage, signal: AbortSignal): Promise<boolean>;
/**
* Called immediately before each `agent.prompt(batch)` cycle. Lets the host
* clear per-update advisor state and apply the in-progress delivery policy.
* The host owns these gates because it routes `advise()` results back to the
* primary.
*/
beginAdvisorUpdate?(inProgress: boolean): void;
/**
* Called with the error of every failed advisor turn, before the retry sleep
* or the dropped-after-3 path. Lets the host apply credential-level remedies
* and configured model fallback that the advisor loop cannot perform itself.
* Return `true` after switching models so the same clean batch is retried
* immediately with a fresh failure budget. `failedMessages` contains the
* failed prompt's appended turns before rollback. Errors thrown here are
* logged and swallowed.
*/
onTurnError?(
error: unknown,
failedMessages: readonly AgentMessage[],
signal: AbortSignal,
): Promise<boolean | undefined> | boolean | undefined;
/** Called after a successful advisor turn so the host can finish fallback lifecycle reporting. */
onTurnSuccess?(): Promise<void> | void;
/** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
notifyFailure?(error: unknown): void;
/** Signal that the advisor paused on a quota/rate-limit after host-level
* recovery (credential switch, fallback chain) declined. Cleared only by
* an explicit reset (`/new`, config rebuild, session restart). */
notifyQuotaExhausted?(): void;
/** Stable identity for the live advisor model. Used to restore full transcript rendering after a model switch. */
getModelIdentity?(): string;
}
/**
* A request rejection that no amount of retrying can fix for this advisor
* configuration: the provider refuses the model/request shape outright (e.g.
* "The 'gpt-5.3-codex-spark' model is not supported when using Codex with a
* ChatGPT account", code=invalid_request_error). Distinct from quota errors,
* which pause via the dedicated quota path and auto-resume on reset.
*/
function isPermanentAdvisorError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /invalid_request_error|model[_ ]not[_ ]found|is not supported when|does not exist/i.test(message);
}
const ADVISOR_QUARANTINE_PREFIX = "Advisor response quarantined";
/** Signals that an advisor response was discarded before it could become model-visible context. */
export class AdvisorOutputQuarantinedError extends Error {
constructor(message: string) {
super(message);
this.name = "AdvisorOutputQuarantinedError";
}
}
interface AdvisorOutputHazard {
label: string;
pattern: RegExp;
}
const ADVISOR_OUTPUT_ONLY_HAZARDS: readonly AdvisorOutputHazard[] = [
{ label: "account-deletion claim", pattern: /\buser\b.{0,80}\b(?:deleted|erased)\b.{0,80}\baccount\b/i },
{
label: "instruction override",
pattern: /\bignore\s+(?:all\s+)?(?:prior|previous|earlier)\s+(?:user\s+)?instructions\b/i,
},
{
label: "destructive shell command",
pattern: /\brm\s+(?=(?:-[a-z]+\s*)*-[a-z]*r[a-z]*)(?=(?:-[a-z]+\s*)*-[a-z]*f[a-z]*)(?:-[a-z]+\s*)+/i,
},
{ label: "denial instruction", pattern: /\bdeny\s+(?:this|it|the\s+request)\s+if\s+(?:asked|questioned)\b/i },
];
/**
* Replaces an advisor assistant turn that requested unavailable tools or generated
* output-only destructive directives with a sanitized error before dispatch.
*
* The agent loop records assistant turns before dispatching tools. Without this
* pre-dispatch rewrite, an advisor hallucination can leave unrelated text in the
* advisor transcript even though the action itself never executes.
*/
export function quarantineAdvisorUnsafeOutput(
message: AssistantMessage,
availableToolNames: ReadonlySet<string>,
sourceText = "",
): string | undefined {
const reasons: string[] = [];
const unavailableToolNames = new Set<string>();
const generatedParts: string[] = [];
for (const block of message.content) {
// Cursor exec-channel native blocks (bash/read/grep/...) are stamped
// kCursorExecResolved: they already ran server-side through the
// advisor-scoped CursorExecHandlers bridge, which rejects ungranted
// tools in-band ("Tool not available") and lets the model self-correct.
// Quarantining them would discard the legitimate advise emitted in the
// same turn (issue #5900). The scoped bridge is the grant gate here, not
// this pre-dispatch check.
if (
block.type === "toolCall" &&
!availableToolNames.has(block.name) &&
(block as CursorExecResolvedCarrier)[kCursorExecResolved] !== true
) {
unavailableToolNames.add(block.name);
}
if (block.type === "toolCall" && block.name === "advise" && typeof block.arguments.note === "string") {
generatedParts.push(block.arguments.note);
}
if (block.type === "text") generatedParts.push(block.text);
}
if (unavailableToolNames.size > 0) {
const names = [...unavailableToolNames].sort();
const toolLabel = names.length === 1 ? "tool" : "tools";
reasons.push(`requested unavailable ${toolLabel} ${names.join(", ")}`);
}
const generatedText = generatedParts.join("\n");
if (generatedText) {
const labels: string[] = [];
const matchedLabels: string[] = [];
for (const hazard of ADVISOR_OUTPUT_ONLY_HAZARDS) {
if (!hazard.pattern.test(generatedText)) continue;
matchedLabels.push(hazard.label);
if (!hazard.pattern.test(sourceText)) labels.push(hazard.label);
}
// A transcript can quote a destructive command while the advisor turns it
// into a new instruction. The output-only override remains sufficient
// provenance to quarantine that combination.
if (
matchedLabels.includes("destructive shell command") &&
labels.includes("instruction override") &&
!labels.includes("destructive shell command")
) {
labels.push("destructive shell command");
}
if (labels.includes("destructive shell command") || labels.length >= 3) {
reasons.push(`generated output-only destructive directives: ${labels.join(", ")}`);
}
}
if (reasons.length === 0) return undefined;
const messageText = `${ADVISOR_QUARANTINE_PREFIX}: ${reasons.join("; ")}`;
message.content = [{ type: "text", text: messageText }];
message.stopReason = "error";
message.stopDetails = undefined;
message.toolCallAbortMessages = undefined;
message.providerPayload = undefined;
message.errorMessage = messageText;
return messageText;
}
/**
* Builds the provenance text used to decide whether hazardous advisor output was
* generated by the advisor or came from model-visible primary/tool context.
*/
export function buildAdvisorQuarantineSourceText(currentInput: string, messages: readonly AgentMessage[]): string {
const parts: string[] = [];
if (currentInput) parts.push(currentInput);
for (const message of messages) {
if (message.role !== "toolResult") continue;
for (const block of message.content) {
if (block.type === "text") parts.push(block.text);
}
}
return parts.join("\n");
}
/**
* Maximum number of late-arrival coalescing rounds in {@link AdvisorRuntime.#collectAndMaintainBatch}.
* After this many rounds any items still in `#pending` are left for the next drain iteration
* so a pathologically fast primary + slow `maintainContext` cannot stall dispatch indefinitely.
*/
const MAX_COALESCE_ROUNDS = 3;
/**
* Consecutive quarantined advisor turns tolerated before the failure is surfaced
* to the host UI. A quarantine discards the advisor's whole turn before dispatch,
* so its advice never reaches the primary; one silent re-prime is allowed to
* recover a one-off hallucination, but a persistent quarantine loop is a real
* supervision gap the user must see (issue #6661). Reset on any successful turn.
*/
const MAX_QUARANTINE_RETRIES = 2;
interface PendingDelta {
text: string;
rawMessages: AgentMessage[];
renderRevision: number;
turns: number;
/** Whether the primary was mid-turn (willContinue:true) when this delta was rendered. */
wip: boolean;
overflowRecovery?: boolean;
}
interface CatchupWaiter {
threshold: number;
finish: (caughtUp: boolean) => void;
timer?: NodeJS.Timeout;
}
interface DeliveredMessage {
message: AgentMessage;
fingerprint: bigint | undefined;
}
function fingerprintMessage(message: AgentMessage): bigint | undefined {
try {
// Field-selective fingerprint: hash every top-level field the advisor
// renderer actually reads (mirrors AppendOnlyContextManager.#messageDigest,
// issue #3406). Unrendered metadata (timestamp, usage, provider internals)
// churns on provider round-trips and would otherwise trigger a full
// transcript replay for a no-op change. Rendered fields (from
// session-history-format.ts): role, content, customType, display, isError,
// toolResult: cancelled/exitCode/output, custom: details, plus the
// execution/branch/compaction/file-mention fields the formatter reads:
// excludeFromContext, command (bashExecution), code (pythonExecution),
// summary + fromId (branch/compaction), files (fileMention).
const m = message as unknown as Record<string, unknown>;
const payload = JSON.stringify({
r: m.role ?? null,
c: m.content ?? null,
toolCallId: m.toolCallId ?? null,
toolName: m.toolName ?? null,
err: m.isError ?? null,
ct: m.customType ?? null,
disp: m.display ?? null,
cancel: m.cancelled ?? null,
exit: m.exitCode ?? null,
out: m.output ?? null,
det: m.details ?? null,
xfc: m.excludeFromContext ?? null,
cmd: m.command ?? null,
code: m.code ?? null,
sum: m.summary ?? null,
from: m.fromId ?? null,
files: m.files ?? null,
});
if (payload === undefined) return undefined;
return Bun.hash.wyhash(payload);
} catch {
return undefined;
}
}
export class AdvisorRuntime {
#lastCount = 0;
/**
* Delivered prefix identities. References make the normal append-only path
* allocation-free; fingerprints preserve identity across equivalent clones.
*/
#deliveredPrefix: DeliveredMessage[] = [];
/** Last-shown body, keyed by primary-context customType (plan/goal mode rules,
* approved plan). These prompts are re-injected verbatim every primary turn;
* this lets {@link #renderDelta} collapse an unchanged copy to a one-line
* marker so the advisor isn't re-fed the full ~1k-token rules each turn.
/** Cleared on every re-prime/seed and when a failed batch is dropped. */
#seenContext = new Map<string, string>();
/**
* Snapshot of {@link #seenContext} taken by #prepareBatch before the
* in-flight batch's first dedup mutation. Restored by
* {@link #rollbackFailedTurn} when the turn fails and its rawMessages are
* requeued, so first-time primary-context is re-delivered in full instead
* of collapsing to "(unchanged — still in effect)" against an advisor
* history that no longer contains it. Cleared on turn success.
*/
#seenContextInFlight: [string, string][] | undefined;
/** Incremented whenever the advisor loses context so queued raw deltas are re-rendered against fresh dedupe state. */
#renderRevision = 0;
/** Regex secret values observed in primary deltas and retained until advisor context resets. */
#advisorRegexSecretValues = new Set<string>();
#pending: PendingDelta[] = [];
#busy = false;
#sessionTransitionPaused = false;
#promptInFlight: Promise<void> | undefined;
#iterationAbort: AbortController | undefined;
#backlog = 0;
#consecutiveFailures = 0;
#failureNotified = false;
/** Consecutive quarantined turns since the last success/reset (issue #6661). */
#consecutiveQuarantines = 0;
/**
* Model identities this refusal cascade has already tried. The cascade walks
* the fallback chain to exhaustion — that is what the chain is for — but
* visits each model at most once, so a chain whose keys point back at each
* other (A→B, B→A) cannot ping-pong forever. Cleared when a successful or
* terminal turn ends the cascade, or on reset, so a later refusal starts fresh.
*/
readonly #refusalModelsTried = new Set<string>();
/** Whether primary reasoning is included in advisor deltas for the current model. */
#includeThinking = true;
#modelIdentity: string | undefined;
/** Completed 3-failure backlog-drop cycles since the last success/reset. */
#droppedBacklogs = 0;
/**
* Hard stop after repeated drop cycles or a permanent request rejection
* (e.g. "model not supported"): without it the advisor re-attempts on every
* new delta forever, and in a shared daemon that unbounded churn burns CPU
* and starves every hosted session's event loop. Cleared only by an
* explicit {@link reset} (config rebuild, /new, session restart).
*/
#halted = false;
/** True from the moment an advisor turn fails until one succeeds (or an
* explicit reset/seed). While set, {@link waitForCatchup} resolves
* immediately: the primary agent NEVER parks on a failing advisor. */
#failing = false;
#latestMessages?: AgentMessage[];
#waiters: CatchupWaiter[] = [];
/** Bumped by every external {@link reset}/{@link dispose}. A drain iteration
* captures it before its awaits; a mismatch on resume means a reset aborted
* the in-flight advisor prompt, so the stale batch is dropped instead of
* being retried/requeued into the post-reset conversation. */
#epoch = 0;
disposed = false;
/** Quota/rate-limit pause state. When `true`, the advisor stops processing
* turns and drops new deltas until an explicit {@link reset} clears it
* (triggered by `/new`, config rebuild, or session restart). There is no
* timer-based auto-resume: provider quota windows (5h/7d) are far longer
* than any reasonable timer, and premature retries waste calls and
* re-trigger the same error. */
#quotaExhausted = false;
constructor(
private readonly agent: AdvisorAgent,
private readonly host: AdvisorRuntimeHost,
private readonly retryDelayMs = 1000,
) {}
get backlog(): number {
return this.#backlog;
}
get quotaExhausted(): boolean {
return this.#quotaExhausted;
}
get failureNotified(): boolean {
return this.#failureNotified;
}
/** True after the runtime hard-stopped on repeated or permanent failures. */
get halted(): boolean {
return this.#halted;
}
/**
* Called after each primary turn ends. Renders the incremental delta and
* queues it for the advisor model.
*
* @param messages - Live primary transcript snapshot (defaults to `snapshotMessages()`).
* @param opts.willContinue - When `true` the primary is mid-turn (more tool-call
* steps will follow). The rendered heading is tagged `[in progress]` so the
* advisor knows to withhold critique on partial work. The flag is carried on
* the delta and forwarded to the reprime path so it is never silently dropped.
*/
onTurnEnd(messages?: AgentMessage[], opts?: { willContinue?: boolean }): void {
if (this.disposed || this.#quotaExhausted || this.#halted) return;
const all = messages ?? this.host.snapshotMessages();
this.#latestMessages = all;
const wip = opts?.willContinue ?? false;
let rendered: Omit<PendingDelta, "turns" | "overflowRecovery"> | null = null;
// #renderDelta advances the cursor/prefix/dedup state before formatting
// can throw; snapshot them so a formatter bug loses NOTHING — the next
// turn re-renders this delta (a prefix change mid-render self-heals via
// the fingerprint scan, at worst costing one full replay).
const cursorBefore = this.#lastCount;
const prefixBefore = this.#deliveredPrefix.slice();
const seenBefore = [...this.#seenContext];
try {
rendered = this.#renderDelta(all, wip);
} catch (err) {
// A render bug must never propagate into the primary agent's
// turn-end callback: the advisor skips this delta and stops gating
// the catch-up wait until a turn succeeds.
this.#lastCount = cursorBefore;
this.#deliveredPrefix = prefixBefore;
this.#seenContext.clear();
for (const [key, value] of seenBefore) this.#seenContext.set(key, value);
this.#failing = true;
this.#wakeAllWaiters();
logger.warn("advisor delta render failed", { err: String(err) });
}
if (rendered) {
this.#pending.push({ ...rendered, turns: 1 });
this.#backlog++;
this.#notifyWaiters();
void this.#drain();
}
}
/**
* Wait until the advisor backlog falls below `threshold`.
*
* Returns `false` when the deadline, abort signal, or a runtime failure releases
* the waiter before the requested backlog was drained.
*/
waitForCatchup(maxMs: number, threshold: number, signal?: AbortSignal): Promise<boolean> {
if (
this.disposed ||
signal?.aborted ||
this.#backlog < threshold ||
this.#quotaExhausted ||
this.#halted ||
// An advisor mid-failure/retry must NEVER gate the primary agent:
// its backlog cannot drain until the retry cycle resolves, and the
// primary would otherwise park for the full catch-up budget.
this.#failing
)
return Promise.resolve(this.#backlog < threshold);
const { promise, resolve } = Promise.withResolvers<boolean>();
let waiter!: CatchupWaiter;
const finish = (caughtUp: boolean): void => {
const idx = this.#waiters.indexOf(waiter);
if (idx >= 0) this.#waiters.splice(idx, 1);
clearTimeout(waiter.timer);
signal?.removeEventListener("abort", abort);
resolve(caughtUp);
};
const abort = (): void => finish(false);
waiter = {
threshold,
finish,
timer: setTimeout(abort, maxMs),
};
this.#waiters.push(waiter);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
}
return promise;
}
dispose(): void {
this.#iterationAbort?.abort("advisor disposed");
this.disposed = true;
this.#epoch++;
this.#pending = [];
this.#backlog = 0;
this.#consecutiveFailures = 0;
this.#failureNotified = false;
this.#advisorRegexSecretValues.clear();
this.#wakeAllWaiters();
try {
this.agent.abort("advisor disposed");
} catch {}
}
#clearSeenContext(): void {
this.#seenContext.clear();
this.#seenContextInFlight = undefined;
this.#advisorRegexSecretValues.clear();
this.#renderRevision++;
}
#clearAdvisorContextAtCurrentCursor(): void {
this.#consecutiveFailures = 0;
this.#clearSeenContext();
try {
this.agent.reset();
} catch {}
try {
this.agent.abort("advisor reset");
} catch {}
}
#resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean, reason?: string): void {
if (reason) {
logger.debug("advisor context reset", {
reason,
lastCount: this.#lastCount,
pending: this.#pending.length,
backlog: this.#backlog,
});
}
this.#lastCount = 0;
this.#deliveredPrefix = [];
this.#pending = [];
this.#clearAdvisorContextAtCurrentCursor();
if (clearBacklog) {
this.#backlog = 0;
}
if (wakeWaiters) {
this.#wakeAllWaiters();
}
}
/**
* Account one completed 3-failure backlog-drop cycle. Repeated cycles (or a
* single permanent request rejection, e.g. "model not supported") hard-stop
* the runtime: in a shared daemon, unbounded advisor churn re-builds heavy
* context on every new delta and starves every hosted session's event loop.
* Only an explicit {@link reset} (config rebuild, /new, restart) resumes.
*/
#noteDroppedBacklog(error: unknown): void {
this.#droppedBacklogs++;
if (this.#droppedBacklogs < 3 && !isPermanentAdvisorError(error)) return;
this.#halted = true;
this.#pending = [];
this.#wakeAllWaiters();
logger.warn("advisor halted after repeated failures; use /advisor or reload config to re-enable", {
droppedBacklogs: this.#droppedBacklogs,
err: String(error),
});
}
/** Stop new advisor work and wait only for the active prompt's recorder-visible events. */
pauseForSessionTransition(): Promise<void> {
if (!this.#sessionTransitionPaused) {
this.#sessionTransitionPaused = true;
this.#wakeAllWaiters();
this.#iterationAbort?.abort("advisor session transition");
try {
this.agent.abort("advisor session transition");
} catch {}
}
return (
this.#promptInFlight?.then(
() => {},
() => {},
) ?? Promise.resolve()
);
}
/** Continue queued work after a session transition rolls back or preserves the conversation. */
resumeAfterSessionTransition(): void {
if (!this.#sessionTransitionPaused) return;
this.#sessionTransitionPaused = false;
if (!this.#quotaExhausted && !this.#halted) void this.#drain();
}
/**
* Re-prime the advisor after a history rewrite (compaction, session
* switch/resume, branch). Clears the advisor's own (non-persisted) context
* and rewinds the cursor to 0 so the NEXT turn replays the full current —
* post-compaction — transcript, giving the advisor fresh context instead of
* leaving it blind to everything before the rewrite.
*/
reset(reason = "external"): void {
// Step-1 observability (issue #7226): every re-prime logs its trigger so
// live investigations can attribute full-transcript replays (cached_tokens
// pinned at the instructions/tools boundary) to a concrete path instead of
// inferring it from payload markers after the fact.
this.#iterationAbort?.abort("advisor reset");
this.#epoch++;
this.#sessionTransitionPaused = false;
this.#quotaExhausted = false;
this.#halted = false;
this.#failing = false;
this.#droppedBacklogs = 0;
this.#consecutiveQuarantines = 0;
this.#refusalModelsTried.clear();
this.#failureNotified = false;
this.#resetAdvisorContext(true, true, reason);
}
/**
* Seed the cursor to the current transcript length when the advisor is enabled
* mid-session. Prevents the next turn from replaying the entire history to the
* advisor (which would be expensive and likely stale).
*/
seedTo(count: number): void {
const messages = this.host.snapshotMessages().slice(0, count);
this.#lastCount = messages.length;
this.#deliveredPrefix = messages.map(message => ({
message,
fingerprint: fingerprintMessage(message),
}));
this.#pending = [];
this.#backlog = 0;
this.#consecutiveFailures = 0;
this.#failing = false;
this.#droppedBacklogs = 0;
this.#failureNotified = false;
this.#clearSeenContext();
this.#wakeAllWaiters();
}
#syncModelIdentity(): void {
const identity = this.host.getModelIdentity?.();
if (identity === undefined || identity === this.#modelIdentity) return;
this.#modelIdentity = identity;
this.#includeThinking = true;
}
// Candidate 4 (multi-message split): render the Session update as MULTIPLE
// user messages — one per source message — instead of one ever-growing user
// message. Provider prompt caches are prefix-based: a single user message
// whose text keeps growing invalidates the whole message on every turn, so
// cache_read stays pinned at the instructions/tools boundary (observed
// 14491 in production, 11066 in tests). Splitting into per-source user
// messages lets the provider cache each appended message (verified
// experimentally: cache_read 11066 → 11091 → 11112 vs pinned 11066).
//
/**
* Shared obfuscation side effects for BOTH render paths (single-block
* {@link #renderPreparedDelta} and multi-message
* {@link #formatRawDeltaMessageChunks}): collect regex secret values from
* primary-context custom messages and the rendered markdown, scrub the
* advisor's own history, and refresh pending placeholder prefixes when new
* secrets appear. Returns whether new secret values were discovered.
* Idempotent across the two calls one drain makes for the same prepared
* list: the second call discovers nothing new and skips the strip.
*/
#collectAdvisorSecrets(obfuscator: SecretObfuscator, delta: AgentMessage[], renderedMd: string): boolean {
let discoveredNewRegexSecretValue = false;
const addRegexValues = (text: string): void => {
for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(text) ?? []) {
if (this.#advisorRegexSecretValues.has(secretValue)) continue;
this.#advisorRegexSecretValues.add(secretValue);
discoveredNewRegexSecretValue = true;
}
};
for (const message of delta) {
if (
message.role === "custom" &&
PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) &&
typeof message.content === "string"
) {
addRegexValues(message.content);
}
}
addRegexValues(renderedMd);
scrubAdvisorHistory(obfuscator, this.agent.state.messages, this.#advisorRegexSecretValues);
if (discoveredNewRegexSecretValue) {
this.#pending = this.#pending.map(delta => ({
...delta,
text: obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(delta.text, this.#advisorRegexSecretValues),
}));
}
return discoveredNewRegexSecretValue;
}
/**
* Map primary-context custom messages through the obfuscator. Shared by
* both render paths so the byte-equivalence contract lives in one place.
*/
#obfuscatePrimaryContextMessages(obfuscator: SecretObfuscator, delta: AgentMessage[]): AgentMessage[] {
return delta.map(message =>
message.role === "custom" && PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType)
? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues)
: message,
);
}
// Each source message is rendered INDEPENDENTLY via
// formatSessionHistoryMarkdown in chunked mode (shared toolResultIndex +
// consumedToolCallIds over the WHOLE delta), so a toolCall finds its
// toolResult across chunk boundaries and consecutive same-role collapsing
// is preserved. Concatenating the chunk texts with the same separator the
// old single-block render used yields byte-identical advisor context.
// Each chunk is delivered as its own user AgentMessage via a SINGLE
// Agent.prompt(AgentMessage[]) call, so the advisor model still runs ONCE
// per update (no per-message assistant turns).
#formatRawDeltaMessageChunks(preparedMessages: AgentMessage[], wip = false): AgentMessage[] | null {
// Consumes the ALREADY-prepared view from #prepareBatch: advisor custom
// messages are filtered and primary-context dedup is applied there, so
// splitting here never double-folds or leaks hidden messages.
const delta = preparedMessages;
if (delta.length === 0) return null;
const obfuscator = this.host.obfuscator;
// Side effects the pure renderer cannot own: collect secrets, scrub the
// advisor's own history and refresh pending placeholder prefixes (shared
// helper — see #collectAdvisorSecrets; idempotent for this drain's
// single-block pass over the same prepared list).
const probeMd = formatSessionHistoryMarkdown(delta, {
...ADVISOR_RENDER_OPTIONS,
includeThinking: this.#includeThinking,
});
if (obfuscator?.hasSecrets()) {
this.#collectAdvisorSecrets(obfuscator, delta, probeMd);
}
// Message-level obfuscation mirrors the old #formatRawDelta path EXACTLY:
// only primary-context custom messages are mapped (tool args, details.diff,
// structured fields), because the old path's contract is whole-delta text
// obfuscation as the final pass. Expanding to every role would mint
// different placeholders and break byte-equivalence with the old render.
const renderDelta = obfuscator?.hasSecrets() ? this.#obfuscatePrimaryContextMessages(obfuscator, delta) : delta;
const chunks = renderAdvisorDeltaChunks(renderDelta, {
wip,
includeThinking: this.#includeThinking,
obfuscator: obfuscator?.hasSecrets() ? obfuscator : undefined,
advisorRegexSecretValues: this.#advisorRegexSecretValues,
});
return chunks;
}
#formatRawDelta(rawMessages: AgentMessage[], wip = false, updateSeenContext = true): string | null {
const delta = rawMessages
.filter(message => !(message.role === "custom" && message.customType === "advisor"))
.map(message =>
updateSeenContext ? this.#dedupContextMessage(message) : this.#dedupContextMessageReadOnly(message),
);
return this.#renderPreparedDelta(delta, wip);
}
/**
* Preview variant of #dedupContextMessage: returns the collapse decision
* WITHOUT advancing the live #seenContext map. Used by #renderDelta so the
* preview text does not make the batch's first real delivery look like a
* re-injection.
*/
#dedupContextMessageReadOnly(msg: AgentMessage): AgentMessage {
if (msg.role !== "custom") return msg;
if (!PRIMARY_CONTEXT_CUSTOM_TYPES.has(msg.customType)) return msg;
if (typeof msg.content !== "string") return msg;
if (this.#seenContext.get(msg.customType) === msg.content) {
return { ...msg, content: "(unchanged — still in effect)" };
}
return msg;
}
/**
* Render already-prepared (deduped + advisor-filtered) messages to the
* single-block Session update text. Does NOT dedup again — callers that
* prepared the list must pass it here directly, and callers that prepared
* via #prepareBatch get byte-identical batch text to what the multi-message
* split consumes.
*/
#renderPreparedDelta(preparedMessages: AgentMessage[], wip = false): string | null {
const delta = preparedMessages;
if (delta.length === 0) return null;
const obfuscator = this.host.obfuscator;
let md = formatSessionHistoryMarkdown(delta, {
...ADVISOR_RENDER_OPTIONS,
includeThinking: this.#includeThinking,
});
if (!md.trim()) return null;
if (obfuscator?.hasSecrets()) {
this.#collectAdvisorSecrets(obfuscator, delta, md);
md = formatSessionHistoryMarkdown(this.#obfuscatePrimaryContextMessages(obfuscator, delta), {
...ADVISOR_RENDER_OPTIONS,
includeThinking: this.#includeThinking,
});
md = obfuscator.obfuscate(md, this.#advisorRegexSecretValues);
}
// Candidate 3: keep the heading byte-identical between wip and final turns
// and put the WIP marker at the END of the batch, so a wip/final flip
// never changes the batch prefix. The provider prompt cache is
// prefix-based; a heading that flips between turns re-prefills the whole
// user message on every in-progress turn.
const heading = "### Session update";
const mdHead = `${heading}\n\n${md}`;
if (!wip) return mdHead;
return `${mdHead}\n\n---\n\n[in progress — more steps follow]`;
}
#renderDelta(messages?: AgentMessage[], wip = false): Omit<PendingDelta, "turns" | "overflowRecovery"> | null {
const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
let prefixChanged = all.length < this.#lastCount;
for (let i = 0; !prefixChanged && i < this.#lastCount; i++) {
const delivered = this.#deliveredPrefix[i];
const current = all[i];
if (delivered === undefined || current === undefined) {
prefixChanged = true;
break;
}
if (delivered.message === current) continue;
const fingerprint = fingerprintMessage(current);
if (
delivered.fingerprint === undefined ||
fingerprint === undefined ||
delivered.fingerprint !== fingerprint
) {
prefixChanged = true;
// Full replays are expensive (the whole transcript is re-sent and
// the provider prompt cache re-prefills from the system prompt), so
// record exactly which delivered message diverged and which
// top-level fields changed — without this the trigger is invisible.
try {
const oldMsg: Record<string, unknown> = delivered.message as unknown as Record<string, unknown>;
const newMsg: Record<string, unknown> = current as unknown as Record<string, unknown>;
const differingFields: string[] = [];
for (const key of new Set([...Object.keys(oldMsg), ...Object.keys(newMsg)])) {
if (JSON.stringify(oldMsg[key]) !== JSON.stringify(newMsg[key])) differingFields.push(key);
}
logger.debug("advisor delivered prefix changed", { index: i, role: newMsg.role, differingFields });
} catch {}
break;
}
delivered.message = current;
}
if (prefixChanged) {
this.#epoch++;
logger.debug("advisor context reset", { reason: "delivered-prefix-changed", lastCount: this.#lastCount });
this.#resetAdvisorContext(true, true);
}
const rawMessages = all.slice(this.#lastCount);
for (let i = this.#lastCount; i < all.length; i++) {
const message = all[i];
if (message === undefined) continue;
this.#deliveredPrefix.push({ message, fingerprint: fingerprintMessage(message) });
}
this.#lastCount = all.length;
// Preview render: do NOT advance #seenContext — the batch's real dedup
// happens once in #prepareBatch. Advancing here would make the first
// real delivery of a re-injected primary-context message collapse to
// "(unchanged…)" (double-fold).
const text = this.#formatRawDelta(rawMessages, wip, false);
return text ? { text, rawMessages, renderRevision: this.#renderRevision, wip } : null;
}
/**
* Collapse a re-injected primary-context prompt (plan/goal mode rules, the
* approved plan) to a short marker when its body is byte-identical to the
* copy already shown to the advisor since the last re-prime. The primary
* re-injects these verbatim every turn; without this the advisor re-reads the
* full rules (~1k tokens) each turn. Returns a CLONE when collapsing — the
* input shares the live primary transcript and must never be mutated.
*/
#dedupContextMessage(msg: AgentMessage): AgentMessage {
if (msg.role !== "custom") return msg;
// Narrowed to CustomMessage: customType and content are properly typed.
if (!PRIMARY_CONTEXT_CUSTOM_TYPES.has(msg.customType)) return msg;
if (typeof msg.content !== "string") return msg;
if (this.#seenContext.get(msg.customType) === msg.content) {
return { ...msg, content: "(unchanged — still in effect)" };
}
this.#seenContext.set(msg.customType, msg.content);
return msg;
}
#notifyWaiters(): void {
for (let i = this.#waiters.length - 1; i >= 0; i--) {
const w = this.#waiters[i];
if (this.#backlog < w.threshold) {
w.finish(true);
}
}
}
#wakeAllWaiters(): void {
for (const w of [...this.#waiters]) {
w.finish(false);
}
}
/**
* Drop the user batch + synthetic assistant-error turn `Agent.#runLoop`
* appended for a failed prompt so a retry replays a clean baseline and the
* dropped-after-3 path never leaks orphan failures into the next successful
* run. Prefers the agent's own `rollbackTo` (which also re-syncs its
* append-only context); falls back to truncating `state.messages` for tests
* that hand-roll a minimal facade.
*/
#restoreSeenContextInFlight(): void {
if (!this.#seenContextInFlight) return;
this.#seenContext.clear();
for (const [key, value] of this.#seenContextInFlight) this.#seenContext.set(key, value);
this.#seenContextInFlight = undefined;
}
#rollbackFailedTurn(snapshot: number): void {
// Restore the primary-context dedup map to its pre-batch state: the
// failed turn never reached the advisor, so first-time context collapsed
// to "(unchanged…)" by this batch's #prepareBatch must expand again on
// the retry/requeue pass.
this.#restoreSeenContextInFlight();
const messages = this.agent.state.messages;
if (messages.length <= snapshot) return;
try {
if (this.agent.rollbackTo) {
this.agent.rollbackTo(snapshot);
return;
}
messages.length = snapshot;
} catch (err) {
logger.debug("advisor rollback failed", { err: String(err) });
}
}
/**
* Collect the popped deltas into one batch, running `maintainContext` for
* correct token budgeting. Loops until the pending queue is stable (no new
* deltas arrived during a maintenance check) or the round cap is reached.
* Every `await` inside the loop has an epoch guard so a reset/dispose
* mid-await cannot leak a stale batch into the post-reset conversation.
*
* When maintenance requests recovery, only the advisor Agent/log is reset
* (at the current primary cursor) and the already-collected raw batch is
* re-rendered — older, already-delivered primary transcript is never
* replayed.
*
* The coalescing loop is capped at {@link MAX_COALESCE_ROUNDS} iterations so
* a pathologically fast primary combined with a slow `maintainContext` cannot
* stall dispatch indefinitely — any items still in `#pending` after the cap
* are left for the next drain iteration. Overflow-recovery batches skip
* coalescing entirely: they retry exactly the bounded batch that overflowed.
*
* Returns `null` when the epoch was invalidated — caller should `continue`.
*/
async #collectAndMaintainBatch(
epoch: number,
initial: PendingDelta[],
recoveringOverflow: boolean,
signal: AbortSignal,
): Promise<{
batch: string | null;
rawMessages: AgentMessage[];
preparedMessages: AgentMessage[];
finalTurns: number;
wip: boolean;
resetContext: boolean;
} | null> {
let batchText = initial.map(b => b.text).join("\n\n");
let rawMessages = initial.flatMap(b => b.rawMessages);
let turns = initial.reduce((sum, b) => sum + b.turns, 0);
// Track WIP state of the most recent delta — forwarded to the re-render
// so a willContinue:true turn keeps its [in progress] heading. Also
// returned to #drain so the retry-requeue path preserves it on failed turns.
let wip = initial.at(-1)?.wip ?? false;
for (let round = 0; round < MAX_COALESCE_ROUNDS; round++) {
if (this.#sessionTransitionPaused) break;
if (this.host.maintainContext) {
let shouldResetContext = false;
try {
shouldResetContext = await this.host.maintainContext(
{ role: "user", content: batchText, timestamp: Date.now() },
signal,
);
} catch (err) {
logger.debug("advisor context maintenance failed", { err: String(err) });
}
// Epoch guard — a reset/dispose during the maintainContext await
// invalidates this batch.
if (this.#epoch !== epoch) return null;
if (shouldResetContext) {
// Once coalescing has begun (round > 0), deltas that arrived during
// this await are part of the coalescing window: tally them so
// finalTurns stays accurate for backlog accounting and their raw
// messages join the bounded re-render. On the initial round the
// popped batch stays bounded exactly as dispatched — later arrivals
// remain queued and ship as their own subsequent batch.
if (round > 0) {
const lateItems = this.#pending.splice(0);
initial.push(...lateItems);
turns += lateItems.reduce((sum, b) => sum + b.turns, 0);
if (lateItems.length > 0) {
wip = lateItems.at(-1)!.wip;
rawMessages = rawMessages.concat(lateItems.flatMap(b => b.rawMessages));
}
}
// Reset only the advisor Agent/log. The primary cursor, backlog,
// waiters, latest snapshot, and epoch stay untouched. Re-render only
// this already-popped raw batch so active plan/reference bodies are
// restored without replaying any older primary transcript.
logger.debug("advisor context reset", {
reason: "context-maintenance",
lastCount: this.#lastCount,
pending: this.#pending.length,
backlog: this.#backlog,
});
this.#clearAdvisorContextAtCurrentCursor();
const { batch: rerendered, preparedMessages } = this.#prepareBatch(rawMessages, wip, batchText);
return {
batch: rerendered ?? (batchText || null),
rawMessages,
preparedMessages,
finalTurns: turns,
wip,
resetContext: true,
};
}
}
// Overflow-recovery batches retry exactly the bounded batch that
// overflowed; pending updates stay queued behind them.
if (recoveringOverflow) break;
// On the final round stop here — any late arrivals would ship without
// a subsequent maintainContext budget check. Leave them in #pending for
// the next drain iteration where they will be properly budgeted.
if (round === MAX_COALESCE_ROUNDS - 1) break;
// Coalesce any deltas that arrived while we were awaiting maintenance.
// If none arrived the batch is stable and we're done; otherwise merge,
// update WIP state, and re-check the maintenance budget.
const late = this.#pending.splice(0);
if (late.length === 0) break;
initial.push(...late);
batchText = [batchText, ...late.map(b => b.text)].join("\n\n");
rawMessages = rawMessages.concat(late.flatMap(b => b.rawMessages));
turns += late.reduce((sum, b) => sum + b.turns, 0);
wip = late.at(-1)!.wip;
}
// Prepare the deduped view AFTER coalescing (rawMessages is complete by
// now): filters advisor custom messages and collapses re-injected
// primary-context to "(unchanged…)". BOTH the single-block text and the
// multi-message split derive from this exact list so they never diverge.
const { batch: preparedBatch, preparedMessages } = this.#prepareBatch(rawMessages, wip, batchText);
return {
batch: preparedBatch ?? (batchText || null),
rawMessages,
preparedMessages,
finalTurns: turns,
wip,
resetContext: false,
};
}
/**
* Single dedup+render pass shared by every batch finalization path (normal
* and context-reset). Filters advisor custom messages, collapses re-injected
* primary-context to "(unchanged…)" via #dedupContextMessage, renders the
* single-block batch text from the SAME prepared list the multi-message
* split consumes, so the two views can never diverge.
*/
#prepareBatch(
rawMessages: AgentMessage[],
wip: boolean,
fallback: string | null,
): { batch: string | null; preparedMessages: AgentMessage[] } {
// Dedup against the LIVE #seenContext (populated by previous turns via
// #renderDelta -> #formatRawDelta) so re-injected primary context that
// was ALREADY shown collapses to "(unchanged…)", while a FIRST delivery
// in this batch stays expanded. This pass advances the live map exactly
// once per batch — #renderDelta's text is a preview and must not set it.
// Snapshot the dedup map BEFORE this batch's first mutation so a failed
// turn can restore it (see #rollbackFailedTurn). `??=` keeps the first
// snapshot across coalescing re-prepares within one in-flight batch.
this.#seenContextInFlight ??= [...this.#seenContext];
const preparedMessages = rawMessages
.filter(message => !(message.role === "custom" && message.customType === "advisor"))
.map(message => this.#dedupContextMessage(message));
const batch = this.#renderPreparedDelta(preparedMessages, wip);
return { batch: batch ?? fallback, preparedMessages };
}
#terminalAssistantFailure(snapshot: number): AssistantMessage | undefined {
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= snapshot; i--) {
const message = messages[i];
if (message.role === "assistant" && message.stopReason === "error") return message;
}
return undefined;
}
#notifyFailureOnce(error: unknown): void {
if (this.#failureNotified) return;
this.#failureNotified = true;
try {
this.host.notifyFailure?.(error);
} catch (notifyErr) {
logger.warn("advisor failure notification failed", { err: String(notifyErr) });
}
}
async #drain(): Promise<void> {
if (this.#busy || this.#sessionTransitionPaused) return;
this.#busy = true;
try {
this.#syncModelIdentity();
while (!this.disposed && !this.#sessionTransitionPaused && this.#pending.length) {
this.#syncModelIdentity();
let popped: PendingDelta[];
if (this.#pending[0]?.overflowRecovery) {
const recovery = this.#pending.shift();
if (!recovery) continue;
popped = [recovery];
} else {
popped = this.#pending.splice(0);
}
const iterationAbort = new AbortController();
this.#iterationAbort = iterationAbort;
const epoch = this.#epoch;
for (const delta of popped) {
if (delta.renderRevision === this.#renderRevision) continue;
// Context maintenance estimates this preview before #prepareBatch makes
// its final deduped render. Rebuild stale text against the new context
// so the maintenance budget cannot undercount an expanded re-injection.
delta.text = this.#formatRawDelta(delta.rawMessages, delta.wip, false) ?? delta.text;
delta.renderRevision = this.#renderRevision;
}
const recoveringOverflow = popped.some(delta => delta.overflowRecovery === true);
const result = await this.#collectAndMaintainBatch(
epoch,
popped,
recoveringOverflow,
iterationAbort.signal,
);
// Epoch was invalidated during batch collection; restart the loop.
if (result === null) continue;
if (this.#sessionTransitionPaused) {
this.#restoreSeenContextInFlight();
this.#pending.unshift(...popped);
continue;
}
const { batch, rawMessages, preparedMessages, finalTurns, wip, resetContext } = result;
if (this.disposed || batch === null) {
this.#backlog = Math.max(0, this.#backlog - finalTurns);
this.#notifyWaiters();
continue;
}
let success = false;
// Capture the advisor's message count BEFORE the prompt so a failure can
// roll back the user batch + synthetic assistant-error turn Agent.#runLoop
// appends to internal state. Without this, a retry would replay the failed
// batch on top of stale turns and the dropped-after-3 path would leak
// orphan failures into the next successful run's context.
const messageSnapshot = this.agent.state.messages.length;
const contextWasFresh = resetContext || recoveringOverflow || messageSnapshot === 0;
try {
this.host.beginAdvisorUpdate?.(wip);
// Candidate 4 (multi-message split): deliver the Session update as
// multiple user messages so the provider prompt cache can
// incrementally hit each appended message (cache_read grows with
// the session instead of staying pinned at the instructions/tools
// boundary). Falls back to the single-block string when the chunk
// renderer cannot split (e.g. empty delta). The split is
// byte-equivalent to the old single-block render (equivalence
// tested), so the advisor sees identical context.
const splitMessages = this.#formatRawDeltaMessageChunks(preparedMessages, wip);
const promptInput: string | AgentMessage[] = splitMessages ?? batch;
const prompt = this.agent.prompt(promptInput);
this.#promptInFlight = prompt;
try {
await prompt;
} finally {
if (this.#promptInFlight === prompt) this.#promptInFlight = undefined;
}
// Agent.#runLoop catches provider/stream failures internally and
// resolves prompt() cleanly with stopReason: "error". Treat that
// as a failed turn so endpoint rejections trip the retry path.
const promptError = this.agent.state.error;
if (promptError) throw new Error(promptError);
// A content-less stop is a deliberate silent review — the documented
// verifier behavior ("prefer silence when the agent is on track") — and
// completes the turn. Sessions can legitimately have nothing to advise
// on for any number of consecutive turns, so silence is never warned
// about (#5216 did, spamming "Advisor unavailable" at quiet models).
const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
if (turnError) throw turnError;
success = true;
this.#seenContextInFlight = undefined;
this.#failing = false;
this.#consecutiveFailures = 0;
this.#failureNotified = false;
this.#droppedBacklogs = 0;
this.#consecutiveQuarantines = 0;
this.#refusalModelsTried.clear();
if (this.host.onTurnSuccess) {
try {
await raceWithSignal(Promise.resolve(this.host.onTurnSuccess()), iterationAbort.signal);
} catch (hookErr) {
logger.debug("advisor onTurnSuccess hook failed", { err: String(hookErr) });
}
}
} catch (err) {
if (this.#sessionTransitionPaused) {
this.#rollbackFailedTurn(messageSnapshot);
this.#pending.unshift(...popped);
continue;
}
// reset()/dispose() aborts the in-flight prompt; treat it as a
// reset, not a transient failure — drop the stale batch.
if (this.#epoch !== epoch) continue;
// Release any parked primary-agent waiters IMMEDIATELY — before
// the async onTurnError hook or any retry sleep — and refuse new
// parks until a turn succeeds. A failing advisor must never hold
// the primary on the catch-up gate.
this.#failing = true;
this.#wakeAllWaiters();
const failedMessages = this.agent.state.messages.slice(messageSnapshot);
const terminalFailure = this.#terminalAssistantFailure(messageSnapshot);
const rawErrorId = AIError.classify(err);
const terminalFailureId =
terminalFailure === undefined ? undefined : AIError.classifyMessage(terminalFailure);
const classifierRefusal =
(terminalFailure !== undefined && isClassifierRefusal(terminalFailure)) ||
(!AIError.is(rawErrorId, AIError.Flag.AccountPolicy) &&
AIError.is(rawErrorId, AIError.Flag.ContentBlocked));
const contextOverflow =
(terminalFailureId !== undefined && AIError.is(terminalFailureId, AIError.Flag.ContextOverflow)) ||
AIError.is(rawErrorId, AIError.Flag.ContextOverflow);
// A terminal provider failure that is neither retriable nor an
// overflow (e.g. a blocked prompt) will fail identically on every
// retry — classify it before rollback so the batch is dropped after
// one attempt instead of burning the 3-attempt budget (#5468).
const terminalFailureRetriable =
terminalFailureId === undefined ||
AIError.retriable(terminalFailureId) ||
AIError.is(terminalFailureId, AIError.Flag.ContextOverflow);
this.#rollbackFailedTurn(messageSnapshot);
logger.debug("advisor turn failed", { err: String(err) });
if (classifierRefusal) {
if (this.#includeThinking) {
this.#includeThinking = false;
// Do NOT advance #seenContext here: the requeued batch is
// re-deduped by #prepareBatch on the next drain, so a mutation
// now would double-fold first-time primary context into
// "(unchanged — still in effect)" on the retry.
const strippedBatch = this.#formatRawDelta(rawMessages, wip, false);
if (strippedBatch) {
this.#pending.unshift({
text: strippedBatch,
rawMessages,
renderRevision: this.#renderRevision,
turns: finalTurns,
wip,
overflowRecovery: recoveringOverflow || undefined,
});
logger.debug("advisor refusal recovered by stripping primary reasoning");
continue;
}
}
// A refusal that outlives the strip is this model's policy call, not
// a malformed request, so hand it to the host's model fallback before
// declaring the advisor dead. The cascade walks the chain to
// exhaustion (the host returns false once candidates run out); the
// tried-set only stops a cyclic chain from revisiting a model. The
// primary turn-recovery path already allows fallback on refusals.
const refusalModel = this.host.getModelIdentity?.() ?? this.#modelIdentity ?? "";
let refusalRecovered = false;
try {
if (!this.#refusalModelsTried.has(refusalModel)) {
this.#refusalModelsTried.add(refusalModel);
refusalRecovered =
(await raceWithSignal(
Promise.resolve(this.host.onTurnError?.(err, failedMessages, iterationAbort.signal)),
iterationAbort.signal,
)) === true;
} else {
logger.debug("advisor refusal chain exhausted", { model: refusalModel });
}
} catch (hookErr) {
logger.debug("advisor onTurnError hook failed after refusal", { err: String(hookErr) });
}
if (this.#epoch !== epoch) continue;
if (this.#sessionTransitionPaused) {
this.#pending.unshift(...popped);
continue;
}
if (refusalRecovered) {
this.#consecutiveFailures = 0;
this.#failureNotified = false;
this.#pending.unshift({
text: batch,
rawMessages,
renderRevision: this.#renderRevision,
turns: finalTurns,
wip,
overflowRecovery: recoveringOverflow || undefined,
});
logger.debug("advisor refusal recovered by model fallback");
continue;
}
// The batch is terminal, so the next primary update is a new
// refusal cascade and must be allowed to try the chain again.
this.#refusalModelsTried.clear();
this.#notifyFailureOnce(err);
this.#clearSeenContext();
this.#backlog = Math.max(0, this.#backlog - finalTurns);
this.#notifyWaiters();
continue;
}
let recovered = false;
try {
recovered =
(await raceWithSignal(
Promise.resolve(this.host.onTurnError?.(err, failedMessages, iterationAbort.signal)),
iterationAbort.signal,
)) === true;
} catch (hookErr) {
logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
}
if (this.#sessionTransitionPaused) {
this.#pending.unshift(...popped);
continue;
}
if (err instanceof AdvisorOutputQuarantinedError) {
// A quarantine discards the advisor's whole turn before dispatch, so
// its advice never reaches the primary. One re-prime is allowed to
// recover a one-off hallucination silently; a persistent quarantine
// loop is a supervision gap the user must see in the main UI — not an
// unbounded silent retry. Surface it (deduped by #notifyFailureOnce)
// and drop the batch to break the loop (issue #6661).
this.#consecutiveQuarantines++;
if (this.#consecutiveQuarantines >= MAX_QUARANTINE_RETRIES) {
this.#notifyFailureOnce(err);
this.#consecutiveQuarantines = 0;
this.#resetAdvisorContext(true, true, "quarantine-retry-exhausted");
continue;
}
const rePrime = this.#pending.length > 0 ? this.#latestMessages : undefined;
// Wake catchup waiters only when nothing is re-primed; otherwise the
// re-primed turn restores the backlog and waiters resolve on its completion.
this.#resetAdvisorContext(true, !rePrime, "quarantine-recovery");
if (rePrime) this.onTurnEnd(rePrime);
continue;
}
// Epoch guard after the async error hook.
if (this.#epoch !== epoch) continue;
if (recovered) {
this.#consecutiveFailures = 0;
this.#failureNotified = false;
this.#pending.unshift({
text: batch,
rawMessages,
renderRevision: this.#renderRevision,
turns: finalTurns,
wip,
overflowRecovery: recoveringOverflow || undefined,
});
continue;
}
if (AIError.isUsageLimit(err)) {
// Host recovery (credential switch / fallback chain) declined:
// pause on the quota latch instead of burning retries — provider
// quota windows (5h/7d) outlast any retry budget. The batch is
// requeued and the backlog stays visible so reset() replays it.
logger.warn("advisor quota exhausted", { err: String(err) });
this.#quotaExhausted = true;
this.#consecutiveFailures = 0;
this.#failureNotified = false;
this.#clearSeenContext();
this.#pending.unshift({
text: batch,
rawMessages,
renderRevision: this.#renderRevision,
turns: finalTurns,
wip,
overflowRecovery: recoveringOverflow || undefined,
});
this.#wakeAllWaiters();
try {
this.host.notifyQuotaExhausted?.();
} catch (notifyErr) {
logger.warn("advisor quota notification failed", { err: String(notifyErr) });
}
break;
}
if (!terminalFailureRetriable) {
logger.warn("advisor terminal failure is non-retriable; dropping bounded batch");
this.#notifyFailureOnce(err);
this.#consecutiveFailures = 0;
// The dropped batch may carry primary-context we never delivered; drop
// the seen-state too so queued raw deltas re-expand before delivery.
this.#clearSeenContext();
this.#noteDroppedBacklog(err);
success = true;
} else if (contextOverflow) {
this.#clearAdvisorContextAtCurrentCursor();
if (contextWasFresh) {
// The bounded update cannot fit even with no advisor history. Drop
// only this batch after its one fresh-context retry; pending and later
// deltas remain eligible so one oversized update cannot disable the advisor.
logger.warn("advisor update overflowed a fresh context; dropping bounded batch");
this.#notifyFailureOnce(err);
success = true;
} else {
// Retry once against the fresh advisor context, using only the same
// bounded raw batch. Pending updates remain queued behind it.
// Same double-fold guard as the refusal branch: #prepareBatch
// re-dedups on retry, so this preview render must not mutate
// #seenContext.
const recoveryBatch = this.#formatRawDelta(rawMessages, wip, false) ?? batch;
this.#pending.unshift({
text: recoveryBatch,
rawMessages,
renderRevision: this.#renderRevision,
turns: finalTurns,
wip,
overflowRecovery: true,
});
logger.debug("advisor context overflow recovered at current primary cursor");
}
} else {
this.#consecutiveFailures++;
if (this.#consecutiveFailures >= 3) {
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
this.#notifyFailureOnce(err);
this.#consecutiveFailures = 0;
// The dropped batch may carry primary-context we never delivered; drop
// the seen-state too so queued raw deltas re-expand before delivery.
this.#clearSeenContext();
this.#noteDroppedBacklog(err);
success = true;
} else {
this.#pending.unshift({
text: batch,
rawMessages,
renderRevision: this.#renderRevision,
turns: finalTurns,
wip,
overflowRecovery: recoveringOverflow || undefined,
});
if (this.retryDelayMs <= 0) {
await Bun.sleep(0);
} else {
try {
await raceWithSignal(Bun.sleep(this.retryDelayMs), iterationAbort.signal);
} catch (sleepError) {
if (!iterationAbort.signal.aborted) throw sleepError;
}
}
}
}
}
if (success && this.#epoch === epoch) {
this.#backlog = Math.max(0, this.#backlog - finalTurns);
this.#notifyWaiters();
}
}
} finally {
this.#iterationAbort = undefined;
this.#busy = false;
}
}
}
/** Mirrors turn recovery's refusal classification without treating account eligibility as a model refusal. */
function isClassifierRefusal(message: AssistantMessage): boolean {
if (message.stopReason !== "error") return false;
const id = AIError.classifyMessage(message);
if (AIError.is(id, AIError.Flag.AccountPolicy)) return false;
const stopType = message.stopDetails?.type;
if (stopType === "refusal" || stopType === "sensitive") return true;
return AIError.is(id, AIError.Flag.ContentBlocked);
}
/**
* The only malformed advisor turn shape: the prompt resolved but produced no
* assistant response at all. Everything an assistant message carries — advice,
* reasoning, or deliberate silence (empty `stop`) — is a completed review.
*/
function getAdvisorTurnError(messages: readonly AgentMessage[]): Error | undefined {
if (messages.length === 0) return undefined;
if (messages.some(message => message.role === "assistant")) return undefined;
return new Error("Advisor turn ended without an assistant response");
}
type TextualContent = string | readonly (TextContent | ImageContent)[];
function obfuscateTextualContent(
obfuscator: SecretObfuscator,
content: TextualContent,
sharedRegexSecretValues: ReadonlySet<string>,
): TextualContent {
if (typeof content === "string") return obfuscator.obfuscate(content, sharedRegexSecretValues);
let changed = false;
const result = content.map((block): TextContent | ImageContent => {
if (block.type !== "text") return block;
const text = obfuscator.obfuscate(block.text, sharedRegexSecretValues);
if (text === block.text) return block;
changed = true;
return { ...block, text };
});
return changed ? result : content;
}
function firstAdvisorToolResultErrorLine(content: TextualContent): string | undefined {
if (typeof content === "string") return content.split("\n", 1)[0];
const first = content[0];
if (first?.type !== "text") return undefined;
return first.text.split("\n", 1)[0];
}
function obfuscateAdvisorToolResultErrorContent(
obfuscator: SecretObfuscator,
content: TextualContent,
sharedRegexSecretValues: ReadonlySet<string>,
): TextualContent {
const firstLine = firstAdvisorToolResultErrorLine(content);
if (firstLine === undefined) return content;
const preview = formatToolResultErrorPreview(content);
const obfuscatedPreview = obfuscator.obfuscate(preview, sharedRegexSecretValues);
if (obfuscatedPreview === firstLine) return content;
if (typeof content === "string") return obfuscatedPreview + content.slice(firstLine.length);
const first = content[0]!;
if (first.type !== "text") return content;
return [{ ...first, text: obfuscatedPreview + first.text.slice(firstLine.length) }, ...content.slice(1)];
}
function obfuscateAssistantMessage(
obfuscator: SecretObfuscator,
message: AssistantMessage,
sharedRegexSecretValues: ReadonlySet<string>,
): AssistantMessage {
let changed = false;
const content = message.content.map((block): AssistantMessage["content"][number] => {
if (block.type === "text") {
const text = obfuscator.obfuscate(block.text, sharedRegexSecretValues);
if (text === block.text) return block;
changed = true;
return { ...block, text };
}
if (block.type === "thinking") {
const thinking = obfuscator.obfuscate(block.thinking, sharedRegexSecretValues);
if (thinking === block.thinking) return block;
changed = true;
return { ...block, thinking, thinkingSignature: undefined };
}
if (block.type === "toolCall") {
const args = obfuscateToolArguments(obfuscator, block.arguments, sharedRegexSecretValues);
if (args === block.arguments) return block;
changed = true;
return { ...block, arguments: args };
}
return block;
});
return changed ? { ...message, content } : message;
}
function obfuscateDetails(
obfuscator: SecretObfuscator,
details: Record<string, unknown> | undefined,
sharedRegexSecretValues: ReadonlySet<string>,
): Record<string, unknown> | undefined {
if (!details) return details;
// Walk strings at every depth: `customOneLiner` renders nested fields
// (e.g. `async-result` reads `details.jobs[].label`/`jobId`), so a shallow
// pass leaks any secret a background job's label happens to contain.
return obfuscateToolArguments(obfuscator, details, sharedRegexSecretValues);
}
function obfuscateAdvisorMessage(
obfuscator: SecretObfuscator,
message: AgentMessage,
sharedRegexSecretValues: ReadonlySet<string>,
): AgentMessage {
switch (message.role) {
case "user":
case "developer": {
const content = obfuscateTextualContent(
obfuscator,
message.content as TextualContent,
sharedRegexSecretValues,
);
return content === message.content ? message : ({ ...(message as object), content } as AgentMessage);
}
case "toolResult": {
const msg = message as AgentMessage & {
content: TextualContent;
details?: Record<string, unknown>;
isError?: boolean;
};
const content = msg.isError
? obfuscateAdvisorToolResultErrorContent(obfuscator, msg.content, sharedRegexSecretValues)
: msg.content;
let details = msg.details;
if (typeof details?.diff === "string") {
const diff = obfuscator.obfuscate(details.diff, sharedRegexSecretValues);
if (diff !== details.diff) details = { ...details, diff };
}
if (content === msg.content && details === msg.details) return message;
return { ...(message as object), content, details } as AgentMessage;
}
case "assistant":
return obfuscateAssistantMessage(
obfuscator,
message as AssistantMessage,
sharedRegexSecretValues,
) as AgentMessage;
case "custom":
case "hookMessage": {
if (!formatSessionHistoryMarkdown([message], { expandPrimaryContext: true }).trim()) return message;
const msg = message as AgentMessage & {
content: TextualContent;
details?: Record<string, unknown>;
};
const content = obfuscateTextualContent(obfuscator, msg.content, sharedRegexSecretValues);
const details = obfuscateDetails(obfuscator, msg.details, sharedRegexSecretValues);
if (content === msg.content && details === msg.details) return message;
return { ...(message as object), content, details } as AgentMessage;
}
case "bashExecution": {
const msg = message as AgentMessage & { command: string };
const command = obfuscator.obfuscate(formatExecutionSourcePreview(msg.command), sharedRegexSecretValues);
return command === msg.command ? message : ({ ...(message as object), command } as AgentMessage);
}
case "pythonExecution": {
const msg = message as AgentMessage & { code: string };
const code = obfuscator.obfuscate(formatExecutionSourcePreview(msg.code), sharedRegexSecretValues);
return code === msg.code ? message : ({ ...(message as object), code } as AgentMessage);
}
case "branchSummary": {
const msg = message as AgentMessage & { summary: string };
const summary = obfuscator.obfuscate(msg.summary, sharedRegexSecretValues);
return summary === msg.summary ? message : ({ ...(message as object), summary } as AgentMessage);
}
case "compactionSummary": {
const msg = message as AgentMessage & { summary: string };
const summary = obfuscator.obfuscate(msg.summary, sharedRegexSecretValues);
return summary === msg.summary ? message : ({ ...(message as object), summary } as AgentMessage);
}
case "fileMention": {
const msg = message as AgentMessage & {
files: Array<{ path: string; content: string; image?: unknown }>;
};
let changed = false;
const files = msg.files.map(file => {
const path = obfuscator.obfuscate(file.path, sharedRegexSecretValues);
if (path === file.path) return file;
changed = true;
return { ...file, path };
});
return changed ? ({ ...(message as object), files } as AgentMessage) : message;
}
default:
return message;
}
}
function scrubAdvisorHistory(
obfuscator: SecretObfuscator,
messages: AgentMessage[],
sharedRegexSecretValues: ReadonlySet<string>,
): void {
for (let index = 0; index < messages.length; index++) {
const message = messages[index]!;
const next = obfuscateAdvisorMessage(obfuscator, message, sharedRegexSecretValues);
if (next !== message) messages[index] = next;
}
}