Files
oh-my-pi/packages/coding-agent/src/thinking.ts
T
2026-07-31 21:28:41 +00:00

378 lines
16 KiB
TypeScript

import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { clampThinkingLevelForModel, getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
export { CLI_THINKING_LEVELS } from "./cli/thinking-levels";
/**
* Metadata used to render thinking selector values in the coding-agent UI.
*/
export interface ThinkingLevelMetadata {
value: ThinkingLevel;
label: string;
description: string;
}
const THINKING_LEVEL_METADATA: Record<ThinkingLevel, ThinkingLevelMetadata> = {
[ThinkingLevel.Inherit]: {
value: ThinkingLevel.Inherit,
label: "inherit",
description: "Inherit session default",
},
[ThinkingLevel.Off]: { value: ThinkingLevel.Off, label: "off", description: "No reasoning" },
[ThinkingLevel.Minimal]: {
value: ThinkingLevel.Minimal,
label: "min",
description: "Very brief reasoning (~1k tokens)",
},
[ThinkingLevel.Low]: { value: ThinkingLevel.Low, label: "low", description: "Light reasoning (~2k tokens)" },
[ThinkingLevel.Medium]: {
value: ThinkingLevel.Medium,
label: "medium",
description: "Moderate reasoning (~8k tokens)",
},
[ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" },
[ThinkingLevel.XHigh]: {
value: ThinkingLevel.XHigh,
label: "xhigh",
description: "Extended reasoning (~32k tokens)",
},
[ThinkingLevel.Max]: {
value: ThinkingLevel.Max,
label: "max",
description: "Maximum reasoning the model supports",
},
};
const EFFORT_BY_SELECTOR: Readonly<Record<string, Effort>> = {
[Effort.Minimal]: Effort.Minimal,
[Effort.Low]: Effort.Low,
[Effort.Medium]: Effort.Medium,
[Effort.High]: Effort.High,
[Effort.XHigh]: Effort.XHigh,
[Effort.Max]: Effort.Max,
};
const THINKING_LEVEL_BY_SELECTOR: Readonly<Record<string, ThinkingLevel>> = {
[ThinkingLevel.Inherit]: ThinkingLevel.Inherit,
[ThinkingLevel.Off]: ThinkingLevel.Off,
[ThinkingLevel.Minimal]: ThinkingLevel.Minimal,
[ThinkingLevel.Low]: ThinkingLevel.Low,
[ThinkingLevel.Medium]: ThinkingLevel.Medium,
[ThinkingLevel.High]: ThinkingLevel.High,
[ThinkingLevel.XHigh]: ThinkingLevel.XHigh,
[ThinkingLevel.Max]: ThinkingLevel.Max,
};
function getOwnSelector<T>(selectors: Readonly<Record<string, T>>, value: string | null | undefined): T | undefined {
if (value === undefined || value === null) return undefined;
if (Object.hasOwn(selectors, value)) return selectors[value];
// Accept unambiguous abbreviations (`xhi` → xhigh, `med` → medium) so every
// selector surface (`--thinking`, `:suffix`, role values) parses alike.
// Two-character minimum keeps single letters (`m`) from guessing.
if (value.length < 2) return undefined;
const matches = Object.keys(selectors).filter(selector => selector.startsWith(value));
return matches.length === 1 ? selectors[matches[0]] : undefined;
}
/**
* Parses a provider-facing effort value. Accepts unambiguous abbreviations.
*/
export function parseEffort(value: string | null | undefined): Effort | undefined {
return getOwnSelector(EFFORT_BY_SELECTOR, value);
}
/**
* Parses an agent-local thinking selector. Accepts unambiguous abbreviations.
*/
export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined {
return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value);
}
/**
* Returns display metadata for a thinking selector.
*/
export function getThinkingLevelMetadata(level: ThinkingLevel): ThinkingLevelMetadata {
return THINKING_LEVEL_METADATA[level];
}
/**
* Converts an agent-local selector into the effort sent to providers.
*/
export function toReasoningEffort(level: ThinkingLevel | undefined): Effort | undefined {
if (level === undefined || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) {
return undefined;
}
return level;
}
/**
* True when a selector explicitly requests provider-side reasoning disablement.
*/
export function shouldDisableReasoning(level: ThinkingLevel | undefined): boolean {
return level === ThinkingLevel.Off;
}
/**
* Resolves a selector against the current model while preserving explicit "off".
*/
export function resolveThinkingLevelForModel(
model: Model | undefined,
level: ThinkingLevel | undefined,
): ResolvedThinkingLevel | undefined {
if (level === undefined || level === ThinkingLevel.Inherit) {
return undefined;
}
if (level === ThinkingLevel.Off) {
return ThinkingLevel.Off;
}
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;
/** Maps the session-level `auto` sentinel to `undefined`; concrete levels pass through. */
export function concreteThinkingLevel(level: ConfiguredThinkingLevel | undefined): ThinkingLevel | undefined {
return level === AUTO_THINKING ? undefined : level;
}
/**
* True when a prewalk hand-off from `current`/`currentLevel` to
* `target`/`targetLevel` would change nothing observable: same model id, same
* auto/fixed mode, and the same model-clamped effective effort. Prewalk arms and
* switches only when this is false.
*
* An effort-only delta on the same model id is a legitimate cheapening hand-off
* — on a reasoning model the effort is the bulk of the cost — so it is NOT a
* no-op and must still switch. A `targetLevel` of `undefined` means the prewalk
* pattern carried no explicit `:level` suffix (no effort change requested),
* which on the same model is a no-op.
*
* `auto` mode is compared before efforts: `auto` and a fixed selector that both
* resolve to `undefined` effort (e.g. `:inherit`) are NOT interchangeable —
* applying the fixed selector clears per-turn classification, so switching
* auto↔fixed is always a real change even when the clamped efforts match.
*
* Efforts are otherwise compared AFTER model clamping, so a target the model
* cannot honor (e.g. `:xhigh` on a model capped at `high`) — which
* `setThinkingLevel` would clamp straight back to the active effort — is
* recognized as a no-op instead of triggering an ephemeral reset and the
* plan/checklist nudges for nothing.
*/
export function prewalkWouldBeNoop(
current: Model | undefined,
currentLevel: ConfiguredThinkingLevel | undefined,
target: Model,
targetLevel: ConfiguredThinkingLevel | undefined,
): boolean {
if (!modelsAreEqual(current, target)) return false;
if (targetLevel === undefined) return true;
if ((targetLevel === AUTO_THINKING) !== (currentLevel === AUTO_THINKING)) return false;
return (
resolveThinkingLevelForModel(target, concreteThinkingLevel(targetLevel)) ===
resolveThinkingLevelForModel(target, concreteThinkingLevel(currentLevel))
);
}
/** 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",
};
/**
* 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);
}
/**
* Parses a `--thinking` CLI value. Accepts every {@link parseConfiguredThinkingLevel}
* selector (`off`, `auto`, `minimal`..`max`) but rejects
* `inherit`: an explicit `inherit` on the command line would suppress the
* settings/scoped-model fallback during startup resolution only to resolve back
* to the provider default, which is never what the user means.
*/
export function parseCliThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
const level = parseConfiguredThinkingLevel(value);
return level === ThinkingLevel.Inherit ? undefined : 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.
* `ceiling` bounds the pool from above, so a policy ceiling survives the model
* clamp: a sparse ladder such as `["max"]` must not snap an `xhigh` request up
* to `max`. The Low floor is resolved against the model's own ladder *before*
* the ceiling applies — a ceiling that hides every tier at or above Low means
* there is nothing legal to pick (`undefined`), not a licence to fall through
* to a sub-Low tier the model happens to expose.
*
* Returns `undefined` for reasoning-capable models without a controllable
* effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
* Cascade selects effort by routing to sibling model ids). Matches
* {@link clampThinkingLevelForModel}: with no effort to pick, `auto` must not
* forward a concrete effort that would then trip {@link requireSupportedEffort}
* downstream.
*/
export function clampAutoThinkingEffort(
model: Model | undefined,
effort: Effort,
ceiling: Effort = Effort.Max,
): Effort | undefined {
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
if (supported.length === 0) return undefined;
const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
const ceilingIndex = THINKING_EFFORTS.indexOf(ceiling);
const atOrAboveLow = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
const floored = atOrAboveLow.length > 0 ? atOrAboveLow : supported;
const pool = floored.filter(level => THINKING_EFFORTS.indexOf(level) <= ceilingIndex);
if (pool.length === 0) return undefined;
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;
}
/** Coarse per-spawn effort selectors accepted by the task tool. */
export const TASK_EFFORTS = ["lo", "med", "hi"] as const;
/** Coarse task-spawn effort: the lowest, middle, or highest thinking level the target model supports. */
export type TaskEffort = (typeof TASK_EFFORTS)[number];
/**
* Maps a coarse task effort onto the model's supported thinking range:
* `lo` = lowest supported level, `hi` = highest (whatever the model tops out
* at — high, xhigh, or max), `med` = the middle (lower of the two middles for
* an even-sized range). Without a model, maps over the full canonical range.
* Returns `undefined` when the model has no controllable effort surface, so
* callers fall back to their default selector (e.g. `auto`). Throws when the
* configured ceiling is below the model's lowest supported effort.
*/
export function resolveTaskEffortLevel(
model: Model | undefined,
effort: TaskEffort,
maxEffort?: Effort,
): Effort | undefined {
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
if (supported.length === 0) return undefined;
let resolved: Effort;
switch (effort) {
case "lo":
resolved = supported[0];
break;
case "med":
resolved = supported[(supported.length - 1) >> 1];
break;
case "hi":
resolved = supported[supported.length - 1];
break;
}
if (maxEffort === undefined) return resolved;
const maxIndex = THINKING_EFFORTS.indexOf(maxEffort);
const ceiling = supported.findLast(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex);
if (ceiling === undefined) {
const modelName = model ? `${model.provider}/${model.id}` : "Selected model";
throw new RangeError(`${modelName} has no supported thinking effort at or below task.maxEffort=${maxEffort}`);
}
return THINKING_EFFORTS.indexOf(resolved) > THINKING_EFFORTS.indexOf(ceiling) ? ceiling : resolved;
}
/**
* Clamps a concrete thinking selector to a per-session effort ceiling (e.g. a
* task spawn's `task.maxEffort`-capped effort hint). `off`/`inherit`/
* `undefined` pass through, as do levels already at or below the ceiling.
* Levels above it snap to the highest model-supported effort at or below the
* ceiling. A model whose floor exceeds the ceiling has nothing valid to snap
* to; the requested level is returned unchanged and the caller is responsible
* for rejecting or skipping such models (see {@link modelSupportsEffortCeiling}).
*/
export function clampThinkingLevelToCeiling(
model: Model | undefined,
level: Effort | undefined,
ceiling: Effort | undefined,
): Effort | undefined;
export function clampThinkingLevelToCeiling(
model: Model | undefined,
level: ThinkingLevel | undefined,
ceiling: Effort | undefined,
): ThinkingLevel | undefined;
export function clampThinkingLevelToCeiling(
model: Model | undefined,
level: ThinkingLevel | undefined,
ceiling: Effort | undefined,
): ThinkingLevel | undefined {
if (ceiling === undefined || level === undefined || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) {
return level;
}
const maxIndex = THINKING_EFFORTS.indexOf(ceiling);
if (THINKING_EFFORTS.indexOf(level) <= maxIndex) return level;
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
return supported.findLast(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex) ?? level;
}
/**
* True when `model` can honor a thinking-effort ceiling: it either has no
* controllable effort surface (nothing to cap) or supports at least one effort
* at or below the ceiling. Retry-fallback candidate filtering uses this to
* skip models whose floor would force the session above the ceiling.
*/
export function modelSupportsEffortCeiling(model: Model, ceiling: Effort): boolean {
const supported = getSupportedEfforts(model);
if (supported.length === 0) return true;
const maxIndex = THINKING_EFFORTS.indexOf(ceiling);
return supported.some(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex);
}
/**
* The provisional concrete level shown while `auto` is configured but before a
* turn has been classified, and the fallback when classification fails. Prefers
* the model's `defaultLevel`, otherwise High, clamped into the auto range.
*
* Deliberately stays below {@link Effort.Max}: the placeholder must not bill the
* top tier for a turn nobody classified, so XHigh is passed as a hard ceiling
* rather than only capping the preferred level — otherwise a sparse `["max"]`
* ladder would snap straight back up. A model whose ladder offers nothing at or
* below XHigh therefore has no provisional level, and `auto` leaves the current
* one in place. Classification itself may still resolve Max on models that
* expose the tier when the user opts in. Returns `undefined` for non-reasoning
* models.
*/
export function resolveProvisionalAutoLevel(model: Model | undefined): Effort | undefined {
if (!model?.reasoning) return undefined;
const preferred = model.thinking?.defaultLevel ?? Effort.High;
return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred, Effort.XHigh);
}