From 7f866a48a8a11f9e3e0cad6d1e18cf92da7055e3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 31 May 2026 03:31:31 +0200 Subject: [PATCH] feat(coding-agent): added per-turn AUTO_THINKING in coding-agent session - Added AUTO_THINKING as a configured thinking level in settings, schema, SDK, and session plumbing. - Implemented per-turn auto reasoning classification with online/local prompts, effort clamping, and skip guards. - Updated model selectors, ACP options, footer/status UI, and events to render auto and auto->resolved states. - Added AUTO_THINKING parse/clamp tests and fixed local-module cycle and hashline preview regressions. --- packages/coding-agent/CHANGELOG.md | 12 + .../src/auto-thinking/classifier.ts | 180 ++++++++++++++ .../src/config/settings-schema.ts | 26 +- packages/coding-agent/src/main.ts | 7 +- .../coding-agent/src/modes/acp/acp-agent.ts | 9 +- .../src/modes/components/footer.ts | 13 +- .../src/modes/components/model-selector.ts | 31 ++- .../src/modes/components/settings-defs.ts | 7 + .../src/modes/components/settings-selector.ts | 5 +- .../modes/components/status-line/segments.ts | 18 +- .../src/modes/controllers/event-controller.ts | 6 +- .../modes/controllers/selector-controller.ts | 26 +- .../coding-agent/src/modes/theme/theme.ts | 6 + .../system/auto-thinking-difficulty-local.md | 14 ++ .../system/auto-thinking-difficulty.md | 12 + packages/coding-agent/src/sdk.ts | 32 ++- .../coding-agent/src/session/agent-session.ts | 225 +++++++++++++++--- packages/coding-agent/src/thinking.ts | 74 +++++- packages/coding-agent/src/tiny/models.ts | 24 ++ .../test/agent-session-role-thinking.test.ts | 133 ++++++++++- .../test/auto-thinking-classifier.test.ts | 43 ++++ 21 files changed, 827 insertions(+), 76 deletions(-) create mode 100644 packages/coding-agent/src/auto-thinking/classifier.ts create mode 100644 packages/coding-agent/src/prompts/system/auto-thinking-difficulty-local.md create mode 100644 packages/coding-agent/src/prompts/system/auto-thinking-difficulty.md create mode 100644 packages/coding-agent/test/auto-thinking-classifier.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a7d127894..77fe5797f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,21 @@ # Changelog ## [Unreleased] +### Added + +- Added `providers.autoThinkingModel` setting so users can choose the `auto` thinking classifier backend (online smol or local tiny-memory model) +- Added an `auto` thinking level that classifies each real user turn and resolves to a concrete low-through-xhigh effort, with online smol classification by default and an opt-in local on-device classifier. + +### Changed + +- Updated the interactive thinking selectors in model/model-role pickers and ACP thinking options to include `auto` as a selectable level +- Updated footer and status-line rendering to show `auto` while auto-thinking is being resolved and `auto → ` once it resolves ### Fixed +- Prevented auto-thinking classification from running on non-user synthetic turns and non-reasoning models, keeping the session on its provisional concrete effort +- Added a bounded auto-thinking classification path that falls back to the provisional effort on failures/timeouts so prompts continue without interruption +- Bypassed auto classifier for `ultrathink` prompts and resolved directly to the highest supported auto effort - Fixed the JavaScript `eval` kernel crashing the whole process with a segfault (`SIGTRAP`, `getImportedModule` on a null record) when imported code reached a local module whose relative-import graph contains a cycle — e.g. `await import("…/edit/streaming.ts")`, or any workspace path with cyclic re-exports. The `LocalModuleLoader` linked and evaluated each local module individually inside the recursive `vm.SourceTextModule` linker callback, which re-entered Bun's `node:vm` module linker mid-instantiation and detonated JSC on the first cycle. The loader now constructs the entire local module graph first and drives a single `link()` + `evaluate()` from the graph root, so cyclic graphs instantiate in one pass; external (`node_modules`) modules stay eagerly loaded since they carry no imports and cannot form a cycle. - Fixed the streaming `edit` preview rendering a blank box for hashline edits whose payload sits on the trailing in-flight line (the common single-op `replace`/`insert` case). The preview path trimmed that still-typing line before diffing, so a single-payload op collapsed to a "No changes" result — shown as an empty box — for almost the entire stream. Hashline previews now feed the raw in-flight text through `applyPartialTo`, whose streaming-tolerant parser drops a payload-less trailing op and projects a partially-typed payload line as it grows, so the diff appears and fills in live. Transient errors from the actively-typed trailing section are also suppressed while streaming (regardless of section count) so a mid-typed op can't wipe an already-good preview frame; real errors still surface once args are complete. diff --git a/packages/coding-agent/src/auto-thinking/classifier.ts b/packages/coding-agent/src/auto-thinking/classifier.ts new file mode 100644 index 000000000..db2048b18 --- /dev/null +++ b/packages/coding-agent/src/auto-thinking/classifier.ts @@ -0,0 +1,180 @@ +/** + * Per-prompt difficulty classifier for the `auto` thinking level. + * + * Picks a coding-difficulty bucket for a user prompt and maps it to a concrete + * {@link Effort}, clamped into the active model's supported range (never below + * {@link Effort.Low}). Two backends, selected by `providers.autoThinkingModel`: + * + * - `online` (default): a smol model classifies into `low|medium|high|xhigh`. + * - a local key: an on-device memory model classifies into the coarser + * `trivial|moderate|hard` scheme (3-class is more reliable than 4-way ordinal + * on sub-2B models), mapped to `low|high|xhigh`. + * + * Throws on any failure (no model, no key, unparseable output, abort/timeout); + * the caller falls back to a concrete level and continues the turn. + */ +import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; +import type { ModelRegistry } from "../config/model-registry"; +import { resolveRoleSelection } from "../config/model-resolver"; +import type { Settings } from "../config/settings"; +import difficultySystemPrompt from "../prompts/system/auto-thinking-difficulty.md" with { type: "text" }; +import difficultyLocalPrompt from "../prompts/system/auto-thinking-difficulty-local.md" with { type: "text" }; +import { clampAutoThinkingEffort } from "../thinking"; +import { isTinyMemoryLocalModelKey, ONLINE_AUTO_THINKING_MODEL_KEY } from "../tiny/models"; +import { tinyModelClient } from "../tiny/title-client"; + +const DIFFICULTY_SYSTEM_PROMPT = prompt.render(difficultySystemPrompt); + +/** Upper bound on prompt characters fed to the classifier. */ +const MAX_INPUT_CHARS = 6000; +const HEAD_CHARS = 4000; +const TAIL_CHARS = 2000; +/** The answer is a single word; keep budgets tiny for non-reasoning backends. */ +const ANSWER_MAX_TOKENS = 8; +/** + * Reasoning backends ignore `disableReasoning` on some providers, so reserve + * enough output room for the keyword to still land after unavoidable thinking. + */ +const REASONING_SAFE_MAX_TOKENS = 1024; + +export interface ClassifyDifficultyDeps { + settings: Settings; + registry: ModelRegistry; + model: Model; + sessionId?: string; + signal?: AbortSignal; + metadataResolver?: (provider: string) => Record | undefined; +} + +/** + * Classify `promptText` and return a concrete effort clamped to `deps.model`. + * @throws when the backend cannot produce a usable classification. + */ +export async function classifyDifficulty(promptText: string, deps: ClassifyDifficultyDeps): Promise { + const backend = deps.settings.get("providers.autoThinkingModel"); + const input = prepareClassifierInput(promptText); + const effort = + backend === ONLINE_AUTO_THINKING_MODEL_KEY + ? await classifyOnline(input, deps) + : await classifyLocal(input, backend, deps); + return clampAutoThinkingEffort(deps.model, effort); +} + +async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Promise { + const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry); + const model = resolved?.model; + if (!model) { + throw new Error("auto-thinking: no smol model available for classification"); + } + const apiKey = await deps.registry.getApiKey(model, deps.sessionId); + if (!apiKey) { + throw new Error(`auto-thinking: no API key for ${model.provider}/${model.id}`); + } + // Resolve metadata after getApiKey so the session-sticky credential is recorded first. + const metadata = deps.metadataResolver?.(model.provider); + const maxTokens = model.reasoning ? Math.max(ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : ANSWER_MAX_TOKENS; + + const response = await completeSimple( + model, + { + systemPrompt: [DIFFICULTY_SYSTEM_PROMPT], + messages: [{ role: "user", content: input, timestamp: Date.now() }], + }, + { + apiKey, + maxTokens, + disableReasoning: true, + metadata, + signal: deps.signal, + }, + ); + + if (response.stopReason === "error") { + throw new Error(`auto-thinking: online classification failed: ${response.errorMessage ?? "unknown error"}`); + } + + const text = extractText(response.content); + const effort = parseDifficultyLevel(text); + if (!effort) { + throw new Error(`auto-thinking: unparseable online classification: ${JSON.stringify(text)}`); + } + return effort; +} + +async function classifyLocal(input: string, modelKey: string, deps: ClassifyDifficultyDeps): Promise { + if (!isTinyMemoryLocalModelKey(modelKey)) { + throw new Error(`auto-thinking: unsupported local classifier model: ${modelKey}`); + } + const builtPrompt = prompt.render(difficultyLocalPrompt, { prompt: input }); + const text = await tinyModelClient.complete(modelKey, builtPrompt, { + maxTokens: ANSWER_MAX_TOKENS, + signal: deps.signal, + }); + if (!text) { + throw new Error("auto-thinking: local classification returned no output"); + } + const effort = parseDifficultyBucket(text); + if (!effort) { + throw new Error(`auto-thinking: unparseable local classification: ${JSON.stringify(text)}`); + } + return effort; +} + +/** Map the online 4-way level keyword to an {@link Effort}; earliest match wins. */ +export function parseDifficultyLevel(text: string): Effort | undefined { + const lower = text.toLowerCase(); + const candidates: Array<[number, Effort]> = []; + // `xhigh` must be probed as its own token: `\bhigh\b` cannot match the "high" + // inside "xhigh" (no word boundary between `x` and `h`), so the two never collide. + const xhigh = lower.search(/x[\s_-]?high/); + if (xhigh >= 0) candidates.push([xhigh, Effort.XHigh]); + const high = lower.search(/\bhigh\b/); + if (high >= 0) candidates.push([high, Effort.High]); + const medium = lower.search(/\bmed(?:ium)?\b/); + if (medium >= 0) candidates.push([medium, Effort.Medium]); + const low = lower.search(/\blow\b/); + if (low >= 0) candidates.push([low, Effort.Low]); + return earliest(candidates); +} + +/** Map the local 3-way bucket keyword to an {@link Effort}; earliest match wins. */ +export function parseDifficultyBucket(text: string): Effort | undefined { + const lower = text.toLowerCase(); + const candidates: Array<[number, Effort]> = []; + const trivial = lower.search(/\btrivial\b/); + if (trivial >= 0) candidates.push([trivial, Effort.Low]); + const moderate = lower.search(/\bmoderate\b/); + if (moderate >= 0) candidates.push([moderate, Effort.High]); + const hard = lower.search(/\bhard\b/); + if (hard >= 0) candidates.push([hard, Effort.XHigh]); + return earliest(candidates); +} + +function earliest(candidates: Array<[number, Effort]>): Effort | undefined { + if (candidates.length === 0) return undefined; + let best = candidates[0]; + for (const candidate of candidates) { + if (candidate[0] < best[0]) best = candidate; + } + return best[1]; +} + +function extractText(content: AssistantMessage["content"]): string { + return content + .filter((block): block is Extract => block.type === "text") + .map(block => block.text) + .join(" ") + .trim(); +} + +/** + * Bound the classifier input. Code blocks are kept (a large diff is signal), but + * very long prompts are head+tail trimmed so the intent (start) and any trailing + * error/stacktrace (end) both survive. + */ +function prepareClassifierInput(text: string): string { + const trimmed = text.trim(); + if (trimmed.length <= MAX_INPUT_CHARS) return trimmed; + return `${trimmed.slice(0, HEAD_CHARS)}\n…\n${trimmed.slice(-TAIL_CHARS)}`; +} diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index de7314079..0fab9bad5 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1,6 +1,6 @@ import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; import { TASK_SIMPLE_MODES } from "../task/simple-mode"; -import { getThinkingLevelMetadata } from "../thinking"; +import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking"; import { TINY_MODEL_DEVICE_DEFAULT, TINY_MODEL_DEVICE_SETTING_OPTIONS, @@ -12,6 +12,9 @@ import { TINY_MODEL_DTYPE_SETTING_VALUES, } from "../tiny/dtype"; import { + AUTO_THINKING_MODEL_OPTIONS, + AUTO_THINKING_MODEL_VALUES, + ONLINE_AUTO_THINKING_MODEL_KEY, ONLINE_MEMORY_MODEL_KEY, ONLINE_TINY_TITLE_MODEL_KEY, TINY_MEMORY_MODEL_OPTIONS, @@ -671,13 +674,16 @@ export const SETTINGS_SCHEMA = { // Reasoning and prompts defaultThinkingLevel: { type: "enum", - values: THINKING_EFFORTS, + values: [...THINKING_EFFORTS, AUTO_THINKING], default: "high", ui: { tab: "model", label: "Thinking Level", description: "Reasoning depth for thinking-capable models", - options: [...THINKING_EFFORTS.map(getThinkingLevelMetadata)], + options: [ + getConfiguredThinkingLevelMetadata(AUTO_THINKING), + ...THINKING_EFFORTS.map(getThinkingLevelMetadata), + ], }, }, @@ -2984,6 +2990,20 @@ export const SETTINGS_SCHEMA = { }, }, + "providers.autoThinkingModel": { + type: "enum", + values: AUTO_THINKING_MODEL_VALUES, + default: ONLINE_AUTO_THINKING_MODEL_KEY, + ui: { + tab: "model", + label: "Auto Thinking Model", + description: + "Difficulty classifier for the `auto` thinking level: online smol by default, or a local on-device model", + condition: "autoThinkingActive", + options: AUTO_THINKING_MODEL_OPTIONS, + }, + }, + "providers.kimiApiFormat": { type: "enum", values: ["openai", "anthropic"] as const, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index eccdf27d3..e6c597a27 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -62,6 +62,7 @@ import type { AgentSession } from "./session/agent-session"; import type { AuthStorage } from "./session/auth-storage"; import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager"; import { resolvePromptInput } from "./system-prompt"; +import { AUTO_THINKING } from "./thinking"; import type { LspStartupServerInfo } from "./tools"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; import type { EventBus } from "./utils/event-bus"; @@ -643,7 +644,11 @@ async function buildSessionOptions( // Scoped models for Ctrl+P cycling - fill in default thinking levels when not explicit if (scopedModels.length > 0) { - const defaultThinkingLevel = activeSettings.get("defaultThinkingLevel"); + // `auto` is a session-level concept only; per-scoped-model (Ctrl+P) thinking + // overrides stay concrete, so coerce the auto default to "unset" here. + const defaultThinkingLevelSetting = activeSettings.get("defaultThinkingLevel"); + const defaultThinkingLevel = + defaultThinkingLevelSetting === AUTO_THINKING ? undefined : defaultThinkingLevelSetting; options.scopedModels = scopedModels.map(scopedModel => ({ model: scopedModel.model, thinkingLevel: scopedModel.explicitThinkingLevel diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index adc226b66..dfebb09aa 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -64,7 +64,7 @@ import { type UsageStatistics, } from "../../session/session-manager"; import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; -import { parseThinkingLevel } from "../../thinking"; +import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking"; import { createAcpClientBridge } from "./acp-client-bridge"; import { buildToolCallStartUpdate, @@ -1274,7 +1274,9 @@ export class AcpAgent implements Agent { name: "Thinking", category: "thought_level", type: "select", - currentValue: this.#toThinkingConfigValue(session.thinkingLevel), + currentValue: this.#toThinkingConfigValue( + session.model?.reasoning ? session.configuredThinkingLevel() : undefined, + ), options: this.#buildThinkingOptions(session), }); return configOptions; @@ -1305,6 +1307,7 @@ export class AcpAgent implements Agent { #buildThinkingOptions(session: AgentSession): Array<{ value: string; name: string; description?: string }> { return [ { value: THINKING_OFF, name: "Off" }, + { value: AUTO_THINKING, name: "Auto", description: "Auto-detect per prompt (low–xhigh)" }, ...session.getAvailableThinkingLevels().map(level => ({ value: level, name: level, @@ -1325,7 +1328,7 @@ export class AcpAgent implements Agent { } #setThinkingLevelById(session: AgentSession, value: string): void { - const thinkingLevel = parseThinkingLevel(value); + const thinkingLevel = parseConfiguredThinkingLevel(value); if (!thinkingLevel) { throw new Error(`Unknown ACP thinking level: ${value}`); } diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index eeb3edfef..f38f3e8b3 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -200,9 +200,16 @@ export class FooterComponent implements Component { // Add thinking level hint when the current model advertises supported efforts let rightSide = modelName; if (state.model?.thinking) { - const thinkingLevel = state.thinkingLevel ?? ThinkingLevel.Off; - if (thinkingLevel !== ThinkingLevel.Off) { - rightSide = `${modelName} • ${thinkingLevel}`; + if (this.session.isAutoThinking) { + // Pending (no turn classified yet / classifying) shows a symbol-theme + // question-box marker; once resolved it shows `auto → `. + const resolved = this.session.autoResolvedThinkingLevel(); + rightSide = `${modelName} • ${resolved ? `auto → ${resolved}` : `${theme.thinking.autoPending} auto`}`; + } else { + const thinkingLevel = state.thinkingLevel ?? ThinkingLevel.Off; + if (thinkingLevel !== ThinkingLevel.Off) { + rightSide = `${modelName} • ${thinkingLevel}`; + } } } diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 054dbf56a..8841720a8 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -19,7 +19,7 @@ import { resolveModelRoleValue } from "../../config/model-resolver"; import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; -import { getThinkingLevelMetadata } from "../../thinking"; +import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; @@ -83,10 +83,15 @@ interface ScopedModelItem { interface RoleAssignment { model: Model; - thinkingLevel: ThinkingLevel; + thinkingLevel: ConfiguredThinkingLevel; } -type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ThinkingLevel, selector?: string) => void; +type RoleSelectCallback = ( + model: Model, + role: string | null, + thinkingLevel?: ConfiguredThinkingLevel, + selector?: string, +) => void; type CancelCallback = () => void; interface MenuRoleAction { label: string; @@ -165,7 +170,7 @@ export class ModelSelectorComponent extends Container { settings: Settings, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray, - onSelect: (model: Model, role: string | null, thinkingLevel?: ThinkingLevel, selector?: string) => void, + onSelect: RoleSelectCallback, onCancel: () => void, options?: { temporaryOnly?: boolean; initialSearchInput?: string }, ) { @@ -790,7 +795,7 @@ export class ModelSelectorComponent extends Container { if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue; const badge = makeInvertedBadge(tag, color ?? "success"); - const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label; + const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label; roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`); } // Custom role badges @@ -799,7 +804,7 @@ export class ModelSelectorComponent extends Container { const roleInfo = getRoleInfo(role, this.#settings); const badgeLabel = roleInfo.tag ?? roleInfo.name; const badge = makeInvertedBadge(badgeLabel, roleInfo.color ?? "muted"); - const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label; + const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label; roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`); } const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : ""; @@ -863,11 +868,11 @@ export class ModelSelectorComponent extends Container { ); } } - #getThinkingLevelsForModel(model: Model): ReadonlyArray { - return [ThinkingLevel.Inherit, ThinkingLevel.Off, ...getSupportedEfforts(model)]; + #getThinkingLevelsForModel(model: Model): ReadonlyArray { + return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)]; } - #getCurrentRoleThinkingLevel(role: string): ThinkingLevel { + #getCurrentRoleThinkingLevel(role: string): ConfiguredThinkingLevel { return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit; } @@ -912,7 +917,7 @@ export class ModelSelectorComponent extends Container { const optionLines = showingThinking ? thinkingOptions.map((thinkingLevel, index) => { const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " "; - const label = getThinkingLevelMetadata(thinkingLevel).label; + const label = getConfiguredThinkingLevelMetadata(thinkingLevel).label; return `${prefix}${label}`; }) : this.#menuRoleActions.map((action, index) => { @@ -1069,7 +1074,11 @@ export class ModelSelectorComponent extends Container { } } - #handleSelect(item: ModelItem | CanonicalModelItem, role: string | null, thinkingLevel?: ThinkingLevel): void { + #handleSelect( + item: ModelItem | CanonicalModelItem, + role: string | null, + thinkingLevel?: ConfiguredThinkingLevel, + ): void { // For temporary role, don't save to settings - just notify caller if (role === null) { this.#onSelectCallback(item.model, null, undefined, item.selector); diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 37cfd179b..b1f6e951e 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -86,6 +86,13 @@ const CONDITIONS: Record boolean> = { return false; } }, + autoThinkingActive: () => { + try { + return Settings.instance.get("defaultThinkingLevel") === "auto"; + } catch { + return false; + } + }, }; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 793c346ae..498ef3aa3 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -23,6 +23,7 @@ import type { import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema"; import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme"; import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; +import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings"; @@ -373,7 +374,9 @@ export class SettingsSelectorComponent extends Container { // Special case: inject runtime options for thinking level if (def.path === "defaultThinkingLevel") { - options = this.context.availableThinkingLevels.map(level => { + // Prepend `auto`; the rest are the model's runtime-supported efforts. + const levels: ConfiguredThinkingLevel[] = [AUTO_THINKING, ...this.context.availableThinkingLevels]; + options = levels.map(level => { const baseOpt = options.find(o => o.value === level); return baseOpt || { value: level, label: level }; }); diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index a763152b2..eeb335ffb 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -90,11 +90,19 @@ const modelSegment: StatusLineSegment = { // Add thinking level with dot separator if (opts.showThinkingLevel !== false && state.model?.thinking) { - const level = state.thinkingLevel ?? ThinkingLevel.Off; - if (level !== ThinkingLevel.Off) { - const thinkingText = theme.thinking[level as keyof typeof theme.thinking]; - if (thinkingText) { - content += `${theme.sep.dot}${thinkingText}`; + if (ctx.session.isAutoThinking) { + // Pending (no turn classified yet / classifying) shows a symbol-theme + // question-box marker; once resolved it shows `auto → `. + const resolved = ctx.session.autoResolvedThinkingLevel(); + const resolvedText = resolved ? (theme.thinking[resolved as keyof typeof theme.thinking] ?? resolved) : ""; + content += `${theme.sep.dot}${resolved ? `auto → ${resolvedText}` : `${theme.thinking.autoPending} auto`}`; + } else { + const level = state.thinkingLevel ?? ThinkingLevel.Off; + if (level !== ThinkingLevel.Off) { + const thinkingText = theme.thinking[level as keyof typeof theme.thinking]; + if (thinkingText) { + content += `${theme.sep.dot}${thinkingText}`; + } } } } diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index ca3a086fe..8bb4d7d20 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -65,7 +65,11 @@ export class EventController { todo_auto_clear: e => this.#handleTodoAutoClear(e), irc_message: e => this.#handleIrcMessage(e), notice: e => this.#handleNotice(e), - thinking_level_changed: async () => {}, + thinking_level_changed: async () => { + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorBorderColor(); + this.ctx.ui.requestRender(); + }, goal_updated: async () => {}, } satisfies AgentSessionEventHandlers; } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 2bc179778..4ef7372e7 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -29,6 +29,7 @@ import { import type { InteractiveModeContext } from "../../modes/types"; import { type SessionInfo, SessionManager } from "../../session/session-manager"; import { FileSessionStorage } from "../../session/session-storage"; +import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { isImageProviderPreference, isSearchProviderPreference, @@ -252,7 +253,7 @@ export class SelectorController { break; case "thinkingLevel": case "defaultThinkingLevel": - this.ctx.session.setThinkingLevel(value as ThinkingLevel, true); + this.ctx.session.setThinkingLevel(value as ConfiguredThinkingLevel, true); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); break; @@ -403,10 +404,18 @@ export class SelectorController { this.ctx.session.modelRegistry, this.ctx.session.scopedModels, async (model, role, thinkingLevel, selector) => { + // `auto` is session-global: never baked into a per-role model value + // (it can't round-trip through `model:`). Apply it to the session + // separately and persist via `defaultThinkingLevel`. + const isAuto = thinkingLevel === AUTO_THINKING; + const concreteThinking = isAuto ? undefined : thinkingLevel; try { if (role === null) { - // Temporary: update agent state but don't persist to settings + // Temporary: update agent state but don't persist the model to settings await this.ctx.session.setModelTemporary(model); + if (isAuto) { + this.ctx.session.setThinkingLevel(AUTO_THINKING, true); + } this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); this.ctx.showStatus(`Temporary model: ${selector ?? model.id}`); @@ -416,10 +425,12 @@ export class SelectorController { // Default: update agent state and persist await this.ctx.session.setModel(model, role, { selector, - thinkingLevel, + thinkingLevel: concreteThinking, }); - if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) { - this.ctx.session.setThinkingLevel(thinkingLevel); + if (isAuto) { + this.ctx.session.setThinkingLevel(AUTO_THINKING, true); + } else if (concreteThinking && concreteThinking !== ThinkingLevel.Inherit) { + this.ctx.session.setThinkingLevel(concreteThinking); } this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); @@ -429,8 +440,11 @@ export class SelectorController { // Other roles (smol, slow): just update settings, not current model this.ctx.settings.setModelRole( role, - formatModelSelectorValue(selector ?? `${model.provider}/${model.id}`, thinkingLevel), + formatModelSelectorValue(selector ?? `${model.provider}/${model.id}`, concreteThinking), ); + if (isAuto) { + this.ctx.session.setThinkingLevel(AUTO_THINKING, true); + } const roleInfo = getRoleInfo(role, settings); const roleLabel = roleInfo?.name ?? role; this.ctx.showStatus(`${roleLabel} model: ${selector ?? model.id}`); diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index b7abcce01..78024b9ee 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -132,6 +132,7 @@ export type SymbolKey = | "thinking.medium" | "thinking.high" | "thinking.xhigh" + | "thinking.autoPending" // Checkboxes | "checkbox.checked" | "checkbox.unchecked" @@ -297,6 +298,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "thinking.medium": "◒ med", "thinking.high": "◕ high", "thinking.xhigh": "◉ xhigh", + "thinking.autoPending": "▣?", // Checkboxes "checkbox.checked": "☑", "checkbox.unchecked": "☐", @@ -550,6 +552,8 @@ const NERD_SYMBOLS: SymbolMap = { "thinking.high": "\u{F111} high", // pick: 🧠 xhi | alt:  xhi  xhi "thinking.xhigh": "\u{F06D} xhi", + // pick: 󰞋 (nf-md-help_box) | alt:  [?] + "thinking.autoPending": "\u{f078b}", // Checkboxes // pick:  | alt:   "checkbox.checked": "\uf14a", @@ -723,6 +727,7 @@ const ASCII_SYMBOLS: SymbolMap = { "thinking.medium": "[med]", "thinking.high": "[high]", "thinking.xhigh": "[xhi]", + "thinking.autoPending": "[?]", // Checkboxes "checkbox.checked": "[x]", "checkbox.unchecked": "[ ]", @@ -1518,6 +1523,7 @@ export class Theme { medium: this.#symbols["thinking.medium"], high: this.#symbols["thinking.high"], xhigh: this.#symbols["thinking.xhigh"], + autoPending: this.#symbols["thinking.autoPending"], }; } diff --git a/packages/coding-agent/src/prompts/system/auto-thinking-difficulty-local.md b/packages/coding-agent/src/prompts/system/auto-thinking-difficulty-local.md new file mode 100644 index 000000000..58470e7dd --- /dev/null +++ b/packages/coding-agent/src/prompts/system/auto-thinking-difficulty-local.md @@ -0,0 +1,14 @@ +Classify the difficulty of the coding request below into one bucket, by how much reasoning it needs. + +Buckets: + +- trivial — obvious, mechanical, or a direct question (rename, typo, one-liner, simple lookup). +- moderate — a real but localized task (a small feature, a normal bug fix, explaining code). +- hard — deep, multi-file, ambiguous, or tricky debugging or design. + +Reply with exactly one word: trivial, moderate, or hard. + +Request: +{{prompt}} + +Answer: diff --git a/packages/coding-agent/src/prompts/system/auto-thinking-difficulty.md b/packages/coding-agent/src/prompts/system/auto-thinking-difficulty.md new file mode 100644 index 000000000..141956353 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/auto-thinking-difficulty.md @@ -0,0 +1,12 @@ +You are a difficulty classifier for a coding agent. Read the user's request and decide how much reasoning effort the agent should spend on it this turn. + +Reply with exactly one word — one of: `low`, `medium`, `high`, `xhigh`. No punctuation, no explanation, no other text. + +Levels: + +- `low` — Trivial or mechanical. A rename, a typo, a one-line edit, a formatting tweak, a direct factual question, or a request whose solution is obvious. +- `medium` — A localized change that needs some reasoning. A small self-contained feature, a straightforward bug fix in one place, or explaining a moderate piece of code. +- `high` — A non-trivial change. Spans multiple files or callers, requires real debugging, a moderate design decision, or a refactor with several moving parts. +- `xhigh` — Deep or open-ended. Subtle concurrency or algorithmic problems, cross-system reasoning, ambiguous requirements, large or risky refactors, or hard root-cause debugging. + +Judge the inherent difficulty of the task, not how politely or verbosely it is phrased. When torn between two levels, choose the lower one. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 86ad1297e..00a706cdb 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -112,7 +112,14 @@ import { loadProjectContextFiles as loadContextFilesInternal, } from "./system-prompt"; import { AgentOutputManager } from "./task/output-manager"; -import { parseThinkingLevel, resolveThinkingLevelForModel, toReasoningEffort } from "./thinking"; +import { + AUTO_THINKING, + type ConfiguredThinkingLevel, + parseThinkingLevel, + resolveProvisionalAutoLevel, + resolveThinkingLevelForModel, + toReasoningEffort, +} from "./thinking"; import { collectDiscoverableTools, type DiscoverableTool, @@ -254,7 +261,7 @@ export interface CreateAgentSessionOptions { * Used when model lookup is deferred because extension-provided models aren't registered yet. */ modelPattern?: string; /** Thinking selector. Default: from settings, else unset */ - thinkingLevel?: ThinkingLevel; + thinkingLevel?: ConfiguredThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; @@ -1013,10 +1020,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (thinkingLevel === undefined) { thinkingLevel = settings.get("defaultThinkingLevel"); } + const autoThinking = thinkingLevel === AUTO_THINKING; + // Concrete level the agent/session start with. With `auto` this is the + // provisional level shown until the first per-turn classification resolves; + // `auto` itself stays a session-only concept handled by AgentSession. + let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel; if (model) { const resolvedModel = model; - thinkingLevel = logger.time("resolveThinkingLevelForModel", () => - resolveThinkingLevelForModel(resolvedModel, thinkingLevel), + effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () => + autoThinking + ? resolveProvisionalAutoLevel(resolvedModel) + : resolveThinkingLevelForModel(resolvedModel, effectiveThinkingLevel), ); // Fire-and-forget TLS+H2 handshake to the model's host so it overlaps // with the rest of session setup (extension/skill load, tool registry, @@ -1838,7 +1852,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} initialState: { systemPrompt, model, - thinkingLevel: toReasoningEffort(thinkingLevel), + thinkingLevel: toReasoningEffort(effectiveThinkingLevel), tools: initialTools, }, convertToLlm: convertToLlmFinal, @@ -1945,7 +1959,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (model) { sessionManager.appendModelChange(`${model.provider}/${model.id}`); } - sessionManager.appendThinkingLevelChange(thinkingLevel); + if (!autoThinking) { + // `auto` is never written to the session log; resume reads it from the + // `defaultThinkingLevel` setting instead, keeping `hasThinkingEntry` false. + sessionManager.appendThinkingLevelChange(effectiveThinkingLevel); + } if (initialServiceTier) { sessionManager.appendServiceTierChange(initialServiceTier); } @@ -1953,7 +1971,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} session = new AgentSession({ agent, - thinkingLevel, + thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel, sessionManager, settings, evalKernelOwnerId, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 1e353093f..b5c3738ec 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -52,7 +52,6 @@ import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/ import type { AssistantMessage, Context, - Effort, ImageContent, Message, MessageAttribution, @@ -69,6 +68,7 @@ import type { import { calculateRateLimitBackoffMs, clearAnthropicFastModeFallback, + Effort, getSupportedEfforts, isContextOverflow, isUsageLimitError, @@ -88,6 +88,7 @@ import { Snowflake, } from "@oh-my-pi/pi-utils"; import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async"; +import { classifyDifficulty } from "../auto-thinking/classifier"; import { reset as resetCapabilities } from "../capability"; import type { Rule } from "../capability/rule"; import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry"; @@ -165,7 +166,14 @@ import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" w import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator"; import { invalidateHostMetadata } from "../ssh/connection-manager"; -import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking"; +import { + AUTO_THINKING, + type ConfiguredThinkingLevel, + clampAutoThinkingEffort, + resolveProvisionalAutoLevel, + resolveThinkingLevelForModel, + toReasoningEffort, +} from "../thinking"; import { shutdownTinyTitleClient } from "../tiny/title-client"; import { buildDiscoverableToolSearchIndex, @@ -241,7 +249,14 @@ export type AgentSessionEvent = | { type: "todo_auto_clear" } | { type: "irc_message"; message: CustomMessage } | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string } - | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined } + | { + type: "thinking_level_changed"; + thinkingLevel: ThinkingLevel | undefined; + /** The user-configured selector when it differs from the effective level (e.g. `auto`). */ + configured?: ConfiguredThinkingLevel; + /** The level `auto` resolved to this turn, once classified. */ + resolved?: Effort; + } | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState }; /** Listener function for agent session events */ @@ -265,7 +280,7 @@ export interface AgentSessionConfig { /** Models to cycle through with Ctrl+P (from --models flag) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; /** Initial session thinking selector. */ - thinkingLevel?: ThinkingLevel; + thinkingLevel?: ConfiguredThinkingLevel; /** Prompt templates for expansion */ promptTemplates?: PromptTemplate[]; /** File-based slash commands for expansion */ @@ -445,8 +460,8 @@ interface RetryFallbackSelector { interface ActiveRetryFallbackState { role: string; originalSelector: string; - originalThinkingLevel: ThinkingLevel | undefined; - lastAppliedFallbackThinkingLevel: ThinkingLevel | undefined; + originalThinkingLevel: ConfiguredThinkingLevel | undefined; + lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined; } function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined { @@ -782,7 +797,12 @@ export class AgentSession { readonly configWarnings: string[] = []; #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + /** Effective, metadata-clamped thinking level applied to the agent (never `auto`). */ #thinkingLevel: ThinkingLevel | undefined; + /** True when the user configured `auto`; the effective level is resolved per turn. */ + #autoThinking: boolean = false; + /** The level `auto` last resolved to (for UI); undefined until a turn is classified. */ + #autoResolvedLevel: Effort | undefined; #promptTemplates: PromptTemplate[]; #slashCommands: FileSlashCommand[]; @@ -1041,7 +1061,15 @@ export class AgentSession { this.#parentEvalSessionId = config.parentEvalSessionId; this.#ownedAsyncJobManager = config.ownedAsyncJobManager; this.#scopedModels = config.scopedModels ?? []; - this.#thinkingLevel = config.thinkingLevel; + if (config.thinkingLevel === AUTO_THINKING) { + // `auto` is session-level: keep the flag and show a provisional concrete + // level (the agent's initial effort was already set by the caller) until + // the first user turn is classified. + this.#autoThinking = true; + this.#thinkingLevel = resolveProvisionalAutoLevel(this.model); + } else { + this.#thinkingLevel = config.thinkingLevel; + } this.#promptTemplates = config.promptTemplates ?? []; this.#slashCommands = config.slashCommands ?? []; this.#extensionRunner = config.extensionRunner; @@ -2935,11 +2963,26 @@ export class AgentSession { return this.agent.state.model; } - /** Current thinking level */ + /** Effective thinking level applied to the agent (the resolved level when `auto`). */ get thinkingLevel(): ThinkingLevel | undefined { return this.#thinkingLevel; } + /** The selector the user configured: `auto` when auto mode is active, else the effective level. */ + configuredThinkingLevel(): ConfiguredThinkingLevel | undefined { + return this.#autoThinking ? AUTO_THINKING : this.#thinkingLevel; + } + + /** True when `auto` thinking mode is active. */ + get isAutoThinking(): boolean { + return this.#autoThinking; + } + + /** The level `auto` resolved to for the current turn (undefined until classified). */ + autoResolvedThinkingLevel(): Effort | undefined { + return this.#autoResolvedLevel; + } + get serviceTier(): ServiceTier | undefined { return this.agent.serviceTier; } @@ -4304,6 +4347,17 @@ export class AgentSession { return; } + // Auto thinking: classify this real user turn and set the effective level + // before the model request. Synthetic/tool-continuation turns (developer/ + // custom roles) and non-auto sessions are skipped. Never blocks the turn — + // failures fall back to a concrete level inside the helper. + if (this.#autoThinking && message.role === "user") { + await this.#applyAutoThinkingLevel(expandedText, generation); + if (this.#promptGeneration !== generation) { + return; + } + } + const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined; await this.#promptAgentWithIdleRetry(messages, agentPromptOptions); if (!options?.skipPostPromptRecoveryWait) { @@ -5061,8 +5115,8 @@ export class AgentSession { this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); // Re-apply thinking for the newly selected model. Prefer the model's - // configured defaultLevel; otherwise preserve the current level. - this.setThinkingLevel(model.thinking?.defaultLevel ?? this.thinkingLevel); + // configured defaultLevel; otherwise preserve the current level (or auto). + this.#reapplyThinkingLevel(model.thinking?.defaultLevel); await this.#syncEditToolModeAfterModelChange(previousEditMode); } @@ -5084,8 +5138,12 @@ export class AgentSession { this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); // Apply explicit thinking level if given; otherwise prefer the model's - // configured defaultLevel; otherwise re-clamp the current level. - this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel); + // configured defaultLevel; otherwise re-clamp the current level (or auto). + if (thinkingLevel !== undefined) { + this.setThinkingLevel(thinkingLevel); + } else { + this.#reapplyThinkingLevel(model.thinking?.defaultLevel); + } await this.#syncEditToolModeAfterModelChange(previousEditMode); } @@ -5233,8 +5291,8 @@ export class AgentSession { this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model)); this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`); - // Apply the scoped model's configured thinking level - this.setThinkingLevel(next.thinkingLevel); + // Apply the scoped model's configured thinking level, preserving auto. + this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel); await this.#syncEditToolModeAfterModelChange(previousEditMode); return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; @@ -5263,8 +5321,8 @@ export class AgentSession { this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`); this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel)); this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`); - // Re-apply the current thinking level for the newly selected model - this.setThinkingLevel(this.thinkingLevel); + // Re-apply the current thinking level (or auto) for the newly selected model + this.#reapplyThinkingLevel(); await this.#syncEditToolModeAfterModelChange(previousEditMode); return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; @@ -5282,10 +5340,29 @@ export class AgentSession { // ========================================================================= /** - * Set thinking level. - * Saves the effective metadata-clamped level to session and settings only if it changes. + * Set the thinking level. `auto` enables per-turn classification (session-level, + * never written to the session log); a concrete level clears auto. The effective + * metadata-clamped level is saved to the session/settings only when it changes. */ - setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void { + setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void { + if (level === AUTO_THINKING) { + const provisional = resolveProvisionalAutoLevel(this.model); + const wasAuto = this.#autoThinking; + this.#autoThinking = true; + this.#autoResolvedLevel = undefined; + this.#thinkingLevel = provisional; + this.agent.setThinkingLevel(toReasoningEffort(provisional)); + if (persist) { + this.settings.set("defaultThinkingLevel", AUTO_THINKING); + } + if (!wasAuto || this.#thinkingLevel !== provisional) { + this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING }); + } + return; + } + + this.#autoThinking = false; + this.#autoResolvedLevel = undefined; const effectiveLevel = resolveThinkingLevelForModel(this.model, level); const isChanging = effectiveLevel !== this.#thinkingLevel; @@ -5302,14 +5379,28 @@ export class AgentSession { } /** - * Cycle to next thinking level. - * @returns New level, or undefined if model doesn't support thinking + * Re-apply the active thinking selection after a model change. Preserves `auto` + * (re-clamping the provisional level to the new model); otherwise re-applies the + * preferred default or the current effective level. */ - cycleThinkingLevel(): ThinkingLevel | undefined { + #reapplyThinkingLevel(preferredDefault?: ThinkingLevel): void { + this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : (preferredDefault ?? this.#thinkingLevel)); + } + + /** + * Cycle to next thinking level: off → auto → minimal..xhigh → off. + * @returns New selector, or undefined if model doesn't support thinking + */ + cycleThinkingLevel(): ConfiguredThinkingLevel | undefined { if (!this.model?.reasoning) return undefined; - const levels = [ThinkingLevel.Off, ...this.getAvailableThinkingLevels()]; - const currentLevel = this.thinkingLevel === ThinkingLevel.Inherit ? ThinkingLevel.Off : this.thinkingLevel; + const levels: ConfiguredThinkingLevel[] = [ + ThinkingLevel.Off, + AUTO_THINKING, + ...this.getAvailableThinkingLevels(), + ]; + const configured = this.configuredThinkingLevel(); + const currentLevel = configured === ThinkingLevel.Inherit ? ThinkingLevel.Off : configured; const currentIndex = currentLevel ? levels.indexOf(currentLevel) : -1; const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; @@ -5319,6 +5410,61 @@ export class AgentSession { return nextLevel; } + /** Timeout (ms) for per-turn auto-thinking classification before falling back. */ + static readonly #AUTO_THINKING_TIMEOUT_MS = 4000; + + /** + * Classify the current user turn and set the effective thinking level for it. + * Bounded by a timeout + abort; on any failure (no smol model, timeout, parse + * error) it falls back to the provisional concrete level and continues. Never + * throws into the turn, and never clears `#autoThinking` (auto stays active). + */ + async #applyAutoThinkingLevel(promptText: string, generation: number): Promise { + const model = this.model; + if (!model?.reasoning) return; + + let resolved: Effort | undefined; + if (containsUltrathink(promptText)) { + // The user explicitly asked for maximum thinking; bypass the classifier + // and jump straight to the highest auto-supported level for this model. + resolved = clampAutoThinkingEffort(model, Effort.XHigh); + } else { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), AgentSession.#AUTO_THINKING_TIMEOUT_MS); + try { + resolved = await classifyDifficulty(promptText, { + settings: this.settings, + registry: this.#modelRegistry, + model, + sessionId: this.sessionId, + signal: controller.signal, + metadataResolver: provider => this.agent.metadataForProvider(provider), + }); + } catch (error) { + logger.debug("auto-thinking: classification failed; using fallback level", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + clearTimeout(timer); + } + } + + // Drop the result if the turn was aborted/superseded while classifying. + if (this.#promptGeneration !== generation || !this.#autoThinking) return; + + const effort = resolved ?? resolveProvisionalAutoLevel(model); + if (effort === undefined) return; + this.#autoResolvedLevel = effort; + this.#thinkingLevel = effort; + this.agent.setThinkingLevel(toReasoningEffort(effort)); + this.#emit({ + type: "thinking_level_changed", + thinkingLevel: effort, + configured: AUTO_THINKING, + resolved: effort, + }); + } + /** * True when *any* fast-mode-granting service tier is configured, regardless * of whether the active model's provider actually realizes it. Used by the @@ -7260,7 +7406,9 @@ export class AgentSession { throw new Error(`No API key for retry fallback ${selector.raw}`); } - const currentThinkingLevel = this.thinkingLevel; + // Capture the configured selector (auto-aware) so a fallback chain preserves + // `auto` instead of collapsing it to the level it resolved to this turn. + const currentThinkingLevel = this.configuredThinkingLevel(); const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel; this.#setModelWithProviderSessionReset(candidate); @@ -7333,7 +7481,7 @@ export class AgentSession { const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId); if (!apiKey) return; - const currentThinkingLevel = this.thinkingLevel; + const currentThinkingLevel = this.configuredThinkingLevel(); const thinkingToApply = currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel; this.#setModelWithProviderSessionReset(primaryModel); @@ -8244,6 +8392,8 @@ export class AgentSession { const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration; const previousModel = this.model; const previousThinkingLevel = this.#thinkingLevel; + const previousAutoThinking = this.#autoThinking; + const previousAutoResolvedLevel = this.#autoResolvedLevel; const previousServiceTier = this.agent.serviceTier; const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames); const previousTools = [...this.agent.state.tools]; @@ -8321,12 +8471,21 @@ export class AgentSession { .some(entry => entry.type === "service_tier_change"); const defaultThinkingLevel = this.settings.get("defaultThinkingLevel"); const configuredServiceTier = this.settings.get("serviceTier"); - const nextThinkingLevel = resolveThinkingLevelForModel( - this.model, - hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel, - ); - this.#thinkingLevel = nextThinkingLevel; - this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel)); + // Session log entries only ever store concrete levels (auto is never + // written), so `auto` can only arrive via the settings default. + const restoredThinkingLevel: ConfiguredThinkingLevel | undefined = hasThinkingEntry + ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) + : defaultThinkingLevel; + if (restoredThinkingLevel === AUTO_THINKING) { + this.#autoThinking = true; + this.#autoResolvedLevel = undefined; + this.#thinkingLevel = resolveProvisionalAutoLevel(this.model); + } else { + this.#autoThinking = false; + this.#autoResolvedLevel = undefined; + this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel); + } + this.agent.setThinkingLevel(toReasoningEffort(this.#thinkingLevel)); this.agent.serviceTier = hasServiceTierEntry ? sessionContext.serviceTier : configuredServiceTier === "none" @@ -8375,6 +8534,8 @@ export class AgentSession { this.#syncToolCallBatchCap(undefined); } this.#thinkingLevel = previousThinkingLevel; + this.#autoThinking = previousAutoThinking; + this.#autoResolvedLevel = previousAutoResolvedLevel; this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel)); this.agent.serviceTier = previousServiceTier; this.#syncTodoPhasesFromBranch(); diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index f9dbe4b66..51698aecd 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -1,5 +1,5 @@ import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { clampThinkingLevelForModel, type Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; +import { clampThinkingLevelForModel, Effort, getSupportedEfforts, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; /** * Metadata used to render thinking selector values in the coding-agent UI. @@ -85,3 +85,75 @@ export function resolveThinkingLevelForModel( } return clampThinkingLevelForModel(model, level); } + +/** + * Sentinel selector for the coding-agent "auto" thinking mode. Kept entirely + * inside the coding-agent layer: it is never an {@link Effort} or + * {@link ThinkingLevel}, so provider mapping/clamping keeps seeing concrete + * efforts. The session resolves `auto` to a concrete effort each turn. + */ +export const AUTO_THINKING = "auto" as const; + +/** A thinking selector as configured by the user — a concrete level or `auto`. */ +export type ConfiguredThinkingLevel = ThinkingLevel | typeof AUTO_THINKING; + +/** Metadata used to render the `auto` selector value alongside concrete levels. */ +export interface ConfiguredThinkingLevelMetadata { + value: ConfiguredThinkingLevel; + label: string; + description: string; +} + +const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = { + value: AUTO_THINKING, + label: "auto", + description: "Auto-detect per prompt (low–xhigh)", +}; + +/** + * Parses a configured thinking selector, accepting `auto` in addition to every + * value {@link parseThinkingLevel} accepts. {@link parseThinkingLevel} itself + * stays strict so model-suffix parsing (`model:high`) keeps rejecting `auto`. + */ +export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined { + if (value === AUTO_THINKING) return AUTO_THINKING; + return parseThinkingLevel(value); +} + +/** Returns display metadata for a configured selector, including `auto`. */ +export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLevel): ConfiguredThinkingLevelMetadata { + return level === AUTO_THINKING ? AUTO_THINKING_METADATA : getThinkingLevelMetadata(level); +} + +/** + * Resolves an auto-classified effort against the active model's supported + * range. Unlike {@link clampThinkingLevelForModel}, `auto` never resolves below + * {@link Effort.Low}: the eligible pool is the model's supported efforts at or + * above Low (falling back to the full supported set only when the model maxes + * out below Low). Within that pool the request snaps to the highest level not + * exceeding it, or the pool minimum when the request is below the pool. + */ +export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort { + const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS; + if (supported.length === 0) return effort; + const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low); + const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex); + const pool = eligible.length > 0 ? eligible : supported; + const requestedIndex = THINKING_EFFORTS.indexOf(effort); + let chosen = pool[0]; + for (const candidate of pool) { + if (THINKING_EFFORTS.indexOf(candidate) > requestedIndex) break; + chosen = candidate; + } + return chosen; +} + +/** + * The provisional concrete level shown while `auto` is configured but before a + * turn has been classified. Prefers the model's `defaultLevel`, otherwise High, + * clamped into the auto range. Returns `undefined` for non-reasoning models. + */ +export function resolveProvisionalAutoLevel(model: Model | undefined): Effort | undefined { + if (!model?.reasoning) return undefined; + return clampAutoThinkingEffort(model, model.thinking?.defaultLevel ?? Effort.High); +} diff --git a/packages/coding-agent/src/tiny/models.ts b/packages/coding-agent/src/tiny/models.ts index 23de49e22..86ff628fd 100644 --- a/packages/coding-agent/src/tiny/models.ts +++ b/packages/coding-agent/src/tiny/models.ts @@ -216,3 +216,27 @@ export const TINY_LOCAL_MODELS = [ ...TINY_TITLE_LOCAL_MODELS, ...TINY_MEMORY_LOCAL_MODELS, ] as const satisfies readonly TinyTitleLocalModelSpec[]; + +/** + * Difficulty-classifier model for the `auto` thinking level. Defaults to the + * online smol path; the local options reuse the memory-model registry because + * the shared worker's `complete()` only accepts memory local keys, and the + * 1B+ memory models classify coding difficulty far more reliably than the + * sub-1B title models. + */ +export const ONLINE_AUTO_THINKING_MODEL_KEY = ONLINE_MEMORY_MODEL_KEY; +export const AUTO_THINKING_MODEL_VALUES = TINY_MEMORY_MODEL_VALUES; +export type AutoThinkingModelKey = TinyMemoryModelKey; + +export const AUTO_THINKING_MODEL_OPTIONS = [ + { + value: ONLINE_AUTO_THINKING_MODEL_KEY, + label: "Online (smol)", + description: "Classify prompt difficulty with the online smol model; no local download or on-device inference.", + }, + ...TINY_MEMORY_LOCAL_MODELS.map(model => ({ + value: model.key, + label: model.label, + description: model.description, + })), +] satisfies ReadonlyArray<{ value: AutoThinkingModelKey; label: string; description: string }>; diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 3a17947e5..a754b54a2 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; @@ -8,6 +8,8 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import * as autoThinkingClassifier from "../src/auto-thinking/classifier"; +import { AUTO_THINKING, clampAutoThinkingEffort, resolveProvisionalAutoLevel } from "../src/thinking"; describe("AgentSession role model thinking behavior", () => { let tempDir: TempDir; @@ -20,6 +22,7 @@ describe("AgentSession role model thinking behavior", () => { }); afterEach(async () => { + vi.restoreAllMocks(); if (session) { await session.dispose(); } @@ -203,7 +206,7 @@ describe("AgentSession role model thinking behavior", () => { expect(session.getAvailableThinkingLevels()).not.toContain("xhigh"); }); - it("cycles through off before returning to effort levels", async () => { + it("cycles through off and auto before returning to effort levels", async () => { const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); const agent = new Agent({ @@ -230,7 +233,133 @@ describe("AgentSession role model thinking behavior", () => { expect(session.cycleThinkingLevel()).toBe("off"); expect(session.thinkingLevel).toBe("off"); + expect(session.cycleThinkingLevel()).toBe(AUTO_THINKING); + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + expect(session.thinkingLevel).toBe(resolveProvisionalAutoLevel(model)); expect(session.cycleThinkingLevel()).toBe(Effort.Minimal); expect(session.thinkingLevel).toBe(Effort.Minimal); }); + + it("keeps auto configured while applying the classifier result as the effective level", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + await createSession({ + initialModelId: model.id, + initialThinkingLevel: Effort.High, + modelRoles: { default: `${model.provider}/${model.id}` }, + }); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium); + + session.setThinkingLevel(AUTO_THINKING); + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + expect(session.autoResolvedThinkingLevel()).toBeUndefined(); + + await session.prompt("Implement a focused parser fix"); + + expect(classifierSpy).toHaveBeenCalledTimes(1); + expect(promptSpy).toHaveBeenCalledTimes(1); + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + expect(session.thinkingLevel).toBe(Effort.Medium); + expect(session.autoResolvedThinkingLevel()).toBe(Effort.Medium); + expect(session.agent.state.thinkingLevel).toBe(Effort.Medium); + }); + + it("falls back to a concrete auto level when classification fails", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + await createSession({ + initialModelId: model.id, + initialThinkingLevel: Effort.High, + modelRoles: { default: `${model.provider}/${model.id}` }, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockRejectedValue(new Error("classifier down")); + + session.setThinkingLevel(AUTO_THINKING); + const fallback = resolveProvisionalAutoLevel(model); + await session.prompt("Investigate a regression"); + + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + expect(session.thinkingLevel).toBe(fallback); + expect(session.autoResolvedThinkingLevel()).toBe(fallback); + expect(session.agent.state.thinkingLevel).toBe(fallback); + }); + + it("skips classification for synthetic turns", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + await createSession({ + initialModelId: model.id, + initialThinkingLevel: Effort.High, + modelRoles: { default: `${model.provider}/${model.id}` }, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.XHigh); + + session.setThinkingLevel(AUTO_THINKING); + const provisional = resolveProvisionalAutoLevel(model); + await session.prompt("Synthetic maintenance turn", { synthetic: true }); + + expect(classifierSpy).not.toHaveBeenCalled(); + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + expect(session.thinkingLevel).toBe(provisional); + expect(session.autoResolvedThinkingLevel()).toBeUndefined(); + }); + + it("maps ultrathink prompts directly to the highest auto-supported level", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + await createSession({ + initialModelId: model.id, + initialThinkingLevel: Effort.High, + modelRoles: { default: `${model.provider}/${model.id}` }, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Low); + + session.setThinkingLevel(AUTO_THINKING); + const expected = clampAutoThinkingEffort(model, Effort.XHigh); + await session.prompt("ultrathink through the unsafe refactor"); + + expect(classifierSpy).not.toHaveBeenCalled(); + expect(session.thinkingLevel).toBe(expected); + expect(session.autoResolvedThinkingLevel()).toBe(expected); + }); + + it("keeps auto effectively off for non-reasoning models", async () => { + const model = getBundledModel("openai", "gpt-4o-mini"); + if (!model) throw new Error("Expected bundled gpt-4o-mini model"); + const agent = new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + thinkingLevel: undefined, + }, + }); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-non-reasoning-auto.db")); + authStorages.push(authStorage); + authStorage.setRuntimeApiKey("openai", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-non-reasoning-auto.yml")); + sessionSettings = Settings.isolated(); + sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: sessionSettings, + modelRegistry, + thinkingLevel: AUTO_THINKING, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.XHigh); + + expect(session.isAutoThinking).toBe(true); + expect(session.thinkingLevel).toBeUndefined(); + expect(session.agent.state.thinkingLevel).toBeUndefined(); + + await session.prompt("Implement a tiny change"); + + expect(classifierSpy).not.toHaveBeenCalled(); + expect(session.thinkingLevel).toBeUndefined(); + expect(session.agent.state.thinkingLevel).toBeUndefined(); + expect(session.autoResolvedThinkingLevel()).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts new file mode 100644 index 000000000..e472420c2 --- /dev/null +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { + AUTO_THINKING, + clampAutoThinkingEffort, + parseConfiguredThinkingLevel, + parseThinkingLevel, +} from "@oh-my-pi/pi-coding-agent/thinking"; +import { parseDifficultyBucket, parseDifficultyLevel } from "../src/auto-thinking/classifier"; + +describe("auto thinking classifier helpers", () => { + it("parses configured thinking without widening provider-facing thinking selectors", () => { + expect(parseConfiguredThinkingLevel(AUTO_THINKING)).toBe(AUTO_THINKING); + expect(parseConfiguredThinkingLevel(Effort.High)).toBe(Effort.High); + expect(parseConfiguredThinkingLevel("bogus")).toBeUndefined(); + expect(parseThinkingLevel(AUTO_THINKING)).toBeUndefined(); + expect(parseThinkingLevel(ThinkingLevel.Off)).toBe(ThinkingLevel.Off); + }); + + it("maps online 4-way classifier labels to effort levels", () => { + expect(parseDifficultyLevel("x-high")).toBe(Effort.XHigh); + expect(parseDifficultyLevel("The answer is HIGH.")).toBe(Effort.High); + expect(parseDifficultyLevel("med")).toBe(Effort.Medium); + expect(parseDifficultyLevel("low")).toBe(Effort.Low); + expect(parseDifficultyLevel("unknown")).toBeUndefined(); + }); + + it("maps local 3-bucket labels to coarse effort levels", () => { + expect(parseDifficultyBucket("trivial")).toBe(Effort.Low); + expect(parseDifficultyBucket("moderate")).toBe(Effort.High); + expect(parseDifficultyBucket("hard")).toBe(Effort.XHigh); + expect(parseDifficultyBucket("medium")).toBeUndefined(); + }); + + it("clamps auto effort to model support while never resolving below low", () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!model) throw new Error("Expected bundled Claude Sonnet 4.6 model"); + + expect(clampAutoThinkingEffort(model, Effort.XHigh)).toBe(Effort.High); + expect(clampAutoThinkingEffort(model, Effort.Minimal)).toBe(Effort.Low); + }); +});