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; 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; /** * 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; /** Called after a successful advisor turn so the host can finish fallback lifecycle reporting. */ onTurnSuccess?(): Promise | 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, sourceText = "", ): string | undefined { const reasons: string[] = []; const unavailableToolNames = new Set(); 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; 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(); /** * 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(); #pending: PendingDelta[] = []; #busy = false; #sessionTransitionPaused = false; #promptInFlight: Promise | 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(); /** 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 | 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 { 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(); 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 { 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 | 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 = delivered.message as unknown as Record; const newMsg: Record = current as unknown as Record; 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 { 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, ): 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, ): 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, ): 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 | undefined, sharedRegexSecretValues: ReadonlySet, ): Record | 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, ): 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; 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; }; 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, ): 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; } }