d214e2f022
# Conflicts: # packages/coding-agent/package.json # packages/coding-agent/src/config/model-registry.ts # packages/coding-agent/src/main.ts # packages/coding-agent/src/modes/components/assistant-message.ts # packages/coding-agent/src/sdk.ts # packages/tui/src/components/markdown.ts
10446 lines
379 KiB
TypeScript
10446 lines
379 KiB
TypeScript
/**
|
||
* AgentSession - Core abstraction for agent lifecycle and session management.
|
||
*
|
||
* This class is shared between all run modes (interactive, print, rpc).
|
||
* It encapsulates:
|
||
* - Agent state access
|
||
* - Event subscription with automatic session persistence
|
||
* - Model and thinking level management
|
||
* - Compaction (manual and auto)
|
||
* - Bash execution
|
||
* - Session switching and branching
|
||
*
|
||
* Modes use this class and add their own I/O layer on top.
|
||
*/
|
||
|
||
import * as fs from "node:fs";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import { scheduler } from "node:timers/promises";
|
||
import { isPromise } from "node:util/types";
|
||
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||
import {
|
||
type AfterToolCallContext,
|
||
type AfterToolCallResult,
|
||
type Agent,
|
||
AgentBusyError,
|
||
type AgentEvent,
|
||
type AgentMessage,
|
||
type AgentState,
|
||
type AgentTool,
|
||
AppendOnlyContextManager,
|
||
type AsideMessage,
|
||
resolveTelemetry,
|
||
ThinkingLevel,
|
||
} from "@oh-my-pi/pi-agent-core";
|
||
|
||
import {
|
||
AGGRESSIVE_SHAKE_CONFIG,
|
||
AUTO_HANDOFF_THRESHOLD_FOCUS,
|
||
applyShakeRegions,
|
||
CompactionCancelledError,
|
||
type CompactionPreparation,
|
||
type CompactionResult,
|
||
calculateContextTokens,
|
||
calculatePromptTokens,
|
||
collectEntriesForBranchSummary,
|
||
collectShakeRegions,
|
||
compact,
|
||
DEFAULT_SHAKE_CONFIG,
|
||
estimateTokens,
|
||
generateBranchSummary,
|
||
generateHandoff,
|
||
prepareCompaction,
|
||
resolveThresholdTokens,
|
||
type ShakeConfig,
|
||
type ShakeRegion,
|
||
type SummaryOptions,
|
||
shouldCompact,
|
||
} from "@oh-my-pi/pi-agent-core/compaction";
|
||
import {
|
||
DEFAULT_PRUNE_CONFIG,
|
||
pruneSupersededToolResults,
|
||
pruneToolOutputs,
|
||
readToolSupersedeKey,
|
||
} from "@oh-my-pi/pi-agent-core/compaction/pruning";
|
||
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
|
||
import type {
|
||
AssistantMessage,
|
||
Context,
|
||
ImageContent,
|
||
Message,
|
||
MessageAttribution,
|
||
Model,
|
||
ProviderResponseMetadata,
|
||
ProviderSessionState,
|
||
ResetCreditAccountStatus,
|
||
ResetCreditRedeemOutcome,
|
||
ResetCreditTarget,
|
||
ServiceTier,
|
||
SimpleStreamOptions,
|
||
TextContent,
|
||
ToolCall,
|
||
ToolChoice,
|
||
Usage,
|
||
UsageReport,
|
||
} from "@oh-my-pi/pi-ai";
|
||
import {
|
||
calculateRateLimitBackoffMs,
|
||
clearAnthropicFastModeFallback,
|
||
deriveClaudeDeviceId,
|
||
Effort,
|
||
isContextOverflow,
|
||
isUsageLimitError,
|
||
parseRateLimitReason,
|
||
resolveServiceTier,
|
||
streamSimple,
|
||
} from "@oh-my-pi/pi-ai";
|
||
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
||
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
||
import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
|
||
import {
|
||
extractRetryHint,
|
||
getAgentDbPath,
|
||
getInstallId,
|
||
isBunTestRuntime,
|
||
isEnoent,
|
||
isUnexpectedSocketCloseMessage,
|
||
logger,
|
||
prompt,
|
||
relativePathWithinRoot,
|
||
Snowflake,
|
||
} from "@oh-my-pi/pi-utils";
|
||
import * as snapcompact from "@oh-my-pi/snapcompact";
|
||
import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
|
||
import { classifyDifficulty } from "../auto-thinking/classifier";
|
||
import { reset as resetCapabilities } from "../capability";
|
||
import type { Rule } from "../capability/rule";
|
||
import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
|
||
import type { ModelRegistry } from "../config/model-registry";
|
||
import {
|
||
extractExplicitThinkingSelector,
|
||
filterAvailableModelsByEnabledPatterns,
|
||
formatModelSelectorValue,
|
||
formatModelString,
|
||
getModelMatchPreferences,
|
||
parseModelString,
|
||
type ResolvedModelRoleValue,
|
||
resolveModelRoleValue,
|
||
} from "../config/model-resolver";
|
||
import { MODEL_ROLE_IDS } from "../config/model-roles";
|
||
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
||
import type { Settings, SkillsSettings } from "../config/settings";
|
||
import { onAppendOnlyModeChanged } from "../config/settings";
|
||
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
||
import { loadCapability } from "../discovery";
|
||
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
||
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
||
import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
|
||
import {
|
||
disposeKernelSessionsByOwner,
|
||
executePython as executePythonCommand,
|
||
type PythonResult,
|
||
} from "../eval/py/executor";
|
||
import { defaultEvalSessionId } from "../eval/session-id";
|
||
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
|
||
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
|
||
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
|
||
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
|
||
import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper";
|
||
import type {
|
||
ExtensionCommandContext,
|
||
ExtensionRunner,
|
||
ExtensionUIContext,
|
||
MessageEndEvent,
|
||
MessageStartEvent,
|
||
MessageUpdateEvent,
|
||
SessionBeforeBranchResult,
|
||
SessionBeforeCompactResult,
|
||
SessionBeforeSwitchResult,
|
||
SessionBeforeTreeResult,
|
||
ToolExecutionEndEvent,
|
||
ToolExecutionStartEvent,
|
||
ToolExecutionUpdateEvent,
|
||
TreePreparation,
|
||
TurnEndEvent,
|
||
TurnStartEvent,
|
||
} from "../extensibility/extensions";
|
||
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
|
||
import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
|
||
import type { HookCommandContext } from "../extensibility/hooks/types";
|
||
import type { Skill, SkillWarning } from "../extensibility/skills";
|
||
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
||
import { GoalRuntime } from "../goals/runtime";
|
||
import type { Goal, GoalModeState } from "../goals/state";
|
||
import type { HindsightSessionState } from "../hindsight/state";
|
||
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
||
import { IrcBus, type IrcMessage } from "../irc/bus";
|
||
import { resolveMemoryBackend } from "../memory-backend";
|
||
import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
|
||
import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
|
||
import { getCurrentThemeName, theme } from "../modes/theme/theme";
|
||
import { parseTurnBudget } from "../modes/turn-budget";
|
||
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
|
||
import { computeNonMessageTokens } from "../modes/utils/context-usage";
|
||
import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
|
||
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
||
import type { PlanModeState } from "../plan-mode/state";
|
||
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
||
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
||
import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
|
||
import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
|
||
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
|
||
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
|
||
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
|
||
import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
|
||
type: "text",
|
||
};
|
||
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
||
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
|
||
import {
|
||
deobfuscateSessionContext,
|
||
obfuscateProviderContext,
|
||
obfuscateProviderTools,
|
||
type SecretObfuscator,
|
||
} from "../secrets/obfuscator";
|
||
import { invalidateHostMetadata } from "../ssh/connection-manager";
|
||
import {
|
||
AUTO_THINKING,
|
||
type ConfiguredThinkingLevel,
|
||
clampAutoThinkingEffort,
|
||
resolveProvisionalAutoLevel,
|
||
resolveThinkingLevelForModel,
|
||
shouldDisableReasoning,
|
||
toReasoningEffort,
|
||
} from "../thinking";
|
||
import { shutdownTinyTitleClient } from "../tiny/title-client";
|
||
import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
|
||
import {
|
||
buildDiscoverableToolSearchIndex,
|
||
collectDiscoverableTools,
|
||
type DiscoverableTool,
|
||
type DiscoverableToolSearchIndex,
|
||
filterBySource,
|
||
isMCPToolName,
|
||
selectDiscoverableToolNamesByServer,
|
||
} from "../tool-discovery/tool-index";
|
||
import { assertEditableFile } from "../tools/auto-generated-guard";
|
||
import type { CheckpointState } from "../tools/checkpoint";
|
||
import { outputMeta } from "../tools/output-meta";
|
||
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
||
import { isAutoQaEnabled } from "../tools/report-tool-issue";
|
||
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
|
||
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
||
import { clampTimeout } from "../tools/tool-timeouts";
|
||
import { parseCommandArgs } from "../utils/command-args";
|
||
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
||
import { normalizeModelContextImages } from "../utils/image-loading";
|
||
import { buildNamedToolChoice } from "../utils/tool-choice";
|
||
import type { AuthStorage } from "./auth-storage";
|
||
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
|
||
import { defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem } from "./codex-auto-reset";
|
||
import {
|
||
type BashExecutionMessage,
|
||
type CustomMessage,
|
||
convertToLlm,
|
||
type PythonExecutionMessage,
|
||
readPendingDisplayTag,
|
||
SILENT_ABORT_MARKER,
|
||
SKILL_PROMPT_MESSAGE_TYPE,
|
||
stripImagesFromMessage,
|
||
} from "./messages";
|
||
import { formatSessionDumpText } from "./session-dump-format";
|
||
import type {
|
||
BranchSummaryEntry,
|
||
CompactionEntry,
|
||
NewSessionOptions,
|
||
SessionContext,
|
||
SessionManager,
|
||
} from "./session-manager";
|
||
import { EPHEMERAL_MODEL_CHANGE_ROLE, getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
|
||
import type { ShakeMode, ShakeResult } from "./shake-types";
|
||
import { ToolChoiceQueue } from "./tool-choice-queue";
|
||
import { YieldQueue } from "./yield-queue";
|
||
|
||
/** Session-specific events that extend the core AgentEvent */
|
||
export type AgentSessionEvent =
|
||
| AgentEvent
|
||
| {
|
||
type: "auto_compaction_start";
|
||
reason: "threshold" | "overflow" | "idle" | "incomplete";
|
||
action: "context-full" | "handoff" | "shake" | "snapcompact";
|
||
}
|
||
| {
|
||
type: "auto_compaction_end";
|
||
action: "context-full" | "handoff" | "shake" | "snapcompact";
|
||
result: CompactionResult | undefined;
|
||
aborted: boolean;
|
||
willRetry: boolean;
|
||
errorMessage?: string;
|
||
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
|
||
skipped?: boolean;
|
||
}
|
||
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||
| { type: "retry_fallback_applied"; from: string; to: string; role: string }
|
||
| { type: "retry_fallback_succeeded"; model: string; role: string }
|
||
| { type: "ttsr_triggered"; rules: Rule[] }
|
||
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
|
||
| { type: "todo_auto_clear" }
|
||
| { type: "irc_message"; message: CustomMessage }
|
||
| { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
|
||
| {
|
||
type: "thinking_level_changed";
|
||
thinkingLevel: ThinkingLevel | undefined;
|
||
/** The user-configured selector when it differs from the effective level (e.g. `auto`). */
|
||
configured?: ConfiguredThinkingLevel;
|
||
/** The level `auto` resolved to this turn, once classified. */
|
||
resolved?: Effort;
|
||
}
|
||
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
|
||
|
||
/** Listener function for agent session events */
|
||
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||
export type CommandMetadataChangedListener = () => void | Promise<void>;
|
||
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
||
|
||
const EMPTY_STOP_MAX_RETRIES = 3;
|
||
const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
|
||
const RETRY_BACKOFF_JITTER_RATIO = 0.25;
|
||
/**
|
||
* Hysteresis band for the post-shake "did we actually create headroom?" check.
|
||
* Shake counts as having resolved threshold pressure only when residual context
|
||
* lands at or below `SHAKE_RECOVERY_BAND × threshold`. Re-checking against the
|
||
* raw threshold lets shake keep reclaiming a trickle of the previous turn's
|
||
* output and land just under the line every turn, sustaining the auto-continue
|
||
* dead loop reported in #2275.
|
||
*/
|
||
const SHAKE_RECOVERY_BAND = 0.8;
|
||
|
||
function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
|
||
const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
|
||
const jitter = 1 - Math.random() * RETRY_BACKOFF_JITTER_RATIO;
|
||
return cappedDelayMs * jitter;
|
||
}
|
||
|
||
/**
|
||
* Slack added past a sibling credential's block expiry before retrying, so
|
||
* the next getApiKey lands after the block has actually lapsed.
|
||
*/
|
||
const SIBLING_UNBLOCK_BUFFER_MS = 1_000;
|
||
const NON_WHITESPACE_RE = /\S/;
|
||
|
||
function hasNonWhitespace(value: string): boolean {
|
||
return NON_WHITESPACE_RE.test(value);
|
||
}
|
||
|
||
export interface AsyncJobSnapshot {
|
||
running: AsyncJobSnapshotItem[];
|
||
recent: AsyncJobSnapshotItem[];
|
||
delivery: AsyncJobDeliveryState;
|
||
}
|
||
|
||
export type { ShakeMode, ShakeResult };
|
||
|
||
// ============================================================================
|
||
// Types
|
||
// ============================================================================
|
||
|
||
export interface AgentSessionConfig {
|
||
agent: Agent;
|
||
sessionManager: SessionManager;
|
||
settings: Settings;
|
||
/** Whether the caller explicitly requested yolo/auto-approve behavior for this session. */
|
||
autoApprove?: boolean;
|
||
/** Models to cycle through with Ctrl+P (from --models flag) */
|
||
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
|
||
/** Initial session thinking selector. */
|
||
thinkingLevel?: ConfiguredThinkingLevel;
|
||
/** Prompt templates for expansion */
|
||
promptTemplates?: PromptTemplate[];
|
||
/** File-based slash commands for expansion */
|
||
slashCommands?: FileSlashCommand[];
|
||
/** Extension runner (created in main.ts with wrapped tools) */
|
||
extensionRunner?: ExtensionRunner;
|
||
/** Loaded skills (already discovered by SDK) */
|
||
skills?: Skill[];
|
||
/** Skill loading warnings (already captured by SDK) */
|
||
skillWarnings?: SkillWarning[];
|
||
/** Custom commands (TypeScript slash commands) */
|
||
customCommands?: LoadedCustomCommand[];
|
||
skillsSettings?: SkillsSettings;
|
||
/** Model registry for API key resolution and model discovery */
|
||
modelRegistry: ModelRegistry;
|
||
/** Tool registry for LSP and settings */
|
||
toolRegistry?: Map<string, AgentTool>;
|
||
/** Current session pre-LLM message transform pipeline */
|
||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
|
||
/** Provider payload hook used by the active session request path */
|
||
onPayload?: SimpleStreamOptions["onPayload"];
|
||
/** Provider response hook used by the active session request path */
|
||
onResponse?: SimpleStreamOptions["onResponse"];
|
||
/** Raw SSE hook used by the active session request path */
|
||
onSseEvent?: SimpleStreamOptions["onSseEvent"];
|
||
/** Per-session raw SSE diagnostic buffer */
|
||
rawSseDebugBuffer?: RawSseDebugBuffer;
|
||
/** Current session message-to-LLM conversion pipeline */
|
||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||
/** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
|
||
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
|
||
/** Rebuild the SSH tool from current capability discovery results. */
|
||
reloadSshTool?: () => Promise<AgentTool | null>;
|
||
requestedToolNames?: ReadonlySet<string>;
|
||
/**
|
||
* Optional accessor for live MCP server instructions. Read by the session's
|
||
* `rebuildSystemPrompt`-skip optimization to detect server-side instruction
|
||
* changes (e.g. an MCP server upgrade) that would otherwise pass the tool-set
|
||
* signature comparison and silently keep a stale prompt cached.
|
||
*/
|
||
getMcpServerInstructions?: () => Map<string, string> | undefined;
|
||
/** Enable hidden-by-default MCP tool discovery for this session. */
|
||
mcpDiscoveryEnabled?: boolean;
|
||
/** MCP tool names to activate for the current session when discovery mode is enabled. */
|
||
initialSelectedMCPToolNames?: string[];
|
||
/** Whether constructor-provided MCP defaults should be persisted immediately. */
|
||
persistInitialMCPToolSelection?: boolean;
|
||
/** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
|
||
defaultSelectedMCPServerNames?: string[];
|
||
/** MCP tool names that should seed brand-new sessions created from this AgentSession. */
|
||
defaultSelectedMCPToolNames?: string[];
|
||
/** TTSR manager for time-traveling stream rules */
|
||
ttsrManager?: TtsrManager;
|
||
/** Secret obfuscator for deobfuscating streaming edit content */
|
||
obfuscator?: SecretObfuscator;
|
||
/** Inherited eval executor session id from a parent agent. */
|
||
parentEvalSessionId?: string;
|
||
/** Logical owner for retained Python kernels created by this session. */
|
||
evalKernelOwnerId?: string;
|
||
/**
|
||
* AsyncJobManager that this session installed as the process-global instance.
|
||
* Only set for top-level sessions; subagents inherit the parent's manager and
|
||
* **MUST NOT** dispose it on their own teardown.
|
||
*/
|
||
ownedAsyncJobManager?: AsyncJobManager;
|
||
/**
|
||
* AsyncJobManager reachable by this session for scoped job actions.
|
||
*
|
||
* Top-level owners receive their own manager, subagents receive the inherited
|
||
* parent manager, and secondary in-process top-level sessions receive
|
||
* `undefined` so job snapshots and ACP drains cannot observe the primary's
|
||
* state.
|
||
*/
|
||
asyncJobManager?: AsyncJobManager;
|
||
/** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
|
||
agentId?: string;
|
||
/**
|
||
* Override the provider-facing session ID for all API requests from this session.
|
||
* When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
|
||
* SDK callers issue probes / prewarming with an explicit `--provider-session-id`
|
||
* so that credential sticky selection is consistent with the session's streaming calls.
|
||
*/
|
||
providerSessionId?: string;
|
||
}
|
||
|
||
/** Options for AgentSession.prompt() */
|
||
export interface PromptOptions {
|
||
/** Whether to expand file-based prompt templates (default: true) */
|
||
expandPromptTemplates?: boolean;
|
||
/** Image attachments */
|
||
images?: ImageContent[];
|
||
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
|
||
streamingBehavior?: "steer" | "followUp";
|
||
/** Optional tool choice override for the next LLM call. */
|
||
toolChoice?: ToolChoice;
|
||
/** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
|
||
synthetic?: boolean;
|
||
/** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
|
||
attribution?: MessageAttribution;
|
||
/** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
|
||
skipCompactionCheck?: boolean;
|
||
}
|
||
|
||
/** Result from a handoff operation. */
|
||
export interface HandoffResult {
|
||
document: string;
|
||
savedPath?: string;
|
||
}
|
||
|
||
export interface SessionHandoffOptions {
|
||
autoTriggered?: boolean;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
/** Result from cycleModel() */
|
||
export interface ModelCycleResult {
|
||
model: Model;
|
||
thinkingLevel: ThinkingLevel | undefined;
|
||
/** Whether cycling through scoped models (--models flag) or all available */
|
||
isScoped: boolean;
|
||
}
|
||
|
||
/** Result from cycleRoleModels() */
|
||
export interface RoleModelCycleResult {
|
||
model: Model;
|
||
thinkingLevel: ThinkingLevel | undefined;
|
||
role: string;
|
||
}
|
||
|
||
/** A configured role resolved to a concrete model, used by role cycling and
|
||
* the plan-approval model slider. */
|
||
export interface ResolvedRoleModel {
|
||
role: string;
|
||
model: Model;
|
||
thinkingLevel?: ThinkingLevel;
|
||
explicitThinkingLevel: boolean;
|
||
}
|
||
|
||
/** The set of resolvable role models plus the index of the currently active
|
||
* one within {@link ResolvedRoleModel.role} order. */
|
||
export interface RoleModelCycle {
|
||
models: ResolvedRoleModel[];
|
||
currentIndex: number;
|
||
}
|
||
|
||
/** Session statistics for /session command */
|
||
export interface SessionStats {
|
||
sessionFile: string | undefined;
|
||
sessionId: string;
|
||
userMessages: number;
|
||
assistantMessages: number;
|
||
toolCalls: number;
|
||
toolResults: number;
|
||
totalMessages: number;
|
||
tokens: {
|
||
input: number;
|
||
output: number;
|
||
cacheRead: number;
|
||
cacheWrite: number;
|
||
total: number;
|
||
};
|
||
premiumRequests: number;
|
||
cost: number;
|
||
}
|
||
|
||
export interface FreshSessionResult {
|
||
previousSessionId: string;
|
||
sessionId: string;
|
||
closedProviderSessions: number;
|
||
}
|
||
|
||
/** Internal marker for hook messages queued through the agent loop */
|
||
// ============================================================================
|
||
// Constants
|
||
// ============================================================================
|
||
|
||
/** Standard thinking levels */
|
||
|
||
type RetryFallbackChains = Record<string, string[]>;
|
||
|
||
type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
|
||
|
||
interface RetryFallbackSelector {
|
||
raw: string;
|
||
provider: string;
|
||
id: string;
|
||
thinkingLevel: ThinkingLevel | undefined;
|
||
}
|
||
|
||
interface ActiveRetryFallbackState {
|
||
role: string;
|
||
originalSelector: string;
|
||
originalThinkingLevel: ConfiguredThinkingLevel | undefined;
|
||
lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined;
|
||
pinned: boolean;
|
||
}
|
||
|
||
function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
|
||
const trimmed = selector.trim();
|
||
if (!trimmed) return undefined;
|
||
const parsed = parseModelString(trimmed);
|
||
if (!parsed) return undefined;
|
||
return {
|
||
raw: trimmed,
|
||
provider: parsed.provider,
|
||
id: parsed.id,
|
||
thinkingLevel: parsed.thinkingLevel,
|
||
};
|
||
}
|
||
|
||
function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
|
||
const selector = formatModelString(model);
|
||
return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
|
||
}
|
||
|
||
function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
|
||
return `${selector.provider}/${selector.id}`;
|
||
}
|
||
|
||
const EPHEMERAL_REPLY_MAX_BYTES = 4096;
|
||
|
||
/**
|
||
* Collapse degenerate ephemeral replies (/btw, /omfg side-channel turns).
|
||
* Models occasionally loop on a single line (~16 reports of N-times-repeated
|
||
* replies); compress runs longer than 3 down to one instance + `[…N×]`, then
|
||
* cap at 4 KiB so a runaway reply can't flood the channel.
|
||
*/
|
||
function dedupeEphemeralReply(text: string): string {
|
||
if (!text) return text;
|
||
const lines = text.split("\n");
|
||
const out: string[] = [];
|
||
let i = 0;
|
||
while (i < lines.length) {
|
||
let j = i + 1;
|
||
while (j < lines.length && lines[j] === lines[i]) j++;
|
||
const runLen = j - i;
|
||
if (runLen > 3) {
|
||
out.push(lines[i], `[…${runLen}×]`);
|
||
} else {
|
||
for (let k = 0; k < runLen; k++) out.push(lines[i]);
|
||
}
|
||
i = j;
|
||
}
|
||
let result = out.join("\n");
|
||
if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) {
|
||
// Trim by characters until we're under the byte budget — handles multi-byte
|
||
// glyphs at the boundary without splitting them.
|
||
const suffix = "\n[…truncated]";
|
||
const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
|
||
while (Buffer.byteLength(result, "utf8") > budget) {
|
||
result = result.slice(0, -1);
|
||
}
|
||
result += suffix;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Build the per-request `metadata` payload for the Anthropic provider, shaped
|
||
* like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
|
||
* device_id }`) so the backend buckets requests under one session and attributes
|
||
* them to the authenticated OAuth account when available. Resolved at request
|
||
* time so token refreshes and login/logout transitions don't strand a stale
|
||
* account UUID in memory. `account_uuid` and `device_id` are omitted for
|
||
* non-Anthropic providers to avoid leaking the user's Claude identity to
|
||
* third-party APIs (including Anthropic-format-compatible proxies such as
|
||
* cloudflare-ai-gateway or gitlab-duo).
|
||
*
|
||
* `provider` is the target provider string (e.g. `"anthropic"`) and gates the
|
||
* `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them.
|
||
*
|
||
* `sessionId` is forwarded to the auth-storage session-sticky lookup so that
|
||
* multi-credential setups attribute to the same OAuth account used for the
|
||
* actual API request rather than always picking the first credential.
|
||
*
|
||
* `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
|
||
* without a real storage layer still work; the resolver simply skips the lookup
|
||
* and emits `{ session_id }` alone, matching the no-OAuth-credential path.
|
||
*/
|
||
function buildSessionMetadata(
|
||
sessionId: string,
|
||
provider: string,
|
||
authStorage: AuthStorage | undefined,
|
||
): Record<string, unknown> {
|
||
const userId: Record<string, string> = { session_id: sessionId };
|
||
// Only look up account_uuid when the request is going to Anthropic. Injecting
|
||
// a Claude OAuth account_uuid into requests bound for other providers (including
|
||
// Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
|
||
// would leak the user's Anthropic identity to unrelated third-party APIs.
|
||
if (provider === "anthropic") {
|
||
const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
|
||
if (typeof accountUuid === "string" && accountUuid.length > 0) {
|
||
userId.account_uuid = accountUuid;
|
||
// Claude Code's `device_id` is a stable 64-hex account-scoped install
|
||
// identifier. Include both omp's persistent install id and the Claude
|
||
// account UUID so two accounts on the same install do not share a device.
|
||
userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid);
|
||
}
|
||
}
|
||
return { user_id: JSON.stringify(userId) };
|
||
}
|
||
|
||
const noOpUIContext: ExtensionUIContext = {
|
||
select: async (_title, _options, _dialogOptions) => undefined,
|
||
confirm: async (_title, _message, _dialogOptions) => false,
|
||
input: async (_title, _placeholder, _dialogOptions) => undefined,
|
||
notify: () => {},
|
||
onTerminalInput: () => () => {},
|
||
setStatus: () => {},
|
||
setWorkingMessage: () => {},
|
||
setWidget: () => {},
|
||
setTitle: () => {},
|
||
custom: async () => undefined as never,
|
||
setEditorText: () => {},
|
||
pasteToEditor: () => {},
|
||
getEditorText: () => "",
|
||
editor: async () => undefined,
|
||
get theme() {
|
||
return theme;
|
||
},
|
||
getAllThemes: () => Promise.resolve([]),
|
||
getTheme: () => Promise.resolve(undefined),
|
||
setTheme: _theme => Promise.resolve({ success: false, error: "UI not available" }),
|
||
setFooter: () => {},
|
||
setHeader: () => {},
|
||
setEditorComponent: () => {},
|
||
getToolsExpanded: () => false,
|
||
setToolsExpanded: () => {},
|
||
};
|
||
|
||
function createHandoffContext(document: string): string {
|
||
return `<handoff-context>\n${document}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
|
||
}
|
||
|
||
function createHandoffFileName(date = new Date()): string {
|
||
const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
|
||
return `handoff-${fileTimestamp}.md`;
|
||
}
|
||
|
||
// ============================================================================
|
||
// ACP Permission Gate
|
||
// ============================================================================
|
||
|
||
/** Tools that require user permission before execution when an ACP client is connected. */
|
||
const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
|
||
|
||
/** Permission options presented to the client on each gated tool call. */
|
||
const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
|
||
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
|
||
{ optionId: "allow_always", name: "Always allow", kind: "allow_always" },
|
||
{ optionId: "reject_once", name: "Reject", kind: "reject_once" },
|
||
{ optionId: "reject_always", name: "Always reject", kind: "reject_always" },
|
||
];
|
||
|
||
const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
|
||
|
||
function getStringProperty(value: Record<string, unknown>, key: string): string | undefined {
|
||
const candidate = value[key];
|
||
return typeof candidate === "string" ? candidate : undefined;
|
||
}
|
||
|
||
function collectStringPaths(value: unknown): string[] {
|
||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||
}
|
||
|
||
function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; paths: string[] } | undefined {
|
||
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
|
||
const a = args as Record<string, unknown>;
|
||
|
||
const edits = Array.isArray(a.edits) ? a.edits : undefined;
|
||
if (edits) {
|
||
const path = getStringProperty(a, "path");
|
||
if (path) {
|
||
for (const edit of edits) {
|
||
if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
|
||
const op = getStringProperty(edit as Record<string, unknown>, "op");
|
||
if (op === "delete") return { kind: "delete", paths: [path] };
|
||
}
|
||
}
|
||
for (const edit of edits) {
|
||
if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
|
||
const entry = edit as Record<string, unknown>;
|
||
const op = getStringProperty(entry, "op");
|
||
const rename = getStringProperty(entry, "rename");
|
||
if (op !== "create" && rename) return { kind: "move", paths: path ? [path, rename] : [rename] };
|
||
}
|
||
}
|
||
|
||
const input = getStringProperty(a, "input");
|
||
if (input) {
|
||
try {
|
||
const entries = expandApplyPatchToEntries({ input });
|
||
const deleteEntry = entries.find(entry => entry.op === "delete");
|
||
if (deleteEntry) return { kind: "delete", paths: [deleteEntry.path] };
|
||
const moveEntry = entries.find(entry => entry.rename);
|
||
if (moveEntry?.rename) return { kind: "move", paths: [moveEntry.path, moveEntry.rename] };
|
||
} catch {
|
||
// If the edit input is not an apply_patch envelope, it is not a delete/move operation.
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
function getPermissionIntent(
|
||
toolName: string,
|
||
args: unknown,
|
||
): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
|
||
const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
|
||
if (toolName === "bash") {
|
||
const cmd = getStringProperty(a, "command")?.slice(0, 80);
|
||
return { toolName, title: cmd || toolName, cacheKey: toolName };
|
||
}
|
||
if (toolName === "delete") {
|
||
const p = getStringProperty(a, "path");
|
||
return { toolName, title: p ? `Delete ${p}` : toolName, paths: p ? [p] : undefined, cacheKey: toolName };
|
||
}
|
||
if (toolName === "move") {
|
||
const from = getStringProperty(a, "oldPath") ?? getStringProperty(a, "path") ?? getStringProperty(a, "from");
|
||
const to = getStringProperty(a, "newPath") ?? getStringProperty(a, "to") ?? getStringProperty(a, "destination");
|
||
if (from && to) return { toolName, title: `Move ${from} to ${to}`, paths: [from, to], cacheKey: toolName };
|
||
return {
|
||
toolName,
|
||
title: from ? `Move ${from}` : toolName,
|
||
paths: from ? [from] : undefined,
|
||
cacheKey: toolName,
|
||
};
|
||
}
|
||
if (toolName === "edit") {
|
||
const intent = getEditDestructiveIntent(args);
|
||
if (!intent) return undefined;
|
||
if (intent.kind === "delete") {
|
||
return {
|
||
toolName,
|
||
title: `Delete ${intent.paths[0] ?? "edit target"}`,
|
||
paths: intent.paths,
|
||
cacheKey: "edit:delete",
|
||
};
|
||
}
|
||
const from = intent.paths[0];
|
||
const to = intent.paths[1];
|
||
return {
|
||
toolName,
|
||
title: from && to ? `Move ${from} to ${to}` : `Move ${from ?? to ?? "edit target"}`,
|
||
paths: intent.paths,
|
||
cacheKey: "edit:move",
|
||
};
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function extractPermissionLocations(
|
||
args: unknown,
|
||
cwd: string,
|
||
explicitPaths?: string[],
|
||
): { path: string; line?: number }[] {
|
||
if (!args || typeof args !== "object") return [];
|
||
const a = args as Record<string, unknown>;
|
||
const out: { path: string; line?: number }[] = [];
|
||
const pushPath = (value: unknown) => {
|
||
if (typeof value !== "string" || value.length === 0) return;
|
||
// ACP locations carry file paths that the editor host will open or focus;
|
||
// they must be absolute or the client cannot resolve them. Resolve raw
|
||
// tool args (often cwd-relative) against the session cwd before sending.
|
||
let resolved: string;
|
||
try {
|
||
resolved = resolveToCwd(value, cwd);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (out.some(location => location.path === resolved)) return;
|
||
out.push({ path: resolved });
|
||
};
|
||
if (explicitPaths) {
|
||
for (const p of explicitPaths) {
|
||
pushPath(p);
|
||
}
|
||
return out;
|
||
}
|
||
pushPath(a.path);
|
||
pushPath(a.file);
|
||
for (const p of collectStringPaths(a.paths)) {
|
||
pushPath(p);
|
||
}
|
||
pushPath(a.oldPath);
|
||
pushPath(a.newPath);
|
||
pushPath(a.from);
|
||
pushPath(a.to);
|
||
pushPath(a.source);
|
||
pushPath(a.destination);
|
||
return out;
|
||
}
|
||
|
||
// ============================================================================
|
||
// AgentSession Class
|
||
// ============================================================================
|
||
|
||
/** Internal record stored in the steering/followUp display queues. The optional
|
||
* `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
|
||
* custom messages queued during streaming) and is matched by the custom-role
|
||
* `message_start` dequeue branch; user-message pushes leave it undefined and
|
||
* rely on the existing text-equality match. `images` carries the original
|
||
* (pre-normalization) image blocks so queue restoration (Esc / Alt+Up) can
|
||
* hand them back to the editor instead of dropping them. */
|
||
type QueuedDisplayEntry = { text: string; tag?: string; images?: ImageContent[] };
|
||
|
||
/** Entry returned by {@link AgentSession.clearQueue} / {@link AgentSession.popLastQueuedMessage}. */
|
||
export type RestoredQueuedMessage = { text: string; images?: ImageContent[] };
|
||
|
||
export class AgentSession {
|
||
readonly agent: Agent;
|
||
readonly sessionManager: SessionManager;
|
||
readonly settings: Settings;
|
||
readonly yieldQueue: YieldQueue;
|
||
fileSnapshotStore?: InMemorySnapshotStore;
|
||
#autoApprove: boolean;
|
||
|
||
#powerAssertion: MacOSPowerAssertion | undefined;
|
||
|
||
readonly configWarnings: string[] = [];
|
||
|
||
#scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
|
||
/** Effective, metadata-clamped thinking level applied to the agent (never `auto`). */
|
||
#thinkingLevel: ThinkingLevel | undefined;
|
||
/** True when the user configured `auto`; the effective level is resolved per turn. */
|
||
#autoThinking: boolean = false;
|
||
/** The level `auto` last resolved to (for UI); undefined until a turn is classified. */
|
||
#autoResolvedLevel: Effort | undefined;
|
||
#promptTemplates: PromptTemplate[];
|
||
#slashCommands: FileSlashCommand[];
|
||
|
||
// Event subscription state
|
||
#unsubscribeAgent?: () => void;
|
||
#unsubscribeAppendOnly?: () => void;
|
||
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
|
||
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
|
||
#eventListeners: AgentSessionEventListener[] = [];
|
||
#commandMetadataChangedListeners: CommandMetadataChangedListener[] = [];
|
||
|
||
/** Tracks pending steering messages for UI display. Removed when delivered.
|
||
* Entry shape: `{ text }` for plain-text steers (user-message dequeue
|
||
* matches by `.text`); `{ text, tag }` for queued custom messages (skill
|
||
* invocations dispatched while streaming) — the custom-role dequeue
|
||
* matches by `.tag` so duplicate-args queued skills cannot collide. */
|
||
#steeringMessages: QueuedDisplayEntry[] = [];
|
||
/** Tracks pending follow-up messages for UI display. Removed when delivered.
|
||
* See `#steeringMessages` for entry shape. */
|
||
#followUpMessages: QueuedDisplayEntry[] = [];
|
||
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
||
#pendingNextTurnMessages: CustomMessage[] = [];
|
||
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
|
||
#planModeState: PlanModeState | undefined;
|
||
#goalModeState: GoalModeState | undefined;
|
||
#goalRuntime: GoalRuntime;
|
||
#goalTurnCounter = 0;
|
||
#planReferenceSent = false;
|
||
#planReferencePath = "local://PLAN.md";
|
||
#clientBridge: ClientBridge | undefined;
|
||
#allowAcpAgentInitiatedTurns = false;
|
||
/** Per-session memory of allow_always / reject_always decisions for gated tools. */
|
||
#acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
|
||
|
||
// Compaction state
|
||
#compactionAbortController: AbortController | undefined = undefined;
|
||
#autoCompactionAbortController: AbortController | undefined = undefined;
|
||
|
||
// Branch summarization state
|
||
#branchSummaryAbortController: AbortController | undefined = undefined;
|
||
|
||
// Handoff state
|
||
#handoffAbortController: AbortController | undefined = undefined;
|
||
#skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
|
||
|
||
// Retry state
|
||
#retryAbortController: AbortController | undefined = undefined;
|
||
#retryAttempt = 0;
|
||
#retryPromise: Promise<void> | undefined = undefined;
|
||
#retryResolve: (() => void) | undefined = undefined;
|
||
#activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
|
||
// Todo completion reminder state
|
||
#todoReminderCount = 0;
|
||
#todoPhases: TodoPhase[] = [];
|
||
#toolChoiceQueue = new ToolChoiceQueue();
|
||
|
||
// Bash execution state
|
||
#bashAbortControllers = new Set<AbortController>();
|
||
#pendingBashMessages: BashExecutionMessage[] = [];
|
||
|
||
// Python execution state
|
||
#evalAbortControllers = new Set<AbortController>();
|
||
#evalKernelOwnerId: string;
|
||
#parentEvalSessionId: string | undefined;
|
||
/**
|
||
* AsyncJobManager owned by this session (top-level only). Subagents leave
|
||
* this undefined and **MUST NOT** dispose the global instance on teardown.
|
||
*/
|
||
readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
|
||
/**
|
||
* AsyncJobManager scoped to this session for introspection/cancellation.
|
||
*
|
||
* This differs from `#ownedAsyncJobManager`: subagents can inherit a parent
|
||
* manager for their own owner id, while secondary top-level sessions are left
|
||
* undefined to avoid reading the primary's jobs.
|
||
*/
|
||
readonly #asyncJobManager: AsyncJobManager | undefined;
|
||
#pendingPythonMessages: PythonExecutionMessage[] = [];
|
||
#activeEvalExecutions = new Set<Promise<unknown>>();
|
||
#evalExecutionDisposing = false;
|
||
|
||
// Incoming IRC messages received while a turn was streaming; drained as
|
||
// non-interrupting asides at the next step boundary (see the aside provider).
|
||
#pendingIrcAsides: CustomMessage[] = [];
|
||
// Agent identity (registry id) used for IRC routing and job ownership.
|
||
#agentId: string | undefined;
|
||
#providerSessionId: string | undefined;
|
||
#freshProviderSessionId: string | undefined;
|
||
#isDisposed = false;
|
||
// Extension system
|
||
#extensionRunner: ExtensionRunner | undefined = undefined;
|
||
#turnIndex = 0;
|
||
|
||
#skills: Skill[];
|
||
#skillWarnings: SkillWarning[];
|
||
|
||
// Custom commands (TypeScript slash commands)
|
||
#customCommands: LoadedCustomCommand[] = [];
|
||
/** MCP prompt commands (updated dynamically when prompts are loaded) */
|
||
#mcpPromptCommands: LoadedCustomCommand[] = [];
|
||
|
||
#skillsSettings: SkillsSettings | undefined;
|
||
|
||
// Model registry for API key resolution
|
||
#modelRegistry: ModelRegistry;
|
||
|
||
// Tool registry and prompt builder for extensions
|
||
#toolRegistry: Map<string, AgentTool>;
|
||
#transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
|
||
#onPayload: SimpleStreamOptions["onPayload"] | undefined;
|
||
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
|
||
#onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
|
||
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||
#rebuildSystemPrompt:
|
||
| ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
|
||
| undefined;
|
||
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
|
||
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
|
||
#requestedToolNames: ReadonlySet<string> | undefined;
|
||
#baseSystemPrompt: string[];
|
||
/**
|
||
* Signature of the (toolNames, tool descriptions) tuple passed to the most
|
||
* recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
|
||
* when MCP servers reconnect without changing their tool definitions, which is
|
||
* the dominant cause of prompt-cache invalidation in long sessions.
|
||
*/
|
||
#lastAppliedToolSignature: string | undefined;
|
||
/**
|
||
* Model identifier (`provider/id`) currently rendered into `#baseSystemPrompt`.
|
||
* The prompt surfaces the active model to the agent, so a model switch must
|
||
* trigger a rebuild. Compared against the live model after every model change
|
||
* to decide whether the cached prompt is stale.
|
||
*/
|
||
#promptModelKey: string | undefined;
|
||
#mcpDiscoveryEnabled = false;
|
||
#discoverableMCPTools = new Map<string, DiscoverableTool>();
|
||
#selectedMCPToolNames = new Set<string>();
|
||
// Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
|
||
#discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
|
||
#selectedDiscoveredToolNames = new Set<string>();
|
||
#rpcHostToolNames = new Set<string>();
|
||
#defaultSelectedMCPServerNames = new Set<string>();
|
||
#defaultSelectedMCPToolNames = new Set<string>();
|
||
#sessionDefaultSelectedMCPToolNames = new Map<string, string[]>();
|
||
|
||
// TTSR manager for time-traveling stream rules
|
||
#ttsrManager: TtsrManager | undefined = undefined;
|
||
#pendingTtsrInjections: Rule[] = [];
|
||
/** Per-tool TTSR rules whose `interruptMode` opted out of aborting the stream.
|
||
* These are folded into the matched tool call's `toolResult` content as an
|
||
* in-band system reminder, instead of spawning a separate follow-up turn. */
|
||
#perToolTtsrInjections = new Map<string, Rule[]>();
|
||
#ttsrAbortPending = false;
|
||
#ttsrRetryToken = 0;
|
||
#ttsrResumePromise: Promise<void> | undefined = undefined;
|
||
#ttsrResumeResolve: (() => void) | undefined = undefined;
|
||
|
||
/** One-shot flag set in InteractiveMode.#approvePlan(compactBeforeExecute=true)
|
||
* before the plan-mode → compaction transition. Consumed inside
|
||
* #handleAgentEvent for the matching `message_end` + `stopReason: "aborted"`;
|
||
* cleared unconditionally by the caller's `finally` so it cannot leak into
|
||
* later unrelated aborts (e.g. when compaction returns cancelled/failed
|
||
* without producing an aborted message_end). */
|
||
#planCompactAbortPending = false;
|
||
|
||
/** Monotonic counter for `enqueueCustomMessageDisplay` tag generation;
|
||
* combined with `Date.now()` so tags stay unique even across rapid
|
||
* same-tick enqueues. */
|
||
#customDisplayTagCounter = 0;
|
||
#postPromptTasks = new Set<Promise<unknown>>();
|
||
#postPromptTasksPromise: Promise<void> | undefined = undefined;
|
||
#postPromptTasksResolve: (() => void) | undefined = undefined;
|
||
#postPromptTasksAbortController = new AbortController();
|
||
|
||
#streamingEditAbortTriggered = false;
|
||
#streamingEditCheckedLineCounts = new Map<string, number>();
|
||
|
||
#streamingEditPrecheckedToolCallIds = new Set<string>();
|
||
|
||
#streamingEditFileCache = new Map<string, string>();
|
||
#promptInFlightCount = 0;
|
||
// Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
|
||
// Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
|
||
// checks in #handleAgentEvent) still fire on the original schedule — only the
|
||
// `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
|
||
// Cursor exec, TUI listeners) is held back. Without this, a client that resumes
|
||
// on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
|
||
// has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
|
||
// both #endInFlight (normal) and #resetInFlight (abort).
|
||
#pendingAgentEndEmit: AgentSessionEvent | undefined;
|
||
#obfuscator: SecretObfuscator | undefined;
|
||
#checkpointState: CheckpointState | undefined = undefined;
|
||
#pendingRewindReport: string | undefined = undefined;
|
||
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
||
#emptyStopRetryCount = 0;
|
||
#promptGeneration = 0;
|
||
#providerSessionState = new Map<string, ProviderSessionState>();
|
||
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
||
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
||
|
||
#acquirePowerAssertion(): void {
|
||
if (process.platform !== "darwin") return;
|
||
if (isBunTestRuntime()) return;
|
||
if (this.#powerAssertion) return;
|
||
const idle = this.settings.get("power.preventIdleSleep");
|
||
const system = this.settings.get("power.preventSystemSleep");
|
||
const user = this.settings.get("power.declareUserActive");
|
||
const display = this.settings.get("power.preventDisplaySleep");
|
||
// All four off → user opted out; do nothing.
|
||
if (!idle && !system && !user && !display) return;
|
||
try {
|
||
this.#powerAssertion = MacOSPowerAssertion.start({
|
||
reason: "Oh My Pi agent session",
|
||
idle,
|
||
system,
|
||
user,
|
||
display,
|
||
});
|
||
} catch (error) {
|
||
logger.warn("Failed to acquire macOS power assertion", { error: String(error) });
|
||
}
|
||
}
|
||
|
||
#releasePowerAssertion(): void {
|
||
const assertion = this.#powerAssertion;
|
||
this.#powerAssertion = undefined;
|
||
if (!assertion) return;
|
||
try {
|
||
assertion.stop();
|
||
} catch (error) {
|
||
logger.warn("Failed to release macOS power assertion", { error: String(error) });
|
||
}
|
||
}
|
||
|
||
#beginInFlight(): void {
|
||
this.#promptInFlightCount++;
|
||
if (this.#promptInFlightCount === 1) {
|
||
this.#acquirePowerAssertion();
|
||
}
|
||
}
|
||
|
||
#endInFlight(): void {
|
||
this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
|
||
if (this.#promptInFlightCount === 0) {
|
||
this.#releasePowerAssertion();
|
||
this.#flushPendingAgentEnd();
|
||
}
|
||
}
|
||
|
||
#resetInFlight(): void {
|
||
this.#promptInFlightCount = 0;
|
||
this.#releasePowerAssertion();
|
||
this.#flushPendingAgentEnd();
|
||
}
|
||
|
||
#flushPendingAgentEnd(): void {
|
||
const pending = this.#pendingAgentEndEmit;
|
||
if (!pending) return;
|
||
this.#pendingAgentEndEmit = undefined;
|
||
this.#emit(pending);
|
||
}
|
||
|
||
constructor(config: AgentSessionConfig) {
|
||
this.agent = config.agent;
|
||
this.sessionManager = config.sessionManager;
|
||
this.settings = config.settings;
|
||
this.#autoApprove = config.autoApprove === true;
|
||
// Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
|
||
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
||
this.#parentEvalSessionId = config.parentEvalSessionId;
|
||
this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
|
||
this.#asyncJobManager = config.asyncJobManager ?? config.ownedAsyncJobManager;
|
||
this.#scopedModels = config.scopedModels ?? [];
|
||
if (config.thinkingLevel === AUTO_THINKING) {
|
||
// `auto` is session-level: keep the flag and show a provisional concrete
|
||
// level (the agent's initial effort was already set by the caller) until
|
||
// the first user turn is classified.
|
||
this.#autoThinking = true;
|
||
this.#thinkingLevel = resolveProvisionalAutoLevel(this.model);
|
||
} else {
|
||
this.#thinkingLevel = config.thinkingLevel;
|
||
}
|
||
this.#applyThinkingLevelToAgent(this.#thinkingLevel);
|
||
this.#promptTemplates = config.promptTemplates ?? [];
|
||
this.#slashCommands = config.slashCommands ?? [];
|
||
this.#extensionRunner = config.extensionRunner;
|
||
this.#skills = config.skills ?? [];
|
||
this.#skillWarnings = config.skillWarnings ?? [];
|
||
this.#customCommands = config.customCommands ?? [];
|
||
this.#skillsSettings = config.skillsSettings;
|
||
this.#modelRegistry = config.modelRegistry;
|
||
this.#validateRetryFallbackChains();
|
||
this.#toolRegistry = config.toolRegistry ?? new Map();
|
||
this.#requestedToolNames = config.requestedToolNames;
|
||
this.#transformContext = config.transformContext ?? (messages => messages);
|
||
this.#onPayload = config.onPayload;
|
||
this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
|
||
// Avoid wrapping in an `async` closure when no user callback is configured: the
|
||
// outer await on `#onResponse` (provider-response.ts) tolerates a sync void return,
|
||
// and skipping the wrapper drops a per-event `newPromiseCapability` allocation that
|
||
// shows up as ~3.5% self time in streaming profiles.
|
||
const configuredOnResponse = config.onResponse;
|
||
this.#onResponse = configuredOnResponse
|
||
? async (response, model) => {
|
||
this.rawSseDebugBuffer.recordResponse(response, model);
|
||
this.#ingestProviderUsageHeaders(response, model);
|
||
await configuredOnResponse(response, model);
|
||
}
|
||
: (response, model) => {
|
||
this.rawSseDebugBuffer.recordResponse(response, model);
|
||
this.#ingestProviderUsageHeaders(response, model);
|
||
};
|
||
const configuredOnSseEvent = config.onSseEvent;
|
||
this.#onSseEvent = configuredOnSseEvent
|
||
? (event, model) => {
|
||
this.rawSseDebugBuffer.recordEvent(event, model);
|
||
configuredOnSseEvent(event, model);
|
||
}
|
||
: (event, model) => {
|
||
this.rawSseDebugBuffer.recordEvent(event, model);
|
||
};
|
||
this.agent.setProviderResponseInterceptor(this.#onResponse);
|
||
this.agent.setRawSseEventInterceptor(this.#onSseEvent);
|
||
this.yieldQueue = new YieldQueue({
|
||
isStreaming: () => this.isStreaming,
|
||
injectIdle: async messages => {
|
||
const first = messages[0];
|
||
if (!first) return;
|
||
await this.agent.prompt(messages.length === 1 ? first : messages);
|
||
},
|
||
scheduleIdleFlush: run => {
|
||
this.#schedulePostPromptTask(
|
||
async () => {
|
||
await run();
|
||
},
|
||
{ delayMs: 1 },
|
||
);
|
||
},
|
||
});
|
||
// Background-job completions / late diagnostics are pulled into the run at
|
||
// each step boundary as non-interrupting asides (see Agent.getAsideMessages),
|
||
// so they reach the model between requests without waiting for a yield.
|
||
this.agent.setAsideMessageProvider(() => {
|
||
const pendingIrc = this.#pendingIrcAsides;
|
||
this.#pendingIrcAsides = [];
|
||
const thunks: AsideMessage[] = pendingIrc.map(record => () => record);
|
||
thunks.push(...this.yieldQueue.drainLazy());
|
||
return thunks;
|
||
});
|
||
this.#convertToLlm = config.convertToLlm ?? convertToLlm;
|
||
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
||
this.#getMcpServerInstructions = config.getMcpServerInstructions;
|
||
this.#reloadSshTool = config.reloadSshTool;
|
||
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
||
this.#promptModelKey = this.#currentPromptModelKey();
|
||
this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
|
||
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
|
||
this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
|
||
this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []);
|
||
this.#defaultSelectedMCPToolNames = new Set(config.defaultSelectedMCPToolNames ?? []);
|
||
this.#pruneSelectedMCPToolNames();
|
||
const persistedSelectedMCPToolNames = this.buildDisplaySessionContext().selectedMCPToolNames;
|
||
const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
||
const persistInitialMCPToolSelection =
|
||
config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
|
||
if (
|
||
this.#mcpDiscoveryEnabled &&
|
||
persistInitialMCPToolSelection &&
|
||
!this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
|
||
) {
|
||
this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
|
||
}
|
||
this.#rememberSessionDefaultSelectedMCPToolNames(
|
||
this.sessionManager.getSessionFile(),
|
||
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
||
);
|
||
this.#ttsrManager = config.ttsrManager;
|
||
this.#obfuscator = config.obfuscator;
|
||
this.#agentId = config.agentId;
|
||
this.#providerSessionId = config.providerSessionId;
|
||
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
|
||
const event: AgentEvent = {
|
||
type: "message_update",
|
||
message,
|
||
assistantMessageEvent,
|
||
};
|
||
this.#preCacheStreamingEditFile(event);
|
||
this.#maybeAbortStreamingEdit(event);
|
||
});
|
||
// Per-tool TTSR reminders are folded into the matched tool's result via this hook.
|
||
this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
|
||
this.agent.providerSessionState = this.#providerSessionState;
|
||
this.#syncAgentSessionId();
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#goalRuntime = new GoalRuntime({
|
||
getState: () => this.#goalModeState,
|
||
setState: state => {
|
||
this.#goalModeState = state;
|
||
},
|
||
getCurrentUsage: () => {
|
||
const usage = this.getSessionStats().tokens;
|
||
return {
|
||
input: usage.input,
|
||
output: usage.output,
|
||
cacheRead: usage.cacheRead,
|
||
cacheWrite: usage.cacheWrite,
|
||
};
|
||
},
|
||
emit: event => {
|
||
if (event.type === "goal_updated") {
|
||
return this.#emitSessionEvent({ type: "goal_updated", goal: event.goal, state: event.state });
|
||
}
|
||
},
|
||
persist: (mode, state) => {
|
||
if (mode === "none") {
|
||
this.sessionManager.appendModeChange("none");
|
||
} else if (state) {
|
||
this.sessionManager.appendModeChange(mode, { goal: state.goal });
|
||
}
|
||
},
|
||
sendHiddenMessage: async message => {
|
||
await this.sendCustomMessage(
|
||
{
|
||
customType: message.customType,
|
||
content: message.content,
|
||
display: false,
|
||
attribution: "agent",
|
||
},
|
||
{ deliverAs: message.deliverAs },
|
||
);
|
||
},
|
||
});
|
||
|
||
// Always subscribe to agent events for internal handling
|
||
// (session persistence, hooks, auto-compaction, retry logic)
|
||
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
||
// Re-evaluate append-only context mode when the setting changes at runtime.
|
||
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
||
}
|
||
|
||
/** Model registry for API key resolution and model discovery */
|
||
get modelRegistry(): ModelRegistry {
|
||
return this.#modelRegistry;
|
||
}
|
||
|
||
get asyncJobManager(): AsyncJobManager | undefined {
|
||
return this.#asyncJobManager;
|
||
}
|
||
|
||
getAgentId(): string | undefined {
|
||
return this.#agentId;
|
||
}
|
||
|
||
/** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
|
||
nextToolChoice(): ToolChoice | undefined {
|
||
return this.#toolChoiceQueue.nextToolChoice();
|
||
}
|
||
|
||
/**
|
||
* Force the next model call to target a specific active tool, then terminate
|
||
* the agent loop. Pushes a two-step sequence [forced, "none"] so the model
|
||
* calls exactly the forced tool once and then cannot call another.
|
||
*/
|
||
setForcedToolChoice(toolName: string): void {
|
||
if (!this.getActiveToolNames().includes(toolName)) {
|
||
throw new Error(`Tool "${toolName}" is not currently active.`);
|
||
}
|
||
|
||
const forced = buildNamedToolChoice(toolName, this.model);
|
||
if (!forced || typeof forced === "string") {
|
||
throw new Error("Current model does not support forcing a specific tool.");
|
||
}
|
||
|
||
this.#toolChoiceQueue.pushSequence([forced, "none"], {
|
||
label: "user-force",
|
||
onRejected: () => "requeue",
|
||
});
|
||
}
|
||
|
||
/** The tool-choice queue: forces forthcoming tool invocations and carries handlers. */
|
||
get toolChoiceQueue(): ToolChoiceQueue {
|
||
return this.#toolChoiceQueue;
|
||
}
|
||
|
||
/** Peek the in-flight directive's invocation handler for use by the resolve tool. */
|
||
peekQueueInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
|
||
return this.#toolChoiceQueue.peekInFlightInvoker();
|
||
}
|
||
|
||
/** Standing (long-lived) handler the `resolve` tool falls back to when no
|
||
* queue invoker is in flight. Used by plan mode so the agent can submit
|
||
* approval via `resolve` without forcing the tool choice every turn. */
|
||
#standingResolveHandler: ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
||
|
||
peekStandingResolveHandler(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
|
||
return this.#standingResolveHandler;
|
||
}
|
||
|
||
setStandingResolveHandler(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void {
|
||
this.#standingResolveHandler = handler ?? undefined;
|
||
}
|
||
|
||
#sessionSwitchReconciler: (() => Promise<void>) | undefined;
|
||
|
||
setSessionSwitchReconciler(reconciler: (() => Promise<void>) | null): void {
|
||
this.#sessionSwitchReconciler = reconciler ?? undefined;
|
||
}
|
||
|
||
/** Provider-scoped mutable state store for transport/session caches. */
|
||
get providerSessionState(): Map<string, ProviderSessionState> {
|
||
return this.#providerSessionState;
|
||
}
|
||
|
||
getHindsightSessionState(): HindsightSessionState | undefined {
|
||
return this.#hindsightSessionState;
|
||
}
|
||
|
||
setHindsightSessionState(state: HindsightSessionState | undefined): HindsightSessionState | undefined {
|
||
const previous = this.#hindsightSessionState;
|
||
this.#hindsightSessionState = state;
|
||
return previous;
|
||
}
|
||
|
||
getMnemopiSessionState(): MnemopiSessionState | undefined {
|
||
return getMnemopiSessionState(this);
|
||
}
|
||
|
||
/** TTSR manager for time-traveling stream rules */
|
||
get ttsrManager(): TtsrManager | undefined {
|
||
return this.#ttsrManager;
|
||
}
|
||
|
||
/** Whether a TTSR abort is pending (stream was aborted to inject rules) */
|
||
get isTtsrAbortPending(): boolean {
|
||
return this.#ttsrAbortPending;
|
||
}
|
||
|
||
/** Whether the plan-mode → compaction transition's expected internal abort is
|
||
* pending. Consumed by `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER`
|
||
* on the next aborted assistant message_end; cleared unconditionally by
|
||
* `InteractiveMode.#approvePlan`'s `finally` block. */
|
||
get isPlanCompactAbortPending(): boolean {
|
||
return this.#planCompactAbortPending;
|
||
}
|
||
|
||
/** Arm the silent-abort marker for the next aborted assistant message_end.
|
||
* Caller MUST clear via `clearPlanCompactAbortPending()` in a `finally`
|
||
* to guarantee no leak. */
|
||
markPlanCompactAbortPending(): void {
|
||
this.#planCompactAbortPending = true;
|
||
}
|
||
|
||
/** Unconditionally clear the silent-abort flag. Idempotent: safe when the
|
||
* flag was never set OR was already consumed by `#handleAgentEvent`. */
|
||
clearPlanCompactAbortPending(): void {
|
||
this.#planCompactAbortPending = false;
|
||
}
|
||
|
||
/** Register a compact display string for a custom message that the caller is
|
||
* about to dispatch via `promptCustomMessage` / `sendCustomMessage`.
|
||
* Returns a stable tag the caller MUST embed in
|
||
* `CustomMessage.details.__pendingDisplayTag` so the agent-side
|
||
* `message_start` handler can remove the matching display entry when the
|
||
* queued message is consumed.
|
||
*
|
||
* Does NOT push to the agent's steering/followUp queue — that happens
|
||
* separately inside `sendCustomMessage`. */
|
||
enqueueCustomMessageDisplay(text: string, mode: "steer" | "followUp"): string {
|
||
const tag = `omp-cmd-${Date.now()}-${++this.#customDisplayTagCounter}`;
|
||
const displayText = text.trim();
|
||
if (!displayText) return tag;
|
||
const entry: QueuedDisplayEntry = { text: displayText, tag };
|
||
if (mode === "steer") {
|
||
this.#steeringMessages.push(entry);
|
||
} else {
|
||
this.#followUpMessages.push(entry);
|
||
}
|
||
return tag;
|
||
}
|
||
|
||
getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
|
||
const manager = this.#asyncJobManager;
|
||
if (!manager) return null;
|
||
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
|
||
const running = manager.getRunningJobs(ownerFilter).map(job => ({
|
||
id: job.id,
|
||
type: job.type,
|
||
status: job.status,
|
||
label: job.label,
|
||
startTime: job.startTime,
|
||
}));
|
||
const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
|
||
id: job.id,
|
||
type: job.type,
|
||
status: job.status,
|
||
label: job.label,
|
||
startTime: job.startTime,
|
||
}));
|
||
const delivery = manager.getDeliveryState(ownerFilter);
|
||
return { running, recent, delivery };
|
||
}
|
||
|
||
/**
|
||
* Cancel async jobs registered by *this* agent only. Used by lifecycle
|
||
* transitions (newSession, switchSession, handoff, dispose) so a subagent
|
||
* cleans up its own background work without touching its parent's jobs.
|
||
*
|
||
* Cancellation runs against this session's scoped manager. Subagents have
|
||
* unique agent ids and inherit the parent's manager to clean up their own
|
||
* jobs. A secondary in-process top-level session gets no scoped manager,
|
||
* because it defaults to `MAIN_AGENT_ID`; reaching through the global
|
||
* singleton would tear down the owning primary session's bash/task jobs at
|
||
* dispose time (issue #1923).
|
||
*
|
||
* No-op when no manager is reachable or this session has no agent id.
|
||
*/
|
||
#cancelOwnAsyncJobs(): void {
|
||
if (!this.#agentId) return;
|
||
const manager = this.#asyncJobManager;
|
||
manager?.cancelAll({ ownerId: this.#agentId });
|
||
}
|
||
|
||
// =========================================================================
|
||
// Event Subscription
|
||
// =========================================================================
|
||
|
||
/** Emit an event to all listeners */
|
||
#emit(event: AgentSessionEvent): void {
|
||
// Copy array before iteration to avoid mutation during iteration.
|
||
const listeners = [...this.#eventListeners];
|
||
for (const l of listeners) {
|
||
try {
|
||
const result = l(event) as unknown;
|
||
// Listener may be an async function whose returned Promise we don't await;
|
||
// attach a catch so a rejection does not become an unhandled rejection.
|
||
if (isPromise(result)) {
|
||
result.catch(err => {
|
||
logger.warn("AgentSession listener rejected", {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
});
|
||
}
|
||
} catch (err) {
|
||
logger.warn("AgentSession listener threw", {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Emit a UI-only notice to the session. Surfaces in interactive mode as a
|
||
* `showWarning` / `showError` / `showStatus` line; non-interactive modes
|
||
* receive the event through the normal subscribe stream.
|
||
*
|
||
* Notices are NOT added to agent state and never reach the LLM — use this
|
||
* for out-of-band conditions the user should see but the model shouldn't
|
||
* react to (e.g. background queue flush failures).
|
||
*/
|
||
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void {
|
||
this.#emit({ type: "notice", level, message, source });
|
||
}
|
||
|
||
#queuedExtensionEvents: Promise<void> = Promise.resolve();
|
||
|
||
#queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
|
||
const emit = async () => {
|
||
await this.#emitExtensionEvent(event);
|
||
};
|
||
const queued = this.#queuedExtensionEvents.then(emit, emit);
|
||
this.#queuedExtensionEvents = queued.catch(() => {});
|
||
return queued;
|
||
}
|
||
|
||
async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
|
||
if (event.type === "message_update") {
|
||
this.#emit(event);
|
||
void this.#queueExtensionEvent(event);
|
||
return;
|
||
}
|
||
await this.#emitExtensionEvent(event);
|
||
// Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
|
||
// (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
|
||
// emitting while #promptInFlightCount > 0 lets a client fire its next
|
||
// `prompt` into a session that still reports isStreaming === true. Flush
|
||
// happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
|
||
// an auto-compaction turn that starts before the original prompt unwinds)
|
||
// supersedes the pending one, which is what subscribers want — they only
|
||
// care about the final settle.
|
||
if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
|
||
this.#pendingAgentEndEmit = event;
|
||
return;
|
||
}
|
||
this.#emit(event);
|
||
}
|
||
|
||
// Track last assistant message for auto-compaction check
|
||
#lastAssistantMessage: AssistantMessage | undefined = undefined;
|
||
|
||
/** Internal handler for agent events - shared by subscribe and reconnect */
|
||
#handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
||
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
|
||
// This ensures the UI sees the updated queue state
|
||
if (event.type === "message_start" && event.message.role === "user") {
|
||
const messageText = this.#getUserMessageText(event.message);
|
||
if (messageText) {
|
||
// Check steering queue first (match by .text on tagged records)
|
||
const steeringIndex = this.#steeringMessages.findIndex(e => e.text === messageText);
|
||
if (steeringIndex !== -1) {
|
||
this.#steeringMessages.splice(steeringIndex, 1);
|
||
} else {
|
||
// Check follow-up queue
|
||
const followUpIndex = this.#followUpMessages.findIndex(e => e.text === messageText);
|
||
if (followUpIndex !== -1) {
|
||
this.#followUpMessages.splice(followUpIndex, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tag-based dequeue for custom messages (skills queued via promptCustomMessage).
|
||
// The InputController attached a stable tag via CustomMessage.details when it
|
||
// registered the display chip; pull it back here to remove the matching entry
|
||
// from the pending bar atomically with the agent's queue consumption. Match by
|
||
// tag (not text) — two queued skills with identical args cannot collide.
|
||
if (event.type === "message_start" && event.message.role === "custom") {
|
||
const tag = readPendingDisplayTag(event.message.details);
|
||
if (tag) {
|
||
const steerIdx = this.#steeringMessages.findIndex(e => e.tag === tag);
|
||
if (steerIdx !== -1) {
|
||
this.#steeringMessages.splice(steerIdx, 1);
|
||
} else {
|
||
const followUpIdx = this.#followUpMessages.findIndex(e => e.tag === tag);
|
||
if (followUpIdx !== -1) {
|
||
this.#followUpMessages.splice(followUpIdx, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
|
||
// persisted message BEFORE the obfuscator's display-side copy below.
|
||
// Invariant (must hold across refactors): this branch precedes the
|
||
// `let displayEvent = event; ... displayEvent = { ...event, message: { ...message, content: deobfuscated } }`
|
||
// block. After stamping, both `displayEvent.message` (via the spread)
|
||
// and `event.message` (in-place mutation, used by SessionManager
|
||
// persistence) carry the marker, guaranteeing streaming render and
|
||
// history replay branch identically. The one-shot flag is consumed
|
||
// here, scoped strictly to this aborted message_end; the caller's
|
||
// `finally` (in `InteractiveMode.#approvePlan`) clears it again on
|
||
// every terminal compaction outcome (`ok` / `cancelled` / `failed` /
|
||
// throw) so a leaked flag cannot silence a later unrelated abort.
|
||
if (
|
||
event.type === "message_end" &&
|
||
event.message.role === "assistant" &&
|
||
event.message.stopReason === "aborted" &&
|
||
this.#planCompactAbortPending
|
||
) {
|
||
(event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
|
||
this.#planCompactAbortPending = false;
|
||
}
|
||
|
||
// Deobfuscate assistant message content for display emission — the LLM echoes back
|
||
// obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
|
||
// values. The original event.message stays obfuscated so the persistence path below
|
||
// writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound
|
||
// traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
|
||
let displayEvent: AgentEvent = event;
|
||
const obfuscator = this.#obfuscator;
|
||
if (obfuscator && event.type === "message_end" && event.message.role === "assistant") {
|
||
const message = event.message;
|
||
const deobfuscatedContent = obfuscator.deobfuscateObject(message.content);
|
||
if (deobfuscatedContent !== message.content) {
|
||
displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } };
|
||
}
|
||
}
|
||
|
||
if (event.type === "turn_start") {
|
||
const usage = this.getSessionStats().tokens;
|
||
this.#goalRuntime.onTurnStart(`turn-${++this.#goalTurnCounter}`, {
|
||
input: usage.input,
|
||
output: usage.output,
|
||
cacheRead: usage.cacheRead,
|
||
cacheWrite: usage.cacheWrite,
|
||
});
|
||
}
|
||
|
||
await this.#emitSessionEvent(displayEvent);
|
||
|
||
if (event.type === "turn_start") {
|
||
this.#resetStreamingEditState();
|
||
// TTSR: Reset buffer on turn start
|
||
this.#ttsrManager?.resetBuffer();
|
||
}
|
||
|
||
// TTSR: Increment message count on turn end (for repeat-after-gap tracking)
|
||
if (event.type === "turn_end" && this.#ttsrManager) {
|
||
this.#ttsrManager.incrementMessageCount();
|
||
}
|
||
// Finalize the tool-choice queue's in-flight yield after tools have executed.
|
||
// This must happen at turn_end (not message_end) because onInvoked handlers
|
||
// run during tool execution, which happens between message_end and turn_end.
|
||
if (event.type === "turn_end" && this.#toolChoiceQueue.hasInFlight) {
|
||
const msg = event.message as AssistantMessage;
|
||
if (msg.stopReason === "aborted" || msg.stopReason === "error") {
|
||
this.#toolChoiceQueue.reject(msg.stopReason === "error" ? "error" : "aborted");
|
||
} else {
|
||
this.#toolChoiceQueue.resolve();
|
||
}
|
||
}
|
||
if (event.type === "tool_execution_end") {
|
||
if (event.toolName === "goal") {
|
||
await this.#goalRuntime.onGoalToolCompleted();
|
||
} else {
|
||
await this.#goalRuntime.onToolCompleted(event.toolName);
|
||
}
|
||
}
|
||
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
|
||
this.#lastSuccessfulYieldToolCallId = event.toolCallId;
|
||
}
|
||
if (event.type === "turn_end" && this.#pendingRewindReport) {
|
||
const report = this.#pendingRewindReport;
|
||
this.#pendingRewindReport = undefined;
|
||
await this.#applyRewind(report);
|
||
}
|
||
|
||
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
|
||
if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
|
||
const assistantEvent = event.assistantMessageEvent;
|
||
let matchContext: TtsrMatchContext | undefined;
|
||
let streamingToolCall: ToolCall | undefined;
|
||
|
||
if (assistantEvent.type === "text_delta") {
|
||
matchContext = { source: "text" };
|
||
} else if (assistantEvent.type === "thinking_delta") {
|
||
matchContext = { source: "thinking" };
|
||
} else if (assistantEvent.type === "toolcall_delta") {
|
||
streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
|
||
matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
|
||
}
|
||
|
||
if (matchContext && "delta" in assistantEvent) {
|
||
const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined;
|
||
const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
|
||
if (matches.length > 0 && this.#handleTtsrMatches(matches, matchContext, targetMessageTimestamp)) {
|
||
return;
|
||
}
|
||
// ast-grep `astCondition` rules match against the reconstructed edit/write
|
||
// snapshot, which only exists for tool argument streams. The native worker
|
||
// call is async, so this path is awaited and self-throttled by the manager.
|
||
if (matchContext.source === "tool" && this.#ttsrManager?.hasAstRules()) {
|
||
const astMatches = await this.#checkTtsrAstStream(matchContext, streamingToolCall);
|
||
if (astMatches.length > 0 && this.#handleTtsrMatches(astMatches, matchContext, targetMessageTimestamp)) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (
|
||
event.type === "message_update" &&
|
||
(event.assistantMessageEvent.type === "toolcall_start" ||
|
||
event.assistantMessageEvent.type === "toolcall_delta" ||
|
||
event.assistantMessageEvent.type === "toolcall_end")
|
||
) {
|
||
void this.#preCacheStreamingEditFile(event);
|
||
}
|
||
|
||
if (
|
||
event.type === "message_update" &&
|
||
(event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
|
||
) {
|
||
this.#maybeAbortStreamingEdit(event);
|
||
}
|
||
|
||
// Handle session persistence
|
||
if (event.type === "message_end") {
|
||
// Check if this is a hook/custom message
|
||
if (event.message.role === "hookMessage" || event.message.role === "custom") {
|
||
// Persist as CustomMessageEntry
|
||
this.sessionManager.appendCustomMessageEntry(
|
||
event.message.customType,
|
||
event.message.content,
|
||
event.message.display,
|
||
event.message.details,
|
||
event.message.attribution ?? "agent",
|
||
);
|
||
if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
|
||
this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
|
||
}
|
||
} else if (
|
||
event.message.role === "user" ||
|
||
event.message.role === "developer" ||
|
||
event.message.role === "assistant" ||
|
||
event.message.role === "toolResult" ||
|
||
event.message.role === "fileMention"
|
||
) {
|
||
// Regular LLM message - persist as SessionMessageEntry
|
||
this.sessionManager.appendMessage(event.message);
|
||
}
|
||
// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
|
||
|
||
// Track assistant message for auto-compaction (checked on agent_end)
|
||
if (event.message.role === "assistant") {
|
||
this.#lastAssistantMessage = event.message;
|
||
const assistantMsg = event.message as AssistantMessage;
|
||
const currentGrantsAnthropicPriority =
|
||
this.serviceTier === "priority" || this.serviceTier === "claude-only";
|
||
if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
|
||
this.setServiceTier(undefined);
|
||
this.emitNotice(
|
||
"warning",
|
||
"Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
|
||
"priority",
|
||
);
|
||
}
|
||
// Resolve TTSR resume gate before checking for new deferred injections.
|
||
// Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming
|
||
// edit) also produces stopReason === "aborted" but has no continuation coming.
|
||
// Only skip when #ttsrAbortPending is true (TTSR continuation is imminent).
|
||
if (!this.#ttsrAbortPending) {
|
||
this.#resolveTtsrResume();
|
||
}
|
||
this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
|
||
if (this.#handoffAbortController) {
|
||
this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
|
||
}
|
||
if (
|
||
assistantMsg.stopReason !== "error" &&
|
||
assistantMsg.stopReason !== "aborted" &&
|
||
!this.#isEmptyAssistantStop(assistantMsg) &&
|
||
this.#retryAttempt > 0
|
||
) {
|
||
if (this.#activeRetryFallback && this.model) {
|
||
await this.#emitSessionEvent({
|
||
type: "retry_fallback_succeeded",
|
||
model: formatRetryFallbackSelector(this.model, this.thinkingLevel),
|
||
role: this.#activeRetryFallback.role,
|
||
});
|
||
}
|
||
await this.#emitSessionEvent({
|
||
type: "auto_retry_end",
|
||
success: true,
|
||
attempt: this.#retryAttempt,
|
||
});
|
||
this.#retryAttempt = 0;
|
||
}
|
||
}
|
||
|
||
if (event.message.role === "toolResult") {
|
||
const { toolName, details, isError, content } = event.message as {
|
||
toolName?: string;
|
||
details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
|
||
isError?: boolean;
|
||
content?: Array<TextContent | ImageContent>;
|
||
};
|
||
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
||
if (toolName === "edit" && details?.path) {
|
||
this.#invalidateFileCacheForPath(details.path);
|
||
}
|
||
if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
|
||
this.setTodoPhases(details.phases);
|
||
}
|
||
if (toolName === "todo" && isError) {
|
||
const errorText = content?.find(part => part.type === "text")?.text;
|
||
const reminderText = [
|
||
"<system-reminder>",
|
||
"todo failed, so todo progress is not visible to the user.",
|
||
errorText ? `Failure: ${errorText}` : "Failure: todo returned an error.",
|
||
"Fix the todo payload and call todo again before continuing.",
|
||
"</system-reminder>",
|
||
].join("\n");
|
||
await this.sendCustomMessage(
|
||
{
|
||
customType: "todo-error-reminder",
|
||
content: reminderText,
|
||
display: false,
|
||
details: { toolName, errorText },
|
||
},
|
||
{ deliverAs: "nextTurn" },
|
||
);
|
||
}
|
||
if (toolName === "checkpoint" && !isError) {
|
||
const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
|
||
this.#checkpointState = {
|
||
checkpointMessageCount: this.agent.state.messages.length,
|
||
checkpointEntryId,
|
||
startedAt: details?.startedAt ?? new Date().toISOString(),
|
||
};
|
||
this.#pendingRewindReport = undefined;
|
||
}
|
||
if (toolName === "rewind" && !isError && this.#checkpointState) {
|
||
const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
|
||
const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? "";
|
||
const report = detailReport || textReport;
|
||
if (report.length > 0) {
|
||
this.#pendingRewindReport = report;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check auto-retry and auto-compaction after agent completes
|
||
if (event.type === "agent_end") {
|
||
const usage = this.getSessionStats().tokens;
|
||
await this.#goalRuntime.onAgentEnd({
|
||
currentUsage: {
|
||
input: usage.input,
|
||
output: usage.output,
|
||
cacheRead: usage.cacheRead,
|
||
cacheWrite: usage.cacheWrite,
|
||
},
|
||
});
|
||
const fallbackAssistant = [...event.messages]
|
||
.reverse()
|
||
.find((message): message is AssistantMessage => message.role === "assistant");
|
||
const msg = this.#lastAssistantMessage ?? fallbackAssistant;
|
||
this.#lastAssistantMessage = undefined;
|
||
if (!msg) {
|
||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||
return;
|
||
}
|
||
|
||
// Invalidate GitHub Copilot credentials on auth failure so stale tokens
|
||
// aren't reused on the next request
|
||
if (
|
||
msg.stopReason === "error" &&
|
||
msg.provider === "github-copilot" &&
|
||
msg.errorMessage?.includes("GitHub Copilot authentication failed")
|
||
) {
|
||
await this.#modelRegistry.authStorage.remove("github-copilot");
|
||
}
|
||
|
||
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
|
||
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||
return;
|
||
}
|
||
|
||
if (this.#assistantEndedWithSuccessfulYield(msg)) {
|
||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||
return;
|
||
}
|
||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||
|
||
if (await this.#handleEmptyAssistantStop(msg)) {
|
||
return;
|
||
}
|
||
|
||
// Check for retryable errors first (overloaded, rate limit, server errors)
|
||
if (this.#isRetryableError(msg)) {
|
||
const didRetry = await this.#handleRetryableError(msg);
|
||
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
||
}
|
||
this.#resolveRetry();
|
||
|
||
const compactionTask = this.#checkCompaction(msg);
|
||
this.#trackPostPromptTask(compactionTask);
|
||
const compactionDeferredHandoff = await compactionTask;
|
||
// Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
|
||
const hasToolCalls = msg.content.some(content => content.type === "toolCall");
|
||
if (hasToolCalls) {
|
||
return;
|
||
}
|
||
// When checkCompaction scheduled a deferred handoff, skip the rewind/todo passes:
|
||
// any reminder we append here would race the handoff's session reset, and
|
||
// #scheduleAgentContinue would start a fresh streaming turn alongside the handoff
|
||
// LLM call (visible as "Auto-handoff" loader + an assistant message still streaming).
|
||
if (compactionDeferredHandoff) {
|
||
return;
|
||
}
|
||
if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
|
||
if (this.#enforceRewindBeforeYield()) {
|
||
return;
|
||
}
|
||
await this.#checkTodoCompletion();
|
||
}
|
||
}
|
||
};
|
||
|
||
/** Resolve the pending retry promise */
|
||
#resolveRetry(): void {
|
||
if (this.#retryResolve) {
|
||
this.#retryResolve();
|
||
this.#retryResolve = undefined;
|
||
this.#retryPromise = undefined;
|
||
}
|
||
}
|
||
|
||
/** Create the TTSR resume gate promise if one doesn't already exist. */
|
||
#ensureTtsrResumePromise(): void {
|
||
if (this.#ttsrResumePromise) return;
|
||
const { promise, resolve } = Promise.withResolvers<void>();
|
||
this.#ttsrResumePromise = promise;
|
||
this.#ttsrResumeResolve = resolve;
|
||
}
|
||
|
||
/** Resolve and clear the TTSR resume gate. */
|
||
#resolveTtsrResume(): void {
|
||
if (!this.#ttsrResumeResolve) return;
|
||
this.#ttsrResumeResolve();
|
||
this.#ttsrResumeResolve = undefined;
|
||
this.#ttsrResumePromise = undefined;
|
||
}
|
||
|
||
#ensurePostPromptTasksPromise(): void {
|
||
if (this.#postPromptTasksPromise) return;
|
||
const { promise, resolve } = Promise.withResolvers<void>();
|
||
this.#postPromptTasksPromise = promise;
|
||
this.#postPromptTasksResolve = resolve;
|
||
}
|
||
|
||
#resolvePostPromptTasks(): void {
|
||
if (!this.#postPromptTasksResolve) return;
|
||
this.#postPromptTasksResolve();
|
||
this.#postPromptTasksResolve = undefined;
|
||
this.#postPromptTasksPromise = undefined;
|
||
}
|
||
|
||
#trackPostPromptTask(task: Promise<unknown>): void {
|
||
this.#postPromptTasks.add(task);
|
||
this.#ensurePostPromptTasksPromise();
|
||
void task
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
this.#postPromptTasks.delete(task);
|
||
if (this.#postPromptTasks.size === 0) {
|
||
this.#resolvePostPromptTasks();
|
||
}
|
||
});
|
||
}
|
||
|
||
#schedulePostPromptTask(
|
||
task: (signal: AbortSignal) => Promise<void>,
|
||
options?: { delayMs?: number; generation?: number; onSkip?: () => void },
|
||
): void {
|
||
const delayMs = options?.delayMs ?? 0;
|
||
const signal = this.#postPromptTasksAbortController.signal;
|
||
const scheduled = (async () => {
|
||
if (delayMs > 0) {
|
||
try {
|
||
await scheduler.wait(delayMs, { signal });
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
if (signal.aborted) {
|
||
options?.onSkip?.();
|
||
return;
|
||
}
|
||
if (options?.generation !== undefined && this.#promptGeneration !== options.generation) {
|
||
options.onSkip?.();
|
||
return;
|
||
}
|
||
await task(signal);
|
||
})();
|
||
this.#trackPostPromptTask(scheduled);
|
||
}
|
||
|
||
#scheduleAgentContinue(options?: {
|
||
delayMs?: number;
|
||
generation?: number;
|
||
shouldContinue?: () => boolean;
|
||
onSkip?: () => void;
|
||
onError?: () => void;
|
||
}): void {
|
||
this.#schedulePostPromptTask(
|
||
async () => {
|
||
// Defense in depth: if compaction/handoff slipped onto the post-prompt queue
|
||
// alongside us (e.g. via a scheduler we don't own), refuse to start a fresh
|
||
// streaming turn — agent.continue() here would race the handoff's session
|
||
// reset. The first-class fix is in #checkCompaction/the agent_end handler,
|
||
// but this guard catches anything that bypasses that path.
|
||
if (this.isCompacting || this.isGeneratingHandoff) {
|
||
options?.onSkip?.();
|
||
return;
|
||
}
|
||
if (options?.shouldContinue && !options.shouldContinue()) {
|
||
options?.onSkip?.();
|
||
return;
|
||
}
|
||
try {
|
||
await this.#maybeRestoreRetryFallbackPrimary();
|
||
await this.agent.continue();
|
||
} catch (error) {
|
||
logger.warn("agent.continue failed after scheduling", {
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
options?.onError?.();
|
||
}
|
||
},
|
||
{
|
||
delayMs: options?.delayMs,
|
||
generation: options?.generation,
|
||
onSkip: options?.onSkip,
|
||
},
|
||
);
|
||
}
|
||
|
||
#scheduleAutoContinuePrompt(generation: number): void {
|
||
const continuePrompt = async () => {
|
||
await this.#promptWithMessage(
|
||
{
|
||
role: "developer",
|
||
content: [{ type: "text", text: autoContinuePrompt }],
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
},
|
||
autoContinuePrompt,
|
||
{ skipPostPromptRecoveryWait: true },
|
||
);
|
||
};
|
||
this.#schedulePostPromptTask(
|
||
async signal => {
|
||
await Promise.resolve();
|
||
if (signal.aborted) return;
|
||
await continuePrompt();
|
||
},
|
||
{ generation },
|
||
);
|
||
}
|
||
|
||
async #cancelPostPromptTasks(): Promise<void> {
|
||
this.#postPromptTasksAbortController.abort();
|
||
this.#postPromptTasksAbortController = new AbortController();
|
||
this.#resolveTtsrResume();
|
||
|
||
const pendingTasks = Array.from(this.#postPromptTasks);
|
||
if (pendingTasks.length === 0) {
|
||
this.#resolvePostPromptTasks();
|
||
return;
|
||
}
|
||
|
||
await Promise.allSettled(pendingTasks);
|
||
if (this.#postPromptTasks.size === 0) {
|
||
this.#resolvePostPromptTasks();
|
||
}
|
||
}
|
||
/**
|
||
* Wait for retry, TTSR resume, and any background continuation to settle.
|
||
* Loops because a TTSR continuation can trigger a retry (or vice-versa),
|
||
* and fire-and-forget `agent.continue()` may still be streaming after
|
||
* the TTSR resume gate resolves.
|
||
*/
|
||
async #waitForPostPromptRecovery(): Promise<void> {
|
||
while (true) {
|
||
if (this.#retryPromise) {
|
||
await this.#retryPromise;
|
||
continue;
|
||
}
|
||
if (this.#ttsrResumePromise) {
|
||
await this.#ttsrResumePromise;
|
||
continue;
|
||
}
|
||
if (this.#postPromptTasksPromise) {
|
||
await this.#postPromptTasksPromise;
|
||
continue;
|
||
}
|
||
// Tracked post-prompt tasks cover deferred continuations scheduled from
|
||
// event handlers. Keep the streaming fallback for direct agent activity
|
||
// outside the scheduler.
|
||
if (this.agent.state.isStreaming) {
|
||
await this.agent.waitForIdle();
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
#formatTtsrAbortReason(rules: Rule[]): string {
|
||
const label = rules.length === 1 ? "rule" : "rules";
|
||
const ruleNames = rules.map(rule => rule.name).join(", ");
|
||
return `TTSR matched ${label}: ${ruleNames}`;
|
||
}
|
||
|
||
/** Get TTSR injection payload and clear pending injections. */
|
||
#getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
|
||
if (this.#pendingTtsrInjections.length === 0) return undefined;
|
||
const rules = this.#pendingTtsrInjections;
|
||
const content = rules
|
||
.map(r =>
|
||
prompt.render(ttsrInterruptTemplate, {
|
||
name: r.name,
|
||
path: this.#displayRulePath(r.path),
|
||
content: r.content,
|
||
}),
|
||
)
|
||
.join("\n\n");
|
||
this.#pendingTtsrInjections = [];
|
||
return { content, rules };
|
||
}
|
||
|
||
/**
|
||
* Render a rule's file path for model-facing TTSR injections without leaking
|
||
* the absolute home directory: cwd-relative when the rule lives in the
|
||
* project, `~`-relative when it lives under home, else the raw path.
|
||
*/
|
||
#displayRulePath(rulePath: string): string {
|
||
const cwdRel =
|
||
relativePathWithinRoot(this.sessionManager.getCwd(), rulePath) ??
|
||
this.#displayPathWithinRoot(this.sessionManager.getCwd(), rulePath);
|
||
if (cwdRel) return cwdRel;
|
||
const homeRel = relativePathWithinRoot(os.homedir(), rulePath);
|
||
if (homeRel) return `~/${homeRel}`;
|
||
return rulePath;
|
||
}
|
||
|
||
#displayPathWithinRoot(root: string, candidate: string): string | null {
|
||
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
||
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : null;
|
||
}
|
||
|
||
#addPendingTtsrInjections(rules: Rule[]): void {
|
||
const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
|
||
for (const rule of rules) {
|
||
if (seen.has(rule.name)) continue;
|
||
this.#pendingTtsrInjections.push(rule);
|
||
seen.add(rule.name);
|
||
}
|
||
}
|
||
|
||
/** Tool-call id whose argument deltas triggered a TTSR match, when known. */
|
||
#extractTtsrToolCallId(matchContext: TtsrMatchContext): string | undefined {
|
||
if (matchContext.source !== "tool") return undefined;
|
||
const key = matchContext.streamKey;
|
||
if (typeof key !== "string" || !key.startsWith("toolcall:")) return undefined;
|
||
const id = key.slice("toolcall:".length);
|
||
return id.length > 0 ? id : undefined;
|
||
}
|
||
|
||
#addPerToolTtsrInjections(toolCallId: string, rules: Rule[]): void {
|
||
const bucket = this.#perToolTtsrInjections.get(toolCallId) ?? [];
|
||
const seen = new Set(bucket.map(rule => rule.name));
|
||
// Dedupe against rules already bucketed for other tool calls in this
|
||
// same assistant message so one rule attaches to exactly one tool call.
|
||
const claimedElsewhere = new Set<string>();
|
||
for (const [otherId, otherBucket] of this.#perToolTtsrInjections) {
|
||
if (otherId === toolCallId) continue;
|
||
for (const rule of otherBucket) claimedElsewhere.add(rule.name);
|
||
}
|
||
const newlyAdded: string[] = [];
|
||
for (const rule of rules) {
|
||
if (seen.has(rule.name) || claimedElsewhere.has(rule.name)) continue;
|
||
bucket.push(rule);
|
||
seen.add(rule.name);
|
||
newlyAdded.push(rule.name);
|
||
}
|
||
if (bucket.length === 0) return;
|
||
this.#perToolTtsrInjections.set(toolCallId, bucket);
|
||
// Claim the rules in the TTSR manager so subsequent deltas in this same
|
||
// turn (e.g. a sibling tool call's argument stream) don't re-match them.
|
||
// Persistence still happens in #ttsrAfterToolCall when the tool actually
|
||
// produces a result we can fold the reminder into.
|
||
if (newlyAdded.length > 0) {
|
||
this.#ttsrManager?.markInjectedByNames(newlyAdded);
|
||
}
|
||
}
|
||
|
||
/** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */
|
||
#ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
|
||
const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id);
|
||
if (!rules || rules.length === 0) return undefined;
|
||
this.#perToolTtsrInjections.delete(ctx.toolCall.id);
|
||
const reminder = rules
|
||
.map(r =>
|
||
prompt.render(ttsrToolReminderTemplate, {
|
||
name: r.name,
|
||
path: this.#displayRulePath(r.path),
|
||
content: r.content,
|
||
}),
|
||
)
|
||
.join("\n\n");
|
||
// The TTSR manager was already claimed at bucket time; only persistence remains.
|
||
const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
|
||
if (ruleNames.length > 0) {
|
||
this.sessionManager.appendTtsrInjection(ruleNames);
|
||
}
|
||
return {
|
||
content: [{ type: "text", text: reminder }, ...ctx.result.content],
|
||
};
|
||
}
|
||
|
||
#extractTtsrRuleNames(details: unknown): string[] {
|
||
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
||
return [];
|
||
}
|
||
const rules = (details as { rules?: unknown }).rules;
|
||
if (!Array.isArray(rules)) {
|
||
return [];
|
||
}
|
||
return rules.filter((ruleName): ruleName is string => typeof ruleName === "string");
|
||
}
|
||
|
||
#markTtsrInjected(ruleNames: string[]): void {
|
||
const uniqueRuleNames = Array.from(
|
||
new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
|
||
);
|
||
if (uniqueRuleNames.length === 0) {
|
||
return;
|
||
}
|
||
this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
|
||
this.sessionManager.appendTtsrInjection(uniqueRuleNames);
|
||
}
|
||
|
||
#findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
|
||
const messages = this.agent.state.messages;
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i];
|
||
if (message.role !== "assistant") {
|
||
continue;
|
||
}
|
||
if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
|
||
return i;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
#shouldInterruptForTtsrMatch(matches: Rule[], matchContext: TtsrMatchContext): boolean {
|
||
const globalMode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
|
||
for (const rule of matches) {
|
||
const mode = rule.interruptMode ?? globalMode;
|
||
if (mode === "never") continue;
|
||
if (mode === "prose-only" && (matchContext.source === "text" || matchContext.source === "thinking"))
|
||
return true;
|
||
if (mode === "tool-only" && matchContext.source === "tool") return true;
|
||
if (mode === "always") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
#queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
|
||
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
|
||
// Tools that hadn't started by abort/error will never produce results to
|
||
// fold injections into — drop their stale per-tool entries.
|
||
this.#perToolTtsrInjections.clear();
|
||
}
|
||
if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
|
||
return;
|
||
}
|
||
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
|
||
this.#pendingTtsrInjections = [];
|
||
return;
|
||
}
|
||
|
||
const injection = this.#getTtsrInjectionContent();
|
||
if (!injection) {
|
||
return;
|
||
}
|
||
this.agent.followUp({
|
||
role: "custom",
|
||
customType: "ttsr-injection",
|
||
content: injection.content,
|
||
display: false,
|
||
details: { rules: injection.rules.map(rule => rule.name) },
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
this.#ensureTtsrResumePromise();
|
||
// Mark as injected after this custom message is delivered and persisted (handled in message_end).
|
||
// followUp() only enqueues; resume on the next tick once streaming settles.
|
||
this.#scheduleAgentContinue({
|
||
delayMs: 1,
|
||
generation: this.#promptGeneration,
|
||
onSkip: () => {
|
||
this.#resolveTtsrResume();
|
||
},
|
||
shouldContinue: () => {
|
||
if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
|
||
this.#resolveTtsrResume();
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
onError: () => {
|
||
this.#resolveTtsrResume();
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Extract the tool-call block a toolcall_delta event refers to, if present. */
|
||
#getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
|
||
if (message.role !== "assistant") {
|
||
return undefined;
|
||
}
|
||
|
||
const content = message.content;
|
||
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
|
||
return undefined;
|
||
}
|
||
|
||
const block = content[contentIndex];
|
||
if (!block || typeof block !== "object" || block.type !== "toolCall") {
|
||
return undefined;
|
||
}
|
||
|
||
return block as ToolCall;
|
||
}
|
||
|
||
/** Build TTSR match context for tool call argument deltas. */
|
||
#getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
|
||
const context: TtsrMatchContext = { source: "tool" };
|
||
if (!toolCall) {
|
||
return context;
|
||
}
|
||
|
||
context.toolName = toolCall.name;
|
||
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
||
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
|
||
return context;
|
||
}
|
||
|
||
/**
|
||
* Match a stream delta against TTSR rules.
|
||
*
|
||
* Tool argument streams prefer the tool's `matcherDigest` normalization — the
|
||
* real content the call introduces — over the raw argument delta, so rule
|
||
* conditions written against source text keep working regardless of the
|
||
* tool's wire format (hashline patches, JSON-escaped strings, ...).
|
||
*/
|
||
#checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
|
||
const manager = this.#ttsrManager;
|
||
if (!manager) {
|
||
return [];
|
||
}
|
||
const digest = this.#resolveTtsrMatcherDigest(toolCall);
|
||
if (digest !== undefined) {
|
||
return manager.checkSnapshot(digest, matchContext);
|
||
}
|
||
return manager.checkDelta(delta, matchContext);
|
||
}
|
||
|
||
/** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
|
||
#resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
|
||
if (!toolCall) {
|
||
return undefined;
|
||
}
|
||
const tools = this.agent.state.tools;
|
||
const tool =
|
||
tools.find(t => t.name === toolCall.name) ??
|
||
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
|
||
return tool?.matcherDigest?.(toolCall.arguments ?? {});
|
||
}
|
||
|
||
/**
|
||
* Match ast-grep `astCondition` rules against the reconstructed tool snapshot.
|
||
*
|
||
* Only edit/write tool streams expose a `matcherDigest`, which is the real source
|
||
* the call introduces; AST matching needs that (and a language inferred from the
|
||
* path argument), so non-digest streams never produce AST matches.
|
||
*/
|
||
async #checkTtsrAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise<Rule[]> {
|
||
const manager = this.#ttsrManager;
|
||
if (!manager) {
|
||
return [];
|
||
}
|
||
const digest = this.#resolveTtsrMatcherDigest(toolCall);
|
||
if (digest === undefined) {
|
||
return [];
|
||
}
|
||
return manager.checkAstSnapshot(digest, matchContext);
|
||
}
|
||
|
||
/**
|
||
* Route TTSR matches to either a per-tool injection or a stream-interrupting
|
||
* retry. Returns true when the stream was aborted and the caller should stop
|
||
* processing this event.
|
||
*/
|
||
#handleTtsrMatches(
|
||
matches: Rule[],
|
||
matchContext: TtsrMatchContext,
|
||
targetMessageTimestamp: number | undefined,
|
||
): boolean {
|
||
// Decide first: a non-interrupting tool-source match attaches to the
|
||
// specific tool call's result instead of driving a loop-wide follow-up.
|
||
const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
|
||
const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
|
||
if (perToolId) {
|
||
this.#addPerToolTtsrInjections(perToolId, matches);
|
||
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
|
||
return false;
|
||
}
|
||
|
||
// Queue rules for injection; mark as injected only after successful enqueue.
|
||
this.#addPendingTtsrInjections(matches);
|
||
if (!shouldInterrupt) {
|
||
return false;
|
||
}
|
||
|
||
// Abort the stream immediately — do not gate on extension callbacks
|
||
this.#ttsrAbortPending = true;
|
||
this.#ensureTtsrResumePromise();
|
||
this.agent.abort(this.#formatTtsrAbortReason(matches));
|
||
// Notify extensions (fire-and-forget, does not block abort)
|
||
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
|
||
// Schedule retry after a short delay
|
||
const retryToken = ++this.#ttsrRetryToken;
|
||
const generation = this.#promptGeneration;
|
||
this.#schedulePostPromptTask(
|
||
async () => {
|
||
if (this.#ttsrRetryToken !== retryToken) {
|
||
this.#resolveTtsrResume();
|
||
return;
|
||
}
|
||
|
||
const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
|
||
if (!this.#ttsrAbortPending || this.#promptGeneration !== generation || targetAssistantIndex === -1) {
|
||
this.#ttsrAbortPending = false;
|
||
this.#pendingTtsrInjections = [];
|
||
this.#perToolTtsrInjections.clear();
|
||
this.#resolveTtsrResume();
|
||
return;
|
||
}
|
||
this.#ttsrAbortPending = false;
|
||
this.#perToolTtsrInjections.clear();
|
||
const ttsrSettings = this.#ttsrManager?.getSettings();
|
||
if (ttsrSettings?.contextMode === "discard") {
|
||
// Remove the partial/aborted assistant turn from agent state
|
||
this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
|
||
}
|
||
// Inject TTSR rules as system reminder before retry
|
||
const injection = this.#getTtsrInjectionContent();
|
||
if (injection) {
|
||
const details = { rules: injection.rules.map(rule => rule.name) };
|
||
this.agent.appendMessage({
|
||
role: "custom",
|
||
customType: "ttsr-injection",
|
||
content: injection.content,
|
||
display: false,
|
||
details,
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
this.sessionManager.appendCustomMessageEntry(
|
||
"ttsr-injection",
|
||
injection.content,
|
||
false,
|
||
details,
|
||
"agent",
|
||
);
|
||
this.#markTtsrInjected(details.rules);
|
||
}
|
||
try {
|
||
await this.agent.continue();
|
||
} catch {
|
||
this.#resolveTtsrResume();
|
||
}
|
||
},
|
||
{ delayMs: 50 },
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
|
||
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
|
||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||
return undefined;
|
||
}
|
||
|
||
const rawPaths: string[] = [];
|
||
for (const [key, value] of Object.entries(args)) {
|
||
const normalizedKey = key.toLowerCase();
|
||
if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
|
||
rawPaths.push(value);
|
||
continue;
|
||
}
|
||
if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
|
||
for (const candidate of value) {
|
||
if (typeof candidate === "string") {
|
||
rawPaths.push(candidate);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue));
|
||
if (normalizedPaths.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
return Array.from(new Set(normalizedPaths));
|
||
}
|
||
|
||
/** Convert a path argument into stable relative/absolute candidates for glob checks. */
|
||
#normalizeTtsrPathCandidates(rawPath: string): string[] {
|
||
const trimmed = rawPath.trim();
|
||
if (trimmed.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const normalizedInput = trimmed.replaceAll("\\", "/");
|
||
const candidates = new Set<string>([normalizedInput]);
|
||
if (normalizedInput.startsWith("./")) {
|
||
candidates.add(normalizedInput.slice(2));
|
||
}
|
||
|
||
const cwd = this.sessionManager.getCwd();
|
||
const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
|
||
candidates.add(absolutePath.replaceAll("\\", "/"));
|
||
|
||
const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/");
|
||
if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") {
|
||
candidates.add(relativePath);
|
||
}
|
||
|
||
return Array.from(candidates);
|
||
}
|
||
/** Extract text content from a message */
|
||
#getUserMessageText(message: Message): string {
|
||
if (message.role !== "user") return "";
|
||
const content = message.content;
|
||
if (typeof content === "string") return content;
|
||
const textBlocks = content.filter(c => c.type === "text");
|
||
const text = textBlocks.map(c => (c as TextContent).text).join("");
|
||
if (text.length > 0) return text;
|
||
const hasImages = content.some(c => c.type === "image");
|
||
return hasImages ? "[Image]" : "";
|
||
}
|
||
|
||
/** Find the last assistant message in agent state (including aborted ones) */
|
||
#findLastAssistantMessage(): AssistantMessage | undefined {
|
||
const messages = this.agent.state.messages;
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const msg = messages[i];
|
||
if (msg.role === "assistant") {
|
||
return msg as AssistantMessage;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
#resetStreamingEditState(): void {
|
||
this.#streamingEditAbortTriggered = false;
|
||
this.#streamingEditCheckedLineCounts.clear();
|
||
this.#streamingEditPrecheckedToolCallIds.clear();
|
||
this.#streamingEditFileCache.clear();
|
||
}
|
||
|
||
#getStreamingEditToolCall(event: AgentEvent):
|
||
| {
|
||
toolCall: ToolCall;
|
||
path: string;
|
||
resolvedPath: string;
|
||
diff?: string;
|
||
op?: string;
|
||
rename?: string;
|
||
}
|
||
| undefined {
|
||
if (event.type !== "message_update") return undefined;
|
||
if (event.message.role !== "assistant") return undefined;
|
||
|
||
const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
|
||
const messageContent = event.message.content;
|
||
if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) {
|
||
return undefined;
|
||
}
|
||
|
||
const toolCall = messageContent[contentIndex] as ToolCall;
|
||
if (toolCall.name !== "edit") return undefined;
|
||
|
||
const args = toolCall.arguments;
|
||
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
|
||
if ("old_text" in args || "new_text" in args) return undefined;
|
||
|
||
const path = typeof args.path === "string" ? args.path : undefined;
|
||
if (!path) return undefined;
|
||
|
||
// `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
|
||
// on-disk artifacts path; pre-caching works as long as we ask the
|
||
// local-protocol handler. Other internal-scheme URLs (agent://, skill://,
|
||
// rule://, mcp://, artifact://) have no stable filesystem representation;
|
||
// skip pre-cache entirely for those — the edit tool itself will reject
|
||
// them through its normal dispatch path.
|
||
const resolvedPath = this.#resolveSessionFsPath(path);
|
||
if (resolvedPath === undefined) return undefined;
|
||
|
||
return {
|
||
toolCall,
|
||
path,
|
||
resolvedPath,
|
||
diff: typeof args.diff === "string" ? args.diff : undefined,
|
||
op: typeof args.op === "string" ? args.op : undefined,
|
||
rename: typeof args.rename === "string" ? args.rename : undefined,
|
||
};
|
||
}
|
||
|
||
#lastStreamingEditToolCallId: string | undefined;
|
||
#abortStreamingEditForAutoGeneratedPath(toolCall: ToolCall, path: string, resolvedPath: string): void {
|
||
if (this.#lastStreamingEditToolCallId === toolCall.id) return;
|
||
this.#lastStreamingEditToolCallId = toolCall.id;
|
||
void assertEditableFile(resolvedPath, path).catch(err => {
|
||
// peekFile and other I/O can reject with ENOENT, etc. Only ToolError means
|
||
// auto-generated detection; other failures are left for the edit tool.
|
||
if (!(err instanceof ToolError)) return;
|
||
if (this.#lastStreamingEditToolCallId !== toolCall.id) return;
|
||
|
||
if (!this.#streamingEditAbortTriggered) {
|
||
this.#streamingEditAbortTriggered = true;
|
||
logger.warn("Streaming edit aborted due to auto-generated file guard", {
|
||
toolCallId: toolCall.id,
|
||
path,
|
||
});
|
||
this.agent.abort();
|
||
}
|
||
});
|
||
}
|
||
|
||
#preCacheStreamingEditFile(event: AgentEvent): void {
|
||
if (this.#streamingEditAbortTriggered) return;
|
||
if (event.type !== "message_update") return;
|
||
|
||
const assistantEvent = event.assistantMessageEvent;
|
||
if (
|
||
assistantEvent.type !== "toolcall_start" &&
|
||
assistantEvent.type !== "toolcall_delta" &&
|
||
assistantEvent.type !== "toolcall_end"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const streamingEdit = this.#getStreamingEditToolCall(event);
|
||
if (!streamingEdit) return;
|
||
|
||
// The auto-generated guard runs unconditionally: editing a generated file
|
||
// is never the user's intent, and the cost of a false-positive abort is one
|
||
// wasted turn vs. silently corrupting a regenerated source.
|
||
const shouldCheckAutoGenerated =
|
||
!streamingEdit.toolCall.id || !this.#streamingEditPrecheckedToolCallIds.has(streamingEdit.toolCall.id);
|
||
if (shouldCheckAutoGenerated) {
|
||
if (streamingEdit.toolCall.id) {
|
||
this.#streamingEditPrecheckedToolCallIds.add(streamingEdit.toolCall.id);
|
||
}
|
||
this.#abortStreamingEditForAutoGeneratedPath(
|
||
streamingEdit.toolCall,
|
||
streamingEdit.path,
|
||
streamingEdit.resolvedPath,
|
||
);
|
||
}
|
||
|
||
// File-cache priming feeds #maybeAbortStreamingEdit's removed-lines check,
|
||
// which is the optional patch-preview verification gated by
|
||
// edit.streamingAbort. Skip the read when the setting is off.
|
||
if (this.settings.get("edit.streamingAbort")) {
|
||
this.#ensureFileCache(streamingEdit.resolvedPath);
|
||
}
|
||
}
|
||
|
||
#ensureFileCache(resolvedPath: string): void {
|
||
if (this.#streamingEditFileCache.has(resolvedPath)) return;
|
||
|
||
try {
|
||
const rawText = fs.readFileSync(resolvedPath, "utf-8");
|
||
const { text } = stripBom(rawText);
|
||
this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
||
} catch {
|
||
// Don't cache on read errors (including ENOENT) - let the edit tool handle them
|
||
}
|
||
}
|
||
|
||
/** Invalidate cache for a file after an edit completes to prevent stale data */
|
||
#invalidateFileCacheForPath(filePath: string): void {
|
||
const resolvedPath = this.#resolveSessionFsPath(filePath);
|
||
if (resolvedPath === undefined) return;
|
||
this.#streamingEditFileCache.delete(resolvedPath);
|
||
}
|
||
|
||
/**
|
||
* Resolve a path supplied to a tool to a real filesystem path.
|
||
*
|
||
* - `local://` URLs route through the local-protocol handler so they map
|
||
* onto the session's on-disk artifacts directory; pre-caching, ENOENT
|
||
* handling, and post-edit invalidation all work normally.
|
||
* - Other internal-scheme URLs (agent://, skill://, rule://, mcp://,
|
||
* artifact://) have no stable filesystem path; this returns `undefined`
|
||
* so callers skip filesystem-only operations.
|
||
* - Cwd-relative and absolute paths resolve via `resolveToCwd`.
|
||
*/
|
||
#resolveSessionFsPath(filePath: string): string | undefined {
|
||
const normalized = normalizeLocalScheme(filePath);
|
||
if (normalized.startsWith("local:")) {
|
||
return resolveLocalUrlToPath(normalized, this.#localProtocolOptions());
|
||
}
|
||
if (
|
||
normalized.startsWith("agent://") ||
|
||
normalized.startsWith("skill://") ||
|
||
normalized.startsWith("rule://") ||
|
||
normalized.startsWith("mcp://") ||
|
||
normalized.startsWith("artifact://")
|
||
) {
|
||
return undefined;
|
||
}
|
||
return resolveToCwd(normalized, this.sessionManager.getCwd());
|
||
}
|
||
|
||
#localProtocolOptions(): LocalProtocolOptions {
|
||
return {
|
||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||
getSessionId: () => this.sessionManager.getSessionId(),
|
||
};
|
||
}
|
||
|
||
#maybeAbortStreamingEdit(event: AgentEvent): void {
|
||
if (!this.settings.get("edit.streamingAbort")) return;
|
||
if (this.#streamingEditAbortTriggered) return;
|
||
if (event.type !== "message_update") return;
|
||
|
||
const assistantEvent = event.assistantMessageEvent;
|
||
if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
|
||
|
||
const streamingEdit = this.#getStreamingEditToolCall(event);
|
||
if (!streamingEdit?.toolCall.id) return;
|
||
|
||
const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit;
|
||
if (!diff) return;
|
||
if (op && op !== "update") return;
|
||
|
||
if (!diff.includes("\n")) return;
|
||
const lastNewlineIndex = diff.lastIndexOf("\n");
|
||
if (lastNewlineIndex < 0) return;
|
||
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
|
||
if (diffForCheck.trim().length === 0) return;
|
||
|
||
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
|
||
if (!normalizedDiff) return;
|
||
// Deobfuscate the diff so removed lines match real file content
|
||
if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
|
||
if (!normalizedDiff) return;
|
||
const lines = normalizedDiff.split("\n");
|
||
const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
|
||
if (!hasChangeLine) return;
|
||
|
||
const lineCount = lines.length;
|
||
const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
|
||
if (lastChecked !== undefined && lineCount <= lastChecked) return;
|
||
this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
|
||
|
||
const removedLines = lines
|
||
.filter(line => line.startsWith("-") && !line.startsWith("--- "))
|
||
.map(line => line.slice(1));
|
||
if (removedLines.length > 0) {
|
||
let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
|
||
if (cachedContent === undefined) {
|
||
this.#ensureFileCache(resolvedPath);
|
||
cachedContent = this.#streamingEditFileCache.get(resolvedPath);
|
||
}
|
||
if (cachedContent !== undefined) {
|
||
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
|
||
if (missing) {
|
||
this.#streamingEditAbortTriggered = true;
|
||
logger.warn("Streaming edit aborted due to patch preview failure", {
|
||
toolCallId: toolCall.id,
|
||
path,
|
||
error: `Failed to find expected lines in ${path}:\n${missing}`,
|
||
});
|
||
this.agent.abort();
|
||
}
|
||
return;
|
||
}
|
||
if (assistantEvent.type === "toolcall_delta") return;
|
||
void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines);
|
||
return;
|
||
}
|
||
|
||
if (assistantEvent.type === "toolcall_delta") return;
|
||
void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff);
|
||
}
|
||
|
||
async #checkRemovedLinesAsync(
|
||
toolCallId: string,
|
||
path: string,
|
||
resolvedPath: string,
|
||
removedLines: string[],
|
||
): Promise<void> {
|
||
if (this.#streamingEditAbortTriggered) return;
|
||
try {
|
||
const { text } = stripBom(await Bun.file(resolvedPath).text());
|
||
const normalizedContent = normalizeToLF(text);
|
||
const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
|
||
if (missing) {
|
||
this.#streamingEditAbortTriggered = true;
|
||
logger.warn("Streaming edit aborted due to patch preview failure", {
|
||
toolCallId,
|
||
path,
|
||
error: `Failed to find expected lines in ${path}:\n${missing}`,
|
||
});
|
||
this.agent.abort();
|
||
}
|
||
} catch (err) {
|
||
// Ignore ENOENT (file not found) - let the edit tool handle missing files
|
||
// Also ignore other errors during async fallback
|
||
if (!isEnoent(err)) {
|
||
// Log unexpected errors but don't abort
|
||
}
|
||
}
|
||
}
|
||
|
||
async #checkPreviewPatchAsync(
|
||
toolCallId: string,
|
||
path: string,
|
||
rename: string | undefined,
|
||
normalizedDiff: string,
|
||
): Promise<void> {
|
||
if (this.#streamingEditAbortTriggered) return;
|
||
try {
|
||
await previewPatch(
|
||
{ path, op: "update", rename, diff: normalizedDiff },
|
||
{
|
||
cwd: this.sessionManager.getCwd(),
|
||
allowFuzzy: this.settings.get("edit.fuzzyMatch"),
|
||
fuzzyThreshold: this.settings.get("edit.fuzzyThreshold"),
|
||
},
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof ParseError) return;
|
||
this.#streamingEditAbortTriggered = true;
|
||
logger.warn("Streaming edit aborted due to patch preview failure", {
|
||
toolCallId,
|
||
path,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
this.agent.abort();
|
||
}
|
||
}
|
||
|
||
/** Emit extension events based on session events */
|
||
async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
|
||
if (!this.#extensionRunner) return;
|
||
if (event.type === "agent_start") {
|
||
this.#turnIndex = 0;
|
||
await this.#extensionRunner.emit({ type: "agent_start" });
|
||
} else if (event.type === "agent_end") {
|
||
await this.#extensionRunner.emit({ type: "agent_end", messages: event.messages });
|
||
} else if (event.type === "turn_start") {
|
||
const hookEvent: TurnStartEvent = {
|
||
type: "turn_start",
|
||
turnIndex: this.#turnIndex,
|
||
timestamp: Date.now(),
|
||
};
|
||
await this.#extensionRunner.emit(hookEvent);
|
||
} else if (event.type === "turn_end") {
|
||
const hookEvent: TurnEndEvent = {
|
||
type: "turn_end",
|
||
turnIndex: this.#turnIndex,
|
||
message: event.message,
|
||
toolResults: event.toolResults,
|
||
};
|
||
await this.#extensionRunner.emit(hookEvent);
|
||
this.#turnIndex++;
|
||
} else if (event.type === "message_start") {
|
||
const extensionEvent: MessageStartEvent = {
|
||
type: "message_start",
|
||
message: event.message,
|
||
};
|
||
await this.#extensionRunner.emit(extensionEvent);
|
||
} else if (event.type === "message_update") {
|
||
const extensionEvent: MessageUpdateEvent = {
|
||
type: "message_update",
|
||
message: event.message,
|
||
assistantMessageEvent: event.assistantMessageEvent,
|
||
};
|
||
await this.#extensionRunner.emit(extensionEvent);
|
||
} else if (event.type === "message_end") {
|
||
const extensionEvent: MessageEndEvent = {
|
||
type: "message_end",
|
||
message: event.message,
|
||
};
|
||
await this.#extensionRunner.emit(extensionEvent);
|
||
} else if (event.type === "tool_execution_start") {
|
||
const extensionEvent: ToolExecutionStartEvent = {
|
||
type: "tool_execution_start",
|
||
toolCallId: event.toolCallId,
|
||
toolName: event.toolName,
|
||
args: event.args,
|
||
intent: event.intent,
|
||
};
|
||
await this.#extensionRunner.emit(extensionEvent);
|
||
} else if (event.type === "tool_execution_update") {
|
||
const extensionEvent: ToolExecutionUpdateEvent = {
|
||
type: "tool_execution_update",
|
||
toolCallId: event.toolCallId,
|
||
toolName: event.toolName,
|
||
args: event.args,
|
||
partialResult: event.partialResult,
|
||
};
|
||
await this.#extensionRunner.emit(extensionEvent);
|
||
} else if (event.type === "tool_execution_end") {
|
||
const extensionEvent: ToolExecutionEndEvent = {
|
||
type: "tool_execution_end",
|
||
toolCallId: event.toolCallId,
|
||
toolName: event.toolName,
|
||
result: event.result,
|
||
isError: event.isError ?? false,
|
||
};
|
||
await this.#extensionRunner.emit(extensionEvent);
|
||
} else if (event.type === "auto_compaction_start") {
|
||
await this.#extensionRunner.emit({
|
||
type: "auto_compaction_start",
|
||
reason: event.reason,
|
||
action: event.action,
|
||
});
|
||
} else if (event.type === "auto_compaction_end") {
|
||
await this.#extensionRunner.emit({
|
||
type: "auto_compaction_end",
|
||
action: event.action,
|
||
result: event.result,
|
||
aborted: event.aborted,
|
||
willRetry: event.willRetry,
|
||
errorMessage: event.errorMessage,
|
||
skipped: event.skipped,
|
||
});
|
||
} else if (event.type === "auto_retry_start") {
|
||
await this.#extensionRunner.emit({
|
||
type: "auto_retry_start",
|
||
attempt: event.attempt,
|
||
maxAttempts: event.maxAttempts,
|
||
delayMs: event.delayMs,
|
||
errorMessage: event.errorMessage,
|
||
});
|
||
} else if (event.type === "auto_retry_end") {
|
||
await this.#extensionRunner.emit({
|
||
type: "auto_retry_end",
|
||
success: event.success,
|
||
attempt: event.attempt,
|
||
finalError: event.finalError,
|
||
});
|
||
} else if (event.type === "ttsr_triggered") {
|
||
await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
|
||
} else if (event.type === "todo_reminder") {
|
||
await this.#extensionRunner.emit({
|
||
type: "todo_reminder",
|
||
todos: event.todos,
|
||
attempt: event.attempt,
|
||
maxAttempts: event.maxAttempts,
|
||
});
|
||
} else if (event.type === "goal_updated") {
|
||
await this.#extensionRunner.emit({
|
||
type: "goal_updated",
|
||
goal: event.goal,
|
||
state: event.state,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Subscribe to agent events.
|
||
* Session persistence is handled internally (saves messages on message_end).
|
||
* Multiple listeners can be added. Returns unsubscribe function for this listener.
|
||
*/
|
||
subscribe(listener: AgentSessionEventListener): () => void {
|
||
this.#eventListeners.push(listener);
|
||
|
||
// Return unsubscribe function for this specific listener
|
||
return () => {
|
||
const index = this.#eventListeners.indexOf(listener);
|
||
if (index !== -1) {
|
||
this.#eventListeners.splice(index, 1);
|
||
}
|
||
};
|
||
}
|
||
|
||
subscribeCommandMetadataChanged(listener: CommandMetadataChangedListener): () => void {
|
||
this.#commandMetadataChangedListeners.push(listener);
|
||
return () => {
|
||
const index = this.#commandMetadataChangedListeners.indexOf(listener);
|
||
if (index !== -1) {
|
||
this.#commandMetadataChangedListeners.splice(index, 1);
|
||
}
|
||
};
|
||
}
|
||
|
||
#notifyCommandMetadataChanged(): void {
|
||
const listeners = [...this.#commandMetadataChangedListeners];
|
||
for (const listener of listeners) {
|
||
try {
|
||
void listener();
|
||
} catch (err) {
|
||
logger.error("Command metadata listener threw", { err });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Temporarily disconnect from agent events.
|
||
* User listeners are preserved and will receive events again after resubscribe().
|
||
* Used internally during operations that need to pause event processing.
|
||
*/
|
||
#disconnectFromAgent(): void {
|
||
if (this.#unsubscribeAgent) {
|
||
this.#unsubscribeAgent();
|
||
this.#unsubscribeAgent = undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reconnect to agent events after _disconnectFromAgent().
|
||
* Preserves all existing listeners.
|
||
*/
|
||
#reconnectToAgent(): void {
|
||
if (this.#unsubscribeAgent) return; // Already connected
|
||
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
||
}
|
||
|
||
#activeProviderSessionId(sessionId?: string): string {
|
||
return this.#freshProviderSessionId ?? this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
|
||
}
|
||
|
||
/**
|
||
* Set agent.sessionId from the session manager and install a dynamic
|
||
* metadata resolver so every Anthropic API request carries
|
||
* `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
|
||
* `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
|
||
* when an Anthropic OAuth credential with a known account UUID is loaded;
|
||
* `device_id` is derived from both the persistent omp install id and that
|
||
* account UUID. Resolving live keeps the value in sync with auth-state changes
|
||
* (login/logout, token refresh that surfaces a new account UUID) without
|
||
* needing to re-call `#syncAgentSessionId()` on every such event.
|
||
*/
|
||
#syncAgentSessionId(sessionId?: string): void {
|
||
const sid = this.#activeProviderSessionId(sessionId);
|
||
this.agent.sessionId = sid;
|
||
this.agent.setMetadataResolver((provider: string) =>
|
||
buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
|
||
);
|
||
}
|
||
|
||
#rekeyHindsightMemoryForCurrentSessionId(): void {
|
||
if (this.settings.get("memory.backend") !== "hindsight") return;
|
||
const sid = this.agent.sessionId;
|
||
if (!sid) return;
|
||
this.getHindsightSessionState()?.setSessionId(sid);
|
||
}
|
||
|
||
#rekeyMnemopiMemoryForCurrentSessionId(): void {
|
||
if (this.settings.get("memory.backend") !== "mnemopi") return;
|
||
const sid = this.agent.sessionId;
|
||
if (!sid) return;
|
||
this.getMnemopiSessionState()?.setSessionId(sid);
|
||
}
|
||
|
||
/** New session file: reset auto-recall / retain-threshold counters for the new transcript. */
|
||
#resetHindsightConversationTrackingIfHindsight(): void {
|
||
if (this.settings.get("memory.backend") !== "hindsight") return;
|
||
const state = this.getHindsightSessionState();
|
||
if (!state || state.aliasOf) return;
|
||
state.resetConversationTracking();
|
||
}
|
||
|
||
#resetMnemopiConversationTrackingIfMnemopi(): void {
|
||
if (this.settings.get("memory.backend") !== "mnemopi") return;
|
||
const state = this.getMnemopiSessionState();
|
||
if (!state || state.aliasOf) return;
|
||
state.resetConversationTracking();
|
||
}
|
||
|
||
/** True once dispose() has begun; deferred background work (e.g. the deferred
|
||
* MCP discovery task in sdk.ts) must not touch the session past this point. */
|
||
get isDisposed(): boolean {
|
||
return this.#isDisposed;
|
||
}
|
||
|
||
/**
|
||
* Synchronously mark the session as disposing so new work is rejected
|
||
* immediately: Python/eval starts throw, queued asides are dropped, and the
|
||
* aside provider is detached. Idempotent; `dispose()` runs it first.
|
||
*
|
||
* Wrappers that await other teardown before delegating to `dispose()` MUST
|
||
* call this before their first await — otherwise work started in that async
|
||
* gap slips past the disposal guards.
|
||
*/
|
||
beginDispose(): void {
|
||
this.#isDisposed = true;
|
||
this.#pendingIrcAsides = [];
|
||
this.yieldQueue.clear();
|
||
this.agent.setAsideMessageProvider(undefined);
|
||
this.#evalExecutionDisposing = true;
|
||
}
|
||
|
||
/**
|
||
* Remove all listeners, flush pending writes, and disconnect from agent.
|
||
* Call this when completely done with the session.
|
||
*/
|
||
async dispose(): Promise<void> {
|
||
this.beginDispose();
|
||
try {
|
||
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
|
||
await this.#extensionRunner.emit({ type: "session_shutdown" });
|
||
}
|
||
} catch (error) {
|
||
logger.warn("Failed to emit session_shutdown event", { error: String(error) });
|
||
}
|
||
// Abort post-prompt work so the drain below can complete. Without this, a
|
||
// deferred-handoff task that has already advanced into
|
||
// `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
|
||
// — Promise.allSettled() in #cancelPostPromptTasks then waits forever, freezing
|
||
// /exit and Ctrl+C-double-tap. The post-prompt task's own AbortSignal does not
|
||
// propagate into the inner handoff/compaction controllers, so we abort them
|
||
// explicitly. agent.abort() is needed for an agent.continue() that may have
|
||
// raced the deferred handoff (its streaming loop is awaited by the wrapper IIFE).
|
||
//
|
||
// Tool work (bash/eval/python) is NOT aborted here — those have their own
|
||
// dispose paths and shared kernels are contractually allowed to survive a
|
||
// session's dispose.
|
||
this.abortRetry();
|
||
this.abortCompaction();
|
||
this.agent.abort();
|
||
await this.#cancelPostPromptTasks();
|
||
// Cancel jobs this agent registered so a subagent's teardown doesn't
|
||
// leak its background bash/task work into the parent's manager. Only
|
||
// the session that owns the manager goes on to dispose it (which itself
|
||
// nukes any leftover jobs and pending deliveries).
|
||
this.#cancelOwnAsyncJobs();
|
||
const ownedAsyncManager = this.#ownedAsyncJobManager;
|
||
if (ownedAsyncManager) {
|
||
const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
|
||
const deliveryState = ownedAsyncManager.getDeliveryState();
|
||
if (drained === false && deliveryState) {
|
||
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
|
||
}
|
||
if (AsyncJobManager.instance() === ownedAsyncManager) {
|
||
AsyncJobManager.setInstance(undefined);
|
||
}
|
||
}
|
||
const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
|
||
if (!pythonExecutionsSettled) {
|
||
logger.warn(
|
||
"Detaching retained Python kernel ownership during dispose while Python execution is still active",
|
||
);
|
||
}
|
||
await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
|
||
await shutdownTinyTitleClient();
|
||
this.#releasePowerAssertion();
|
||
await this.sessionManager.close();
|
||
this.#closeAllProviderSessions("dispose");
|
||
// Flush the retain queue BEFORE clearing the session's pointer so
|
||
// `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
|
||
// Reversed, the spliced batch survives just long enough to fail the
|
||
// identity check and get dropped with a `session vanished` warning.
|
||
const hindsightState = this.getHindsightSessionState();
|
||
await hindsightState?.flushRetainQueue();
|
||
this.setHindsightSessionState(undefined);
|
||
hindsightState?.dispose();
|
||
const mnemopiState = setMnemopiSessionState(this, undefined);
|
||
await mnemopiState?.dispose();
|
||
this.#disconnectFromAgent();
|
||
if (this.#unsubscribeAppendOnly) {
|
||
this.#unsubscribeAppendOnly();
|
||
this.#unsubscribeAppendOnly = undefined;
|
||
}
|
||
this.#eventListeners = [];
|
||
}
|
||
|
||
#closeAllProviderSessions(reason: string): void {
|
||
for (const [providerKey, state] of this.#providerSessionState) {
|
||
try {
|
||
state.close();
|
||
} catch (error) {
|
||
logger.warn("Failed to close provider session state", {
|
||
providerKey,
|
||
reason,
|
||
error: String(error),
|
||
});
|
||
}
|
||
}
|
||
|
||
this.#providerSessionState.clear();
|
||
}
|
||
|
||
freshSession(): FreshSessionResult | undefined {
|
||
if (this.isStreaming) return undefined;
|
||
const previousSessionId = this.sessionId;
|
||
const closedProviderSessions = this.#providerSessionState.size;
|
||
this.#closeAllProviderSessions("fresh session");
|
||
this.#freshProviderSessionId = Bun.randomUUIDv7();
|
||
this.#syncAgentSessionId();
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
this.agent.appendOnlyContext?.invalidateForModelChange();
|
||
return {
|
||
previousSessionId,
|
||
sessionId: this.sessionId,
|
||
closedProviderSessions,
|
||
};
|
||
}
|
||
|
||
// =========================================================================
|
||
// Read-only State Access
|
||
// =========================================================================
|
||
|
||
/** Full agent state */
|
||
get state(): AgentState {
|
||
return this.agent.state;
|
||
}
|
||
|
||
/** Current model (may be undefined if not yet selected) */
|
||
get model(): Model | undefined {
|
||
return this.agent.state.model;
|
||
}
|
||
|
||
/** Effective thinking level applied to the agent (the resolved level when `auto`). */
|
||
get thinkingLevel(): ThinkingLevel | undefined {
|
||
return this.#thinkingLevel;
|
||
}
|
||
|
||
/** The selector the user configured: `auto` when auto mode is active, else the effective level. */
|
||
configuredThinkingLevel(): ConfiguredThinkingLevel | undefined {
|
||
return this.#autoThinking ? AUTO_THINKING : this.#thinkingLevel;
|
||
}
|
||
|
||
/** True when `auto` thinking mode is active. */
|
||
get isAutoThinking(): boolean {
|
||
return this.#autoThinking;
|
||
}
|
||
|
||
/** The level `auto` resolved to for the current turn (undefined until classified). */
|
||
autoResolvedThinkingLevel(): Effort | undefined {
|
||
return this.#autoResolvedLevel;
|
||
}
|
||
|
||
get serviceTier(): ServiceTier | undefined {
|
||
return this.agent.serviceTier;
|
||
}
|
||
|
||
/** Whether agent is currently streaming a response */
|
||
get isStreaming(): boolean {
|
||
return this.agent.state.isStreaming || this.#promptInFlightCount > 0;
|
||
}
|
||
|
||
/** Wait until streaming and deferred recovery work are fully settled. */
|
||
async waitForIdle(): Promise<void> {
|
||
await this.agent.waitForIdle();
|
||
await this.#waitForPostPromptRecovery();
|
||
}
|
||
|
||
async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
|
||
const manager = this.#asyncJobManager;
|
||
if (!manager) return false;
|
||
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
|
||
const before = manager.getDeliveryState(ownerFilter);
|
||
if (before.queued === 0 && !before.delivering) return false;
|
||
const previousAllowAcpAgentInitiatedTurns = this.#allowAcpAgentInitiatedTurns;
|
||
this.#allowAcpAgentInitiatedTurns = true;
|
||
try {
|
||
const drained = await manager.drainDeliveries({ timeoutMs: options?.timeoutMs, filter: ownerFilter });
|
||
const after = manager.getDeliveryState(ownerFilter);
|
||
return drained && (before.queued !== after.queued || before.delivering !== after.delivering);
|
||
} finally {
|
||
this.#allowAcpAgentInitiatedTurns = previousAllowAcpAgentInitiatedTurns;
|
||
}
|
||
}
|
||
|
||
/** Most recent assistant message in agent state. */
|
||
getLastAssistantMessage(): AssistantMessage | undefined {
|
||
return this.#findLastAssistantMessage();
|
||
}
|
||
/** Current effective system prompt blocks (includes any per-turn extension modifications) */
|
||
get systemPrompt(): string[] {
|
||
return this.agent.state.systemPrompt;
|
||
}
|
||
|
||
/** Current retry attempt (0 if not retrying) */
|
||
get retryAttempt(): number {
|
||
return this.#retryAttempt;
|
||
}
|
||
|
||
#collectDiscoverableMCPToolsFromRegistry(): Map<string, DiscoverableTool> {
|
||
const mcpTools = filterBySource(collectDiscoverableTools(this.#toolRegistry.values()), "mcp");
|
||
return new Map(mcpTools.map(tool => [tool.name, tool] as const));
|
||
}
|
||
|
||
#setDiscoverableMCPTools(discoverableMCPTools: Map<string, DiscoverableTool>): void {
|
||
this.#discoverableMCPTools = discoverableMCPTools;
|
||
this.#invalidateDiscoveryCaches();
|
||
}
|
||
|
||
/** Single point for invalidating cached discovery indices. Call after any change that can
|
||
* affect which tools should be discoverable: registry mutations (refreshMCPTools,
|
||
* refreshRpcHostTools) or active-tool mutations (#applyActiveToolsByName). */
|
||
#invalidateDiscoveryCaches(): void {
|
||
this.#discoverableToolSearchIndex = null;
|
||
}
|
||
|
||
#filterSelectableMCPToolNames(toolNames: Iterable<string>): string[] {
|
||
return Array.from(toolNames).filter(name => this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name));
|
||
}
|
||
|
||
#getConfiguredDefaultSelectedMCPToolNames(): string[] {
|
||
return this.#filterSelectableMCPToolNames([
|
||
...this.#defaultSelectedMCPToolNames,
|
||
...selectDiscoverableToolNamesByServer(
|
||
this.#discoverableMCPTools.values(),
|
||
this.#defaultSelectedMCPServerNames,
|
||
),
|
||
]);
|
||
}
|
||
|
||
#pruneSelectedMCPToolNames(): void {
|
||
this.#selectedMCPToolNames = new Set(this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames));
|
||
}
|
||
|
||
#selectedMCPToolNamesMatch(left: string[], right: string[]): boolean {
|
||
return left.length === right.length && left.every((name, index) => name === right[index]);
|
||
}
|
||
|
||
#rememberSessionDefaultSelectedMCPToolNames(
|
||
sessionFile: string | null | undefined,
|
||
toolNames: Iterable<string>,
|
||
): void {
|
||
if (!sessionFile) return;
|
||
this.#sessionDefaultSelectedMCPToolNames.set(
|
||
path.resolve(sessionFile),
|
||
this.#filterSelectableMCPToolNames(toolNames),
|
||
);
|
||
}
|
||
|
||
#getSessionDefaultSelectedMCPToolNames(sessionFile: string | null | undefined): string[] {
|
||
if (!sessionFile) return [];
|
||
return this.#sessionDefaultSelectedMCPToolNames.get(path.resolve(sessionFile)) ?? [];
|
||
}
|
||
|
||
#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames: string[]): void {
|
||
if (!this.#mcpDiscoveryEnabled) return;
|
||
const nextSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
||
if (this.#selectedMCPToolNamesMatch(previousSelectedMCPToolNames, nextSelectedMCPToolNames)) {
|
||
return;
|
||
}
|
||
this.sessionManager.appendMCPToolSelection(nextSelectedMCPToolNames);
|
||
}
|
||
|
||
#getActiveNonMCPToolNames(): string[] {
|
||
return this.getActiveToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name));
|
||
}
|
||
|
||
/**
|
||
* Get the names of currently active tools.
|
||
* Returns the names of tools currently set on the agent.
|
||
*/
|
||
getActiveToolNames(): string[] {
|
||
return this.agent.state.tools.map(t => t.name);
|
||
}
|
||
|
||
/** Whether the edit tool is registered in this session. */
|
||
get hasEditTool(): boolean {
|
||
return this.#toolRegistry.has("edit");
|
||
}
|
||
|
||
/**
|
||
* Get a tool by name from the registry.
|
||
*/
|
||
getToolByName(name: string): AgentTool | undefined {
|
||
return this.#toolRegistry.get(name);
|
||
}
|
||
|
||
/**
|
||
* Get all configured tool names (built-in via --tools or default, plus custom tools).
|
||
*/
|
||
getAllToolNames(): string[] {
|
||
return Array.from(this.#toolRegistry.keys());
|
||
}
|
||
|
||
#getEditModeSession() {
|
||
return {
|
||
settings: this.settings,
|
||
getActiveModelString: () => (this.model ? formatModelString(this.model) : undefined),
|
||
} as const;
|
||
}
|
||
|
||
#resolveActiveEditMode(): EditMode {
|
||
return resolveEditMode(this.#getEditModeSession());
|
||
}
|
||
|
||
/**
|
||
* Model key (`provider/id`) currently surfaced in the system prompt, or
|
||
* undefined when the model is unset or `includeModelInPrompt` is disabled.
|
||
*/
|
||
#currentPromptModelKey(): string | undefined {
|
||
if (!this.settings.get("includeModelInPrompt")) return undefined;
|
||
return this.model ? formatModelString(this.model) : undefined;
|
||
}
|
||
|
||
async #syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
|
||
const currentEditMode = this.#resolveActiveEditMode();
|
||
const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
|
||
// The system prompt may surface the active model; a switch makes the cached prompt stale.
|
||
const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
|
||
if (editModeChanged || modelChanged) {
|
||
await this.refreshBaseSystemPrompt();
|
||
}
|
||
}
|
||
|
||
isMCPDiscoveryEnabled(): boolean {
|
||
return this.#mcpDiscoveryEnabled;
|
||
}
|
||
|
||
/**
|
||
* Flip MCP discovery on after deferred discovery learns the real tool count.
|
||
* UI sessions resolve `tools.discoveryMode: "auto"` before MCP servers
|
||
* connect, so a large MCP toolset discovered later must be able to upgrade
|
||
* the session from the force-activate path to the discovery path. One-way:
|
||
* discovery is never downgraded mid-session.
|
||
*/
|
||
enableMCPDiscovery(): void {
|
||
this.#mcpDiscoveryEnabled = true;
|
||
}
|
||
|
||
getSelectedMCPToolNames(): string[] {
|
||
if (!this.#mcpDiscoveryEnabled) {
|
||
return this.getActiveToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
|
||
}
|
||
return this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames);
|
||
}
|
||
|
||
async activateDiscoveredMCPTools(toolNames: string[]): Promise<string[]> {
|
||
const nextSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
||
const activated: string[] = [];
|
||
for (const name of toolNames) {
|
||
if (!isMCPToolName(name) || !this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) {
|
||
continue;
|
||
}
|
||
nextSelectedMCPToolNames.add(name);
|
||
activated.push(name);
|
||
}
|
||
if (activated.length === 0) {
|
||
return [];
|
||
}
|
||
const nextActive = [
|
||
...this.#getActiveNonMCPToolNames(),
|
||
...this.#filterSelectableMCPToolNames(nextSelectedMCPToolNames),
|
||
];
|
||
await this.setActiveToolsByName(nextActive);
|
||
return [...new Set(activated)];
|
||
}
|
||
|
||
// ── Generic tool discovery (covers built-in + MCP + extension) ────────────
|
||
|
||
/** Resolve effective discovery mode from the current registry size. */
|
||
#resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" {
|
||
const mode = resolveEffectiveToolDiscoveryMode(
|
||
this.settings,
|
||
countToolsForAutoDiscovery(this.#toolRegistry.keys()),
|
||
);
|
||
if (mode !== "off") return mode;
|
||
return this.#mcpDiscoveryEnabled ? "mcp-only" : "off";
|
||
}
|
||
|
||
isToolDiscoveryEnabled(): boolean {
|
||
return this.#resolveEffectiveDiscoveryMode() !== "off";
|
||
}
|
||
|
||
getDiscoverableTools(filter?: { source?: DiscoverableTool["source"] }): DiscoverableTool[] {
|
||
// For "all" mode we combine built-in registry entries + MCP tools.
|
||
// For "mcp-only" mode we only return MCP tools.
|
||
const mode = this.#resolveEffectiveDiscoveryMode();
|
||
const activeNames = new Set(this.getActiveToolNames());
|
||
const mcpTools = Array.from(this.#discoverableMCPTools.values()).filter(t => !activeNames.has(t.name));
|
||
const builtinTools: DiscoverableTool[] = mode === "all" ? this.#collectDiscoverableBuiltinTools() : [];
|
||
const allTools = [...builtinTools, ...mcpTools];
|
||
return filter?.source ? allTools.filter(t => t.source === filter.source) : allTools;
|
||
}
|
||
|
||
/** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool
|
||
* definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools
|
||
* (resolve, yield, report_finding, report_tool_issue) out of the index
|
||
* and avoids mislabeling extension/custom default-inactive tools as built-ins. */
|
||
#collectDiscoverableBuiltinTools(): DiscoverableTool[] {
|
||
const activeNames = new Set(this.getActiveToolNames());
|
||
const result: DiscoverableTool[] = [];
|
||
for (const tool of this.#toolRegistry.values()) {
|
||
if (tool.loadMode !== "discoverable") continue;
|
||
if (activeNames.has(tool.name)) continue;
|
||
const collected = collectDiscoverableTools([tool], { source: "builtin" });
|
||
result.push(...collected);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
getDiscoverableToolSearchIndex(): DiscoverableToolSearchIndex {
|
||
if (!this.#discoverableToolSearchIndex) {
|
||
this.#discoverableToolSearchIndex = buildDiscoverableToolSearchIndex(this.getDiscoverableTools());
|
||
}
|
||
return this.#discoverableToolSearchIndex;
|
||
}
|
||
|
||
/** Invalidate the generic search index cache (call after tool set changes).
|
||
* Delegates to {@link #invalidateDiscoveryCaches} so all discovery-related caches stay in sync. */
|
||
#invalidateDiscoverableToolSearchIndex(): void {
|
||
this.#invalidateDiscoveryCaches();
|
||
}
|
||
|
||
getSelectedDiscoveredToolNames(): string[] {
|
||
// Union of MCP-selected and generic non-MCP selected. Non-MCP selections are only
|
||
// selected while they are still active; otherwise BM25 must be able to rediscover them.
|
||
const activeNames = new Set(this.getActiveToolNames());
|
||
const mcpSelected = this.getSelectedMCPToolNames();
|
||
const nonMcpSelected = Array.from(this.#selectedDiscoveredToolNames).filter(
|
||
name => activeNames.has(name) && this.#toolRegistry.has(name) && !isMCPToolName(name),
|
||
);
|
||
return [...new Set([...mcpSelected, ...nonMcpSelected])];
|
||
}
|
||
|
||
async activateDiscoveredTools(toolNames: string[]): Promise<string[]> {
|
||
const mcpNames = toolNames.filter(isMCPToolName);
|
||
const nonMcpNames = toolNames.filter(name => !isMCPToolName(name));
|
||
const activated: string[] = [];
|
||
|
||
// Activate MCP tools via existing path
|
||
if (mcpNames.length > 0) {
|
||
const activatedMcp = await this.activateDiscoveredMCPTools(mcpNames);
|
||
activated.push(...activatedMcp);
|
||
}
|
||
|
||
// Activate non-MCP tools (built-ins that are in the registry but not currently active)
|
||
if (nonMcpNames.length > 0) {
|
||
const currentActiveNames = new Set(this.getActiveToolNames());
|
||
const newlyAdded: string[] = [];
|
||
for (const name of nonMcpNames) {
|
||
if (this.#toolRegistry.has(name) && !currentActiveNames.has(name)) {
|
||
newlyAdded.push(name);
|
||
this.#selectedDiscoveredToolNames.add(name);
|
||
activated.push(name);
|
||
}
|
||
}
|
||
if (newlyAdded.length > 0) {
|
||
const nextActive = [...this.getActiveToolNames(), ...newlyAdded];
|
||
await this.setActiveToolsByName(nextActive);
|
||
this.#invalidateDiscoverableToolSearchIndex();
|
||
}
|
||
}
|
||
|
||
return [...new Set(activated)];
|
||
}
|
||
|
||
/**
|
||
* Wrap a tool with a permission-gate proxy when an ACP client is connected.
|
||
* Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
|
||
* the bridge exposes `requestPermission`. No-ops for all other cases.
|
||
*
|
||
* When the user has explicitly opted into `yolo` / auto-approve behavior (via
|
||
* the SDK/CLI `autoApprove` flag or a configured `tools.approvalMode: yolo`),
|
||
* skips the gate unless the per-tool policy explicitly requires a prompt or
|
||
* deny. The schema default is also `yolo`, so an explicit configuration or
|
||
* explicit session flag is required: default-config ACP sessions keep the
|
||
* client-side permission gate.
|
||
*/
|
||
#wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
|
||
const bridge = this.#clientBridge;
|
||
// Match the capability+method gating pattern used by read/write/bash.
|
||
if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
|
||
if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
|
||
// Skip the gate only on explicit yolo opt-in; honour per-tool policies
|
||
// that require a prompt or deny (matching the normal approval wrapper).
|
||
if (this.#isExplicitAutoApproveMode()) {
|
||
const userPolicies = (this.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
|
||
const toolPolicy = userPolicies[tool.name];
|
||
if (!toolPolicy || toolPolicy === "allow") return tool;
|
||
}
|
||
return new Proxy(tool, {
|
||
get: (target, prop) => {
|
||
if (prop !== "execute") return Reflect.get(target, prop, target);
|
||
return async (
|
||
toolCallId: string,
|
||
args: unknown,
|
||
signal: AbortSignal | undefined,
|
||
onUpdate: never,
|
||
ctx: never,
|
||
) => {
|
||
const permissionIntent = getPermissionIntent(target.name, args);
|
||
if (!permissionIntent) {
|
||
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
||
}
|
||
const command =
|
||
target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
|
||
? getStringProperty(args as Record<string, unknown>, "command")
|
||
: undefined;
|
||
const commandContent = command
|
||
? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
|
||
: undefined;
|
||
// Short-circuit on persisted decisions.
|
||
const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
|
||
if (persisted === "allow_always") {
|
||
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
||
}
|
||
if (persisted === "reject_always") {
|
||
throw new ToolError(`Tool call rejected by user (preference)`);
|
||
}
|
||
if (signal?.aborted) {
|
||
throw new ToolAbortError("Permission request cancelled");
|
||
}
|
||
type PermissionRaceResult =
|
||
| { kind: "permission"; outcome: ClientBridgePermissionOutcome }
|
||
| { kind: "aborted" };
|
||
const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
|
||
const onAbort = () => resolveAbort({ kind: "aborted" });
|
||
signal?.addEventListener("abort", onAbort, { once: true });
|
||
let raced: PermissionRaceResult;
|
||
try {
|
||
const permissionPromise = bridge.requestPermission!(
|
||
{
|
||
toolCallId,
|
||
toolName: target.name,
|
||
title: permissionIntent.title,
|
||
...(target.name === "bash" ? { kind: "execute" } : {}),
|
||
status: "pending",
|
||
rawInput: args,
|
||
...(commandContent ? { content: commandContent } : {}),
|
||
locations: extractPermissionLocations(
|
||
args,
|
||
this.sessionManager.getCwd(),
|
||
permissionIntent.paths,
|
||
),
|
||
},
|
||
PERMISSION_OPTIONS,
|
||
signal,
|
||
).then(outcome => ({ kind: "permission" as const, outcome }));
|
||
raced = await Promise.race([permissionPromise, abortPromise]);
|
||
} finally {
|
||
signal?.removeEventListener("abort", onAbort);
|
||
}
|
||
if (raced.kind === "aborted" || signal?.aborted) {
|
||
throw new ToolAbortError("Permission request cancelled");
|
||
}
|
||
const outcome = raced.outcome;
|
||
if (outcome.outcome === "cancelled") {
|
||
throw new ToolAbortError("Permission request cancelled");
|
||
}
|
||
const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId);
|
||
if (!selectedOption) {
|
||
throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
|
||
}
|
||
if (selectedOption.kind === "allow_always") {
|
||
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
|
||
} else if (selectedOption.kind === "reject_always") {
|
||
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
|
||
}
|
||
if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
|
||
throw new ToolError(`Tool call rejected by user (${target.name})`);
|
||
}
|
||
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
||
};
|
||
},
|
||
}) as T;
|
||
}
|
||
|
||
#isExplicitAutoApproveMode(): boolean {
|
||
return (
|
||
this.#autoApprove ||
|
||
(this.settings.isConfigured("tools.approvalMode") && this.settings.get("tools.approvalMode") === "yolo")
|
||
);
|
||
}
|
||
|
||
async #applyActiveToolsByName(
|
||
toolNames: string[],
|
||
options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
|
||
): Promise<void> {
|
||
toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
|
||
const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
|
||
const tools: AgentTool[] = [];
|
||
const validToolNames: string[] = [];
|
||
for (const name of toolNames) {
|
||
const tool = this.#toolRegistry.get(name);
|
||
if (tool) {
|
||
tools.push(this.#wrapToolForAcpPermission(tool));
|
||
validToolNames.push(name);
|
||
}
|
||
}
|
||
// Auto-QA tool must survive any runtime tool-set mutation.
|
||
if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) {
|
||
const qaTool = this.#toolRegistry.get("report_tool_issue");
|
||
if (qaTool) {
|
||
tools.push(this.#wrapToolForAcpPermission(qaTool));
|
||
validToolNames.push("report_tool_issue");
|
||
}
|
||
}
|
||
if (this.#mcpDiscoveryEnabled) {
|
||
this.#selectedMCPToolNames = new Set(
|
||
validToolNames.filter(
|
||
name => isMCPToolName(name) && this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name),
|
||
),
|
||
);
|
||
}
|
||
const activeNameSet = new Set(validToolNames);
|
||
for (const name of Array.from(this.#selectedDiscoveredToolNames)) {
|
||
if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) {
|
||
this.#selectedDiscoveredToolNames.delete(name);
|
||
}
|
||
}
|
||
this.agent.setTools(tools);
|
||
|
||
// Active tool set changed → discoverable tool list (which excludes already-active tools)
|
||
// is now stale. Invalidate before any prompt-template hook reads the discovery list.
|
||
this.#invalidateDiscoveryCaches();
|
||
|
||
// Rebuild base system prompt with new tool set, but only when the tool set
|
||
// actually changed. MCP servers can reconnect at arbitrary times and call
|
||
// `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
|
||
// tool list is byte-identical. Skipping the rebuild keeps the system prompt
|
||
// stable, which is required for Anthropic prompt caching to keep hitting.
|
||
if (this.#rebuildSystemPrompt) {
|
||
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
|
||
if (signature !== this.#lastAppliedToolSignature) {
|
||
const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
|
||
this.#baseSystemPrompt = built.systemPrompt;
|
||
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
||
this.#lastAppliedToolSignature = signature;
|
||
this.#promptModelKey = this.#currentPromptModelKey();
|
||
}
|
||
}
|
||
if (options?.persistMCPSelection !== false) {
|
||
this.#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reload the SSH tool from disk-backed capability discovery and make the
|
||
* refreshed definition visible to the next model call without restarting.
|
||
*/
|
||
async refreshSshTool(options?: { activateIfAvailable?: boolean }): Promise<void> {
|
||
resetCapabilities();
|
||
if (!this.#reloadSshTool) return;
|
||
const previousSshTool = this.#toolRegistry.get("ssh");
|
||
const previousActiveToolNames = this.getActiveToolNames();
|
||
const hadSshTool = previousSshTool !== undefined;
|
||
const wasActive = previousActiveToolNames.includes("ssh");
|
||
const previousHostNames =
|
||
previousSshTool && "hostNames" in previousSshTool && Array.isArray(previousSshTool.hostNames)
|
||
? [...previousSshTool.hostNames]
|
||
: [];
|
||
const candidateHostNames = new Set(previousHostNames);
|
||
const capability = await loadCapability<{ name: string }>("ssh", { cwd: this.sessionManager.getCwd() });
|
||
for (const host of capability.items) {
|
||
if (typeof host?.name === "string") {
|
||
candidateHostNames.add(host.name);
|
||
}
|
||
}
|
||
await invalidateHostMetadata(candidateHostNames);
|
||
const sshAllowed = this.#requestedToolNames === undefined || this.#requestedToolNames.has("ssh");
|
||
const refreshedTool = await this.#reloadSshTool();
|
||
if (refreshedTool) {
|
||
this.#toolRegistry.set(refreshedTool.name, refreshedTool);
|
||
} else {
|
||
this.#toolRegistry.delete("ssh");
|
||
this.#selectedDiscoveredToolNames.delete("ssh");
|
||
}
|
||
|
||
const nextActive = previousActiveToolNames.filter(name => name !== "ssh" && this.#toolRegistry.has(name));
|
||
if (refreshedTool && sshAllowed && (wasActive || (options?.activateIfAvailable && !hadSshTool))) {
|
||
nextActive.push(refreshedTool.name);
|
||
}
|
||
await this.#applyActiveToolsByName(nextActive);
|
||
}
|
||
|
||
/**
|
||
* Set active tools by name.
|
||
* Only tools in the registry can be enabled. Unknown tool names are ignored.
|
||
* Also rebuilds the system prompt to reflect the new tool set.
|
||
* Changes take effect before the next model call.
|
||
*/
|
||
async setActiveToolsByName(toolNames: string[]): Promise<void> {
|
||
await this.#applyActiveToolsByName(toolNames);
|
||
}
|
||
|
||
async #restoreMCPSelectionsForSessionContext(
|
||
sessionContext: SessionContext,
|
||
options?: { fallbackSelectedMCPToolNames?: Iterable<string> },
|
||
): Promise<void> {
|
||
if (!this.#mcpDiscoveryEnabled) return;
|
||
const nextActiveNonMCPToolNames = this.#getActiveNonMCPToolNames();
|
||
const fallbackSelectedMCPToolNames =
|
||
options?.fallbackSelectedMCPToolNames ?? this.#getConfiguredDefaultSelectedMCPToolNames();
|
||
const restoredMCPToolNames = sessionContext.hasPersistedMCPToolSelection
|
||
? this.#filterSelectableMCPToolNames(sessionContext.selectedMCPToolNames)
|
||
: this.#filterSelectableMCPToolNames(fallbackSelectedMCPToolNames);
|
||
this.#rememberSessionDefaultSelectedMCPToolNames(
|
||
this.sessionFile,
|
||
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
||
);
|
||
await this.#applyActiveToolsByName([...nextActiveNonMCPToolNames, ...restoredMCPToolNames], {
|
||
persistMCPSelection: false,
|
||
});
|
||
}
|
||
/** Rebuild the base system prompt using the current active tool set. */
|
||
async refreshBaseSystemPrompt(): Promise<void> {
|
||
if (!this.#rebuildSystemPrompt) return;
|
||
const activeToolNames = this.getActiveToolNames();
|
||
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
|
||
this.#baseSystemPrompt = built.systemPrompt;
|
||
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
||
this.#promptModelKey = this.#currentPromptModelKey();
|
||
// Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
|
||
// the same tool set does not re-rebuild on top of the explicit refresh we
|
||
// just performed (and conversely, a different set forces a fresh rebuild).
|
||
const activeTools = activeToolNames
|
||
.map(name => this.#toolRegistry.get(name))
|
||
.filter((tool): tool is AgentTool => tool != null);
|
||
this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
|
||
}
|
||
|
||
async #buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
|
||
const backend = await resolveMemoryBackend(this.settings);
|
||
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
|
||
|
||
try {
|
||
const injected = await backend.beforeAgentStartPrompt(this, promptText);
|
||
if (!injected) return this.#baseSystemPrompt;
|
||
return [...this.#baseSystemPrompt, injected];
|
||
} catch (err) {
|
||
logger.debug("Memory backend beforeAgentStartPrompt failed", {
|
||
backend: backend.id,
|
||
error: String(err),
|
||
});
|
||
return this.#baseSystemPrompt;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
|
||
* Two calls producing identical signatures are guaranteed to produce identical
|
||
* system prompt bytes, so the rebuild can be skipped.
|
||
*
|
||
* The signature covers:
|
||
* 1. Active tool names in order (the prompt renders them in this order).
|
||
* 2. Active tool labels, descriptions, and wire-visible names — all are
|
||
* rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\``
|
||
* and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from
|
||
* `tool.customWireName` and overrides the internal name on the model wire
|
||
* (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode);
|
||
* a stale wire name would desync prompt guidance from actual tool routing.
|
||
* 3. When MCP discovery is on, every registry tool's name+label+description+
|
||
* customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP
|
||
* tools that are not in the active set.
|
||
* 4. MCP server instructions text (per server), since `rebuildSystemPrompt`
|
||
* embeds these in the appended prompt under "## MCP Server Instructions".
|
||
* A server upgrade can change instructions while keeping tools identical.
|
||
*
|
||
* Settings-driven tool metadata is covered automatically: built-in tools that
|
||
* depend on settings expose `description`/`label` via getters (see `TaskTool`,
|
||
* `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every
|
||
* call - so a settings flip that mutates the rendered string differs the signature
|
||
* the next time `#applyActiveToolsByName` runs. Do not refactor `describeTool` to
|
||
* cache per-tool strings without preserving this property.
|
||
*
|
||
* Inputs NOT covered: tool input schemas; memory instructions read from disk;
|
||
* and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
|
||
* `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
|
||
* closure-captured ones cannot change at runtime regardless of skip behavior.
|
||
* For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
|
||
* after side-effecting changes; see e.g. the memory hooks and
|
||
* `#syncAfterModelChange`.
|
||
*
|
||
* The current calendar date IS covered (appended as a segment) because
|
||
* `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
|
||
* Without this, a session spanning midnight with only tool-stable MCP
|
||
* reconnects would keep yesterday's date indefinitely.
|
||
*/
|
||
#computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
|
||
// Order-preserving join: any reorder must produce a different signature so
|
||
// the rebuild fires and the new tool list reaches the API.
|
||
const nameSegment = toolNames.join("\u0001");
|
||
const describeTool = (tool: AgentTool): string =>
|
||
`${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
|
||
const descriptionSegment = tools.map(describeTool).join("\u0002");
|
||
let registrySegment = "";
|
||
if (this.#mcpDiscoveryEnabled) {
|
||
// Registry iteration order is not load-bearing for the prompt content, so we
|
||
// sort to keep the signature insensitive to incidental insertion order.
|
||
const entries: string[] = [];
|
||
for (const tool of this.#toolRegistry.values()) {
|
||
entries.push(describeTool(tool));
|
||
}
|
||
entries.sort();
|
||
registrySegment = entries.join("\u0004");
|
||
}
|
||
let instructionsSegment = "";
|
||
const serverInstructions = this.#getMcpServerInstructions?.();
|
||
if (serverInstructions && serverInstructions.size > 0) {
|
||
// Sort by server name so transport flap order does not perturb the signature.
|
||
const entries: string[] = [];
|
||
for (const [server, instructions] of serverInstructions) {
|
||
entries.push(`${server}=${instructions}`);
|
||
}
|
||
entries.sort();
|
||
instructionsSegment = entries.join("\u0006");
|
||
}
|
||
const date = new Date().toISOString().slice(0, 10);
|
||
return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
|
||
}
|
||
|
||
/**
|
||
* Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
|
||
* This allows /mcp add/remove/reauth to take effect without restarting the session.
|
||
*
|
||
* @param mcpTools The new MCP tools to register.
|
||
* @param options.activateAll When true, force-activates every newly registered MCP tool
|
||
* regardless of prior selection state. Used when an ACP client provisions MCP servers
|
||
* for a session where MCP discovery is disabled.
|
||
*/
|
||
async refreshMCPTools(mcpTools: CustomTool[], options?: { activateAll?: boolean }): Promise<void> {
|
||
const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
||
const existingNames = Array.from(this.#toolRegistry.keys());
|
||
for (const name of existingNames) {
|
||
if (isMCPToolName(name)) {
|
||
this.#toolRegistry.delete(name);
|
||
}
|
||
}
|
||
|
||
const getCustomToolContext = (): CustomToolContext => ({
|
||
sessionManager: this.sessionManager,
|
||
modelRegistry: this.#modelRegistry,
|
||
model: this.model,
|
||
isIdle: () => !this.isStreaming,
|
||
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
||
abort: () => {
|
||
this.agent.abort();
|
||
},
|
||
});
|
||
|
||
for (const customTool of mcpTools) {
|
||
const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool;
|
||
const finalTool = (
|
||
this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped
|
||
) as AgentTool;
|
||
this.#toolRegistry.set(finalTool.name, finalTool);
|
||
}
|
||
|
||
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
|
||
this.#pruneSelectedMCPToolNames();
|
||
if (!this.buildDisplaySessionContext().hasPersistedMCPToolSelection) {
|
||
this.#selectedMCPToolNames = new Set([
|
||
...this.#selectedMCPToolNames,
|
||
...this.#getConfiguredDefaultSelectedMCPToolNames(),
|
||
]);
|
||
}
|
||
this.#rememberSessionDefaultSelectedMCPToolNames(
|
||
this.sessionFile,
|
||
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
||
);
|
||
|
||
if (options?.activateAll) {
|
||
// Force-activate every newly registered MCP tool. This path is used
|
||
// when an ACP client provisions MCP servers for a session where MCP
|
||
// discovery is disabled — without it, getSelectedMCPToolNames()
|
||
// returns only already-active tools (circular deadlock: tools can
|
||
// only become active if they're already active).
|
||
const newMcpNames = mcpTools.map(t => t.name);
|
||
const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...newMcpNames])];
|
||
await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
|
||
return;
|
||
}
|
||
|
||
const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
|
||
await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
|
||
}
|
||
|
||
/**
|
||
* Replace RPC host-owned tools and refresh the active tool set before the next model call.
|
||
*/
|
||
async refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
|
||
const nextToolNames = rpcTools.map(tool => tool.name);
|
||
const uniqueToolNames = new Set(nextToolNames);
|
||
if (uniqueToolNames.size !== nextToolNames.length) {
|
||
throw new Error("RPC host tool names must be unique");
|
||
}
|
||
|
||
for (const name of uniqueToolNames) {
|
||
if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) {
|
||
throw new Error(`RPC host tool "${name}" conflicts with an existing tool`);
|
||
}
|
||
}
|
||
|
||
const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
|
||
const previousActiveToolNames = this.getActiveToolNames();
|
||
for (const name of previousRpcHostToolNames) {
|
||
this.#toolRegistry.delete(name);
|
||
}
|
||
this.#rpcHostToolNames.clear();
|
||
|
||
for (const tool of rpcTools) {
|
||
const finalTool = (
|
||
this.#extensionRunner ? new ExtensionToolWrapper(tool, this.#extensionRunner) : tool
|
||
) as AgentTool;
|
||
this.#toolRegistry.set(finalTool.name, finalTool);
|
||
this.#rpcHostToolNames.add(finalTool.name);
|
||
}
|
||
|
||
// Registry contents changed — invalidate discovery caches so the next BM25 lookup sees
|
||
// the new RPC-host tool set. (#applyActiveToolsByName below also invalidates, but doing
|
||
// it here too keeps the contract local to "registry mutated".)
|
||
this.#invalidateDiscoveryCaches();
|
||
|
||
const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name));
|
||
const preservedRpcToolNames = previousActiveToolNames.filter(
|
||
name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name),
|
||
);
|
||
const autoActivatedRpcToolNames = rpcTools
|
||
.filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
|
||
.map(tool => tool.name);
|
||
await this.#applyActiveToolsByName(
|
||
Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
|
||
);
|
||
}
|
||
|
||
/** Whether auto-compaction is currently running */
|
||
get isCompacting(): boolean {
|
||
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
|
||
}
|
||
|
||
/**
|
||
* Whether idle-flush tasks, auto-continuations, or other short-lived
|
||
* post-prompt work are pending. True in the brief window after
|
||
* `session.prompt()` returns but before a scheduled background delivery
|
||
* (e.g. an async-job result) has finished its own streaming turn.
|
||
* Loop-mode and similar auto-submit paths should treat this as a block
|
||
* to avoid racing against the delivery turn.
|
||
*/
|
||
get hasPostPromptWork(): boolean {
|
||
return this.#postPromptTasks.size > 0;
|
||
}
|
||
|
||
/** All messages including custom types like BashExecutionMessage */
|
||
get messages(): AgentMessage[] {
|
||
return this.agent.state.messages;
|
||
}
|
||
|
||
buildDisplaySessionContext(): SessionContext {
|
||
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
|
||
}
|
||
|
||
/**
|
||
* Full-history transcript for TUI display: every path entry in
|
||
* chronological order with compactions rendered inline at the point they
|
||
* fired (instead of replacing prior history). Display-only — NEVER feed
|
||
* the result to `agent.replaceMessages` or a provider.
|
||
*/
|
||
buildTranscriptSessionContext(): SessionContext {
|
||
return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
|
||
}
|
||
|
||
#obfuscateForProvider<T>(value: T): T {
|
||
if (!this.#obfuscator?.hasSecrets()) return value;
|
||
return this.#obfuscator.obfuscateObject(value);
|
||
}
|
||
|
||
#obfuscateTextForProvider(text: string | undefined): string | undefined {
|
||
if (!text || !this.#obfuscator?.hasSecrets()) return text;
|
||
return this.#obfuscator.obfuscate(text);
|
||
}
|
||
|
||
#obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
|
||
if (!this.#obfuscator?.hasSecrets()) return preparation;
|
||
if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation;
|
||
return {
|
||
...preparation,
|
||
previousSummary: preparation.previousSummary
|
||
? this.#obfuscator.obfuscate(preparation.previousSummary)
|
||
: preparation.previousSummary,
|
||
previousPreserveData: preparation.previousPreserveData
|
||
? this.#obfuscator.obfuscateObject(preparation.previousPreserveData)
|
||
: preparation.previousPreserveData,
|
||
};
|
||
}
|
||
|
||
#deobfuscateFromProvider(text: string): string {
|
||
if (!this.#obfuscator?.hasSecrets()) return text;
|
||
return this.#obfuscator.deobfuscate(text);
|
||
}
|
||
|
||
#deobfuscatedProviderTextReadyForDelta(text: string): string {
|
||
const deobfuscated = this.#deobfuscateFromProvider(text);
|
||
if (!this.#obfuscator?.hasSecrets()) return deobfuscated;
|
||
const pendingPlaceholderStart = deobfuscated.match(/#[A-Z0-9]{0,4}$/);
|
||
if (pendingPlaceholderStart?.index === undefined) return deobfuscated;
|
||
return deobfuscated.slice(0, pendingPlaceholderStart.index);
|
||
}
|
||
|
||
#convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
|
||
return this.#obfuscateForProvider(convertToLlm(messages));
|
||
}
|
||
|
||
/** Convert session messages using the same pre-LLM pipeline as the active session. */
|
||
async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
|
||
const transformedMessages = await this.#transformContext(messages, signal);
|
||
return await this.#convertToLlm(transformedMessages);
|
||
}
|
||
|
||
/** Apply session-level stream hooks to a direct side request. */
|
||
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions {
|
||
const sessionOnPayload = this.#onPayload;
|
||
const sessionOnResponse = this.#onResponse;
|
||
const sessionMetadata = this.agent.metadataForProvider(provider);
|
||
const sessionOnSseEvent = this.#onSseEvent;
|
||
const openrouterRoutingPreset =
|
||
provider === "openrouter" ? this.settings.get("providers.openrouterVariant") : "default";
|
||
const openrouterVariant =
|
||
openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined
|
||
? openrouterRoutingPreset
|
||
: undefined;
|
||
if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent && !openrouterVariant)
|
||
return options;
|
||
|
||
const preparedOptions: SimpleStreamOptions =
|
||
openrouterVariant === undefined ? { ...options } : { ...options, openrouterVariant };
|
||
|
||
// Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
|
||
// they share the same session bucket as Agent.prompt-routed requests on Anthropic
|
||
// OAuth. Caller-provided metadata wins so explicit overrides are respected.
|
||
if (sessionMetadata && !options.metadata) {
|
||
preparedOptions.metadata = sessionMetadata;
|
||
}
|
||
|
||
if (sessionOnPayload) {
|
||
if (!options.onPayload) {
|
||
preparedOptions.onPayload = sessionOnPayload;
|
||
} else {
|
||
const requestOnPayload = options.onPayload;
|
||
preparedOptions.onPayload = async (payload, model) => {
|
||
const sessionPayload = await sessionOnPayload(payload, model);
|
||
const sessionResolvedPayload = sessionPayload ?? payload;
|
||
const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
|
||
return requestPayload ?? sessionResolvedPayload;
|
||
};
|
||
}
|
||
}
|
||
|
||
if (sessionOnResponse) {
|
||
if (!options.onResponse) {
|
||
preparedOptions.onResponse = sessionOnResponse;
|
||
} else {
|
||
const requestOnResponse = options.onResponse;
|
||
preparedOptions.onResponse = async (response, model) => {
|
||
await sessionOnResponse(response, model);
|
||
await requestOnResponse(response, model);
|
||
};
|
||
}
|
||
}
|
||
|
||
if (sessionOnSseEvent) {
|
||
if (!options.onSseEvent) {
|
||
preparedOptions.onSseEvent = sessionOnSseEvent;
|
||
} else {
|
||
const requestOnSseEvent = options.onSseEvent;
|
||
preparedOptions.onSseEvent = (event, model) => {
|
||
sessionOnSseEvent(event, model);
|
||
requestOnSseEvent(event, model);
|
||
};
|
||
}
|
||
}
|
||
|
||
return preparedOptions;
|
||
}
|
||
|
||
/** Current steering mode */
|
||
get steeringMode(): "all" | "one-at-a-time" {
|
||
return this.agent.getSteeringMode();
|
||
}
|
||
|
||
/** Current follow-up mode */
|
||
get followUpMode(): "all" | "one-at-a-time" {
|
||
return this.agent.getFollowUpMode();
|
||
}
|
||
|
||
/** Current interrupt mode */
|
||
get interruptMode(): "immediate" | "wait" {
|
||
return this.agent.getInterruptMode();
|
||
}
|
||
|
||
/** Current session file path, or undefined if sessions are disabled */
|
||
get sessionFile(): string | undefined {
|
||
return this.sessionManager.getSessionFile();
|
||
}
|
||
|
||
/** Current session ID */
|
||
get sessionId(): string {
|
||
return this.#activeProviderSessionId();
|
||
}
|
||
getEvalSessionId(): string | null {
|
||
if (this.#parentEvalSessionId !== undefined) return this.#parentEvalSessionId;
|
||
return defaultEvalSessionId({
|
||
cwd: this.sessionManager.getCwd(),
|
||
getSessionFile: () => this.sessionManager.getSessionFile() ?? null,
|
||
});
|
||
}
|
||
|
||
/** Current session display name, if set */
|
||
get sessionName(): string | undefined {
|
||
return this.sessionManager.getSessionName();
|
||
}
|
||
|
||
/** Scoped models for cycling (from --models flag) */
|
||
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> {
|
||
return this.#scopedModels;
|
||
}
|
||
|
||
/** Prompt templates */
|
||
getPlanModeState(): PlanModeState | undefined {
|
||
return this.#planModeState;
|
||
}
|
||
|
||
setPlanModeState(state: PlanModeState | undefined): void {
|
||
this.#planModeState = state;
|
||
if (state?.enabled) {
|
||
this.#planReferenceSent = false;
|
||
this.#planReferencePath = state.planFilePath;
|
||
}
|
||
}
|
||
|
||
getGoalModeState(): GoalModeState | undefined {
|
||
return this.#goalModeState;
|
||
}
|
||
|
||
setGoalModeState(state: GoalModeState | undefined): void {
|
||
this.#goalModeState = state;
|
||
}
|
||
|
||
get goalRuntime(): GoalRuntime {
|
||
return this.#goalRuntime;
|
||
}
|
||
|
||
markPlanReferenceSent(): void {
|
||
this.#planReferenceSent = true;
|
||
}
|
||
|
||
setPlanReferencePath(path: string): void {
|
||
this.#planReferencePath = path;
|
||
}
|
||
|
||
getPlanReferencePath(): string {
|
||
return this.#planReferencePath;
|
||
}
|
||
|
||
get clientBridge(): ClientBridge | undefined {
|
||
return this.#clientBridge;
|
||
}
|
||
|
||
setClientBridge(bridge: ClientBridge | undefined): void {
|
||
this.#clientBridge = bridge;
|
||
this.#acpPermissionDecisions.clear();
|
||
const activeToolNames = this.getActiveToolNames();
|
||
const activeTools = activeToolNames
|
||
.map(name => this.#toolRegistry.get(name))
|
||
.filter((tool): tool is AgentTool => tool !== undefined)
|
||
.map(tool => this.#wrapToolForAcpPermission(tool));
|
||
this.agent.setTools(activeTools);
|
||
}
|
||
|
||
getCheckpointState(): CheckpointState | undefined {
|
||
return this.#checkpointState;
|
||
}
|
||
|
||
setCheckpointState(state: CheckpointState | undefined): void {
|
||
this.#checkpointState = state;
|
||
if (!state) {
|
||
this.#pendingRewindReport = undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Inject the plan mode context message into the conversation history.
|
||
*/
|
||
async sendPlanModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
|
||
const message = await this.#buildPlanModeMessage();
|
||
if (!message) return;
|
||
await this.sendCustomMessage(
|
||
{
|
||
customType: message.customType,
|
||
content: message.content,
|
||
display: message.display,
|
||
details: message.details,
|
||
},
|
||
options ? { deliverAs: options.deliverAs } : undefined,
|
||
);
|
||
}
|
||
|
||
async sendGoalModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
|
||
const message = this.#buildGoalModeMessage();
|
||
if (!message) return;
|
||
await this.sendCustomMessage(
|
||
{
|
||
customType: message.customType,
|
||
content: message.content,
|
||
display: message.display,
|
||
details: message.details,
|
||
attribution: message.attribution,
|
||
},
|
||
options ? { deliverAs: options.deliverAs } : undefined,
|
||
);
|
||
}
|
||
|
||
resolveRoleModel(role: string): Model | undefined {
|
||
return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
|
||
}
|
||
|
||
/**
|
||
* Resolve a role to its model AND thinking level.
|
||
* Unlike resolveRoleModel(), this preserves the thinking level suffix
|
||
* from role configuration (e.g., "anthropic/claude-sonnet-4-5:xhigh").
|
||
*/
|
||
resolveRoleModelWithThinking(role: string): ResolvedModelRoleValue {
|
||
return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model);
|
||
}
|
||
|
||
get promptTemplates(): ReadonlyArray<PromptTemplate> {
|
||
return this.#promptTemplates;
|
||
}
|
||
|
||
/** Replace file-based slash commands used for prompt expansion. */
|
||
setSlashCommands(slashCommands: FileSlashCommand[]): void {
|
||
this.#slashCommands = [...slashCommands];
|
||
}
|
||
|
||
/** Custom commands (TypeScript slash commands and MCP prompts) */
|
||
get customCommands(): ReadonlyArray<LoadedCustomCommand> {
|
||
if (this.#mcpPromptCommands.length === 0) return this.#customCommands;
|
||
return [...this.#customCommands, ...this.#mcpPromptCommands];
|
||
}
|
||
|
||
/** MCP prompt commands only, for command-list metadata. */
|
||
get mcpPromptCommands(): ReadonlyArray<LoadedCustomCommand> {
|
||
return this.#mcpPromptCommands;
|
||
}
|
||
|
||
/** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */
|
||
setMCPPromptCommands(commands: LoadedCustomCommand[]): void {
|
||
this.#mcpPromptCommands = commands;
|
||
this.#notifyCommandMetadataChanged();
|
||
}
|
||
|
||
// =========================================================================
|
||
// Prompting
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Build a plan mode message.
|
||
* Returns null if plan mode is not enabled.
|
||
* @returns The plan mode message, or null if plan mode is not enabled.
|
||
*/
|
||
async #buildPlanReferenceMessage(): Promise<CustomMessage | null> {
|
||
if (this.#planModeState?.enabled) return null;
|
||
if (this.#planReferenceSent) return null;
|
||
|
||
const planFilePath = this.#planReferencePath;
|
||
const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, this.#localProtocolOptions());
|
||
let planContent: string;
|
||
try {
|
||
planContent = await Bun.file(resolvedPlanPath).text();
|
||
} catch (error) {
|
||
if (isEnoent(error)) {
|
||
return null;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const content = prompt.render(planModeReferencePrompt, {
|
||
planFilePath,
|
||
planContent,
|
||
});
|
||
|
||
this.#planReferenceSent = true;
|
||
|
||
return {
|
||
role: "custom",
|
||
customType: "plan-mode-reference",
|
||
content,
|
||
display: false,
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
};
|
||
}
|
||
|
||
async #buildPlanModeMessage(): Promise<CustomMessage | null> {
|
||
const state = this.#planModeState;
|
||
if (!state?.enabled) return null;
|
||
const sessionPlanUrl = "local://PLAN.md";
|
||
const resolvedPlanPath = state.planFilePath.startsWith("local:")
|
||
? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), this.#localProtocolOptions())
|
||
: resolveToCwd(state.planFilePath, this.sessionManager.getCwd());
|
||
const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, this.#localProtocolOptions());
|
||
const displayPlanPath =
|
||
state.planFilePath.startsWith("local:") || resolvedPlanPath !== resolvedSessionPlan
|
||
? state.planFilePath
|
||
: sessionPlanUrl;
|
||
|
||
const planExists = fs.existsSync(resolvedPlanPath);
|
||
const content = prompt.render(planModeActivePrompt, {
|
||
planFilePath: displayPlanPath,
|
||
planExists,
|
||
askToolName: "ask",
|
||
writeToolName: "write",
|
||
editToolName: "edit",
|
||
reentry: state.reentry ?? false,
|
||
iterative: state.workflow === "iterative",
|
||
});
|
||
|
||
return {
|
||
role: "custom",
|
||
customType: "plan-mode-context",
|
||
content,
|
||
display: false,
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
};
|
||
}
|
||
|
||
#buildGoalModeMessage(): CustomMessage | null {
|
||
const content = this.#goalRuntime.buildActivePrompt();
|
||
if (!content) return null;
|
||
return {
|
||
role: "custom",
|
||
customType: "goal-mode-context",
|
||
content,
|
||
display: false,
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
};
|
||
}
|
||
|
||
async #normalizeMessageContentImages(
|
||
content: string | (TextContent | ImageContent)[],
|
||
): Promise<string | (TextContent | ImageContent)[]> {
|
||
if (typeof content === "string") return content;
|
||
const images = content.filter((part): part is ImageContent => part.type === "image");
|
||
if (images.length === 0) return content;
|
||
const normalizedImages = await normalizeModelContextImages(images);
|
||
if (!normalizedImages) return content;
|
||
let imageIndex = 0;
|
||
return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part));
|
||
}
|
||
|
||
async #normalizeAgentMessageImages<T extends AgentMessage>(message: T): Promise<T> {
|
||
if (!("content" in message)) return message;
|
||
const content = message.content;
|
||
if (typeof content !== "string" && !Array.isArray(content)) return message;
|
||
const normalized = await this.#normalizeMessageContentImages(content as string | (TextContent | ImageContent)[]);
|
||
if (normalized === content) return message;
|
||
return { ...message, content: normalized } as T;
|
||
}
|
||
|
||
#magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean {
|
||
return this.settings.get("magicKeywords.enabled") && this.settings.get(`magicKeywords.${keyword}`);
|
||
}
|
||
|
||
#createMagicKeywordNotices(text: string): CustomMessage[] {
|
||
const timestamp = Date.now();
|
||
const turnBudget = parseTurnBudget(text);
|
||
this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false);
|
||
const keywordNotices: CustomMessage[] = [];
|
||
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(text)) {
|
||
keywordNotices.push({
|
||
role: "custom",
|
||
customType: "ultrathink-notice",
|
||
content: ULTRATHINK_NOTICE,
|
||
display: false,
|
||
attribution: "user",
|
||
timestamp,
|
||
});
|
||
}
|
||
if (this.#magicKeywordEnabled("orchestrate") && containsOrchestrate(text)) {
|
||
keywordNotices.push({
|
||
role: "custom",
|
||
customType: "orchestrate-notice",
|
||
content: ORCHESTRATE_NOTICE,
|
||
display: false,
|
||
attribution: "user",
|
||
timestamp,
|
||
});
|
||
}
|
||
if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
|
||
keywordNotices.push({
|
||
role: "custom",
|
||
customType: "workflow-notice",
|
||
content: WORKFLOW_NOTICE,
|
||
display: false,
|
||
attribution: "user",
|
||
timestamp,
|
||
});
|
||
}
|
||
return keywordNotices;
|
||
}
|
||
|
||
/**
|
||
* Send a prompt to the agent.
|
||
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
|
||
* - Expands file-based prompt templates by default
|
||
* - During streaming, queues via steer() or followUp() based on streamingBehavior option
|
||
* - Validates model and API key before sending (when not streaming)
|
||
* @throws Error if streaming and no streamingBehavior specified
|
||
* @throws Error if no model selected or no API key available (when not streaming)
|
||
*/
|
||
/**
|
||
* Returns `false` when the command was fully handled locally (extension or
|
||
* custom-TS command consumed without calling the LLM). Returns `true` when
|
||
* the prompt was forwarded to the agent — either directly or queued as a
|
||
* steer/follow-up. Callers that render a UI or manage turn lifecycle (e.g.
|
||
* the ACP agent) use this to know whether to expect an `agent_end` event.
|
||
*/
|
||
async prompt(text: string, options?: PromptOptions): Promise<boolean> {
|
||
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
|
||
|
||
// Handle extension commands first (execute immediately, even during streaming)
|
||
if (expandPromptTemplates && text.startsWith("/")) {
|
||
const handled = await this.#tryExecuteExtensionCommand(text);
|
||
if (handled) {
|
||
return false;
|
||
}
|
||
|
||
// Try custom commands (TypeScript slash commands)
|
||
const customResult = await this.#tryExecuteCustomCommand(text);
|
||
if (customResult !== null) {
|
||
if (customResult === "") {
|
||
return false;
|
||
}
|
||
text = customResult;
|
||
}
|
||
|
||
// Try file-based slash commands (markdown files from commands/ directories)
|
||
// Only if text still starts with "/" (wasn't transformed by custom command)
|
||
if (text.startsWith("/")) {
|
||
text = expandSlashCommand(text, this.#slashCommands);
|
||
}
|
||
}
|
||
|
||
// Expand file-based prompt templates if requested
|
||
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
|
||
|
||
// Magic keywords ("ultrathink", "orchestrate"): append hidden system notices after the
|
||
// user's message that steer this turn. User-authored prompts only — synthetic /
|
||
// agent-initiated turns never trigger them.
|
||
const keywordNotices = options?.synthetic ? [] : this.#createMagicKeywordNotices(expandedText);
|
||
|
||
// If streaming, queue via steer() or followUp() based on option
|
||
if (this.isStreaming) {
|
||
if (!options?.streamingBehavior) {
|
||
throw new AgentBusyError();
|
||
}
|
||
if (options.streamingBehavior === "followUp") {
|
||
await this.#queueFollowUp(expandedText, options?.images);
|
||
} else {
|
||
await this.#queueSteer(expandedText, options?.images);
|
||
}
|
||
// Steer/follow-up the keyword notices alongside the queued user message.
|
||
for (const notice of keywordNotices) {
|
||
await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Skip eager todo prelude when the user has already queued a directive
|
||
const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
|
||
const eagerTodoPrelude =
|
||
!options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
|
||
const normalizedImages = await normalizeModelContextImages(options?.images);
|
||
|
||
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
||
if (normalizedImages) {
|
||
userContent.push(...normalizedImages);
|
||
}
|
||
|
||
const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
|
||
const message = options?.synthetic
|
||
? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
|
||
: { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
|
||
|
||
if (eagerTodoPrelude) {
|
||
this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
|
||
label: "eager-todo",
|
||
});
|
||
}
|
||
|
||
try {
|
||
await this.#promptWithMessage(message, expandedText, {
|
||
...options,
|
||
images: normalizedImages,
|
||
prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
|
||
appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
|
||
});
|
||
} finally {
|
||
// Clean up residual eager-todo directive if the prompt never consumed it
|
||
// (e.g., compaction aborted, validation failed).
|
||
this.#toolChoiceQueue.removeByLabel("eager-todo");
|
||
}
|
||
if (!options?.synthetic) {
|
||
await this.#enforcePlanModeToolDecision();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async promptCustomMessage<T = unknown>(
|
||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
|
||
options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
|
||
): Promise<void> {
|
||
const textContent =
|
||
typeof message.content === "string"
|
||
? message.content
|
||
: message.content
|
||
.filter((content): content is TextContent => content.type === "text")
|
||
.map(content => content.text)
|
||
.join("");
|
||
|
||
let keywordNotices: CustomMessage[] = [];
|
||
if (message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user") {
|
||
const details = message.details;
|
||
let skillArgs = "";
|
||
if (details && typeof details === "object" && "args" in details && typeof details.args === "string") {
|
||
skillArgs = details.args;
|
||
}
|
||
keywordNotices = this.#createMagicKeywordNotices(skillArgs);
|
||
}
|
||
|
||
if (this.isStreaming) {
|
||
if (!options?.streamingBehavior) {
|
||
throw new AgentBusyError();
|
||
}
|
||
await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
|
||
for (const notice of keywordNotices) {
|
||
await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
|
||
}
|
||
return;
|
||
}
|
||
|
||
const customMessage: CustomMessage<T> = {
|
||
role: "custom",
|
||
customType: message.customType,
|
||
content: message.content,
|
||
display: message.display,
|
||
details: message.details,
|
||
attribution: message.attribution ?? "agent",
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
await this.#promptWithMessage(customMessage, textContent, {
|
||
...options,
|
||
appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
|
||
});
|
||
}
|
||
|
||
async #promptWithMessage(
|
||
message: AgentMessage,
|
||
expandedText: string,
|
||
options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
|
||
prependMessages?: AgentMessage[];
|
||
appendMessages?: AgentMessage[];
|
||
skipPostPromptRecoveryWait?: boolean;
|
||
},
|
||
): Promise<void> {
|
||
this.#beginInFlight();
|
||
const generation = this.#promptGeneration;
|
||
try {
|
||
// Flush any pending bash messages before the new prompt
|
||
this.#flushPendingBashMessages();
|
||
this.#flushPendingPythonMessages();
|
||
this.#flushPendingIrcAsides();
|
||
|
||
// Reset todo reminder count on new user prompt
|
||
this.#todoReminderCount = 0;
|
||
this.#emptyStopRetryCount = 0;
|
||
|
||
await this.#maybeRestoreRetryFallbackPrimary();
|
||
|
||
// Validate model
|
||
if (!this.model) {
|
||
throw new Error(
|
||
"No model selected.\n\n" +
|
||
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
|
||
"Then use /model to select a model.",
|
||
);
|
||
}
|
||
|
||
// Validate API key
|
||
const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(
|
||
`No API key found for ${this.model.provider}.\n\n` +
|
||
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
|
||
);
|
||
}
|
||
|
||
// Check if we need to compact before sending (catches aborted responses). Run
|
||
// inline (allowDefer=false) so the handoff/maintenance fully settles before this
|
||
// prompt's agent loop starts — otherwise a deferred handoff would fire on the
|
||
// next microtask alongside the new turn.
|
||
const lastAssistant = this.#findLastAssistantMessage();
|
||
if (lastAssistant && !options?.skipCompactionCheck) {
|
||
await this.#checkCompaction(lastAssistant, false, false, false);
|
||
}
|
||
|
||
// Build messages array (session context, eager todo prelude, then active prompt message)
|
||
const messages: AgentMessage[] = [];
|
||
const planReferenceMessage = await this.#buildPlanReferenceMessage?.();
|
||
if (planReferenceMessage) {
|
||
messages.push(planReferenceMessage);
|
||
}
|
||
const planModeMessage = await this.#buildPlanModeMessage();
|
||
if (planModeMessage) {
|
||
messages.push(planModeMessage);
|
||
}
|
||
const goalModeMessage = this.#buildGoalModeMessage();
|
||
if (goalModeMessage) {
|
||
messages.push(goalModeMessage);
|
||
}
|
||
if (options?.prependMessages) {
|
||
messages.push(...options.prependMessages);
|
||
}
|
||
|
||
messages.push(message);
|
||
|
||
// Inject the ultrathink notice (and any other per-turn appends) right after the
|
||
// user message so the model reads it as part of the same turn.
|
||
if (options?.appendMessages) {
|
||
messages.push(...options.appendMessages);
|
||
}
|
||
|
||
// Early bail-out: if a newer abort/prompt cycle started during setup,
|
||
// return before mutating shared state (nextTurn messages, system prompt).
|
||
if (this.#promptGeneration !== generation) {
|
||
return;
|
||
}
|
||
|
||
// Inject any pending "nextTurn" messages as context alongside the user message
|
||
for (const msg of this.#pendingNextTurnMessages) {
|
||
messages.push(msg);
|
||
}
|
||
this.#pendingNextTurnMessages = [];
|
||
|
||
// Auto-read @filepath mentions
|
||
const fileMentions = extractFileMentions(expandedText);
|
||
if (fileMentions.length > 0) {
|
||
const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
|
||
autoResizeImages: this.settings.get("images.autoResize"),
|
||
useHashLines: resolveFileDisplayMode(this).hashLines,
|
||
snapshotStore: getFileSnapshotStore(this),
|
||
});
|
||
for (const fileMentionMessage of fileMentionMessages) {
|
||
messages.push(await this.#normalizeAgentMessageImages(fileMentionMessage));
|
||
}
|
||
}
|
||
|
||
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
|
||
|
||
// Emit before_agent_start extension event
|
||
if (this.#extensionRunner) {
|
||
const result = await this.#extensionRunner.emitBeforeAgentStart(
|
||
expandedText,
|
||
options?.images,
|
||
beforeAgentStartSystemPrompt,
|
||
);
|
||
if (result?.messages) {
|
||
const promptAttribution: "user" | "agent" | undefined =
|
||
"attribution" in message ? message.attribution : undefined;
|
||
for (const msg of result.messages) {
|
||
messages.push(
|
||
await this.#normalizeAgentMessageImages({
|
||
role: "custom",
|
||
customType: msg.customType,
|
||
content: msg.content,
|
||
display: msg.display,
|
||
details: msg.details,
|
||
attribution:
|
||
msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
|
||
timestamp: Date.now(),
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (result?.systemPrompt !== undefined) {
|
||
this.agent.setSystemPrompt(result.systemPrompt);
|
||
} else {
|
||
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
|
||
}
|
||
} else {
|
||
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
|
||
}
|
||
|
||
// Bail out if a newer abort/prompt cycle has started since we began setup
|
||
if (this.#promptGeneration !== generation) {
|
||
return;
|
||
}
|
||
|
||
// Auto thinking: classify this real user turn and set the effective level
|
||
// before the model request. Synthetic/tool-continuation turns (developer/
|
||
// custom roles) and non-auto sessions are skipped. Never blocks the turn —
|
||
// failures fall back to a concrete level inside the helper.
|
||
if (this.#autoThinking && message.role === "user") {
|
||
await this.#applyAutoThinkingLevel(expandedText, generation);
|
||
if (this.#promptGeneration !== generation) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
await this.#runPrePromptCompactionIfNeeded(messages);
|
||
if (this.#promptGeneration !== generation) {
|
||
return;
|
||
}
|
||
|
||
const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
|
||
await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
|
||
if (!options?.skipPostPromptRecoveryWait) {
|
||
await this.#waitForPostPromptRecovery();
|
||
}
|
||
} finally {
|
||
this.#endInFlight();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Try to execute an extension command. Returns true if command was found and executed.
|
||
*/
|
||
async #tryExecuteExtensionCommand(text: string): Promise<boolean> {
|
||
if (!this.#extensionRunner) return false;
|
||
|
||
// Parse command name and args
|
||
const spaceIndex = text.indexOf(" ");
|
||
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
||
|
||
const command = this.#extensionRunner.getCommand(commandName);
|
||
if (!command) return false;
|
||
|
||
// Get command context from extension runner (includes session control methods)
|
||
const ctx = this.#extensionRunner.createCommandContext();
|
||
|
||
try {
|
||
await command.handler(args, ctx);
|
||
return true;
|
||
} catch (err) {
|
||
// Emit error via extension runner
|
||
this.#extensionRunner.emitError({
|
||
extensionPath: `command:${commandName}`,
|
||
event: "command",
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
return true;
|
||
}
|
||
}
|
||
|
||
#createCommandContext(): ExtensionCommandContext {
|
||
if (this.#extensionRunner) {
|
||
return this.#extensionRunner.createCommandContext();
|
||
}
|
||
|
||
return {
|
||
ui: noOpUIContext,
|
||
hasUI: false,
|
||
cwd: this.sessionManager.getCwd(),
|
||
sessionManager: this.sessionManager,
|
||
modelRegistry: this.#modelRegistry,
|
||
model: this.model ?? undefined,
|
||
isIdle: () => !this.isStreaming,
|
||
abort: () => {
|
||
void this.abort();
|
||
},
|
||
hasPendingMessages: () => this.queuedMessageCount > 0,
|
||
shutdown: () => {
|
||
void this.dispose();
|
||
process.exit(0);
|
||
},
|
||
getContextUsage: () => this.getContextUsage(),
|
||
waitForIdle: () => this.waitForIdle(),
|
||
newSession: async options => {
|
||
const success = await this.newSession({ parentSession: options?.parentSession });
|
||
if (!success) {
|
||
return { cancelled: true };
|
||
}
|
||
if (options?.setup) {
|
||
await options.setup(this.sessionManager);
|
||
}
|
||
return { cancelled: false };
|
||
},
|
||
branch: async entryId => {
|
||
const result = await this.branch(entryId);
|
||
return { cancelled: result.cancelled };
|
||
},
|
||
navigateTree: async (targetId, options) => {
|
||
const result = await this.navigateTree(targetId, { summarize: options?.summarize });
|
||
return { cancelled: result.cancelled };
|
||
},
|
||
compact: async instructionsOrOptions => {
|
||
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
|
||
const options =
|
||
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
|
||
await this.compact(instructions, options);
|
||
},
|
||
switchSession: async sessionPath => {
|
||
const success = await this.switchSession(sessionPath);
|
||
return { cancelled: !success };
|
||
},
|
||
reload: async () => {
|
||
await this.reload();
|
||
},
|
||
getSystemPrompt: () => this.systemPrompt,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Try to execute a custom command. Returns the prompt string if found, null otherwise.
|
||
* If the command returns void, returns empty string to indicate it was handled.
|
||
*/
|
||
async #tryExecuteCustomCommand(text: string): Promise<string | null> {
|
||
if (this.#customCommands.length === 0 && this.#mcpPromptCommands.length === 0) return null;
|
||
|
||
// Parse command name and args
|
||
const spaceIndex = text.indexOf(" ");
|
||
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
||
|
||
// Find matching command
|
||
const loaded =
|
||
this.#customCommands.find(c => c.command.name === commandName) ??
|
||
this.#mcpPromptCommands.find(c => c.command.name === commandName);
|
||
if (!loaded) return null;
|
||
|
||
// Get command context from extension runner (includes session control methods)
|
||
const baseCtx = this.#createCommandContext();
|
||
const ctx = {
|
||
...baseCtx,
|
||
hasQueuedMessages: baseCtx.hasPendingMessages,
|
||
} as unknown as HookCommandContext;
|
||
|
||
try {
|
||
const args = parseCommandArgs(argsString);
|
||
const result = await loaded.command.execute(args, ctx);
|
||
// If result is a string, it's a prompt to send to LLM
|
||
// If void/undefined, command handled everything
|
||
return result ?? "";
|
||
} catch (err) {
|
||
// Emit error via extension runner
|
||
if (this.#extensionRunner) {
|
||
this.#extensionRunner.emitError({
|
||
extensionPath: `custom-command:${commandName}`,
|
||
event: "command",
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
} else {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
logger.error("Custom command failed", { commandName, error: message });
|
||
}
|
||
return ""; // Command was handled (with error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Queue a steering message to interrupt the agent mid-run.
|
||
*/
|
||
async steer(text: string, images?: ImageContent[]): Promise<void> {
|
||
if (text.startsWith("/")) {
|
||
this.#throwIfExtensionCommand(text);
|
||
}
|
||
|
||
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
|
||
await this.#queueSteer(expandedText, images);
|
||
}
|
||
|
||
/**
|
||
* Queue a follow-up message to process after the agent would otherwise stop.
|
||
*/
|
||
async followUp(text: string, images?: ImageContent[]): Promise<void> {
|
||
if (text.startsWith("/")) {
|
||
this.#throwIfExtensionCommand(text);
|
||
}
|
||
|
||
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
|
||
await this.#queueFollowUp(expandedText, images);
|
||
}
|
||
|
||
/**
|
||
* Internal: Queue a steering message (already expanded, no extension command check).
|
||
*/
|
||
async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
|
||
const normalizedImages = await normalizeModelContextImages(images);
|
||
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
||
this.#steeringMessages.push({ text: displayText, images });
|
||
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
||
if (normalizedImages && normalizedImages.length > 0) {
|
||
content.push(...normalizedImages);
|
||
}
|
||
this.agent.steer({
|
||
role: "user",
|
||
content,
|
||
steering: true,
|
||
attribution: "user",
|
||
timestamp: Date.now(),
|
||
});
|
||
// A steer can land on an idle session: the caller checked isStreaming
|
||
// before the (potentially slow) image normalization above, so the turn
|
||
// may have ended in between. Without a drain the message would strand in
|
||
// the queue until the next manual prompt — schedule an immediate continue,
|
||
// mirroring #queueFollowUp's idle-path delivery.
|
||
if (this.#canAutoContinueForFollowUp()) {
|
||
this.#scheduleAgentContinue({
|
||
shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Internal: Queue a follow-up message (already expanded, no extension command check).
|
||
*/
|
||
async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
|
||
const normalizedImages = await normalizeModelContextImages(images);
|
||
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
||
this.#followUpMessages.push({ text: displayText, images });
|
||
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
||
if (normalizedImages && normalizedImages.length > 0) {
|
||
content.push(...normalizedImages);
|
||
}
|
||
this.agent.followUp({
|
||
role: "user",
|
||
content,
|
||
attribution: "user",
|
||
timestamp: Date.now(),
|
||
});
|
||
// When fully idle AND the session is in a resumable assistant-ended state,
|
||
// schedule an immediate continue so the queued follow-up is delivered
|
||
// without waiting for the next user turn. We gate on isStreaming (model
|
||
// actively producing), isRetrying (auto-retry backoff is sleeping between
|
||
// attempts, #retryPromise set), and the last message being assistant —
|
||
// agent.continue() only dequeues follow-ups from an assistant-ended state;
|
||
// resuming from user/toolResult state runs an extra model call on the
|
||
// stale prompt before draining the queue.
|
||
if (this.#canAutoContinueForFollowUp()) {
|
||
this.#scheduleAgentContinue({
|
||
shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
|
||
*/
|
||
#canAutoContinueForFollowUp(): boolean {
|
||
if (this.isStreaming) return false;
|
||
if (this.isRetrying) return false;
|
||
const messages = this.agent.state.messages;
|
||
const last = messages[messages.length - 1];
|
||
return last?.role === "assistant";
|
||
}
|
||
|
||
queueDeferredMessage(message: CustomMessage): void {
|
||
this.#queueHiddenNextTurnMessage(message, true);
|
||
}
|
||
|
||
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
|
||
this.#pendingNextTurnMessages.push(message);
|
||
if (!triggerTurn) return;
|
||
const generation = this.#promptGeneration;
|
||
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
||
return;
|
||
}
|
||
this.#scheduledHiddenNextTurnGeneration = generation;
|
||
this.#schedulePostPromptTask(
|
||
async () => {
|
||
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
}
|
||
if (this.#pendingNextTurnMessages.length === 0) {
|
||
return;
|
||
}
|
||
try {
|
||
await this.#promptQueuedHiddenNextTurnMessages();
|
||
} catch {
|
||
// Leave the hidden next-turn messages queued for the next explicit prompt.
|
||
}
|
||
},
|
||
{
|
||
generation,
|
||
onSkip: () => {
|
||
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
}
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
|
||
if (this.#pendingNextTurnMessages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const queuedMessages = [...this.#pendingNextTurnMessages];
|
||
this.#pendingNextTurnMessages = [];
|
||
const message = queuedMessages[queuedMessages.length - 1];
|
||
if (!message) {
|
||
return;
|
||
}
|
||
|
||
const prependMessages = queuedMessages.slice(0, -1);
|
||
const textContent = this.#getCustomMessageTextContent(message);
|
||
try {
|
||
await this.#promptWithMessage(message, textContent, {
|
||
prependMessages,
|
||
skipPostPromptRecoveryWait: true,
|
||
});
|
||
} catch (error) {
|
||
this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
#getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
|
||
if (typeof message.content === "string") {
|
||
return message.content;
|
||
}
|
||
return message.content
|
||
.filter((content): content is TextContent => content.type === "text")
|
||
.map(content => content.text)
|
||
.join("");
|
||
}
|
||
|
||
/**
|
||
* Throw an error if the text is an extension command.
|
||
*/
|
||
#throwIfExtensionCommand(text: string): void {
|
||
if (!this.#extensionRunner) return;
|
||
|
||
const spaceIndex = text.indexOf(" ");
|
||
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||
const command = this.#extensionRunner.getCommand(commandName);
|
||
|
||
if (command) {
|
||
throw new Error(
|
||
`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send a custom message to the session. Creates a CustomMessageEntry.
|
||
*
|
||
* Handles three cases:
|
||
* - Streaming: queue as steer/follow-up or store for next turn
|
||
* - Not streaming + triggerTurn: appends to state/session, starts new turn unless the client cannot own it
|
||
* - Not streaming + no trigger: appends to state/session, no turn
|
||
*/
|
||
async sendCustomMessage<T = unknown>(
|
||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
|
||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||
): Promise<void> {
|
||
const appMessage: CustomMessage<T> = {
|
||
role: "custom",
|
||
customType: message.customType,
|
||
content: message.content,
|
||
display: message.display,
|
||
details: message.details,
|
||
attribution: message.attribution ?? "agent",
|
||
timestamp: Date.now(),
|
||
};
|
||
const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
|
||
if (this.isStreaming) {
|
||
if (options?.deliverAs === "nextTurn") {
|
||
this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false);
|
||
return;
|
||
}
|
||
|
||
if (options?.deliverAs === "followUp") {
|
||
this.agent.followUp(normalizedAppMessage);
|
||
} else {
|
||
this.agent.steer(normalizedAppMessage);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (options?.deliverAs === "nextTurn") {
|
||
if (options?.triggerTurn) {
|
||
if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
|
||
this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
|
||
return;
|
||
}
|
||
await this.agent.prompt(normalizedAppMessage);
|
||
return;
|
||
}
|
||
this.agent.appendMessage(normalizedAppMessage);
|
||
this.sessionManager.appendCustomMessageEntry(
|
||
normalizedAppMessage.customType,
|
||
normalizedAppMessage.content,
|
||
message.display,
|
||
message.details,
|
||
message.attribution ?? "agent",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (options?.triggerTurn) {
|
||
if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
|
||
this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
|
||
return;
|
||
}
|
||
await this.agent.prompt(normalizedAppMessage);
|
||
return;
|
||
}
|
||
|
||
this.agent.appendMessage(normalizedAppMessage);
|
||
this.sessionManager.appendCustomMessageEntry(
|
||
normalizedAppMessage.customType,
|
||
normalizedAppMessage.content,
|
||
message.display,
|
||
message.details,
|
||
message.attribution ?? "agent",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Send a user message to the agent.
|
||
* When deliverAs is set, queue the message instead of starting a new turn.
|
||
*
|
||
* @param content User message content (string or content array)
|
||
* @param options.deliverAs Delivery mode: "steer" or "followUp"
|
||
*/
|
||
async sendUserMessage(
|
||
content: string | (TextContent | ImageContent)[],
|
||
options?: { deliverAs?: "steer" | "followUp" },
|
||
): Promise<void> {
|
||
// Normalize content to text string + optional images
|
||
let text: string;
|
||
let images: ImageContent[] | undefined;
|
||
|
||
if (typeof content === "string") {
|
||
text = content;
|
||
} else {
|
||
const textParts: string[] = [];
|
||
images = [];
|
||
for (const part of content) {
|
||
if (part.type === "text") {
|
||
textParts.push(part.text);
|
||
} else {
|
||
images.push(part);
|
||
}
|
||
}
|
||
text = textParts.join("\n");
|
||
if (images.length === 0) images = undefined;
|
||
}
|
||
|
||
if (options?.deliverAs === "followUp") {
|
||
await this.#queueFollowUp(text, images);
|
||
return;
|
||
}
|
||
if (options?.deliverAs === "steer") {
|
||
await this.#queueSteer(text, images);
|
||
return;
|
||
}
|
||
|
||
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
|
||
await this.prompt(text, {
|
||
expandPromptTemplates: false,
|
||
images,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Clear queued messages and return them (text plus any attached images).
|
||
* Useful for restoring to editor when user aborts. The internal entry
|
||
* arrays are handed out as-is — a `tag` (if any) is inert once the record
|
||
* leaves the queue.
|
||
*/
|
||
clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
|
||
const steering = this.#steeringMessages;
|
||
const followUp = this.#followUpMessages;
|
||
this.#steeringMessages = [];
|
||
this.#followUpMessages = [];
|
||
this.agent.clearAllQueues();
|
||
return { steering, followUp };
|
||
}
|
||
|
||
/** Number of pending messages (includes steering, follow-up, and next-turn messages) */
|
||
get queuedMessageCount(): number {
|
||
return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
|
||
}
|
||
|
||
/** Get pending messages (read-only). Returns the public text-only view;
|
||
* internal `{text, tag?}` records are mapped to `.text` so callers
|
||
* (`updatePendingMessagesDisplay`, `restoreQueuedMessagesToEditor`) see
|
||
* the unchanged historical shape. */
|
||
getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
|
||
return {
|
||
steering: this.#steeringMessages.map(e => e.text),
|
||
followUp: this.#followUpMessages.map(e => e.text),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Pop the last queued message (steering first, then follow-up).
|
||
* Used by dequeue keybinding to restore messages to editor one at a time.
|
||
* Returns the popped entry's text and images; the tag (if any) dies with
|
||
* the record — no orphan state can outlive the queue entry.
|
||
*/
|
||
popLastQueuedMessage(): RestoredQueuedMessage | undefined {
|
||
// Pop from steering first (LIFO)
|
||
if (this.#steeringMessages.length > 0) {
|
||
const entry = this.#steeringMessages.pop();
|
||
this.agent.popLastSteer();
|
||
return entry;
|
||
}
|
||
// Then from follow-up
|
||
if (this.#followUpMessages.length > 0) {
|
||
const entry = this.#followUpMessages.pop();
|
||
this.agent.popLastFollowUp();
|
||
return entry;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
get skillsSettings(): SkillsSettings | undefined {
|
||
return this.#skillsSettings;
|
||
}
|
||
|
||
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
|
||
get skills(): readonly Skill[] {
|
||
return this.#skills;
|
||
}
|
||
|
||
/** Skill loading warnings captured by SDK */
|
||
get skillWarnings(): readonly SkillWarning[] {
|
||
return this.#skillWarnings;
|
||
}
|
||
|
||
getTodoPhases(): TodoPhase[] {
|
||
return this.#cloneTodoPhases(this.#todoPhases);
|
||
}
|
||
|
||
setTodoPhases(phases: TodoPhase[]): void {
|
||
this.#todoPhases = this.#cloneTodoPhases(phases);
|
||
}
|
||
|
||
#syncTodoPhasesFromBranch(): void {
|
||
const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
|
||
// Strip completed/abandoned tasks — they were done in a previous run,
|
||
// so they have no bearing on progress tracking for the new turn.
|
||
for (const phase of phases) {
|
||
phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
|
||
}
|
||
this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
|
||
}
|
||
|
||
#cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
|
||
return phases.map(phase => ({
|
||
name: phase.name,
|
||
tasks: phase.tasks.map(task => ({ content: task.content, status: task.status })),
|
||
}));
|
||
}
|
||
|
||
// Auto-clear of completed/abandoned tasks was removed: the timer-driven
|
||
// splice mutated canonical `#todoPhases` between tool calls, so the model
|
||
// observed phase totals shrinking ("5 → 4") after marking tasks done. The
|
||
// `tasks.todoClearDelay` setting is now inert; completed tasks survive
|
||
// until the next explicit `todo` call removes them via `rm`/`drop`.
|
||
|
||
/**
|
||
* Abort current operation and wait for agent to become idle.
|
||
*
|
||
* `reason` (e.g. `USER_INTERRUPT_LABEL`) rides the agent's `AbortController`
|
||
* and surfaces verbatim on the aborted assistant message's `errorMessage`, so
|
||
* the transcript can distinguish a deliberate user interrupt from an opaque
|
||
* abort. Omit it for internal/lifecycle aborts.
|
||
*/
|
||
async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
|
||
this.abortRetry();
|
||
this.#promptGeneration++;
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
this.abortCompaction();
|
||
this.abortHandoff();
|
||
this.abortBash();
|
||
this.abortEval();
|
||
const postPromptDrain = this.#cancelPostPromptTasks();
|
||
this.agent.abort(options?.reason);
|
||
await postPromptDrain;
|
||
await this.agent.waitForIdle();
|
||
await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
|
||
// Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
|
||
// block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
|
||
// a subsequent prompt() can incorrectly observe the session as busy after an abort.
|
||
this.#resetInFlight();
|
||
// Safety net: if the agent loop aborted without producing an assistant
|
||
// message (e.g. failed before the first stream), the in-flight yield was
|
||
// never resolved or rejected by the normal message_end path. Reject it now
|
||
// so any requeue callback still fires and the queue stays consistent.
|
||
if (this.#toolChoiceQueue.hasInFlight) {
|
||
this.#toolChoiceQueue.reject("aborted");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Abort active work, then immediately resume the agent so queued steer/follow-up
|
||
* messages drain instead of waiting for another natural turn boundary.
|
||
*/
|
||
async interruptAndFlushQueuedMessages(options?: { reason?: string }): Promise<void> {
|
||
if (!this.agent.hasQueuedMessages()) return;
|
||
await this.abort({ reason: options?.reason });
|
||
if (!this.agent.hasQueuedMessages()) return;
|
||
if (this.isCompacting || this.isGeneratingHandoff) return;
|
||
await this.#maybeRestoreRetryFallbackPrimary();
|
||
await this.agent.continue();
|
||
}
|
||
|
||
/**
|
||
* Start a new session, optionally with initial messages and parent tracking.
|
||
* Clears all messages and starts a new session.
|
||
* Listeners are preserved and will continue receiving events.
|
||
* @param options - Optional initial messages and parent session path
|
||
* @returns true if completed, false if cancelled by hook
|
||
*/
|
||
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
||
const previousSessionFile = this.sessionFile;
|
||
const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
|
||
? [
|
||
...this.#getActiveNonMCPToolNames(),
|
||
...this.#filterSelectableMCPToolNames(this.#defaultSelectedMCPToolNames),
|
||
]
|
||
: undefined;
|
||
|
||
// Emit session_before_switch event with reason "new" (can be cancelled)
|
||
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session_before_switch",
|
||
reason: "new",
|
||
})) as SessionBeforeSwitchResult | undefined;
|
||
|
||
if (result?.cancel) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
this.#disconnectFromAgent();
|
||
await this.abort();
|
||
this.#cancelOwnAsyncJobs();
|
||
this.#closeAllProviderSessions("new session");
|
||
this.agent.reset();
|
||
if (options?.drop && previousSessionFile) {
|
||
try {
|
||
await this.sessionManager.dropSession(previousSessionFile);
|
||
} catch (err) {
|
||
logger.error("Failed to delete session during /drop", { err });
|
||
}
|
||
} else {
|
||
await this.sessionManager.flush();
|
||
}
|
||
await this.sessionManager.newSession(options);
|
||
this.setTodoPhases([]);
|
||
this.#freshProviderSessionId = undefined;
|
||
this.#syncAgentSessionId();
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
this.#resetHindsightConversationTrackingIfHindsight();
|
||
this.#resetMnemopiConversationTrackingIfMnemopi();
|
||
this.#steeringMessages = [];
|
||
this.#followUpMessages = [];
|
||
this.#pendingNextTurnMessages = [];
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
|
||
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
||
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
||
if (nextDiscoverySessionToolNames) {
|
||
await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
|
||
if (this.getSelectedMCPToolNames().length > 0) {
|
||
this.sessionManager.appendMCPToolSelection(this.getSelectedMCPToolNames());
|
||
}
|
||
}
|
||
this.#rememberSessionDefaultSelectedMCPToolNames(
|
||
this.sessionFile,
|
||
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
||
);
|
||
|
||
this.#todoReminderCount = 0;
|
||
this.#planReferenceSent = false;
|
||
this.#planReferencePath = "local://PLAN.md";
|
||
this.#reconnectToAgent();
|
||
|
||
// Emit session_switch event with reason "new" to hooks
|
||
if (this.#extensionRunner) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_switch",
|
||
reason: "new",
|
||
previousSessionFile,
|
||
});
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Set a display name for the current session.
|
||
*/
|
||
setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
||
return this.sessionManager.setSessionName(name, source);
|
||
}
|
||
|
||
/**
|
||
* Fork the current session, creating a new session file with the exact same state.
|
||
* Copies all entries and artifacts to the new session.
|
||
* Unlike newSession(), this preserves all messages in the agent state.
|
||
* @returns true if completed, false if cancelled by hook or not persisting
|
||
*/
|
||
async fork(): Promise<boolean> {
|
||
const previousSessionFile = this.sessionFile;
|
||
|
||
// Emit session_before_switch event with reason "fork" (can be cancelled)
|
||
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session_before_switch",
|
||
reason: "fork",
|
||
})) as SessionBeforeSwitchResult | undefined;
|
||
|
||
if (result?.cancel) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Flush current session to ensure all entries are written
|
||
await this.sessionManager.flush();
|
||
|
||
// Fork the session (creates new session file with same entries)
|
||
const forkResult = await this.sessionManager.fork();
|
||
if (!forkResult) {
|
||
return false;
|
||
}
|
||
|
||
// Copy artifacts directory if it exists
|
||
const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
|
||
const newArtifactDir = forkResult.newSessionFile.slice(0, -6);
|
||
|
||
try {
|
||
const oldDirStat = await fs.promises.stat(oldArtifactDir);
|
||
if (oldDirStat.isDirectory()) {
|
||
await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true });
|
||
}
|
||
} catch (err) {
|
||
if (!isEnoent(err)) {
|
||
logger.warn("Failed to copy artifacts during fork", {
|
||
oldArtifactDir,
|
||
newArtifactDir,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Update agent session ID
|
||
this.#freshProviderSessionId = undefined;
|
||
this.#syncAgentSessionId();
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
this.#resetMnemopiConversationTrackingIfMnemopi();
|
||
|
||
// Emit session_switch event with reason "fork" to hooks
|
||
if (this.#extensionRunner) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_switch",
|
||
reason: "fork",
|
||
previousSessionFile,
|
||
});
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// =========================================================================
|
||
// Model Management
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Set model directly.
|
||
* Validates API key and saves to the active session. Persists settings only when requested.
|
||
* @throws Error if no API key available for the model
|
||
*/
|
||
async setModel(
|
||
model: Model,
|
||
role: string = "default",
|
||
options?: { selector?: string; thinkingLevel?: ThinkingLevel; persist?: boolean },
|
||
): Promise<void> {
|
||
const previousEditMode = this.#resolveActiveEditMode();
|
||
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
||
}
|
||
|
||
this.#clearActiveRetryFallback();
|
||
this.#setModelWithProviderSessionReset(model);
|
||
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
|
||
if (options?.persist) {
|
||
this.settings.setModelRole(
|
||
role,
|
||
this.#formatRoleModelValue(role, model, options.selector, options.thinkingLevel),
|
||
);
|
||
}
|
||
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
||
|
||
// Re-apply thinking for the newly selected model. Prefer the model's
|
||
// configured defaultLevel; otherwise preserve the current level (or auto).
|
||
this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
|
||
await this.#syncAfterModelChange(previousEditMode);
|
||
}
|
||
|
||
/**
|
||
* Set model temporarily (for this session only).
|
||
* Validates API key, saves to session log but NOT to settings.
|
||
* @throws Error if no API key available for the model
|
||
*/
|
||
async setModelTemporary(
|
||
model: Model,
|
||
thinkingLevel?: ThinkingLevel,
|
||
options?: { ephemeral?: boolean },
|
||
): Promise<void> {
|
||
const previousEditMode = this.#resolveActiveEditMode();
|
||
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
||
}
|
||
|
||
this.#clearActiveRetryFallback();
|
||
this.#setModelWithProviderSessionReset(model);
|
||
this.sessionManager.appendModelChange(
|
||
`${model.provider}/${model.id}`,
|
||
options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
|
||
);
|
||
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
||
|
||
// Apply explicit thinking level if given; otherwise prefer the model's
|
||
// configured defaultLevel; otherwise re-clamp the current level (or auto).
|
||
if (thinkingLevel !== undefined) {
|
||
this.setThinkingLevel(thinkingLevel);
|
||
} else {
|
||
this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
|
||
}
|
||
await this.#syncAfterModelChange(previousEditMode);
|
||
}
|
||
|
||
/**
|
||
* Cycle to next/previous model.
|
||
* Uses scoped models (from --models flag) if available, otherwise all available models.
|
||
* @param direction - "forward" (default) or "backward"
|
||
* @returns The new model info, or undefined if only one model available
|
||
*/
|
||
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
|
||
if (this.#scopedModels.length > 0) {
|
||
return this.#cycleScopedModel(direction);
|
||
}
|
||
return this.#cycleAvailableModel(direction);
|
||
}
|
||
|
||
/**
|
||
* Resolve the configured role models in the given order plus the index of
|
||
* the currently active one. Roles that have no configured model, or whose
|
||
* configured model is not currently available, are skipped. The `default`
|
||
* role falls back to the active model when no explicit assignment exists.
|
||
*
|
||
* Returns `undefined` only when there is no current model or no available
|
||
* models at all; an empty `models` array is never returned (callers should
|
||
* still guard on `models.length`).
|
||
*/
|
||
getRoleModelCycle(roleOrder: readonly string[]): RoleModelCycle | undefined {
|
||
const availableModels = this.#modelRegistry.getAvailable();
|
||
if (availableModels.length === 0) return undefined;
|
||
|
||
const currentModel = this.model;
|
||
if (!currentModel) return undefined;
|
||
const matchPreferences = getModelMatchPreferences(this.settings);
|
||
const models: ResolvedRoleModel[] = [];
|
||
|
||
for (const role of roleOrder) {
|
||
const roleModelStr =
|
||
role === "default"
|
||
? (this.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
|
||
: this.settings.getModelRole(role);
|
||
if (!roleModelStr) continue;
|
||
|
||
const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
|
||
settings: this.settings,
|
||
matchPreferences,
|
||
modelRegistry: this.#modelRegistry,
|
||
});
|
||
if (!resolved.model) continue;
|
||
|
||
models.push({
|
||
role,
|
||
model: resolved.model,
|
||
thinkingLevel: resolved.thinkingLevel,
|
||
explicitThinkingLevel: resolved.explicitThinkingLevel,
|
||
});
|
||
}
|
||
|
||
if (models.length === 0) return undefined;
|
||
|
||
const lastRole = this.sessionManager.getLastModelChangeRole();
|
||
let currentIndex = lastRole ? models.findIndex(entry => entry.role === lastRole) : -1;
|
||
if (currentIndex === -1) {
|
||
currentIndex = models.findIndex(entry => modelsAreEqual(entry.model, currentModel));
|
||
}
|
||
if (currentIndex === -1) currentIndex = 0;
|
||
|
||
return { models, currentIndex };
|
||
}
|
||
|
||
/**
|
||
* Apply a resolved role model as the active model without changing global
|
||
* settings. Shared with role cycling and the plan-approval model slider.
|
||
*/
|
||
async applyRoleModel(entry: ResolvedRoleModel): Promise<void> {
|
||
await this.setModel(entry.model, entry.role);
|
||
if (entry.explicitThinkingLevel && entry.thinkingLevel !== undefined) {
|
||
this.setThinkingLevel(entry.thinkingLevel);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cycle through configured role models in a fixed order.
|
||
* Skips missing roles and changes only the active session model.
|
||
* @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"])
|
||
* @param direction - "forward" (default) or "backward"
|
||
*/
|
||
async cycleRoleModels(
|
||
roleOrder: readonly string[],
|
||
direction: "forward" | "backward" = "forward",
|
||
): Promise<RoleModelCycleResult | undefined> {
|
||
const cycle = this.getRoleModelCycle(roleOrder);
|
||
if (!cycle || cycle.models.length <= 1) return undefined;
|
||
|
||
const step = direction === "backward" ? -1 : 1;
|
||
const next = cycle.models[(cycle.currentIndex + step + cycle.models.length) % cycle.models.length];
|
||
|
||
await this.applyRoleModel(next);
|
||
|
||
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
|
||
}
|
||
|
||
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel?: ThinkingLevel }>> {
|
||
const apiKeysByProvider = new Map<string, string | undefined>();
|
||
const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = [];
|
||
|
||
for (const scoped of this.#scopedModels) {
|
||
const provider = scoped.model.provider;
|
||
let apiKey: string | undefined;
|
||
if (apiKeysByProvider.has(provider)) {
|
||
apiKey = apiKeysByProvider.get(provider);
|
||
} else {
|
||
apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId);
|
||
apiKeysByProvider.set(provider, apiKey);
|
||
}
|
||
|
||
if (apiKey) {
|
||
result.push(scoped);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
||
const previousEditMode = this.#resolveActiveEditMode();
|
||
const scopedModels = await this.#getScopedModelsWithApiKey();
|
||
if (scopedModels.length <= 1) return undefined;
|
||
|
||
const currentModel = this.model;
|
||
let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
|
||
|
||
if (currentIndex === -1) currentIndex = 0;
|
||
const len = scopedModels.length;
|
||
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
||
const next = scopedModels[nextIndex];
|
||
|
||
// Apply model
|
||
this.#clearActiveRetryFallback();
|
||
this.#setModelWithProviderSessionReset(next.model);
|
||
this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
|
||
this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
|
||
|
||
// Apply the scoped model's configured thinking level, preserving auto.
|
||
this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel);
|
||
await this.#syncAfterModelChange(previousEditMode);
|
||
|
||
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
|
||
}
|
||
|
||
async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
||
const previousEditMode = this.#resolveActiveEditMode();
|
||
const availableModels = this.#modelRegistry.getAvailable();
|
||
if (availableModels.length <= 1) return undefined;
|
||
|
||
const currentModel = this.model;
|
||
let currentIndex = availableModels.findIndex(m => modelsAreEqual(m, currentModel));
|
||
|
||
if (currentIndex === -1) currentIndex = 0;
|
||
const len = availableModels.length;
|
||
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
||
const nextModel = availableModels[nextIndex];
|
||
|
||
const apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
|
||
}
|
||
|
||
this.#clearActiveRetryFallback();
|
||
this.#setModelWithProviderSessionReset(nextModel);
|
||
this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
|
||
this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
|
||
// Re-apply the current thinking level (or auto) for the newly selected model
|
||
this.#reapplyThinkingLevel();
|
||
await this.#syncAfterModelChange(previousEditMode);
|
||
|
||
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
|
||
}
|
||
|
||
/**
|
||
* Get all available models with valid API keys, filtered by `enabledModels` when configured.
|
||
* See {@link filterAvailableModelsByEnabledPatterns} for supported pattern forms and limitations.
|
||
*/
|
||
getAvailableModels(): Model[] {
|
||
const all = this.#modelRegistry.getAvailable();
|
||
const patterns = this.settings.get("enabledModels");
|
||
if (!patterns || patterns.length === 0) return all;
|
||
return filterAvailableModelsByEnabledPatterns(all, patterns, this.#modelRegistry);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Thinking Level Management
|
||
// =========================================================================
|
||
|
||
#applyThinkingLevelToAgent(level: ThinkingLevel | undefined): void {
|
||
this.agent.setThinkingLevel(toReasoningEffort(level));
|
||
this.agent.setDisableReasoning(shouldDisableReasoning(level));
|
||
}
|
||
|
||
/**
|
||
* Set the thinking level. `auto` enables per-turn classification; the selector
|
||
* itself is never written to the session log, but resolved concrete levels are
|
||
* persisted when real user turns are classified so resumed sessions keep the
|
||
* last resolved effort instead of reverting to pending auto.
|
||
*/
|
||
setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void {
|
||
if (level === AUTO_THINKING) {
|
||
const provisional = resolveProvisionalAutoLevel(this.model);
|
||
const wasAuto = this.#autoThinking;
|
||
this.#autoThinking = true;
|
||
this.#autoResolvedLevel = undefined;
|
||
this.#thinkingLevel = provisional;
|
||
this.#applyThinkingLevelToAgent(provisional);
|
||
if (persist) {
|
||
this.settings.set("defaultThinkingLevel", AUTO_THINKING);
|
||
}
|
||
if (!wasAuto || this.#thinkingLevel !== provisional) {
|
||
this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING });
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.#autoThinking = false;
|
||
this.#autoResolvedLevel = undefined;
|
||
const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
|
||
const isChanging = effectiveLevel !== this.#thinkingLevel;
|
||
|
||
this.#thinkingLevel = effectiveLevel;
|
||
this.#applyThinkingLevelToAgent(effectiveLevel);
|
||
|
||
if (isChanging) {
|
||
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
||
if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
|
||
this.settings.set("defaultThinkingLevel", effectiveLevel);
|
||
}
|
||
this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-apply the active thinking selection after a model change. Preserves `auto`
|
||
* (re-clamping the provisional level to the new model); otherwise re-applies the
|
||
* preferred default or the current effective level.
|
||
*/
|
||
#reapplyThinkingLevel(preferredDefault?: ThinkingLevel): void {
|
||
this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : (preferredDefault ?? this.#thinkingLevel));
|
||
}
|
||
|
||
/**
|
||
* Cycle to next thinking level: off → auto → minimal..xhigh → off.
|
||
* @returns New selector, or undefined if model doesn't support thinking
|
||
*/
|
||
cycleThinkingLevel(): ConfiguredThinkingLevel | undefined {
|
||
if (!this.model?.reasoning) return undefined;
|
||
|
||
const levels: ConfiguredThinkingLevel[] = [
|
||
ThinkingLevel.Off,
|
||
AUTO_THINKING,
|
||
...this.getAvailableThinkingLevels(),
|
||
];
|
||
const configured = this.configuredThinkingLevel();
|
||
const currentLevel = configured === ThinkingLevel.Inherit ? ThinkingLevel.Off : configured;
|
||
const currentIndex = currentLevel ? levels.indexOf(currentLevel) : -1;
|
||
const nextIndex = (currentIndex + 1) % levels.length;
|
||
const nextLevel = levels[nextIndex];
|
||
if (!nextLevel) return undefined;
|
||
|
||
this.setThinkingLevel(nextLevel);
|
||
return nextLevel;
|
||
}
|
||
|
||
/** Timeout (ms) for per-turn auto-thinking classification before falling back. */
|
||
static readonly #AUTO_THINKING_TIMEOUT_MS = 4000;
|
||
|
||
/**
|
||
* Classify the current user turn and set the effective thinking level for it.
|
||
* Bounded by a timeout + abort; on any failure (no smol model, timeout, parse
|
||
* error) it falls back to the provisional concrete level and continues. Never
|
||
* throws into the turn, and never clears `#autoThinking` (auto stays active).
|
||
*/
|
||
async #applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
|
||
const model = this.model;
|
||
if (!model?.reasoning) return;
|
||
|
||
let resolved: Effort | undefined;
|
||
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
|
||
// The user explicitly asked for maximum thinking; bypass the classifier
|
||
// and jump straight to the highest auto-supported level for this model.
|
||
resolved = clampAutoThinkingEffort(model, Effort.XHigh);
|
||
} else {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), AgentSession.#AUTO_THINKING_TIMEOUT_MS);
|
||
try {
|
||
resolved = await classifyDifficulty(promptText, {
|
||
settings: this.settings,
|
||
registry: this.#modelRegistry,
|
||
model,
|
||
sessionId: this.sessionId,
|
||
signal: controller.signal,
|
||
metadataResolver: provider => this.agent.metadataForProvider(provider),
|
||
});
|
||
} catch (error) {
|
||
logger.debug("auto-thinking: classification failed; using fallback level", {
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
// Drop the result if the turn was aborted/superseded while classifying.
|
||
if (this.#promptGeneration !== generation || !this.#autoThinking) return;
|
||
|
||
const effort = resolved ?? resolveProvisionalAutoLevel(model);
|
||
if (effort === undefined) return;
|
||
const shouldPersistResolution = this.#autoResolvedLevel !== effort;
|
||
this.#autoResolvedLevel = effort;
|
||
this.#thinkingLevel = effort;
|
||
this.#applyThinkingLevelToAgent(effort);
|
||
if (shouldPersistResolution) {
|
||
this.sessionManager.appendThinkingLevelChange(effort);
|
||
}
|
||
this.#emit({
|
||
type: "thinking_level_changed",
|
||
thinkingLevel: effort,
|
||
configured: AUTO_THINKING,
|
||
resolved: effort,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* True when *any* fast-mode-granting service tier is configured, regardless
|
||
* of whether the active model's provider actually realizes it. Used by the
|
||
* toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
|
||
* `claude-only`) doesn't silently broaden it to unscoped `priority`.
|
||
*
|
||
* For "is fast mode actually applied to the next request?" use
|
||
* {@link isFastModeActive} instead — that one respects the model's provider.
|
||
*/
|
||
isFastModeEnabled(): boolean {
|
||
return (
|
||
this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* True when the configured `serviceTier` resolves to `"priority"` for the
|
||
* *currently selected model's provider*. Returns false for scoped tiers
|
||
* that don't match (e.g. `"openai-only"` on an anthropic model) and when
|
||
* no model is selected.
|
||
*/
|
||
isFastModeActive(): boolean {
|
||
return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority";
|
||
}
|
||
|
||
setServiceTier(serviceTier: ServiceTier | undefined): void {
|
||
if (this.serviceTier === serviceTier) return;
|
||
// Re-arming priority on Anthropic? Clear the per-session auto-fallback
|
||
// sticky disable so the next request actually carries `speed: "fast"`
|
||
// again. Without this, `/fast on` (or user switching to a tier that
|
||
// grants anthropic priority) after an auto-disable is a silent no-op
|
||
// and the warning notice fires every turn.
|
||
if (serviceTier === "priority" || serviceTier === "claude-only") {
|
||
clearAnthropicFastModeFallback(this.#providerSessionState);
|
||
}
|
||
this.agent.serviceTier = serviceTier;
|
||
this.sessionManager.appendServiceTierChange(serviceTier ?? null);
|
||
}
|
||
|
||
setFastMode(enabled: boolean): void {
|
||
if (enabled && this.isFastModeEnabled()) {
|
||
// Already on under any scope — keep the user's scoped value.
|
||
return;
|
||
}
|
||
this.setServiceTier(enabled ? "priority" : undefined);
|
||
}
|
||
|
||
toggleFastMode(): boolean {
|
||
const enabled = !this.isFastModeEnabled();
|
||
this.setFastMode(enabled);
|
||
return enabled;
|
||
}
|
||
|
||
/**
|
||
* Get available thinking levels for current model.
|
||
*/
|
||
getAvailableThinkingLevels(): ReadonlyArray<Effort> {
|
||
if (!this.model) return [];
|
||
return getSupportedEfforts(this.model);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Message Queue Mode Management
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Set steering mode.
|
||
* Saves to settings.
|
||
*/
|
||
setSteeringMode(mode: "all" | "one-at-a-time"): void {
|
||
this.agent.setSteeringMode(mode);
|
||
this.settings.set("steeringMode", mode);
|
||
}
|
||
|
||
/**
|
||
* Set follow-up mode.
|
||
* Saves to settings.
|
||
*/
|
||
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
|
||
this.agent.setFollowUpMode(mode);
|
||
this.settings.set("followUpMode", mode);
|
||
}
|
||
|
||
/**
|
||
* Set interrupt mode.
|
||
* Saves to settings.
|
||
*/
|
||
setInterruptMode(mode: "immediate" | "wait"): void {
|
||
this.agent.setInterruptMode(mode);
|
||
this.settings.set("interruptMode", mode);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Compaction
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Append plan-read protection to a prune/shake config so the active plan
|
||
* file survives compaction alongside skill reads (the config defaults
|
||
* already carry skill protection). The matcher reads the current plan
|
||
* reference path at match time, so retitled plans are covered.
|
||
*/
|
||
#withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
|
||
const planMatcher = createPlanReadMatcher(() => this.#planReferencePath);
|
||
return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
|
||
}
|
||
|
||
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
||
const branchEntries = this.sessionManager.getBranch();
|
||
const result = pruneToolOutputs(branchEntries, this.#withPlanProtection(DEFAULT_PRUNE_CONFIG));
|
||
if (result.prunedCount === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
await this.sessionManager.rewriteEntries();
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Per-turn supersede pass: prune older `read` results that a newer read of
|
||
* the same file has made stale. Cache-aware (only fires when the suffix
|
||
* after a candidate is small or the session has been idle long enough that
|
||
* the provider prompt cache is cold), so it is cheap to run every turn.
|
||
* Gated on the `compaction.supersedeReads` setting.
|
||
*/
|
||
async #pruneSupersededReads(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
||
if (!this.settings.getGroup("compaction").supersedeReads) return undefined;
|
||
const branchEntries = this.sessionManager.getBranch();
|
||
const result = pruneSupersededToolResults(
|
||
branchEntries,
|
||
this.#withPlanProtection({
|
||
supersedeKey: readToolSupersedeKey,
|
||
protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
|
||
}),
|
||
);
|
||
if (result.prunedCount === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
await this.sessionManager.rewriteEntries();
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Strip image content blocks from every message on the current branch and
|
||
* persist the rewrite. Walks `SessionManager.getBranch()` in place — both
|
||
* `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
|
||
* are mutated, then `rewriteEntries` durably commits the new shape. The
|
||
* agent's runtime view is rebuilt from the freshly-mutated entries so any
|
||
* provider sessions caching message identity (Codex Responses) are torn
|
||
* down to force a clean replay on the next turn.
|
||
*
|
||
* No-op when the branch carries no images; returns `{ removed: 0 }` and
|
||
* skips the disk rewrite.
|
||
*/
|
||
async dropImages(): Promise<{ removed: number }> {
|
||
const branchEntries = this.sessionManager.getBranch();
|
||
let removed = 0;
|
||
for (const entry of branchEntries) {
|
||
if (entry.type === "message") {
|
||
removed += stripImagesFromMessage(entry.message);
|
||
continue;
|
||
}
|
||
if (entry.type === "custom_message" && typeof entry.content !== "string") {
|
||
const kept: typeof entry.content = [];
|
||
let dropped = 0;
|
||
for (const part of entry.content) {
|
||
if (part.type === "image") {
|
||
dropped++;
|
||
} else {
|
||
kept.push(part);
|
||
}
|
||
}
|
||
if (dropped > 0) {
|
||
if (kept.length === 0) {
|
||
kept.push({ type: "text", text: "[image removed]" });
|
||
}
|
||
entry.content = kept;
|
||
removed += dropped;
|
||
}
|
||
}
|
||
}
|
||
if (removed === 0) {
|
||
return { removed: 0 };
|
||
}
|
||
await this.sessionManager.rewriteEntries();
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
return { removed };
|
||
}
|
||
|
||
/**
|
||
* Surgically reduce context by dropping heavy content ("shake").
|
||
*
|
||
* - `images` delegates to {@link dropImages}.
|
||
* - `elide` replaces whole tool-call results and large fenced/XML blocks
|
||
* with short placeholders that embed an `artifact://` recovery link.
|
||
*
|
||
* Mutates the branch in place, persists via `rewriteEntries`, replays the
|
||
* rebuilt context through the agent, and tears down provider sessions that
|
||
* cache message identity — same rewrite contract as {@link dropImages}.
|
||
*
|
||
* No-op (zero counts) when nothing is eligible.
|
||
*/
|
||
async shake(mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal } = {}): Promise<ShakeResult> {
|
||
if (mode === "images") {
|
||
const { removed } = await this.dropImages();
|
||
return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
|
||
}
|
||
|
||
const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
|
||
const regions = collectShakeRegions(this.sessionManager.getBranch(), config);
|
||
if (regions.length === 0) {
|
||
return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
|
||
}
|
||
|
||
const artifactId = await this.#saveShakeArtifact(regions);
|
||
const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId));
|
||
|
||
let toolResultsDropped = 0;
|
||
let blocksDropped = 0;
|
||
let originalTokens = 0;
|
||
let replacementTokens = 0;
|
||
const items = regions.map((region, index) => {
|
||
if (region.kind === "toolResult") toolResultsDropped++;
|
||
else blocksDropped++;
|
||
originalTokens += region.tokens;
|
||
const replacement = replacements[index];
|
||
if (replacement.length > 0) replacementTokens += countTokens(replacement);
|
||
return { region, replacement };
|
||
});
|
||
|
||
applyShakeRegions(items);
|
||
|
||
await this.sessionManager.rewriteEntries();
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
|
||
return {
|
||
mode,
|
||
toolResultsDropped,
|
||
blocksDropped,
|
||
tokensFreed: Math.max(0, originalTokens - replacementTokens),
|
||
artifactId,
|
||
};
|
||
}
|
||
|
||
#shakeElidePlaceholder(region: ShakeRegion, index: number, artifactId: string | undefined): string {
|
||
if (artifactId) {
|
||
return `[shaken ~${region.tokens} tokens — recover: artifact://${artifactId} (region ${index + 1})]`;
|
||
}
|
||
return `[shaken ~${region.tokens} tokens]`;
|
||
}
|
||
|
||
/**
|
||
* Concatenate the original region contents into one session artifact so the
|
||
* agent can read them back via `artifact://<id>`. Returns `undefined` when
|
||
* the session is not persisted or the write fails — callers degrade to a
|
||
* bare placeholder.
|
||
*/
|
||
async #saveShakeArtifact(regions: ShakeRegion[]): Promise<string | undefined> {
|
||
const parts: string[] = [];
|
||
for (let i = 0; i < regions.length; i++) {
|
||
const region = regions[i];
|
||
parts.push(`### region ${i + 1} (${region.label}, ~${region.tokens} tok)`, "", region.originalText, "");
|
||
}
|
||
try {
|
||
return await this.sessionManager.saveArtifact(parts.join("\n"), "shake");
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Manually compact the session context.
|
||
* Aborts current agent operation first.
|
||
* @param customInstructions Optional instructions for the compaction summary
|
||
* @param options Optional callbacks for completion/error handling
|
||
*/
|
||
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
|
||
if (this.#compactionAbortController) {
|
||
throw new Error("Compaction already in progress");
|
||
}
|
||
this.#disconnectFromAgent();
|
||
await this.abort();
|
||
const compactionAbortController = new AbortController();
|
||
this.#compactionAbortController = compactionAbortController;
|
||
|
||
try {
|
||
if (!this.model) {
|
||
throw new Error("No model selected");
|
||
}
|
||
|
||
const compactionSettings = this.settings.getGroup("compaction");
|
||
const pathEntries = this.sessionManager.getBranch();
|
||
const preparation = prepareCompaction(pathEntries, compactionSettings);
|
||
if (!preparation) {
|
||
// Check why we can't compact
|
||
const lastEntry = pathEntries[pathEntries.length - 1];
|
||
if (lastEntry?.type === "compaction") {
|
||
throw new Error("Already compacted");
|
||
}
|
||
throw new Error("Nothing to compact (session too small)");
|
||
}
|
||
|
||
let hookCompaction: CompactionResult | undefined;
|
||
let fromExtension = false;
|
||
let preserveData: Record<string, unknown> | undefined;
|
||
|
||
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session_before_compact",
|
||
preparation,
|
||
branchEntries: pathEntries,
|
||
customInstructions,
|
||
signal: compactionAbortController.signal,
|
||
})) as SessionBeforeCompactResult | undefined;
|
||
|
||
if (result?.cancel) {
|
||
throw new CompactionCancelledError();
|
||
}
|
||
|
||
if (result?.compaction) {
|
||
hookCompaction = result.compaction;
|
||
fromExtension = true;
|
||
}
|
||
}
|
||
|
||
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
||
|
||
// Strategy honored on manual /compact too. Custom instructions imply a
|
||
// directed LLM summary; a text-only model cannot read the frames back —
|
||
// both take the summarizer path (the latter loudly).
|
||
const wantsSnapcompact =
|
||
compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions;
|
||
const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
|
||
if (wantsSnapcompact && !snapcompactReady) {
|
||
this.emitNotice(
|
||
"warning",
|
||
`snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`,
|
||
"compaction",
|
||
);
|
||
}
|
||
|
||
let summary: string;
|
||
let shortSummary: string | undefined;
|
||
let firstKeptEntryId: string;
|
||
let tokensBefore: number;
|
||
let details: unknown;
|
||
|
||
if (compactionPrep.kind === "fromHook") {
|
||
summary = compactionPrep.summary;
|
||
shortSummary = compactionPrep.shortSummary;
|
||
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
||
tokensBefore = compactionPrep.tokensBefore;
|
||
details = compactionPrep.details;
|
||
preserveData = compactionPrep.preserveData;
|
||
} else if (snapcompactReady) {
|
||
const snapcompactResult = await snapcompact.compact(preparation, {
|
||
convertToLlm,
|
||
model: this.model,
|
||
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
||
// Providers with hard image caps (OpenRouter: 8) silently drop
|
||
// frames past the cap — keep the archive within budget.
|
||
maxFrames: snapcompact.providerFrameBudget(this.model.provider),
|
||
});
|
||
summary = snapcompactResult.summary;
|
||
shortSummary = snapcompactResult.shortSummary;
|
||
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
||
tokensBefore = snapcompactResult.tokensBefore;
|
||
details = snapcompactResult.details;
|
||
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
|
||
} else {
|
||
// Generate compaction result. Only convert known abort-shaped
|
||
// rejections (AbortError raised while the abort signal is set,
|
||
// or an already-typed sentinel) into `CompactionCancelledError`
|
||
// so downstream callers can discriminate cancel from generic
|
||
// failure via `instanceof` without inspecting message strings.
|
||
// Real compaction bugs (network, server, parsing, etc.) keep
|
||
// their original shape — they must not be silently relabeled
|
||
// as cancellations even if the signal happens to be aborted
|
||
// for an unrelated reason. Assignments live inside the try
|
||
// block because every catch path throws — the post-try reads
|
||
// of the result-derived locals are reachable only on success.
|
||
try {
|
||
const result = await this.#compactWithFallbackModel(
|
||
preparation,
|
||
customInstructions,
|
||
compactionAbortController.signal,
|
||
{
|
||
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
|
||
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
|
||
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
|
||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||
},
|
||
);
|
||
summary = result.summary;
|
||
shortSummary = result.shortSummary;
|
||
firstKeptEntryId = result.firstKeptEntryId;
|
||
tokensBefore = result.tokensBefore;
|
||
details = result.details;
|
||
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
|
||
} catch (err) {
|
||
if (err instanceof CompactionCancelledError) {
|
||
throw err;
|
||
}
|
||
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
||
throw new CompactionCancelledError();
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
if (compactionAbortController.signal.aborted) {
|
||
throw new CompactionCancelledError();
|
||
}
|
||
|
||
this.sessionManager.appendCompaction(
|
||
summary,
|
||
shortSummary,
|
||
firstKeptEntryId,
|
||
tokensBefore,
|
||
details,
|
||
fromExtension,
|
||
preserveData,
|
||
);
|
||
const newEntries = this.sessionManager.getEntries();
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
|
||
// Get the saved compaction entry for the hook
|
||
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
||
| CompactionEntry
|
||
| undefined;
|
||
|
||
if (this.#extensionRunner && savedCompactionEntry) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_compact",
|
||
compactionEntry: savedCompactionEntry,
|
||
fromExtension,
|
||
});
|
||
}
|
||
|
||
const compactionResult: CompactionResult = {
|
||
summary,
|
||
shortSummary,
|
||
firstKeptEntryId,
|
||
tokensBefore,
|
||
details,
|
||
preserveData,
|
||
};
|
||
options?.onComplete?.(compactionResult);
|
||
return compactionResult;
|
||
} catch (error) {
|
||
const err = error instanceof Error ? error : new Error(String(error));
|
||
options?.onError?.(err);
|
||
throw error;
|
||
} finally {
|
||
if (this.#compactionAbortController === compactionAbortController) {
|
||
this.#compactionAbortController = undefined;
|
||
}
|
||
this.#reconnectToAgent();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ask the active memory backend for an extra-context block to splice into
|
||
* the compaction summary prompt. Both the manual and auto compaction paths
|
||
* funnel through this helper so the behaviour stays identical.
|
||
*
|
||
* Failures are swallowed: a memory backend going sideways MUST NOT block
|
||
* compaction (which is itself the recovery path for context overflow).
|
||
*/
|
||
async #collectMemoryBackendContext(preparation: {
|
||
messagesToSummarize: AgentMessage[];
|
||
turnPrefixMessages: AgentMessage[];
|
||
}): Promise<string | undefined> {
|
||
const backend = await resolveMemoryBackend(this.settings);
|
||
if (!backend.preCompactionContext) return undefined;
|
||
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
||
try {
|
||
return await backend.preCompactionContext(messages, this.settings, this);
|
||
} catch (err) {
|
||
logger.debug("Memory backend preCompactionContext failed", {
|
||
backend: backend.id,
|
||
error: String(err),
|
||
});
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff).
|
||
*/
|
||
abortCompaction(): void {
|
||
this.#compactionAbortController?.abort();
|
||
this.#autoCompactionAbortController?.abort();
|
||
this.#handoffAbortController?.abort();
|
||
}
|
||
|
||
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
|
||
async runIdleCompaction(): Promise<void> {
|
||
if (this.isStreaming || this.isCompacting) return;
|
||
await this.#runAutoCompaction("idle", false, true);
|
||
}
|
||
|
||
/**
|
||
* Cancel in-progress branch summarization.
|
||
*/
|
||
abortBranchSummary(): void {
|
||
this.#branchSummaryAbortController?.abort();
|
||
}
|
||
|
||
/**
|
||
* Cancel in-progress handoff generation.
|
||
*/
|
||
abortHandoff(): void {
|
||
this.#handoffAbortController?.abort();
|
||
}
|
||
|
||
/**
|
||
* Check if handoff generation is in progress.
|
||
*/
|
||
get isGeneratingHandoff(): boolean {
|
||
return this.#handoffAbortController !== undefined;
|
||
}
|
||
|
||
/**
|
||
* Generate a handoff document with a oneshot LLM call, then start a new session with it.
|
||
*
|
||
* @param customInstructions Optional focus for the handoff document
|
||
* @param options Handoff execution options
|
||
* @returns The handoff document text, or undefined if cancelled/failed
|
||
*/
|
||
async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
|
||
const entries = this.sessionManager.getBranch();
|
||
const messageCount = entries.filter(e => e.type === "message").length;
|
||
|
||
if (messageCount < 2) {
|
||
throw new Error("Nothing to hand off (no messages yet)");
|
||
}
|
||
|
||
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
||
|
||
this.#handoffAbortController = new AbortController();
|
||
const handoffAbortController = this.#handoffAbortController;
|
||
const handoffSignal = handoffAbortController.signal;
|
||
const sourceSignal = options?.signal;
|
||
const onSourceAbort = () => {
|
||
if (!handoffSignal.aborted) {
|
||
handoffAbortController.abort();
|
||
}
|
||
};
|
||
if (sourceSignal) {
|
||
sourceSignal.addEventListener("abort", onSourceAbort, { once: true });
|
||
if (sourceSignal.aborted) {
|
||
onSourceAbort();
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (handoffSignal.aborted) {
|
||
throw new Error("Handoff cancelled");
|
||
}
|
||
|
||
const model = this.model;
|
||
if (!model) {
|
||
throw new Error("No model selected for handoff");
|
||
}
|
||
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for ${model.provider}`);
|
||
}
|
||
|
||
const rawHandoffText = await generateHandoff(
|
||
this.agent.state.messages,
|
||
model,
|
||
this.#modelRegistry.resolver(model, this.sessionId),
|
||
{
|
||
systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
|
||
tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
|
||
customInstructions: this.#obfuscateTextForProvider(customInstructions),
|
||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||
initiatorOverride: "agent",
|
||
metadata: this.agent.metadataForProvider(model.provider),
|
||
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
||
// Honor the user's /model thinking selection on the handoff
|
||
// path. Clamped per-model inside generateHandoff via
|
||
// resolveCompactionEffort so unsupported-effort models don't
|
||
// trip requireSupportedEffort.
|
||
thinkingLevel: this.thinkingLevel,
|
||
},
|
||
handoffSignal,
|
||
);
|
||
const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
|
||
|
||
if (handoffSignal.aborted) {
|
||
throw new Error("Handoff cancelled");
|
||
}
|
||
if (!handoffText) {
|
||
return undefined;
|
||
}
|
||
|
||
// Start a new session
|
||
const previousSessionFile = this.sessionFile;
|
||
await this.sessionManager.flush();
|
||
this.#cancelOwnAsyncJobs();
|
||
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
|
||
this.agent.reset();
|
||
this.#freshProviderSessionId = undefined;
|
||
this.#syncAgentSessionId();
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
this.#resetHindsightConversationTrackingIfHindsight();
|
||
this.#resetMnemopiConversationTrackingIfMnemopi();
|
||
this.#steeringMessages = [];
|
||
this.#followUpMessages = [];
|
||
this.#pendingNextTurnMessages = [];
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
this.#todoReminderCount = 0;
|
||
|
||
// Inject the handoff document as a custom message
|
||
const handoffContent = createHandoffContext(handoffText);
|
||
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
|
||
await this.sessionManager.ensureOnDisk();
|
||
let savedPath: string | undefined;
|
||
if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
|
||
const artifactsDir = this.sessionManager.getArtifactsDir();
|
||
if (artifactsDir) {
|
||
const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
|
||
try {
|
||
await Bun.write(handoffFilePath, `${handoffText}\n`);
|
||
savedPath = handoffFilePath;
|
||
} catch (error) {
|
||
logger.warn("Failed to save handoff document to disk", {
|
||
path: handoffFilePath,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
} else {
|
||
logger.debug("Skipping handoff document save because session is not persisted");
|
||
}
|
||
}
|
||
|
||
// Rebuild agent messages from session
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
|
||
return { document: handoffText, savedPath };
|
||
} catch (error) {
|
||
if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
|
||
throw new Error("Handoff cancelled");
|
||
}
|
||
throw error;
|
||
} finally {
|
||
sourceSignal?.removeEventListener("abort", onSourceAbort);
|
||
this.#handoffAbortController = undefined;
|
||
}
|
||
}
|
||
|
||
#estimatePendingPromptTokens(messages: AgentMessage[]): number {
|
||
let tokens = computeNonMessageTokens(this);
|
||
for (const message of this.messages) {
|
||
tokens += estimateTokens(message);
|
||
}
|
||
for (const message of messages) {
|
||
tokens += estimateTokens(message);
|
||
}
|
||
return tokens;
|
||
}
|
||
|
||
async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
|
||
const model = this.model;
|
||
if (!model) return;
|
||
const contextWindow = model.contextWindow ?? 0;
|
||
if (contextWindow <= 0) return;
|
||
const compactionSettings = this.settings.getGroup("compaction");
|
||
const contextTokens = this.#estimatePendingPromptTokens(messages);
|
||
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
|
||
|
||
logger.debug("Pre-prompt context maintenance triggered by pending prompt size", {
|
||
contextTokens,
|
||
contextWindow,
|
||
model: `${model.provider}/${model.id}`,
|
||
});
|
||
await this.#runAutoCompaction("threshold", false, false, false, { autoContinue: false });
|
||
}
|
||
|
||
/**
|
||
* Check if context maintenance or promotion is needed and run it.
|
||
* Called after agent_end and before prompt submission.
|
||
*
|
||
* Four cases (in order):
|
||
* 1. Input overflow + promotion: promote to larger model, retry without maintenance.
|
||
* 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
|
||
* 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
|
||
* model burned its output budget without producing an actionable deliverable
|
||
* (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
|
||
* run compaction/handoff and retry.
|
||
* 4. Threshold: context over threshold, run context maintenance (no auto-retry).
|
||
*
|
||
* @param assistantMessage The assistant message to check
|
||
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
||
* @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a
|
||
* deferred post-prompt task instead of running inline. Callers running inside the
|
||
* `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
|
||
* on the pre-prompt path (where the next agent turn is about to start) set it to false
|
||
* to avoid racing the deferred handoff against the new turn.
|
||
* @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
|
||
* @returns true when a deferred handoff was scheduled. Callers MUST then skip any
|
||
* subsequent `#scheduleAgentContinue` / reminder appends for this turn — the
|
||
* handoff will replace session state and a concurrent `agent.continue()` would
|
||
* stream into the soon-to-be-discarded session.
|
||
*/
|
||
async #checkCompaction(
|
||
assistantMessage: AssistantMessage,
|
||
skipAbortedCheck = true,
|
||
allowDefer = true,
|
||
autoContinue = true,
|
||
): Promise<boolean> {
|
||
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
|
||
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
|
||
const contextWindow = this.model?.contextWindow ?? 0;
|
||
const generation = this.#promptGeneration;
|
||
// Skip overflow check if the message came from a different model.
|
||
// This handles the case where user switched from a smaller-context model (e.g. opus)
|
||
// to a larger-context model (e.g. codex) - the overflow error from the old model
|
||
// shouldn't trigger compaction for the new model.
|
||
const sameModel =
|
||
this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
|
||
// This handles the case where an error was kept after compaction (in the "kept" region).
|
||
// The error shouldn't trigger another compaction since we already compacted.
|
||
// Example: opus fails -> switch to codex -> compact -> switch back to opus -> opus error
|
||
// is still in context but shouldn't trigger compaction again.
|
||
const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
|
||
const errorIsFromBeforeCompaction =
|
||
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
|
||
if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
|
||
// Remove the error message from agent state (it IS saved to session for history,
|
||
// but we don't want it in context for the retry)
|
||
const messages = this.agent.state.messages;
|
||
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
||
this.agent.replaceMessages(messages.slice(0, -1));
|
||
}
|
||
|
||
// Try context promotion first - switch to a larger model and retry without compacting
|
||
const promoted = await this.#tryContextPromotion(assistantMessage);
|
||
if (promoted) {
|
||
// Retry on the promoted (larger) model without compacting
|
||
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
||
return false;
|
||
}
|
||
|
||
// No promotion target available fall through to compaction
|
||
const compactionSettings = this.settings.getGroup("compaction");
|
||
if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
|
||
await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue });
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
|
||
// (and Codex) maps to stopReason === "length". The model burned its
|
||
// `max_output_tokens` budget on reasoning/text and emitted no actionable
|
||
// deliverable. Same recovery class as overflow: promotion if available,
|
||
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
|
||
// allow the handoff strategy to actually run.
|
||
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
|
||
const messages = this.agent.state.messages;
|
||
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
||
this.agent.replaceMessages(messages.slice(0, -1));
|
||
}
|
||
|
||
const promoted = await this.#tryContextPromotion(assistantMessage);
|
||
if (promoted) {
|
||
logger.debug("Context promotion triggered by response.incomplete (length stop)", {
|
||
from: `${assistantMessage.provider}/${assistantMessage.model}`,
|
||
});
|
||
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
||
return false;
|
||
}
|
||
|
||
const incompleteCompactionSettings = this.settings.getGroup("compaction");
|
||
if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
|
||
logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
|
||
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
||
strategy: incompleteCompactionSettings.strategy,
|
||
});
|
||
await this.#runAutoCompaction("incomplete", true, false, allowDefer, {
|
||
autoContinue,
|
||
triggerContextTokens: calculateContextTokens(assistantMessage.usage),
|
||
});
|
||
} else {
|
||
// Neither promotion nor compaction is available — surface the dead-end so
|
||
// the user understands why the turn yielded with nothing.
|
||
logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
|
||
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Supersede pass runs every turn, before any threshold gating: it is cheap
|
||
// (bails when no candidate) and independent of the compaction setting.
|
||
const supersedeResult = await this.#pruneSupersededReads();
|
||
|
||
const compactionSettings = this.settings.getGroup("compaction");
|
||
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
|
||
|
||
// Case 4: Threshold - turn succeeded but context is getting large
|
||
// Skip if this was an error (non-overflow errors don't have usage data)
|
||
if (assistantMessage.stopReason === "error") return false;
|
||
const pruneResult = await this.#pruneToolOutputs();
|
||
let contextTokens = calculateContextTokens(assistantMessage.usage);
|
||
if (supersedeResult) {
|
||
contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
|
||
}
|
||
if (pruneResult) {
|
||
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
|
||
}
|
||
if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
|
||
// Try promotion first — if a larger model is available, switch instead of compacting
|
||
const promoted = await this.#tryContextPromotion(assistantMessage);
|
||
if (!promoted) {
|
||
return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
|
||
autoContinue,
|
||
triggerContextTokens: contextTokens,
|
||
});
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
#assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
|
||
const toolCallId = this.#lastSuccessfulYieldToolCallId;
|
||
if (!toolCallId) return false;
|
||
const lastToolCall = assistantMessage.content
|
||
.slice()
|
||
.reverse()
|
||
.find((content): content is ToolCall => content.type === "toolCall");
|
||
return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
|
||
}
|
||
|
||
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
|
||
if (!this.#isEmptyAssistantStop(assistantMessage)) {
|
||
this.#emptyStopRetryCount = 0;
|
||
return false;
|
||
}
|
||
|
||
this.#emptyStopRetryCount++;
|
||
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
|
||
logger.warn("Assistant returned empty stop after retry cap", {
|
||
attempts: this.#emptyStopRetryCount - 1,
|
||
model: assistantMessage.model,
|
||
provider: assistantMessage.provider,
|
||
});
|
||
if (this.#retryAttempt > 0) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_retry_end",
|
||
success: false,
|
||
attempt: this.#retryAttempt,
|
||
finalError: "Assistant returned empty stop after retry cap",
|
||
});
|
||
this.#retryAttempt = 0;
|
||
}
|
||
this.#resolveRetry();
|
||
// Tool-use orphans corrupt Anthropic message history (tool_result without
|
||
// matching tool_use). Always remove them even when the retry cap is hit.
|
||
if (assistantMessage.stopReason === "toolUse") {
|
||
this.#removeEmptyStopFromActiveContext(assistantMessage);
|
||
}
|
||
return true;
|
||
}
|
||
this.#removeEmptyStopFromActiveContext(assistantMessage);
|
||
this.agent.appendMessage({
|
||
role: "developer",
|
||
content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
|
||
return true;
|
||
}
|
||
|
||
#isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
|
||
switch (assistantMessage.stopReason) {
|
||
case "stop":
|
||
for (const content of assistantMessage.content) {
|
||
if (content.type === "toolCall") return false;
|
||
if (content.type === "text" && hasNonWhitespace(content.text)) return false;
|
||
if (content.type === "thinking" && hasNonWhitespace(content.thinking)) return false;
|
||
}
|
||
return true;
|
||
case "toolUse":
|
||
// An orphaned toolUse stop (no tool_use block) corrupts Anthropic history:
|
||
// a later tool_result has nothing to anchor to. Thinking alone cannot anchor
|
||
// a tool_result, so it does not rescue a toolUse stop here.
|
||
for (const content of assistantMessage.content) {
|
||
if (content.type === "toolCall") return false;
|
||
if (content.type === "text" && hasNonWhitespace(content.text)) return false;
|
||
}
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
#emptyStopRetryReminder(): string {
|
||
return prompt.render(emptyStopRetryTemplate, {
|
||
retryCount: this.#emptyStopRetryCount,
|
||
maxRetries: EMPTY_STOP_MAX_RETRIES,
|
||
});
|
||
}
|
||
|
||
#removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
|
||
const messages = this.agent.state.messages;
|
||
const lastMessage = messages[messages.length - 1];
|
||
if (
|
||
lastMessage?.role === "assistant" &&
|
||
this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
|
||
) {
|
||
this.agent.replaceMessages(messages.slice(0, -1));
|
||
}
|
||
|
||
const emptyStopEntry = this.sessionManager
|
||
.getBranch()
|
||
.slice()
|
||
.reverse()
|
||
.find(
|
||
entry =>
|
||
entry.type === "message" &&
|
||
entry.message.role === "assistant" &&
|
||
this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
|
||
);
|
||
if (!emptyStopEntry) {
|
||
return;
|
||
}
|
||
if (emptyStopEntry.parentId === null) {
|
||
this.sessionManager.resetLeaf();
|
||
} else {
|
||
this.sessionManager.branch(emptyStopEntry.parentId);
|
||
}
|
||
}
|
||
|
||
#isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
|
||
return (
|
||
left === right ||
|
||
(left.timestamp === right.timestamp &&
|
||
left.provider === right.provider &&
|
||
left.model === right.model &&
|
||
left.stopReason === right.stopReason)
|
||
);
|
||
}
|
||
|
||
#enforceRewindBeforeYield(): boolean {
|
||
if (!this.#checkpointState || this.#pendingRewindReport) {
|
||
return false;
|
||
}
|
||
const reminder = [
|
||
"<system-warning>",
|
||
"You are in an active checkpoint. You MUST call rewind with your investigation findings before yielding. Do NOT yield without completing the checkpoint.",
|
||
"</system-warning>",
|
||
].join("\n");
|
||
this.agent.appendMessage({
|
||
role: "developer",
|
||
content: [{ type: "text", text: reminder }],
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
|
||
return true;
|
||
}
|
||
|
||
async #applyRewind(report: string): Promise<void> {
|
||
const checkpointState = this.#checkpointState;
|
||
if (!checkpointState) {
|
||
return;
|
||
}
|
||
const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
|
||
this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
|
||
try {
|
||
this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
|
||
startedAt: checkpointState.startedAt,
|
||
});
|
||
} catch (error) {
|
||
logger.warn("Rewind branch checkpoint missing, falling back to root", {
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
|
||
}
|
||
const details = { startedAt: checkpointState.startedAt, rewoundAt: new Date().toISOString() };
|
||
this.agent.appendMessage({
|
||
role: "custom",
|
||
customType: "rewind-report",
|
||
content: report,
|
||
display: false,
|
||
details,
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details, "agent");
|
||
this.#checkpointState = undefined;
|
||
this.#pendingRewindReport = undefined;
|
||
}
|
||
async #enforcePlanModeToolDecision(): Promise<void> {
|
||
if (!this.#planModeState?.enabled) {
|
||
return;
|
||
}
|
||
const assistantMessage = this.#findLastAssistantMessage();
|
||
if (!assistantMessage) {
|
||
return;
|
||
}
|
||
if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
|
||
return;
|
||
}
|
||
|
||
const calledRequiredTool = assistantMessage.content.some(
|
||
content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"),
|
||
);
|
||
if (calledRequiredTool) {
|
||
return;
|
||
}
|
||
const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve");
|
||
if (!hasRequiredTools) {
|
||
logger.warn("Plan mode enforcement skipped because ask/resolve tools are unavailable", {
|
||
activeToolNames: this.agent.state.tools.map(tool => tool.name),
|
||
});
|
||
return;
|
||
}
|
||
|
||
const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
|
||
askToolName: "ask",
|
||
});
|
||
|
||
await this.prompt(reminder, {
|
||
synthetic: true,
|
||
expandPromptTemplates: false,
|
||
toolChoice: "required",
|
||
});
|
||
}
|
||
|
||
#createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
|
||
const eagerTodosEnabled = this.settings.get("todo.eager");
|
||
const todosEnabled = this.settings.get("todo.enabled");
|
||
if (!eagerTodosEnabled || !todosEnabled) {
|
||
return undefined;
|
||
}
|
||
|
||
if (this.#planModeState?.enabled) {
|
||
return undefined;
|
||
}
|
||
if (this.getTodoPhases().length > 0) {
|
||
return undefined;
|
||
}
|
||
|
||
// Only inject on the first user message of the conversation. Subsequent user
|
||
// turns must not receive the eager todo reminder — they often correct, clarify,
|
||
// or redirect the prior task, and forcing a brand-new todo list there is wrong.
|
||
const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
|
||
if (hasPriorUserMessage) {
|
||
return undefined;
|
||
}
|
||
|
||
const trimmedPromptText = promptText.trimEnd();
|
||
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
|
||
return undefined;
|
||
}
|
||
|
||
// Must check the active tool set, not just the registry: tool discovery
|
||
// (tools.discoveryMode === "all") can register `todo` while hiding it from
|
||
// the exposed tools. Forcing a named tool_choice for an inactive tool makes
|
||
// the provider reject the request (HTTP 400).
|
||
if (!this.getActiveToolNames().includes("todo")) {
|
||
logger.warn("Eager todo enforcement skipped because todo is not active", {
|
||
activeToolNames: this.getActiveToolNames(),
|
||
});
|
||
return undefined;
|
||
}
|
||
|
||
const todoToolChoice = buildNamedToolChoice("todo", this.model);
|
||
if (!todoToolChoice) {
|
||
logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo", {
|
||
modelApi: this.model?.api,
|
||
modelId: this.model?.id,
|
||
});
|
||
return undefined;
|
||
}
|
||
|
||
const eagerTodoReminder = prompt.render(eagerTodoPrompt);
|
||
|
||
return {
|
||
message: {
|
||
role: "custom",
|
||
customType: "eager-todo-prelude",
|
||
content: eagerTodoReminder,
|
||
display: false,
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
},
|
||
toolChoice: todoToolChoice,
|
||
};
|
||
}
|
||
/**
|
||
* Check if agent stopped with incomplete todos and prompt to continue.
|
||
*/
|
||
async #checkTodoCompletion(): Promise<void> {
|
||
// Skip todo reminders when the most recent turn was driven by an explicit user force —
|
||
// the user wanted exactly that tool, not a follow-up nag about incomplete todos.
|
||
const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
|
||
if (lastServedLabel === "user-force") {
|
||
return;
|
||
}
|
||
|
||
const remindersEnabled = this.settings.get("todo.reminders");
|
||
const todosEnabled = this.settings.get("todo.enabled");
|
||
if (!remindersEnabled || !todosEnabled) {
|
||
this.#todoReminderCount = 0;
|
||
return;
|
||
}
|
||
|
||
const remindersMax = this.settings.get("todo.reminders.max");
|
||
if (this.#todoReminderCount >= remindersMax) {
|
||
logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount });
|
||
return;
|
||
}
|
||
|
||
const phases = this.getTodoPhases();
|
||
if (phases.length === 0) {
|
||
this.#todoReminderCount = 0;
|
||
return;
|
||
}
|
||
|
||
const incompleteByPhase = phases
|
||
.map(phase => ({
|
||
name: phase.name,
|
||
tasks: phase.tasks
|
||
.filter(
|
||
(task): task is TodoItem & { status: "pending" | "in_progress" } =>
|
||
task.status === "pending" || task.status === "in_progress",
|
||
)
|
||
.map(task => ({ content: task.content, status: task.status })),
|
||
}))
|
||
.filter(phase => phase.tasks.length > 0);
|
||
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
|
||
if (incomplete.length === 0) {
|
||
this.#todoReminderCount = 0;
|
||
return;
|
||
}
|
||
|
||
// Build reminder message
|
||
this.#todoReminderCount++;
|
||
const todoList = incompleteByPhase
|
||
.map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
|
||
.join("\n");
|
||
const reminder =
|
||
`<system-reminder>\n` +
|
||
`You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
|
||
`Please continue working on these tasks or mark them complete if finished.\n` +
|
||
`(Reminder ${this.#todoReminderCount}/${remindersMax})\n` +
|
||
`</system-reminder>`;
|
||
|
||
logger.debug("Todo completion: sending reminder", {
|
||
incomplete: incomplete.length,
|
||
attempt: this.#todoReminderCount,
|
||
});
|
||
|
||
// Emit event for UI to render notification
|
||
await this.#emitSessionEvent({
|
||
type: "todo_reminder",
|
||
todos: incomplete,
|
||
attempt: this.#todoReminderCount,
|
||
maxAttempts: remindersMax,
|
||
});
|
||
|
||
// Inject reminder and continue the conversation
|
||
this.agent.appendMessage({
|
||
role: "developer",
|
||
content: [{ type: "text", text: reminder }],
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
|
||
}
|
||
|
||
/**
|
||
* Attempt context promotion to a larger model.
|
||
* Returns true if promotion succeeded (caller should retry without compacting).
|
||
*/
|
||
async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
|
||
const promotionSettings = this.settings.getGroup("contextPromotion");
|
||
if (!promotionSettings.enabled) return false;
|
||
const currentModel = this.model;
|
||
if (!currentModel) return false;
|
||
if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
|
||
return false;
|
||
const contextWindow = currentModel.contextWindow ?? 0;
|
||
if (contextWindow <= 0) return false;
|
||
const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
|
||
if (!targetModel) return false;
|
||
|
||
try {
|
||
await this.setModelTemporary(targetModel, undefined, { ephemeral: true });
|
||
logger.debug("Context promotion switched model on overflow", {
|
||
from: `${currentModel.provider}/${currentModel.id}`,
|
||
to: `${targetModel.provider}/${targetModel.id}`,
|
||
});
|
||
return true;
|
||
} catch (error) {
|
||
logger.warn("Context promotion failed", {
|
||
from: `${currentModel.provider}/${currentModel.id}`,
|
||
to: `${targetModel.provider}/${targetModel.id}`,
|
||
error: String(error),
|
||
});
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
|
||
const availableModels = this.#modelRegistry.getAvailable();
|
||
if (availableModels.length === 0) return undefined;
|
||
|
||
const candidate = this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels);
|
||
if (!candidate) return undefined;
|
||
if (modelsAreEqual(candidate, currentModel)) return undefined;
|
||
if (candidate.contextWindow <= contextWindow) return undefined;
|
||
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
||
if (!apiKey) return undefined;
|
||
return candidate;
|
||
}
|
||
|
||
#setModelWithProviderSessionReset(model: Model): void {
|
||
const currentModel = this.model;
|
||
if (currentModel) {
|
||
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
||
}
|
||
this.agent.setModel(model);
|
||
|
||
// Re-evaluate append-only context mode — provider or setting may have changed
|
||
this.#syncAppendOnlyContext(model);
|
||
}
|
||
|
||
#closeCodexProviderSessionsForHistoryRewrite(): void {
|
||
const currentModel = this.model;
|
||
if (currentModel?.api !== "openai-codex-responses") return;
|
||
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
||
}
|
||
|
||
#resetCurrentResponsesProviderSession(reason: string): void {
|
||
const currentModel = this.model;
|
||
if (currentModel?.api !== "openai-responses" && currentModel?.api !== "openai-codex-responses") {
|
||
return;
|
||
}
|
||
|
||
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
||
this.agent.appendOnlyContext?.invalidateForModelChange();
|
||
logger.debug("Reset Responses provider session after stale replay error", {
|
||
provider: currentModel.provider,
|
||
model: currentModel.id,
|
||
api: currentModel.api,
|
||
reason,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Re-evaluate append-only context mode, creating or destroying the
|
||
* manager as needed. Called on model switch AND setting change.
|
||
*/
|
||
#syncAppendOnlyContext(model: Model | null | undefined): void {
|
||
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
|
||
const enable = shouldEnableAppendOnlyContext(setting, model);
|
||
const providerId = model?.provider;
|
||
const prev = this.#lastAppendOnlyResolution;
|
||
if (prev && prev.enable === enable && prev.providerId === providerId) return;
|
||
this.#lastAppendOnlyResolution = { enable, providerId };
|
||
|
||
if (enable && !this.agent.appendOnlyContext) {
|
||
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
|
||
} else if (enable && this.agent.appendOnlyContext) {
|
||
// Already active — invalidate prefix + log so the next turn
|
||
// rebuilds for the current model's normalization.
|
||
this.agent.appendOnlyContext.invalidateForModelChange();
|
||
} else if (!enable && this.agent.appendOnlyContext) {
|
||
this.agent.setAppendOnlyContext(undefined);
|
||
}
|
||
}
|
||
|
||
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
|
||
const providerKeys = new Set<string>();
|
||
if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
|
||
providerKeys.add("openai-codex-responses");
|
||
}
|
||
if (currentModel.api === "openai-responses") {
|
||
providerKeys.add(`openai-responses:${currentModel.provider}`);
|
||
}
|
||
if (nextModel.api === "openai-responses") {
|
||
providerKeys.add(`openai-responses:${nextModel.provider}`);
|
||
}
|
||
|
||
for (const providerKey of providerKeys) {
|
||
const state = this.#providerSessionState.get(providerKey);
|
||
if (!state) continue;
|
||
|
||
try {
|
||
state.close();
|
||
} catch (error) {
|
||
logger.warn("Failed to close provider session state during model switch", {
|
||
providerKey,
|
||
error: String(error),
|
||
});
|
||
}
|
||
|
||
this.#providerSessionState.delete(providerKey);
|
||
}
|
||
}
|
||
|
||
#normalizeProviderReplayValue(value: unknown): unknown {
|
||
if (Array.isArray(value)) {
|
||
return value.map(item => this.#normalizeProviderReplayValue(item));
|
||
}
|
||
if (value && typeof value === "object") {
|
||
return Object.fromEntries(
|
||
Object.entries(value).map(([key, entryValue]) => [key, this.#normalizeProviderReplayValue(entryValue)]),
|
||
);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
#normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown {
|
||
switch (message.role) {
|
||
case "user":
|
||
case "developer":
|
||
return {
|
||
role: message.role,
|
||
content: this.#normalizeProviderReplayValue(message.content),
|
||
providerPayload: message.providerPayload,
|
||
};
|
||
case "assistant": {
|
||
const isResponsesFamilyMessage =
|
||
message.api === "openai-responses" || message.api === "openai-codex-responses";
|
||
return {
|
||
role: message.role,
|
||
content:
|
||
isResponsesFamilyMessage && Array.isArray(message.content)
|
||
? message.content.flatMap(block => {
|
||
if (block.type === "thinking") {
|
||
return [];
|
||
}
|
||
if (block.type === "toolCall") {
|
||
return [
|
||
{
|
||
type: block.type,
|
||
id: block.id,
|
||
name: block.name,
|
||
arguments: block.arguments,
|
||
},
|
||
];
|
||
}
|
||
if (block.type === "text") {
|
||
return [{ type: block.type, text: block.text, textSignature: block.textSignature }];
|
||
}
|
||
return [this.#normalizeProviderReplayValue(block)];
|
||
})
|
||
: this.#normalizeProviderReplayValue(message.content),
|
||
api: message.api,
|
||
provider: message.provider,
|
||
model: message.model,
|
||
stopReason: message.stopReason,
|
||
errorMessage: message.errorMessage,
|
||
providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload,
|
||
};
|
||
}
|
||
case "toolResult":
|
||
return {
|
||
role: message.role,
|
||
toolName: message.toolName,
|
||
toolCallId: message.toolCallId,
|
||
isError: message.isError,
|
||
content: this.#normalizeProviderReplayValue(message.content),
|
||
};
|
||
case "bashExecution":
|
||
return {
|
||
role: message.role,
|
||
command: message.command,
|
||
output: message.output,
|
||
exitCode: message.exitCode,
|
||
cancelled: message.cancelled,
|
||
meta: message.meta
|
||
? {
|
||
truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
|
||
limits: this.#normalizeProviderReplayValue(message.meta.limits),
|
||
diagnostics: message.meta.diagnostics
|
||
? this.#normalizeProviderReplayValue({
|
||
summary: message.meta.diagnostics.summary,
|
||
messages: message.meta.diagnostics.messages,
|
||
})
|
||
: undefined,
|
||
}
|
||
: undefined,
|
||
excludeFromContext: message.excludeFromContext,
|
||
};
|
||
case "pythonExecution":
|
||
return {
|
||
role: message.role,
|
||
code: message.code,
|
||
output: message.output,
|
||
exitCode: message.exitCode,
|
||
cancelled: message.cancelled,
|
||
meta: message.meta
|
||
? {
|
||
truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
|
||
limits: this.#normalizeProviderReplayValue(message.meta.limits),
|
||
diagnostics: message.meta.diagnostics
|
||
? this.#normalizeProviderReplayValue({
|
||
summary: message.meta.diagnostics.summary,
|
||
messages: message.meta.diagnostics.messages,
|
||
})
|
||
: undefined,
|
||
}
|
||
: undefined,
|
||
excludeFromContext: message.excludeFromContext,
|
||
};
|
||
case "custom":
|
||
case "hookMessage":
|
||
return {
|
||
role: message.role,
|
||
customType: message.customType,
|
||
content: this.#normalizeProviderReplayValue(message.content),
|
||
};
|
||
case "branchSummary":
|
||
return { role: message.role, summary: message.summary };
|
||
case "compactionSummary":
|
||
return {
|
||
role: message.role,
|
||
summary: message.summary,
|
||
providerPayload: message.providerPayload,
|
||
};
|
||
case "fileMention":
|
||
return {
|
||
role: message.role,
|
||
files: message.files.map(file => ({
|
||
path: file.path,
|
||
content: file.content,
|
||
image: file.image,
|
||
})),
|
||
};
|
||
default:
|
||
return this.#normalizeProviderReplayValue(message);
|
||
}
|
||
}
|
||
|
||
#didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
|
||
return (
|
||
JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
|
||
JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
|
||
);
|
||
}
|
||
|
||
#getModelKey(model: Model): string {
|
||
return `${model.provider}/${model.id}`;
|
||
}
|
||
|
||
#formatRoleModelValue(
|
||
role: string,
|
||
model: Model,
|
||
selectorOverride?: string,
|
||
thinkingLevelOverride?: ThinkingLevel,
|
||
): string {
|
||
const modelKey = selectorOverride ?? `${model.provider}/${model.id}`;
|
||
if (thinkingLevelOverride !== undefined) {
|
||
return formatModelSelectorValue(modelKey, thinkingLevelOverride);
|
||
}
|
||
const existingRoleValue = this.settings.getModelRole(role);
|
||
if (!existingRoleValue) return modelKey;
|
||
|
||
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings);
|
||
return formatModelSelectorValue(modelKey, thinkingLevel);
|
||
}
|
||
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
||
const configuredTarget = currentModel.contextPromotionTarget?.trim();
|
||
if (!configuredTarget) return undefined;
|
||
|
||
const parsed = parseModelString(configuredTarget);
|
||
if (parsed) {
|
||
const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
||
if (explicitModel) return explicitModel;
|
||
}
|
||
|
||
return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
|
||
}
|
||
|
||
#resolveRoleModelFull(
|
||
role: string,
|
||
availableModels: Model[],
|
||
currentModel: Model | undefined,
|
||
): ResolvedModelRoleValue {
|
||
const roleModelStr =
|
||
role === "default"
|
||
? (this.settings.getModelRole("default") ??
|
||
(currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
|
||
: this.settings.getModelRole(role);
|
||
|
||
if (!roleModelStr) {
|
||
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
|
||
}
|
||
|
||
return resolveModelRoleValue(roleModelStr, availableModels, {
|
||
settings: this.settings,
|
||
matchPreferences: getModelMatchPreferences(this.settings),
|
||
modelRegistry: this.#modelRegistry,
|
||
});
|
||
}
|
||
|
||
#getCompactionModelCandidates(availableModels: Model[]): Model[] {
|
||
const candidates: Model[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
const addCandidate = (model: Model | undefined): void => {
|
||
if (!model) return;
|
||
const key = this.#getModelKey(model);
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
candidates.push(model);
|
||
};
|
||
|
||
const currentModel = this.model;
|
||
// Prefer the active session's model: it's what the user is actively using,
|
||
// and routing compaction to a different provider (e.g. an OpenAI default
|
||
// model while the chat is on Anthropic) changes provider-specific behavior
|
||
// like remote compaction endpoints. Role-based candidates only kick in
|
||
// as auth fallbacks when the current model has no usable credentials.
|
||
addCandidate(currentModel);
|
||
for (const role of MODEL_ROLE_IDS) {
|
||
addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
|
||
}
|
||
|
||
const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
|
||
for (const model of sortedByContext) {
|
||
if (!seen.has(this.#getModelKey(model))) {
|
||
addCandidate(model);
|
||
break;
|
||
}
|
||
}
|
||
|
||
return candidates;
|
||
}
|
||
#isCompactionAuthFailure(error: unknown): boolean {
|
||
if (!(error instanceof Error)) return false;
|
||
// Real provider 401/403 — surfaced as `.status` by the compaction layer
|
||
// (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts).
|
||
// Without this branch, an expired/revoked Anthropic key would bypass the
|
||
// authenticated-fallback path and dump the raw HTTP body into the UI.
|
||
const status = (error as Error & { status?: number }).status;
|
||
if (status === 401 || status === 403) return true;
|
||
// pi-native gateway synthetic for "no credential configured" (issue #986).
|
||
// Carries no HTTP status, so the legacy message regex stays.
|
||
return /auth_unavailable|no auth available/i.test(error.message);
|
||
}
|
||
|
||
#buildCompactionAuthError(): Error {
|
||
const currentModel = this.model;
|
||
if (!currentModel) {
|
||
return new Error(
|
||
"Compaction requires a model with usable credentials, but no authenticated compaction model is available.",
|
||
);
|
||
}
|
||
return new Error(
|
||
`Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` +
|
||
`Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`,
|
||
);
|
||
}
|
||
|
||
async #compactWithFallbackModel(
|
||
preparation: CompactionPreparation,
|
||
customInstructions: string | undefined,
|
||
signal: AbortSignal,
|
||
options?: SummaryOptions,
|
||
): Promise<CompactionResult> {
|
||
const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
|
||
const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
|
||
|
||
for (const candidate of candidates) {
|
||
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
||
if (!apiKey) continue;
|
||
|
||
try {
|
||
return await compact(
|
||
this.#obfuscatePreparationForProvider(preparation),
|
||
candidate,
|
||
this.#modelRegistry.resolver(candidate, this.sessionId),
|
||
this.#obfuscateTextForProvider(customInstructions),
|
||
signal,
|
||
{
|
||
...options,
|
||
metadata: this.agent.metadataForProvider(candidate.provider),
|
||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||
telemetry,
|
||
// Honor the user's /model thinking selection (incl. `off`) on
|
||
// the manual `/compact` path. Clamped per-model inside compact()
|
||
// via resolveCompactionEffort so unsupported-effort models
|
||
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
|
||
thinkingLevel: this.thinkingLevel,
|
||
},
|
||
);
|
||
} catch (error) {
|
||
if (!this.#isCompactionAuthFailure(error)) {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
throw this.#buildCompactionAuthError();
|
||
}
|
||
|
||
async #prepareCompactionFromHooks(
|
||
preparation: CompactionPreparation,
|
||
hookCompaction: CompactionResult | undefined,
|
||
): Promise<
|
||
| {
|
||
kind: "fromHook";
|
||
summary: string;
|
||
shortSummary: string | undefined;
|
||
firstKeptEntryId: string;
|
||
tokensBefore: number;
|
||
details: unknown;
|
||
preserveData: Record<string, unknown> | undefined;
|
||
}
|
||
| {
|
||
kind: "needsLlm";
|
||
hookContext: string[] | undefined;
|
||
hookPrompt: string | undefined;
|
||
preserveData: Record<string, unknown> | undefined;
|
||
}
|
||
> {
|
||
let hookContext: string[] | undefined;
|
||
let hookPrompt: string | undefined;
|
||
let preserveData: Record<string, unknown> | undefined;
|
||
|
||
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
|
||
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session.compacting",
|
||
sessionId: this.sessionId,
|
||
messages: compactMessages,
|
||
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
||
|
||
hookContext = result?.context;
|
||
hookPrompt = result?.prompt;
|
||
preserveData = result?.preserveData;
|
||
}
|
||
|
||
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
|
||
if (memoryBackendContext) {
|
||
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
|
||
}
|
||
|
||
if (hookCompaction) {
|
||
preserveData ??= hookCompaction.preserveData;
|
||
return {
|
||
kind: "fromHook",
|
||
summary: hookCompaction.summary,
|
||
shortSummary: hookCompaction.shortSummary,
|
||
firstKeptEntryId: hookCompaction.firstKeptEntryId,
|
||
tokensBefore: hookCompaction.tokensBefore,
|
||
details: hookCompaction.details,
|
||
preserveData,
|
||
};
|
||
}
|
||
|
||
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
|
||
}
|
||
|
||
/**
|
||
* Internal: Run auto-compaction with events.
|
||
*
|
||
* @param allowDefer If true (default), threshold-driven handoff strategy is allowed to
|
||
* schedule itself as a deferred post-prompt task and return `true` immediately. The
|
||
* caller MUST treat that as "compaction will happen async — do not also schedule
|
||
* `agent.continue()` for this turn", otherwise the deferred handoff races a fresh
|
||
* streaming turn (the symptom: "Auto-handoff" loader + assistant message still
|
||
* streaming). Callers on a path that is about to start a new agent turn (e.g.
|
||
* the pre-prompt check in `#promptWithMessage`) pass `false` to force inline
|
||
* execution so the handoff completes before the new turn begins.
|
||
* @returns true when a deferred handoff was scheduled. Inline runs always return false.
|
||
*/
|
||
async #runAutoCompaction(
|
||
reason: "overflow" | "threshold" | "idle" | "incomplete",
|
||
willRetry: boolean,
|
||
deferred = false,
|
||
allowDefer = true,
|
||
options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
|
||
): Promise<boolean> {
|
||
const compactionSettings = this.settings.getGroup("compaction");
|
||
if (compactionSettings.strategy === "off") return false;
|
||
if (reason !== "idle" && !compactionSettings.enabled) return false;
|
||
const generation = this.#promptGeneration;
|
||
const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
|
||
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
|
||
// reclaims nothing we fall through to the summary-compaction body below so
|
||
// the oversized input still gets resolved.
|
||
if (compactionSettings.strategy === "shake") {
|
||
const outcome = await this.#runAutoShake(
|
||
reason,
|
||
willRetry,
|
||
generation,
|
||
shouldAutoContinue,
|
||
options.triggerContextTokens,
|
||
);
|
||
if (outcome !== "fallback") return false;
|
||
}
|
||
// "overflow" and "incomplete" force inline execution because they are recovery
|
||
// paths the caller wants resolved before scheduling the next turn. "idle" is
|
||
// triggered by the idle loop and does its own scheduling.
|
||
if (
|
||
!deferred &&
|
||
allowDefer &&
|
||
reason !== "overflow" &&
|
||
reason !== "incomplete" &&
|
||
reason !== "idle" &&
|
||
compactionSettings.strategy === "handoff"
|
||
) {
|
||
this.#schedulePostPromptTask(
|
||
async signal => {
|
||
await Promise.resolve();
|
||
if (signal.aborted) return;
|
||
await this.#runAutoCompaction(reason, willRetry, true);
|
||
},
|
||
{ generation },
|
||
);
|
||
return true;
|
||
}
|
||
|
||
// "overflow" forces context-full because the input itself is broken — a handoff
|
||
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
|
||
// so a handoff request on the existing context is still viable. Snapcompact is
|
||
// safe for every reason (it makes no LLM call at all) but requires a vision
|
||
// model to be worth anything — fall back to context-full otherwise.
|
||
let action: "context-full" | "handoff" | "snapcompact" =
|
||
compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
|
||
if (compactionSettings.strategy === "snapcompact") {
|
||
if (this.model?.input.includes("image")) {
|
||
action = "snapcompact";
|
||
} else {
|
||
logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", {
|
||
model: this.model?.id,
|
||
});
|
||
this.emitNotice(
|
||
"warning",
|
||
`snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`,
|
||
"compaction",
|
||
);
|
||
}
|
||
}
|
||
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
||
// Abort any older auto-compaction before installing this run's controller.
|
||
this.#autoCompactionAbortController?.abort();
|
||
const autoCompactionAbortController = new AbortController();
|
||
this.#autoCompactionAbortController = autoCompactionAbortController;
|
||
const autoCompactionSignal = autoCompactionAbortController.signal;
|
||
|
||
try {
|
||
if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
|
||
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
|
||
const handoffResult = await this.handoff(handoffFocus, {
|
||
autoTriggered: true,
|
||
signal: this.#autoCompactionAbortController.signal,
|
||
});
|
||
if (!handoffResult) {
|
||
const aborted = autoCompactionSignal.aborted;
|
||
if (aborted) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: true,
|
||
willRetry: false,
|
||
});
|
||
return false;
|
||
}
|
||
logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
|
||
reason,
|
||
});
|
||
action = "context-full";
|
||
}
|
||
if (handoffResult) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
});
|
||
if (!autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue) {
|
||
this.#scheduleAutoContinuePrompt(generation);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!this.model) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
skipped: true,
|
||
});
|
||
return false;
|
||
}
|
||
|
||
const availableModels = this.#modelRegistry.getAvailable();
|
||
if (availableModels.length === 0) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
skipped: true,
|
||
});
|
||
return false;
|
||
}
|
||
|
||
const pathEntries = this.sessionManager.getBranch();
|
||
|
||
const preparation = prepareCompaction(pathEntries, compactionSettings);
|
||
if (!preparation) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
skipped: true,
|
||
});
|
||
if (!willRetry && this.agent.hasQueuedMessages()) {
|
||
this.#scheduleAgentContinue({
|
||
delayMs: 100,
|
||
generation,
|
||
shouldContinue: () => this.agent.hasQueuedMessages(),
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
|
||
let hookCompaction: CompactionResult | undefined;
|
||
let fromExtension = false;
|
||
let preserveData: Record<string, unknown> | undefined;
|
||
|
||
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
||
const hookResult = (await this.#extensionRunner.emit({
|
||
type: "session_before_compact",
|
||
preparation,
|
||
branchEntries: pathEntries,
|
||
customInstructions: undefined,
|
||
signal: autoCompactionSignal,
|
||
})) as SessionBeforeCompactResult | undefined;
|
||
|
||
if (hookResult?.cancel) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: true,
|
||
willRetry: false,
|
||
});
|
||
return false;
|
||
}
|
||
|
||
if (hookResult?.compaction) {
|
||
hookCompaction = hookResult.compaction;
|
||
fromExtension = true;
|
||
}
|
||
}
|
||
|
||
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
||
|
||
let summary: string;
|
||
let shortSummary: string | undefined;
|
||
let firstKeptEntryId: string;
|
||
let tokensBefore: number;
|
||
let details: unknown;
|
||
|
||
if (compactionPrep.kind === "fromHook") {
|
||
summary = compactionPrep.summary;
|
||
shortSummary = compactionPrep.shortSummary;
|
||
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
||
tokensBefore = compactionPrep.tokensBefore;
|
||
details = compactionPrep.details;
|
||
preserveData = compactionPrep.preserveData;
|
||
} else if (action === "snapcompact") {
|
||
// Local, deterministic: render discarded history onto PNG frames.
|
||
// No model candidates, no API key, no retry loop.
|
||
const snapcompactResult = await snapcompact.compact(preparation, {
|
||
convertToLlm,
|
||
model: this.model,
|
||
maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
|
||
});
|
||
summary = snapcompactResult.summary;
|
||
shortSummary = snapcompactResult.shortSummary;
|
||
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
||
tokensBefore = snapcompactResult.tokensBefore;
|
||
details = snapcompactResult.details;
|
||
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
|
||
} else {
|
||
const candidates = this.#getCompactionModelCandidates(availableModels);
|
||
const retrySettings = this.settings.getGroup("retry");
|
||
const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
|
||
let compactResult: CompactionResult | undefined;
|
||
let lastError: unknown;
|
||
|
||
for (const candidate of candidates) {
|
||
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
||
if (!apiKey) continue;
|
||
|
||
let attempt = 0;
|
||
while (true) {
|
||
try {
|
||
compactResult = await compact(
|
||
this.#obfuscatePreparationForProvider(preparation),
|
||
candidate,
|
||
this.#modelRegistry.resolver(candidate, this.sessionId),
|
||
undefined,
|
||
autoCompactionSignal,
|
||
{
|
||
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
|
||
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
|
||
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
|
||
metadata: this.agent.metadataForProvider(candidate.provider),
|
||
initiatorOverride: "agent",
|
||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||
telemetry,
|
||
// Honor the user's /model thinking selection on the
|
||
// auto-compaction path — the most-fired compaction
|
||
// site. Clamped per-model inside compact() via
|
||
// resolveCompactionEffort.
|
||
thinkingLevel: this.thinkingLevel,
|
||
},
|
||
);
|
||
break;
|
||
} catch (error) {
|
||
if (autoCompactionSignal.aborted) {
|
||
throw error;
|
||
}
|
||
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
if (this.#isCompactionAuthFailure(error)) {
|
||
lastError = this.#buildCompactionAuthError();
|
||
break;
|
||
}
|
||
const retryAfterMs = this.#parseRetryAfterMsFromError(message);
|
||
const shouldRetry =
|
||
retrySettings.enabled &&
|
||
attempt < retrySettings.maxRetries &&
|
||
(retryAfterMs !== undefined ||
|
||
this.#isTransientErrorMessage(message) ||
|
||
isUsageLimitError(message));
|
||
if (!shouldRetry) {
|
||
lastError = error;
|
||
break;
|
||
}
|
||
|
||
const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
|
||
const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
|
||
|
||
// If retry delay is too long (>30s), try next candidate instead of waiting
|
||
const maxAcceptableDelayMs = 30_000;
|
||
if (delayMs > maxAcceptableDelayMs) {
|
||
const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1;
|
||
if (hasMoreCandidates) {
|
||
logger.warn("Auto-compaction retry delay too long, trying next model", {
|
||
delayMs,
|
||
retryAfterMs,
|
||
error: message,
|
||
model: `${candidate.provider}/${candidate.id}`,
|
||
});
|
||
lastError = error;
|
||
break; // Exit retry loop, continue to next candidate
|
||
}
|
||
// No more candidates - we have to wait
|
||
}
|
||
|
||
attempt++;
|
||
logger.warn("Auto-compaction failed, retrying", {
|
||
attempt,
|
||
maxRetries: retrySettings.maxRetries,
|
||
delayMs,
|
||
retryAfterMs,
|
||
error: message,
|
||
model: `${candidate.provider}/${candidate.id}`,
|
||
});
|
||
await scheduler.wait(delayMs, { signal: autoCompactionSignal });
|
||
}
|
||
}
|
||
|
||
if (compactResult) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!compactResult) {
|
||
if (lastError) {
|
||
throw lastError;
|
||
}
|
||
throw new Error("Compaction failed: no available model");
|
||
}
|
||
|
||
summary = compactResult.summary;
|
||
shortSummary = compactResult.shortSummary;
|
||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||
tokensBefore = compactResult.tokensBefore;
|
||
details = compactResult.details;
|
||
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
|
||
}
|
||
|
||
if (autoCompactionSignal.aborted) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: true,
|
||
willRetry: false,
|
||
});
|
||
return false;
|
||
}
|
||
|
||
this.sessionManager.appendCompaction(
|
||
summary,
|
||
shortSummary,
|
||
firstKeptEntryId,
|
||
tokensBefore,
|
||
details,
|
||
fromExtension,
|
||
preserveData,
|
||
);
|
||
const newEntries = this.sessionManager.getEntries();
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
|
||
// Get the saved compaction entry for the hook
|
||
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
||
| CompactionEntry
|
||
| undefined;
|
||
|
||
if (this.#extensionRunner && savedCompactionEntry) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_compact",
|
||
compactionEntry: savedCompactionEntry,
|
||
fromExtension,
|
||
});
|
||
}
|
||
|
||
const result: CompactionResult = {
|
||
summary,
|
||
shortSummary,
|
||
firstKeptEntryId,
|
||
tokensBefore,
|
||
details,
|
||
preserveData,
|
||
};
|
||
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
|
||
|
||
if (!willRetry && reason !== "idle" && shouldAutoContinue) {
|
||
this.#scheduleAutoContinuePrompt(generation);
|
||
}
|
||
|
||
if (willRetry) {
|
||
const messages = this.agent.state.messages;
|
||
const lastMsg = messages[messages.length - 1];
|
||
if (lastMsg?.role === "assistant") {
|
||
const lastAssistant = lastMsg as AssistantMessage;
|
||
// Drop the prior turn before retry when it carries no actionable deliverable:
|
||
// - "error": failure was kept in history but must not re-enter the next turn's prompt.
|
||
// - reason === "incomplete" && stopReason === "length": truncated output (typically
|
||
// reasoning-only) — re-running it produces the same dead-end.
|
||
const shouldDrop =
|
||
lastAssistant.stopReason === "error" ||
|
||
(reason === "incomplete" && lastAssistant.stopReason === "length");
|
||
if (shouldDrop) {
|
||
this.agent.replaceMessages(messages.slice(0, -1));
|
||
}
|
||
}
|
||
|
||
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
||
} else if (this.agent.hasQueuedMessages()) {
|
||
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
|
||
// Kick the loop so queued messages are actually delivered.
|
||
this.#scheduleAgentContinue({
|
||
delayMs: 100,
|
||
generation,
|
||
shouldContinue: () => this.agent.hasQueuedMessages(),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (autoCompactionSignal.aborted) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: true,
|
||
willRetry: false,
|
||
});
|
||
return false;
|
||
}
|
||
const errorMessage = error instanceof Error ? error.message : "compaction failed";
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
errorMessage:
|
||
reason === "overflow"
|
||
? `Context overflow recovery failed: ${errorMessage}`
|
||
: reason === "incomplete"
|
||
? `Incomplete response recovery failed: ${errorMessage}`
|
||
: `Auto-compaction failed: ${errorMessage}`,
|
||
});
|
||
} finally {
|
||
if (this.#autoCompactionAbortController === autoCompactionAbortController) {
|
||
this.#autoCompactionAbortController = undefined;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Run a shake-strategy auto-maintenance pass. Emits the
|
||
* `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`,
|
||
* runs {@link shake} inline against the protect-window config, and schedules
|
||
* continuation exactly like the context-full tail.
|
||
*
|
||
* Returns `"fallback"` only for an overflow recovery where shake reclaimed
|
||
* nothing (or threw) — the caller then runs the summary-compaction body so
|
||
* the oversized input still gets resolved. Returns `"handled"` otherwise.
|
||
*/
|
||
async #runAutoShake(
|
||
reason: "overflow" | "threshold" | "idle" | "incomplete",
|
||
willRetry: boolean,
|
||
generation: number,
|
||
autoContinue: boolean,
|
||
triggerContextTokens?: number,
|
||
): Promise<"handled" | "fallback"> {
|
||
const action = "shake";
|
||
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
||
this.#autoCompactionAbortController?.abort();
|
||
const controller = new AbortController();
|
||
this.#autoCompactionAbortController = controller;
|
||
const signal = controller.signal;
|
||
try {
|
||
const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
|
||
if (signal.aborted) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: true,
|
||
willRetry: false,
|
||
});
|
||
return "handled";
|
||
}
|
||
const reclaimed = result.toolResultsDropped + result.blocksDropped > 0;
|
||
// Detect the dead-loop reported in issues #2119/#2275: the threshold check
|
||
// fires, shake runs, but residual context is still above the configured
|
||
// threshold. The next agent_end would re-trigger shake, which has nothing
|
||
// new to drop on the second pass, so the loop spins until the user kills it.
|
||
// Same hazard for "incomplete" (the retry would re-hit the length cap) and
|
||
// for the existing "overflow + nothing reclaimed" case. In every recovery
|
||
// reason we hand off to the summarization-driven context-full path so the
|
||
// situation actually resolves; "idle" is exempt because its 60s+ timer
|
||
// re-checks usage before re-firing and cannot dead-loop on its own.
|
||
//
|
||
// #2275: the post-shake check MUST be anchored on the same metric that
|
||
// triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
|
||
// undercounts thinking-signature payloads, so on thinking-heavy sessions it
|
||
// reads well below the provider-reported usage that fired the threshold.
|
||
// When that estimate slips under the threshold, the fallback never fires
|
||
// and the auto-continue prompt re-injects every turn. Prefer the trigger's
|
||
// own `contextTokens` (provider-anchored) when the caller supplies it, and
|
||
// add hysteresis (80% recovery band) so we don't oscillate at the boundary
|
||
// while shake keeps reclaiming a trickle of the previous turn's output.
|
||
const contextWindow = this.model?.contextWindow ?? 0;
|
||
const compactionSettings = this.settings.getGroup("compaction");
|
||
let stillOverThreshold = false;
|
||
if (contextWindow > 0) {
|
||
if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
|
||
const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
|
||
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
||
const recoveryBand = Math.floor(thresholdTokens * SHAKE_RECOVERY_BAND);
|
||
stillOverThreshold = correctedTokens > recoveryBand;
|
||
} else {
|
||
const postShakeTokens = this.#estimatePendingPromptTokens([]);
|
||
stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
|
||
}
|
||
}
|
||
const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
|
||
if (shouldFallBack) {
|
||
const errorMessage = reclaimed
|
||
? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.`
|
||
: "Auto-shake found nothing eligible to drop; falling back to context-full compaction.";
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
skipped: !reclaimed,
|
||
errorMessage,
|
||
});
|
||
return "fallback";
|
||
}
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry,
|
||
skipped: !reclaimed,
|
||
});
|
||
|
||
if (!willRetry && reason !== "idle" && autoContinue) {
|
||
this.#scheduleAutoContinuePrompt(generation);
|
||
}
|
||
if (willRetry) {
|
||
// The shake rebuild replays every entry, so a trailing error/length
|
||
// assistant from the failed turn re-enters agent state — drop it before
|
||
// retrying, same as the context-full tail.
|
||
const messages = this.agent.state.messages;
|
||
const lastMsg = messages[messages.length - 1];
|
||
if (lastMsg?.role === "assistant") {
|
||
const lastAssistant = lastMsg as AssistantMessage;
|
||
const shouldDrop =
|
||
lastAssistant.stopReason === "error" ||
|
||
(reason === "incomplete" && lastAssistant.stopReason === "length");
|
||
if (shouldDrop) this.agent.replaceMessages(messages.slice(0, -1));
|
||
}
|
||
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
||
} else if (this.agent.hasQueuedMessages()) {
|
||
this.#scheduleAgentContinue({
|
||
delayMs: 100,
|
||
generation,
|
||
shouldContinue: () => this.agent.hasQueuedMessages(),
|
||
});
|
||
}
|
||
return "handled";
|
||
} catch (error) {
|
||
if (signal.aborted) {
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: true,
|
||
willRetry: false,
|
||
});
|
||
return "handled";
|
||
}
|
||
const message = error instanceof Error ? error.message : "shake failed";
|
||
await this.#emitSessionEvent({
|
||
type: "auto_compaction_end",
|
||
action,
|
||
result: undefined,
|
||
aborted: false,
|
||
willRetry: false,
|
||
errorMessage: `Auto-shake failed: ${message}`,
|
||
});
|
||
// Overflow still needs recovery even if shake threw.
|
||
return reason === "overflow" ? "fallback" : "handled";
|
||
} finally {
|
||
if (this.#autoCompactionAbortController === controller) {
|
||
this.#autoCompactionAbortController = undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle auto-compaction setting.
|
||
*/
|
||
setAutoCompactionEnabled(enabled: boolean): void {
|
||
this.settings.set("compaction.enabled", enabled);
|
||
if (enabled && this.settings.get("compaction.strategy") === "off") {
|
||
this.settings.set("compaction.strategy", "context-full");
|
||
}
|
||
}
|
||
|
||
/** Whether auto-compaction is enabled */
|
||
get autoCompactionEnabled(): boolean {
|
||
return this.settings.get("compaction.enabled") && this.settings.get("compaction.strategy") !== "off";
|
||
}
|
||
|
||
// =========================================================================
|
||
// Auto-Retry
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Check if an error is retryable (transient errors or usage limits).
|
||
* Context overflow errors are NOT retryable (handled by compaction instead).
|
||
* Usage-limit errors are retryable because the retry handler performs credential switching.
|
||
*/
|
||
#isRetryableError(message: AssistantMessage): boolean {
|
||
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
||
|
||
// Context overflow is handled by compaction, not retry
|
||
const contextWindow = this.model?.contextWindow ?? 0;
|
||
if (isContextOverflow(message, contextWindow)) return false;
|
||
|
||
if (this.#isClassifierRefusal(message)) return true;
|
||
if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
|
||
|
||
const err = message.errorMessage;
|
||
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
|
||
}
|
||
|
||
#isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
|
||
const currentApi = this.model?.api;
|
||
if (
|
||
message.api !== "openai-responses" &&
|
||
message.api !== "openai-codex-responses" &&
|
||
currentApi !== "openai-responses" &&
|
||
currentApi !== "openai-codex-responses"
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
const errorMessage = message.errorMessage;
|
||
if (!errorMessage) return false;
|
||
|
||
return (
|
||
/\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
|
||
(/previous[ _]?response/i.test(errorMessage) &&
|
||
/not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
|
||
);
|
||
}
|
||
|
||
#isClassifierRefusal(message: AssistantMessage): boolean {
|
||
if (message.stopReason !== "error") return false;
|
||
const stopType = message.stopDetails?.type;
|
||
return stopType === "refusal" || stopType === "sensitive";
|
||
}
|
||
|
||
#isTransientErrorMessage(errorMessage: string): boolean {
|
||
return (
|
||
this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
|
||
);
|
||
}
|
||
|
||
#isTransientEnvelopeErrorMessage(errorMessage: string): boolean {
|
||
// Match Anthropic stream-envelope failures that indicate a broken stream before any content starts.
|
||
return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
|
||
}
|
||
|
||
#isTransientTransportErrorMessage(errorMessage: string): boolean {
|
||
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
||
// service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
|
||
// gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets
|
||
// (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from
|
||
// src/http/h2_client/dispatch.zig)
|
||
return (
|
||
isUnexpectedSocketCloseMessage(errorMessage) ||
|
||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test(
|
||
errorMessage,
|
||
)
|
||
);
|
||
}
|
||
|
||
#getRetryFallbackChains(): RetryFallbackChains {
|
||
const configuredChains = this.settings.get("retry.fallbackChains");
|
||
if (!configuredChains || typeof configuredChains !== "object") return {};
|
||
return configuredChains as RetryFallbackChains;
|
||
}
|
||
|
||
#validateRetryFallbackChains(): void {
|
||
const configuredChains = this.settings.get("retry.fallbackChains");
|
||
if (configuredChains === undefined) return;
|
||
if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
|
||
const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
|
||
logger.warn(msg);
|
||
this.configWarnings.push(msg);
|
||
return;
|
||
}
|
||
|
||
for (const [role, chain] of Object.entries(configuredChains)) {
|
||
if (!Array.isArray(chain)) {
|
||
const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
|
||
logger.warn(msg);
|
||
this.configWarnings.push(msg);
|
||
continue;
|
||
}
|
||
for (const selectorStr of chain) {
|
||
if (typeof selectorStr !== "string") {
|
||
const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
|
||
logger.warn(msg);
|
||
this.configWarnings.push(msg);
|
||
continue;
|
||
}
|
||
const parsed = parseRetryFallbackSelector(selectorStr);
|
||
if (!parsed) {
|
||
const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
|
||
logger.warn(msg);
|
||
this.configWarnings.push(msg);
|
||
continue;
|
||
}
|
||
const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
|
||
if (!exists) {
|
||
const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
|
||
logger.warn(msg);
|
||
this.configWarnings.push(msg);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#getRetryFallbackRevertPolicy(): RetryFallbackRevertPolicy {
|
||
return this.settings.get("retry.fallbackRevertPolicy") === "never" ? "never" : "cooldown-expiry";
|
||
}
|
||
|
||
#getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
|
||
const configuredSelector = this.settings.getModelRole(role);
|
||
return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
|
||
}
|
||
|
||
#clearActiveRetryFallback(): void {
|
||
this.#activeRetryFallback = undefined;
|
||
}
|
||
|
||
#isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean {
|
||
return this.#modelRegistry.isSelectorSuppressed(selector.raw);
|
||
}
|
||
|
||
#noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void {
|
||
let cooldownMs = retryAfterMs;
|
||
if (!cooldownMs || cooldownMs <= 0) {
|
||
const reason = parseRateLimitReason(errorMessage);
|
||
cooldownMs = reason === "UNKNOWN" ? 5 * 60 * 1000 : calculateRateLimitBackoffMs(reason);
|
||
}
|
||
this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
|
||
}
|
||
|
||
#resolveRetryFallbackRole(currentSelector: string): string | undefined {
|
||
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
|
||
if (!parsedCurrent) return undefined;
|
||
const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
|
||
for (const role of Object.keys(this.#getRetryFallbackChains())) {
|
||
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
|
||
if (!primarySelector) continue;
|
||
if (primarySelector.raw === currentSelector) return role;
|
||
if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
#getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
|
||
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
|
||
if (!primarySelector) return [];
|
||
const chain = [primarySelector];
|
||
const seen = new Set<string>([primarySelector.raw]);
|
||
for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
|
||
const parsed = parseRetryFallbackSelector(selector);
|
||
if (!parsed || seen.has(parsed.raw)) continue;
|
||
seen.add(parsed.raw);
|
||
chain.push(parsed);
|
||
}
|
||
return chain;
|
||
}
|
||
|
||
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
|
||
const chain = this.#getRetryFallbackEffectiveChain(role);
|
||
if (chain.length <= 1) return [];
|
||
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
|
||
const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
|
||
const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
|
||
if (exactIndex >= 0) return chain.slice(exactIndex + 1);
|
||
const baseIndex = currentBaseSelector
|
||
? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
|
||
: -1;
|
||
if (baseIndex >= 0) return chain.slice(baseIndex + 1);
|
||
return chain.slice(1);
|
||
}
|
||
|
||
async #applyRetryFallbackCandidate(
|
||
role: string,
|
||
selector: RetryFallbackSelector,
|
||
currentSelector: string,
|
||
options?: { pinFallback?: boolean },
|
||
): Promise<void> {
|
||
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
|
||
if (!candidate) {
|
||
throw new Error(`Retry fallback model not found: ${selector.raw}`);
|
||
}
|
||
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for retry fallback ${selector.raw}`);
|
||
}
|
||
|
||
// Capture the configured selector (auto-aware) so a fallback chain preserves
|
||
// `auto` instead of collapsing it to the level it resolved to this turn.
|
||
const currentThinkingLevel = this.configuredThinkingLevel();
|
||
const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
|
||
|
||
this.#setModelWithProviderSessionReset(candidate);
|
||
this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
|
||
this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
|
||
this.setThinkingLevel(nextThinkingLevel);
|
||
if (!this.#activeRetryFallback) {
|
||
this.#activeRetryFallback = {
|
||
role,
|
||
originalSelector: currentSelector,
|
||
originalThinkingLevel: currentThinkingLevel,
|
||
lastAppliedFallbackThinkingLevel: nextThinkingLevel,
|
||
pinned: options?.pinFallback === true,
|
||
};
|
||
} else {
|
||
this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
|
||
this.#activeRetryFallback.pinned = this.#activeRetryFallback.pinned || options?.pinFallback === true;
|
||
}
|
||
await this.#emitSessionEvent({
|
||
type: "retry_fallback_applied",
|
||
from: currentSelector,
|
||
to: selector.raw,
|
||
role,
|
||
});
|
||
}
|
||
|
||
async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise<boolean> {
|
||
const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
|
||
if (!role) return false;
|
||
|
||
for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
|
||
if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
|
||
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
|
||
if (!candidate) continue;
|
||
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
||
if (!apiKey) continue;
|
||
await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
|
||
if (!this.#activeRetryFallback) return;
|
||
if (this.#activeRetryFallback.pinned) return;
|
||
if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
|
||
|
||
const {
|
||
originalSelector: originalSelectorRaw,
|
||
originalThinkingLevel,
|
||
lastAppliedFallbackThinkingLevel,
|
||
} = this.#activeRetryFallback;
|
||
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
|
||
if (!originalSelector) {
|
||
this.#clearActiveRetryFallback();
|
||
return;
|
||
}
|
||
|
||
const currentModel = this.model;
|
||
if (!currentModel) return;
|
||
const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel);
|
||
if (currentSelector === originalSelector.raw) {
|
||
if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
|
||
this.#clearActiveRetryFallback();
|
||
}
|
||
return;
|
||
}
|
||
if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
|
||
|
||
const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
|
||
if (!primaryModel) return;
|
||
const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
|
||
if (!apiKey) return;
|
||
|
||
const currentThinkingLevel = this.configuredThinkingLevel();
|
||
const thinkingToApply =
|
||
currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
|
||
this.#setModelWithProviderSessionReset(primaryModel);
|
||
this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
|
||
this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
|
||
this.setThinkingLevel(thinkingToApply);
|
||
this.#clearActiveRetryFallback();
|
||
}
|
||
|
||
#parseRetryAfterMsFromError(errorMessage: string): number | undefined {
|
||
const now = Date.now();
|
||
const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
||
if (retryAfterMsMatch) {
|
||
return Math.max(0, Number(retryAfterMsMatch[1]));
|
||
}
|
||
|
||
const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
|
||
if (retryAfterMatch) {
|
||
const value = retryAfterMatch[1];
|
||
const seconds = Number(value);
|
||
if (!Number.isNaN(seconds)) {
|
||
return Math.max(0, seconds * 1000);
|
||
}
|
||
const dateMs = Date.parse(value);
|
||
if (!Number.isNaN(dateMs)) {
|
||
return Math.max(0, dateMs - now);
|
||
}
|
||
}
|
||
|
||
const retryHintMs = extractRetryHint(undefined, errorMessage);
|
||
if (retryHintMs !== undefined) {
|
||
return retryHintMs;
|
||
}
|
||
|
||
const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
||
if (resetMsMatch) {
|
||
const resetMs = Number(resetMsMatch[1]);
|
||
if (!Number.isNaN(resetMs)) {
|
||
if (resetMs > 1_000_000_000_000) {
|
||
return Math.max(0, resetMs - now);
|
||
}
|
||
return Math.max(0, resetMs);
|
||
}
|
||
}
|
||
|
||
const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
||
if (resetMatch) {
|
||
const resetSeconds = Number(resetMatch[1]);
|
||
if (!Number.isNaN(resetSeconds)) {
|
||
if (resetSeconds > 1_000_000_000) {
|
||
return Math.max(0, resetSeconds * 1000 - now);
|
||
}
|
||
return Math.max(0, resetSeconds * 1000);
|
||
}
|
||
}
|
||
|
||
// Smart Fallback if no exact headers found
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Handle retryable errors with exponential backoff.
|
||
* @returns true if retry was initiated, false if max retries exceeded or disabled
|
||
*/
|
||
async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
|
||
const retrySettings = this.settings.getGroup("retry");
|
||
if (!retrySettings.enabled) return false;
|
||
const classifierRefusal = this.#isClassifierRefusal(message);
|
||
|
||
const generation = this.#promptGeneration;
|
||
this.#retryAttempt++;
|
||
|
||
// Create retry promise on first attempt so waitForRetry() can await it
|
||
// Ensure only one promise exists (avoid orphaned promises from concurrent calls)
|
||
if (!this.#retryPromise) {
|
||
const { promise, resolve } = Promise.withResolvers<void>();
|
||
this.#retryPromise = promise;
|
||
this.#retryResolve = resolve;
|
||
}
|
||
|
||
if (this.#retryAttempt > retrySettings.maxRetries) {
|
||
// Max retries exceeded, emit final failure and reset
|
||
await this.#emitSessionEvent({
|
||
type: "auto_retry_end",
|
||
success: false,
|
||
attempt: this.#retryAttempt - 1,
|
||
finalError: message.errorMessage,
|
||
});
|
||
this.#retryAttempt = 0;
|
||
this.#resolveRetry(); // Resolve so waitForRetry() completes
|
||
return false;
|
||
}
|
||
|
||
const errorMessage = message.errorMessage || "Unknown error";
|
||
const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
|
||
const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
|
||
let delayMs = staleOpenAIResponsesReplayError
|
||
? 0
|
||
: calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
|
||
let switchedCredential = false;
|
||
let switchedModel = false;
|
||
// Set when a usage-limit error pinned the wait to credential
|
||
// availability — suppresses the generic retry-after bump below.
|
||
let usageLimitWaitMs: number | undefined;
|
||
|
||
if (staleOpenAIResponsesReplayError) {
|
||
this.#resetCurrentResponsesProviderSession("stale replay error");
|
||
}
|
||
|
||
if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
|
||
const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
||
const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
|
||
this.model.provider,
|
||
this.sessionId,
|
||
{
|
||
retryAfterMs,
|
||
baseUrl: this.model.baseUrl,
|
||
modelId: this.model.id,
|
||
},
|
||
);
|
||
if (outcome.switched) {
|
||
switchedCredential = true;
|
||
delayMs = 0;
|
||
} else if (await this.#maybeAutoRedeemCodexReset()) {
|
||
// A live usage-limit 429 on the active Codex account, with a banked
|
||
// reset and the opt-in setting on: spend the reset and retry
|
||
// immediately instead of waiting out the window. Runs after the
|
||
// free sibling-switch above and before model fallback below.
|
||
switchedCredential = true;
|
||
delayMs = 0;
|
||
} else {
|
||
// No sibling credential is usable right now. Wait for whichever
|
||
// comes first: the provider's retry-after window for the current
|
||
// account, or the earliest moment a temporarily blocked sibling
|
||
// frees up (e.g. a 60s post-401 block or a 5-min usage-probe
|
||
// block) — the next attempt's getApiKey re-ranks and picks it up.
|
||
// Without this, one short-lived sibling block escalates a
|
||
// recoverable situation into the provider's multi-hour wait and
|
||
// trips the fail-fast cap below.
|
||
usageLimitWaitMs = retryAfterMs;
|
||
if (outcome.retryAtMs !== undefined) {
|
||
const siblingWaitMs = Math.max(0, outcome.retryAtMs - Date.now()) + SIBLING_UNBLOCK_BUFFER_MS;
|
||
if (siblingWaitMs < usageLimitWaitMs) {
|
||
usageLimitWaitMs = siblingWaitMs;
|
||
}
|
||
}
|
||
if (usageLimitWaitMs > delayMs) {
|
||
delayMs = usageLimitWaitMs;
|
||
}
|
||
}
|
||
}
|
||
|
||
const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
|
||
if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
|
||
if (retrySettings.modelFallback) {
|
||
if (!classifierRefusal) {
|
||
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
|
||
}
|
||
switchedModel = await this.#tryRetryModelFallback(currentSelector, { pinFallback: classifierRefusal });
|
||
}
|
||
if (switchedModel) {
|
||
delayMs = 0;
|
||
} else if (usageLimitWaitMs === undefined && parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
|
||
delayMs = parsedRetryAfterMs;
|
||
}
|
||
}
|
||
if (classifierRefusal && !switchedModel) {
|
||
this.#retryAttempt = 0;
|
||
this.#resolveRetry();
|
||
return false;
|
||
}
|
||
|
||
// Fail-fast cap: if the provider asks us to wait longer than
|
||
// retry.maxDelayMs and we have no fallback credential or model to
|
||
// switch to, surface the error instead of sleeping. Defends against
|
||
// 3-hour Anthropic rate-limit windows that would otherwise leave a
|
||
// subagent (or interactive session) silently hung. The original
|
||
// assistant error message is preserved in agent state so the caller
|
||
// can act on it.
|
||
const maxDelayMs = retrySettings.maxDelayMs;
|
||
if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
|
||
const attempt = this.#retryAttempt;
|
||
this.#retryAttempt = 0;
|
||
await this.#emitSessionEvent({
|
||
type: "auto_retry_end",
|
||
success: false,
|
||
attempt,
|
||
finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
|
||
});
|
||
this.#resolveRetry();
|
||
return false;
|
||
}
|
||
|
||
await this.#emitSessionEvent({
|
||
type: "auto_retry_start",
|
||
attempt: this.#retryAttempt,
|
||
maxAttempts: retrySettings.maxRetries,
|
||
delayMs,
|
||
errorMessage,
|
||
});
|
||
|
||
// Remove error message from agent state (keep in session for history)
|
||
const messages = this.agent.state.messages;
|
||
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
||
this.agent.replaceMessages(messages.slice(0, -1));
|
||
}
|
||
|
||
// Wait with exponential backoff (abortable).
|
||
const retryAbortController = new AbortController();
|
||
this.#retryAbortController?.abort();
|
||
this.#retryAbortController = retryAbortController;
|
||
try {
|
||
await scheduler.wait(delayMs, { signal: retryAbortController.signal });
|
||
} catch {
|
||
if (this.#retryAbortController !== retryAbortController) {
|
||
return false;
|
||
}
|
||
// Aborted during sleep - emit end event so UI can clean up
|
||
const attempt = this.#retryAttempt;
|
||
this.#retryAttempt = 0;
|
||
this.#retryAbortController = undefined;
|
||
await this.#emitSessionEvent({
|
||
type: "auto_retry_end",
|
||
success: false,
|
||
attempt,
|
||
finalError: "Retry cancelled",
|
||
});
|
||
this.#resolveRetry();
|
||
return false;
|
||
}
|
||
if (this.#retryAbortController === retryAbortController) {
|
||
this.#retryAbortController = undefined;
|
||
}
|
||
|
||
// Retry via continue() outside the agent_end event callback chain.
|
||
this.#scheduleAgentContinue({ delayMs: 1, generation });
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Cancel in-progress retry.
|
||
*/
|
||
abortRetry(): void {
|
||
this.#retryAbortController?.abort();
|
||
// Note: _retryAttempt is reset in the catch block of _autoRetry
|
||
this.#resolveRetry();
|
||
}
|
||
|
||
async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
|
||
const deadline = Date.now() + 30_000;
|
||
for (;;) {
|
||
try {
|
||
await this.agent.prompt(messages, options);
|
||
return;
|
||
} catch (err) {
|
||
if (!(err instanceof AgentBusyError)) {
|
||
throw err;
|
||
}
|
||
if (Date.now() >= deadline) {
|
||
throw new Error("Timed out waiting for prior agent run to finish before prompting.");
|
||
}
|
||
await this.agent.waitForIdle();
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Whether auto-retry is currently in progress */
|
||
get isRetrying(): boolean {
|
||
return this.#retryPromise !== undefined;
|
||
}
|
||
|
||
/** Whether auto-retry is enabled */
|
||
get autoRetryEnabled(): boolean {
|
||
return this.settings.get("retry.enabled") ?? true;
|
||
}
|
||
|
||
/**
|
||
* Toggle auto-retry setting.
|
||
*/
|
||
setAutoRetryEnabled(enabled: boolean): void {
|
||
this.settings.set("retry.enabled", enabled);
|
||
}
|
||
/**
|
||
* Manually retry the last failed assistant turn.
|
||
* Removes the error message from agent state and re-attempts with a fresh retry budget.
|
||
* @returns true if retry was initiated, false if no failed turn to retry or agent is busy
|
||
*/
|
||
async retry(): Promise<boolean> {
|
||
if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
|
||
|
||
const messages = this.agent.state.messages;
|
||
const lastMsg = messages[messages.length - 1];
|
||
if (lastMsg?.role !== "assistant") return false;
|
||
|
||
const assistantMsg = lastMsg as AssistantMessage;
|
||
if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
|
||
|
||
// Remove the failed/aborted assistant message (same as auto-retry does before re-attempting)
|
||
this.agent.replaceMessages(messages.slice(0, -1));
|
||
|
||
// Reset retry budget for a fresh attempt
|
||
this.#retryAttempt = 0;
|
||
|
||
// Re-attempt the turn
|
||
this.#scheduleAgentContinue({ delayMs: 1 });
|
||
|
||
return true;
|
||
}
|
||
|
||
// =========================================================================
|
||
// Bash Execution
|
||
// =========================================================================
|
||
|
||
async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
|
||
try {
|
||
return await this.sessionManager.saveArtifact(originalText, "bash-original");
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute a bash command.
|
||
* Adds result to agent context and session.
|
||
* @param command The bash command to execute
|
||
* @param onChunk Optional streaming callback for output
|
||
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
|
||
* @param options.useUserShell If true, allow caller to request configured user-shell routing
|
||
*/
|
||
async executeBash(
|
||
command: string,
|
||
onChunk?: (chunk: string) => void,
|
||
options?: { excludeFromContext?: boolean; useUserShell?: boolean },
|
||
): Promise<BashResult> {
|
||
const excludeFromContext = options?.excludeFromContext === true;
|
||
const cwd = this.sessionManager.getCwd();
|
||
|
||
if (this.#extensionRunner?.hasHandlers("user_bash")) {
|
||
const hookResult = await this.#extensionRunner.emitUserBash({
|
||
type: "user_bash",
|
||
command,
|
||
excludeFromContext,
|
||
cwd,
|
||
});
|
||
if (hookResult?.result) {
|
||
this.recordBashResult(command, hookResult.result, options);
|
||
return hookResult.result;
|
||
}
|
||
}
|
||
|
||
const abortController = new AbortController();
|
||
this.#bashAbortControllers.add(abortController);
|
||
|
||
try {
|
||
const result = await executeBashCommand(command, {
|
||
onChunk,
|
||
signal: abortController.signal,
|
||
sessionKey: this.sessionId,
|
||
timeout: clampTimeout("bash") * 1000,
|
||
onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
|
||
useUserShell: options?.useUserShell,
|
||
});
|
||
|
||
this.recordBashResult(command, result, options);
|
||
return result;
|
||
} finally {
|
||
this.#bashAbortControllers.delete(abortController);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Record a bash execution result in session history.
|
||
* Used by executeBash and by extensions that handle bash execution themselves.
|
||
*/
|
||
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
|
||
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
|
||
const bashMessage: BashExecutionMessage = {
|
||
role: "bashExecution",
|
||
command,
|
||
output: result.output,
|
||
exitCode: result.exitCode,
|
||
cancelled: result.cancelled,
|
||
truncated: result.truncated,
|
||
meta,
|
||
timestamp: Date.now(),
|
||
excludeFromContext: options?.excludeFromContext,
|
||
};
|
||
|
||
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
||
if (this.isStreaming) {
|
||
// Queue for later - will be flushed on agent_end
|
||
this.#pendingBashMessages.push(bashMessage);
|
||
} else {
|
||
// Add to agent state immediately
|
||
this.agent.appendMessage(bashMessage);
|
||
|
||
// Save to session
|
||
this.sessionManager.appendMessage(bashMessage);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cancel running bash command.
|
||
*/
|
||
abortBash(): void {
|
||
for (const abortController of this.#bashAbortControllers) {
|
||
abortController.abort();
|
||
}
|
||
}
|
||
|
||
/** Whether a bash command is currently running */
|
||
get isBashRunning(): boolean {
|
||
return this.#bashAbortControllers.size > 0;
|
||
}
|
||
|
||
/** Whether there are pending bash messages waiting to be flushed */
|
||
get hasPendingBashMessages(): boolean {
|
||
return this.#pendingBashMessages.length > 0;
|
||
}
|
||
|
||
/**
|
||
* Flush pending bash messages to agent state and session.
|
||
* Called after agent turn completes to maintain proper message ordering.
|
||
*/
|
||
#flushPendingBashMessages(): void {
|
||
if (this.#pendingBashMessages.length === 0) return;
|
||
|
||
for (const bashMessage of this.#pendingBashMessages) {
|
||
// Add to agent state
|
||
this.agent.appendMessage(bashMessage);
|
||
|
||
// Save to session
|
||
this.sessionManager.appendMessage(bashMessage);
|
||
}
|
||
|
||
this.#pendingBashMessages = [];
|
||
}
|
||
|
||
// =========================================================================
|
||
// User-Initiated Python Execution
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Execute Python code in the shared kernel.
|
||
* Uses the same kernel session as eval's Python backend, allowing collaborative editing.
|
||
* @param code The Python code to execute
|
||
* @param onChunk Optional streaming callback for output
|
||
* @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
|
||
*/
|
||
async executePython(
|
||
code: string,
|
||
onChunk?: (chunk: string) => void,
|
||
options?: { excludeFromContext?: boolean },
|
||
): Promise<PythonResult> {
|
||
const excludeFromContext = options?.excludeFromContext === true;
|
||
const cwd = this.sessionManager.getCwd();
|
||
this.assertEvalExecutionAllowed();
|
||
|
||
const abortController = new AbortController();
|
||
const execution = (async (): Promise<PythonResult> => {
|
||
if (this.#extensionRunner?.hasHandlers("user_python")) {
|
||
const hookResult = await this.#extensionRunner.emitUserPython({
|
||
type: "user_python",
|
||
code,
|
||
excludeFromContext,
|
||
cwd,
|
||
});
|
||
this.assertEvalExecutionAllowed();
|
||
if (hookResult?.result) {
|
||
this.recordPythonResult(code, hookResult.result, options);
|
||
return hookResult.result;
|
||
}
|
||
}
|
||
|
||
// Use the same session ID as eval's Python backend for kernel sharing.
|
||
const sessionId =
|
||
this.getEvalSessionId() ??
|
||
defaultEvalSessionId({
|
||
cwd,
|
||
getSessionFile: () => this.sessionManager.getSessionFile() ?? null,
|
||
});
|
||
const result = await executePythonCommand(code, {
|
||
cwd,
|
||
sessionId: namespacePythonSessionId(sessionId),
|
||
kernelOwnerId: this.#evalKernelOwnerId,
|
||
kernelMode: this.settings.get("python.kernelMode"),
|
||
interpreter: this.settings.get("python.interpreter")?.trim() || undefined,
|
||
onChunk,
|
||
signal: abortController.signal,
|
||
});
|
||
this.recordPythonResult(code, result, options);
|
||
return result;
|
||
})();
|
||
return await this.trackEvalExecution(execution, abortController);
|
||
}
|
||
|
||
assertEvalExecutionAllowed(): void {
|
||
if (this.#evalExecutionDisposing) {
|
||
throw new Error("Python execution is unavailable while session disposal is in progress");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Track Python work started outside AgentSession.executePython so dispose can await and abort it too.
|
||
*/
|
||
trackEvalExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
|
||
this.#evalAbortControllers.add(abortController);
|
||
this.#activeEvalExecutions.add(execution);
|
||
void execution.then(
|
||
() => {
|
||
this.#evalAbortControllers.delete(abortController);
|
||
this.#activeEvalExecutions.delete(execution);
|
||
},
|
||
() => {
|
||
this.#evalAbortControllers.delete(abortController);
|
||
this.#activeEvalExecutions.delete(execution);
|
||
},
|
||
);
|
||
return execution;
|
||
}
|
||
|
||
/**
|
||
* Record a Python execution result in session history.
|
||
*/
|
||
recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
|
||
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
|
||
const pythonMessage: PythonExecutionMessage = {
|
||
role: "pythonExecution",
|
||
code,
|
||
output: result.output,
|
||
exitCode: result.exitCode,
|
||
cancelled: result.cancelled,
|
||
truncated: result.truncated,
|
||
meta,
|
||
timestamp: Date.now(),
|
||
excludeFromContext: options?.excludeFromContext,
|
||
};
|
||
|
||
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
||
if (this.isStreaming) {
|
||
this.#pendingPythonMessages.push(pythonMessage);
|
||
} else {
|
||
this.agent.appendMessage(pythonMessage);
|
||
this.sessionManager.appendMessage(pythonMessage);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cancel running Python execution.
|
||
*/
|
||
abortEval(): void {
|
||
for (const abortController of this.#evalAbortControllers) {
|
||
abortController.abort();
|
||
}
|
||
}
|
||
|
||
async #waitForEvalExecutionsToSettle(timeoutMs: number): Promise<boolean> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (this.#activeEvalExecutions.size > 0) {
|
||
const remainingMs = deadline - Date.now();
|
||
if (remainingMs <= 0) {
|
||
return false;
|
||
}
|
||
const settled = await Promise.race([
|
||
Promise.allSettled(Array.from(this.#activeEvalExecutions)).then(() => true),
|
||
Bun.sleep(remainingMs).then(() => false),
|
||
]);
|
||
if (!settled && this.#activeEvalExecutions.size > 0) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async #prepareEvalExecutionsForDispose(): Promise<boolean> {
|
||
if (!(await this.#waitForEvalExecutionsToSettle(3_000))) {
|
||
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
|
||
this.abortEval();
|
||
if (!(await this.#waitForEvalExecutionsToSettle(1_000))) {
|
||
logger.warn(
|
||
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Whether a Python execution is currently running */
|
||
get isEvalRunning(): boolean {
|
||
return this.#evalAbortControllers.size > 0;
|
||
}
|
||
|
||
/** Whether there are pending Python messages waiting to be flushed */
|
||
get hasPendingPythonMessages(): boolean {
|
||
return this.#pendingPythonMessages.length > 0;
|
||
}
|
||
|
||
/**
|
||
* Flush pending Python messages to agent state and session.
|
||
*/
|
||
#flushPendingPythonMessages(): void {
|
||
if (this.#pendingPythonMessages.length === 0) return;
|
||
|
||
for (const pythonMessage of this.#pendingPythonMessages) {
|
||
this.agent.appendMessage(pythonMessage);
|
||
this.sessionManager.appendMessage(pythonMessage);
|
||
}
|
||
|
||
this.#pendingPythonMessages = [];
|
||
}
|
||
|
||
// =========================================================================
|
||
// IRC Delivery
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Deliver an IRC message into this session (recipient side; called by the
|
||
* IrcBus). Emits the `irc_message` session event for UI cards and injects
|
||
* the rendered message into the model's context as an `irc:incoming`
|
||
* custom message:
|
||
*
|
||
* - mid-turn → queued on the aside channel and folded in at the next step
|
||
* boundary (non-interrupting, like async-result deliveries) → "injected";
|
||
* - idle → starts a real turn with the message so the recipient wakes
|
||
* → "woken".
|
||
*
|
||
* Never blocks on the recipient's turn: the wake turn is fire-and-forget.
|
||
*
|
||
* When the sender expects a reply (`send await:true`) and this session is
|
||
* mid-turn with async execution disabled, the next step boundary may be
|
||
* gated on the sender's own batch finishing (blocking task spawns), so a
|
||
* real reply turn can never happen in time. In that case an ephemeral
|
||
* side-channel auto-reply is generated from the current context (the old
|
||
* `respondAsBackground` path) and sent back over the bus on this agent's
|
||
* behalf.
|
||
*/
|
||
async deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
|
||
if (this.#isDisposed) {
|
||
throw new Error("Recipient session is disposed.");
|
||
}
|
||
const autoReply = (opts?.expectsReply ?? false) && this.isStreaming && !this.settings.get("async.enabled");
|
||
const record: CustomMessage = {
|
||
role: "custom",
|
||
customType: "irc:incoming",
|
||
content: prompt.render(ircIncomingTemplate, {
|
||
from: msg.from,
|
||
message: msg.body,
|
||
replyTo: msg.replyTo ?? "",
|
||
autoReplied: autoReply,
|
||
}),
|
||
display: true,
|
||
details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
|
||
attribution: "agent",
|
||
timestamp: msg.ts,
|
||
};
|
||
void this.#emitSessionEvent({ type: "irc_message", message: record });
|
||
if (this.isStreaming) {
|
||
this.#pendingIrcAsides.push(record);
|
||
if (autoReply) void this.#runIrcAutoReply(msg);
|
||
return "injected";
|
||
}
|
||
// Idle: same wake primitive the yield queue uses for async-result
|
||
// delivery — prompt the agent directly so a real turn runs.
|
||
this.agent.prompt(record).catch(error => {
|
||
logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) });
|
||
});
|
||
return "woken";
|
||
}
|
||
|
||
/**
|
||
* Generate and deliver an ephemeral auto-reply to `msg` on this agent's
|
||
* behalf: a no-tools side-channel turn over the current history (same
|
||
* pipeline as `/btw`), recorded into this session as an `irc:autoreply`
|
||
* aside so the model knows what was said for it, and sent back to the
|
||
* sender as a regular bus message (`replyTo: msg.id`) so their parked
|
||
* `wait`/`await:true` resolves. Failures only log — the sender then hits
|
||
* its normal wait timeout.
|
||
*/
|
||
async #runIrcAutoReply(msg: IrcMessage): Promise<void> {
|
||
try {
|
||
const { replyText } = await this.runEphemeralTurn({
|
||
promptText: prompt.render(ircAutoReplyTemplate, {
|
||
from: msg.from,
|
||
message: msg.body,
|
||
replyTo: msg.replyTo ?? "",
|
||
}),
|
||
});
|
||
const body = replyText.trim();
|
||
if (!body || this.#isDisposed) return;
|
||
const record: CustomMessage = {
|
||
role: "custom",
|
||
customType: "irc:autoreply",
|
||
content: `[IRC you → \`${msg.from}\` (auto)]\n\n${body}`,
|
||
display: true,
|
||
details: { to: msg.from, body, replyTo: msg.id },
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
};
|
||
void this.#emitSessionEvent({ type: "irc_message", message: record });
|
||
// Asides drain at the next step boundary; anything left over is
|
||
// flushed at the start of the next prompt (#flushPendingIrcAsides).
|
||
this.#pendingIrcAsides.push(record);
|
||
// `from` must be the id the sender addressed (msg.to) so their
|
||
// from-filtered waiter matches.
|
||
const receipt = await IrcBus.global().send({ from: msg.to, to: msg.from, body, replyTo: msg.id });
|
||
if (receipt.outcome === "failed") {
|
||
logger.warn("IRC auto-reply delivery failed", { to: msg.from, error: receipt.error });
|
||
}
|
||
} catch (error) {
|
||
logger.warn("IRC auto-reply turn failed", { from: msg.from, error: String(error) });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Emit an IRC relay observation event on this session for UI rendering only.
|
||
* Does not persist the record to history. Called by the IrcBus to surface
|
||
* agent↔agent traffic on the main session.
|
||
*/
|
||
emitIrcRelayObservation(record: CustomMessage): void {
|
||
void this.#emitSessionEvent({ type: "irc_message", message: record });
|
||
}
|
||
|
||
/**
|
||
* Run a single ephemeral side-channel turn against this session's current
|
||
* model + system prompt + history. No tools are used; the side request
|
||
* does not block on, or interfere with, any in-flight main turn. The
|
||
* session's history and persisted state are NOT modified by this call.
|
||
*
|
||
* Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share
|
||
* the snapshot + stream pipeline. The snapshot includes any in-flight
|
||
* streaming assistant text so the model sees the half-finished response
|
||
* rather than missing context.
|
||
*/
|
||
async runEphemeralTurn(args: {
|
||
promptText: string;
|
||
onTextDelta?: (delta: string) => void;
|
||
signal?: AbortSignal;
|
||
dedupeReply?: boolean;
|
||
}): Promise<{ replyText: string; assistantMessage: AssistantMessage }> {
|
||
const model = this.model;
|
||
if (!model) {
|
||
throw new Error("No active model on session");
|
||
}
|
||
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
||
}
|
||
|
||
const snapshot = this.#buildEphemeralSnapshot(args.promptText);
|
||
const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
|
||
const context: Context = {
|
||
systemPrompt: this.systemPrompt,
|
||
messages: llmMessages,
|
||
// Empty tools array: with toolChoice="none" some encoders still serialize the
|
||
// recipient's tool catalog and the model leaks raw call markup
|
||
// (<function_calls>, DSML envelopes) into IRC replies. Stripping tools here
|
||
// removes the surface entirely.
|
||
tools: [],
|
||
};
|
||
const cacheSessionId = this.sessionId;
|
||
const options = this.prepareSimpleStreamOptions(
|
||
{
|
||
apiKey,
|
||
// Side-channel turns must not share OpenAI/Codex append-only
|
||
// conversation state with the main agent turn: IRC and /btw can run
|
||
// while the main turn is mid-tool-call. Keep the prompt-cache key
|
||
// stable, but give provider routing a unique request lineage.
|
||
sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
|
||
promptCacheKey: cacheSessionId,
|
||
preferWebsockets: false,
|
||
reasoning: toReasoningEffort(this.thinkingLevel),
|
||
disableReasoning: shouldDisableReasoning(this.thinkingLevel),
|
||
hideThinkingSummary: this.agent.hideThinkingSummary,
|
||
serviceTier: this.serviceTier,
|
||
signal: args.signal,
|
||
toolChoice: "none",
|
||
},
|
||
model.provider,
|
||
);
|
||
|
||
let providerReplyText = "";
|
||
let emittedReplyText = "";
|
||
let assistantMessage: AssistantMessage | undefined;
|
||
const stream = streamSimple(model, obfuscateProviderContext(this.#obfuscator, context), options);
|
||
for await (const event of stream) {
|
||
if (event.type === "text_delta") {
|
||
providerReplyText += event.delta;
|
||
if (args.onTextDelta) {
|
||
const readyText = this.#deobfuscatedProviderTextReadyForDelta(providerReplyText);
|
||
if (readyText.length > emittedReplyText.length) {
|
||
const delta = readyText.slice(emittedReplyText.length);
|
||
emittedReplyText = readyText;
|
||
args.onTextDelta(delta);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (event.type === "done") {
|
||
assistantMessage = this.#obfuscator?.hasSecrets()
|
||
? { ...event.message, content: this.#obfuscator.deobfuscateObject(event.message.content) }
|
||
: event.message;
|
||
break;
|
||
}
|
||
if (event.type === "error") {
|
||
throw new Error(event.error.errorMessage || "Ephemeral turn failed");
|
||
}
|
||
}
|
||
|
||
if (!assistantMessage) {
|
||
throw new Error("Ephemeral turn ended without a final message");
|
||
}
|
||
const replyText = this.#deobfuscateFromProvider(providerReplyText);
|
||
if (args.onTextDelta && replyText.length > emittedReplyText.length) {
|
||
args.onTextDelta(replyText.slice(emittedReplyText.length));
|
||
}
|
||
return {
|
||
replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()),
|
||
assistantMessage,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build a message snapshot for an ephemeral side-channel turn. Includes
|
||
* the in-flight streaming assistant message (if any) so the model sees
|
||
* the partial response in context, then appends the prompt as a virtual
|
||
* user message.
|
||
*/
|
||
#buildEphemeralSnapshot(promptText: string): AgentMessage[] {
|
||
const messages = [...this.messages];
|
||
const streaming = this.agent.state.streamMessage;
|
||
if (streaming && streaming.role === "assistant") {
|
||
const preservedBlocks: AssistantMessage["content"] = [];
|
||
// Preserve thinking blocks: DeepSeek-class encoders replay them as
|
||
// `reasoning_content` and reject the request (HTTP 400) when the field
|
||
// goes missing on a turn that previously emitted thinking.
|
||
for (const c of streaming.content) {
|
||
if (c.type === "thinking") preservedBlocks.push(c);
|
||
}
|
||
const streamingText = streaming.content
|
||
.filter((c): c is TextContent => c.type === "text")
|
||
.map(c => c.text)
|
||
.join("");
|
||
if (streamingText) {
|
||
preservedBlocks.push({ type: "text", text: streamingText });
|
||
}
|
||
if (preservedBlocks.length > 0) {
|
||
const normalized: AssistantMessage = {
|
||
...streaming,
|
||
content: preservedBlocks,
|
||
};
|
||
const lastMessage = messages.at(-1);
|
||
if (lastMessage?.role === "assistant") {
|
||
messages[messages.length - 1] = normalized;
|
||
} else {
|
||
messages.push(normalized);
|
||
}
|
||
}
|
||
}
|
||
messages.push({
|
||
role: "user",
|
||
content: [{ type: "text", text: promptText }],
|
||
attribution: "agent",
|
||
timestamp: Date.now(),
|
||
});
|
||
return messages;
|
||
}
|
||
|
||
/**
|
||
* Persist any IRC asides that missed their step-boundary injection (the
|
||
* message landed after the turn's last aside drain). Called at the start
|
||
* of the next prompt so the model still sees them.
|
||
*/
|
||
#flushPendingIrcAsides(): void {
|
||
if (this.#pendingIrcAsides.length === 0) return;
|
||
const records = this.#pendingIrcAsides;
|
||
this.#pendingIrcAsides = [];
|
||
for (const record of records) {
|
||
// emitExternalEvent on message_end appends to agent state and dispatches
|
||
// to all session listeners, which in turn handle TUI rendering and
|
||
// sessionManager persistence via #handleAgentEvent.
|
||
this.agent.emitExternalEvent({ type: "message_start", message: record });
|
||
this.agent.emitExternalEvent({ type: "message_end", message: record });
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Session Management
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Reload the current session from disk.
|
||
*
|
||
* Intended for extension commands and headless modes to re-read the current session
|
||
* file and re-emit session_switch hooks.
|
||
*/
|
||
async reload(): Promise<void> {
|
||
const sessionFile = this.sessionFile;
|
||
if (!sessionFile) return;
|
||
await this.switchSession(sessionFile);
|
||
}
|
||
|
||
/**
|
||
* Switch to a different session file.
|
||
* Aborts current operation, loads messages, restores model/thinking.
|
||
* Listeners are preserved and will continue receiving events.
|
||
* @returns true if switch completed, false if cancelled by hook
|
||
*/
|
||
async switchSession(sessionPath: string): Promise<boolean> {
|
||
const previousSessionFile = this.sessionManager.getSessionFile();
|
||
const switchingToDifferentSession = previousSessionFile
|
||
? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
|
||
: true;
|
||
// Emit session_before_switch event (can be cancelled)
|
||
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session_before_switch",
|
||
reason: "resume",
|
||
targetSessionFile: sessionPath,
|
||
})) as SessionBeforeSwitchResult | undefined;
|
||
|
||
if (result?.cancel) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
this.#disconnectFromAgent();
|
||
await this.abort();
|
||
|
||
// Flush pending writes before switching so restore snapshots reflect committed state.
|
||
await this.sessionManager.flush();
|
||
const previousSessionState = this.sessionManager.captureState();
|
||
const previousSessionContext = this.buildDisplaySessionContext();
|
||
// switchSession replaces these arrays wholesale during load/rollback, so retaining
|
||
// the existing message objects is sufficient and avoids structured-clone failures for
|
||
// extension/custom metadata that is valid to persist but not cloneable.
|
||
const previousAgentMessages = [...this.agent.state.messages];
|
||
const previousSteeringMessages = [...this.#steeringMessages];
|
||
const previousFollowUpMessages = [...this.#followUpMessages];
|
||
const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
||
const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
||
const previousModel = this.model;
|
||
const previousThinkingLevel = this.#thinkingLevel;
|
||
const previousAutoThinking = this.#autoThinking;
|
||
const previousAutoResolvedLevel = this.#autoResolvedLevel;
|
||
const previousServiceTier = this.agent.serviceTier;
|
||
const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
||
const previousTools = [...this.agent.state.tools];
|
||
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
||
const previousSystemPrompt = this.agent.state.systemPrompt;
|
||
const previousFreshProviderSessionId = this.#freshProviderSessionId;
|
||
const previousFallbackSelectedMCPToolNames = previousSessionFile
|
||
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
|
||
: undefined;
|
||
|
||
this.#steeringMessages = [];
|
||
this.#followUpMessages = [];
|
||
this.#pendingNextTurnMessages = [];
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
|
||
try {
|
||
await this.sessionManager.setSessionFile(sessionPath);
|
||
if (switchingToDifferentSession) {
|
||
this.#freshProviderSessionId = undefined;
|
||
}
|
||
this.#syncAgentSessionId();
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
const didReloadConversationChange =
|
||
!switchingToDifferentSession &&
|
||
this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
|
||
const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
|
||
await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
|
||
|
||
// Emit session_switch event to hooks
|
||
if (this.#extensionRunner) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_switch",
|
||
reason: "resume",
|
||
previousSessionFile,
|
||
});
|
||
}
|
||
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
if (switchingToDifferentSession) {
|
||
this.#closeAllProviderSessions("session switch");
|
||
} else if (didReloadConversationChange) {
|
||
this.#closeAllProviderSessions("session reload");
|
||
}
|
||
|
||
// Restore model if saved
|
||
const targetModelStrings = getRestorableSessionModels(
|
||
sessionContext.models,
|
||
this.sessionManager.getLastModelChangeRole(),
|
||
);
|
||
if (targetModelStrings.length > 0) {
|
||
const availableModels = this.#modelRegistry.getAvailable();
|
||
let match: Model | undefined;
|
||
for (const targetModelStr of targetModelStrings) {
|
||
const slashIdx = targetModelStr.indexOf("/");
|
||
if (slashIdx <= 0) continue;
|
||
const provider = targetModelStr.slice(0, slashIdx);
|
||
const modelId = targetModelStr.slice(slashIdx + 1);
|
||
match = availableModels.find(m => m.provider === provider && m.id === modelId);
|
||
if (match) break;
|
||
}
|
||
if (match) {
|
||
const currentModel = this.model;
|
||
const shouldResetProviderState =
|
||
switchingToDifferentSession ||
|
||
(currentModel !== undefined &&
|
||
(currentModel.provider !== match.provider ||
|
||
currentModel.id !== match.id ||
|
||
currentModel.api !== match.api));
|
||
if (shouldResetProviderState) {
|
||
this.#setModelWithProviderSessionReset(match);
|
||
} else {
|
||
this.agent.setModel(match);
|
||
}
|
||
}
|
||
}
|
||
|
||
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
||
const hasServiceTierEntry = this.sessionManager
|
||
.getBranch()
|
||
.some(entry => entry.type === "service_tier_change");
|
||
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
||
const configuredServiceTier = this.settings.get("serviceTier");
|
||
// Session log entries store only concrete levels. When `auto` has resolved
|
||
// for a turn, the persisted context may already carry that concrete level
|
||
// even if the branch scan races a just-flushed thinking entry under isolated
|
||
// parallel test workers. Prefer the concrete context value in that case;
|
||
// otherwise keep the configured `auto` selector so fresh sessions still
|
||
// classify their first turn.
|
||
const restoredThinkingLevel: ConfiguredThinkingLevel | undefined =
|
||
hasThinkingEntry || (defaultThinkingLevel === AUTO_THINKING && sessionContext.thinkingLevel !== "off")
|
||
? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
|
||
: defaultThinkingLevel;
|
||
if (restoredThinkingLevel === AUTO_THINKING) {
|
||
this.#autoThinking = true;
|
||
this.#autoResolvedLevel = undefined;
|
||
this.#thinkingLevel = resolveProvisionalAutoLevel(this.model);
|
||
} else {
|
||
this.#autoThinking = false;
|
||
this.#autoResolvedLevel = undefined;
|
||
this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel);
|
||
}
|
||
this.#applyThinkingLevelToAgent(this.#thinkingLevel);
|
||
this.agent.serviceTier = hasServiceTierEntry
|
||
? sessionContext.serviceTier
|
||
: configuredServiceTier === "none"
|
||
? undefined
|
||
: configuredServiceTier;
|
||
|
||
if (switchingToDifferentSession) {
|
||
this.#resetHindsightConversationTrackingIfHindsight();
|
||
this.#resetMnemopiConversationTrackingIfMnemopi();
|
||
}
|
||
this.#reconnectToAgent();
|
||
try {
|
||
await this.#sessionSwitchReconciler?.();
|
||
} catch (error) {
|
||
logger.warn("Failed to reconcile session mode after switch", {
|
||
targetSessionFile: sessionPath,
|
||
error: String(error),
|
||
});
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
this.sessionManager.restoreState(previousSessionState);
|
||
this.#freshProviderSessionId = previousFreshProviderSessionId;
|
||
this.#syncAgentSessionId(previousSessionState.sessionId);
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
let restoreMcpError: unknown;
|
||
try {
|
||
await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
|
||
fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
|
||
});
|
||
} catch (mcpError) {
|
||
restoreMcpError = mcpError;
|
||
logger.warn("Failed to restore MCP selections after switch error", {
|
||
previousSessionFile,
|
||
targetSessionFile: sessionPath,
|
||
error: String(mcpError),
|
||
});
|
||
this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
|
||
this.agent.setTools(previousTools);
|
||
this.#baseSystemPrompt = previousBaseSystemPrompt;
|
||
this.agent.setSystemPrompt(previousSystemPrompt);
|
||
}
|
||
this.#baseSystemPrompt = previousBaseSystemPrompt;
|
||
this.agent.setSystemPrompt(previousSystemPrompt);
|
||
this.agent.replaceMessages(previousAgentMessages);
|
||
this.#steeringMessages = previousSteeringMessages;
|
||
this.#followUpMessages = previousFollowUpMessages;
|
||
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
|
||
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
||
if (previousModel) {
|
||
this.agent.setModel(previousModel);
|
||
}
|
||
this.#thinkingLevel = previousThinkingLevel;
|
||
this.#autoThinking = previousAutoThinking;
|
||
this.#autoResolvedLevel = previousAutoResolvedLevel;
|
||
this.#applyThinkingLevelToAgent(previousThinkingLevel);
|
||
this.agent.serviceTier = previousServiceTier;
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#reconnectToAgent();
|
||
if (restoreMcpError) {
|
||
throw restoreMcpError;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a branch from a specific entry.
|
||
* Emits before_branch/branch session events to hooks.
|
||
*
|
||
* @param entryId ID of the entry to branch from
|
||
* @returns Object with:
|
||
* - selectedText: The text of the selected user message (for editor pre-fill)
|
||
* - cancelled: True if a hook cancelled the branch
|
||
*/
|
||
async branch(entryId: string): Promise<{
|
||
selectedText: string;
|
||
cancelled: boolean;
|
||
}> {
|
||
const previousSessionFile = this.sessionFile;
|
||
const selectedEntry = this.sessionManager.getEntry(entryId);
|
||
|
||
if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
|
||
throw new Error("Invalid entry ID for branching");
|
||
}
|
||
|
||
const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
|
||
|
||
let skipConversationRestore = false;
|
||
|
||
// Emit session_before_branch event (can be cancelled)
|
||
if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session_before_branch",
|
||
entryId,
|
||
})) as SessionBeforeBranchResult | undefined;
|
||
|
||
if (result?.cancel) {
|
||
return { selectedText, cancelled: true };
|
||
}
|
||
skipConversationRestore = result?.skipConversationRestore ?? false;
|
||
}
|
||
|
||
// Clear pending messages (bound to old session state)
|
||
this.#pendingNextTurnMessages = [];
|
||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||
|
||
// Flush pending writes before branching
|
||
await this.sessionManager.flush();
|
||
this.#cancelOwnAsyncJobs();
|
||
|
||
if (!selectedEntry.parentId) {
|
||
await this.sessionManager.newSession({ parentSession: previousSessionFile });
|
||
} else {
|
||
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
||
}
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#freshProviderSessionId = undefined;
|
||
this.#syncAgentSessionId();
|
||
this.#rekeyHindsightMemoryForCurrentSessionId();
|
||
this.#rekeyMnemopiMemoryForCurrentSessionId();
|
||
this.#resetHindsightConversationTrackingIfHindsight();
|
||
this.#resetMnemopiConversationTrackingIfMnemopi();
|
||
|
||
// Reload messages from entries (works for both file and in-memory mode)
|
||
const sessionContext = this.buildDisplaySessionContext();
|
||
|
||
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
|
||
|
||
// Emit session_branch event to hooks (after branch completes)
|
||
if (this.#extensionRunner) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_branch",
|
||
previousSessionFile,
|
||
});
|
||
}
|
||
|
||
if (!skipConversationRestore) {
|
||
this.agent.replaceMessages(sessionContext.messages);
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
}
|
||
|
||
return { selectedText, cancelled: false };
|
||
}
|
||
|
||
// =========================================================================
|
||
// Tree Navigation
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Navigate to a different node in the session tree.
|
||
* Unlike branch() which creates a new session file, this stays in the same file.
|
||
*
|
||
* @param targetId The entry ID to navigate to
|
||
* @param options.summarize Whether user wants to summarize abandoned branch
|
||
* @param options.customInstructions Custom instructions for summarizer
|
||
* @returns Result with editorText (if user message) and cancelled status
|
||
*/
|
||
async navigateTree(
|
||
targetId: string,
|
||
options: { summarize?: boolean; customInstructions?: string } = {},
|
||
): Promise<{
|
||
editorText?: string;
|
||
cancelled: boolean;
|
||
aborted?: boolean;
|
||
summaryEntry?: BranchSummaryEntry;
|
||
/** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
|
||
sessionContext?: SessionContext;
|
||
}> {
|
||
const oldLeafId = this.sessionManager.getLeafId();
|
||
|
||
// No-op if already at target
|
||
if (targetId === oldLeafId) {
|
||
return { cancelled: false };
|
||
}
|
||
|
||
// Model required for summarization
|
||
if (options.summarize && !this.model) {
|
||
throw new Error("No model available for summarization");
|
||
}
|
||
|
||
const targetEntry = this.sessionManager.getEntry(targetId);
|
||
if (!targetEntry) {
|
||
throw new Error(`Entry ${targetId} not found`);
|
||
}
|
||
|
||
// Collect entries to summarize (from old leaf to common ancestor)
|
||
const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
|
||
this.sessionManager,
|
||
oldLeafId,
|
||
targetId,
|
||
);
|
||
|
||
// Prepare event data
|
||
const preparation: TreePreparation = {
|
||
targetId,
|
||
oldLeafId,
|
||
commonAncestorId,
|
||
entriesToSummarize,
|
||
userWantsSummary: options.summarize ?? false,
|
||
};
|
||
|
||
// Set up abort controller for summarization
|
||
this.#branchSummaryAbortController = new AbortController();
|
||
let hookSummary: { summary: string; details?: unknown } | undefined;
|
||
let fromExtension = false;
|
||
|
||
// Emit session_before_tree event
|
||
if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
|
||
const result = (await this.#extensionRunner.emit({
|
||
type: "session_before_tree",
|
||
preparation,
|
||
signal: this.#branchSummaryAbortController.signal,
|
||
})) as SessionBeforeTreeResult | undefined;
|
||
|
||
if (result?.cancel) {
|
||
return { cancelled: true };
|
||
}
|
||
|
||
if (result?.summary && options.summarize) {
|
||
hookSummary = result.summary;
|
||
fromExtension = true;
|
||
}
|
||
}
|
||
|
||
// Run default summarizer if needed
|
||
let summaryText: string | undefined;
|
||
let summaryDetails: unknown;
|
||
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
||
const model = this.model!;
|
||
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
||
if (!apiKey) {
|
||
throw new Error(`No API key for ${model.provider}`);
|
||
}
|
||
const branchSummarySettings = this.settings.getGroup("branchSummary");
|
||
const result = await generateBranchSummary(entriesToSummarize, {
|
||
model,
|
||
apiKey: this.#modelRegistry.resolver(model, this.sessionId),
|
||
signal: this.#branchSummaryAbortController.signal,
|
||
customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
|
||
reserveTokens: branchSummarySettings.reserveTokens,
|
||
metadata: this.agent.metadataForProvider(model.provider),
|
||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
||
});
|
||
this.#branchSummaryAbortController = undefined;
|
||
if (result.aborted) {
|
||
return { cancelled: true, aborted: true };
|
||
}
|
||
if (result.error) {
|
||
throw new Error(result.error);
|
||
}
|
||
summaryText = result.summary;
|
||
summaryDetails = {
|
||
readFiles: result.readFiles || [],
|
||
modifiedFiles: result.modifiedFiles || [],
|
||
};
|
||
} else if (hookSummary) {
|
||
summaryText = hookSummary.summary;
|
||
summaryDetails = hookSummary.details;
|
||
}
|
||
|
||
// Determine the new leaf position based on target type
|
||
let newLeafId: string | null;
|
||
let editorText: string | undefined;
|
||
|
||
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
||
// User message: leaf = parent (null if root), text goes to editor
|
||
newLeafId = targetEntry.parentId;
|
||
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
||
} else if (targetEntry.type === "custom_message") {
|
||
// Custom message: leaf = parent (null if root), text goes to editor
|
||
newLeafId = targetEntry.parentId;
|
||
editorText =
|
||
typeof targetEntry.content === "string"
|
||
? targetEntry.content
|
||
: targetEntry.content
|
||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||
.map(c => c.text)
|
||
.join("");
|
||
} else {
|
||
// Non-user message: leaf = selected node
|
||
newLeafId = targetId;
|
||
}
|
||
|
||
// Switch leaf (with or without summary)
|
||
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
||
let summaryEntry: BranchSummaryEntry | undefined;
|
||
if (summaryText) {
|
||
// Create summary at target position (can be null for root)
|
||
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
||
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
||
} else if (newLeafId === null) {
|
||
// No summary, navigating to root - reset leaf
|
||
this.sessionManager.resetLeaf();
|
||
} else {
|
||
// No summary, navigating to non-root
|
||
this.sessionManager.branch(newLeafId);
|
||
}
|
||
|
||
// Update agent state — build display context to populate agent messages.
|
||
const stateContext = this.sessionManager.buildSessionContext();
|
||
const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
|
||
await this.#restoreMCPSelectionsForSessionContext(displayContext);
|
||
this.agent.replaceMessages(displayContext.messages);
|
||
this.#syncTodoPhasesFromBranch();
|
||
this.#closeCodexProviderSessionsForHistoryRewrite();
|
||
|
||
this.#branchSummaryAbortController = undefined;
|
||
|
||
// Emit session_tree event; only handlers can mutate session entries, so skip
|
||
// the emit and the context rebuild when no handlers are registered (mirrors
|
||
// the session_before_tree guard above).
|
||
if (this.#extensionRunner?.hasHandlers("session_tree")) {
|
||
await this.#extensionRunner.emit({
|
||
type: "session_tree",
|
||
newLeafId: this.sessionManager.getLeafId(),
|
||
oldLeafId,
|
||
summaryEntry,
|
||
fromExtension: summaryText ? fromExtension : undefined,
|
||
});
|
||
const rawContext = this.sessionManager.buildSessionContext();
|
||
return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext };
|
||
}
|
||
return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
|
||
}
|
||
|
||
/**
|
||
* Get all user messages from session for branch selector.
|
||
*/
|
||
getUserMessagesForBranching(): Array<{ entryId: string; text: string }> {
|
||
const entries = this.sessionManager.getEntries();
|
||
const result: Array<{ entryId: string; text: string }> = [];
|
||
|
||
for (const entry of entries) {
|
||
if (entry.type !== "message") continue;
|
||
if (entry.message.role !== "user") continue;
|
||
|
||
const text = this.#extractUserMessageText(entry.message.content);
|
||
if (text) {
|
||
result.push({ entryId: entry.id, text });
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
#extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
|
||
if (typeof content === "string") return content;
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||
.map(c => c.text)
|
||
.join("");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* Get session statistics.
|
||
*/
|
||
getSessionStats(): SessionStats {
|
||
const state = this.state;
|
||
const userMessages = state.messages.filter(m => m.role === "user").length;
|
||
const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
|
||
const toolResults = state.messages.filter(m => m.role === "toolResult").length;
|
||
|
||
let toolCalls = 0;
|
||
let totalInput = 0;
|
||
let totalOutput = 0;
|
||
let totalCacheRead = 0;
|
||
let totalCacheWrite = 0;
|
||
let totalCost = 0;
|
||
|
||
let totalPremiumRequests = 0;
|
||
const getTaskToolUsage = (details: unknown): Usage | undefined => {
|
||
if (!details || typeof details !== "object") return undefined;
|
||
const record = details as Record<string, unknown>;
|
||
const usage = record.usage;
|
||
if (!usage || typeof usage !== "object") return undefined;
|
||
return usage as Usage;
|
||
};
|
||
|
||
for (const message of state.messages) {
|
||
if (message.role === "assistant") {
|
||
const assistantMsg = message as AssistantMessage;
|
||
toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
|
||
totalInput += assistantMsg.usage.input;
|
||
totalOutput += assistantMsg.usage.output;
|
||
totalCacheRead += assistantMsg.usage.cacheRead;
|
||
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
||
totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
|
||
totalCost += assistantMsg.usage.cost.total;
|
||
}
|
||
|
||
if (message.role === "toolResult" && message.toolName === "task") {
|
||
const usage = getTaskToolUsage(message.details);
|
||
if (usage) {
|
||
totalInput += usage.input;
|
||
totalOutput += usage.output;
|
||
totalCacheRead += usage.cacheRead;
|
||
totalCacheWrite += usage.cacheWrite;
|
||
totalPremiumRequests += usage.premiumRequests ?? 0;
|
||
totalCost += usage.cost.total;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
sessionFile: this.sessionFile,
|
||
sessionId: this.sessionId,
|
||
userMessages,
|
||
assistantMessages,
|
||
toolCalls,
|
||
toolResults,
|
||
totalMessages: state.messages.length,
|
||
tokens: {
|
||
input: totalInput,
|
||
output: totalOutput,
|
||
cacheRead: totalCacheRead,
|
||
cacheWrite: totalCacheWrite,
|
||
total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
|
||
},
|
||
cost: totalCost,
|
||
premiumRequests: totalPremiumRequests,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get current context usage statistics.
|
||
* Uses the last assistant message's usage data when available,
|
||
* otherwise estimates tokens for all messages.
|
||
*/
|
||
getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined {
|
||
const model = this.model;
|
||
const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
|
||
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
|
||
|
||
// After compaction, the last assistant usage reflects pre-compaction context size.
|
||
// We can only trust usage from an assistant that responded after the latest compaction.
|
||
// If no such assistant exists, context token count is unknown until the next LLM response.
|
||
const branchEntries = this.sessionManager.getBranch();
|
||
const latestCompaction = getLatestCompactionEntry(branchEntries);
|
||
|
||
if (latestCompaction) {
|
||
// Check if there's a valid assistant usage after the compaction boundary
|
||
const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
|
||
let hasPostCompactionUsage = false;
|
||
for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
|
||
const entry = branchEntries[i];
|
||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||
const assistant = entry.message;
|
||
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
|
||
const contextTokens = calculateContextTokens(assistant.usage);
|
||
if (contextTokens > 0) {
|
||
hasPostCompactionUsage = true;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!hasPostCompactionUsage) {
|
||
return { tokens: null, contextWindow, percent: null };
|
||
}
|
||
}
|
||
|
||
const estimate = this.#estimateContextTokens();
|
||
const percent = (estimate.tokens / contextWindow) * 100;
|
||
|
||
return {
|
||
tokens: estimate.tokens,
|
||
contextWindow,
|
||
percent,
|
||
};
|
||
}
|
||
|
||
#ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
|
||
if (model?.provider !== "anthropic") return;
|
||
this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
|
||
sessionId: this.agent.sessionId,
|
||
baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
|
||
});
|
||
}
|
||
|
||
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
||
const authStorage = this.#modelRegistry.authStorage;
|
||
if (!authStorage.fetchUsageReports) return null;
|
||
return authStorage.fetchUsageReports({
|
||
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
|
||
signal,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Redeem one saved Codex rate-limit reset for a specific account, injecting
|
||
* the provider base URL like {@link AgentSession.fetchUsageReports}. Powers
|
||
* the `/usage reset` command and auto-redeem. Never throws for business
|
||
* outcomes — inspect the returned `code`.
|
||
*/
|
||
async redeemResetCredit(target: ResetCreditTarget, signal?: AbortSignal): Promise<ResetCreditRedeemOutcome> {
|
||
return this.#modelRegistry.authStorage.redeemResetCredit({
|
||
target,
|
||
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
|
||
signal,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* List saved Codex rate-limit resets per stored account, fetched live from
|
||
* the dedicated credits endpoint (bypasses the usage cache). Powers the
|
||
* `/usage reset` account selector.
|
||
*/
|
||
async listResetCredits(signal?: AbortSignal): Promise<ResetCreditAccountStatus[]> {
|
||
return this.#modelRegistry.authStorage.listResetCredits({
|
||
sessionId: this.sessionId,
|
||
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
|
||
signal,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s
|
||
* usage-limit branch. Returns `true` only when a saved Codex reset was
|
||
* actually spent (so the caller retries immediately). Opt-in, reactive, and
|
||
* heavily gated — see `./codex-auto-reset` and the design in
|
||
* `local://autoreset-spec.md`. Per-account in-flight dedup lets concurrent
|
||
* sessions adopt one redeem instead of double-spending.
|
||
*/
|
||
async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise<boolean> {
|
||
const cfg = this.settings.getGroup("codexResets");
|
||
const model = this.model;
|
||
// Cheap exits before any IO.
|
||
if (!cfg.autoRedeem || !model || model.provider !== "openai-codex") return false;
|
||
const authStorage = this.#modelRegistry.authStorage;
|
||
// Capture identity BEFORE awaits: markUsageLimitReached leaves the
|
||
// usage-limit session credential sticky, so this names the blocked account.
|
||
const identity = authStorage.getOAuthAccountIdentity("openai-codex", this.sessionId);
|
||
const accountKey = (identity?.accountId ?? identity?.email)?.trim().toLowerCase();
|
||
if (!accountKey) return false;
|
||
const existing = coordinator.inFlightByAccount.get(accountKey);
|
||
if (existing) return existing;
|
||
|
||
const run = (async (): Promise<boolean> => {
|
||
const reports = await this.fetchUsageReports();
|
||
const decision = evaluateCodexAutoRedeem({
|
||
nowMs: Date.now(),
|
||
provider: model.provider,
|
||
modelId: model.id,
|
||
settings: {
|
||
autoRedeem: cfg.autoRedeem,
|
||
minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
|
||
keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
|
||
},
|
||
identity,
|
||
reports,
|
||
attemptedBlockKeys: coordinator.attemptedBlockKeys,
|
||
lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
|
||
});
|
||
if (!decision.redeem) {
|
||
logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
|
||
return false;
|
||
}
|
||
// Commit the attempt BEFORE acting so this block can never re-enter.
|
||
coordinator.attemptedBlockKeys.add(decision.blockKey);
|
||
coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now());
|
||
const who = decision.target.email ?? decision.target.accountId ?? "the active account";
|
||
const outcome = await authStorage.redeemResetCredit({
|
||
target: decision.target,
|
||
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
|
||
// Not tied to the retry abort controller: aborting a consume
|
||
// mid-flight leaves credit state unknown.
|
||
signal: AbortSignal.timeout(15_000),
|
||
});
|
||
switch (outcome.code) {
|
||
case "reset": {
|
||
const left = Math.max(0, decision.availableCount - 1);
|
||
this.emitNotice(
|
||
"info",
|
||
`Auto-redeemed a saved Codex rate-limit reset for ${who} (${left} left); retrying now.`,
|
||
"codex-auto-reset",
|
||
);
|
||
void this.fetchUsageReports();
|
||
return true;
|
||
}
|
||
case "already_redeemed":
|
||
this.emitNotice(
|
||
"warning",
|
||
"A saved Codex reset was already redeemed elsewhere; waiting for the window.",
|
||
"codex-auto-reset",
|
||
);
|
||
return false;
|
||
case "no_credit":
|
||
logger.debug("codex-auto-reset: no_credit (snapshot/live mismatch)", { account: accountKey });
|
||
return false;
|
||
case "nothing_to_reset":
|
||
this.emitNotice(
|
||
"warning",
|
||
"Codex reset reported nothing to reset; auto-redeem suppressed for this window.",
|
||
"codex-auto-reset",
|
||
);
|
||
return false;
|
||
default:
|
||
this.emitNotice("warning", `Codex auto-redeem failed (${outcome.code}).`, "codex-auto-reset");
|
||
return false;
|
||
}
|
||
})().finally(() => coordinator.inFlightByAccount.delete(accountKey));
|
||
coordinator.inFlightByAccount.set(accountKey, run);
|
||
return run;
|
||
}
|
||
|
||
/**
|
||
* Estimate context tokens from messages, using the last assistant usage when available.
|
||
*/
|
||
#estimateContextTokens(): {
|
||
tokens: number;
|
||
} {
|
||
const messages = this.messages;
|
||
|
||
// Find last assistant message with valid usage.
|
||
let lastUsageIndex: number | null = null;
|
||
let lastUsage: Usage | undefined;
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const msg = messages[i];
|
||
if (msg.role === "assistant") {
|
||
const assistantMsg = msg as AssistantMessage;
|
||
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
||
lastUsage = assistantMsg.usage;
|
||
lastUsageIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!lastUsage || lastUsageIndex === null) {
|
||
// No usage data - estimate all messages
|
||
let estimated = 0;
|
||
for (const message of messages) {
|
||
estimated += estimateTokens(message);
|
||
}
|
||
return {
|
||
tokens: estimated,
|
||
};
|
||
}
|
||
|
||
const usageTokens = calculatePromptTokens(lastUsage);
|
||
let trailingTokens = 0;
|
||
for (let i = lastUsageIndex + 1; i < messages.length; i++) {
|
||
trailingTokens += estimateTokens(messages[i]);
|
||
}
|
||
|
||
return {
|
||
tokens: usageTokens + trailingTokens,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Export session to HTML.
|
||
* @param outputPath Optional output path (defaults to session directory)
|
||
* @returns Path to exported file
|
||
*/
|
||
async exportToHtml(outputPath?: string): Promise<string> {
|
||
const themeName = getCurrentThemeName();
|
||
const { exportSessionToHtml } = await import("../export/html");
|
||
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
|
||
}
|
||
|
||
// =========================================================================
|
||
// Utilities
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Get text content of last assistant message.
|
||
* Useful for /copy command.
|
||
* @returns Text content, or undefined if no assistant message exists
|
||
*/
|
||
getLastAssistantText(): string | undefined {
|
||
const lastAssistant = this.#getLastCopyCandidateAssistantMessage();
|
||
if (!lastAssistant) return undefined;
|
||
|
||
let text = "";
|
||
for (const content of lastAssistant.content) {
|
||
if (content.type === "text") {
|
||
text += content.text;
|
||
}
|
||
}
|
||
|
||
return text.trim() || undefined;
|
||
}
|
||
|
||
hasCopyCandidateAssistantMessage(): boolean {
|
||
return this.#getLastCopyCandidateAssistantMessage() !== undefined;
|
||
}
|
||
|
||
#getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined {
|
||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||
const message = this.messages[i];
|
||
if (message.role !== "assistant") continue;
|
||
|
||
const assistantMessage = message as AssistantMessage;
|
||
// Skip aborted messages with no content
|
||
if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue;
|
||
|
||
return assistantMessage;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
/**
|
||
* Get text content of the most recent visible handoff message.
|
||
* Fresh handoff sessions store the handoff context as a custom message, not
|
||
* an assistant message, so callers that copy the "last" message can use this
|
||
* as a fallback before the new session has an assistant response.
|
||
*/
|
||
getLastVisibleHandoffText(): string | undefined {
|
||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||
const message = this.messages[i];
|
||
if (message.role !== "custom") continue;
|
||
|
||
const customMessage = message as CustomMessage;
|
||
if (customMessage.customType !== "handoff" || !customMessage.display) continue;
|
||
|
||
if (typeof customMessage.content === "string") {
|
||
return customMessage.content.trim() || undefined;
|
||
}
|
||
|
||
let text = "";
|
||
for (const content of customMessage.content) {
|
||
if (content.type === "text") {
|
||
text += content.text;
|
||
}
|
||
}
|
||
return text.trim() || undefined;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Format the entire session as plain text for clipboard export.
|
||
* Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
|
||
*/
|
||
formatSessionAsText(): string {
|
||
return formatSessionDumpText({
|
||
messages: this.messages,
|
||
systemPrompt: this.agent.state.systemPrompt,
|
||
model: this.agent.state.model,
|
||
thinkingLevel: this.#thinkingLevel,
|
||
tools: this.agent.state.tools,
|
||
});
|
||
}
|
||
|
||
// =========================================================================
|
||
// Extension System
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Check if extensions have handlers for a specific event type.
|
||
*/
|
||
hasExtensionHandlers(eventType: string): boolean {
|
||
return this.#extensionRunner?.hasHandlers(eventType) ?? false;
|
||
}
|
||
|
||
/**
|
||
* Get the extension runner (for setting UI context and error handlers).
|
||
*/
|
||
get extensionRunner(): ExtensionRunner | undefined {
|
||
return this.#extensionRunner;
|
||
}
|
||
}
|