import { Agent, type AgentMessage, type AgentTool, type AgentToolContext, AppendOnlyContextManager, type CompactionSummaryMessage, countTokens, resolveTelemetry, type StreamFn, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { type CompactionResult, calculateContextTokens, compact, compactionContextTokens, createCompactionSummaryMessage, estimateTokens, NativeCompactionError, prepareCompaction, type SessionMessageEntry, shouldCompact, shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, CodexCompactionContext, Context, Message, Model, ProviderSessionState, ServiceTier, SimpleStreamOptions, } from "@oh-my-pi/pi-ai"; import { isUsageLimitOutcome, resolveModelServiceTier, streamSimple } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils"; import { ADVISOR_DEFAULT_TOOL_NAMES, AdviseTool, type AdvisorAgent, type AdvisorConfig, AdvisorEmissionGuard, type AdvisorMessageDetails, type AdvisorNote, AdvisorOutputQuarantinedError, AdvisorRuntime, type AdvisorRuntimeStatus, type AdvisorSeverity, AdvisorTranscriptRecorder, advisorTranscriptFilename, buildAdvisorQuarantineSourceText, formatAdvisorBatchContent, getOrCreateAdvisorProviderSessionId, isAdvisorInterruptImmuneTurnActive, isInterruptingSeverity, quarantineAdvisorUnsafeOutput, resolveAdvisorDeliveryChannel, slugifyAdvisorName, } from "../advisor"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelString, formatModelStringWithRouting, resolveAdvisorRoleSelection, resolveModelOverride, } from "../config/model-resolver"; import { MODEL_ROLES } from "../config/model-roles"; import { serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier"; import type { Settings } from "../config/settings"; import { CursorExecHandlers, type CursorMcpResourceAdapter } from "../cursor"; import { bridgeToolMap } from "../cursor-bridge-tools"; import { estimateToolSchemaTokens } from "../modes/utils/context-usage"; import type { PlanModeState } from "../plan-mode/state"; import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" }; import type { SecretObfuscator } from "../secrets/obfuscator"; import { concreteThinkingLevel, resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort, } from "../thinking"; import type { AgentSessionEvent } from "./agent-session-events"; import type { ClientBridge } from "./client-bridge"; import type { CustomMessage, CustomMessagePayload } from "./messages"; import { isAdvisorCard, isTerminalTextAssistantAnswer } from "./queued-messages"; import { formatRetryFallbackSelector, getRetryFallbackRevertPolicy, parseRetryFallbackSelector, type RetryFallbackSelector, } from "./retry-fallback-chains"; import { formatSessionDumpText } from "./session-dump-format"; import type { CompactionEntry, SessionEntry } from "./session-entries"; import { formatSessionHistoryMarkdown } from "./session-history-format"; import type { SessionManager } from "./session-manager"; import { buildSessionMetadata } from "./session-metadata"; import type { YieldQueue } from "./yield-queue"; const ADVISOR_CODEX_SSE_MAX_ATTEMPTS = 1; /** Advisor statistics for the advisor status command. */ export interface AdvisorStats { configured: boolean; active: boolean; model?: Model; contextWindow: number; contextTokens: number; tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number; total: number; }; cost: number; messages: { user: number; assistant: number; total: number; }; /** Per-advisor breakdown for every configured advisor. */ advisors: PerAdvisorStat[]; } /** One advisor's status, token usage, cost, and message counts. */ export interface PerAdvisorStat { name: string; status: AdvisorRuntimeStatus; model?: Model; contextWindow: number; contextTokens: number; tokens: AdvisorStats["tokens"]; cost: number; messages: AdvisorStats["messages"]; sessionId?: string; } interface AdvisorRetryFallbackState { role: string; originalSelector: string; originalThinkingLevel: ThinkingLevel; lastAppliedThinkingLevel: ThinkingLevel; } interface ActiveAdvisor { name: string; slug: string; agent: Agent; runtime: AdvisorRuntime; adviseTool: AdviseTool; emissionGuard: AdvisorEmissionGuard; recorder: AdvisorTranscriptRecorder; recorderClosed: Promise; agentUnsubscribe?: () => void; model: Model; thinkingLevel: ThinkingLevel; providerSessionId: string | undefined; retryFallback?: AdvisorRetryFallbackState; retryFallbackPendingSuccess: boolean; signature: string; } interface AdvisorCompactionSummaryMessage extends CompactionSummaryMessage { firstKeptEntryId?: string; advisorUsageAnchorStartIndex?: number; } interface AdvisorRuntimeDescriptor { config: AdvisorConfig; name: string; slug: string; model: Model; thinkingLevel: ThinkingLevel; signature: string; } /** Inputs that configure the advisor roster owned by a session. */ export interface SessionAdvisorsOptions { enabled: boolean; tools?: AgentTool[]; /** * Build a `grep` honoring a Cursor `pi_grep` frame's own context width and * match cap. The advisor's tools are fixed instances carrying session * defaults, so without this an advisor running against Cursor silently * drops both fields — the same gap the primary bridge closes. */ createGrepTool?(options: { context?: number; totalMatchLimit?: number }): AgentTool | undefined; /** * Build the `replace`-mode `edit` a Cursor `pi_edit` frame needs. The * advisor's own instance follows the configured `edit.mode` (`hashline` by * default), whose schema the frame's `old_string`/`new_string` args do not * match, so without this every native advisor edit fails validation. */ createEditTool?(): AgentTool | undefined; /** * The execute-time context the bridge's tools resolve approval from. * * `ExtensionToolWrapper` reads the approval mode, per-tool policies and * `autoApprove` only from here; with none it falls back to `yolo` and empty * policies, so a native frame would run past a configured `ask` or `deny`. */ getToolContext?: () => AgentToolContext | undefined; /** * The live MCP connections Cursor's resource frames answer from. * * Advisors share the session's connections and may hold tools from those * same servers, so without this their frames report that every server * advertises nothing. */ mcpResources?: CursorMcpResourceAdapter; watchdogPrompt?: string; sharedInstructions?: string; contextPrompt?: string; configs?: AdvisorConfig[]; streamFn?: StreamFn; transformProviderContext?: (context: Context, model: Model) => Context | Promise; /** Advisor spend already persisted for this session, restored on resume. */ initialCosts?: ReadonlyMap; } /** Options accepted when an advisor injects a primary-session message. */ export interface AdvisorMessageDeliveryOptions { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; queueChipText?: string; acceptTerminalEmptyStop?: boolean; } /** Session capabilities borrowed by the advisor controller. */ export interface SessionAdvisorsHost { agent: Agent; sessionManager: SessionManager; settings: Settings; modelRegistry: ModelRegistry; yieldQueue: YieldQueue; obfuscator: SecretObfuscator | undefined; providerSessionState: Map; preferWebsockets: boolean | undefined; onPayload: SimpleStreamOptions["onPayload"] | undefined; onResponse: SimpleStreamOptions["onResponse"] | undefined; onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined; agentKind(): "main" | "sub"; isDisposed(): boolean; abortInProgress(): boolean; allowAgentInitiatedTurns(): boolean; planModeState(): PlanModeState | undefined; clientBridge(): ClientBridge | undefined; emitSessionEvent(event: AgentSessionEvent): Promise; emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void; sendCustomMessage(message: CustomMessagePayload, options?: AdvisorMessageDeliveryOptions): Promise; extractQueuedAdvisorCards(): CustomMessage[]; dropPendingAdvisorCards(): void; preserveAdvisorCard(card: CustomMessage): void; hasPendingNextTurnMessages(): boolean; convertToLlmForSideRequest(messages: AgentMessage[]): Message[]; effectiveServiceTier(model: Model): ServiceTier | undefined; resolveContextPromotionTarget( currentModel: Model, contextWindow: number, signal: AbortSignal, ): Promise; resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[]; resolveRetryFallbackRole( currentSelector: string, currentModel?: Model | null, roleHint?: string, ): string | undefined; findRetryFallbackCandidates( role: string, currentSelector: string, currentModel?: Model | null, ): RetryFallbackSelector[]; isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean; noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void; createCodexCompactionContext(options: { trigger: CodexCompactionContext["trigger"]; reason: CodexCompactionContext["reason"]; phase: CodexCompactionContext["phase"]; }): CodexCompactionContext; sessionId(): string; } /** Owns advisor runtimes, delivery policy, context maintenance, and status reporting. */ export class SessionAdvisors { readonly #host: SessionAdvisorsHost; #advisorEnabled: boolean; #advisorTools: AgentTool[] | undefined; #advisorCreateGrepTool: SessionAdvisorsOptions["createGrepTool"]; #advisorCreateEditTool: SessionAdvisorsOptions["createEditTool"]; #advisorGetToolContext: SessionAdvisorsOptions["getToolContext"]; #advisorMcpResources: SessionAdvisorsOptions["mcpResources"]; #advisorWatchdogPrompt: string | undefined; #advisorSharedInstructions: string | undefined; #advisorContextPrompt: string | undefined; #advisorStreamFn: StreamFn | undefined; #transformProviderContext: ((context: Context, model: Model) => Context | Promise) | undefined; #advisors: ActiveAdvisor[] = []; #advisorConfigs: AdvisorConfig[] | undefined; #advisorStatuses = new Map(); #advisorProviderSessionIds = new Map(); #advisorCosts = new Map(); #advisorRecorderClosed: Promise = Promise.resolve(); #advisorAutoResumeSuppressed = false; #preserveAdvisorAdvice = false; #advisorPrimaryTurnsCompleted = 0; #advisorInterruptImmuneTurnStart: number | undefined; #pendingAdvisorCardEvents = new Set>(); #advisorYieldQueueUnsubscribe: (() => void) | undefined; constructor(host: SessionAdvisorsHost, options: SessionAdvisorsOptions) { this.#host = host; this.#advisorEnabled = options.enabled; this.#advisorTools = options.tools; this.#advisorCreateGrepTool = options.createGrepTool; this.#advisorCreateEditTool = options.createEditTool; this.#advisorGetToolContext = options.getToolContext; this.#advisorMcpResources = options.mcpResources; this.#advisorWatchdogPrompt = options.watchdogPrompt; this.#advisorSharedInstructions = options.sharedInstructions; this.#advisorContextPrompt = options.contextPrompt; this.#advisorConfigs = options.configs; this.#advisorStreamFn = options.streamFn; this.#transformProviderContext = options.transformProviderContext; if (options.initialCosts) this.#advisorCosts = new Map(options.initialCosts); if (this.#advisorEnabled) this.#buildAdvisorRuntime(); } /** Delivers one completed primary turn to every live advisor. */ async onPrimaryTurnEnd( messages: AgentMessage[], willContinue: boolean | undefined, signal?: AbortSignal, ): Promise { this.#advisorPrimaryTurnsCompleted++; for (const advisor of this.#advisors) { if (advisor.runtime.disposed) continue; try { advisor.runtime.onTurnEnd(messages, { willContinue }); } catch (error) { logger.warn("advisor onTurnEnd threw; delta dropped", { advisor: advisor.name, err: String(error) }); } } const syncBacklog = this.#host.settings.get("advisor.syncBacklog"); if (this.#advisors.length === 0 || syncBacklog === "off") return; const threshold = Number.parseInt(syncBacklog, 10); await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(30_000, threshold, signal))); } /** Rebuilds live advisors when role assignments alter their resolved runtime inputs. */ onModelRolesChanged(): void { if (!this.#advisorEnabled || this.#host.isDisposed()) return; if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime(); this.#buildAdvisorRuntime(true); } /** Starts configured advisor runtimes when they are eligible. */ buildRuntime(seedToCurrent = false): boolean { return this.#buildAdvisorRuntime(seedToCurrent); } /** Stops every advisor runtime and starts recorder shutdown. */ stopRuntime(): void { this.#stopAdvisorRuntime(); } /** * Pause advisor work while old-session recorder feeds remain attached, then * detach only after any active prompt has settled. */ async drainAndDetachRecorders(): Promise { await Promise.all(this.#advisors.map(advisor => advisor.runtime.pauseForSessionTransition())); await this.detachAndCloseRecorders(); } /** Detaches and drains recorder feeds before transcript artifacts are removed. */ async detachAndCloseRecorders(): Promise { const closes: Promise[] = []; for (const advisor of this.#advisors) { advisor.agentUnsubscribe?.(); advisor.agentUnsubscribe = undefined; advisor.recorderClosed = advisor.recorder.close(); closes.push(advisor.recorderClosed); } await Promise.all(closes); } /** Reattach recorder feeds and resume work after a rolled-back or preserving transition. */ reattachRecorderFeeds(): void { for (const advisor of this.#advisors) { if (!advisor.agentUnsubscribe) this.#attachAdvisorRecorderFeed(advisor); advisor.runtime.resumeAfterSessionTransition(); } } /** Re-primes advisor transcript views across a conversation boundary. */ resetSessionState(options: { preserveCost?: boolean } = {}): void { this.#resetAdvisorSessionState(options.preserveCost === true); } /** Drop the recorded spend once a conversation boundary has committed. */ clearCost(): void { this.#advisorCosts.clear(); } /** Replace the ledger with the spend recorded for the session becoming active. */ restoreCost(costs: ReadonlyMap): void { this.#advisorCosts = new Map(costs); } /** * Rebind every live advisor to the active primary conversation's provider * identity (session id, prompt-cache key, credential + metadata resolvers, * telemetry). Invoked on every provider-session change — including branch * paths that skip conversation restore — so advisors never keep emitting the * previous conversation's session id/metadata (issue #6625). */ refreshProviderIdentity(): void { for (const advisor of this.#advisors) this.#refreshAdvisorProviderIdentity(advisor); } /** Re-primes advisor transcript views after an in-conversation history rewrite. */ resetAllRuntimes(reason?: string): void { this.#resetAllAdvisorRuntimes(reason); } /** Whether live runtimes still match the resolved advisor configuration. */ runtimeMatchesCurrentConfig(): boolean { return this.#advisorRuntimeMatchesCurrentConfig(); } /** Whether concern/blocker delivery is inside the post-interrupt immunity window. */ isInterruptImmuneTurnActive(): boolean { return this.#isAdvisorInterruptImmuneTurnActive(); } /** Latest aggregate recorder-close barrier. */ recorderClosed(): Promise { return this.#advisorRecorderClosed; } /** Whether a user interrupt currently suppresses advisor-driven auto-resume. */ get autoResumeSuppressed(): boolean { return this.#advisorAutoResumeSuppressed; } set autoResumeSuppressed(value: boolean) { this.#advisorAutoResumeSuppressed = value; } /** Tracks persistence of a visible advisor card emitted outside the primary loop. */ trackCardEvent(processing: Promise): void { this.#pendingAdvisorCardEvents.add(processing); void processing.finally(() => this.#pendingAdvisorCardEvents.delete(processing)).catch(() => {}); } /** Waits for all advisor-card persistence handlers currently in flight. */ async waitForPendingCardEvents(): Promise { await Promise.allSettled([...this.#pendingAdvisorCardEvents]); } // Advisor runtime lifecycle // ------------------------------------------------------------------------- #advisorImmuneTurnLimit(): number { const immuneTurns = this.#host.settings.get("advisor.immuneTurns") as number; if (!Number.isFinite(immuneTurns) || immuneTurns <= 0) return 0; return Math.trunc(immuneTurns); } #isAdvisorInterruptImmuneTurnActive(): boolean { return isAdvisorInterruptImmuneTurnActive({ completedTurns: this.#advisorPrimaryTurnsCompleted, immuneTurnStart: this.#advisorInterruptImmuneTurnStart, immuneTurns: this.#advisorImmuneTurnLimit(), }); } // The next primary turn number starts the immune-turn window. While the // interrupting steer is still in flight, completedTurns is lower than this // start, so duplicate concern/blocker advice is also downgraded. #recordAdvisorInterruptDelivered(): void { this.#advisorInterruptImmuneTurnStart = this.#advisorPrimaryTurnsCompleted + 1; } /** Rebind one advisor to the active primary conversation's provider identity. */ #refreshAdvisorProviderIdentity(advisor: ActiveAdvisor): void { const primaryProviderSessionId = this.#host.sessionId(); const providerSessionId = getOrCreateAdvisorProviderSessionId( this.#advisorProviderSessionIds, primaryProviderSessionId, advisor.slug, ); advisor.providerSessionId = providerSessionId; advisor.agent.sessionId = providerSessionId; advisor.agent.promptCacheKey = this.#host.agent.promptCacheKey ?? providerSessionId; advisor.agent.getApiKey = requestModel => this.#host.modelRegistry.resolver(requestModel, providerSessionId); advisor.agent.setMetadataResolver( providerSessionId ? provider => buildSessionMetadata(providerSessionId, provider, this.#host.modelRegistry.authStorage) : undefined, ); const telemetry = advisor.agent.telemetry; if (telemetry?.agent) { advisor.agent.setTelemetry({ ...telemetry, agent: { ...telemetry.agent, id: advisor.slug ? `${primaryProviderSessionId}-advisor-${advisor.slug}` : `${primaryProviderSessionId}-advisor`, }, }); } } /** * Re-prime the advisor across a conversation boundary: `/new`, `/branch`, * `/btw`, `/tree`, and session switch/resume. Beyond {@link AdvisorRuntime.reset} * (which only re-primes the advisor's transcript view and is also fired by * within-conversation rewrites like compaction/shake/rewind), this clears the * session-level interrupt latches so the prior conversation's cooldown cannot * leak into the new one: the post-interrupt immune-turn window * (`#advisorPrimaryTurnsCompleted`, `#advisorInterruptImmuneTurnStart`) and the * user-interrupt auto-resume suppression flag. It also drops advisor deliveries * still queued against the prior conversation — pending asides in the yield * queue (advisor entries use `skipIdleFlush`, so they linger until the next * `drainLazy` rather than self-flushing), interrupting cards parked in the * agent steer/follow-up queues, and preserved cards deferred to the next turn — * so none of them inject into the new conversation. */ #resetAdvisorSessionState(preserveCost: boolean): void { if (!preserveCost) this.#advisorCosts.clear(); // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor // loop, and that abort can emit an `aborted` message_end we must not attribute to // either session's transcript. Detach, reset, then re-attach the live agent's feed. for (const a of this.#advisors) { a.agentUnsubscribe?.(); a.agentUnsubscribe = undefined; a.runtime.reset("conversation-boundary"); a.adviseTool.resetDeliveredNotes(); a.emissionGuard.reset(); this.#attachAdvisorRecorderFeed(a); } this.#advisorPrimaryTurnsCompleted = 0; this.#advisorInterruptImmuneTurnStart = undefined; this.#advisorAutoResumeSuppressed = false; this.#host.yieldQueue.clear("advisor"); this.#host.extractQueuedAdvisorCards(); this.#host.dropPendingAdvisorCards(); } #resolveAdvisorRuntimeDescriptors(emitWarnings: boolean): AdvisorRuntimeDescriptor[] { const legacy = !this.#advisorConfigs?.length; const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!; const descriptors: AdvisorRuntimeDescriptor[] = []; const usedSlugs = new Set(); for (const config of roster) { let slug = legacy ? "" : slugifyAdvisorName(config.name); if (slug) { let candidate = slug; let n = 2; while (usedSlugs.has(candidate)) candidate = `${slug}-${n++}`; slug = candidate; usedSlugs.add(slug); } // Per-advisor toggle: skip disabled advisors but keep them in the // status map so they show `○` rather than disappearing. if (config.enabled === false) { this.#advisorStatuses.set(slug, { name: config.name, status: "paused" }); continue; } // Resolve the advisor's model: an explicit `model` override wins; else the // `advisor` role chain. A model that fails to resolve skips just this advisor. let model: Model | undefined; let thinkingLevel: ThinkingLevel | undefined; if (config.model) { const resolved = resolveModelOverride([config.model], this.#host.modelRegistry, this.#host.settings); model = resolved.model; thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel); if (!model) { this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" }); if (emitWarnings) { this.#host.emitNotice( "warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor", ); } continue; } } else { const sel = resolveAdvisorRoleSelection(this.#host.settings, this.#host.modelRegistry.getAvailable()); if (!sel) { this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" }); if (emitWarnings) { logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", { advisor: config.name, }); } continue; } model = sel.model; thinkingLevel = concreteThinkingLevel(sel.thinkingLevel); } // Clamp the effort against the resolved model. Historically we defaulted // to `ThinkingLevel.Medium` unconditionally, which threw at first stream // on reasoning models that expose no controllable effort surface // (e.g. `devin-agent`: Cascade routes by sibling model id, not a wire // param; `getSupportedEfforts` returns `[]`). `resolveThinkingLevelForModel` // preserves an explicit `off`, clamps a concrete effort into the model's // supported range, and returns `undefined` for reasoning models without // controllable efforts — for that case we forward `Inherit` so no effort // is sent and reasoning stays enabled (matching the `auto`-path fix for // Devin models via `clampAutoThinkingEffort`). See #4579. const requestedLevel = thinkingLevel ?? ThinkingLevel.Medium; const resolvedLevel = resolveThinkingLevelForModel(model, requestedLevel); const advisorThinkingLevel: ThinkingLevel = resolvedLevel ?? ThinkingLevel.Inherit; // Record the status entry now (in roster order) so the Map's insertion // order matches the configured roster even when earlier advisors were // skipped as paused/no_model. The build loop overwrites this to "running" // without changing insertion order. this.#advisorStatuses.set(slug, { name: config.name, status: "running" }); descriptors.push({ config, name: config.name, slug, model, thinkingLevel: advisorThinkingLevel, signature: this.#advisorRuntimeSignature(config, slug, model, advisorThinkingLevel), }); } return descriptors; } #advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string { const tools = config.tools?.length ? config.tools.join("\u001e") : ""; const instructions = config.instructions?.trim() ?? ""; return [config.name, slug, formatModelStringWithRouting(model), thinkingLevel, tools, instructions].join( "\u001f", ); } #advisorRuntimeMatchesCurrentConfig(): boolean { const descriptors = this.#resolveAdvisorRuntimeDescriptors(false); if (descriptors.length !== this.#advisors.length) return false; for (let i = 0; i < descriptors.length; i++) { if (descriptors[i].signature !== this.#advisors[i].signature) return false; } return true; } #buildAdvisorRuntime(seedToCurrent = false): boolean { if (this.#host.isDisposed()) return false; if (this.#advisors.length > 0) return true; if (!this.#advisorEnabled) return false; if (this.#host.agentKind() !== "main" && !this.#host.settings.get("advisor.subagents")) return false; // Rebuild the status map from scratch so removed/renamed advisors don't // leave stale entries. #resolveAdvisorRuntimeDescriptors populates every // entry (`paused`/`no_model`/`running`) in roster order; the build loop // below confirms `running` for successfully built advisors. this.#advisorStatuses.clear(); const descriptors = this.#resolveAdvisorRuntimeDescriptors(true); // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor // on standard processing; "inherit" tracks the session's live per-family // tiers per request (like the main agent, including /fast toggles); a // concrete value is broadcast across families and applied to the advisor // model's family. One value for all advisors. const advisorTierSetting = this.#host.settings.get("tier.advisor"); const advisorTierMap = advisorTierSetting === "inherit" ? undefined : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting)); const advisorServiceTierResolver = (model: Model): ServiceTier | undefined => advisorTierSetting === "inherit" ? this.#host.effectiveServiceTier(model) : resolveModelServiceTier(advisorTierMap, model); for (const descriptor of descriptors) { const { config, slug, model: advisorModel, name: advisorName, thinkingLevel: advisorThinkingLevel, signature, } = descriptor; const emissionGuard = new AdvisorEmissionGuard(); const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity)); // `#advisorWatchdogPrompt` already carries WATCHDOG.md + YAML shared // instructions; `config.instructions` adds this advisor's specialization. const systemPrompt = [advisorSystemPrompt]; if (this.#advisorContextPrompt) systemPrompt.push(this.#advisorContextPrompt); if (this.#advisorWatchdogPrompt) systemPrompt.push(this.#advisorWatchdogPrompt); if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions); if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim()); const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools); const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name)); const advisorLoopTools: AgentTool[] = [adviseTool, ...tools]; const advisorToolMap = new Map>(); const availableAdvisorToolNames = new Set(); for (const tool of advisorLoopTools) { availableAdvisorToolNames.add(tool.name); advisorToolMap.set(tool.name, tool); if (tool.customWireName !== undefined) { availableAdvisorToolNames.add(tool.customWireName); advisorToolMap.set(tool.customWireName, tool); } } let quarantinedAdvisorOutput: string | undefined; let currentAdvisorInput = ""; const primaryProviderSessionId = this.#host.sessionId(); const advisorSessionLabel = slug ? `${primaryProviderSessionId}-advisor-${slug}` : `${primaryProviderSessionId}-advisor`; const advisorProviderSessionId = getOrCreateAdvisorProviderSessionId( this.#advisorProviderSessionIds, primaryProviderSessionId, slug, ); const appendOnlyContext = new AppendOnlyContextManager(); // Thread the primary's telemetry into the advisor loop so the advisor // model's GenAI spans + usage/cost hooks fire stamped with the local advisor // identity. `conversationId` is cleared so provider telemetry falls back to // the UUIDv7 provider session id, not the local `-advisor` label. const advisorTelemetry = this.#host.agent.telemetry ? { ...this.#host.agent.telemetry, agent: { id: advisorSessionLabel, name: slug ? `${MODEL_ROLES.advisor.name}: ${advisorName}` : MODEL_ROLES.advisor.name, description: formatModelString(advisorModel), }, conversationId: undefined, } : undefined; // Mirror the SDK's provider-shaping options (streamFn/onPayload/..., // providerSessionState, promptCacheKey, transformProviderContext) so each // advisor's requests cache, route, and obfuscate like the main turn. // `promptCacheKey` preserves an explicitly pinned provider cache key // unchanged so tan/shared-session advisor calls read the exact shard the // parent turn populated. Otherwise the advisor uses its provider UUIDv7 so // Codex request identity remains UUID-shaped while local labels keep the // `-advisor` suffix. const advisorPromptCacheKey = this.#host.agent.promptCacheKey ?? advisorProviderSessionId; // On the Cursor provider every tool runs server-side and is dispatched // back through `cursorExecHandlers`; without this bridge the advisor's // own tools (including the MCP `advise` tool) return `toolNotFound` and // no advice is ever routed (issue #5680). Mirrors the primary agent's // bridge (`sdk.ts`), scoped to this advisor's granted tool set. // Cursor's native `delete` frame removes files directly, bypassing the // tool map, so gate it on the advisor actually holding a file-mutating // tool. A default read-only advisor (advise/read/grep/glob) never gets // to delete workspace files it was never granted (issue #5680 review). const advisorCanMutateFiles = advisorToolMap.has("write") || advisorToolMap.has("edit"); if (advisorCanMutateFiles) availableAdvisorToolNames.add("delete"); // `pi_edit` speaks `replace`'s `old_string`/`new_string` schema, which the // advisor's ordinary `EditTool` (built at the session's configured // `edit.mode`, `hashline` by default) does not accept. The bridge map // swaps in a `replace` instance for the exec channel only — the // advisor's own loop keeps the tool it was given — and only when // `edit` was actually granted. const advisorCursorExecHandlers = new CursorExecHandlers({ cwd: this.#host.sessionManager.getCwd(), getCwd: () => this.#host.sessionManager.getCwd(), tools: bridgeToolMap(advisorToolMap, this.#advisorCreateEditTool), // Approval mode, per-tool policies and `autoApprove` live only on // this context; without it every bridge tool resolves as `yolo`. getToolContext: this.#advisorGetToolContext, allowDirectFileMutation: advisorCanMutateFiles, // Gated on the advisor's own grant: the factory builds a fresh // tool, so handing it over unconditionally would give a roster // without `grep` a search tool it was denied. createGrepTool: advisorToolMap.has("grep") ? this.#advisorCreateGrepTool : undefined, // Advisors share the session's live MCP connections, so their // resource frames answer from the same catalog the primary sees. // Not gated on a tool grant: reading what a server advertises is // not the same permission as calling one of its tools. mcpResources: this.#advisorMcpResources, }); const baseAdvisorStreamFn = this.#advisorStreamFn ?? streamSimple; const advisorStreamFn: StreamFn = (requestModel, context, options) => { if (requestModel.api === "openai-codex-responses") { return baseAdvisorStreamFn(requestModel, context, { ...options, codexSseMaxAttempts: ADVISOR_CODEX_SSE_MAX_ATTEMPTS, }); } if ( requestModel.api === "google-generative-ai" || requestModel.api === "google-gemini-cli" || requestModel.api === "google-vertex" ) { return baseAdvisorStreamFn(requestModel, context, { ...options, acceptEmptyResponse: true }); } return baseAdvisorStreamFn(requestModel, context, options); }; const advisorAgent = new Agent({ initialState: { systemPrompt, model: advisorModel, thinkingLevel: toReasoningEffort(advisorThinkingLevel), tools: advisorLoopTools, }, appendOnlyContext, sessionId: advisorProviderSessionId, promptCacheKey: advisorPromptCacheKey, providerSessionState: this.#host.providerSessionState, cursorExecHandlers: advisorCursorExecHandlers, cwdResolver: () => this.#host.sessionManager.getCwd(), preferWebsockets: this.#host.preferWebsockets, getApiKey: requestModel => this.#host.modelRegistry.resolver(requestModel, advisorProviderSessionId), streamFn: advisorStreamFn, onPayload: this.#host.onPayload, onResponse: this.#host.onResponse, onSseEvent: this.#host.onSseEvent, transformProviderContext: this.#transformProviderContext, intentTracing: false, transformAssistantMessage: message => { quarantinedAdvisorOutput = quarantineAdvisorUnsafeOutput( message, availableAdvisorToolNames, buildAdvisorQuarantineSourceText(currentAdvisorInput, advisorAgent.state.messages), ); }, telemetry: advisorTelemetry, serviceTier: undefined, serviceTierResolver: advisorServiceTierResolver, }); advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel)); const advisorAgentFacade: AdvisorAgent = { prompt: async input => { let quarantined: string | undefined; try { quarantinedAdvisorOutput = undefined; // Multi-message input (candidate 4) must serialize deterministically // for quarantine source text; reuse the session history formatter // rather than ad-hoc joins so all message kinds (text/tool/ // custom/structured) are preserved exactly as rendered. currentAdvisorInput = Array.isArray(input) ? formatSessionHistoryMarkdown(input, { watchedRoles: true }) : input; // Agent.prompt's overloads accept string OR AgentMessage[] but not // the union, so narrow first; both branches intentionally identical. if (Array.isArray(input)) await advisorAgent.prompt(input); else await advisorAgent.prompt(input); quarantined = quarantinedAdvisorOutput; } finally { quarantinedAdvisorOutput = undefined; currentAdvisorInput = ""; } if (quarantined) throw new AdvisorOutputQuarantinedError(quarantined); }, abort: reason => advisorAgent.abort(reason), reset: () => { advisorAgent.reset(); appendOnlyContext.log.clear(); }, rollbackTo: count => { // Drop the failed user batch + synthetic assistant-error turn // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`. const messages = advisorAgent.state.messages; if (count < messages.length) { messages.length = count; } appendOnlyContext.resetSyncCursor(); advisorAgent.state.error = undefined; }, state: advisorAgent.state, }; // Persist this advisor's turns to `/__advisor[.].jsonl` // (resolved lazily so it follows session switches) for stats attribution // and Agent Hub observability, without registering it as a peer. const recorder = new AdvisorTranscriptRecorder( () => this.#host.sessionManager.getSessionFile(), () => this.#host.sessionManager.getCwd(), advisorTranscriptFilename(slug), // On the advisor on→off→on toggle, wait for the prior recorders' closes // so two SessionManagers never hold the same file at once. this.#advisorRecorderClosed, ); const runtime = new AdvisorRuntime(advisorAgentFacade, { snapshotMessages: () => this.#host.agent.state.messages, enqueueAdvice: (note, severity) => this.#routeAdvice(advisorRef, note, severity), maintainContext: (incomingTokens, signal) => this.#maintainAdvisorContext(advisorRef, incomingTokens, signal), obfuscator: this.#host.obfuscator, getModelIdentity: () => formatModelString(advisorRef.agent.state.model), beginAdvisorUpdate: inProgress => { advisorRef.adviseTool.beginUpdate(inProgress); advisorRef.emissionGuard.beginUpdate(); }, onTurnError: (error, failedMessages, signal) => this.#recoverAdvisorTurn(advisorRef, error, failedMessages, signal), onTurnSuccess: async () => { const fallback = advisorRef.retryFallback; if (!advisorRef.retryFallbackPendingSuccess || !fallback) return; advisorRef.retryFallbackPendingSuccess = false; await this.#host.emitSessionEvent({ type: "retry_fallback_succeeded", model: formatRetryFallbackSelector(advisorRef.agent.state.model, advisorRef.thinkingLevel), role: fallback.role, }); }, notifyFailure: error => { this.#advisorStatuses.set(slug, { name: advisorName, status: "error" }); const message = error instanceof Error ? error.message : String(error); this.#host.emitNotice( "warning", `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorAgent.state.model)}: ${message}`, "advisor", ); }, notifyQuotaExhausted: () => { this.#advisorStatuses.set(slug, { name: advisorName, status: "quota_exhausted" }); this.#host.emitNotice( "warning", `Advisor "${advisorName}" quota exhausted — pausing until reset.`, "advisor", ); }, }); const advisorRef: ActiveAdvisor = { name: advisorName, slug, agent: advisorAgent, runtime, adviseTool, emissionGuard, recorder, recorderClosed: Promise.resolve(), model: advisorModel, thinkingLevel: advisorThinkingLevel, providerSessionId: advisorProviderSessionId, retryFallbackPendingSuccess: false, signature, }; this.#refreshAdvisorProviderIdentity(advisorRef); this.#attachAdvisorRecorderFeed(advisorRef); if (seedToCurrent) runtime.seedTo(this.#host.agent.state.messages.length); this.#advisorStatuses.set(slug, { name: advisorName, status: "running" }); this.#advisors.push(advisorRef); } // One shared non-blocking aside channel for all advisors; the build callback // aggregates every advisor's queued nits into one card (each entry already // carries its own `advisor` name). if (this.#advisors.length > 0 && !this.#advisorYieldQueueUnsubscribe) { this.#advisorYieldQueueUnsubscribe = this.#host.yieldQueue.register("advisor", { build: entries => entries.length === 0 ? null : ({ role: "custom", customType: "advisor", display: true, attribution: "agent", timestamp: Date.now(), content: formatAdvisorBatchContent(entries), details: { notes: entries } satisfies AdvisorMessageDetails, } satisfies CustomMessage), skipIdleFlush: true, }); } return this.#advisors.length > 0; } /** * Route one accepted advice note from `advisor` to the primary. Concern and * blocker interrupt the running agent through the steering channel; once the * loop has yielded, `triggerTurn` resumes it. After a terminal text answer with * no queued work, a concern is preserved as a visible advisor card, while a * blocker wakes the primary to acknowledge work it handed off incorrectly. * After a deliberate user interrupt auto-resume is suppressed while idle/unwinding * (the note becomes a preserved card re-entering on resume); a live-streaming turn is * steered in directly. A plain nit always rides the non-interrupting YieldQueue * aside. Suppression by the per-advisor emission guard drops the note silently — * the model still saw `Recorded.`, so it isn't tempted to rephrase the same note * past the dedupe. */ #hasTerminalTextAnswerWithoutQueuedWork(): boolean { if (this.#host.agent.hasQueuedMessages() || this.#host.hasPendingNextTurnMessages()) return false; const messages = this.#host.agent.state.messages; let tail = messages.length - 1; while (tail >= 0 && isAdvisorCard(messages[tail])) tail--; return isTerminalTextAssistantAnswer(messages[tail]); } #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void { if (!advisor.emissionGuard.accept(note)) { logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name }); return; } // The implicit single ("default") advisor stamps no source name, so its // agent-facing `` bytes stay identical to the pre-multi-advisor path. const source = advisor.slug ? advisor.name : undefined; const interrupting = isInterruptingSeverity(severity); const channel = resolveAdvisorDeliveryChannel({ severity, autoResumeSuppressed: this.#advisorAutoResumeSuppressed, preserveOnly: this.#preserveAdvisorAdvice, // Key on the live agent-core loop, not session `isStreaming` (which also // counts `#promptInFlightCount` during post-turn unwind). Only a running // loop consumes a steer at its next boundary. streaming: this.#host.agent.state.isStreaming, aborting: this.#host.abortInProgress(), terminalAnswerNoQueuedWork: this.#hasTerminalTextAnswerWithoutQueuedWork(), interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(), }); if (channel === "aside") { this.#host.yieldQueue.enqueue("advisor", { note, severity, advisor: source }); return; } const notes: AdvisorNote[] = [{ note, severity, advisor: source }]; const content = formatAdvisorBatchContent(notes); const details = { notes } satisfies AdvisorMessageDetails; if (channel === "preserve") { this.#host.preserveAdvisorCard({ role: "custom", customType: "advisor", content, display: true, attribution: "agent", details, timestamp: Date.now(), }); return; } // A steered interrupting note only continues the run when the session can // actually start (or is already running) a turn. Two idle cases cannot, so // `sendCustomMessage({ triggerTurn: true })` would silently bury the card in // `#pendingNextTurnMessages` until the next user prompt — strictly worse than // the visible preserved card. Preserve instead: // - Plan mode: only user-driven turns converge on ask/resolve. // - ACP bridges with `deferAgentInitiatedTurns`: the client cannot show an // agent-initiated turn as busy, so idle triggers are refused (#5628 review). const cannotAutoTrigger = !this.#host.agent.state.isStreaming && this.#host.clientBridge()?.deferAgentInitiatedTurns === true && !this.#host.allowAgentInitiatedTurns(); if (this.#host.planModeState()?.enabled || cannotAutoTrigger) { this.#host.preserveAdvisorCard({ role: "custom", customType: "advisor", content, display: true, attribution: "agent", details, timestamp: Date.now(), }); return; } // Arm the post-interrupt immune window only now that a turn is actually // being steered/triggered. A merely preserved card never interrupts, so // arming earlier would downgrade the next `advisor.immuneTurns` worth of // real concerns/blockers to skip-idle-flush asides (#5628 review). this.#recordAdvisorInterruptDelivered(); void this.#host .sendCustomMessage( { customType: "advisor", content, display: true, attribution: "agent", details }, { deliverAs: "steer", triggerTurn: true }, ) .catch(err => logger.debug("advisor delivery failed", { err: String(err) })); } /** Re-prime every advisor's transcript view after an in-conversation history rewrite. */ #resetAllAdvisorRuntimes(reason?: string): void { for (const a of this.#advisors) a.runtime.reset(reason); } #stopAdvisorRuntime(): void { // Detach each recorder feed BEFORE aborting its advisor agent: dispose() aborts // the loop, and an abort emits a final `message_end` we must not enqueue against // a closing recorder (it would reopen and resurrect an already-released file). const closes: Promise[] = []; for (const a of this.#advisors) { a.agentUnsubscribe?.(); a.agentUnsubscribe = undefined; a.runtime.dispose(); // Capture each close so dispose()/`/drop` can await the queued open+append+close — // the last advisor turn would otherwise be lost on a fast process exit. a.recorderClosed = a.recorder.close(); closes.push(a.recorderClosed); } this.#advisorRecorderClosed = Promise.all(closes).then(() => {}); this.#advisors = []; this.#advisorYieldQueueUnsubscribe?.(); this.#advisorYieldQueueUnsubscribe = undefined; } #recordAdvisorCost(advisor: ActiveAdvisor, message: AssistantMessage): void { this.#advisorCosts.set(advisor.slug, (this.#advisorCosts.get(advisor.slug) ?? 0) + message.usage.cost.total); } /** Subscribe the advisor agent's finalized messages into the transcript recorder. * Idempotent-by-replacement: callers detach the prior feed first. Kept separate * so the re-prime path can mute the feed across an abort-driven reset. */ #attachAdvisorRecorderFeed(advisor: ActiveAdvisor): void { advisor.agentUnsubscribe = advisor.agent.subscribe(event => { if (event.type !== "message_end") return; if (event.message.role === "assistant") this.#recordAdvisorCost(advisor, event.message); advisor.recorder.record(event.message); }); } /** Switch one advisor model while preserving its context and effort invariants. */ #setAdvisorModel(advisor: ActiveAdvisor, model: Model, requestedThinkingLevel: ThinkingLevel): ThinkingLevel { const resolvedThinkingLevel = resolveThinkingLevelForModel(model, requestedThinkingLevel); const nextThinkingLevel = resolvedThinkingLevel ?? ThinkingLevel.Inherit; advisor.agent.setModel(model); advisor.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel)); advisor.agent.setDisableReasoning(shouldDisableReasoning(nextThinkingLevel)); advisor.agent.appendOnlyContext?.invalidateForModelChange(); advisor.model = model; advisor.thinkingLevel = nextThinkingLevel; return nextThinkingLevel; } /** Restore an advisor's configured primary once its fallback cooldown expires. */ async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor, signal: AbortSignal): Promise { const fallback = advisor.retryFallback; if (!fallback || getRetryFallbackRevertPolicy(this.#host.settings) !== "cooldown-expiry") return; const originalSelector = parseRetryFallbackSelector(fallback.originalSelector, this.#host.modelRegistry); if (!originalSelector) { advisor.retryFallback = undefined; advisor.retryFallbackPendingSuccess = false; return; } const currentSelector = formatRetryFallbackSelector(advisor.agent.state.model, advisor.thinkingLevel); if (currentSelector === originalSelector.raw) { if (!this.#host.isRetryFallbackSelectorSuppressed(originalSelector)) { advisor.retryFallback = undefined; advisor.retryFallbackPendingSuccess = false; } return; } if (this.#host.isRetryFallbackSelectorSuppressed(originalSelector)) return; const resolvedPrimary = resolveModelOverride( [originalSelector.raw], this.#host.modelRegistry, this.#host.settings, ); const primaryModel = resolvedPrimary.model ?? this.#host.modelRegistry.find(originalSelector.provider, originalSelector.id); if (!primaryModel) return; const apiKey = await this.#host.modelRegistry.getApiKey(primaryModel, advisor.providerSessionId, { signal }); if (!apiKey) return; signal.throwIfAborted(); const thinkingToApply = advisor.thinkingLevel === fallback.lastAppliedThinkingLevel ? fallback.originalThinkingLevel : advisor.thinkingLevel; this.#setAdvisorModel(advisor, primaryModel, thinkingToApply); this.#host.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(primaryModel)); advisor.retryFallback = undefined; advisor.retryFallbackPendingSuccess = false; } /** * Apply the advisor's configured provider-failure fallback chain after * same-provider credential rotation has no usable sibling. */ async #recoverAdvisorTurn( advisor: ActiveAdvisor, error: unknown, failedMessages: readonly AgentMessage[], signal: AbortSignal, ): Promise { if (error instanceof AdvisorOutputQuarantinedError) return false; const failedMessage = failedMessages.findLast( (message): message is AssistantMessage => message.role === "assistant", ); const assistantFailure = failedMessage?.stopReason === "error" ? failedMessage : undefined; if (assistantFailure?.content.some(block => block.type === "toolCall")) return false; const currentModel = advisor.agent.state.model; const message = assistantFailure?.errorMessage ?? (error instanceof Error ? error.message : String(error)); const errorId = assistantFailure ? AIError.classifyMessage({ api: currentModel.api, errorId: assistantFailure.errorId, errorMessage: message, errorStatus: assistantFailure.errorStatus, }) : AIError.classify(error, currentModel.api); if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) return false; if ( AIError.is(errorId, AIError.Flag.ContextOverflow) || (assistantFailure && AIError.isContextOverflow(assistantFailure, currentModel.contextWindow ?? 0)) ) { return false; } const accountPolicyDenial = AIError.is(errorId, AIError.Flag.AccountPolicy); if (accountPolicyDenial) { const switched = await this.#host.modelRegistry.authStorage.rotateSessionCredential( currentModel.provider, advisor.providerSessionId, { error: message, modelId: currentModel.id, signal }, ); if (switched) return true; } const retryAfterMs = extractRetryHint(undefined, message); const usageLimit = AIError.is(errorId, AIError.Flag.UsageLimit) || isUsageLimitOutcome(extractHttpStatusFromError(error), message); if (usageLimit) { const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached( currentModel.provider, advisor.providerSessionId, { retryAfterMs, baseUrl: currentModel.baseUrl, modelId: currentModel.id, signal, }, ); if (outcome.switched) return true; } if (!assistantFailure && !accountPolicyDenial && !usageLimit) return false; const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel); const retrySettings = this.#host.settings.getGroup("retry"); if (!retrySettings.enabled || !retrySettings.modelFallback) return false; const role = advisor.retryFallback?.role ?? this.#host.resolveRetryFallbackRole(currentSelector, currentModel, "advisor"); if (!role || this.#host.findRetryFallbackCandidates(role, currentSelector, currentModel).length === 0) return false; this.#host.noteRetryFallbackCooldown(currentSelector, retryAfterMs, message); for (const selector of this.#host.findRetryFallbackCandidates(role, currentSelector, currentModel)) { if (this.#host.isRetryFallbackSelectorSuppressed(selector)) continue; const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings); const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id); if (!candidate || modelsAreEqual(candidate, currentModel)) continue; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisor.providerSessionId, { signal }); if (!apiKey) continue; signal.throwIfAborted(); const originalThinkingLevel = advisor.thinkingLevel; const requestedThinkingLevel = selector.thinkingLevel ?? originalThinkingLevel; const nextThinkingLevel = this.#setAdvisorModel(advisor, candidate, requestedThinkingLevel); if (advisor.retryFallback) { advisor.retryFallback.lastAppliedThinkingLevel = nextThinkingLevel; } else { advisor.retryFallback = { role, originalSelector: currentSelector, originalThinkingLevel, lastAppliedThinkingLevel: nextThinkingLevel, }; } advisor.retryFallbackPendingSuccess = true; this.#host.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(candidate)); await this.#host.emitSessionEvent({ type: "retry_fallback_applied", from: currentSelector, to: selector.raw, role, }); return true; } return false; } async #promoteAdvisorContextModel( advisor: ActiveAdvisor, currentModel: Model, signal: AbortSignal, ): Promise { const promotionSettings = this.#host.settings.getGroup("contextPromotion"); if (!promotionSettings.enabled) return false; const contextWindow = currentModel.contextWindow ?? 0; if (contextWindow <= 0) return false; const targetModel = await this.#host.resolveContextPromotionTarget(currentModel, contextWindow, signal); if (!targetModel) return false; signal.throwIfAborted(); // Preserve this advisor's own thinking level (a configured `model:...:high` // keeps its suffix across a promotion); only the model changes. const advisorThinkingLevel = advisor.thinkingLevel; try { this.#setAdvisorModel(advisor, targetModel, advisorThinkingLevel); logger.debug("Advisor context promotion switched model on overflow", { advisor: advisor.name, from: `${currentModel.provider}/${currentModel.id}`, to: `${targetModel.provider}/${targetModel.id}`, }); return true; } catch (error) { logger.warn("Advisor context promotion failed", { advisor: advisor.name, from: `${currentModel.provider}/${currentModel.id}`, to: `${targetModel.provider}/${targetModel.id}`, error: String(error), }); return false; } } async #maintainAdvisorContext( advisor: ActiveAdvisor, incomingTokens: number, signal: AbortSignal, ): Promise { await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor, signal); const agent = advisor.agent; const compactionSettings = this.#host.settings.getGroup("compaction"); if (compactionSettings.strategy === "off") return false; if (!compactionSettings.enabled) return false; const advisorModel = agent.state.model; const contextWindow = advisorModel.contextWindow ?? 0; if (contextWindow <= 0) return false; const messages = agent.state.messages; const estimateOptions = { excludeEncryptedReasoning: true } as const; let storedConversationTokens = 0; for (const message of messages) { storedConversationTokens += estimateTokens(message, estimateOptions); } // Provider usage (including cache reads and generated output) is the // trustworthy anchor for accumulated context. Add only the trailing incoming // delta to that arm. Floor it by a full local estimate — fixed advisor system // prompt, tool schemas, stored messages, and incoming delta — so provider // under-reporting or payload transforms cannot suppress maintenance. const providerContextTokens = this.#estimateAdvisorContextTokens(messages) + incomingTokens; const localContextTokens = countTokens(agent.state.systemPrompt) + estimateToolSchemaTokens(agent.state.tools) + storedConversationTokens + incomingTokens; const contextTokens = compactionContextTokens(providerContextTokens, localContextTokens); if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) { return false; } // 1. Try promotion first if (await this.#promoteAdvisorContextModel(advisor, advisorModel, signal)) { // Promotion succeeded, check if new model has enough space const newModel = agent.state.model; const newWindow = newModel.contextWindow ?? 0; if (newWindow > 0) { const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings); if (!stillNeedsCompaction) return false; } } // 2. Run compaction on advisor messages const pathEntries: SessionEntry[] = messages.map((message, i) => { const id = `msg-${i}`; const parentId = i > 0 ? `msg-${i - 1}` : null; const timestamp = String(message.timestamp || Date.now()); if (message.role === "compactionSummary") { const advisorSummary = message as AdvisorCompactionSummaryMessage; return { type: "compaction", id, parentId, timestamp, summary: message.summary, shortSummary: message.shortSummary, firstKeptEntryId: advisorSummary.firstKeptEntryId || `msg-${i + 1}`, tokensBefore: message.tokensBefore, } satisfies CompactionEntry; } return { type: "message", id, parentId, timestamp, message, } satisfies SessionMessageEntry; }); const availableModels = this.#host.modelRegistry.getAvailable(); const candidates = this.#host.resolveCompactionModelCandidates(advisorModel, availableModels); if (candidates.length === 0) { // No compaction candidates, fallback to re-prime return true; } const advisorProviderSessionId = getOrCreateAdvisorProviderSessionId( this.#advisorProviderSessionIds, this.#host.sessionId(), advisor.slug, ); const preparation = prepareCompaction(pathEntries, compactionSettings, advisorModel); if (!preparation) { // Cannot prepare compaction, fallback to re-prime return true; } const advisorCompactionThinkingLevel: ThinkingLevel | undefined = agent.state.disableReasoning ? ThinkingLevel.Off : agent.state.thinkingLevel; // Advisor state is in-memory-only, so snapcompact's frame archive has no // stable SessionEntry preserveData slot to carry across future advisor // maintenance runs. Use an LLM summary even when the primary session is // configured for snapcompact. let compactResult: CompactionResult | undefined; let lastError: unknown; let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined; // Instrument the advisor's overflow-compaction one-shot like the primary // compaction path so the advisor model's maintenance call also emits spans. const telemetry = resolveTelemetry(agent.telemetry, advisorProviderSessionId); const codexCompaction = this.#host.createCodexCompactionContext({ trigger: "auto", reason: "context_limit", phase: "pre_turn", }); for (const candidate of candidates) { const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); if (!apiKey) continue; if ( nativeCompactionFailure && (candidate.provider !== nativeCompactionFailure.provider || !shouldUseProviderNativeCompaction(candidate, compactionSettings)) ) { throw nativeCompactionFailure.error; } // The advisor overflow-compaction one-shot bypasses the advisor `Agent`, // so its installed metadata resolver never runs. Emit the same // `metadata.user_id` identity here (resolved per candidate provider, // after the session-sticky credential is selected) so summarization // requests carry the advisor session id like every other advisor call // (issue #6625). const advisorMetadata = advisorProviderSessionId ? buildSessionMetadata(advisorProviderSessionId, candidate.provider, this.#host.modelRegistry.authStorage) : undefined; try { compactResult = await compact( preparation, candidate, this.#host.modelRegistry.resolver(candidate, advisorProviderSessionId), undefined, signal, { thinkingLevel: advisorCompactionThinkingLevel, convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages), telemetry, tools: agent.state.tools, sessionId: advisorProviderSessionId, promptCacheKey: advisorProviderSessionId, metadata: advisorMetadata, providerSessionState: this.#host.providerSessionState, preferWebsockets: this.#host.preferWebsockets, codexCompaction, }, ); break; } catch (error) { if (signal.aborted) throw error; const id = AIError.classify(error, candidate.api); if (error instanceof NativeCompactionError && !AIError.is(id, AIError.Flag.AuthFailed)) { nativeCompactionFailure ??= { error, provider: candidate.provider }; lastError = nativeCompactionFailure.error; continue; } lastError = error; } } if (!compactResult && nativeCompactionFailure) throw nativeCompactionFailure.error; if (!compactResult) { logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) }); return true; } const summary = compactResult.summary; const shortSummary = compactResult.shortSummary; const firstKeptEntryId = compactResult.firstKeptEntryId; const tokensBefore = compactResult.tokensBefore; // The retained messages still carry provider usage from before this // compaction. Record their exact array boundary on the in-memory summary so // only assistants appended afterward can become the next usage anchor. const advisorUsageAnchorStartIndex = preparation.recentMessages.length + 1; const summaryMessage = { ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary), firstKeptEntryId, advisorUsageAnchorStartIndex, } satisfies AdvisorCompactionSummaryMessage; agent.replaceMessages([summaryMessage, ...preparation.recentMessages]); return false; } /** * Prevent advisor notes from starting hidden primary turns while a headless * caller prints and drains the final primary response. */ prepareForHeadlessAdvisorDrain(): void { this.#preserveAdvisorAdvice = true; } async #waitForPendingAdvisorCardEvents(timeoutMs: number): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); while (this.#pendingAdvisorCardEvents.size > 0) { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return false; const settled = Promise.allSettled([...this.#pendingAdvisorCardEvents]).then(() => true as const); const { promise: timedOut, resolve } = Promise.withResolvers(); const timer = setTimeout(() => resolve(false), remainingMs); try { if (!(await Promise.race([settled, timedOut]))) return false; } finally { clearTimeout(timer); } } return true; } /** * Wait for active advisor reviews and their emitted card events before a * headless caller disposes the session. Returns `false` and logs work disposal * will abandon when the shared deadline expires or an advisor fails. */ async waitForAdvisorCatchup(timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; const results = await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(timeoutMs, 1))); const cardEventsCaughtUp = await this.#waitForPendingAdvisorCardEvents(Math.max(0, deadline - Date.now())); const abandoned = this.#advisors.filter( (advisor, index) => results[index] === false && advisor.runtime.backlog > 0, ); if (abandoned.length > 0 || !cardEventsCaughtUp) { logger.warn("advisor shutdown drain incomplete; disposal will abandon reviews or cards", { timeoutMs, advisors: abandoned.map(advisor => ({ name: advisor.name, backlog: advisor.runtime.backlog })), pendingAdvisorCards: this.#pendingAdvisorCardEvents.size, }); return false; } return true; } /** * Enable or disable the advisor for this session. The setting is overridden for the session, * and the runtime is started or stopped to match. * * @returns true when the advisor is actively running after the call. */ setAdvisorEnabled(enabled: boolean): boolean { this.#advisorEnabled = enabled; if (enabled) { if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime(); return this.#buildAdvisorRuntime(true); } this.#stopAdvisorRuntime(); return false; } /** * Toggle the advisor setting and start/stop the runtime accordingly. * * @returns true when the advisor is actively running after the call. */ toggleAdvisorEnabled(): boolean { return this.setAdvisorEnabled(!this.#advisorEnabled); } /** * Replace the live advisor roster from an edited `WATCHDOG.yml` (the `/advisor * configure` save path). Swaps the configs + shared baseline, then rebuilds the * runtimes in place so the change applies without a restart. When the advisor is * disabled the new configs are simply stored for the next enable. * * @returns the number of advisors active after the rebuild. */ applyAdvisorConfigs(advisors: AdvisorConfig[], sharedInstructions: string | undefined): number { this.#advisorConfigs = advisors; this.#advisorSharedInstructions = sharedInstructions; if (!this.#advisorEnabled) return 0; this.#stopAdvisorRuntime(); this.#buildAdvisorRuntime(true); return this.#advisors.length; } /** * Swap the project context prompt handed to advisor sessions after context * files change (`/reload-plugins` edit/disable). Rebuilds live runtimes in * place so the next advisor turn evaluates against the current instructions; * a no-op when the rendered prompt is unchanged. */ setContextPrompt(contextPrompt: string | undefined): void { if (contextPrompt === this.#advisorContextPrompt) return; this.#advisorContextPrompt = contextPrompt; if (!this.#advisorEnabled || this.#advisors.length === 0) return; this.#stopAdvisorRuntime(); this.#buildAdvisorRuntime(true); } /** * Whether the advisor setting is enabled for this session. */ isAdvisorEnabled(): boolean { return this.#advisorEnabled; } /** * Whether a live advisor agent is attached to this session. True only when * `advisor.enabled` is set AND a model resolved for the `advisor` role AND * the advisor applies to this agent kind — i.e. the actual runtime exists, * not merely the setting. Drives the status-line badge and `/dump advisor`. */ isAdvisorActive(): boolean { return this.#advisors.length > 0; } /** * The names of the tools available to advisors this session (the pool a * `/advisor configure` editor lists). The advisor is a full agent, so this is the * full built tool set; a tool whose optional factory returns null (e.g. lsp with * no servers) is absent. */ getAdvisorAvailableToolNames(): string[] { return (this.#advisorTools ?? []).map(tool => tool.name); } /** * The live advisor `Agent`, or `undefined` when no advisor runtime is * attached. Surfaced for diagnostics (`/dump advisor` already serializes * its transcript via {@link formatAdvisorHistoryAsText}) and so callers can * verify the advisor inherits the session's provider-shaping options * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...). */ getAdvisorAgent(): Agent | undefined { return this.#advisors[0]?.agent; } /** * Lightweight advisor status for the status line: returns just the configured * flag and per-advisor name/status without computing token/cost breakdowns. * Avoids re-tokenizing the advisor transcript on every render frame. */ getAdvisorStatusOverview(): { configured: boolean; advisors: { name: string; status: AdvisorRuntimeStatus }[] } { // Override stale map entries with live runtime status: failureNotified/quotaExhausted // clear on reset() but #advisorStatuses lags until the next build. const liveStatusBySlug = new Map(); for (const a of this.#advisors) { liveStatusBySlug.set( a.slug, a.runtime.quotaExhausted ? "quota_exhausted" : a.runtime.failureNotified ? "error" : "running", ); } const advisors = [...this.#advisorStatuses.entries()].map(([slug, { name, status }]) => ({ name, status: liveStatusBySlug.get(slug) ?? status, })); return { configured: this.#advisorEnabled, advisors }; } /** Return cumulative advisor cost recorded for the current session. */ getAdvisorCost(): number { let cost = 0; for (const advisorCost of this.#advisorCosts.values()) cost += advisorCost; return cost; } /** * Return structured advisor stats for the status command and TUI panel. */ getAdvisorStats(): AdvisorStats { const configured = this.#advisorEnabled; const liveAdvisors = this.#advisors.map(a => this.#computeAdvisorStat(a)); // Build the complete roster from #advisorStatuses, which already has the // correct de-duped slugs as keys. Live advisors (from #advisors) carry full // token/cost data; disabled/no-model/quota-exhausted advisors appear as // skeleton entries with just name + status so the status line renders a dot. const liveStatBySlug = new Map(this.#advisors.map((a, i) => [a.slug, liveAdvisors[i]])); const roster: PerAdvisorStat[] = []; for (const [slug, entry] of this.#advisorStatuses) { const live = liveStatBySlug.get(slug); if (live) { roster.push(live); } else { roster.push({ name: entry.name, status: entry.status, contextWindow: 0, contextTokens: 0, tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: this.#advisorCosts.get(slug) ?? 0, messages: { user: 0, assistant: 0, total: 0 }, }); } } const active = liveAdvisors.length > 0; const cost = this.getAdvisorCost(); if (liveAdvisors.length === 0) { return { configured, active, contextWindow: 0, contextTokens: 0, tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost, messages: { user: 0, assistant: 0, total: 0 }, advisors: roster, }; } const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; const messages = { user: 0, assistant: 0, total: 0 }; let contextTokens = 0; for (const a of liveAdvisors) { tokens.input += a.tokens.input; tokens.output += a.tokens.output; tokens.reasoning += a.tokens.reasoning; tokens.cacheRead += a.tokens.cacheRead; tokens.cacheWrite += a.tokens.cacheWrite; tokens.total += a.tokens.total; messages.user += a.messages.user; messages.assistant += a.messages.assistant; messages.total += a.messages.total; contextTokens += a.contextTokens; } // Single-advisor displays read the top-level model/window directly; surface the // first advisor's so the legacy status line stays byte-identical. return { configured, active, model: liveAdvisors[0].model, contextWindow: liveAdvisors[0].contextWindow, contextTokens, tokens, cost, messages, advisors: roster, }; } /** Compute one advisor's stats slice (tokens, cost, context, message counts). */ #computeAdvisorStat(advisor: ActiveAdvisor): PerAdvisorStat { const model = advisor.agent.state.model; const messages = advisor.agent.state.messages; const contextTokens = this.#estimateAdvisorContextTokens(messages); let input = 0; let output = 0; let reasoning = 0; let cacheRead = 0; let cacheWrite = 0; let totalTokens = 0; let user = 0; let assistant = 0; for (const message of messages) { if (message.role === "user") user++; if (message.role === "assistant") { assistant++; const assistantMsg = message as AssistantMessage; input += assistantMsg.usage.input; output += assistantMsg.usage.output; reasoning += assistantMsg.usage.reasoningTokens ?? 0; cacheRead += assistantMsg.usage.cacheRead; cacheWrite += assistantMsg.usage.cacheWrite; totalTokens += assistantMsg.usage.totalTokens; } } return { name: advisor.name, status: advisor.runtime.quotaExhausted ? "quota_exhausted" : advisor.runtime.failureNotified ? "error" : "running", model, contextWindow: model.contextWindow ?? 0, contextTokens, tokens: { input, output, reasoning, cacheRead, cacheWrite, total: totalTokens }, cost: this.#advisorCosts.get(advisor.slug) ?? 0, messages: { user, assistant, total: messages.length }, sessionId: advisor.agent.sessionId, }; } /** * Format a concise advisor status line for ACP/text output. */ formatAdvisorStatus(): string { const stats = this.getAdvisorStats(); if (!stats.active && stats.advisors.length === 0) { return stats.configured ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role." : "Advisor is disabled."; } if (stats.advisors.length <= 1) { const s = stats.advisors[0]; if (s && s.status === "no_model") { return stats.configured ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role." : "Advisor is disabled."; } const contextLine = s.contextWindow > 0 ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)` : `Context: ${s.contextTokens.toLocaleString()} tokens`; const spendParts = [`${s.tokens.input.toLocaleString()} input`, `${s.tokens.output.toLocaleString()} output`]; if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`); if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`); const spendLine = `Spend: ${spendParts.join(", ")}, $${stats.cost.toFixed(4)}`; if (!s.model || s.status !== "running") return `Advisor "${s.name}" is ${s.status.replace("_", " ")}.`; return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`; } const lines = [`Advisors enabled (${stats.advisors.length}):`]; for (const s of stats.advisors) { const ctx = s.contextWindow > 0 ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)` : `${s.contextTokens.toLocaleString()}`; lines.push( ` • ${s.name}${s.model && s.status === "running" ? ` (${s.model.provider}/${s.model.id})` : ` [${s.status}]`} — context ${ctx} tokens, $${s.cost.toFixed(4)}`, ); } lines.push( `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`, ); return lines.join("\n"); } /** * Estimate the advisor's current context tokens. A successful provider usage * after the latest advisor compaction is ground truth for the prompt plus its * generated output; only messages after that anchor are estimated. Usage from * retained pre-compaction messages is stale and must not immediately retrigger * maintenance on the newly compacted context. */ #estimateAdvisorContextTokens(messages: AgentMessage[]): number { let usageAnchorStartIndex = 0; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role !== "compactionSummary") continue; const advisorSummary = message as AdvisorCompactionSummaryMessage; // Advisor summaries created before this runtime-only boundary existed have // no trustworthy way to distinguish retained from newly appended messages. // Conservatively ignore every current assistant until the next compaction. usageAnchorStartIndex = advisorSummary.advisorUsageAnchorStartIndex ?? messages.length; break; } let lastUsageIndex: number | undefined; let lastUsage: AssistantMessage["usage"] | undefined; for (let i = messages.length - 1; i >= usageAnchorStartIndex; i--) { const message = messages[i]; if (message.role !== "assistant") continue; const assistant = message as AssistantMessage; if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) { lastUsage = assistant.usage; lastUsageIndex = i; break; } } const estimateOptions = { excludeEncryptedReasoning: true } as const; if (!lastUsage || lastUsageIndex === undefined) { let estimated = 0; for (const message of messages) { estimated += estimateTokens(message, estimateOptions); } return estimated; } let trailingTokens = 0; for (let i = lastUsageIndex + 1; i < messages.length; i++) { trailingTokens += estimateTokens(messages[i], estimateOptions); } return calculateContextTokens(lastUsage) + trailingTokens; } /** * Format the advisor agent's own transcript (its system prompt, config, * tools, and the markdown deltas it received plus its thinking/advise/read * calls) as plain text — the advisor-side equivalent of * {@link formatSessionAsText}. Returns null when no advisor is active. */ formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null { if (this.#advisors.length === 0) return null; const dump = (a: ActiveAdvisor): string => options?.compact ? formatSessionHistoryMarkdown(a.agent.state.messages) : formatSessionDumpText({ messages: a.agent.state.messages, systemPrompt: a.agent.state.systemPrompt, model: a.agent.state.model, thinkingLevel: a.agent.state.thinkingLevel, tools: a.agent.state.tools, }); if (this.#advisors.length === 1) return dump(this.#advisors[0]); return this.#advisors .map(a => `### Advisor: ${a.name} (${a.agent.state.model.provider}/${a.agent.state.model.id})\n\n${dump(a)}`) .join("\n\n"); } }