diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d41a13f3eb..aba77378e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Preserve Claude SDK OAuth compaction boundaries and idle settings across replay. + - Preserve Claude SDK OAuth terminal results and failure attribution across replay races. ### Removed diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 7b1e9c5ce3..edb9a1a2b2 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -1,5 +1,16 @@ # Local fork changes +## 2026-09-06 - Preserve Claude SDK OAuth compaction boundaries + +### What changed + +- Keep Claude SDK OAuth compaction on the SDK-native lane, mirror SDK compact boundaries, and preserve idle compaction settings across replay and resume flows. + +### Why + +- Prevent duplicate Senpi compaction and lost compaction state when Claude SDK OAuth sessions compact. + + ## 2026-09-04 - Restore the @anthropic-ai/sdk 0.123.0 pin the R4b merge dropped ### What changed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0ac195217a..d2619cb18e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -15,9 +15,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { rm } from "node:fs/promises"; import { basename, dirname } from "node:path"; -import { isDeepStrictEqual } from "node:util"; import type { Agent, AgentContinuationOptions, @@ -32,20 +30,13 @@ import type { PrepareNextTurnContext, ThinkingLevel, } from "@earendil-works/pi-agent-core"; -import { ProviderRetryWatchdogAbortError, prepareAgentToolCall } from "@earendil-works/pi-agent-core"; -import { - contentText, - measureCursorHistorySerializedBytes, - SERVER_FALLBACK_ABORTED_DIAGNOSTIC, - type ThinkingSelection, -} from "@earendil-works/pi-ai"; +import { prepareAgentToolCall } from "@earendil-works/pi-agent-core"; +import { contentText, SERVER_FALLBACK_ABORTED_DIAGNOSTIC } from "@earendil-works/pi-ai"; import type { Api, AssistantMessage, AuthResult, - Context, ImageContent, - Message, Model, ProviderHeaders, SimpleStreamOptions, @@ -54,42 +45,27 @@ import type { } from "@earendil-works/pi-ai/compat"; import { cleanupSessionResources, - cursorOverflowCompactionSettings, isClassifierRefusal, isContextOverflow, - isCursorPayloadResourceExhausted, - isCursorQuotaResourceExhausted, - isCursorZeroTokenResourceExhausted, isProviderStreamStallError, isProviderTimeoutError, - isRecoverableLength, isRetryableAssistantError, modelsAreEqual, type RetryCallbacks, resetApiProviders, - shouldRetryOverflowWithoutCompact, streamSimple, } from "@earendil-works/pi-ai/compat"; import { extract429RetryAfterMs, parseRetryAfterMsMarker } from "@earendil-works/pi-ai/utils/retry-hint"; -import { retryBackoffDelayMs } from "@earendil-works/pi-ai/utils/retry-profile/backoff"; import { getAgentDir } from "../config.ts"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; -import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; -import { AgentAbortProvenance, type AgentAbortSource } from "./agent-abort-provenance.ts"; -import { - AgentSettledDelivery, - type DeferredAgentSettledAction, - type DeferredTurnClaim, -} from "./agent-settled-delivery.ts"; +import { AgentAbortProvenance } from "./agent-abort-provenance.ts"; +import { AgentSettledDelivery, type DeferredAgentSettledAction } from "./agent-settled-delivery.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; -import { envValue } from "./brand.ts"; import { - type CacheFriendlySummaryOptions, - type CompactionPreparation, type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, @@ -98,26 +74,14 @@ import { estimateTokens, generateBranchSummary, prepareCompaction, - resolveThresholdContextTokens, shouldCompact, } from "./compaction/index.ts"; import { CompactionLifecycleCoordinator, type CompactionLifecycleState } from "./compaction/lifecycle.ts"; -import { isWarmSummaryAnchorValid } from "./compaction/warm-anchor.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import { type BuildDynamicSystemPromptOptions, buildDynamicSystemPrompt } from "./dynamic-prompt/index.ts"; -import { areExperimentalFeaturesEnabled } from "./experimental.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; -import { - type ModelUsabilityAdmission, - ModelUsabilityBudgetError, - projectModelUsabilityBudget, -} from "./extensions/builtin/compaction/model-usability-budget.ts"; -import { resolveReserveTokens } from "./extensions/builtin/compaction/policy.ts"; -import { WAKE_SOURCE_STATE_EVENT } from "./extensions/builtin/monitor-state-event.ts"; -import { CODEX_RESPONSES_API, type ServiceTier } from "./extensions/builtin/service-tier.ts"; -import { deriveExtensionRegistrationId } from "./extensions/builtin/tool-search/engine/marker.ts"; -import { getToolSearchService } from "./extensions/builtin/tool-search/service.ts"; +import type { ServiceTier } from "./extensions/builtin/service-tier.ts"; import { type ContextUsage, ExecuteToolError, @@ -135,7 +99,6 @@ import { type ReplacedSessionContext, type SessionBeforeCompactResult, type SessionBeforeTreeResult, - type SessionCompactFailedEvent, type SessionStartEvent, type ShutdownHandler, type SystemPromptChangeEvent, @@ -158,20 +121,14 @@ import type { LazyToolActivator, ModelSelectSource, } from "./extensions/types.ts"; -import { normalizeToolExposure, RUNTIME_EXTENSION_PATH } from "./extensions/types.ts"; +import { RUNTIME_EXTENSION_PATH } from "./extensions/types.ts"; import { shouldWarnHighReasoning } from "./high-reasoning-warning.ts"; -import { MANUAL_CONTINUE_CUSTOM_TYPE, MANUAL_CONTINUE_DIRECTIVE } from "./manual-continue.ts"; -import { - type BashExecutionMessage, - type CustomMessage, - convertToLlm, - filterContextExcludedMessages, -} from "./messages.ts"; +import { type BashExecutionMessage, type CustomMessage, filterContextExcludedMessages } from "./messages.ts"; import { ModelRegistry } from "./model-registry.ts"; import { type AvailableModelsSource, getModelNarrowingPatterns, resolveModelScope } from "./model-resolver.ts"; import type { ModelRuntime } from "./model-runtime.ts"; import { PROMPT_CACHE_SAFE_WAIT_ENV, resolvePromptCacheSafeWaitSeconds } from "./prompt-cache-budget.ts"; -import { expandPromptTemplateWithMetadata, type PromptTemplate } from "./prompt-templates.ts"; +import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; import { createProviderTimeoutRetryPlan, runBoundedRetryContinuation } from "./provider-timeout-retry.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; import { isBillingErrorMessage } from "./retry-fallback/billing.ts"; @@ -180,8 +137,6 @@ import { RetryFallbackController } from "./retry-fallback/controller.ts"; import { SelectorCooldowns } from "./retry-fallback/cooldown.ts"; import { classifyRateLimitedWait, - degradeWithoutFallback, - type HintTier, nextInTurnDelayMs, type ProbePhase, probeBackSchedule, @@ -189,7 +144,6 @@ import { import { createFallbackLogger } from "./retry-fallback/log.ts"; import { ProbeBackScheduler } from "./retry-fallback/probe-scheduler.ts"; import { validateFallbackChains } from "./retry-fallback/validate.ts"; -import { isSessionBusySnapshot, type SessionActivitySnapshot, WakeSourceTracker } from "./session-activity.ts"; import { createSessionLogger, type SessionLogger } from "./session-log.ts"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; import { @@ -200,56 +154,22 @@ import { } from "./session-manager.ts"; import { generateSessionTitle, sessionTitleRetryPolicy, shouldSkipSessionTitle } from "./session-title-generator.ts"; import { SessionWorkBarrier } from "./session-work-barrier.ts"; -import type { SettingsManager, SettingsSourceSelection } from "./settings-manager.ts"; +import type { SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import { getSupportedThinkingLevels, supportsMax, supportsXhigh } from "./thinking-levels.ts"; import { resetTimings, time } from "./timings.ts"; import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; -import { composeFilesystemPolicies } from "./tools/filesystem-policy.ts"; -import { createAllToolDefinitions, temporarilyDisabledToolNames } from "./tools/index.ts"; +import { createAllToolDefinitions } from "./tools/index.ts"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts"; import { addUsageToTotals, createUsageTotals } from "./usage-totals.ts"; -/** Tools routed exclusively through eval while the registered eval tool is available. */ -const EVAL_ONLY_TOOL_NAMES: ReadonlySet = new Set(["bash", "powershell", "workflow", "monitor"]); - -/** Sample eval-cell call for an eval-only tool, using the argument name that tool actually takes. */ -function evalHelperCall(name: string): string { - if (name === "bash" || name === "powershell") return `tool.${name}({ command: "..." })`; - if (name === "workflow") return `tool.${name}({ action: "..." })`; - if (name === "monitor") return `tool.monitor({ description: "...", command: "...", filter: "..." })`; - return `tool.${name}({ ... })`; -} const TURN_RETRY_SUPPRESSION_PREFIX = "senpi:no-turn-retry:"; -const DEFERRED_RETRY_QUEUE_OWNERS = new WeakSet(); // ============================================================================ -// Skill Invocation Formatting and Parsing +// Skill Block Parsing // ============================================================================ -export interface SkillInvocationPromptSkill { - name: string; - filePath: string; - baseDir: string; - body: string; -} - -/** Format the user-attributed payload for one or more explicit skill invocations. */ -export function formatSkillInvocationPrompt( - skills: readonly SkillInvocationPromptSkill[], - userRequest?: string, -): string { - const skillBlocks = skills.map( - (skill) => - `The user explicitly invoked the "${skill.name}" skill. Follow the instructions in as binding for this request, while respecting higher-priority instructions.\n\n\nReferences are relative to ${skill.baseDir}.\n\n${skill.body}\n`, - ); - const expandedSkills = skillBlocks.join("\n\n"); - return userRequest && /\S/.test(userRequest) - ? `${expandedSkills}\n\n\n${userRequest}\n` - : expandedSkills; -} - /** Parsed skill block from a user message */ export interface ParsedSkillBlock { name: string; @@ -263,213 +183,38 @@ export interface ParsedSkillBlock { * Returns null if the text doesn't contain a skill block. */ export function parseSkillBlock(text: string): ParsedSkillBlock | null { - const instructionPattern = - /^The user explicitly invoked the "([^"]+)" skill\. Follow the instructions in as binding for this request, while respecting higher-priority instructions\.\n\n\n([\s\S]*?)\n<\/skill-instruction>/; - const instructionMatch = text.match(instructionPattern); - if (instructionMatch) { - if (instructionMatch[1] !== instructionMatch[2]) return null; - let remainder = text.slice(instructionMatch[0].length); - while (remainder.startsWith("\n\nThe user explicitly invoked the ")) { - const chainedMatch = remainder.slice(2).match(instructionPattern); - if (!chainedMatch || chainedMatch[1] !== chainedMatch[2]) return null; - remainder = remainder.slice(chainedMatch[0].length + 2); - } - const requestMatch = remainder.match(/^\n\n\n([\s\S]*?)\n<\/user-request>$/); - if (remainder && !requestMatch) return null; - return { - name: instructionMatch[1], - location: instructionMatch[3], - content: instructionMatch[4], - userMessage: requestMatch?.[1].trim() || undefined, - }; - } - - const legacyMatch = text.match( - /^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/, - ); - if (!legacyMatch) return null; + const match = text.match(/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); + if (!match) return null; return { - name: legacyMatch[1], - location: legacyMatch[2], - content: legacyMatch[3], - userMessage: legacyMatch[4]?.trim() || undefined, + name: match[1], + location: match[2], + content: match[3], + userMessage: match[4]?.trim() || undefined, }; } -export type SkillInvocationSyntax = "dollar" | "slash"; - -export interface CommandInvocation { - name: string; - source: "extension" | "prompt"; - sourceInfo: SourceInfo; - syntax: "slash"; -} - -export interface SkillInvocationToken { - name: string; - syntax: SkillInvocationSyntax; - start: number; - end: number; - position: "inline" | "leading"; -} - -export const MAX_SKILL_INVOCATION_TOKENS_PER_PROMPT = 64; - -const LEADING_SKILL_INVOCATION_PATTERN = /^(?:\/skill:([a-zA-Z][a-zA-Z0-9:_-]*)|\$([a-zA-Z][a-zA-Z0-9:_-]*))(?=\s|$)/; -const INLINE_DOLLAR_SKILL_INVOCATION_PATTERN = /(^|\s)\$skill:([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; - -/** - * Find explicit skill invocation tokens without treating ordinary inline dollar - * prose (for example `$HOME`) as executable. - * - * Leading runs accept `/skill:name`, `$name`, and `$skill:name`. Outside the - * leading run only the desktop's explicit `$skill:name` token is executable. - */ -export function parseSkillInvocationTokens(text: string): SkillInvocationToken[] { - const tokens: SkillInvocationToken[] = []; - let cursor = 0; - - while (cursor < text.length) { - while (cursor < text.length && /\s/.test(text[cursor]!)) cursor++; - const match = text.slice(cursor).match(LEADING_SKILL_INVOCATION_PATTERN); - if (!match) break; - const syntax: SkillInvocationSyntax = match[1] ? "slash" : "dollar"; - const dollarName = match[2]; - const name = match[1] ?? (dollarName?.startsWith("skill:") ? dollarName.slice("skill:".length) : dollarName); - if (!name) break; - tokens.push({ - name, - syntax, - start: cursor, - end: cursor + match[0].length, - position: "leading", - }); - if (tokens.length >= MAX_SKILL_INVOCATION_TOKENS_PER_PROMPT) return tokens; - cursor += match[0].length; - } - - INLINE_DOLLAR_SKILL_INVOCATION_PATTERN.lastIndex = cursor; - for (const match of text.matchAll(INLINE_DOLLAR_SKILL_INVOCATION_PATTERN)) { - const start = (match.index ?? 0) + match[1].length; - tokens.push({ - name: match[2], - syntax: "dollar", - start, - end: start + `$skill:${match[2]}`.length, - position: "inline", - }); - if (tokens.length >= MAX_SKILL_INVOCATION_TOKENS_PER_PROMPT) break; - } - - return tokens; -} - -function stripLeadingInvocationSeparators(text: string): string { - let cursor = 0; - while (text[cursor] === " " || text[cursor] === "\t") cursor++; - while (text[cursor] === "\n" || (text[cursor] === "\r" && text[cursor + 1] === "\n")) { - cursor += text[cursor] === "\r" ? 2 : 1; - const lineStart = cursor; - while (text[cursor] === " " || text[cursor] === "\t") cursor++; - if (text[cursor] !== "\n" && !(text[cursor] === "\r" && text[cursor + 1] === "\n")) { - return text.slice(lineStart); - } - } - return text.slice(cursor); -} - -function removeSkillInvocationTokens(text: string, tokens: readonly SkillInvocationToken[]): string { - let cursor = 0; - let result = ""; - for (const token of tokens) { - result += text.slice(cursor, token.start); - cursor = token.end; - if ( - token.position === "inline" && - (result.endsWith(" ") || result.endsWith("\t")) && - (text[cursor] === " " || text[cursor] === "\t") - ) { - cursor++; - } - } - result += text.slice(cursor); - return tokens.some((token) => token.position === "leading") ? stripLeadingInvocationSeparators(result) : result; -} - /** Session-specific events that extend the core AgentEvent */ -type AgentSessionAgentEndEvent = Extract & { - willRetry: boolean; -}; +type AgentSessionAgentEndEvent = Extract & { willRetry: boolean }; export type AgentSessionEvent = | Exclude | AgentSessionAgentEndEvent | { type: "agent_settled" } - | { type: "agent_idle" } | { type: "session_abort" } | { type: "continuation_error"; errorMessage: string } - | { - type: "skill_invocation"; - skills: readonly { - name: string; - path: string; - syntax: SkillInvocationSyntax; - }[]; - } - | { - type: "command_invocation"; - command: CommandInvocation; - } | { type: "queue_update"; steering: readonly string[]; followUp: readonly string[]; - ordered: readonly { text: string; mode: "steer" | "followUp"; enqueueOrder: number }[]; } | { type: "compaction_start"; reason: CompactionReason; requestId?: string } - | { - type: "compaction_progress"; - reason: CompactionReason; - delta?: string; - text?: string; - } + | { type: "compaction_progress"; reason: CompactionReason; delta?: string; text?: string } | { type: "entry_appended"; entry: SessionEntry } | { type: "session_info_changed"; name: string | undefined } | ExtensionToolHookLifecycleEvent | SystemPromptChangeEvent | { type: "thinking_level_changed"; level: ThinkingLevel } - | { - type: "high_reasoning_warning"; - modelId: string; - provider: string; - thinkingLevel: ThinkingLevel; - } - | ({ type: "settings_source_selected" } & SettingsSourceSelection) - /** Active model changed; `thinkingLevel` is the level in force AFTER the switch. */ - | { - type: "model_changed"; - model: Model; - thinkingLevel: ThinkingLevel; - source: ModelSelectSource; - } - | { - type: "model_change_skipped"; - model: Model; - contextWindow: number; - liveContextTokens: number; - requiredTokens: number; - shortfallTokens: number; - safetyMarginProfile: string; - direction: "forward" | "backward"; - } - /** Effective service tier or fast-mode state changed. */ - | { type: "service_tier_changed"; tier?: ServiceTier; fastMode: boolean } - | { - type: "session_settings_changed"; - steeringMode: "all" | "one-at-a-time"; - followUpMode: "all" | "one-at-a-time"; - autoCompactionEnabled: boolean; - } + | { type: "high_reasoning_warning"; modelId: string; provider: string; thinkingLevel: ThinkingLevel } | { type: "compaction_end"; reason: CompactionReason; @@ -481,19 +226,8 @@ export type AgentSessionEvent = rejectionCause?: CompactionRejectionCause; errorMessage?: string; } - | { - type: "auto_retry_start"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; - } - | { - type: "auto_retry_end"; - success: boolean; - attempt: number; - finalError?: string; - } + | { 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; @@ -504,23 +238,13 @@ export type AgentSessionEvent = | { type: "retry_fallback_succeeded"; model: string; chainKey: string } | { type: "retry_fallback_reverted"; from: string; to: string } | { type: "retry_fallback_exhausted"; chainKey: string; lastError: string } - | { - type: "server_fallback_aborted"; - from: string; - to: string; - chainConfigured: boolean; - } + | { type: "server_fallback_aborted"; from: string; to: string; chainConfigured: boolean } // Auth login flow (task 13) is additive with event-only completion. The // login_start command responds immediately, then the OAuth URL and the // terminal result arrive here, because an interactive browser round-trip // cannot fit inside the request timeout. | { type: "auth_login_url"; provider: string; url: string } - | { - type: "auth_login_end"; - provider: string; - success: boolean; - error?: string; - } + | { type: "auth_login_end"; provider: string; success: boolean; error?: string } | { type: "summarization_retry_scheduled"; attempt: number; @@ -535,18 +259,8 @@ export type AgentSessionEvent = reason: CompactionReason; } | { type: "summarization_retry_finished" } - | { - type: "retry_probe_scheduled"; - selector: string; - atMs: number; - probeIndex: 1 | 2; - } - | { - type: "retry_probe_result"; - selector: string; - ok: boolean; - errorMessage?: string; - } + | { type: "retry_probe_scheduled"; selector: string; atMs: number; probeIndex: 1 | 2 } + | { type: "retry_probe_result"; selector: string; ok: boolean; errorMessage?: string } | { type: "bash_execution_update"; id?: string; delta: string }; /** Listener function for agent session events */ @@ -571,22 +285,10 @@ export interface AgentSessionConfig { agentDir?: string; /** Clock override for fallback selector cooldowns (tests only). */ fallbackNow?: () => number; - /** Random source for retry jitter (tests only). */ - retryRandom?: () => number; /** Global model narrowing for selectors and startup model choice (from --models / enabledModels) */ - scopedModels?: Array<{ - model: Model; - thinkingLevel?: ThinkingLevel; - thinkingSelection?: ThinkingSelection; - serviceTier?: ServiceTier; - }>; + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel; serviceTier?: ServiceTier }>; /** Favorite models to cycle through with Ctrl+P */ - favoriteModels?: Array<{ - model: Model; - thinkingLevel?: ThinkingLevel; - thinkingSelection?: ThinkingSelection; - serviceTier?: ServiceTier; - }>; + favoriteModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel; serviceTier?: ServiceTier }>; /** Resource loader for extensions, skills, prompts, themes, context files, and system prompt */ resourceLoader: ResourceLoader; /** SDK custom tools registered outside extensions */ @@ -597,10 +299,6 @@ export interface AgentSessionConfig { modelRegistry?: ModelRegistry; /** Initial active built-in tool names. Default: [read, bash, edit, write] */ initialActiveToolNames?: string[]; - /** Configured built-in defaults; fork-native builtin extension tools outside this set are omitted. */ - defaultToolNames?: string[]; - /** Tool names that remain executable only through the registered eval tool. */ - evalOnlyToolNames?: string[]; /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ allowedToolNames?: string[]; /** Optional denylist of tool names. When provided, these tool names are not exposed. */ @@ -619,12 +317,7 @@ export interface AgentSessionConfig { autoTitleSessions?: boolean; } -type SessionModelEntry = { - model: Model; - thinkingLevel?: ThinkingLevel; - thinkingSelection?: ThinkingSelection; - serviceTier?: ServiceTier; -}; +type SessionModelEntry = { model: Model; thinkingLevel?: ThinkingLevel; serviceTier?: ServiceTier }; interface CompactionExecutionRequest { controller: AbortController; @@ -664,10 +357,7 @@ function isCompactionOwnedPreCompactDiagnostic(message: AgentMessage, requestId: if (message.role !== "custom" || message.customType !== "senpi.hook") return false; const details = message.details; if (!details || typeof details !== "object") return false; - const diagnostic = details as { - event?: unknown; - compactionRequestId?: unknown; - }; + const diagnostic = details as { event?: unknown; compactionRequestId?: unknown }; return diagnostic.event === "PreCompact" && diagnostic.compactionRequestId === requestId; } @@ -683,8 +373,6 @@ function describeCompactionRejection(cause: CompactionRejectionCause): string { return "Compaction rejected: the produced summary would still overflow the model context window. Reduce context (e.g. /new, drop attachments) or switch to a larger-context model."; case "cancelled-by-extension": return "Compaction rejected: cancelled by an extension."; - case "external-owner": - return "Compaction rejected: the active provider owns compaction for this session."; case "circuit-breaker": return "Compaction rejected: the compaction circuit breaker is open after repeated failures. Wait for the cooldown and retry."; case "per-turn-cap": @@ -785,7 +473,7 @@ export type ClearedQueue = { }; export interface PromptOptions { - /** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */ + /** Whether to expand file-based prompt templates (default: true) */ expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; @@ -812,8 +500,6 @@ export interface ModelCycleResult { thinkingLevel: ThinkingLevel; /** Whether cycling used the configured favorite model list */ isScoped: boolean; - /** Models skipped because their usability budget cannot admit the current context. */ - skippedModels: readonly Model[]; /** Present when the model switch also changed the active system prompt. */ systemPromptChange?: SystemPromptChangeEvent; } @@ -871,138 +557,10 @@ const THINKING_LEVELS_WITH_MAX: ThinkingLevel[] = ["off", "minimal", "low", "med /** Caps explicit skill expansion so one prompt cannot consume unbounded context. */ export const MAX_SKILL_EXPANSIONS_PER_PROMPT = 5; -/** Cursor ingest rejects large verbatim tool payloads. The bound is UTF-8 bytes. */ -export const CURSOR_TOOL_RESULT_MAX_CHARS = 2000; -export const CURSOR_TOOL_RESULT_MAX_BYTES = 50_000; -const CURSOR_TRUNCATION_MARKER = "\n...[truncated]"; - -export function truncateToolResultBodies( - messages: AgentMessage[] | undefined, - maxChars = CURSOR_TOOL_RESULT_MAX_CHARS, - maxBytes = CURSOR_TOOL_RESULT_MAX_BYTES, - convert = (candidate: AgentMessage[]) => convertToLlm(candidate), -): { messages: AgentMessage[] | undefined; changed: boolean } { - if (!Array.isArray(messages) || messages.length === 0) return { messages, changed: false }; - const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); - const markerChars = [...segmenter.segment(CURSOR_TRUNCATION_MARKER)].length; - const result = messages.slice(); - let changed = false; - - // Apply the per-result character cap first, independent of the aggregate wire cap. - for (let messageIndex = result.length - 1; messageIndex >= 0; messageIndex--) { - const message = result[messageIndex]; - if (message.role !== "toolResult" || !Array.isArray(message.content)) continue; - let content = message.content; - for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) { - const part = content[partIndex]; - if (part.type === "image" && typeof part.data === "string") continue; - if (part.type !== "text" || typeof part.text !== "string") continue; - const graphemes = [...segmenter.segment(part.text)].map((item) => item.segment); - if (graphemes.length <= maxChars) continue; - const kept = graphemes.slice(0, Math.max(0, maxChars - markerChars)).join(""); - const nextText = kept + CURSOR_TRUNCATION_MARKER; - content = content === message.content ? content.slice() : content; - content[partIndex] = { ...part, text: nextText }; - result[messageIndex] = { ...message, content }; - changed = true; - } - } - - const measure = (candidate: AgentMessage[]): number => { - const converted = convert(candidate); - const activeUserMessageIndex = converted.at(-1)?.role === "user" ? converted.length - 1 : -1; - return measureCursorHistorySerializedBytes(converted, activeUserMessageIndex); - }; - const fits = (candidate = result) => measure(candidate) <= maxBytes; - if (fits()) return { messages: changed ? result : messages, changed }; - - // Empty the oldest result bodies. Search the monotonic prefix of candidates - // rather than serializing once for every result (admission must stay bounded). - const emptyToolResult = (message: AgentMessage): AgentMessage => { - if (message.role !== "toolResult" || !Array.isArray(message.content)) return message; - const emptiedContent = message.content.map((part) => - part.type === "text" ? { ...part, text: "" } : part.type === "image" ? { ...part, data: "" } : part, - ); - const content = emptiedContent.filter((part, index) => { - if (index === 0) return true; - const previous = emptiedContent[index - 1]; - const empty = part.type === "text" ? part.text === "" : part.type === "image" && part.data === ""; - const previousEmpty = - previous.type === "text" ? previous.text === "" : previous.type === "image" && previous.data === ""; - return !empty || !previousEmpty; - }); - return { ...message, content }; - }; - const toolResultIndexes = result.flatMap((message, index) => - message.role === "toolResult" && Array.isArray(message.content) ? [index] : [], - ); - const withEmptyPrefix = (count: number): AgentMessage[] => { - const candidate = result.slice(); - for (let i = 0; i < count; i++) - candidate[toolResultIndexes[i]] = emptyToolResult(candidate[toolResultIndexes[i]]); - return candidate; - }; - let low = 0; - let high = toolResultIndexes.length; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (fits(withEmptyPrefix(middle + 1))) high = middle; - else low = middle + 1; - } - const emptyCount = Math.min(low + 1, toolResultIndexes.length); - if (emptyCount > 0) { - result.splice(0, result.length, ...withEmptyPrefix(emptyCount)); - changed = true; - } - if (fits()) return { messages: changed ? result : messages, changed }; - - // If metadata alone exceeds the cap, discard the oldest complete turns. This - // search is also monotonic and avoids quadratic whole-history reserialization. - const turnRanges: Array<[number, number]> = []; - const isConvertedUser = (message: AgentMessage): boolean => convert([message])[0]?.role === "user"; - for (let index = 0; index < result.length; index++) { - if (!isConvertedUser(result[index])) continue; - const nextUser = result.findIndex((message, nextIndex) => nextIndex > index && isConvertedUser(message)); - turnRanges.push([index, nextUser < 0 ? result.length : nextUser]); - } - const withoutTurns = (count: number): AgentMessage[] => { - if (count === 0) return result; - const start = turnRanges[0]?.[0] ?? 0; - const end = turnRanges[count - 1]?.[1] ?? start; - return [...result.slice(0, start), ...result.slice(end)]; - }; - low = 0; - high = turnRanges.length; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (fits(withoutTurns(middle + 1))) high = middle; - else low = middle + 1; - } - const turnCount = Math.min(low + 1, turnRanges.length); - if (turnCount > 0) { - const next = withoutTurns(turnCount); - result.splice(0, result.length, ...next); - changed = true; - } - return { messages: changed ? result : messages, changed }; -} - // ============================================================================ // AgentSession Class // ============================================================================ -export function providerRetryWatchdogAbortMessage( - retryTimeoutMs: number | undefined, - streamStartTimeoutMs: number | undefined, -): string { - return ( - `Provider retry continuation watchdog timed out after ${retryTimeoutMs}ms` + - (streamStartTimeoutMs === undefined - ? " (stream-start guard disabled; raise retry.provider.streamStartTimeoutMs, 0 disables)" - : ` (stream-start guard: ${streamStartTimeoutMs}ms; raise retry.provider.streamStartTimeoutMs, 0 disables)`) - ); -} - export class AgentSession { readonly agent: Agent; readonly sessionManager: SessionManager; @@ -1013,7 +571,6 @@ export class AgentSession { // Event subscription state private _unsubscribeAgent?: () => void; - private _unsubscribeSettingsSource?: () => void; private _eventListeners: AgentSessionEventListener[] = []; private _agentEventQueue: Promise = Promise.resolve(); /** @@ -1029,7 +586,6 @@ export class AgentSession { private _nextInputId = 0; private _idleWaitPromise: Promise | undefined; private _resolveIdleWait: (() => void) | undefined; - private _settlementEpoch = 0; /** Tracks pending steering messages for UI display. Removed when delivered. */ private _steeringMessages: string[] = []; @@ -1045,7 +601,6 @@ export class AgentSession { private readonly _supersededCompactionLogAttemptIds = new Set(); /** Messages queued to be included with the next user prompt as context ("asides"). */ private _pendingNextTurnMessages: CustomMessage[] = []; - private _pendingCustomMessages: CustomMessage[] = []; // Queues held while the first post-compaction response is classified. Agent // core otherwise drains steering immediately before AgentSession can consume // the stale-usage exemption and schedule the continuation itself. @@ -1058,16 +613,7 @@ export class AgentSession { private _pendingCompactionAdmission: PendingCompactionAdmission | undefined = undefined; private readonly _compactionLifecycle = new CompactionLifecycleCoordinator(); private readonly _sessionWorkBarrier = new SessionWorkBarrier(); - /** - * Extension-published activity that outlives a turn (background terminal - * jobs, terminal monitors, loop-guard holds). Counts persist across extension - * reloads on purpose: a stale "busy" only delays reclamation, while a lost - * one would let the host evict a session still doing work. - */ - private readonly _wakeSources = new WakeSourceTracker(); - private _unsubscribeWakeSources: (() => void) | undefined; private _overflowRecoveryAttempted = false; - private _compactionSkippedTooSmall = false; private _requiredCompactionAdmissionError: RequiredCompactionError | undefined; // Preserve provenance across agent-core's conversion of our admission error // into an assistant error message. Matching provider text alone is not proof @@ -1077,7 +623,6 @@ export class AgentSession { // response must not retrigger threshold compaction from stale provider usage. private _skipNextPostRetryCompactionCheck = false; private _blockedPostCompactionAssistant: { assistant: AssistantMessage; revision: number } | undefined; - private _delegatedCompactionKey: { provider: string; id: string } | undefined; private _skipNextPostCompactionAssistantCheck = false; private _scheduledContinuationRecompacted = false; private readonly _assistantsPendingAtCompaction = new WeakSet(); @@ -1094,18 +639,7 @@ export class AgentSession { // Retry state private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; - - /** - * Resolve the effective retry profile for the current model's provider. - * Falls back to the senpi-default profile when the provider declares none. - */ - private _resolveRetryProfile() { - const providerId = this.model?.provider; - const declared = providerId !== undefined ? this._modelRuntime.getProvider(providerId)?.retryPolicy : undefined; - return this.settingsManager.resolveRetryProfile( - providerId !== undefined ? { id: providerId, retryPolicy: declared } : undefined, - ); - } + private _consecutiveProviderStreamStalls = 0; private _probePhase: ProbePhase = "idle"; private _hintDeadlineMs: number | undefined = undefined; private _cumulativeHintedWaitMs = 0; @@ -1135,16 +669,6 @@ export class AgentSession { private _agentDir: string; private _extensionRunnerRef?: { current?: ExtensionRunner }; private _initialActiveToolNames?: string[]; - private _defaultToolNames?: Set; - private _evalOnlyToolNames?: ReadonlySet; - /** Explicit config override; when supplied it wins over the fixed policy across reloads. */ - private readonly _evalOnlyToolNamesOverride?: ReadonlySet; - /** Policy tools withheld from the model, retained so disarming can restore direct access. */ - private readonly _withheldEvalOnlyToolNames = new Set(); - /** Eval-only hint names this session published, so a disarm can withdraw exactly those. */ - private readonly _publishedEvalOnlyHintNames = new Set(); - /** Active-tool selection as requested by callers, before eval-only filtering. */ - private _requestedActiveToolNames?: string[]; private _allowedToolNames?: Set; private _excludedToolNames?: Set; private _baseToolsOverride?: Record; @@ -1165,7 +689,6 @@ export class AgentSession { private readonly _selectorCooldowns: SelectorCooldowns; private readonly _probeBackScheduler: ProbeBackScheduler; private readonly _fallbackNow: () => number; - private readonly _retryRandom: () => number; // Tool registry for extension getTools/setTools private _toolRegistry: Map = new Map(); @@ -1179,25 +702,16 @@ export class AgentSession { private _currentServiceTier: ServiceTier | undefined = undefined; private _sessionFastMode = false; private readonly _shownHighReasoningWarningKeys = new Set(); - // Widened with the upstream BuildSystemPromptOptions user-override fields so - // extensions (prompt-preset) can see CLI/SDK custom prompts via - // before_agent_start/model_select systemPromptOptions and ctx.getSystemPromptOptions(). - private _baseSystemPromptOptions!: BuildDynamicSystemPromptOptions & { - customPrompt?: string; - appendSystemPrompt?: string; - }; + private _baseSystemPromptOptions!: BuildDynamicSystemPromptOptions; private _systemPromptOverride?: string; constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; - this._unsubscribeSettingsSource = this.settingsManager.subscribeToSourceSelection((source) => { - this._emit({ type: "settings_source_selected", ...source }); - }); const noModelFallback = config.resourceLoader.getExtensions().runtime.flagValues.get("no-model-fallback") === true || - envValue("NO_FALLBACK") === "1"; + process.env.SENPI_NO_FALLBACK === "1"; if (noModelFallback) { this.settingsManager.applyOverrides({ retry: { modelFallback: false } }); } @@ -1223,14 +737,10 @@ export class AgentSession { // points at the file to open; "default" means no scope configured any. const fallbackChainsSource = this.settingsManager.getFallbackChainsScope() ?? "default"; for (const warning of this._fallbackValidationWarnings) { - fallbackLogger.warn("validation_warning", { - warning, - source: fallbackChainsSource, - }); + fallbackLogger.warn("validation_warning", { warning, source: fallbackChainsSource }); } this._selectorCooldowns = new SelectorCooldowns(config.fallbackNow ?? (() => Date.now())); this._fallbackNow = config.fallbackNow ?? (() => Date.now()); - this._retryRandom = config.retryRandom ?? Math.random; this._retryFallback = new RetryFallbackController({ getSettings: () => this.settingsManager.getRetryFallbackSettings(), registry: this._modelRegistry, @@ -1256,16 +766,10 @@ export class AgentSession { }); this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; - this._defaultToolNames = config.defaultToolNames ? new Set(config.defaultToolNames) : undefined; - this._evalOnlyToolNamesOverride = config.evalOnlyToolNames ? new Set(config.evalOnlyToolNames) : undefined; - this._evalOnlyToolNames = this._resolveEvalOnlyToolNames(); this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; - this._sessionStartEvent = config.sessionStartEvent ?? { - type: "session_start", - reason: "startup", - }; + this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; this._autoTitleSessions = config.autoTitleSessions ?? false; const initialModel = this.agent.state.model; @@ -1293,7 +797,6 @@ export class AgentSession { } private async _getRequiredRequestAuth(model: Model): Promise<{ - model: Model; apiKey?: string; headers?: Record; extraBody?: Record; @@ -1310,9 +813,7 @@ export class AgentSession { throw error; } if (result && (result.auth.apiKey || result.auth.headers)) { - const requestModel = result.auth.baseUrl ? { ...model, baseUrl: result.auth.baseUrl } : model; return { - model: requestModel, apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), extraBody: this._modelRuntime.getCompatibilityRequestConfig(model).extraBody, @@ -1336,7 +837,6 @@ export class AgentSession { * functions may provide ambient credentials, unlike streamSimple. */ private async _getSummarizationRequestAuth(model: Model): Promise<{ - model: Model; apiKey?: string; headers?: Record; env?: Record; @@ -1346,27 +846,39 @@ export class AgentSession { } try { - const storedResult = await this._modelRuntime.getAuth(model); - const activeApiKey = await this.agent.getApiKey?.(model.provider); - const result = - activeApiKey !== undefined && storedResult?.source !== "OAuth" - ? await this._modelRuntime.getAuth(model, { apiKey: activeApiKey }) - : storedResult; - if (!result) return { model }; - const requestModel = result.auth.baseUrl ? { ...model, baseUrl: result.auth.baseUrl } : model; - return { - model: requestModel, - apiKey: result.auth.apiKey, - headers: withoutDeletedHeaders(result.auth.headers), - env: result.env, - }; + const apiKey = await this.agent.getApiKey?.(model.provider); + const result = await this._modelRuntime.getAuth(model, { apiKey }); + return result + ? { + apiKey: apiKey ?? result.auth.apiKey, + headers: withoutDeletedHeaders(result.auth.headers), + env: result.env, + } + : {}; } catch { - return { model }; + return {}; } } + /** + * Resolve the model used for compaction summarization. When the user sets a + * `compaction.model` override ("provider/model"), that model is used for the + * summarization call instead of the session model — this is what lets an + * SDK-owned lane (claude-sdk-oauth) compact on a cheaper/different model. + * Any resolution failure (unset, malformed, unknown model) falls back to the + * session model so compaction never silently breaks. + */ + private _resolveCompactionModel(sessionModel: Model): Model { + const override = this.settingsManager.getCompactionSettings().model; + if (!override) return sessionModel; + const slash = override.indexOf("/"); + if (slash <= 0 || slash === override.length - 1) return sessionModel; + const provider = override.slice(0, slash); + const modelId = override.slice(slash + 1); + return this._modelRuntime.getModel(provider, modelId) ?? sessionModel; + } + private async _getCompactionRequestAuth(model: Model): Promise<{ - model: Model; apiKey?: string; headers?: Record; extraBody?: Record; @@ -1406,22 +918,10 @@ export class AgentSession { } private _installAgentToolHooks(): void { - this.agent.resolveUnknownToolCall = (toolName) => { - let service: ReturnType; - try { - service = getToolSearchService(); - } catch { - return undefined; - } - const catalogTool = service.getCatalog().some((doc) => doc.name === toolName); - if (!catalogTool || !this._activateLazyTool(toolName)) return undefined; - return this.agent.state.tools.find((tool) => tool.name === toolName); - }; - this.agent.beforeToolCall = async ({ toolCall, args }) => { this._toolExecutionDepth++; try { - const result = await this.preflightToolCall(toolCall, args); + const result = await this._emitBeforeToolCallHooks(toolCall, args); if (result?.block) { this._toolExecutionDepth--; } @@ -1441,7 +941,11 @@ export class AgentSession { }; } - async preflightToolCall(toolCall: AgentToolCall, args: unknown, options: { waitForEventQueue?: boolean } = {}) { + private async _emitBeforeToolCallHooks( + toolCall: AgentToolCall, + args: unknown, + options: { waitForEventQueue?: boolean } = {}, + ) { if (options.waitForEventQueue !== false) { await this._agentEventQueue; } @@ -1473,32 +977,30 @@ export class AgentSession { isError: boolean, ) { const runner = this._extensionRunner; - const hookResult = runner.hasHandlers("tool_result") - ? await runner.emitToolResult({ - type: "tool_result", - toolName: toolCall.name, - toolCallId: toolCall.id, - input: args as Record, - content: result.content, - details: result.details, - isError, - usage: result.usage, - }) - : undefined; - const content = hookResult?.content ?? result.content ?? []; - const normalizedContent = await normalizeToolResultImages(content, { - autoResizeImages: this.settingsManager.getImageAutoResize(), + if (!runner.hasHandlers("tool_result")) { + return undefined; + } + + const hookResult = await runner.emitToolResult({ + type: "tool_result", + toolName: toolCall.name, + toolCallId: toolCall.id, + input: args as Record, + content: result.content, + details: result.details, + isError, + usage: result.usage, }); - if (!hookResult && normalizedContent === content) { + if (!hookResult) { return undefined; } return { - content: normalizedContent, - details: hookResult?.details, - isError: hookResult?.isError ?? isError, - usage: hookResult?.usage, + content: hookResult.content, + details: hookResult.details, + isError: hookResult.isError ?? isError, + usage: hookResult.usage, }; } @@ -1508,37 +1010,12 @@ export class AgentSession { (this.agent.prepareNextTurn ? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal) : undefined); - const previousTransformContext = this.agent.transformContext; - this.agent.transformContext = async (messages, signal) => { - const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages; - if (this.model?.provider === "cursor" || this.model?.provider === "cursor-cli-oauth") { - return ( - ( - await truncateToolResultBodies( - transformed, - CURSOR_TOOL_RESULT_MAX_CHARS, - CURSOR_TOOL_RESULT_MAX_BYTES, - (candidate) => this.agent.convertToLlm(candidate) as Message[], - ) - ).messages ?? transformed - ); - } - return transformed; - }; - this.agent.prepareNextTurnWithContext = async (turn, signal) => { // Enforce compaction only when this prepare precedes an actual provider // admission: a tool continuation or queued steer/follow-up messages. A // completed turn with no continuation keeps pre-PR timing, while the // prior prepare callback and context refresh below still run every turn. const compactBeforeNextAdmission = async (): Promise => { - const provider = this.model?.provider; - // Cursor rebuilds the full conversation each hop. Compacting here - // mutates rootPrompt mid-run and poisons conversationId (#984). - // Still truncate verbatim toolResult bodies so the skip cannot send MB-scale payloads (#1043). - if (provider === "cursor" || provider === "cursor-cli-oauth") { - return false; - } if (turn.toolResults.length === 0 && !this.agent.hasQueuedMessages()) { return false; } @@ -1585,10 +1062,7 @@ export class AgentSession { // this invocation: one replay is the bounded admission path. const postLateCompactionTurn = { ...turn, - context: { - ...turn.context, - messages: this.agent.state.messages.slice(), - }, + context: { ...turn.context, messages: this.agent.state.messages.slice() }, }; const postLateCompactionSnapshot = await previousPrepareNextTurnWithContext?.( postLateCompactionTurn, @@ -1612,9 +1086,6 @@ export class AgentSession { }, model: this.agent.state.model, thinkingLevel: this.agent.state.thinkingLevel, - thinkingSelection: this.agent.state.thinkingSelection ?? null, - abortServerSideFallback: - this.settingsManager.getAbortServerSideFallback() && this._retryFallback.hasConfiguredChain(), }; }; } @@ -1624,19 +1095,6 @@ export class AgentSession { // ========================================================================= /** Emit an event to all listeners */ - private _emitEntryAppended(entryId: string): void { - if (this._extensionMode !== "rpc") return; - const entry = this.sessionManager.getEntry(entryId); - if (entry) this._emit({ type: "entry_appended", entry }); - } - - /** Append a transport-provided entry and publish it on the RPC event stream. */ - appendSessionEntry(entry: SessionEntry): void { - this.sessionManager.appendEntry(entry); - this.agent.state.messages = this.sessionManager.buildSessionContext().messages; - this._emitEntryAppended(entry.id); - } - private _emit(event: AgentSessionEvent): void { this._logSessionEvent(event); for (const l of this._eventListeners) { @@ -1644,15 +1102,6 @@ export class AgentSession { } } - private async _emitSessionCompactFailed(event: Omit): Promise { - if (this._extensionRunner.hasHandlers("session_compact_failed")) { - await this._extensionRunner.emit({ - type: "session_compact_failed", - ...event, - }); - } - } - /** Mirror stuck-prone lifecycle transitions into logs/session.log (content-free). */ private _logSessionEvent(event: AgentSessionEvent): void { if (event.type === "compaction_start") { @@ -1735,10 +1184,7 @@ export class AgentSession { : isProviderTimeoutError(message) ? "timeout" : "error"; - this._sessionLogger.warn("provider_error", { - kind, - error: message.errorMessage, - }); + this._sessionLogger.warn("provider_error", { kind, error: message.errorMessage }); } } @@ -1757,7 +1203,6 @@ export class AgentSession { type: "queue_update", steering: [...this._steeringMessages], followUp: [...this._followUpMessages], - ordered: [...this._queuedInputOrder].sort((a, b) => a.enqueueOrder - b.enqueueOrder), }); } @@ -1775,14 +1220,6 @@ export class AgentSession { return this._agentDir; } - /** - * Working directory this session resolves settings against — the same value extensions - * receive as `ctx.cwd`, so a host surface (RPC) reads project settings identically. - */ - get cwd(): string { - return this._cwd; - } - private async _waitForSettledSessionWork(): Promise { await this._sessionWorkBarrier.waitForSettled(() => this._agentEventQueue); } @@ -1805,24 +1242,9 @@ export class AgentSession { private _invalidateCompactionForModelSelection(): void { this.abortCompaction(); this.abortBranchSummary(); - this._delegatedCompactionKey = undefined; this._incrementMessageRevision(); } - private _modelChangeWouldExhaustContext( - model: Model, - ): ReturnType | undefined { - if (model.contextWindow <= 0) return undefined; - const projection = projectModelUsabilityBudget({ - model, - systemPrompt: this.agent.state.systemPrompt, - tools: this.agent.state.tools, - liveContextTokens: this._getDownswitchLiveContextTokens(model), - compaction: this.settingsManager.getCompactionSettings(), - }); - return projection.usable ? undefined : projection; - } - private _getIdleWaitPromise(): Promise { if (!this._idleWaitPromise) { this._idleWaitPromise = new Promise((resolve) => { @@ -1853,46 +1275,21 @@ export class AgentSession { } this._isAgentRunActive = false; let deferredActions: DeferredAgentSettledAction[] = []; - let deferredTurnClaims: DeferredTurnClaim[] = []; this._agentSettledDelivery.begin(this._userAbortGeneration); - const settlementEpoch = ++this._settlementEpoch; try { await this._extensionRunner.emit({ type: "agent_settled" }); this._emit({ type: "agent_settled" }); if (this._abortProvenance.takeLateUserJoin()) await this._emitSessionAbort(); - ({ actions: deferredActions, turnClaims: deferredTurnClaims } = this._agentSettledDelivery.finish( - this._userAbortGeneration, - )); + deferredActions = this._agentSettledDelivery.finish(this._userAbortGeneration); } finally { this._agentSettledDelivery.cancel(); this._abortProvenance.closeAgentEndBoundary(); this._resolveIdleWaitIfIdle(); } for (const action of deferredActions) action(); - queueMicrotask(() => { - void this._emitAgentIdleAfterDeferredTurns(settlementEpoch, deferredTurnClaims); - }); - } - - private async _emitAgentIdleAfterDeferredTurns( - settlementEpoch: number, - deferredTurnClaims: DeferredTurnClaim[], - ): Promise { - const dispositions = await Promise.all(deferredTurnClaims.map((claim) => claim.disposition)); - if (dispositions.includes("started")) return; - if (dispositions.includes("delegated") || this._sessionWorkBarrier.hasActiveWork) { - await this._waitForSettledSessionWork(); - } - if (settlementEpoch !== this._settlementEpoch) return; - if (this._isAgentRunActive || this._sessionWorkBarrier.hasActiveWork) return; - this._emit({ type: "agent_idle" }); } - private async _promptAgent( - messages: AgentMessage | AgentMessage[], - deferredTurnClaim?: DeferredTurnClaim, - ): Promise { - deferredTurnClaim?.resolve("started"); + private async _promptAgent(messages: AgentMessage | AgentMessage[]): Promise { this._isAgentRunActive = true; this._requiredCompactionAdmissionError = undefined; this.agent.abortServerSideFallback = @@ -1906,10 +1303,7 @@ export class AgentSession { const requiredCompactionError = this._requiredCompactionAdmissionError; this._requiredCompactionAdmissionError = undefined; if (requiredCompactionError) { - this._sessionLogger.warn("prompt_rejected", { - stage: "admission", - error: "RequiredCompactionError", - }); + this._sessionLogger.warn("prompt_rejected", { stage: "admission", error: "RequiredCompactionError" }); throw requiredCompactionError; } } catch (error) { @@ -1994,9 +1388,7 @@ export class AgentSession { }) : processing; - // Keep queue alive if an event handler fails, including the promise created - // by finally() above. The originating prompt observes the stored admission - // error after the queue settles; no rejection should become unhandled. + // Keep queue alive if an event handler fails this._agentEventQueue.catch(() => {}); }; @@ -2013,10 +1405,7 @@ export class AgentSession { const lastAssistant = this._findLastAssistantInMessages(event.messages); if ( !lastAssistant || - (!this._isRetryableError(lastAssistant) && - !this._isHardErrorFallbackEligible(lastAssistant) && - !isCursorZeroTokenResourceExhausted(lastAssistant) && - !isCursorQuotaResourceExhausted(lastAssistant, this.model?.contextWindow ?? 0)) + (!this._isRetryableError(lastAssistant) && !this._isHardErrorFallbackEligible(lastAssistant)) ) { return; } @@ -2072,18 +1461,6 @@ export class AgentSession { return reason; } - /** - * Threshold checks measure the larger of the provider-reported context and - * the plain local-transcript estimate. Providers whose usage tracks a - * server-side summarized conversation (native Cursor checkpoints) can report - * a context far smaller than the transcript this client replays every turn, - * and that small figure must not hide a transcript already past the window. - */ - private _resolveThresholdContextTokens(directContextTokens: number): number { - const messages = filterContextExcludedMessages(this.agent.state.messages); - return resolveThresholdContextTokens(directContextTokens, estimateMessagesTokens(messages)); - } - private _getAutoCompactionReason(message: AssistantMessage): "overflow" | "threshold" | undefined { const settings = this.settingsManager.getCompactionSettings(); if (!settings.enabled || message.stopReason === "aborted") { @@ -2108,14 +1485,11 @@ export class AgentSession { if (isContextOverflow(message, model.contextWindow) && (sameModel || currentContextNeedsCompaction)) { return "overflow"; } - if (this._isCursorPayloadOverflow(message)) { - return "overflow"; - } let contextTokens: number; const directContextTokens = message.usage ? calculateContextTokens(message.usage) : 0; if (message.stopReason !== "error" && directContextTokens !== 0) { - contextTokens = this._resolveThresholdContextTokens(directContextTokens); + contextTokens = directContextTokens; } else { const messages = filterContextExcludedMessages(this.agent.state.messages); const estimate = estimateContextTokens(messages); @@ -2213,18 +1587,8 @@ export class AgentSession { if (!settings.enabled) { return false; } - // The same-model budget comes from the resolved profile so a provider-declared - // budget (e.g. kimi-code's 9) is honoured; identical to settings.maxRetries for - // providers without a profile. - const turnMaxRetries = this._resolveRetryProfile().turn.maxRetries; const retryableError = this._isRetryableError(lastAssistant); - if (isCursorZeroTokenResourceExhausted(lastAssistant)) { - return true; - } - if (isCursorQuotaResourceExhausted(lastAssistant, this.model?.contextWindow ?? 0)) { - return this._retryFallback.canTryFallback(); - } if (!retryableError && this._isHardErrorFallbackEligible(lastAssistant)) { return true; } @@ -2234,10 +1598,10 @@ export class AgentSession { } if (isClassifierRefusal(lastAssistant)) { - return this._retryAttempt + 1 <= turnMaxRetries && this._retryFallback.canTryFallback(); + return this._retryAttempt + 1 <= settings.maxRetries && this._retryFallback.canTryFallback(); } - if (this._retryAttempt + 1 > turnMaxRetries) { + if (this._retryAttempt + 1 > settings.maxRetries) { return this._retryFallback.canTryFallback(); } @@ -2326,7 +1690,7 @@ export class AgentSession { event.message.role === "toolResult" ) { // Regular LLM message - persist as SessionMessageEntry - this._emitEntryAppended(this.sessionManager.appendMessage(event.message)); + this.sessionManager.appendMessage(event.message); this._incrementMessageRevision(); } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere @@ -2337,11 +1701,10 @@ export class AgentSession { const assistantMsg = event.message as AssistantMessage; const succeeded = - !assistantMsg.errorMessage && assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted" && !isClassifierRefusal(assistantMsg); - if (succeeded && assistantMsg.stopReason !== "length") { + if (succeeded) { this._overflowRecoveryAttempted = false; } @@ -2371,7 +1734,6 @@ export class AgentSession { // Check auto-retry and auto-compaction after agent completes. let launchedContinuation = false; let retryContinuationBlocked = false; - let retryExhaustionAllowsQueuedContinuation = false; let allowsPostCompactionUsageExemptContinuation = false; const userAbortSuppressedQueuedContinuation = event.type === "agent_end" && this._suppressQueuedContinuationAfterUserAbort; @@ -2393,12 +1755,10 @@ export class AgentSession { // Retry transient failures normally and eligible hard errors only through a fallback. const retryableError = this._isRetryableError(msg); const hardErrorFallbackEligible = this._isHardErrorFallbackEligible(msg); - const cursorZeroTokenRe = isCursorZeroTokenResourceExhausted(msg); - const cursorQuotaRe = isCursorQuotaResourceExhausted(msg, this.model?.contextWindow ?? 0); const retryCanAdmitProvider = !userAbortSuppressedQueuedContinuation && this.settingsManager.getRetrySettings().enabled && - (retryableError || hardErrorFallbackEligible || cursorZeroTokenRe || cursorQuotaRe); + (retryableError || hardErrorFallbackEligible); let compactedBeforeRetry = false; if ( retryCanAdmitProvider && @@ -2406,59 +1766,23 @@ export class AgentSession { !(requiredAutoCompaction === "threshold" && this._hasPendingPostCompactionUsageExemption(msg)) ) { this._retireFailedRetryAssistant(msg); - compactedBeforeRetry = await this._runPrePromptCompaction(msg, true, requiredAutoCompaction, true); - retryContinuationBlocked = - !compactedBeforeRetry && !this._isCompactionDelegated() && !cursorQuotaRe && !hardErrorFallbackEligible; + compactedBeforeRetry = await this._runPrePromptCompaction(msg, true, "threshold", true); + retryContinuationBlocked = !compactedBeforeRetry; } - let retryOutcome: "continued" | "blocked" | "not-handled" | "cancelled" = "not-handled"; - const retryOwnedDeferredQueue = DEFERRED_RETRY_QUEUE_OWNERS.has(this); - DEFERRED_RETRY_QUEUE_OWNERS.delete(this); + let retryOutcome: "continued" | "blocked" | "not-handled" = "not-handled"; if (!retryContinuationBlocked && !userAbortSuppressedQueuedContinuation) { - if (cursorZeroTokenRe) { - retryOutcome = await this._handleRetryableError(msg, { sameModelRemint: true }); - } else if (cursorQuotaRe) { - // Mid-turn Cursor errors may retain unpaired tool calls. Remove the - // failed assistant before provider fallback so replay stays valid. - this._retireFailedRetryAssistant(msg); - retryOutcome = await this._handleRetryableError(msg, { hardErrorFallback: true }); - } else if (retryableError) { + if (retryableError) { retryOutcome = await this._handleRetryableError(msg); } else if (hardErrorFallbackEligible) { - retryOutcome = await this._handleRetryableError(msg, { - hardErrorFallback: true, - }); + retryOutcome = await this._handleRetryableError(msg, { hardErrorFallback: true }); } } if (retryOutcome === "continued") { this._abortProvenance.closeAgentEndBoundary(); return; } - // Provider-timeout retries deliberately skip their first queue poll so - // steering cannot be consumed by another doomed retry request. Once the - // managed retry owner exhausts its budget, hand that retained queue back - // to the normal scheduled-continuation path instead of parking it until - // an unrelated later prompt arrives. - retryExhaustionAllowsQueuedContinuation = - !userAbortSuppressedQueuedContinuation && - retryOwnedDeferredQueue && - retryOutcome === "not-handled" && - (msg.stopReason === "error" || msg.stopReason === "aborted"); - if (retryOutcome === "not-handled" && cursorQuotaRe && msg.errorMessage) { - msg.errorMessage = `${msg.errorMessage} (likely provider usage/quota exhaustion: conversation is well below the model context window)`; - } - if (retryOutcome === "not-handled" && this._retryAttempt > 0 && msg.errorMessage) { - const attempt = this._retryAttempt; - this._retryAttempt = 0; - this._resetHintTierState(); - this._emit({ - type: "auto_retry_end", - success: false, - attempt, - finalError: msg.errorMessage, - }); - } this._resolveRetry(); retryContinuationBlocked ||= retryOutcome === "blocked"; if (!retryContinuationBlocked && !userAbortSuppressedQueuedContinuation) { @@ -2500,9 +1824,7 @@ export class AgentSession { if ( !launchedContinuation && !retryContinuationBlocked && - (allowsQueuedContinuation || - allowsPostCompactionUsageExemptContinuation || - retryExhaustionAllowsQueuedContinuation) && + (allowsQueuedContinuation || allowsPostCompactionUsageExemptContinuation) && this.agent.hasQueuedMessages() ) { // A scheduled continuation owns the queue now; the stored admission @@ -2575,13 +1897,7 @@ export class AgentSession { demotedModel, { systemPrompt: "Reply with OK.", - messages: [ - { - role: "user", - content: [{ type: "text", text: "OK" }], - timestamp: now, - }, - ], + messages: [{ role: "user", content: [{ type: "text", text: "OK" }], timestamp: now }], }, { maxTokens: 1, signal }, ); @@ -2715,7 +2031,6 @@ export class AgentSession { }; await this._extensionRunner.emit(extensionEvent); this._turnIndex++; - this._flushPendingCustomMessages(); } else if (event.type === "message_start") { const extensionEvent: MessageStartEvent = { type: "message_start", @@ -2784,9 +2099,6 @@ export class AgentSession { */ subscribe(listener: AgentSessionEventListener): () => void { this._eventListeners.push(listener); - for (const source of this.settingsManager.getSelectedSettingsSources()) { - listener({ type: "settings_source_selected", ...source }); - } // Return unsubscribe function for this specific listener return () => { @@ -2839,36 +2151,10 @@ export class AgentSession { "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", ); this._disconnectFromAgent(); - this._unsubscribeSettingsSource?.(); - this._unsubscribeSettingsSource = undefined; - this._unsubscribeWakeSources?.(); - this._unsubscribeWakeSources = undefined; this._eventListeners = []; cleanupSessionResources(this.sessionId); } - /** Live in-session activity signals; see `session-activity.ts` for the contract. */ - get activitySnapshot(): SessionActivitySnapshot { - return { - isStreaming: this._isAgentRunActive, - isBashRunning: this.isBashRunning, - isCompacting: this.isCompacting, - hasSessionWork: this._sessionWorkBarrier.hasActiveWork, - hasActiveWakeSource: this._wakeSources.hasActive, - }; - } - - /** - * Whether this session owns work that must outlive a teardown decision: an - * agent run, bash, compaction, barrier-held session work, or an - * extension-published wake source. Occupancy sweeps (the shared RPC host's - * idle eviction) MUST consult this rather than individual flags, so a new - * activity source is picked up by every call site at once. - */ - get isSessionBusy(): boolean { - return isSessionBusySnapshot(this.activitySnapshot); - } - // ========================================================================= // Read-only State Access // ========================================================================= @@ -2888,11 +2174,6 @@ export class AgentSession { return this.agent.state.thinkingLevel; } - /** Explicit selector provenance, absent for SDK-defaulted effective levels. */ - get thinkingSelection(): ThinkingSelection | undefined { - return this.agent.state.thinkingSelection; - } - get serviceTier(): ServiceTier | undefined { return this._currentServiceTier; } @@ -2910,60 +2191,14 @@ export class AgentSession { return this._sessionFastMode || this._currentServiceTier === "priority"; } - /** - * The tier a request would carry right now: `serviceTier`, promoted to `"priority"` while - * session fast mode is on (which is exactly what the service-tier extension puts on the - * wire). Reported to clients so `serviceTier` and `fastMode` can never disagree. - */ - get effectiveServiceTier(): ServiceTier | undefined { - return this.isFastModeActive() ? "priority" : this._currentServiceTier; - } - - /** - * Session-scoped fast-mode indicator; never persisted, reset on session start. - * - * Turning fast OFF also clears the cached priority tier: `/fast off` writes a remembered - * `"auto"` that must override an inherited catalog-priority tier immediately (display and - * request side), not only on the next session. Only codex-response models are touched, and - * never when an explicit scoped/favorite `:priority` pin is in force — those are pinned by - * the user's model selection, not by `/fast`. - */ + /** Session-scoped fast-mode indicator; never persisted, reset on session start. */ setSessionFastMode(enabled: boolean): void { - const previousFastMode = this.isFastModeActive(); - const previousTier = this._currentServiceTier; this._sessionFastMode = enabled; - if (!enabled && this._currentServiceTier === "priority" && this.model?.api === CODEX_RESPONSES_API) { - // Only an INHERITED (catalog) priority is cleared. A priority the catalog does not - // explain came from an explicit scoped/favorite `:priority` pin, which `/fast` must not undo. - if (this._modelRuntime.getCompatibilityRequestConfig(this.model).serviceTier === "priority") { - this._currentServiceTier = undefined; - } - } - this._emitServiceTierChangeIfNeeded(previousTier, previousFastMode); - } - - /** - * Emit `service_tier_changed` when the effective tier or the fast-mode indicator actually - * moved. Both are observable state for RPC clients (`get_state.serviceTier` / `.fastMode`), - * and they can move independently: a session fast-mode toggle need not change the resolved - * tier, and a model switch can change the tier with fast mode untouched. - */ - private _emitServiceTierChangeIfNeeded(previousTier: ServiceTier | undefined, previousFastMode: boolean): void { - const fastMode = this.isFastModeActive(); - if (previousTier === this._currentServiceTier && previousFastMode === fastMode) return; - this._emit({ - type: "service_tier_changed", - tier: this.effectiveServiceTier, - fastMode, - }); } /** * Explicit scoped/favorite tiers win; otherwise fall back to the model's - * configured serviceTier from models.json/extension compatibility config. The per-model - * `/fast` memory is honored by the service-tier extension (`liveMemoryTier` + the session - * flag in `before_provider_request`), not cached here: caching it would survive a same-session - * `/fast off` (no model switch to re-resolve) and leak an inherited priority onto the wire. + * configured serviceTier from models.json/extension compatibility config. */ private _resolveServiceTier( model: Model | undefined, @@ -2994,11 +2229,6 @@ export class AgentSession { return this._retryAttempt; } - /** Abort owner for the current turn boundary, used by internal renderers. */ - get currentAbortSource(): AgentAbortSource | undefined { - return this._abortProvenance.currentSource; - } - /** * Get the names of currently active tools. * Returns the names of tools currently set on the agent. @@ -3008,17 +2238,15 @@ export class AgentSession { } /** - * Get all configured tools with normalized exposure metadata and source metadata. + * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata. */ getAllTools(): ToolInfo[] { return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ name: definition.name, - label: definition.label, description: definition.description, parameters: definition.parameters, promptGuidelines: definition.promptGuidelines, sourceInfo, - ...normalizeToolExposure(definition), })); } @@ -3033,9 +2261,6 @@ export class AgentSession { ): Promise> { let activeTools = this.getActiveToolNames(); let tool = this.agent.state.tools.find((candidate) => candidate.name === toolName); - if (!tool && this._isEvalOnlyPolicyArmed() && this._evalOnlyToolNames?.has(toolName)) { - tool = this._toolRegistry.get(toolName); - } if ( !tool && options?.activateInactiveTool === true && @@ -3078,7 +2303,7 @@ export class AgentSession { ); } - const beforeResult = await this.preflightToolCall(prepared.toolCall, prepared.args, { + const beforeResult = await this._emitBeforeToolCallHooks(prepared.toolCall, prepared.args, { waitForEventQueue: this._toolExecutionDepth === 0, }); if (beforeResult?.block) { @@ -3101,12 +2326,7 @@ export class AgentSession { ); } catch (err) { result = { - content: [ - { - type: "text", - text: err instanceof Error ? err.message : String(err), - }, - ], + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], details: { isError: true }, }; isError = true; @@ -3124,107 +2344,21 @@ export class AgentSession { }; } - /** - * Lazily activate a registered inactive tool. - * - * Resolution order is deliberate: resolve the winning definition and enforce its - * `allowLazyActivation` hard stop, then invoke activators in registration order. - * The caller re-resolves the tool from the active registry before execution. - */ + /** Lets a registering extension activate its own inactive tool on demand; it owns eligibility. */ private _activateLazyTool(toolName: string): boolean { - const definition = this._toolDefinitions.get(toolName)?.definition; - if (!definition || !normalizeToolExposure(definition).allowLazyActivation) return false; return this._lazyToolActivators.some((activate) => activate(toolName)); } - private _isEvalOnlyPolicyArmed(): boolean { - return this._evalOnlyToolNames !== undefined && this._toolRegistry.has("eval"); - } - - /** Resolve the fixed eval-only policy, unless an SDK embedder supplied an override. */ - private _resolveEvalOnlyToolNames(): ReadonlySet { - return this._evalOnlyToolNamesOverride ?? EVAL_ONLY_TOOL_NAMES; - } - - /** - * Publish per-tool eval-only redirect hints so a direct call names its own eval helper. - * - * Hints published here are tracked so a disarm - or a shrinking armed set, e.g. bash+workflow - * down to workflow only - withdraws the stale ones. Only tracked names are ever deleted, so - * hints owned by other publishers survive. - */ - private _publishEvalOnlyToolHints(): void { - const armed = this._isEvalOnlyPolicyArmed() ? (this._evalOnlyToolNames ?? new Set()) : undefined; - const registered = armed ? new Set([...armed].filter((name) => this._toolRegistry.has(name))) : undefined; - for (const name of this._publishedEvalOnlyHintNames) { - if (registered?.has(name)) continue; - delete this.agent.removedToolHints[name]; - } - this._publishedEvalOnlyHintNames.clear(); - if (!registered) return; - for (const name of registered) { - this.agent.removedToolHints[name] = - `Run ${name} inside an eval cell via ${evalHelperCall(name)}; hooks and permissions still apply.`; - this._publishedEvalOnlyHintNames.add(name); - } - } - /** * 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 on the next agent turn. */ - /** - * Resolve an executable tool from the full registry (builtin + extension - * tools), independent of the active set. The Cursor exec bridge uses this: - * Cursor drives its native tools (read/bash/grep/ls/write) over the exec - * channel regardless of which tools the request advertised. - */ - getRegisteredTool(name: string): AgentTool | undefined { - return this._toolRegistry.get(name); - } - - /** Cursor exec already ran the tool; still emit tool_result so plan-touch trackers see .omo/plans writes. */ - async emitExecBridgeToolResult( - toolName: string, - toolCallId: string, - args: unknown, - result: AgentToolResult, - isError: boolean, - ): Promise { - await this._emitAfterToolCallHooks( - { - type: "toolCall", - id: toolCallId, - name: toolName, - arguments: - args && typeof args === "object" && !Array.isArray(args) ? (args as Record) : {}, - }, - args, - result, - isError, - ); - } - setActiveToolsByName(toolNames: string[]): void { const tools: AgentTool[] = []; const validToolNames: string[] = []; - const policyArmed = this._isEvalOnlyPolicyArmed(); - this._publishEvalOnlyToolHints(); - const policyNames = this._evalOnlyToolNames; - let filteredToolNames = toolNames; - if (policyArmed && policyNames) { - for (const name of toolNames) { - if (policyNames.has(name)) this._withheldEvalOnlyToolNames.add(name); - } - filteredToolNames = toolNames.filter((name) => !policyNames.has(name)); - } else { - this._withheldEvalOnlyToolNames.clear(); - } - // Remember the unfiltered request so a later disarm can restore withheld eval-only tools. - this._requestedActiveToolNames = [...new Set([...toolNames, ...this._withheldEvalOnlyToolNames])]; - for (const name of filteredToolNames) { + for (const name of toolNames) { const tool = this._toolRegistry.get(name); if (tool) { tools.push(tool); @@ -3240,20 +2374,15 @@ export class AgentSession { this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt; if (activeToolNamesChanged) { - // A tool change emitted while extensions are still binding belongs to the - // binding itself, not to a mid-session change. The session was already - // committed to the client, so cancelling or invalidating work it started - // against that session would be spurious. - if (this._extensionBindingPromptReadiness === undefined) { - this.abortCompaction(); - this._incrementMessageRevision(); - } + this.abortCompaction(); + this._incrementMessageRevision(); } } /** Whether compaction or branch summarization is currently running */ get isCompacting(): boolean { return ( + this._pendingCompactionAdmission !== undefined || this._compactionLifecycle.state.status === "running" || this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined || @@ -3290,38 +2419,36 @@ export class AgentSession { return this.sessionManager.getSessionId(); } - /** Subscribe to the internal event bus shared by this session's extensions. */ - onExtensionEvent(channel: string, handler: (data: unknown) => void): () => void { - return this._resourceLoader.onExtensionEvent?.(channel, handler) ?? (() => {}); - } - - /** Publish on the internal event bus shared by this session's extensions. */ - emitExtensionEvent(channel: string, data: unknown): void { - this._resourceLoader.emitExtensionEvent?.(channel, data); - } - /** Current session display name, if set */ get sessionName(): string | undefined { return this.sessionManager.getSessionName(); } /** Globally narrowed models (from --models / enabledModels) */ - get scopedModels(): ReadonlyArray { + get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel; serviceTier?: ServiceTier }> { return this._scopedModels; } /** Update global model narrowing */ - setScopedModels(scopedModels: SessionModelEntry[]): void { + setScopedModels( + scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel; serviceTier?: ServiceTier }>, + ): void { this._scopedModels = scopedModels; } /** Favorite models for Ctrl+P cycling */ - get favoriteModels(): ReadonlyArray { + get favoriteModels(): ReadonlyArray<{ + model: Model; + thinkingLevel?: ThinkingLevel; + serviceTier?: ServiceTier; + }> { return this._getCurrentFavoriteModels(); } /** Update favorite models for Ctrl+P cycling */ - setFavoriteModels(favoriteModels: SessionModelEntry[]): void { + setFavoriteModels( + favoriteModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel; serviceTier?: ServiceTier }>, + ): void { this._favoriteModels = favoriteModels; } @@ -3348,7 +2475,6 @@ export class AgentSession { favoriteModels.push({ model, thinkingLevel: favorite.thinkingLevel, - thinkingSelection: favorite.thinkingSelection, serviceTier: favorite.serviceTier, }); } @@ -3387,19 +2513,9 @@ export class AgentSession { private _rebuildSystemPrompt(toolNames: string[]): string { const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); - // An eval-only tool is hidden from the model but still callable as `tool.(...)`, - // so its own snippet and guidelines still apply and must survive the withholding. - // `selectedTools` stays the model-visible list; only the contributions are widened. - // This can run before the field initializers below it during construction, so the - // withheld set is read defensively rather than spread directly. - const withheld = this._withheldEvalOnlyToolNames ?? new Set(); - const contributingToolNames = [ - ...validToolNames, - ...[...withheld].filter((name) => this._toolRegistry.has(name) && !validToolNames.includes(name)), - ]; const toolSnippets: Record = {}; const promptGuidelines: string[] = []; - for (const name of contributingToolNames) { + for (const name of validToolNames) { const snippet = this._toolPromptSnippets.get(name); if (snippet) { toolSnippets[name] = snippet; @@ -3423,37 +2539,11 @@ export class AgentSession { selectedTools: validToolNames, toolSnippets, promptGuidelines, - customPrompt: loaderSystemPrompt, - appendSystemPrompt: loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined, }; const basePrompt = loaderSystemPrompt ?? buildDynamicSystemPrompt(this._baseSystemPromptOptions); - const prompt = - loaderAppendSystemPrompt.length > 0 ? `${basePrompt}\n\n${loaderAppendSystemPrompt.join("\n\n")}` : basePrompt; - if (!this._isEvalOnlyPolicyArmed()) return prompt; - const armed = this._evalOnlyToolNames ?? new Set(); - const registered = new Set([...armed].filter((name) => this._toolRegistry.has(name))); - const sentences: string[] = []; - const shellHelpers = ["bash", "powershell"].filter((name) => registered.has(name)).map(evalHelperCall); - if (shellHelpers.length > 0) { - sentences.push( - `Shell commands run ONLY inside eval cells via ${shellHelpers.join(" or ")}; hooks and permissions still apply.`, - ); - } - if (registered.has("workflow")) { - sentences.push( - `The workflow tool runs ONLY inside eval cells via ${evalHelperCall("workflow")}; hooks and permissions still apply.`, - ); - } - // SDK embedders can arm names outside the built-in groups; they still need eval guidance. - const otherHelpers = [...registered] - .filter((name) => name !== "bash" && name !== "powershell" && name !== "workflow") - .map(evalHelperCall); - if (otherHelpers.length > 0) { - sentences.push( - `These tools run ONLY inside eval cells via ${otherHelpers.join(" or ")}; hooks and permissions still apply.`, - ); - } - return sentences.length > 0 ? `${prompt}\n\n${sentences.join("\n\n")}` : prompt; + return loaderAppendSystemPrompt.length > 0 + ? `${basePrompt}\n\n${loaderAppendSystemPrompt.join("\n\n")}` + : basePrompt; } /** @@ -3478,39 +2568,6 @@ export class AgentSession { await userAbortPromise; throwIfCancelled(); } - - // Extension commands are UI actions, not prompts: dispatch them before the - // settled-session-work gate below. That gate makes a bare prompt() wait for - // _sessionWorkBarrier, which a scheduled continuation (goal chain, queued - // follow-up) holds for an entire run, so a command typed mid-turn used to run - // only after the turn ended. The registry lookup stays synchronous so ordinary - // text beginning with "/" gains no await before the prompt-start bookkeeping. - try { - if ((options?.expandPromptTemplates ?? true) && text.startsWith("/")) { - const spaceIndex = text.indexOf(" "); - const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - if (this._extensionRunner.getCommand(commandName)) { - const handled = await this._tryExecuteExtensionCommand(text); - throwIfCancelled(); - if (handled) { - options?.promptDisposition?.("handled"); - options?.preflightResult?.(true); - return; - } - } - } - } catch (error) { - options?.preflightResult?.(false); - throw error; - } - - if (options?.source !== "extension" && this._compactionAbortController !== undefined) { - options?.preflightResult?.(false); - throw new Error( - "Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry.", - ); - } - const ownsPromptStart = !this.isStreaming && !this._promptStartPending && options?.streamingBehavior === undefined; if (ownsPromptStart) this._promptStartPending = true; @@ -3536,22 +2593,9 @@ export class AgentSession { (this.isStreaming || this._promptStartPending) && !this.isCompacting && options?.streamingBehavior !== undefined; - // Auto-compaction claims only _autoCompactionAbortController, which the - // admission guard above deliberately ignores so background compaction never - // rejects typed input. Without a queue route that message matched no branch - // below and fell through neither queued nor started (field bug: input typed - // while the TUI showed "Compacting context..." was accepted and dropped). - // Manual compaction keeps its fail-closed admission path untouched. - const canQueueDuringAutoCompaction = - this._autoCompactionAbortController !== undefined && - this._compactionAbortController === undefined && - !this.isStreaming && - !this._promptStartPending && - options?.streamingBehavior !== undefined; if ( shouldWaitForSessionWork && !canQueueWhileStreaming && - !canQueueDuringAutoCompaction && (!this.isStreaming || this.isCompacting || this._sessionWorkBarrier.hasActiveWork) ) { await this._waitForSettledSessionWork(); @@ -3576,49 +2620,25 @@ export class AgentSession { let titlePrompt: string | undefined; let consumedNextTurnMessages: CustomMessage[] | undefined; let inputId: string | undefined; - let pendingCommandInvocation: CommandInvocation | undefined; - const emitPendingCommandInvocation = (): void => { - if (!pendingCommandInvocation) return; - this._emit({ - type: "command_invocation", - command: pendingCommandInvocation, - }); - pendingCommandInvocation = undefined; - }; const emitInputDisposition = async ( disposition: "handled" | "queued" | "started" | "rejected", ): Promise => { if (inputId === undefined) return; - await this._extensionRunner.emit({ - type: "input_disposition", - inputId, - disposition, - }); + await this._extensionRunner.emit({ type: "input_disposition", inputId, disposition }); }; try { - // Bare "." on a session that already has messages is the manual-continue - // shortcut: it never becomes a visible user turn. Route it through the - // hidden custom-message path before extensions see an input event, so a - // "." cannot be echoed, transformed, or persisted as literal text. An - // empty session, or a "." carrying image attachments (the user is sending - // the images, not asking to continue), falls through to ordinary prompt - // handling below. - if (text.trim() === "." && this.agent.state.messages.length > 0 && !options?.images?.length) { - await this.sendCustomMessage( - { - customType: MANUAL_CONTINUE_CUSTOM_TYPE, - content: MANUAL_CONTINUE_DIRECTIVE, - display: false, - }, - { - triggerTurn: true, - deliverAs: options?.streamingBehavior === "followUp" ? "followUp" : "steer", - }, - ); - promptDisposition?.("handled"); - preflightResult?.(true); - return; + // Handle extension commands first (execute immediately, even during streaming) + // Extension commands manage their own LLM interaction via pi.sendMessage() + if (expandPromptTemplates && text.startsWith("/")) { + const handled = await this._tryExecuteExtensionCommand(text); + throwIfCancelled(); + if (handled) { + // Extension command executed, no prompt to send + promptDisposition?.("handled"); + preflightResult?.(true); + return; + } } // Emit input event for extension interception (before skill/template expansion) @@ -3650,16 +2670,7 @@ export class AgentSession { let expandedText = currentText; if (expandPromptTemplates) { expandedText = this._expandSkillCommand(expandedText); - const templateExpansion = expandPromptTemplateWithMetadata(expandedText, [...this.promptTemplates]); - expandedText = templateExpansion.text; - if (templateExpansion.template) { - pendingCommandInvocation = { - name: templateExpansion.template.name, - source: "prompt", - sourceInfo: templateExpansion.template.sourceInfo, - syntax: "slash", - }; - } + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); } titlePrompt = options?.sessionTitlePrompt === false ? undefined : (options?.sessionTitlePrompt ?? text); @@ -3678,7 +2689,6 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - emitPendingCommandInvocation(); await emitInputDisposition("queued"); promptDisposition?.("queued"); preflightResult?.(true); @@ -3697,27 +2707,6 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - emitPendingCommandInvocation(); - await emitInputDisposition("queued"); - promptDisposition?.("queued"); - preflightResult?.(true); - return; - } - - // Auto-compaction owns the session without claiming the admission controller, - // so a queueable submission reaches here with no branch above matching and - // would fall through neither queued nor started. Queue it instead of starting - // a turn against a context that is still being compacted. - if (canQueueDuringAutoCompaction && !this.isStreaming) { - if (options?.thinkingLevel !== undefined) { - throw new Error("Cannot set thinkingLevel on a queued prompt; set it after the current turn completes."); - } - if (options?.streamingBehavior === "followUp") { - await this._queueFollowUp(expandedText, currentImages); - } else { - await this._queueSteer(expandedText, currentImages); - } - emitPendingCommandInvocation(); await emitInputDisposition("queued"); promptDisposition?.("queued"); preflightResult?.(true); @@ -3746,7 +2735,6 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - emitPendingCommandInvocation(); await emitInputDisposition("queued"); promptDisposition?.("queued"); preflightResult?.(true); @@ -3770,7 +2758,6 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - emitPendingCommandInvocation(); await emitInputDisposition("queued"); promptDisposition?.("queued"); preflightResult?.(true); @@ -3879,7 +2866,6 @@ export class AgentSession { } promptDisposition?.("started"); - emitPendingCommandInvocation(); preflightResult?.(true); if (options?.thinkingLevel !== undefined) { this.setSessionThinkingLevel(options.thinkingLevel); @@ -3914,15 +2900,6 @@ export class AgentSession { const command = this._extensionRunner.getCommand(commandName); if (!command) return false; - this._emit({ - type: "command_invocation", - command: { - name: commandName, - source: "extension", - sourceInfo: command.sourceInfo, - syntax: "slash", - }, - }); // Get command context from extension runner (includes session control methods) const ctx = this._extensionRunner.createCommandContext(); @@ -3942,79 +2919,68 @@ export class AgentSession { } /** - * Expand explicit skill invocations to their full content. - * Leading runs accept slash and dollar syntax; inline expansion is limited to - * the desktop's explicit `$skill:name` token so ordinary dollar prose stays literal. + * Expand a leading run of skill commands (/skill:name /skill:other args) to their full content. + * Returns the expanded text, or the original text if the first skill command is not found. + * Emits errors via extension runner if file reads fail or the expansion cap is reached. */ private _expandSkillCommand(text: string): string { - const invocationTokens = parseSkillInvocationTokens(text); - if (invocationTokens.length === 0) return text; + if (!text.startsWith("/skill:")) return text; const skills = this.resourceLoader.getSkills().skills; const expandedSkillNames = new Set(); - const skillBlocks: SkillInvocationPromptSkill[] = []; - const invocationMetadata: Array<{ - name: string; - path: string; - syntax: SkillInvocationSyntax; - }> = []; - const removedTokens: SkillInvocationToken[] = []; - - for (const token of invocationTokens) { - const skill = skills.find((candidate) => candidate.name === token.name); - if (!skill) { - if (token.position === "leading") break; - continue; - } - - if (skillBlocks.length >= MAX_SKILL_EXPANSIONS_PER_PROMPT) { - this._extensionRunner.emitError({ - extensionPath: "skill:expansion", - event: "skill_expansion", - error: `Expanded at most ${MAX_SKILL_EXPANSIONS_PER_PROMPT} skills; remaining skill commands were left as literal text.`, - }); - break; - } + const skillBlocks: string[] = []; + let tokenStart = 0; + + while (tokenStart < text.length) { + let tokenEnd = tokenStart; + while (tokenEnd < text.length && !/\s/.test(text[tokenEnd]!)) { + tokenEnd += 1; + } + const token = text.slice(tokenStart, tokenEnd); + if (!token.startsWith("/skill:")) break; + + const skillName = token.slice(7); + const skill = skills.find((candidate) => candidate.name === skillName); + if (!skill) break; // Unknown skills and everything after them remain literal text. + + if (!expandedSkillNames.has(skill.name)) { + if (skillBlocks.length >= MAX_SKILL_EXPANSIONS_PER_PROMPT) { + this._extensionRunner.emitError({ + extensionPath: "skill:expansion", + event: "skill_expansion", + error: `Expanded at most ${MAX_SKILL_EXPANSIONS_PER_PROMPT} skills; remaining skill commands were left as literal text.`, + }); + break; + } - removedTokens.push(token); - if (expandedSkillNames.has(skill.name)) { - this._extensionRunner.emitError({ - extensionPath: skill.filePath, - event: "skill_expansion", - error: `Skipped duplicate skill invocation: ${skill.name}`, - }); - continue; + try { + const content = readFileSync(skill.filePath, "utf-8"); + const body = stripFrontmatter(content).trim(); + skillBlocks.push( + `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`, + ); + expandedSkillNames.add(skill.name); + } catch (err) { + this._extensionRunner.emitError({ + extensionPath: skill.filePath, + event: "skill_expansion", + error: err instanceof Error ? err.message : String(err), + }); + return text; // Return the original prompt when any skill file cannot be read. + } } - try { - const content = readFileSync(skill.filePath, "utf-8"); - const body = stripFrontmatter(content).trim(); - skillBlocks.push({ - name: skill.name, - filePath: skill.filePath, - baseDir: skill.baseDir, - body, - }); - expandedSkillNames.add(skill.name); - invocationMetadata.push({ - name: skill.name, - path: skill.filePath, - syntax: token.syntax, - }); - } catch (err) { - this._extensionRunner.emitError({ - extensionPath: skill.filePath, - event: "skill_expansion", - error: err instanceof Error ? err.message : String(err), - }); - return text; // Return the original prompt when any skill file cannot be read. + tokenStart = tokenEnd; + while (tokenStart < text.length && /\s/.test(text[tokenStart]!)) { + tokenStart += 1; } } if (skillBlocks.length === 0) return text; - const userRequest = removeSkillInvocationTokens(text, removedTokens); - this._emit({ type: "skill_invocation", skills: invocationMetadata }); - return formatSkillInvocationPrompt(skillBlocks, userRequest); + + const args = text.slice(tokenStart).trim(); + const expandedSkills = skillBlocks.join("\n\n"); + return args ? `${expandedSkills}\n\n${args}` : expandedSkills; } /** @@ -4033,21 +2999,9 @@ export class AgentSession { // Expand skill commands and prompt templates let expandedText = this._expandSkillCommand(text); - const templateExpansion = expandPromptTemplateWithMetadata(expandedText, [...this.promptTemplates]); - expandedText = templateExpansion.text; + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); await this._queueSteer(expandedText, images, recovery?.enqueueOrder); - if (templateExpansion.template) { - this._emit({ - type: "command_invocation", - command: { - name: templateExpansion.template.name, - source: "prompt", - sourceInfo: templateExpansion.template.sourceInfo, - syntax: "slash", - }, - }); - } } /** @@ -4065,21 +3019,9 @@ export class AgentSession { // Expand skill commands and prompt templates let expandedText = this._expandSkillCommand(text); - const templateExpansion = expandPromptTemplateWithMetadata(expandedText, [...this.promptTemplates]); - expandedText = templateExpansion.text; + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); await this._queueFollowUp(expandedText, images, recovery?.enqueueOrder); - if (templateExpansion.template) { - this._emit({ - type: "command_invocation", - command: { - name: templateExpansion.template.name, - source: "prompt", - sourceInfo: templateExpansion.template.sourceInfo, - syntax: "slash", - }, - }); - } } private _startSessionTitleGeneration(firstPrompt: string): void { @@ -4104,7 +3046,7 @@ export class AgentSession { abortController: AbortController, ): Promise { try { - const auth = await this._getSummarizationRequestAuth(model); + const auth = await this._getCompactionRequestAuth(model); const title = await generateSessionTitle({ firstPrompt, model, @@ -4163,20 +3105,13 @@ export class AgentSession { private async _queueSteer(text: string, images?: ImageContent[], enqueueOrder?: number): Promise { this._steeringMessages.push(text); this._recordQueuedInput(text, "steer", enqueueOrder); - this._sessionLogger.debug("queue_enqueue", { - mode: "steer", - count: this._steeringMessages.length, - }); + this._sessionLogger.debug("queue_enqueue", { mode: "steer", count: this._steeringMessages.length }); this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); } - const message: AgentMessage = { - role: "user", - content, - timestamp: Date.now(), - }; + const message: AgentMessage = { role: "user", content, timestamp: Date.now() }; if (this._promptStartPending && this._skipNextPostCompactionAssistantCheck) { this._postCompactionDeferredSteeringMessages.push(message); return; @@ -4190,20 +3125,13 @@ export class AgentSession { private async _queueFollowUp(text: string, images?: ImageContent[], enqueueOrder?: number): Promise { this._followUpMessages.push(text); this._recordQueuedInput(text, "followUp", enqueueOrder); - this._sessionLogger.debug("queue_enqueue", { - mode: "followUp", - count: this._followUpMessages.length, - }); + this._sessionLogger.debug("queue_enqueue", { mode: "followUp", count: this._followUpMessages.length }); this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); } - const message: AgentMessage = { - role: "user", - content, - timestamp: Date.now(), - }; + const message: AgentMessage = { role: "user", content, timestamp: Date.now() }; if (this._promptStartPending && this._skipNextPostCompactionAssistantCheck) { this._postCompactionDeferredFollowUpMessages.push(message); return; @@ -4251,11 +3179,7 @@ export class AgentSession { */ async sendCustomMessage( message: Pick, "customType" | "content" | "display" | "details">, - options?: { - triggerTurn?: boolean; - deliverAs?: "steer" | "followUp" | "nextTurn"; - }, - deferredTurnClaim?: DeferredTurnClaim, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { const userAbortGeneration = this._userAbortGeneration; const appMessage = { @@ -4271,11 +3195,7 @@ export class AgentSession { options?.triggerTurn === true && options.deliverAs !== "nextTurn" && !this.isStreaming && - this._sessionWorkBarrier.hasActiveWork && - // The session-start binding itself holds the barrier while it emits - // session_start; a triggerTurn message queued from that emission (e.g. a - // goal continuation) must not wait on the very work that is delivering it. - this._extensionBindingPromptReadiness === undefined; + this._sessionWorkBarrier.hasActiveWork; const pendingCompactionAdmission = this._pendingCompactionAdmission; const activeCompactionGeneration = this._compactionLifecycle.state.status === "running" ? this._compactionLifecycle.state.generation : undefined; @@ -4289,8 +3209,7 @@ export class AgentSession { if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); - } else if (this.isStreaming && options?.triggerTurn !== false) { - deferredTurnClaim?.resolve("delegated"); + } else if (this.isStreaming) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { @@ -4303,7 +3222,6 @@ export class AgentSession { this._compactionLifecycle.state.generation === activeCompactionGeneration && this._compactionLifecycle.state.status !== "completed")) ) { - deferredTurnClaim?.resolve("delegated"); if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { @@ -4325,39 +3243,24 @@ export class AgentSession { } throw error; } - await this._promptAgent(appMessage, deferredTurnClaim); - } else if (this.isStreaming) { - this._pendingCustomMessages.push(appMessage); + await this._promptAgent(appMessage); } else { - this._appendCustomMessage(appMessage); + this.agent.state.messages.push(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + this._incrementMessageRevision(); + this._emit({ type: "message_start", message: appMessage }); + this._emit({ type: "message_end", message: appMessage }); } } finally { - deferredTurnClaim?.resolve("finished-without-start"); finishSessionWork?.(); } } - private _appendCustomMessage(appMessage: CustomMessage): void { - this.agent.state.messages.push(appMessage); - this._emitEntryAppended( - this.sessionManager.appendCustomMessageEntry( - appMessage.customType, - appMessage.content, - appMessage.display, - appMessage.details, - ), - ); - this._incrementMessageRevision(); - this._emit({ type: "message_start", message: appMessage }); - this._emit({ type: "message_end", message: appMessage }); - } - - private _flushPendingCustomMessages(): void { - const pending = this._pendingCustomMessages; - this._pendingCustomMessages = []; - for (const message of pending) this._appendCustomMessage(message); - } - /** * Send a user message to the agent. Always triggers a turn. * When the agent is streaming, use deliverAs to specify how to queue the message. @@ -4367,15 +3270,10 @@ export class AgentSession { * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" - * @param options.expandPromptTemplates Whether to dispatch extension commands and expand skill commands and prompt templates. Default: false. */ async sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { - deliverAs?: "steer" | "followUp"; - expandPromptTemplates?: boolean; - }, - deferredTurnClaim?: DeferredTurnClaim, + options?: { deliverAs?: "steer" | "followUp" }, ): Promise { const bindingPromptReadiness = this._extensionBindingPromptReadiness; let resolveBindingPromptReadiness: (() => void) | undefined; @@ -4385,32 +3283,24 @@ export class AgentSession { }); bindingPromptReadiness.add(readiness); } - // Normalize content to text string + optional images. A throw here (null or a - // content array whose iterator/part getter throws) happens before the guarded - // try below, so resolve the deferred-turn claim first to keep agent_idle reachable. + // Normalize content to text string + optional images let text: string; let images: ImageContent[] | undefined; - try { - 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); - } + 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; } - } catch (error) { - deferredTurnClaim?.resolve("finished-without-start"); - resolveBindingPromptReadiness?.(); - throw error; + text = textParts.join("\n"); + if (images.length === 0) images = undefined; } // An extension binding invokes this method fire-and-forget. When it @@ -4420,15 +3310,14 @@ export class AgentSession { let finishSessionWork: (() => void) | undefined; let disposition: PromptDisposition | undefined; try { + // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { - expandPromptTemplates: options?.expandPromptTemplates ?? false, + expandPromptTemplates: false, streamingBehavior: options?.deliverAs, images, source: "extension", promptDisposition: (nextDisposition) => { disposition = nextDisposition; - if (nextDisposition === "started") deferredTurnClaim?.resolve("started"); - else if (nextDisposition === "queued") deferredTurnClaim?.resolve("delegated"); resolveBindingPromptReadiness?.(); }, onSessionWorkReady: waitForExistingSessionWork @@ -4450,9 +3339,6 @@ export class AgentSession { } throw error; } finally { - // A path that neither started nor delegated a turn (handled, rejected, - // admission failure, cancellation) resolves as finished-without-start. - deferredTurnClaim?.resolve("finished-without-start"); resolveBindingPromptReadiness?.(); finishSessionWork?.(); } @@ -4498,10 +3384,7 @@ export class AgentSession { this.agent.clearAllQueues(); this._emitQueueUpdate(); const cleared = { steering, followUp } as ClearedQueue; - Object.defineProperty(cleared, "ordered", { - value: ordered, - enumerable: false, - }); + Object.defineProperty(cleared, "ordered", { value: ordered, enumerable: false }); return cleared; } @@ -4609,47 +3492,6 @@ export class AgentSession { return this._setModel(model, true); } - assertModelUsable( - model: Model | undefined = this.model, - liveContextTokens = 0, - options?: { includeSpeculationLead?: boolean; admission?: ModelUsabilityAdmission }, - ): void { - if (!model || model.contextWindow <= 0) return; - const projection = projectModelUsabilityBudget({ - model, - systemPrompt: this.agent.state.systemPrompt, - tools: this.agent.state.tools, - liveContextTokens, - compaction: this.settingsManager.getCompactionSettings(), - includeSpeculationLead: options?.includeSpeculationLead, - admission: options?.admission, - }); - if (!projection.usable) throw new ModelUsabilityBudgetError(projection); - } - - private _getDownswitchLiveContextTokens(model: Model): number { - const currentModel = this.model; - if (!currentModel) return 0; - const compaction = this.settingsManager.getCompactionSettings(); - const currentBudget = projectModelUsabilityBudget({ - model: currentModel, - systemPrompt: this.agent.state.systemPrompt, - tools: this.agent.state.tools, - compaction, - }); - const targetBudget = projectModelUsabilityBudget({ - model, - systemPrompt: this.agent.state.systemPrompt, - tools: this.agent.state.tools, - compaction, - }); - const currentUsableContext = currentBudget.contextWindow - currentBudget.requiredTokens; - const targetUsableContext = targetBudget.contextWindow - targetBudget.requiredTokens; - if (targetUsableContext >= currentUsableContext) return 0; - const fixedPrefixTokens = currentBudget.systemPromptTokens + currentBudget.activeToolSchemaTokens; - return Math.max(0, (this.getContextUsage()?.tokens ?? 0) - fixedPrefixTokens); - } - /** * Set the model for this session without changing the global model defaults. * The selection is still persisted in this session's history. @@ -4662,7 +3504,6 @@ export class AgentSession { model: Model, updateGlobalDefaults: boolean, ): Promise { - this.assertModelUsable(model, this._getDownswitchLiveContextTokens(model)); if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -4694,17 +3535,6 @@ export class AgentSession { } } - /** - * A senpi-owned compaction just rewrote the conversation context: release any - * refusal-caused fallback pin (the "same context refuses again" assumption no - * longer holds) and, while no run is active, eagerly re-attempt the original - * model through the existing revert gate. Billing-caused pins never release. - */ - private async _onCompactionContextChanged(): Promise { - const released = this._retryFallback.notifyCompactionApplied(); - if (released && !this.isStreaming) await this._maybeRestoreFallbackPrimary(); - } - private async _switchActiveModel( model: Model, opts: { @@ -4718,20 +3548,11 @@ export class AgentSession { }, ): Promise { const previousModel = this.model; - if ( - opts.invalidateCompaction && - (this._modelSelectionChangesContext(previousModel, model) || - previousModel?.provider !== model.provider || - previousModel?.id !== model.id) - ) { + if (opts.invalidateCompaction && this._modelSelectionChangesContext(previousModel, model)) { this._invalidateCompactionForModelSelection(); } - const thinking = this._getThinkingForModelSwitch(model, opts.ephemeralThinkingLevel); - const liveContextTokens = this._getDownswitchLiveContextTokens(model); + const thinkingLevel = this._getThinkingLevelForModelSwitch(opts.ephemeralThinkingLevel); this.agent.state.model = model; - if (!(model.id === "gpt-6-astra" && (model.provider === "openai" || model.provider === "openai-codex"))) { - this.agent.state.reasoningBaseline = undefined; - } this.agent.abortServerSideFallback = this.settingsManager.getAbortServerSideFallback() && this._retryFallback.hasConfiguredChain(); if (opts.appendSessionEntry) { @@ -4748,45 +3569,23 @@ export class AgentSession { } const scopedMatch = this._scopedModels.find((sm) => modelsAreEqual(sm.model, model)); - const previousTier = this._currentServiceTier; - const previousFastMode = this.isFastModeActive(); this._currentServiceTier = this._resolveServiceTier(model, scopedMatch?.serviceTier); if (opts.ephemeralThinkingLevel !== undefined) { - this._applyEphemeralThinkingLevel(thinking.level); + this._applyEphemeralThinkingLevel(thinkingLevel); } else { - this._setThinkingLevel(thinking.level, false, thinking.selection); + this.setSessionThinkingLevel(thinkingLevel); } this._emitHighReasoningWarningIfNeeded(); - // Post-switch: the level reported here is the one actually in force (clamped, or restored - // from this model's memory), not the level requested for the previous model. - this._emit({ - type: "model_changed", - model, - thinkingLevel: this.thinkingLevel, - source: opts.modelSelectSource, - }); - this._emitServiceTierChangeIfNeeded(previousTier, previousFastMode); if (!opts.emitModelSelect) return undefined; - const previousSystemPrompt = this.agent.state.systemPrompt; - try { - const systemPromptChange = await this._emitModelSelect(model, previousModel, opts.modelSelectSource); - this.assertModelUsable(model, liveContextTokens); - return systemPromptChange; - } catch (error) { - if (previousModel) this.agent.state.model = previousModel; - else delete (this.agent.state as { model?: Model }).model; - this.agent.state.systemPrompt = previousSystemPrompt; - throw error; - } + return await this._emitModelSelect(model, previousModel, opts.modelSelectSource); } private _applyEphemeralThinkingLevel(level: ThinkingLevel): void { const previousLevel = this.agent.state.thinkingLevel; this.agent.state.thinkingLevel = level; - this.agent.state.thinkingSelection = undefined; if (previousLevel !== level) { this._emit({ type: "thinking_level_changed", level }); } @@ -4813,7 +3612,6 @@ export class AgentSession { if (favoriteModels.length <= 1) return undefined; const currentModel = this.model; - if (!currentModel) return undefined; const currentIndex = favoriteModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel)); let nextIndex: number; @@ -4823,102 +3621,33 @@ export class AgentSession { const len = favoriteModels.length; nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; } - const favoriteCount = favoriteModels.length; - const step = direction === "forward" ? 1 : -1; - let selectedIndex: number | undefined; - const skippedModels: Model[] = []; - for (let offset = 0; offset < favoriteCount; offset++) { - const candidate = favoriteModels[(nextIndex + step * offset + favoriteCount) % favoriteCount]; - if (modelsAreEqual(candidate.model, currentModel)) continue; - const admission = this._modelChangeWouldExhaustContext(candidate.model); - if (admission) { - skippedModels.push(candidate.model); - this._emit({ - type: "model_change_skipped", - model: candidate.model, - contextWindow: candidate.model.contextWindow, - liveContextTokens: admission.liveContextTokens, - requiredTokens: admission.requiredTokens, - shortfallTokens: admission.shortfallTokens, - safetyMarginProfile: admission.safetyMarginProfile, - direction, - }); - continue; - } - selectedIndex = (nextIndex + step * offset + favoriteCount) % favoriteCount; - break; - } - if (selectedIndex === undefined) { - // Every candidate was inadmissible. Skipping only makes sense when there was somewhere else - // to skip TO: with more than one alternative the cycle reports the skipped list and stays put - // (#1378's convenience). With a single alternative the user asked for THAT model, so the - // documented ModelUsabilityBudgetError still surfaces instead of a silent no-op. - const alternatives = favoriteModels.filter((entry) => !modelsAreEqual(entry.model, currentModel)); - const onlyAlternative = alternatives.length === 1 ? alternatives[0] : undefined; - if (onlyAlternative) { - this.assertModelUsable(onlyAlternative.model, this._getDownswitchLiveContextTokens(onlyAlternative.model)); - } - return { - model: currentModel, - thinkingLevel: this.thinkingLevel, - isScoped: true, - skippedModels, - }; - } - const next = favoriteModels[selectedIndex]; - const liveContextTokens = this._getDownswitchLiveContextTokens(next.model); - this.assertModelUsable(next.model, liveContextTokens); - const invalidatesCompaction = - this._modelSelectionChangesContext(currentModel, next.model) || - currentModel?.provider !== next.model.provider || - currentModel?.id !== next.model.id; + const next = favoriteModels[nextIndex]; + const invalidatesCompaction = this._modelSelectionChangesContext(currentModel, next.model); if (invalidatesCompaction) { this._invalidateCompactionForModelSelection(); } this._probeBackScheduler.cancel("manual-model-change"); this._retryFallback.clearForManualModelChange(next.model); - const thinking = this._getThinkingForModelSwitch(next.model, next.thinkingLevel, next.thinkingSelection); + const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); this.agent.state.model = next.model; this.sessionManager.appendModelChange(next.model.provider, next.model.id); this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); - const previousTier = this._currentServiceTier; - const previousFastMode = this.isFastModeActive(); this._currentServiceTier = this._resolveServiceTier(next.model, next.serviceTier); - // Apply thinking level and provenance from the favorite projection or remembered preference. - this._setThinkingLevel(thinking.level, false, thinking.selection); + // Apply thinking level. + // - Explicit favorite model thinking level overrides the effective session level + // - Undefined favorite model thinking level restores the remembered user preference + // setSessionThinkingLevel clamps without replacing that preference. + this.setSessionThinkingLevel(thinkingLevel); - // Post-switch, same contract as _switchActiveModel: the level in force AFTER the cycle. - this._emit({ - type: "model_changed", - model: next.model, - thinkingLevel: this.thinkingLevel, - source: "cycle", - }); - this._emitServiceTierChangeIfNeeded(previousTier, previousFastMode); + const systemPromptChange = await this._emitModelSelect(next.model, currentModel, "cycle"); - const previousSystemPrompt = this.agent.state.systemPrompt; - try { - const systemPromptChange = await this._emitModelSelect(next.model, currentModel, "cycle"); - this.assertModelUsable(next.model, liveContextTokens); - - const cycleResult: ModelCycleResult = { - model: next.model, - thinkingLevel: this.thinkingLevel, - isScoped: true, - skippedModels, - }; - if (systemPromptChange) { - cycleResult.systemPromptChange = systemPromptChange; - } - return cycleResult; - } catch (error) { - if (currentModel) this.agent.state.model = currentModel; - else delete (this.agent.state as { model?: Model }).model; - this.agent.state.systemPrompt = previousSystemPrompt; - throw error; + const cycleResult: ModelCycleResult = { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; + if (systemPromptChange) { + cycleResult.systemPromptChange = systemPromptChange; } + return cycleResult; } // ========================================================================= @@ -4928,10 +3657,10 @@ export class AgentSession { /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. - * Persistent calls refresh per-model memory; session entries and events are emitted only on change. + * Saves to session and settings only if the level actually changes. */ setThinkingLevel(level: ThinkingLevel): void { - this._setThinkingLevel(level, true, { level, source: "explicit" }); + this._setThinkingLevel(level, true); } /** @@ -4939,52 +3668,24 @@ export class AgentSession { * The effective level is still persisted in this session's history. */ setSessionThinkingLevel(level: ThinkingLevel): void { - this._setThinkingLevel(level, false, { level, source: "explicit" }); + this._setThinkingLevel(level, false); } - private _setThinkingLevel( - level: ThinkingLevel, - updateGlobalDefault: boolean, - selection: ThinkingSelection | undefined, - ): void { + private _setThinkingLevel(level: ThinkingLevel, updateGlobalDefault: boolean): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); // Only persist if actually changing const previousLevel = this.agent.state.thinkingLevel; - const previousSelection = this.agent.state.thinkingSelection; - const effectiveSelection = selection ? { ...selection, level: effectiveLevel } : undefined; - const selectionChanged = - previousSelection?.level !== effectiveSelection?.level || - previousSelection?.source !== effectiveSelection?.source || - previousSelection?.legacyVariantId !== effectiveSelection?.legacyVariantId; const isChanging = effectiveLevel !== previousLevel; - if (isChanging || selectionChanged) { + if (isChanging) { this._retryFallback.noteManualThinkingLevel(); } this.agent.state.thinkingLevel = effectiveLevel; - this.agent.state.thinkingSelection = effectiveSelection; - if (updateGlobalDefault) { - const model = this.model; - if (model) { - this.settingsManager.setModelThinkingLevel(model.provider, model.id, effectiveLevel); - } - } - - if (isChanging || selectionChanged) { - this.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveSelection); - const model = this.model; - if ( - isChanging && - model?.id === "gpt-6-astra" && - (model.provider === "openai" || model.provider === "openai-codex") - ) { - this.agent.state.reasoningBaseline ??= previousLevel; - this.sessionManager.appendConfigurationUpdate(effectiveLevel); - this.agent.state.messages = this.sessionManager.buildSessionContext().messages; - } + if (isChanging) { + this.sessionManager.appendThinkingLevelChange(effectiveLevel); if (updateGlobalDefault && (this.supportsThinking() || effectiveLevel !== "off")) { this.settingsManager.setDefaultThinkingLevel(effectiveLevel); } @@ -5060,34 +3761,11 @@ export class AgentSession { return !!this.model?.reasoning; } - private _getThinkingForModelSwitch( - model: Model, - explicitLevel?: ThinkingLevel, - explicitSelection?: ThinkingSelection, - ): { level: ThinkingLevel; selection?: ThinkingSelection } { - let requestedLevel = explicitLevel; - let selection = explicitSelection; - if (requestedLevel !== undefined && !selection) selection = { level: requestedLevel, source: "explicit" }; - if (requestedLevel === undefined) { - const remembered = this.settingsManager.getModelThinkingLevel(model.provider, model.id); - if (remembered !== undefined) { - requestedLevel = remembered; - selection = { level: remembered, source: "explicit" }; - } - } - if (requestedLevel === undefined) { - const configuredDefault = this.settingsManager.getDefaultThinkingLevel(); - if (configuredDefault !== undefined) { - requestedLevel = configuredDefault; - selection = { level: configuredDefault, source: "explicit" }; - } - } - requestedLevel ??= DEFAULT_THINKING_LEVEL; - const level = this._clampThinkingLevel(requestedLevel, getSupportedThinkingLevels(model) as ThinkingLevel[]); - return { - level, - selection: selection ? { ...selection, level } : undefined, - }; + private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + if (explicitLevel !== undefined) { + return explicitLevel; + } + return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL; } private _clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel { @@ -5122,7 +3800,6 @@ export class AgentSession { setSteeringMode(mode: "all" | "one-at-a-time"): void { this.agent.steeringMode = mode; this.settingsManager.setSteeringMode(mode); - this._emitSessionSettingsChanged(); } /** @@ -5132,7 +3809,6 @@ export class AgentSession { setFollowUpMode(mode: "all" | "one-at-a-time"): void { this.agent.followUpMode = mode; this.settingsManager.setFollowUpMode(mode); - this._emitSessionSettingsChanged(); } // ========================================================================= @@ -5248,141 +3924,12 @@ export class AgentSession { } } - /** Generate the built-in summary while preserving fork routing identity and transforms. */ - private async _runDefaultCompaction( - preparation: CompactionPreparation, - requestModel: Model, - apiKey: string | undefined, - headers: Record | undefined, - extraBody: Record | undefined, - customInstructions: string | undefined, - signal: AbortSignal, - env: Record | undefined, - reason: CompactionReason, - ): Promise { - let cacheFriendly: CacheFriendlySummaryOptions | undefined; - - if (areExperimentalFeaturesEnabled()) { - const systemPrompt = this.agent.state.systemPrompt; - const tools = this.agent.state.tools.slice(); - const buildSourceContext = (messages: AgentMessage[]) => - this.agent.buildProviderContext({ systemPrompt, messages: messages.slice(), tools: tools.slice() }, signal); - const fullContext = await buildSourceContext(this.agent.state.messages); - const isExactPrefix = (candidate: Context): boolean => - candidate.messages.length > 0 && - candidate.messages.length <= fullContext.messages.length && - isDeepStrictEqual(candidate.messages, fullContext.messages.slice(0, candidate.messages.length)); - - let sourceContext: Context | undefined; - if (preparation.messagesToSummarize.length > 0 && preparation.sourceMessages) { - const candidate = await buildSourceContext(preparation.sourceMessages); - if (isExactPrefix(candidate)) sourceContext = candidate; - } - - let turnPrefixSourceContext: Context | undefined; - if ( - preparation.isSplitTurn && - preparation.turnPrefixMessages.length > 0 && - preparation.turnPrefixSourceMessages - ) { - const candidate = await buildSourceContext(preparation.turnPrefixSourceMessages); - const providerTurnPrefix = await this.agent.convertToLlm(preparation.turnPrefixMessages.slice()); - if ( - isExactPrefix(candidate) && - providerTurnPrefix.length > 0 && - providerTurnPrefix.length <= candidate.messages.length && - isDeepStrictEqual(candidate.messages.slice(-providerTurnPrefix.length), providerTurnPrefix) - ) { - turnPrefixSourceContext = candidate; - } - } - - if (sourceContext || turnPrefixSourceContext) { - cacheFriendly = { - sourceContext, - turnPrefixSourceContext, - requestOptions: { - sessionId: this.agent.sessionId, - onPayload: this.agent.onPayload, - onResponse: this.agent.onResponse, - transport: this.agent.transport, - thinkingBudgets: this.agent.thinkingBudgets, - maxRetryDelayMs: this.agent.maxRetryDelayMs, - }, - }; - } - } - - return compact( - preparation, - requestModel, - apiKey, - headers, - customInstructions, - signal, - extraBody, - this.thinkingLevel, - this.agent.streamFunction, - env, - this.agent.transformContext, - this.settingsManager.getRetrySettings(), - this._summarizationRetryCallbacks({ source: "compaction", reason }), - this.sessionManager.getSessionId(), - cacheFriendly, - ); - } - /** * Manually compact the session context. - * - * This is the manual entry point used by `/compact`, RPC, and extensions. It is - * separate from automatic threshold/overflow compaction, which enters through - * `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the - * `session_before_compact` hook, both paths call the lower-level `compact()` - * function imported from `./compaction/index.ts`, unless the hook cancels or - * supplies a custom result. - * - * Aborts the current agent operation first. Manual compaction never retries or - * continues the interrupted agent turn. - * + * Aborts current agent operation first. * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { - const model = this.model; - if (!model) throw new Error(formatNoModelSelectedMessage()); - const pathEntries = this.sessionManager.getBranch(); - const settings = cursorOverflowCompactionSettings( - this.settingsManager.getCompactionSettings(), - model.provider, - "manual", - ); - if (!prepareCompaction(pathEntries, settings)) { - const requestId = randomUUID(); - const lastEntry = pathEntries[pathEntries.length - 1]; - const error = new Error( - lastEntry?.type === "compaction" ? "Already compacted" : "Nothing to compact (session too small)", - ); - const errorMessage = `Compaction failed: ${error.message}`; - this._emit({ type: "compaction_start", reason: "manual", requestId }); - this._emit({ - type: "compaction_end", - reason: "manual", - result: undefined, - aborted: false, - willRetry: false, - requestId, - errorMessage, - }); - await this._emitSessionCompactFailed({ - reason: "manual", - errorMessage, - aborted: false, - willRetry: false, - fromExtension: false, - }); - throw error; - } - const admission = this._claimPendingCompactionAdmission(); const controller = admission.controller; const requestId = randomUUID(); @@ -5420,7 +3967,6 @@ export class AgentSession { } const message = error instanceof Error ? error.message : String(error); const aborted = isCompactionExecutionAborted(error); - const errorMessage = aborted ? undefined : `Compaction failed: ${message}`; this._emit({ type: "compaction_end", reason: "manual", @@ -5428,14 +3974,7 @@ export class AgentSession { aborted, willRetry: false, requestId, - errorMessage, - }); - await this._emitSessionCompactFailed({ - reason: "manual", - errorMessage, - aborted, - willRetry: false, - fromExtension: false, + errorMessage: aborted ? undefined : `Compaction failed: ${message}`, }); throw error; } finally { @@ -5458,12 +3997,6 @@ export class AgentSession { if (options.expectedRevision !== undefined && options.expectedRevision !== this._messageRevision) { return { applied: false, reason: "stale" }; } - if ( - options.expectedWarmAnchor !== undefined && - !isWarmSummaryAnchorValid(options.expectedWarmAnchor, this.sessionManager.getBranch()) - ) { - return { applied: false, reason: "stale" }; - } const ownsController = this._compactionAbortController === undefined; const lifecycleState = this._compactionLifecycle.state; @@ -5473,11 +4006,7 @@ export class AgentSession { : randomUUID(); if (ownsController) { this._claimCompactionController(new AbortController(), "compaction"); - this._emit({ - type: "compaction_start", - reason: options.reason, - requestId, - }); + this._emit({ type: "compaction_start", reason: options.reason, requestId }); } const controller = this._compactionAbortController; if (!controller) return { applied: false, reason: "rejected" }; @@ -5591,6 +4120,7 @@ export class AgentSession { private async _executeCompaction(request: CompactionExecutionRequest): Promise { const model = this.model; if (!model) throw new Error(formatNoModelSelectedMessage()); + const thinkingLevel = this.thinkingLevel; const controller = request.controller; // Async auth and provider preparation may yield to a newer route. A stale // controller must never promote itself into a lifecycle generation. @@ -5617,11 +4147,7 @@ export class AgentSession { throw new CompactionCancelledError(); } const pathEntries = this.sessionManager.getBranch(); - const settings = cursorOverflowCompactionSettings( - this.settingsManager.getCompactionSettings(), - this.model?.provider, - request.reason, - ); + const settings = this.settingsManager.getCompactionSettings(); let compactionResult = request.precomputed; let fromExtension = request.precomputed !== undefined; @@ -5685,23 +4211,28 @@ export class AgentSession { } if (!compactionResult) { - const { - model: requestModel, - apiKey, - headers, - extraBody, - env, - } = await this._getCompactionRequestAuth(model); - compactionResult = await this._runDefaultCompaction( + // A configured compaction.model override redirects only the + // summarization call to that model; every other part of the session + // (lifecycle record, preparation, branch) still tracks the session + // model. Falls back to the session model when the override is unset + // or cannot be resolved. + const compactionModel = this._resolveCompactionModel(model); + const { apiKey, headers, extraBody, env } = await this._getCompactionRequestAuth(compactionModel); + compactionResult = await compact( preparation, - requestModel, + compactionModel, apiKey, headers, - extraBody, request.customInstructions, signal, + extraBody, + thinkingLevel, + this.agent.streamFunction, env, - request.reason, + this.agent.transformContext, + this.settingsManager.getRetrySettings(), + this._summarizationRetryCallbacks({ source: "compaction", reason: request.reason }), + this.sessionManager.getSessionId(), ); } } @@ -5739,13 +4270,10 @@ export class AgentSession { return await this._rejectCompaction(request, requestId, operationId, "stale-revision", false); } - if (await this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model)) { + if (this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model)) { return await this._rejectCompaction(request, requestId, operationId, "would-overflow", false); } - const latestConfigurationEffort = this.sessionManager - .getBranch() - .findLast((entry) => entry.type === "configuration_update")?.reasoning.effort; const compactionEntryId = this.sessionManager.appendCompaction( compactionResult.summary, compactionResult.firstKeptEntryId, @@ -5758,14 +4286,6 @@ export class AgentSession { if (savedEntry?.type !== "compaction") { throw new Error("Compaction entry was not saved"); } - const modelAfterCompaction = this.model; - if ( - latestConfigurationEffort !== undefined && - modelAfterCompaction?.id === "gpt-6-astra" && - (modelAfterCompaction.provider === "openai" || modelAfterCompaction.provider === "openai-codex") - ) { - this.sessionManager.appendConfigurationUpdate(latestConfigurationEffort); - } const sessionContext = this.sessionManager.buildSessionContext(); const currentAgentMessages = this.agent.state.messages; @@ -5804,10 +4324,6 @@ export class AgentSession { ) { throw new CompactionCancelledError(); } - this._delegatedCompactionKey = undefined; - if (request.owner === "compaction" && this._compactionAbortController === request.controller) { - this._compactionAbortController = undefined; - } this._emit({ type: "compaction_end", @@ -5829,18 +4345,7 @@ export class AgentSession { willRetry: request.willRetry, }); - // Shared success seam: every senpi-owned compaction apply (manual /compact, - // extension applyCompaction, pre-prompt and auto compaction) funnels through - // here after the compaction entry is appended and the success event emitted. - await this._onCompactionContextChanged(); - - return { - accepted: true, - requestId, - result: compactionResult, - compactionEntry: savedEntry, - fromExtension, - }; + return { accepted: true, requestId, result: compactionResult, compactionEntry: savedEntry, fromExtension }; } catch (error) { if (error instanceof CompactionExecutionError) { throw error; @@ -5862,12 +4367,12 @@ export class AgentSession { } } - private async _wouldCompactionOverflow( + private _wouldCompactionOverflow( pathEntries: SessionEntry[], compactionResult: CompactionResult, fromExtension: boolean, model: Model, - ): Promise { + ): boolean { const currentLeaf = pathEntries[pathEntries.length - 1]; if (!currentLeaf) return false; @@ -5883,22 +4388,13 @@ export class AgentSession { fromHook: fromExtension, }; - let simulatedMessages = buildSessionContext( + const simulatedMessages = buildSessionContext( [...pathEntries, simulatedCompactionEntry], simulatedCompactionEntry.id, ).messages; - // Size the same retained context that will be admitted to Cursor. Persisted JSONL - // remains verbatim, but the in-memory request representation is bounded first. - if (model.provider === "cursor" || model.provider === "cursor-cli-oauth") { - simulatedMessages = truncateToolResultBodies(simulatedMessages).messages ?? simulatedMessages; - } const contextTokens = estimateMessagesTokens(filterContextExcludedMessages(simulatedMessages)); const settings = this.settingsManager.getCompactionSettings(); - const reserveTokens = - settings.reserveScalingEnabled === false - ? settings.reserveTokens - : resolveReserveTokens(model.contextWindow, settings.reserveTokens); - return contextTokens > model.contextWindow - reserveTokens; + return contextTokens > model.contextWindow - settings.reserveTokens; } /** @@ -5934,9 +4430,6 @@ export class AgentSession { // also emitted with accepted:false so the compaction extension's circuit-breaker // bookkeeping stops being dead code; other builtin session_compact handlers guard // on event.accepted. - if (rejectionCause === "external-owner" && this.model) { - this._delegatedCompactionKey = { provider: this.model.provider, id: this.model.id }; - } const trimmedExtensionReason = extensionReason?.trim(); const detailedMessage = trimmedExtensionReason ? `Compaction rejected: ${trimmedExtensionReason}` @@ -6009,8 +4502,8 @@ export class AgentSession { } /** - * Dispatch automatic compaction after `agent_end` or before prompt submission. - * Manual compaction does not call this method; it enters through `compact()`. + * Check if compaction is needed and run it. + * Called after agent_end and before prompt submission. * * Two cases: * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry @@ -6018,7 +4511,6 @@ export class AgentSession { * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true - * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages */ private async _enforceCompactionBeforeProvider( assistantMessage: AssistantMessage | undefined, @@ -6067,9 +4559,7 @@ export class AgentSession { const compacted = await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason); if (compacted) return true; } - if (this._isCompactionOnCooldown() || this._isCompactionDelegated() || this._hasSupersedingCompactionClaim()) { - return false; - } + if (this._isCompactionOnCooldown()) return false; throw new RequiredCompactionError(); } @@ -6081,21 +4571,14 @@ export class AgentSession { * reapply their already-assembled one-shot additions after it succeeds. */ private async _enforceFinalProviderAdmission(messages: readonly AgentMessage[]): Promise { - // User-only prompts are deliberately admitted without this gate: prompt - // admission must never brick on a rejected or cooled-down compaction - // (issues #531/#886), so oversized user prompts rely on threshold - // compaction — which also samples the local transcript estimate — and on - // provider-overflow recovery. This gate closes the separate gap opened by - // turn-local custom additions, which the pre-prompt check cannot observe. + // Normal user-only prompts keep the established provider-overflow recovery + // path. This final gate closes the separate gap introduced by turn-local + // custom additions, which the normal pre-prompt check cannot observe. if (!messages.some((message) => message.role === "custom")) return; const model = this.model; if (!model) return; const settings = this.settingsManager.getCompactionSettings(); - const reserveTokens = - settings.reserveScalingEnabled === false - ? settings.reserveTokens - : resolveReserveTokens(model.contextWindow, settings.reserveTokens); const isOversized = (): boolean => { const providerMessages = filterContextExcludedMessages([...this.agent.state.messages, ...messages]); const estimate = estimateContextTokens(providerMessages); @@ -6107,7 +4590,7 @@ export class AgentSession { usageMessage?.role === "assistant" && this._isAssistantFromBeforeLatestCompaction(usageMessage) ? estimateMessagesTokens(providerMessages) : estimate.tokens; - return contextTokens > model.contextWindow - reserveTokens; + return contextTokens > model.contextWindow - settings.reserveTokens; }; if (!settings.enabled || !isOversized()) return; @@ -6118,8 +4601,6 @@ export class AgentSession { } const compacted = await this._runPrePromptCompaction(lastAssistantMessage, false, "pre_prompt"); - if (!compacted && this._isCompactionDelegated()) return; - if (!compacted && this._hasSupersedingCompactionClaim()) return; if (!compacted && !isOversized() && this._isCompactionOnCooldown()) return; if (!compacted || isOversized()) { throw new RequiredCompactionError(); @@ -6167,11 +4648,8 @@ export class AgentSession { contextUsage !== undefined && contextUsage.tokens !== null && shouldCompact(contextUsage.tokens, contextUsage.contextWindow, settings); - const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0); const isOverflow = - (isContextOverflow(assistantMessage, contextWindow) && (sameModel || currentContextNeedsCompaction)) || - recoverableLength || - this._isCursorPayloadOverflow(assistantMessage); + isContextOverflow(assistantMessage, contextWindow) && (sameModel || currentContextNeedsCompaction); if ( isOverflow && assistantMessage.stopReason === "stop" && @@ -6183,14 +4661,11 @@ export class AgentSession { this._flushPostCompactionDeferredMessages(); const willRetry = retryAfterCompaction || assistantMessage.stopReason !== "stop"; - // Case 2: the response completed successfully. Compact, but do not retry because - // agent.continue() cannot continue from a completed assistant response. if (!willRetry) { const compacted = await this._runAutoCompaction("overflow", false); if ( !compacted && this._compactionLifecycle.state.status === "failed" && - this._compactionLifecycle.state.rejectionCause !== "external-owner" && getLatestCompactionEntry(this.sessionManager.getBranch()) !== null ) { this._blockedPostCompactionAssistant = { @@ -6218,8 +4693,6 @@ export class AgentSession { return false; } - // Case 1: remove the failed or truncated message from agent state, compact, and - // retry once. The message remains in session history but is excluded from retry context. this._overflowRecoveryAttempted = true; // 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) @@ -6237,15 +4710,7 @@ export class AgentSession { this._restoreAgentMessagesFromSession(); this._incrementMessageRevision(); } - if (!compacted && inlineReason && !this._isCompactionDelegated() && !this._hasSupersedingCompactionClaim()) { - if (this._compactionSkippedTooSmall) { - this._compactionSkippedTooSmall = false; - const provider = this.model?.provider; - if (provider === "cursor" || provider === "cursor-cli-oauth") { - this._truncateAgentMessagesToLastUserTurn(); - } - return true; - } + if (!compacted && inlineReason) { throw new RequiredCompactionError(); } return compacted; @@ -6267,11 +4732,13 @@ export class AgentSession { } else { const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0; if (assistantMessage.stopReason !== "error" && directContextTokens !== 0) { - contextTokens = this._resolveThresholdContextTokens(directContextTokens); + contextTokens = directContextTokens; } else { const messages = filterContextExcludedMessages(this.agent.state.messages); const estimate = estimateContextTokens(messages); - if (estimate.lastUsageIndex !== null) { + if (estimate.lastUsageIndex === null) { + if (!this._isRequiredCompactionError(assistantMessage)) return false; + } else { // Verify the usage source is post-compaction. Kept pre-compaction messages // have stale usage reflecting the old (larger) context and would falsely // trigger compaction right after one just finished. @@ -6300,7 +4767,6 @@ export class AgentSession { if ( !compacted && this._compactionLifecycle.state.status === "failed" && - this._compactionLifecycle.state.rejectionCause !== "external-owner" && getLatestCompactionEntry(this.sessionManager.getBranch()) !== null ) { this._blockedPostCompactionAssistant = { @@ -6319,28 +4785,6 @@ export class AgentSession { return state.status === "failed" && state.rejectionCause === "circuit-breaker"; } - /** - * Compaction claims are last-writer-wins: a newer admission aborts the - * incumbent controller (_claimCompactionController). When the failed attempt - * lost that race, a live claimant now owns the route and re-gates admission - * itself, so the loser must not surface RequiredCompactionError (issue #886). - * A user abort leaves no live claimant behind and keeps throwing. - */ - private _hasSupersedingCompactionClaim(): boolean { - const claimant = this._compactionAbortController ?? this._autoCompactionAbortController; - return claimant !== undefined && !claimant.signal.aborted; - } - - private _isCompactionDelegated(): boolean { - const model = this.model; - return ( - this._delegatedCompactionKey !== undefined && - model !== undefined && - this._delegatedCompactionKey.provider === model.provider && - this._delegatedCompactionKey.id === model.id - ); - } - private async _runPrePromptCompaction( lastAssistantMessage: AssistantMessage | undefined, skipAbortedCheck: boolean, @@ -6348,7 +4792,6 @@ export class AgentSession { willRetry = false, allowSummaryOnly = false, ): Promise { - if (this._isCompactionDelegated()) return false; const controller = new AbortController(); const requestId = randomUUID(); this._claimCompactionController(controller, "compaction"); @@ -6381,7 +4824,6 @@ export class AgentSession { this._overflowRecoveryAttempted = false; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; - this._compactionSkippedTooSmall = shouldRetryOverflowWithoutCompact(false, errorMessage); const aborted = isCompactionExecutionAborted(error); this._emit({ type: "compaction_end", @@ -6416,9 +4858,7 @@ export class AgentSession { const compacted = await this._runPrePromptCompaction(this._findLastAssistantMessage(), true, "pre_prompt"); if (!compacted) { - if (this._isCompactionOnCooldown() || this._isCompactionDelegated() || this._hasSupersedingCompactionClaim()) { - return; - } + if (this._isCompactionOnCooldown()) return; throw new RequiredCompactionError(); } this._scheduledContinuationRecompacted = true; @@ -6432,22 +4872,6 @@ export class AgentSession { try { if (this.agent.state.isStreaming) return "taken-over"; - // Pre-admission restore point. A senpi-owned compaction that succeeds - // while a run is still active releases its refusal pin through - // _onCompactionContextChanged() but cannot restore there: model-select - // work must stay ordered behind the session-work barrier while the run - // owns it. Every post-compaction continuation admission - auto/overflow - // compaction recovery, _resumeQueuedMessagesAfterCompaction(), the - // agent_end queued continuation and the retry continuation - is scheduled - // through _scheduleContinuationAfterCurrentEvent() and funnels here, so - // this is the single choke point every such request passes before - // reaching the provider. Restoring here (a no-op when nothing is pending) - // keeps the guarantee that the very next request after a compaction runs - // on the primary, and precedes the admission revalidation below so that - // check samples the restored model's context window - the same ordering - // the retry-continuation restore uses. - await this._maybeRestoreFallbackPrimary(); - await this._revalidateScheduledContinuationAdmission(); if (this.agent.state.isStreaming) return "taken-over"; @@ -6468,12 +4892,7 @@ export class AgentSession { } }, getActiveSignal: () => this.agent.signal, - abortActive: () => - this.agent.abort( - new ProviderRetryWatchdogAbortError( - providerRetryWatchdogAbortMessage(retryTimeoutMs, this.agent.streamStartTimeoutMs), - ), - ), + abortActive: () => this.agent.abort(), timeoutMs: retryTimeoutMs, }); return "continued"; @@ -6514,14 +4933,8 @@ export class AgentSession { const finishContinuationWork = this._sessionWorkBarrier.begin(); const continueAfterEvent = async (): Promise => { try { - const outcome = await this._continueAgentAfterCurrentRun(options, retryTimeoutMs); - if (retryContinuation && outcome === "taken-over") { - DEFERRED_RETRY_QUEUE_OWNERS.delete(this); - } + await this._continueAgentAfterCurrentRun(options, retryTimeoutMs); } catch (error) { - if (retryContinuation) { - DEFERRED_RETRY_QUEUE_OWNERS.delete(this); - } const message = error instanceof Error ? error.message : String(error); this._emit({ type: "continuation_error", @@ -6541,17 +4954,9 @@ export class AgentSession { } /** - * Execute threshold or overflow compaction. Manual compaction uses - * `AgentSession.compact()` instead. Both paths call the lower-level `compact()` - * function imported from `./compaction/index.ts` after preparation and extension - * interception. - * - * @param reason Automatic trigger selected by `_checkCompaction()` - * @param willRetry Whether to continue the interrupted turn after overflow compaction - * @returns Whether the post-run loop should call `agent.continue()` + * Internal: Run auto-compaction with events. */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { - if (this._isCompactionDelegated()) return false; const finishCompactionWork = this._sessionWorkBarrier.begin(); const agentMessagesAtStart = this.agent.state.messages.slice(); const autoCompactionController = new AbortController(); @@ -6598,11 +5003,7 @@ export class AgentSession { const preparation = prepareCompaction( this.sessionManager.getBranch(), - cursorOverflowCompactionSettings( - this.settingsManager.getCompactionSettings(), - this.model?.provider, - reason, - ), + this.settingsManager.getCompactionSettings(), reason === "overflow", ); if (!preparation) { @@ -6630,11 +5031,7 @@ export class AgentSession { if (willRetry) { const messages = this.agent.state.messages; const lastMsg = messages[messages.length - 1]; - if ( - lastMsg?.role === "assistant" && - ((lastMsg as AssistantMessage).stopReason === "error" || - (lastMsg as AssistantMessage).stopReason === "length") - ) { + if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") { this.agent.state.messages = messages.slice(0, -1); this._incrementMessageRevision(); } @@ -6657,11 +5054,6 @@ export class AgentSession { if (reason === "overflow") this._overflowRecoveryAttempted = false; const errorMessage = error instanceof Error ? error.message : "compaction failed"; const aborted = isCompactionExecutionAborted(error); - const formattedErrorMessage = aborted - ? undefined - : reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`; this._emit({ type: "compaction_end", reason, @@ -6669,14 +5061,11 @@ export class AgentSession { aborted, willRetry: false, requestId, - errorMessage: formattedErrorMessage, - }); - await this._emitSessionCompactFailed({ - reason, - errorMessage: formattedErrorMessage, - aborted, - willRetry: false, - fromExtension: false, + errorMessage: aborted + ? undefined + : reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, }); return false; } finally { @@ -6692,16 +5081,6 @@ export class AgentSession { */ setAutoCompactionEnabled(enabled: boolean): void { this.settingsManager.setCompactionEnabled(enabled); - this._emitSessionSettingsChanged(); - } - - private _emitSessionSettingsChanged(): void { - this._emit({ - type: "session_settings_changed", - steeringMode: this.steeringMode, - followUpMode: this.followUpMode, - autoCompactionEnabled: this.autoCompactionEnabled, - }); } /** Whether auto-compaction is enabled */ @@ -6736,7 +5115,6 @@ export class AgentSession { this._applyExtensionBindings(this._extensionRunner); this.syncPromptCacheSafeWaitEnv(); await this._extensionRunner.emit(this._sessionStartEvent); - this._enforceConfiguredDefaultTools(); await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup"); } finally { if (this._extensionBindingPromptReadiness === bindingPromptReadiness) { @@ -6777,12 +5155,7 @@ export class AgentSession { private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{ path: string; - metadata: { - source: string; - scope: "temporary"; - origin: "top-level"; - baseDir?: string; - }; + metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string }; }> { return entries.map((entry) => { const source = this.getExtensionSourceLabel(entry.extensionPath); @@ -6822,7 +5195,6 @@ export class AgentSession { } private _refreshCurrentModelFromRegistry(): void { - this._delegatedCompactionKey = undefined; const currentModel = this.model; if (!currentModel) { return; @@ -6837,14 +5209,6 @@ export class AgentSession { } private _bindExtensionCore(runner: ExtensionRunner): void { - // Activity extensions publish about work outliving a turn (background - // terminal jobs, monitors, holds) feeds the session-busy contract. Re-bound - // per runner; counts survive the swap so a reload cannot make live work - // look idle to an occupancy sweep. - this._unsubscribeWakeSources?.(); - this._unsubscribeWakeSources = runner.onBusEvent(WAKE_SOURCE_STATE_EVENT, (data) => - this._wakeSources.observe(data), - ); const getCommands = (): SlashCommandInfo[] => { const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({ name: command.invocationName, @@ -6873,44 +5237,25 @@ export class AgentSession { runner.bindCore( { sendMessage: (message, options) => { - const reportError = (err: unknown) => { - runner.emitError({ - extensionPath: RUNTIME_EXTENSION_PATH, - event: "send_message", - error: err instanceof Error ? err.message : String(err), + const send = () => + this.sendCustomMessage(message, options).catch((err) => { + runner.emitError({ + extensionPath: RUNTIME_EXTENSION_PATH, + event: "send_message", + error: err instanceof Error ? err.message : String(err), + }); }); - }; - if (options?.triggerTurn === true) { - if ( - this._agentSettledDelivery.deferTriggerTurn((claim) => { - this.sendCustomMessage(message, options, claim).catch(reportError); - }) - ) { - return; - } - } - const send = () => this.sendCustomMessage(message, options).catch(reportError); if (this._agentSettledDelivery.defer(send)) return; send(); }, sendUserMessage: (content, options) => { - const reportError = (err: unknown) => { + this.sendUserMessage(content, options).catch((err) => { runner.emitError({ extensionPath: RUNTIME_EXTENSION_PATH, event: "send_user_message", error: err instanceof Error ? err.message : String(err), }); - }; - // sendUserMessage always triggers a turn; register a settlement-deferred - // turn claim so agent_idle is not emitted before its deferred agent_start. - if ( - this._agentSettledDelivery.deferTriggerTurn((claim) => { - this.sendUserMessage(content, options, claim).catch(reportError); - }) - ) { - return; - } - this.sendUserMessage(content, options).catch(reportError); + }); }, appendEntry: (customType, data) => { const entryId = this.sessionManager.appendCustomEntry(customType, data); @@ -6977,8 +5322,6 @@ export class AgentSession { getContextUsage: () => this.getContextUsage(), getCompactionSettings: () => this.settingsManager.getCompactionSettings(), getPromptCacheSafeWaitSeconds: () => this.resolvePromptCacheSafeWaitSeconds(), - getPromptCacheGoalBackstopMaxSeconds: () => this.settingsManager.getPromptCacheGoalBackstopMaxSeconds(), - getPromptCacheKeepAliveSettings: () => this.settingsManager.getPromptCacheKeepAliveSettings(), getLookAtSettings: () => { const global = this.settingsManager.getGlobalSettings().lookAt; const project = this.settingsManager.getProjectSettings().lookAt; @@ -7034,11 +5377,7 @@ export class AgentSession { await this._abortActiveAgentAndRetry("system"); this._disconnectFromAgent(); disconnected = true; - this._emit({ - type: "compaction_start", - reason: "extension", - requestId, - }); + this._emit({ type: "compaction_start", reason: "extension", requestId }); const execution = await this._executeCompaction({ controller, owner: "compaction", @@ -7129,31 +5468,7 @@ export class AgentSession { return this._fallbackValidationWarnings; } - private _isBuiltinExtensionPath(path: string): boolean { - return path.startsWith(" { - if (defaultToolNames.has(name)) return true; - const entry = this._toolDefinitions.get(name); - return ( - entry !== undefined && - !this._isBuiltinExtensionPath(entry.sourceInfo.path) && - entry.sourceInfo.source !== "builtin" - ); - }), - ); - } - - private _refreshToolRegistry(options?: { - activeToolNames?: string[]; - includeAllExtensionTools?: boolean; - previousActiveToolRegistrationIds?: ReadonlyMap; - }): void { + private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void { const previousRegistryNames = new Set(this._toolRegistry.keys()); const previousActiveToolNames = this.getActiveToolNames(); const allowedToolNames = this._allowedToolNames; @@ -7162,33 +5477,21 @@ export class AgentSession { (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name); const registeredTools = this._extensionRunner.getAllRegisteredTools(); - const defaultToolNames = this._defaultToolNames; - const isConfiguredBuiltinTool = (tool: (typeof registeredTools)[number]): boolean => - (tool.sourceInfo.source !== "builtin" && !this._isBuiltinExtensionPath(tool.sourceInfo.path)) || - defaultToolNames === undefined || - defaultToolNames.has(tool.definition.name); const allCustomTools = [ - ...registeredTools.filter(isConfiguredBuiltinTool), + ...registeredTools, ...this._customTools.map((definition) => ({ definition, - sourceInfo: createSyntheticSourceInfo(``, { - source: "sdk", - }), + sourceInfo: createSyntheticSourceInfo(``, { source: "sdk" }), })), ].filter((tool) => isAllowedTool(tool.definition.name)); - // Withheld tools stay in _baseToolDefinitions (and therefore in _toolRegistry, which - // getRegisteredTool serves to the Cursor exec bridge) but are dropped from the model-facing - // definitions so they never reach the prompt. See temporarilyDisabledToolNames. const definitionRegistry = new Map( Array.from(this._baseToolDefinitions.entries()) - .filter(([name]) => isAllowedTool(name) && !temporarilyDisabledToolNames.has(name)) + .filter(([name]) => isAllowedTool(name)) .map(([name, definition]) => [ name, { definition, - sourceInfo: createSyntheticSourceInfo(``, { - source: "builtin", - }), + sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), }, ]), ); @@ -7232,31 +5535,10 @@ export class AgentSession { toolRegistry.set(tool.name, tool); } this._toolRegistry = toolRegistry; - this._publishEvalOnlyToolHints(); - const isDirectlyExposed = (name: string): boolean => { - const entry = this._toolDefinitions.get(name); - return entry !== undefined && normalizeToolExposure(entry.definition).exposure === "direct"; - }; - // A withheld tool is dropped from the DEFAULT selection only. An explicit activeToolNames - // request names the tool deliberately, and callers that do so (tests, SDK embedders, - // filesystem-policy wiring) still expect it to activate. - const hasExplicitActiveToolNames = options?.activeToolNames !== undefined; const nextActiveToolNames = ( - options?.activeToolNames - ? [...options.activeToolNames] - : [...(this._requestedActiveToolNames ?? previousActiveToolNames)] - ).filter((name) => { - if (!isAllowedTool(name)) return false; - if (!hasExplicitActiveToolNames && temporarilyDisabledToolNames.has(name)) return false; - const previousRegistrationIds = options?.previousActiveToolRegistrationIds; - if (!previousRegistrationIds) return true; - const current = this._toolDefinitions.get(name); - return ( - current !== undefined && - previousRegistrationIds.get(name) === deriveExtensionRegistrationId(current.sourceInfo, name) - ); - }); + options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames] + ).filter((name) => isAllowedTool(name)); if (allowedToolNames) { for (const toolName of this._toolRegistry.keys()) { @@ -7266,11 +5548,11 @@ export class AgentSession { } } else if (options?.includeAllExtensionTools) { for (const tool of wrappedExtensionTools) { - if (isDirectlyExposed(tool.name)) nextActiveToolNames.push(tool.name); + nextActiveToolNames.push(tool.name); } } else if (!options?.activeToolNames) { for (const toolName of this._toolRegistry.keys()) { - if (!previousRegistryNames.has(toolName) && isDirectlyExposed(toolName)) { + if (!previousRegistryNames.has(toolName)) { nextActiveToolNames.push(toolName); } } @@ -7283,16 +5565,10 @@ export class AgentSession { activeToolNames?: string[]; flagValues?: Map; includeAllExtensionTools?: boolean; - previousActiveToolRegistrationIds?: ReadonlyMap; }): void { - this._delegatedCompactionKey = undefined; const autoResizeImages = this.settingsManager.getImageAutoResize(); const shellCommandPrefix = this.settingsManager.getShellCommandPrefix(); const shellPath = this.settingsManager.getShellPath(); - const extensionsResult = this._resourceLoader.getExtensions(); - const filesystemPolicy = composeFilesystemPolicies( - extensionsResult.extensions.flatMap((extension) => extension.filesystemPolicies ?? []), - ); const baseToolDefinitions = this._baseToolsOverride ? Object.fromEntries( Object.entries(this._baseToolsOverride).map(([name, tool]) => [ @@ -7301,18 +5577,15 @@ export class AgentSession { ]), ) : createAllToolDefinitions(this._cwd, { - read: { autoResizeImages, filesystemPolicy }, + read: { autoResizeImages }, bash: { commandPrefix: shellCommandPrefix, shellPath }, - write: { filesystemPolicy }, - edit: { filesystemPolicy }, - grep: { filesystemPolicy }, - find: { filesystemPolicy }, - ls: { filesystemPolicy }, }); this._baseToolDefinitions = new Map( Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]), ); + + const extensionsResult = this._resourceLoader.getExtensions(); if (options.flagValues) { for (const [name, value] of options.flagValues) { extensionsResult.runtime.flagValues.set(name, value); @@ -7325,7 +5598,6 @@ export class AgentSession { this._cwd, this.sessionManager, this._modelRegistry, - extensionsResult.eventBus, ); if (this._extensionRunnerRef) { this._extensionRunnerRef.current = this._extensionRunner; @@ -7340,7 +5612,6 @@ export class AgentSession { this._refreshToolRegistry({ activeToolNames: baseActiveToolNames, includeAllExtensionTools: options.includeAllExtensionTools, - previousActiveToolRegistrationIds: options.previousActiveToolRegistrationIds, }); } @@ -7356,25 +5627,9 @@ export class AgentSession { const oldExtensionRunner = this._extensionRunner; const oldExtensionIdentities = oldExtensionRunner.getExtensionIdentities(); const previousFlagValues = oldExtensionRunner.getFlagValues(); - const previousActiveToolRegistrationIds = new Map(); - // Cover withheld eval-only tools too: the rebuild drops any seeded name missing from this - // map, which would strand eval-only tools when the policy disarms during this reload. - for (const name of this._requestedActiveToolNames ?? this.getActiveToolNames()) { - const entry = this._toolDefinitions.get(name); - if (entry) previousActiveToolRegistrationIds.set(name, deriveExtensionRegistrationId(entry.sourceInfo, name)); - } - await emitSessionShutdownEvent(oldExtensionRunner, { - type: "session_shutdown", - reason: "reload", - }); + await emitSessionShutdownEvent(oldExtensionRunner, { type: "session_shutdown", reason: "reload" }); time("shutdown", "reload"); await this.settingsManager.reload(); - this._delegatedCompactionKey = undefined; - // Capture the unfiltered request BEFORE the rebuild reassigns it, so a policy that - // disarms during this reload can still restore its withheld eval-only tools. - const requestedActiveToolNamesBeforeRebuild = [...(this._requestedActiveToolNames ?? this.getActiveToolNames())]; - // Re-resolve the fixed policy (or SDK override) so reload cannot regress it. - this._evalOnlyToolNames = this._resolveEvalOnlyToolNames(); this.syncQueueModesFromSettings(); resetApiProviders(); time("settings", "reload"); @@ -7384,9 +5639,7 @@ export class AgentSession { // swallows availability errors, so a failed scan must fall back to the runtime and // keep the previous refresh-and-surface-the-error behavior. const refreshedModels: AvailableModelsSource = this._modelRuntime.hasFreshAvailabilitySnapshot() - ? { - getAvailable: async () => this._modelRuntime.getAvailableSnapshot(), - } + ? { getAvailable: async () => this._modelRuntime.getAvailableSnapshot() } : this._modelRuntime; this.setScopedModels( await resolveModelScope( @@ -7398,16 +5651,13 @@ export class AgentSession { ); this.setFavoriteModels(await resolveModelScope(this.settingsManager.getFavoriteModels() ?? [], refreshedModels)); time("models", "reload"); - await this._resourceLoader.reload({ - settingsAlreadyReloadedFor: this.settingsManager, - }); + await this._resourceLoader.reload({ settingsAlreadyReloadedFor: this.settingsManager }); time("resources", "reload"); try { this._buildRuntime({ - activeToolNames: requestedActiveToolNamesBeforeRebuild, + activeToolNames: this.getActiveToolNames(), flagValues: previousFlagValues, includeAllExtensionTools: true, - previousActiveToolRegistrationIds, }); } finally { // An extension removed by this reload must be told even if the rebuild throws @@ -7419,16 +5669,8 @@ export class AgentSession { const removed = oldExtensionIdentities.filter( (extension) => !newExtensionResolvedPaths.has(extension.resolvedPath), ); - try { - if (removed.length > 0) { - await oldExtensionRunner.emit({ - type: "session_extensions_removed", - reason: "reload", - removed, - }); - } - } finally { - oldExtensionRunner.invalidate("stale extension generation after reload"); + if (removed.length > 0) { + await oldExtensionRunner.emit({ type: "session_extensions_removed", reason: "reload", removed }); } time("runtime", "reload"); } @@ -7441,10 +5683,7 @@ export class AgentSession { if (hasBindings) { await options?.beforeSessionStart?.(); this.syncPromptCacheSafeWaitEnv(); - await this._extensionRunner.emit({ - type: "session_start", - reason: "reload", - }); + await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); await this.extendResourcesFromExtensions("reload"); } time("lifecycle", "reload"); @@ -7462,9 +5701,7 @@ export class AgentSession { if (!this._extensionRunner.hasHandlers("session_before_reload")) { return { cancelled: false }; } - const result = await this._extensionRunner.emit({ - type: "session_before_reload", - }); + const result = await this._extensionRunner.emit({ type: "session_before_reload" }); if (result?.cancel !== true) { return { cancelled: false }; } @@ -7496,32 +5733,11 @@ export class AgentSession { return isRetryableAssistantError(message); } - private _isCursorPayloadOverflow(message: AssistantMessage): boolean { - return isCursorPayloadResourceExhausted(message, 0); - } - - private _truncateAgentMessagesToLastUserTurn(): boolean { - const messages = this.agent?.state?.messages; - if (!Array.isArray(messages) || messages.length === 0) return false; - let lastUser = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i]?.role === "user") { - lastUser = i; - break; - } - } - if (lastUser <= 0) return false; - this.agent.state.messages = messages.slice(lastUser); - return true; - } - private _isHardErrorFallbackEligible(message: AssistantMessage): boolean { return ( !message.errorMessage?.startsWith(TURN_RETRY_SUPPRESSION_PREFIX) && message.stopReason === "error" && !isContextOverflow(message, this.model?.contextWindow ?? 0) && - !this._isCursorPayloadOverflow(message) && - !isCursorZeroTokenResourceExhausted(message) && !isClassifierRefusal(message) && !message.content.some((content) => content.type === "toolCall") && this._retryFallback.canTryFallback() @@ -7566,87 +5782,20 @@ export class AgentSession { }; } - /** - * A 429-class failure with no usable fallback candidate must not fail the - * turn with zero attempts: a provider answering 429 is asking for a retry. - * No-hint and tier2 waits degrade to same-model in-turn retries under the - * normal retry budget (tier2 clamps the hinted wait to the in-turn cap); - * only tier3 hour-plus waits stay terminal, with the requested wait named - * in the final error. Returns the in-turn retry delay, or undefined after - * emitting the terminal auto_retry_end. - */ - private _degradeRateLimitedWithoutFallback( - tier: HintTier, - hintMs: number | undefined, - message: AssistantMessage, - errorMessage: string, - ): number | undefined { - const settings = this.settingsManager.getRetrySettings(); - // Budget checks use the resolved profile (same value as settings.maxRetries - // for providers without a declared profile). - const turnMaxRetries = this._resolveRetryProfile().turn.maxRetries; - const hintSettings = this.settingsManager.getHintPolicySettings(); - const finishTurn = (attempt: number, finalError: string | undefined) => { - const exhaustedChainKey = this._retryFallback.exhaustedChainKey; - if (exhaustedChainKey) { - this._emit({ - type: "retry_fallback_exhausted", - chainKey: exhaustedChainKey, - lastError: errorMessage, - }); - } - this._emit({ - type: "auto_retry_end", - success: false, - attempt, - finalError, - }); - this._retryAttempt = 0; - this._resetHintTierState(); - this._resolveRetry(); - }; - const degraded = degradeWithoutFallback( - tier, - hintMs, - this._retryAttempt + 1, - settings.baseDelayMs, - hintSettings.hintedWaitCapMs, - ); - if (degraded.kind === "fail") { - const waitSeconds = Math.ceil(degraded.hintMs / 1000); - finishTurn( - this._retryAttempt, - `Provider requested a ${waitSeconds}s wait before retrying and no usable fallback model is available. ${message.errorMessage ?? ""}`, - ); - return undefined; - } - this._retryAttempt++; - if (this._retryAttempt > turnMaxRetries) { - finishTurn(this._retryAttempt - 1, message.errorMessage); - return undefined; - } - return degraded.delayMs; - } - /** * Handle retryable errors with exponential backoff. * @returns whether retry continuation started, was blocked by compaction, or was not handled */ private async _handleRetryableError( message: AssistantMessage, - options: { hardErrorFallback?: boolean; sameModelRemint?: boolean } = {}, - ): Promise<"continued" | "blocked" | "not-handled" | "cancelled"> { + options: { hardErrorFallback?: boolean } = {}, + ): Promise<"continued" | "blocked" | "not-handled"> { const settings = this.settingsManager.getRetrySettings(); if (!settings.enabled) { this._resolveRetry(); return "not-handled"; } - // Resolve the effective retry profile for the current provider. - // Profile-driven behaviour only diverges when a provider declares one; - // the senpi-default profile preserves today's tier routing exactly. - const retryProfile = this._resolveRetryProfile(); - // Retry promise is created synchronously in _handleAgentEvent for agent_end. // Keep a defensive fallback here in case a future refactor bypasses that path. if (!this._retryPromise) { @@ -7658,43 +5807,16 @@ export class AgentSession { const errorMessage = message.errorMessage || "Unknown error"; const isRefusal = isClassifierRefusal(message); const hardErrorFallback = options.hardErrorFallback === true; - const sameModelRemint = options.sameModelRemint === true; let switchedFallback = false; let is429TierRouted = false; let hintTierDelayMs: number | undefined; - if (sameModelRemint) { - this._retryAttempt++; - if (this._retryAttempt > retryProfile.turn.maxRetries) { - if (this._retryAttempt > 1) { - this._emit({ - type: "auto_retry_end", - success: false, - attempt: this._retryAttempt, - finalError: message.errorMessage, - }); - } - this._retryAttempt = 0; - this._resetHintTierState(); - this._resolveRetry(); - return "not-handled"; - } - } else if (hardErrorFallback) { + if (hardErrorFallback) { // A non-retryable provider failure must never replay on the same model. // Billing-class failures never recover on this account, so the fallback // switch pins as the session model instead of reverting after the cooldown. const reason = isBillingErrorMessage(errorMessage) ? "billing" : "hard-error"; - switchedFallback = await this._retryFallback.tryFallback(reason, { - errorMessage, - }); + switchedFallback = await this._retryFallback.tryFallback(reason, { errorMessage }); if (!switchedFallback) { - const exhaustedChainKey = this._retryFallback.exhaustedChainKey; - if (exhaustedChainKey) { - this._emit({ - type: "retry_fallback_exhausted", - chainKey: exhaustedChainKey, - lastError: errorMessage, - }); - } this._resolveRetry(); return "not-handled"; } @@ -7703,7 +5825,7 @@ export class AgentSession { } else if (isRefusal) { // Refusals are only retried through a new chain candidate. They never use // same-model retries or the transient over-budget fallback escape hatch. - if (this._retryAttempt + 1 > retryProfile.turn.maxRetries) { + if (this._retryAttempt + 1 > settings.maxRetries) { if (this._retryAttempt > 0) { this._emit({ type: "auto_retry_end", @@ -7721,11 +5843,7 @@ export class AgentSession { if (!switchedFallback) { const exhaustedChainKey = this._retryFallback.exhaustedChainKey; if (exhaustedChainKey) { - this._emit({ - type: "retry_fallback_exhausted", - chainKey: exhaustedChainKey, - lastError: errorMessage, - }); + this._emit({ type: "retry_fallback_exhausted", chainKey: exhaustedChainKey, lastError: errorMessage }); } if (this._retryAttempt > 0) { this._emit({ @@ -7742,33 +5860,33 @@ export class AgentSession { } this._retryAttempt++; } else { - // A provider-stream stall is an ordinary transient failure: it consumes - // the same bounded same-model budget (the resolved profile's turn - // maxRetries) as every other retryable class and escalates to the fallback chain only when - // that budget is exhausted. It is excluded from 429-class tier routing - // because a stall carries no rate-limit markers or retry-after hint. + if (this._retryAttempt === 0) { + this._consecutiveProviderStreamStalls = 0; + } + // A provider-stream stall means the request was accepted but delivered + // zero events for the whole idle budget. Replaying the identical payload + // against a hung provider burns that full budget again per attempt + // ((1 + maxRetries) * httpIdleTimeoutMs of opaque dead air), so a second + // consecutive stall escalates to the fallback chain immediately. Fast + // non-stall failures in between reset the streak and keep the normal + // same-model recovery semantics. const stallError = isProviderStreamStallError(message); + const escalateAfterRepeatedStall = stallError && this._consecutiveProviderStreamStalls > 0; + this._consecutiveProviderStreamStalls = stallError ? this._consecutiveProviderStreamStalls + 1 : 0; // 429-class detection: retryable AND message carries rate-limit markers. - // Every same-model wait derived below is floored by the exponential schedule - // inside the pure hint policy, so repeated tiny retry-after hints cannot pin - // the cadence at a few milliseconds. Do not recompute that floor here. const is429Class = !stallError && /rate.?limit|(?:^429(?=\s+\{)|(?:\bHTTP\/1\.[01]\s+|\bHTTP\s+|\bstatus(?:\s+code)?\s+|\berror\s+|\bcode\s+)429\b)|too many requests|resource.?exhausted/i.test( errorMessage, ); - // Profile-driven routing: "after-turn-budget" (Kimi) keeps 429s on the - // ordinary same-model budget; "tiered" (senpi default) uses hint tiers. - if (is429Class && retryProfile.fallback.rateLimited === "after-turn-budget") { - // Kimi profile: 429s consume the same-model budget like any transient. - // Mark tier-routed so the generic non-429 path below does not double-count. + if (is429Class) { + const hintMs = this._getProviderRetryDelayMs(errorMessage); + const hintSettings = this.settingsManager.getHintPolicySettings(); + const tier = classifyRateLimitedWait(hintMs, hintSettings); is429TierRouted = true; - this._retryAttempt++; - if (this._retryAttempt > retryProfile.turn.maxRetries) { - switchedFallback = await this._retryFallback.tryFallback("transient", { - errorMessage, - retryAfterMs: this._getProviderRetryDelayMs(errorMessage), - }); + if (tier === "no-hint-fast-fallback") { + // Skip same-model retries entirely; fall back immediately. + switchedFallback = await this._retryFallback.tryFallback("transient", { errorMessage }); if (switchedFallback) { this._retryAttempt = 1; } else { @@ -7783,7 +5901,7 @@ export class AgentSession { this._emit({ type: "auto_retry_end", success: false, - attempt: this._retryAttempt - 1, + attempt: 0, finalError: message.errorMessage, }); this._retryAttempt = 0; @@ -7791,26 +5909,9 @@ export class AgentSession { this._resolveRetry(); return "not-handled"; } - } - } else if (is429Class) { - const hintMs = this._getProviderRetryDelayMs(errorMessage); - const hintSettings = this.settingsManager.getHintPolicySettings(); - const tier = classifyRateLimitedWait(hintMs, hintSettings); - is429TierRouted = true; - if (tier === "no-hint-fast-fallback") { - // Fall back immediately when a candidate exists; otherwise degrade - // to same-model in-turn retries instead of failing the turn. - switchedFallback = await this._retryFallback.tryFallback("transient", { errorMessage }); - if (switchedFallback) { - this._retryAttempt = 1; - } else { - const degradedDelayMs = this._degradeRateLimitedWithoutFallback(tier, hintMs, message, errorMessage); - if (degradedDelayMs === undefined) return "not-handled"; - hintTierDelayMs = degradedDelayMs; - } } else if (tier === "tier1-in-turn") { this._retryAttempt++; - if (this._retryAttempt > retryProfile.turn.maxRetries) { + if (this._retryAttempt > settings.maxRetries) { // Budget exhausted within tier1; fall back. switchedFallback = await this._retryFallback.tryFallback("transient", { errorMessage, @@ -7901,16 +6002,31 @@ export class AgentSession { this._armProbeBackForDemotedSelector(selector, remainingHintMs); } } else { - const degradedDelayMs = this._degradeRateLimitedWithoutFallback(tier, hintMs, message, errorMessage); - if (degradedDelayMs === undefined) return "not-handled"; - hintTierDelayMs = degradedDelayMs; + const exhaustedChainKey = this._retryFallback.exhaustedChainKey; + if (exhaustedChainKey) { + this._emit({ + type: "retry_fallback_exhausted", + chainKey: exhaustedChainKey, + lastError: errorMessage, + }); + } + this._emit({ + type: "auto_retry_end", + success: false, + attempt: 0, + finalError: message.errorMessage, + }); + this._retryAttempt = 0; + this._resetHintTierState(); + this._resolveRetry(); + return "not-handled"; } } } if (!is429TierRouted) { this._retryAttempt++; } - if (!is429TierRouted && this._retryAttempt > retryProfile.turn.maxRetries) { + if (!is429TierRouted && (this._retryAttempt > settings.maxRetries || escalateAfterRepeatedStall)) { switchedFallback = await this._retryFallback.tryFallback("transient", { errorMessage, retryAfterMs: this._getProviderRetryDelayMs(errorMessage), @@ -7941,16 +6057,14 @@ export class AgentSession { } } + if (switchedFallback) { + this._consecutiveProviderStreamStalls = 0; + } + const providerDelayMs = isRefusal || hardErrorFallback ? undefined : this._getProviderRetryDelayMs(errorMessage); const maxRetryDelayMs = this.settingsManager.getProviderRetrySettings().maxRetryDelayMs; - // Profile ceiling null (Kimi) bypasses the over-ceiling error path entirely. - const profileCeiling = - retryProfile.turn.serverHint.mode === "override" - ? retryProfile.turn.serverHint.ceiling.maxDelayMs - : maxRetryDelayMs; - const effectiveMaxRetryDelayMs = profileCeiling ?? Number.MAX_SAFE_INTEGER; // For 429-class failures the tier routing replaces the over-budget gate. - if (!is429TierRouted && providerDelayMs !== undefined && providerDelayMs > effectiveMaxRetryDelayMs) { + if (!is429TierRouted && providerDelayMs !== undefined && providerDelayMs > maxRetryDelayMs) { // A wait this long means the model is unavailable rather than busy, so the // configured chain beats failing the turn. The switch is gated: the over-budget // branch above may have already switched on this same error, and hopping again @@ -7983,30 +6097,19 @@ export class AgentSession { // reach this point with a fallback already applied (hard-error, refusal) set // switchedFallback first and force providerDelayMs undefined, so no branch may // be reordered to fall through here expecting an implicit switch. - // 429-tier delays already carry the exponential floor from nextInTurnDelayMs / - // degradeWithoutFallback; the non-tier branch keeps its own exponential fallback. const nonTierProviderDelayMs = providerDelayMs === 0 ? undefined : providerDelayMs; - // Locally computed exponential goes through the profile's backoff policy - // (cap + jitter), sampled through the injectable retryRandom seam so tests - // stay deterministic; provider-derived hints on the non-429 path remain - // authoritative and fallback switches stay exact. - const localExponentialMs = retryBackoffDelayMs( - retryProfile.turn.backoff, - this._retryAttempt, - this._retryRandom(), - ); const delayMs = switchedFallback ? 0 : is429TierRouted - ? (hintTierDelayMs ?? providerDelayMs ?? localExponentialMs) - : (nonTierProviderDelayMs ?? localExponentialMs); + ? (hintTierDelayMs ?? providerDelayMs ?? settings.baseDelayMs * 2 ** (this._retryAttempt - 1)) + : (nonTierProviderDelayMs ?? settings.baseDelayMs * 2 ** (this._retryAttempt - 1)); // Prepare before auto_retry_start so an immediate Esc can cancel the retry sleep. this._retryAbortController = new AbortController(); this._emit({ type: "auto_retry_start", attempt: this._retryAttempt, - maxAttempts: retryProfile.turn.maxRetries, + maxAttempts: settings.maxRetries, delayMs, errorMessage, }); @@ -8035,7 +6138,7 @@ export class AgentSession { finalError: "Retry cancelled", }); this._resolveRetry(); - return "cancelled"; + return "not-handled"; } this._retryAbortController = undefined; @@ -8057,12 +6160,7 @@ export class AgentSession { shouldCompact(contextTokens, model.contextWindow, compactionSettings) ) { const preRetryCompaction = await this._runPrePromptCompaction(message, true, "threshold", true, true); - if ( - !preRetryCompaction && - !this._isCompactionOnCooldown() && - !this._isCompactionDelegated() && - !this._hasSupersedingCompactionClaim() - ) { + if (!preRetryCompaction && !this._isCompactionOnCooldown()) { const attempt = this._retryAttempt; this._retryAttempt = 0; this._resetHintTierState(); @@ -8090,11 +6188,6 @@ export class AgentSession { timeoutMs: this.agent.timeoutMs, streamStartTimeoutMs: this.agent.streamStartTimeoutMs, }); - if (continuation.options.deferQueuedMessages === true) { - DEFERRED_RETRY_QUEUE_OWNERS.add(this); - } else { - DEFERRED_RETRY_QUEUE_OWNERS.delete(this); - } this.agent.suppressQueuedMessageDrain(); this._scheduleContinuationAfterCurrentEvent(continuation.options, true, continuation.watchdogTimeoutMs); @@ -8156,11 +6249,7 @@ export class AgentSession { async executeBash( command: string, onChunk?: (chunk: string) => void, - options?: { - excludeFromContext?: boolean; - id?: string; - operations?: BashOperations; - }, + options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations }, ): Promise { const abortController = new AbortController(); this._bashAbortControllers.add(abortController); @@ -8177,13 +6266,8 @@ export class AgentSession { options?.operations ?? createLocalBashOperations({ shellPath }), { onChunk: (delta) => { - const callbackResult = onChunk?.(delta); - this._emit({ - type: "bash_execution_update", - id: options?.id, - delta, - }); - return callbackResult; + onChunk?.(delta); + this._emit({ type: "bash_execution_update", id: options?.id, delta }); }, signal: abortController.signal, }, @@ -8236,11 +6320,6 @@ export class AgentSession { } } - /** Remove a host-owned full-output spill after a remote observer failed. */ - async cleanupBashOutput(path: string): Promise { - await rm(path, { force: true }); - } - /** Whether a bash command is currently running */ get isBashRunning(): boolean { return this._bashAbortControllers.size > 0; @@ -8279,10 +6358,7 @@ export class AgentSession { */ setSessionName(name: string): void { this.sessionManager.appendSessionInfo(name); - const event = { - type: "session_info_changed", - name: this.sessionManager.getSessionName(), - } as const; + const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() } as const; this._emit(event); void this._extensionRunner.emit(event); } @@ -8304,18 +6380,8 @@ export class AgentSession { */ async navigateTree( targetId: string, - options: { - summarize?: boolean; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; - } = {}, - ): Promise<{ - editorText?: string; - cancelled: boolean; - aborted?: boolean; - summaryEntry?: BranchSummaryEntry; - }> { + options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {}, + ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> { if (this.isStreaming) { throw new Error("Wait for the current response to finish before navigating the session tree."); } @@ -8402,16 +6468,10 @@ export class AgentSession { let summaryUsage: Usage | undefined; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; - const { - model: requestModel, - apiKey, - headers, - extraBody, - env, - } = await this._getCompactionRequestAuth(model); + const { apiKey, headers, extraBody, env } = await this._getCompactionRequestAuth(model); const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { - model: requestModel, + model, apiKey, headers, extraBody, @@ -8422,9 +6482,7 @@ export class AgentSession { reserveTokens: branchSummarySettings.reserveTokens, streamFn: this.agent.streamFunction, retry: this.settingsManager.getRetrySettings(), - callbacks: this._summarizationRetryCallbacks({ - source: "branchSummary", - }), + callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }), }); if (result.aborted) { return { cancelled: true, aborted: true }; @@ -8494,7 +6552,6 @@ export class AgentSession { // Update agent state (preserving exact messages still awaiting persistence) this._restoreAgentMessagesFromSession(); - this._delegatedCompactionKey = undefined; this._incrementMessageRevision(); // Emit session_tree event @@ -8647,11 +6704,7 @@ export class AgentSession { if (!hasPostCompactionUsage) { const tokens = messages.reduce((sum, message) => sum + estimateTokens(message), 0); - return { - tokens, - contextWindow, - percent: (tokens / contextWindow) * 100, - }; + return { tokens, contextWindow, percent: (tokens / contextWindow) * 100 }; } } @@ -8668,13 +6721,11 @@ export class AgentSession { /** * Export session to HTML. * @param outputPath Optional output path (defaults to session directory) - * @param options Optional export presentation settings * @returns Path to exported file */ - async exportToHtml(outputPath?: string, options: { themeName?: string } = {}): Promise { - const themeName = [options.themeName, this.settingsManager.getTheme()].find( - (candidate) => candidate !== undefined && getThemeByName(candidate) !== undefined, - ); + async exportToHtml(outputPath?: string): Promise { + const configuredThemeName = this.settingsManager.getTheme(); + const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined; // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 2a6c65d540..8a8293aabb 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,13 +6,7 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { - dropFailedAssistantTurns, - type RetryCallbacks, - type RetryPolicy, - retryAssistantCall, - uuidv7, -} from "@earendil-works/pi-ai"; +import { type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, @@ -23,7 +17,6 @@ import type { Usage, } from "@earendil-works/pi-ai/compat"; import { streamSimple } from "@earendil-works/pi-ai/compat"; -import { estimateContextTokens as estimateProviderContextTokens } from "@earendil-works/pi-ai/utils/estimate"; import { convertToLlm, filterContextExcludedMessages, isContextExcludedCustomMessage } from "../messages.ts"; import { buildSessionContext, @@ -31,46 +24,27 @@ import { type SessionEntry, sessionEntryToContextMessages, } from "../session-manager.ts"; -import type { CompactionSettings } from "./compaction-settings.ts"; - -export { type CompactionSettings, DEFAULT_COMPACTION_SETTINGS } from "./compaction-settings.ts"; - import { consumeStreamWithIdleTimeout, DEFAULT_SUMMARIZATION_IDLE_TIMEOUT_MS, DEFAULT_SUMMARIZATION_MAX_DURATION_MS, } from "./stream-watchdog.ts"; import { + computeFileLists, contentTextForSummary, createFileOps, extractFileOpsFromMessage, type FileOperations, + formatFileOperations, SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, } from "./utils.ts"; -export type SummarizationStreamFn = StreamFn; +type SummarizationStreamFn = StreamFn; type SummarizationOptions = SimpleStreamOptions & { readonly env?: Record; }; -function getAnthropicSummarizationFallback(model: Model): readonly { model: string }[] | undefined { - if (model.provider !== "anthropic" || model.api !== "anthropic-messages") { - return undefined; - } - const allowedFallbackModels = (model as Model<"anthropic-messages">).compat?.allowedFallbackModels; - return allowedFallbackModels?.length - ? [ - { - model: - typeof allowedFallbackModels[0] === "string" - ? allowedFallbackModels[0] - : allowedFallbackModels[0].model, - }, - ] - : undefined; -} - // ============================================================================ // File Operation Tracking // ============================================================================ @@ -135,27 +109,6 @@ function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | u return contextMessagesForCompactionEntry(entry)[0]; } -/** Build an active-context prefix, placing the previous compaction summary before its retained messages. */ -function collectSourceMessages( - entries: SessionEntry[], - startIndex: number, - endIndex: number, - previousCompactionIndex: number, -): AgentMessage[] { - const messages: AgentMessage[] = []; - if (previousCompactionIndex >= 0) { - messages.push(...sessionEntryToContextMessages(entries[previousCompactionIndex])); - } - for (let i = startIndex; i < endIndex; i++) { - // The latest compaction was moved to the front above. Keep older compaction - // entries because buildSessionContext retains them in the active provider prefix. - if (i !== previousCompactionIndex) { - messages.push(...sessionEntryToContextMessages(entries[i])); - } - } - return messages; -} - /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { summary: string; @@ -168,7 +121,7 @@ export interface CompactionResult { details?: T; } -export function combineUsage(first: Usage, second: Usage): Usage { +function combineUsage(first: Usage, second: Usage): Usage { return { input: first.input + second.input, output: first.output + second.output, @@ -195,19 +148,38 @@ export function combineUsage(first: Usage, second: Usage): Usage { // Types // ============================================================================ -/** Active provider contexts and request settings used to preserve cacheable compaction prefixes. */ -export interface CacheFriendlySummaryOptions { - /** Exact provider context prefix containing the history to summarize. */ - sourceContext?: Context; - /** Exact provider context prefix containing a split turn's prefix. */ - turnPrefixSourceContext?: Context; - /** Provider request settings copied from the active agent request path. */ - requestOptions?: Pick< - SimpleStreamOptions, - "sessionId" | "onPayload" | "onResponse" | "transport" | "thinkingBudgets" | "maxRetryDelayMs" - >; +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; + speculativeEnabled?: boolean; + speculativeFraction?: number; + speculativeCooldownMs?: number; + restorationEnabled?: boolean; + restorationMaxItems?: number; + restorationMaxTokensPerItem?: number; + restorationMaxTotalTokens?: number; + restorationContextRatio?: number; + idleCompactionEnabled?: boolean; + /** Optional "provider/model" override for the compaction summarization model. */ + model?: string; } +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, + speculativeEnabled: true, + speculativeFraction: 0.75, + speculativeCooldownMs: 30000, + restorationEnabled: true, + restorationMaxItems: 10, + restorationMaxTokensPerItem: 5000, + restorationMaxTotalTokens: 50_000, + restorationContextRatio: 0.15, + idleCompactionEnabled: true, +}; + // ============================================================================ // Token calculation // ============================================================================ @@ -220,20 +192,6 @@ export function calculateContextTokens(usage: Usage): number { return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; } -/** - * Threshold numerator. Prefer the larger of billed usage and the local - * transcript estimate, unless billed usage is implausibly larger than the - * estimate (Cursor cacheRead spikes of several million vs ~150k local). - */ -export function resolveThresholdContextTokens(usageTokens: number, estimateTokens: number): number { - const usage = usageTokens > 0 ? usageTokens : 0; - const estimate = estimateTokens > 0 ? estimateTokens : 0; - if (estimate >= 50_000 && usage > estimate * 8) { - return estimate; - } - return Math.max(usage, estimate); -} - /** * Get usage from an assistant message if available. * Skips aborted, error, and all-zero usage messages as they don't have valid usage data. @@ -274,14 +232,9 @@ export interface ContextUsageEstimate { lastUsageIndex: number | null; } -function getLastAssistantUsageInfo( - messages: AgentMessage[], - counted: ReadonlySet, -): { usage: Usage; index: number } | undefined { +function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined { for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (!counted.has(message)) continue; - const usage = getAssistantUsage(message); + const usage = getAssistantUsage(messages[i]); if (usage) return { usage, index: i }; } return undefined; @@ -292,17 +245,12 @@ function getLastAssistantUsageInfo( * If there are messages after the last usage, estimate their tokens with estimateTokens. */ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { - // Count the same set the next provider request will carry: convertToLlm drops - // failed (error/aborted) assistant turns and their orphaned tool results. - // Indices stay relative to the INPUT array because callers read - // `messages[lastUsageIndex]` on the array they passed in. - const counted = new Set(dropFailedAssistantTurns(messages)); - const usageInfo = getLastAssistantUsageInfo(messages, counted); + const usageInfo = getLastAssistantUsageInfo(messages); if (!usageInfo) { let estimated = 0; for (const message of messages) { - if (counted.has(message)) estimated += estimateTokens(message); + estimated += estimateTokens(message); } return { tokens: estimated, @@ -315,7 +263,7 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst const usageTokens = calculateContextTokens(usageInfo.usage); let trailingTokens = 0; for (let i = usageInfo.index + 1; i < messages.length; i++) { - if (counted.has(messages[i])) trailingTokens += estimateTokens(messages[i]); + trailingTokens += estimateTokens(messages[i]); } return { @@ -413,8 +361,6 @@ export function estimateTokens(message: AgentMessage): number { chars = message.summary.length; return Math.ceil(chars / 4); } - case "configurationUpdate": - return 0; } } @@ -428,7 +374,6 @@ function isCutPointMessage(message: AgentMessage): boolean { case "compactionSummary": return true; case "toolResult": - case "configurationUpdate": return false; } return false; @@ -444,7 +389,6 @@ function isTurnStartMessage(message: AgentMessage): boolean { return true; case "assistant": case "toolResult": - case "configurationUpdate": return false; } return false; @@ -524,11 +468,7 @@ export function findCutPoint( const cutPoints = findValidCutPoints(entries, startIndex, endIndex); if (cutPoints.length === 0) { - return { - firstKeptEntryIndex: startIndex, - turnStartIndex: -1, - isSplitTurn: false, - }; + return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; } // Walk backwards from newest, accumulating estimated message sizes @@ -591,13 +531,6 @@ export function findCutPoint( // Summarization // ============================================================================ -export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined { - if (response.stopReason === "error") return `${label} failed: ${response.errorMessage || "Unknown error"}`; - if (response.stopReason === "length") - return `${label} failed: generation hit the token cap and the summary is incomplete`; - return undefined; -} - const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. Use this EXACT format: @@ -631,7 +564,9 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES: +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: - PRESERVE all existing information from the previous summary - ADD new progress, decisions, and context from the new messages - UPDATE the Progress section: move items from "In Progress" to "Done" when completed @@ -668,15 +603,7 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. - -${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; - -const SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT = `The messages above contain an existing structured summary of earlier conversation history followed by NEW conversation messages. - -${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; - -export function createSummarizationOptions( +function createSummarizationOptions( model: Model, maxTokens: number, apiKey: string | undefined, @@ -686,22 +613,9 @@ export function createSummarizationOptions( thinkingLevel: ThinkingLevel | undefined, extraBody?: Record, sessionId?: string, - requestOptions?: CacheFriendlySummaryOptions["requestOptions"], - cacheRetention?: SimpleStreamOptions["cacheRetention"], ): SummarizationOptions { - const options: SummarizationOptions = { - ...requestOptions, - maxTokens, - signal, - apiKey, - headers, - env, - extraBody, - cacheRetention, - }; + const options: SummarizationOptions = { maxTokens, signal, apiKey, headers, env, extraBody }; if (sessionId) options.affinitySessionId = sessionId; - const refusalFallbacks = getAnthropicSummarizationFallback(model); - if (refusalFallbacks) options.refusalFallbacks = refusalFallbacks; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } @@ -723,12 +637,10 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Summary requests retain the fork's request-identity split: each request gets a - // fresh identity while affinity follows the caller. Cache-friendly callers may - // opt into short retention for an exact provider-context prefix. + // Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused. const isolatedOptions: SimpleStreamOptions = { ...options, - cacheRetention: options.cacheRetention ?? "none", + cacheRetention: "none", affinitySessionId: options.affinitySessionId ?? options.sessionId, sessionId: uuidv7(), }; @@ -743,10 +655,7 @@ export async function completeSummarization( else callerSignal.addEventListener("abort", onCallerAbort, { once: true }); } try { - const requestOptions = { - ...isolatedOptions, - signal: requestController.signal, - }; + const requestOptions = { ...isolatedOptions, signal: requestController.signal }; const responseStream = Promise.resolve( streamFn ? streamFn(model, context, requestOptions) : streamSimple(model, context, requestOptions), ); @@ -769,16 +678,10 @@ async function transformSummarySource( previousSummary: string | undefined, transformContext: ((messages: AgentMessage[], signal?: AbortSignal) => Promise) | undefined, signal: AbortSignal | undefined, -): Promise<{ - readonly messages: AgentMessage[]; - readonly previousSummary: string | undefined; -}> { +): Promise<{ readonly messages: AgentMessage[]; readonly previousSummary: string | undefined }> { if (!transformContext) return { messages: currentMessages, previousSummary }; if (!previousSummary) { - return { - messages: await transformContext(currentMessages, signal), - previousSummary: undefined, - }; + return { messages: await transformContext(currentMessages, signal), previousSummary: undefined }; } const timestamps = new Set(currentMessages.map((message) => message.timestamp)); @@ -826,7 +729,6 @@ export async function generateSummary( retry?: RetryPolicy, callbacks?: RetryCallbacks, sessionId?: string, - cacheFriendly?: Pick, ): Promise { return ( await generateSummaryWithUsage( @@ -846,47 +748,10 @@ export async function generateSummary( retry, callbacks, sessionId, - cacheFriendly, ) ).text; } -/** Build a standalone summary request or append its instruction to an existing provider context. */ -export function buildSummarizationContext(promptText: string, sourceContext?: Context): Context { - const instructionMessage = { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }; - - if (sourceContext) { - return { - ...sourceContext, - messages: [...sourceContext.messages, instructionMessage], - }; - } - - return { - systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, - messages: [instructionMessage], - }; -} - -/** - * Extra room for provider framing and tokenizer variance omitted by the heuristic context estimate. - * This matches the 4096-token margin used when normal simple requests clamp maxTokens to their context window. - */ -const CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS = 4096; - -/** Whether the source context leaves room for the requested summary output and provider safety margin. */ -export function cacheFriendlyContextFits(model: Model, context: Context, maxTokens: number): boolean { - return ( - model.contextWindow <= 0 || - estimateProviderContextTokens(context).tokens + maxTokens + CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS <= - model.contextWindow - ); -} - /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], @@ -905,48 +770,41 @@ export async function generateSummaryWithUsage( retry?: RetryPolicy, callbacks?: RetryCallbacks, sessionId?: string, - cacheFriendly?: Pick, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); - let sourceContext = cacheFriendly?.sourceContext; - let transformedSource = { messages: currentMessages, previousSummary }; - if (!sourceContext) { - transformedSource = await transformSummarySource(currentMessages, previousSummary, transformContext, signal); - } + + const transformedSource = await transformSummarySource(currentMessages, previousSummary, transformContext, signal); const providerPreviousSummary = transformedSource.previousSummary; - let basePrompt = providerPreviousSummary - ? sourceContext - ? SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT - : UPDATE_SUMMARIZATION_PROMPT - : SUMMARIZATION_PROMPT; + + // Use update prompt if we have a previous summary, otherwise initial prompt + let basePrompt = providerPreviousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } - if ( - sourceContext && - !cacheFriendlyContextFits(model, buildSummarizationContext(basePrompt, sourceContext), maxTokens) - ) { - sourceContext = undefined; - transformedSource = await transformSummarySource(currentMessages, previousSummary, transformContext, signal); - basePrompt = transformedSource.previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; - if (customInstructions) basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; - } + // Serialize conversation to text so model doesn't try to continue it + // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) + const llmMessages = convertToLlm(transformedSource.messages); + const conversationText = serializeConversation(llmMessages); - let promptText = ""; - if (!sourceContext) { - const llmMessages = convertToLlm(transformedSource.messages); - const conversationText = serializeConversation(llmMessages); - promptText = `\n${conversationText}\n\n\n`; - if (transformedSource.previousSummary) { - promptText += `\n${transformedSource.previousSummary}\n\n\n`; - } + // Build the prompt with conversation wrapped in tags + let promptText = `\n${conversationText}\n\n\n`; + if (providerPreviousSummary) { + promptText += `\n${providerPreviousSummary}\n\n\n`; } promptText += basePrompt; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + const completionOptions = createSummarizationOptions( model, maxTokens, @@ -957,22 +815,18 @@ export async function generateSummaryWithUsage( thinkingLevel, extraBody, sessionId, - sourceContext ? cacheFriendly?.requestOptions : undefined, - sourceContext ? "short" : undefined, ); const response = await completeSummarization( model, - buildSummarizationContext(promptText, sourceContext), + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, streamFn, retry, callbacks, ); - const failure = getSummarizationFailure(response, "Summarization"); - if (failure) throw new Error(failure); - if (response.content.some((block) => block.type === "toolCall")) { - throw new Error("Summarization attempted to call a tool"); + if (response.stopReason === "error") { + throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); } const textContent = contentTextForSummary(response.content); @@ -989,15 +843,8 @@ export interface CompactionPreparation { firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; - /** - * Active-context prefix for the history summary. - * Includes the previous compaction summary before messages retained by that compaction. - */ - sourceMessages?: AgentMessage[]; /** Messages that will be turned into turn prefix summary (if splitting) */ turnPrefixMessages: AgentMessage[]; - /** Active-context prefix through the split-turn prefix, or empty when not splitting. */ - turnPrefixSourceMessages?: AgentMessage[]; /** Whether this is a split turn (cut point in middle of turn) */ isSplitTurn: boolean; tokensBefore: number; @@ -1070,8 +917,6 @@ export function prepareCompaction( if (msg) messagesToSummarize.push(msg); } - const sourceMessages = collectSourceMessages(pathEntries, boundaryStart, historyEnd, prevCompactionIndex); - // Messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { @@ -1080,9 +925,6 @@ export function prepareCompaction( if (msg) turnPrefixMessages.push(msg); } } - const turnPrefixSourceMessages = cutPoint.isSplitTurn - ? collectSourceMessages(pathEntries, boundaryStart, cutPoint.firstKeptEntryIndex, prevCompactionIndex) - : []; // A model switch can make an existing summary too large even when no new // messages were added. The retry fallback path explicitly opts into @@ -1104,9 +946,7 @@ export function prepareCompaction( return { firstKeptEntryId, messagesToSummarize, - sourceMessages, turnPrefixMessages, - turnPrefixSourceMessages, isSplitTurn: cutPoint.isSplitTurn, tokensBefore, previousSummary, @@ -1119,4 +959,194 @@ export function prepareCompaction( // Main compaction function // ============================================================================ -export { compact } from "./compaction-execution.ts"; +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + +/** + * Generate summaries for compaction using prepared data. + * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. + * + * @param preparation - Pre-calculated preparation from prepareCompaction() + * @param customInstructions - Optional custom focus for the summary + */ +export async function compact( + preparation: CompactionPreparation, + model: Model, + apiKey: string | undefined, + headers?: Record, + customInstructions?: string, + signal?: AbortSignal, + extraBody?: Record, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + env?: Record, + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, + sessionId?: string, +): Promise { + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; + + // Generate summaries and merge into one + let summary: string; + let summaryUsage: Usage; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + let historyText = "No prior history."; + let historyUsage: Usage | undefined; + if (messagesToSummarize.length > 0) { + const historyResult = await generateSummaryWithUsage( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + extraBody, + thinkingLevel, + streamFn, + env, + transformContext, + retry, + callbacks, + sessionId, + ); + historyText = historyResult.text; + historyUsage = historyResult.usage; + } + const turnPrefixResult = await generateTurnPrefixSummary( + turnPrefixMessages, + model, + settings.reserveTokens, + apiKey, + headers, + env, + signal, + extraBody, + thinkingLevel, + streamFn, + transformContext, + retry, + callbacks, + sessionId, + ); + // Merge into single summary + summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; + summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage; + } else { + // Just generate history summary + const result = await generateSummaryWithUsage( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + extraBody, + thinkingLevel, + streamFn, + env, + transformContext, + retry, + callbacks, + sessionId, + ); + summary = result.text; + summaryUsage = result.usage; + } + + // Compute file lists and append to summary + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + if (!firstKeptEntryId) { + throw new Error("First kept entry has no UUID - session may need migration"); + } + + return { + summary, + firstKeptEntryId, + tokensBefore, + usage: summaryUsage, + details: { readFiles, modifiedFiles } as CompactionDetails, + }; +} + +/** + * Generate a summary for a turn prefix (when splitting a turn). + */ +async function generateTurnPrefixSummary( + messages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + env?: Record, + signal?: AbortSignal, + extraBody?: Record, + thinkingLevel?: ThinkingLevel, + streamFn?: SummarizationStreamFn, + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, + sessionId?: string, +): Promise<{ text: string; usage: Usage }> { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Smaller budget for turn prefix + const providerMessages = transformContext ? await transformContext(messages, signal) : messages; + const llmMessages = convertToLlm(providerMessages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await completeSummarization( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, extraBody, sessionId), + streamFn, + retry, + callbacks, + ); + + if (response.stopReason === "error") { + throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + } + + return { + text: contentTextForSummary(response.content), + usage: response.usage, + }; +} diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts index d601c2a0b1..9fe7cc3498 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts @@ -26,8 +26,9 @@ export const CLAUDE_SDK_OAUTH_COMPACT_ENTRY_TYPE = "claude-sdk-oauth-compact"; export const CLAUDE_SDK_OAUTH_COMPACT_BOUNDARY_DIAGNOSTIC = "claude_sdk_oauth_compact_boundary"; /** * Reason reported when senpi declines to compact an SDK-native lane. The - * `external-owner` rejection cause carries the machine-readable policy while - * this string is what the user and the compaction log see. + * structured `rejectionCause` stays `cancelled-by-extension` (a closed union + * shared with the RPC surface); this string is what the user and the compaction + * log see. */ export const SDK_NATIVE_LANE_REJECTION_REASON = "the Claude Agent SDK owns compaction for this session"; const COMPACT_BOUNDARY_SCHEMA = "senpi.claude-sdk-oauth.compact-boundary.v1"; @@ -45,6 +46,13 @@ export interface SdkNativeLaneInput { export interface LaneContext { cwd: string; model: LaneModel | undefined; + /** + * Optional resolved-settings getter (present on the real ExtensionContext). + * Used to read a configured `compaction.model` override. When an override is + * set the lane hands summarization to a senpi-owned model, so the SDK-native + * stand-down no longer applies even with a resident SDK session. + */ + getCompactionSettings?: () => { model?: string }; } export interface CompactionLanePolicy { @@ -86,6 +94,10 @@ export function createCompactionLanePolicy( return { disablesSenpiCompaction(context: LaneContext): boolean { if (context.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) return false; + // A configured compaction model override makes senpi own summarization + // for the lane, so the SDK-native stand-down no longer applies. This is + // the escape hatch for lanes whose SDK never fires native compaction. + if (context.getCompactionSettings?.().model) return false; // Per-cwd cache is the intended contract (pinned by lane-policy.test.ts): // resumeMode is read once per cwd. A mid-session switch takes effect on // the next cwd or session. diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 75eab147ea..cbc596f14f 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,40 +1,15 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Transport } from "@earendil-works/pi-ai"; -import { SENPI_DEFAULT_RETRY_PROFILE } from "@earendil-works/pi-ai/utils/retry-profile/profiles"; -import type { - RetryPolicyProfile, - RetryStagePolicy, - RetryTieredHintStrategy, -} from "@earendil-works/pi-ai/utils/retry-profile/types"; -import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@earendil-works/pi-tui"; +import type { ScrollViewScrollbar } from "@earendil-works/pi-tui"; import { createHash, randomUUID } from "crypto"; -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { findNearestParentConfigDir } from "../nearest-parent-config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; -import { stripBom } from "../utils/text.ts"; -import { envValue } from "./brand.ts"; -import type { CompactionSettings } from "./compaction-settings-access.ts"; -import { - compactionEnabled, - compactionKeepRecentTokens, - compactionReserveTokens, -} from "./compaction-settings-access.ts"; -import { type ResolvedCompactionSettings, resolveCompactionSettings } from "./compaction-settings-resolver.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; -import { - CredentialStoreBusyError, - FILE_STORAGE_LOCK_OPTIONS, - FILE_STORAGE_LOCK_RETRY_MAX_DELAY_MS, - FILE_STORAGE_LOCK_RETRY_MIN_DELAY_MS, - FILE_STORAGE_SYNC_LOCK_BUDGET_MS, - isLockError, -} from "./lockfile-policy.ts"; -import type { RetryPolicyOverride } from "./retry-fallback/profile-override.ts"; -import { validateRetryProviderOverrides } from "./retry-fallback/profile-override.ts"; import { type ResolvedHintPolicySettings, type ResolvedRetryFallbackSettings, @@ -43,50 +18,88 @@ import { resolveHintPolicySettings, resolveRetryFallbackSettings, } from "./retry-fallback/settings.ts"; -import type { - ImageSettings, - LookAtSettings, - MarkdownSettings, - MermaidRenderingMode, - OpenAISettings, - PromptCacheKeepAliveSettings, - PromptCacheSettings, - ThinkingBudgetsSettings, -} from "./settings-shapes.ts"; -import type { BranchSummarySettings, TerminalSettings } from "./terminal-settings.ts"; - -export type * from "./settings-public-types.ts"; - -export const DEFAULT_STREAM_START_TIMEOUT_MS = 300_000; + +export type { ProviderRetrySettings, RetrySettings } from "./retry-fallback/settings.ts"; + +export const DEFAULT_STREAM_START_TIMEOUT_MS = 90_000; export const DEFAULT_PROVIDER_STREAM_RETRY_TIMEOUT_MS = 30_000; -export type TuiMode = RendererTuiMode; -export type FullscreenExitOutput = "transcript" | "resume-hint"; +export type UiMode = "regular" | "fullscreen"; + +export interface CompactionSettings { + enabled?: boolean; // default: true + reserveTokens?: number; // default: 16384 + keepRecentTokens?: number; // default: 20000 + speculativeEnabled?: boolean; // default: true + speculativeFraction?: number; // default: 0.75 + speculativeCooldownMs?: number; // default: 30000 + restorationEnabled?: boolean; // default: true + restorationMaxItems?: number; // default: 10 + restorationMaxTokensPerItem?: number; // default: 5000 + restorationMaxTotalTokens?: number; // default: 50000 + restorationContextRatio?: number; // default: 0.15 + idleCompactionEnabled?: boolean; // default: true + /** + * Optional compaction model override as "provider/model" (e.g. "deepseek/deepseek-chat"). + * When set, compaction summarization runs on this model instead of the session model, + * and a configured override re-enables senpi compaction on SDK-owned lanes + * (claude-sdk-oauth with a resident session) whose own compaction never fires. + */ + model?: string; // default: undefined (use session model) +} + +export interface BranchSummarySettings { + reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) + skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary +} -/** Service tier remembered per model; "auto" is an explicit opt-out of an inherited priority tier. */ -export type ModelServiceTier = "auto" | "flex" | "priority"; +export interface TerminalSettings { + showImages?: boolean; // default: true (only relevant if terminal supports images) + imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells) + clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks) + showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators) + // Persistent-terminal tool suite (builtin `terminal` extension) config. + defaultCols?: number; // default: 120 (PTY width for new sessions) + defaultRows?: number; // default: 40 (PTY height for new sessions) + scrollback?: number; // default: 10000 (xterm scrollback lines per session) + maxSessions?: number; // default: 32 (concurrent background sessions before LRU-exited pruning) + timeoutAction?: "background" | "kill"; // default: "background" (fate of a foreground timeout) + notify?: "wake" | "next-turn" | "off"; // default: "wake" (async completion wake behavior) + monitorCoalesceWindowMs?: number; // default: 2000 (event batching window) + monitorRateLimitMs?: number; // default: 5000 (minimum interval per monitor injection) + monitorMaxLinesPerInjection?: number; // default: 50 (bounded monitor event batch) + monitorMaxCharsPerInjection?: number; // default: 4096 (bounded monitor event batch) + monitorWakeBudget?: number; // default: 5 (consecutive monitor-only wake limit) +} -const THINKING_LEVEL_VALUES: ReadonlySet = new Set([ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -]); +export interface PromptCacheSettings { + cacheAwareTimeouts?: boolean; // default: true (size foreground tool waits by the model's prompt-cache TTL) + safetyBufferSeconds?: number; // default: 30 (headroom subtracted from the cache TTL) +} -const MODEL_SERVICE_TIER_VALUES: ReadonlySet = new Set(["auto", "flex", "priority"]); +export interface ImageSettings { + autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) + blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers +} -/** Opaque per-model memory key. Ids may contain `/` and `:`, so keys are never split back apart. */ -function modelMemoryKey(provider: string, modelId: string): string { - return `${provider}/${modelId}`; +export interface LookAtSettings { + enabled?: boolean; // default: true + models?: string[]; // default: undefined (use the default look-at chain) } -function readModelMemoryEntry(map: unknown, key: string, allowed: ReadonlySet): string | undefined { - if (typeof map !== "object" || map === null || Array.isArray(map)) return undefined; - const value = (map as Record)[key]; - return typeof value === "string" && allowed.has(value) ? value : undefined; +export interface ThinkingBudgetsSettings { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +export interface MarkdownSettings { + codeBlockIndent?: string; // default: " " +} + +export interface OpenAISettings { + serviceTier?: "auto" | "flex" | "priority"; } export interface WarningSettings { @@ -116,18 +129,11 @@ export type PackageSource = hooks?: string[]; }; -export interface ExperimentalSettings { - sharedHost?: boolean; -} - export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: ThinkingLevel; - modelThinkingLevels?: Record; // `${provider}/${id}` -> effective thinking level for that model - modelLastOnThinkingLevels?: Record; // `${provider}/${id}` -> last non-off thinking level - modelServiceTiers?: Record; // `${provider}/${id}` -> last service tier set for that model transport?: TransportSetting; // default: "auto" steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time"; @@ -138,7 +144,7 @@ export interface Settings { hideThinkingBlock?: boolean; smoothStreaming?: boolean; // default: true smoothStreamingFps?: number; // default: 60, clamped to 30-120 when read - showCacheMissNotices?: boolean; // default: false - show prompt-cache miss and compaction cost notices + showCacheMissNotices?: boolean; // default: false - show transcript notices for significant prompt-cache misses externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion quietStartup?: boolean; @@ -167,7 +173,6 @@ export interface Settings { recommendedModels?: string[]; // Preferred default model ids, in priority order favoriteModels?: string[]; // Model patterns for Ctrl+P cycling (same format as --models CLI flag) enabledModels?: string[]; // Legacy global model narrowing patterns (same format as --models CLI flag) - defaultTools?: string[]; // Initial built-in tool selection doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels @@ -182,48 +187,44 @@ export interface Settings { httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it - tuiMode?: TuiMode; // default: "regular" - fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode - fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode - experimental?: ExperimentalSettings; - fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode + uiMode?: UiMode; // default: "regular" + fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular UI mode } -function isMergeableObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function deepMergeObjects(base: Record, overrides: Record): Record { - const result = { ...base }; +/** + * Merge settings one object level deep: project/overrides take precedence. + * Nested settings such as retry.fallbackChains replace wholesale per scope. + */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + const result: Settings = { ...base }; - for (const key of Object.keys(overrides)) { + for (const key of Object.keys(overrides) as (keyof Settings)[]) { const overrideValue = overrides[key]; + const baseValue = base[key]; + if (overrideValue === undefined) { continue; } - const baseValue = base[key]; - result[key] = - isMergeableObject(baseValue) && isMergeableObject(overrideValue) - ? deepMergeObjects(baseValue, overrideValue) - : overrideValue; + // For nested objects, merge recursively + if ( + typeof overrideValue === "object" && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + (result as Record)[key] = { ...baseValue, ...overrideValue }; + } else { + // For primitives and arrays, override value wins + (result as Record)[key] = overrideValue; + } } return result; } -/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ -function deepMergeSettings(base: Settings, overrides: Settings): Settings { - const result = deepMergeObjects(base as Record, overrides as Record) as Settings; - if (overrides.retry?.fallbackChains !== undefined) { - result.retry = { - ...result.retry, - fallbackChains: structuredClone(overrides.retry.fallbackChains), - }; - } - return result; -} - function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { const timeoutMs = parseHttpIdleTimeoutMs(value); if (timeoutMs !== undefined) { @@ -236,96 +237,6 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde } export type SettingsScope = "global" | "project"; -export type SettingsFormat = "jsonc" | "json"; -export type SettingsSourceReason = "explicit-jsonc" | "json-only"; - -export interface SettingsSourceSelection { - path: string; - format: SettingsFormat; - reason: SettingsSourceReason; - scope: SettingsScope; -} - -export type SettingsSourceListener = (source: SettingsSourceSelection) => void; - -/** Parse JSON or JSONC without changing comment-like text inside strings. */ -export function parseSettingsJson(content: string): Record { - content = stripBom(content); - const withoutComments: string[] = []; - let inString = false; - let escaped = false; - - for (let index = 0; index < content.length; index += 1) { - const char = content[index]; - const next = content[index + 1]; - if (inString) { - withoutComments.push(char); - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inString = false; - continue; - } - if (char === '"') { - inString = true; - withoutComments.push(char); - continue; - } - if (char === "/" && next === "/") { - withoutComments.push(" ", " "); - index += 2; - while (index < content.length && content[index] !== "\n" && content[index] !== "\r") { - withoutComments.push(" "); - index += 1; - } - if (index < content.length) withoutComments.push(content[index]); - continue; - } - if (char === "/" && next === "*") { - withoutComments.push(" ", " "); - index += 2; - let closed = false; - for (; index < content.length; index += 1) { - if (content[index] === "*" && content[index + 1] === "/") { - withoutComments.push(" ", " "); - index += 1; - closed = true; - break; - } - withoutComments.push(content[index] === "\n" || content[index] === "\r" ? content[index] : " "); - } - if (!closed) throw new SyntaxError("Unterminated block comment in settings"); - continue; - } - withoutComments.push(char); - } - - const normalized = withoutComments; - inString = false; - escaped = false; - for (let index = 0; index < normalized.length; index += 1) { - const char = normalized[index]; - if (inString) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inString = false; - continue; - } - if (char === '"') { - inString = true; - continue; - } - if (char !== ",") continue; - let nextIndex = index + 1; - while (nextIndex < normalized.length && /\s/.test(normalized[nextIndex])) nextIndex += 1; - if (normalized[nextIndex] === "}" || normalized[nextIndex] === "]") normalized[index] = " "; - } - - const parsed: unknown = JSON.parse(normalized.join("")); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new TypeError("Settings must contain a JSON object"); - } - return parsed as Record; -} const SELF_WRITE_TTL_MS = 15_000; const MAX_SELF_WRITES_PER_PATH = 8; @@ -394,47 +305,19 @@ export function __setSelfWriteTrackerClockForTests(clock: (() => number) | undef selfWriteClock = clock ?? Date.now; } -function getSettingsDirectory(cwd: string, agentDir: string, scope: SettingsScope, homeDir: string): string { - if (scope === "global") return resolvePath(agentDir); - const resolvedCwd = resolvePath(cwd); - return findNearestParentConfigDir(resolvedCwd, homeDir, CONFIG_DIR_NAME) ?? join(resolvedCwd, CONFIG_DIR_NAME); -} - -/** Resolve the existing settings source, preferring JSONC when both formats exist. */ -export function resolveSettingsSource( - cwd: string, - agentDir: string, - scope: SettingsScope, - homeDir: string = homedir(), -): SettingsSourceSelection | undefined { - const directory = getSettingsDirectory(cwd, agentDir, scope, homeDir); - const jsoncPath = join(directory, "settings.jsonc"); - if (existsSync(jsoncPath)) { - return { - path: jsoncPath, - format: "jsonc", - reason: "explicit-jsonc", - scope, - }; - } - const jsonPath = join(directory, "settings.json"); - if (existsSync(jsonPath)) { - return { path: jsonPath, format: "json", reason: "json-only", scope }; - } - return undefined; -} - -/** Returns the selected settings path, or the legacy JSON write target when no source exists. */ +/** Returns the absolute settings path for a filesystem-backed storage scope. */ export function getSettingsPath( cwd: string, agentDir: string, scope: SettingsScope, homeDir: string = homedir(), ): string { - return ( - resolveSettingsSource(cwd, agentDir, scope, homeDir)?.path ?? - join(getSettingsDirectory(cwd, agentDir, scope, homeDir), "settings.json") - ); + if (scope === "global") { + return join(resolvePath(agentDir), "settings.json"); + } + const resolvedCwd = resolvePath(cwd); + const projectConfigDir = findNearestParentConfigDir(resolvedCwd, homeDir, CONFIG_DIR_NAME); + return join(projectConfigDir ?? join(resolvedCwd, CONFIG_DIR_NAME), "settings.json"); } /** Returns the stable virtual path used to identify in-memory settings storage writes. */ @@ -448,111 +331,83 @@ export interface SettingsManagerCreateOptions { export interface SettingsStorage { withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; - selectSource?(scope: SettingsScope): SettingsSourceSelection | undefined; } export interface SettingsError { scope: SettingsScope; - path?: string; error: Error; } -type SettingsPaths = Partial>; - -function toSettingsError(scope: SettingsScope, error: unknown, path?: string): SettingsError { - return { - scope, - ...(path ? { path } : {}), - error: error instanceof Error ? error : new Error(String(error)), - }; -} - export class FileSettingsStorage implements SettingsStorage { private globalSettingsPath: string; private projectSettingsPath: string; - private readonly cwd: string; - private readonly agentDir: string; - private readonly homeDir: string; constructor(cwd: string, agentDir: string, homeDir: string = homedir()) { - this.cwd = cwd; - this.agentDir = agentDir; - this.homeDir = homeDir; this.globalSettingsPath = getSettingsPath(cwd, agentDir, "global", homeDir); this.projectSettingsPath = getSettingsPath(cwd, agentDir, "project", homeDir); } - selectSource(scope: SettingsScope): SettingsSourceSelection | undefined { - const source = resolveSettingsSource(this.cwd, this.agentDir, scope, this.homeDir); - const path = - source?.path ?? join(getSettingsDirectory(this.cwd, this.agentDir, scope, this.homeDir), "settings.json"); - if (scope === "global") this.globalSettingsPath = path; - else this.projectSettingsPath = path; - return source; - } - private acquireLockSyncWithRetry(path: string): () => void { - const startedAt = Date.now(); - let attempt = 0; - while (true) { + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return lockfile.lockSync(path, { ...FILE_STORAGE_LOCK_OPTIONS, retries: 0 }); + return lockfile.lockSync(path, { realpath: false }); } catch (error) { - if (!isLockError(error)) throw error; - const waitedMs = Date.now() - startedAt; - if (waitedMs >= FILE_STORAGE_SYNC_LOCK_BUDGET_MS) { - throw new CredentialStoreBusyError(path, waitedMs, error); + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing callers to async. } - const delayMs = Math.min( - FILE_STORAGE_LOCK_RETRY_MIN_DELAY_MS * 2 ** attempt, - FILE_STORAGE_LOCK_RETRY_MAX_DELAY_MS, - FILE_STORAGE_SYNC_LOCK_BUDGET_MS - waitedMs, - ); - attempt++; - const sleeper = new Int32Array(new SharedArrayBuffer(4)); - Atomics.wait(sleeper, 0, 0, delayMs); } } + + throw (lastError as Error) ?? new Error("Failed to acquire settings lock"); } withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath; const dir = dirname(path); - // Read without the lock: writers publish atomically via temp+rename below, so - // a reader can never observe partial content. Read-only callers therefore skip - // lock acquisition entirely (no lock churn, no lock-dir filesystem events). - const current = existsSync(path) ? readFileSync(path, "utf-8") : undefined; - let next = fn(current); - if (next === undefined) { - return; - } - // Only create directory when we actually need to write - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - const release = this.acquireLockSyncWithRetry(path); + let release: (() => void) | undefined; try { - const underLock = existsSync(path) ? readFileSync(path, "utf-8") : undefined; - if (underLock !== current) { - // Lost a write race: re-merge against the winner's content under the lock. - next = fn(underLock); + // Only create directory and lock if file exists or we need to write + let current: string | undefined; + if (existsSync(path)) { + release = this.acquireLockSyncWithRetry(path); + current = existsSync(path) ? readFileSync(path, "utf-8") : undefined; } + let next = fn(current); if (next !== undefined) { - // Publish atomically: write a same-directory temp file, then rename over - // the settings path so lock-free readers never see a torn write. - const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; - try { - writeFileSync(tempPath, next, "utf-8"); + // Only create directory when we actually need to write + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!release) { + release = this.acquireLockSyncWithRetry(path); + if (existsSync(path)) { + // Lost the first-write race: re-merge against the winner's content under the lock. + next = fn(readFileSync(path, "utf-8")); + } + } + if (next !== undefined) { + writeFileSync(path, next, "utf-8"); recordSelfWrite(path, next); - renameSync(tempPath, path); - } catch (error) { - rmSync(tempPath, { force: true }); - throw error; } } } finally { - release(); + if (release) { + release(); + } } } } @@ -589,9 +444,6 @@ export class SettingsManager { private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors private writeQueue: Promise = Promise.resolve(); private errors: SettingsError[]; - private settingsPaths: SettingsPaths; - private selectedSources = new Map(); - private sourceListeners: SettingsSourceListener[] = []; private constructor( storage: SettingsStorage, @@ -601,8 +453,6 @@ export class SettingsManager { projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], projectTrusted = true, - initialSources: readonly SettingsSourceSelection[] = [], - settingsPaths: SettingsPaths = {}, ) { this.storage = storage; this.globalSettings = initialGlobal; @@ -611,8 +461,6 @@ export class SettingsManager { this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; - this.settingsPaths = settingsPaths; - for (const source of initialSources) this.selectedSources.set(source.scope, source); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } @@ -629,26 +477,14 @@ export class SettingsManager { /** Create a SettingsManager from an arbitrary storage backend */ static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { const projectTrusted = options.projectTrusted ?? true; - const initialSources: SettingsSourceSelection[] = []; - const settingsPaths: SettingsPaths = {}; - const globalSource = storage.selectSource?.("global"); - if (globalSource) { - initialSources.push(globalSource); - settingsPaths.global = globalSource.path; - } const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); - const projectSource = projectTrusted ? storage.selectSource?.("project") : undefined; - if (projectSource) { - initialSources.push(projectSource); - settingsPaths.project = projectSource.path; - } const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { - initialErrors.push(toSettingsError("global", globalLoad.error, settingsPaths.global)); + initialErrors.push({ scope: "global", error: globalLoad.error }); } if (projectLoad.error) { - initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project)); + initialErrors.push({ scope: "project", error: projectLoad.error }); } return new SettingsManager( @@ -659,8 +495,6 @@ export class SettingsManager { projectLoad.error, initialErrors, projectTrusted, - initialSources, - settingsPaths, ); } @@ -686,7 +520,8 @@ export class SettingsManager { if (!content) { return {}; } - return SettingsManager.migrateSettings(parseSettingsJson(content)); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); } private static tryLoadFromStorage( @@ -695,10 +530,7 @@ export class SettingsManager { projectTrusted = true, ): { settings: Settings; error: Error | null } { try { - return { - settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), - error: null, - }; + return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null }; } catch (error) { return { settings: {}, error: error as Error }; } @@ -774,24 +606,6 @@ export class SettingsManager { return structuredClone(this.projectSettings); } - getPromptCacheGoalBackstopMaxSeconds(): number { - return ( - this.projectSettings.promptCache?.goalBackstopMaxSeconds ?? - this.globalSettings.promptCache?.goalBackstopMaxSeconds ?? - 3570 - ); - } - - getPromptCacheKeepAliveSettings(): Required { - const configured = this.settings.promptCache?.keepAlive; - return { - enabled: configured?.enabled ?? false, - maxRequestsPerSession: configured?.maxRequestsPerSession ?? 3, - maxCostUsdPerSession: configured?.maxCostUsdPerSession ?? 0.05, - marginSeconds: configured?.marginSeconds ?? 60, - }; - } - isProjectTrusted(): boolean { return this.projectTrusted; } @@ -812,7 +626,6 @@ export class SettingsManager { return; } - this.selectAndPublishSource("project"); const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted); this.projectSettings = projectLoad.settings; this.projectSettingsLoadError = projectLoad.error; @@ -824,7 +637,6 @@ export class SettingsManager { async reload(): Promise { await this.writeQueue; - this.selectAndPublishSource("global"); const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); if (!globalLoad.error) { this.globalSettings = globalLoad.settings; @@ -839,7 +651,6 @@ export class SettingsManager { this.modifiedProjectFields.clear(); this.modifiedProjectNestedFields.clear(); - if (this.projectTrusted) this.selectAndPublishSource("project"); const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted); if (!projectLoad.error) { this.projectSettings = projectLoad.settings; @@ -852,28 +663,6 @@ export class SettingsManager { this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } - getSelectedSettingsSources(): SettingsSourceSelection[] { - return [...this.selectedSources.values()].map((source) => ({ ...source })); - } - - subscribeToSourceSelection(listener: SettingsSourceListener): () => void { - this.sourceListeners.push(listener); - return () => { - const index = this.sourceListeners.indexOf(listener); - if (index !== -1) this.sourceListeners.splice(index, 1); - }; - } - - private selectAndPublishSource(scope: SettingsScope): void { - const source = this.storage.selectSource?.(scope); - if (!source) { - this.selectedSources.delete(scope); - return; - } - this.selectedSources.set(scope, source); - for (const listener of this.sourceListeners) listener({ ...source }); - } - /** Apply additional overrides on top of current settings */ applyOverrides(overrides: Partial): void { this.settings = deepMergeSettings(this.settings, overrides); @@ -909,7 +698,7 @@ export class SettingsManager { private recordError(scope: SettingsScope, error: unknown): void { const normalizedError = error instanceof Error ? error : new Error(String(error)); - this.errors.push(toSettingsError(scope, normalizedError, this.settingsPaths[scope])); + this.errors.push({ scope, error: normalizedError }); } private clearModifiedScope(scope: SettingsScope): void { @@ -952,7 +741,9 @@ export class SettingsManager { modifiedNestedFields: Map>, ): void { this.storage.withLock(scope, (current) => { - const currentFileSettings = current ? SettingsManager.migrateSettings(parseSettingsJson(current)) : {}; + const currentFileSettings = current + ? SettingsManager.migrateSettings(JSON.parse(current) as Record) + : {}; const mergedSettings: Settings = { ...currentFileSettings }; for (const field of modifiedFields) { const value = snapshotSettings[field]; @@ -1115,100 +906,6 @@ export class SettingsManager { this.save(); } - getAllModelThinkingLevels(): Record { - return { ...(this.settings.modelThinkingLevels ?? {}) }; - } - - /** Thinking level last set for this exact model, or undefined when unknown/invalid on disk. */ - getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined { - return readModelMemoryEntry( - this.settings.modelThinkingLevels, - modelMemoryKey(provider, modelId), - THINKING_LEVEL_VALUES, - ) as ThinkingLevel | undefined; - } - - /** Remember (or with `undefined`, forget) this model's effective thinking level in GLOBAL settings. */ - setModelThinkingLevel( - provider: string, - modelId: string, - level: ThinkingLevel | undefined, - options: { preserveLastOn?: boolean } = {}, - ): void { - const key = modelMemoryKey(provider, modelId); - const existing = this.globalSettings.modelThinkingLevels; - const map: Record = - typeof existing === "object" && existing !== null && !Array.isArray(existing) ? { ...existing } : {}; - if (level === undefined) { - delete map[key]; - } else { - map[key] = level; - } - this.globalSettings.modelThinkingLevels = map; - // Nested key only: concurrent sessions writing OTHER models must survive the merge. - this.markModified("modelThinkingLevels", key); - - // `off` is durable effective state, but it must not erase the level `/reasoning on` restores. - if (level !== "off" && !options.preserveLastOn) { - this.updateModelLastOnThinkingLevel(key, level); - } - this.save(); - } - - /** Last non-off thinking level for this exact model, or undefined when unknown/invalid on disk. */ - getModelLastOnThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined { - const level = readModelMemoryEntry( - this.settings.modelLastOnThinkingLevels, - modelMemoryKey(provider, modelId), - THINKING_LEVEL_VALUES, - ) as ThinkingLevel | undefined; - return level === "off" ? undefined : level; - } - - /** Remember (or with `undefined`, forget) this model's last non-off level in GLOBAL settings. */ - setModelLastOnThinkingLevel(provider: string, modelId: string, level: ThinkingLevel | undefined): void { - this.updateModelLastOnThinkingLevel(modelMemoryKey(provider, modelId), level === "off" ? undefined : level); - this.save(); - } - - private updateModelLastOnThinkingLevel(key: string, level: ThinkingLevel | undefined): void { - const existing = this.globalSettings.modelLastOnThinkingLevels; - const map: Record = - typeof existing === "object" && existing !== null && !Array.isArray(existing) ? { ...existing } : {}; - if (level === undefined) { - delete map[key]; - } else { - map[key] = level; - } - this.globalSettings.modelLastOnThinkingLevels = map; - this.markModified("modelLastOnThinkingLevels", key); - } - - /** Service tier last set for this exact model, or undefined when unknown/invalid on disk. */ - getModelServiceTier(provider: string, modelId: string): ModelServiceTier | undefined { - return readModelMemoryEntry( - this.settings.modelServiceTiers, - modelMemoryKey(provider, modelId), - MODEL_SERVICE_TIER_VALUES, - ) as ModelServiceTier | undefined; - } - - /** Remember (or with `undefined`, forget) this model's service tier in GLOBAL settings. */ - setModelServiceTier(provider: string, modelId: string, tier: ModelServiceTier | undefined): void { - const key = modelMemoryKey(provider, modelId); - const existing = this.globalSettings.modelServiceTiers; - const map: Record = - typeof existing === "object" && existing !== null && !Array.isArray(existing) ? { ...existing } : {}; - if (tier === undefined) { - delete map[key]; - } else { - map[key] = tier; - } - this.globalSettings.modelServiceTiers = map; - this.markModified("modelServiceTiers", key); - this.save(); - } - getTransport(): TransportSetting { return this.settings.transport ?? "auto"; } @@ -1224,7 +921,7 @@ export class SettingsManager { } getCompactionEnabled(): boolean { - return compactionEnabled(this.settings.compaction); + return this.settings.compaction?.enabled ?? true; } setCompactionEnabled(enabled: boolean): void { @@ -1237,15 +934,43 @@ export class SettingsManager { } getCompactionReserveTokens(): number { - return compactionReserveTokens(this.settings.compaction); + return this.settings.compaction?.reserveTokens ?? 16384; } getCompactionKeepRecentTokens(): number { - return compactionKeepRecentTokens(this.settings.compaction); + return this.settings.compaction?.keepRecentTokens ?? 20000; } - getCompactionSettings(): ResolvedCompactionSettings { - return resolveCompactionSettings(this.settings.compaction); + getCompactionSettings(): { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; + speculativeEnabled: boolean; + speculativeFraction: number; + speculativeCooldownMs: number; + restorationEnabled: boolean; + restorationMaxItems: number; + restorationMaxTokensPerItem: number; + restorationMaxTotalTokens: number; + restorationContextRatio: number; + idleCompactionEnabled: boolean; + model?: string; + } { + return { + enabled: this.getCompactionEnabled(), + reserveTokens: this.getCompactionReserveTokens(), + keepRecentTokens: this.getCompactionKeepRecentTokens(), + speculativeEnabled: this.settings.compaction?.speculativeEnabled ?? true, + speculativeFraction: this.settings.compaction?.speculativeFraction ?? 0.75, + speculativeCooldownMs: this.settings.compaction?.speculativeCooldownMs ?? 30000, + restorationEnabled: this.settings.compaction?.restorationEnabled ?? true, + restorationMaxItems: this.settings.compaction?.restorationMaxItems ?? 10, + restorationMaxTokensPerItem: this.settings.compaction?.restorationMaxTokensPerItem ?? 5000, + restorationMaxTotalTokens: this.settings.compaction?.restorationMaxTotalTokens ?? 50_000, + restorationContextRatio: this.settings.compaction?.restorationContextRatio ?? 0.15, + idleCompactionEnabled: this.settings.compaction?.idleCompactionEnabled ?? true, + model: this.settings.compaction?.model, + }; } getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { @@ -1272,16 +997,10 @@ export class SettingsManager { this.save(); } - getRetrySettings(): { - enabled: boolean; - maxRetries: number; - baseDelayMs: number; - } { + getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } { return { enabled: this.getRetryEnabled(), - // Derived, not duplicated: the one `retry.maxRetries` key must mean the - // same budget on every consumer, so the default tracks the shipped profile. - maxRetries: this.settings.retry?.maxRetries ?? SENPI_DEFAULT_RETRY_PROFILE.turn.maxRetries, + maxRetries: this.settings.retry?.maxRetries ?? 3, baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, }; } @@ -1325,10 +1044,7 @@ export class SettingsManager { this.globalSettings.retry = {}; } const chains = this.getGlobalFallbackChains(); - this.globalSettings.retry.fallbackChains = { - ...chains, - [key]: [...entries], - }; + this.globalSettings.retry.fallbackChains = { ...chains, [key]: [...entries] }; this.markModified("retry", "fallbackChains"); this.save(); } @@ -1392,11 +1108,7 @@ export class SettingsManager { this.save(); } - getProviderRetrySettings(): { - timeoutMs?: number; - maxRetries?: number; - maxRetryDelayMs: number; - } { + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { return { timeoutMs: this.settings.retry?.provider?.timeoutMs, maxRetries: this.settings.retry?.provider?.maxRetries, @@ -1404,72 +1116,6 @@ export class SettingsManager { }; } - /** - * Resolve the effective retry profile for a provider. Precedence: - * 1) SENPI_DEFAULT_RETRY_PROFILE is the base. - * 2) A provider-declared retryPolicy replaces the base entirely. - * 3) User global retry.maxRetries / retry.baseDelayMs apply ONLY when the - * provider declared NO profile (they must not silently re-tune one). - * 4) Validated retry.providers. patches scheduling knobs last. - * 5) retry.enabled === false is a hard gate: resolved turn.enabled is false. - */ - resolveRetryProfile(provider: { id: string; retryPolicy?: RetryPolicyProfile } | undefined): RetryPolicyProfile { - const base = provider?.retryPolicy ?? SENPI_DEFAULT_RETRY_PROFILE; - const declared = provider?.retryPolicy !== undefined; - - const turnBackoff = { ...base.turn.backoff }; - if (!declared) { - if (this.settings.retry?.maxRetries !== undefined) { - turnBackoff.baseDelayMs = this.settings.retry.baseDelayMs ?? turnBackoff.baseDelayMs; - } - } - - let turnMaxRetries = base.turn.maxRetries; - let turnBaseDelayMs = turnBackoff.baseDelayMs; - if (!declared) { - if (this.settings.retry?.maxRetries !== undefined) turnMaxRetries = this.settings.retry.maxRetries; - if (this.settings.retry?.baseDelayMs !== undefined) turnBaseDelayMs = this.settings.retry.baseDelayMs; - } - - const providerOverride = this._resolveRetryProviderOverride(provider?.id); - if (providerOverride?.turn?.maxRetries !== undefined) turnMaxRetries = providerOverride.turn.maxRetries; - if (providerOverride?.turn?.baseDelayMs !== undefined) turnBaseDelayMs = providerOverride.turn.baseDelayMs; - - const turnEnabled = this.getRetryEnabled() ? (providerOverride?.turn?.enabled ?? base.turn.enabled) : false; - - const tierStrategy: RetryTieredHintStrategy = - base.turn.serverHint.mode === "tiered" - ? base.turn.serverHint.strategy - : () => { - throw new Error("not tiered"); - }; - - const turn: RetryStagePolicy = { - enabled: turnEnabled, - maxRetries: turnMaxRetries, - backoff: { ...base.turn.backoff, baseDelayMs: turnBaseDelayMs }, - extractServerHint: base.turn.extractServerHint, - serverHint: - base.turn.serverHint.mode === "tiered" ? { mode: "tiered", strategy: tierStrategy } : base.turn.serverHint, - classify: base.turn.classify, - }; - - return { - id: base.id, - providerRequest: base.providerRequest, - turn, - fallback: base.fallback, - }; - } - - private _resolveRetryProviderOverride(providerId: string | undefined): RetryPolicyOverride | undefined { - if (providerId === undefined) return undefined; - const raw = this.settings.retry?.providers; - if (raw === undefined) return undefined; - const { overrides } = validateRetryProviderOverrides(raw, new Set([providerId])); - return overrides[providerId]; - } - /** * First-request liveness cap for retries of known provider stream/transport * timeouts. `retry.provider.streamRetryTimeoutMs` overrides the 30s default; @@ -1507,7 +1153,7 @@ export class SettingsManager { * accepts the request but never answers is otherwise bounded only by the * idle timeout (default 5 minutes) — long enough to make a session feel * permanently stuck. `retry.provider.streamStartTimeoutMs` overrides the - * 300s default (0 disables). The default never exceeds the idle timeout and + * 90s default (0 disables). The default never exceeds the idle timeout and * is disabled together with a disabled idle guard. */ getAgentStreamStartTimeoutMs(): number | undefined { @@ -1801,33 +1447,6 @@ export class SettingsManager { return this.settings.thinkingBudgets; } - getTerminalCapabilityOverrides(): Partial { - const terminal = this.settings.terminal; - const imageSetting = terminal?.images; - const imageOverride = imageSetting !== undefined ? imageSetting : envValue("IMAGE_PROTOCOL")?.toLowerCase(); - const trueColorSetting = terminal?.trueColor; - const trueColorOverride = trueColorSetting !== undefined ? trueColorSetting : envValue("TRUE_COLOR"); - const hyperlinksSetting = terminal?.hyperlinks; - const hyperlinksOverride = hyperlinksSetting !== undefined ? hyperlinksSetting : envValue("HYPERLINKS"); - return { - ...(imageOverride === "kitty" || imageOverride === "iterm2" - ? { images: imageOverride } - : imageOverride === false || imageOverride === "none" || imageOverride === "0" - ? { images: null } - : {}), - ...(typeof trueColorOverride === "boolean" - ? { trueColor: trueColorOverride } - : trueColorOverride === "1" || trueColorOverride === "0" - ? { trueColor: trueColorOverride === "1" } - : {}), - ...(typeof hyperlinksOverride === "boolean" - ? { hyperlinks: hyperlinksOverride } - : hyperlinksOverride === "1" || hyperlinksOverride === "0" - ? { hyperlinks: hyperlinksOverride === "1" } - : {}), - }; - } - getShowImages(): boolean { return this.settings.terminal?.showImages ?? true; } @@ -1863,7 +1482,7 @@ export class SettingsManager { if (this.settings.terminal?.clearOnShrink !== undefined) { return this.settings.terminal.clearOnShrink; } - return envValue("CLEAR_ON_SHRINK") === "1"; + return process.env.PI_CLEAR_ON_SHRINK === "1"; } setClearOnShrink(enabled: boolean): void { @@ -1888,23 +1507,13 @@ export class SettingsManager { this.save(); } - getTuiMode(): TuiMode { - return this.settings.tuiMode === "fullscreen" ? "fullscreen" : "regular"; - } - - setTuiMode(mode: TuiMode): void { - this.globalSettings.tuiMode = mode; - this.markModified("tuiMode"); - this.save(); - } - - getFullscreenExitOutput(): FullscreenExitOutput { - return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript"; + getUiMode(): UiMode { + return this.settings.uiMode === "fullscreen" ? "fullscreen" : "regular"; } - setFullscreenExitOutput(output: FullscreenExitOutput): void { - this.globalSettings.fullscreenExitOutput = output; - this.markModified("fullscreenExitOutput"); + setUiMode(mode: UiMode): void { + this.globalSettings.uiMode = mode; + this.markModified("uiMode"); this.save(); } @@ -1919,16 +1528,6 @@ export class SettingsManager { this.save(); } - getFullscreenCopyOnSelect(): boolean { - return this.settings.fullscreenCopyOnSelect ?? true; - } - - setFullscreenCopyOnSelect(enabled: boolean): void { - this.globalSettings.fullscreenCopyOnSelect = enabled; - this.markModified("fullscreenCopyOnSelect"); - this.save(); - } - getImageAutoResize(): boolean { return this.settings.images?.autoResize ?? true; } @@ -1946,11 +1545,6 @@ export class SettingsManager { return this.settings.images?.blockImages ?? false; } - getMaxHistoricalImages(): number | undefined { - const value = this.settings.images?.maxHistoricalImages; - return Number.isInteger(value) && value !== undefined && value >= 0 ? value : undefined; - } - setBlockImages(blocked: boolean): void { if (!this.globalSettings.images) { this.globalSettings.images = {}; @@ -1978,15 +1572,6 @@ export class SettingsManager { this.save(); } - getDefaultTools(): string[] | undefined { - const tools = this.settings.defaultTools; - return tools ? [...tools] : undefined; - } - - getExperimentalSharedHost(): boolean { - return this.settings.experimental?.sharedHost === true; - } - setEnabledModels(patterns: string[] | undefined): void { this.globalSettings.enabledModels = patterns; this.markModified("enabledModels"); @@ -2016,7 +1601,7 @@ export class SettingsManager { } getShowHardwareCursor(): boolean { - return this.settings.showHardwareCursor ?? envValue("HARDWARE_CURSOR") === "1"; + return this.settings.showHardwareCursor ?? process.env.PI_HARDWARE_CURSOR === "1"; } setShowHardwareCursor(enabled: boolean): void { @@ -2059,18 +1644,6 @@ export class SettingsManager { return this.settings.markdown?.codeBlockIndent ?? " "; } - getMermaidRenderingMode(): MermaidRenderingMode { - const mode = this.settings.markdown?.mermaid; - return mode === "off" || mode === "final" ? mode : "streaming"; - } - - setMermaidRenderingMode(mode: MermaidRenderingMode): void { - this.globalSettings.markdown ??= {}; - this.globalSettings.markdown.mermaid = mode; - this.markModified("markdown", "mermaid"); - this.save(); - } - getWarnings(): WarningSettings { return { ...(this.settings.warnings ?? {}) }; } diff --git a/packages/coding-agent/test/compaction/idle-settings.test.ts b/packages/coding-agent/test/compaction/idle-settings.test.ts index ba6f04b5ab..f7a251f467 100644 --- a/packages/coding-agent/test/compaction/idle-settings.test.ts +++ b/packages/coding-agent/test/compaction/idle-settings.test.ts @@ -12,3 +12,15 @@ describe("idleCompactionEnabled setting", () => { expect(sm.getCompactionSettings().idleCompactionEnabled).toBe(false); }); }); + +describe("compaction model override setting", () => { + it("defaults to undefined", () => { + const sm = SettingsManager.inMemory(); + expect(sm.getCompactionSettings().model).toBeUndefined(); + }); + + it("passes through a configured provider/model override", () => { + const sm = SettingsManager.inMemory({ compaction: { model: "deepseek/deepseek-chat" } }); + expect(sm.getCompactionSettings().model).toBe("deepseek/deepseek-chat"); + }); +}); diff --git a/packages/coding-agent/test/compaction/lane-policy.test.ts b/packages/coding-agent/test/compaction/lane-policy.test.ts index b96f269fdc..8af9921c32 100644 --- a/packages/coding-agent/test/compaction/lane-policy.test.ts +++ b/packages/coding-agent/test/compaction/lane-policy.test.ts @@ -105,6 +105,41 @@ describe("compaction lane policy — instance policy", () => { expect(policy.disablesSenpiCompaction({ cwd: "/repo", model: { provider: "claude-sdk-oauth" } })).toBe(false); }); + + it("re-enables senpi compaction on the SDK-native lane when a compaction model override is set", () => { + const policy = createCompactionLanePolicy({ + loadProviderSettings: () => ({ resumeMode: "auto" }), + }); + const ctx = { + cwd: "/repo", + model: { provider: "claude-sdk-oauth" }, + getCompactionSettings: () => ({ model: "deepseek/deepseek-chat" }), + }; + + // The override makes senpi own summarization, so the stand-down lifts. + expect(policy.disablesSenpiCompaction(ctx)).toBe(false); + }); + + it("still stands down on the SDK-native lane when the override resolves to no model", () => { + const policy = createCompactionLanePolicy({ + loadProviderSettings: () => ({ resumeMode: "auto" }), + }); + + expect( + policy.disablesSenpiCompaction({ + cwd: "/repo", + model: { provider: "claude-sdk-oauth" }, + getCompactionSettings: () => ({ model: undefined }), + }), + ).toBe(true); + expect( + policy.disablesSenpiCompaction({ + cwd: "/repo", + model: { provider: "claude-sdk-oauth" }, + getCompactionSettings: () => ({}), + }), + ).toBe(true); + }); }); describe("compaction lane policy — compact_boundary mirroring", () => {