Files
oh-my-pi/packages/coding-agent/src/auto-thinking/classifier.ts
T
roboomp b0e07f52d2 fix(coding-agent): clamped auto thinking to undefined for models without controllable effort
Devin provider models (devin-agent) advertise reasoning: true but no
thinking.efforts metadata — Cascade selects effort by routing to sibling
model ids, not a wire param. getSupportedEfforts(model) therefore returns
[]. clampAutoThinkingEffort previously short-circuited that empty supported
list by returning the requested effort as-is, so the auto-thinking
classifier-resolved level (e.g. low) reached stream.ts:1163 where
requireSupportedEffort threw 'Thinking effort low is not supported by
devin/<id>. Supported efforts: '. In --print mode the user saw the error
text; in the TUI it was silently swallowed, producing the reported
'working then empty response' symptom.

Returns undefined when supported is empty so the result mirrors
clampThinkingLevelForModel's behavior on the same shape (the explicit
--thinking low / high paths already worked because of this). Updates
classifyDifficulty's return type to Effort | undefined and threads through
to the existing #applyAutoThinkingLevel undefined-effort early-return.
#applyAutoThinkingLevel also short-circuits the classifier call up front
for these models — there is no effort to pick.

Fixes #3356
2026-06-24 14:12:18 +00:00

193 lines
7.7 KiB
TypeScript

/**
* 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,
isTinyMemoryReasoningModelKey,
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 online answer is a single word; keep budgets tiny for non-reasoning backends. */
const ANSWER_MAX_TOKENS = 8;
/** Local classifiers occasionally need more room for chat-template boilerplate. */
const LOCAL_ANSWER_MAX_TOKENS = 16;
/**
* 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<string, unknown> | undefined;
}
/**
* Classify `promptText` and return a concrete effort clamped to `deps.model`,
* or `undefined` when the model has no controllable effort surface (auto has
* nothing to pick — the caller leaves the prior reasoning level in place).
* @throws when the backend cannot produce a usable classification.
*/
export async function classifyDifficulty(promptText: string, deps: ClassifyDifficultyDeps): Promise<Effort | undefined> {
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<Effort> {
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: deps.registry.resolver(model, deps.sessionId),
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<Effort> {
if (!isTinyMemoryLocalModelKey(modelKey)) {
throw new Error(`auto-thinking: unsupported local classifier model: ${modelKey}`);
}
const maxTokens = isTinyMemoryReasoningModelKey(modelKey)
? Math.max(LOCAL_ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS)
: LOCAL_ANSWER_MAX_TOKENS;
const builtPrompt = prompt.render(difficultyLocalPrompt, { prompt: input });
const text = await tinyModelClient.complete(modelKey, builtPrompt, {
maxTokens,
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<AssistantMessage["content"][number], { type: "text" }> => 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)}`;
}