7a6265f801
prewalkWouldBeNoop collapsed both auto and a fixed :inherit selector to an undefined clamped effort, so a same-model prewalk targeting :inherit while the session ran auto was dropped as a no-op. Applying :inherit clears per-turn classification, so that hand-off is a real change. Compare auto/fixed mode before comparing clamped efforts so an auto<->fixed transition always switches. Fixes #6659
302 lines
12 KiB
TypeScript
302 lines
12 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";
|
||
|
||
/**
|
||
* 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 (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);
|
||
}
|
||
|
||
/**
|
||
* Thinking selectors accepted by the `--thinking` CLI flag, in display order:
|
||
* `off`, every concrete effort (`minimal`..`max`), then `auto`. Single source
|
||
* for the flag's `options` list, shell completions, and the "invalid level"
|
||
* warning so all three stay in sync.
|
||
*/
|
||
export const CLI_THINKING_LEVELS: readonly string[] = [ThinkingLevel.Off, ...THINKING_EFFORTS, AUTO_THINKING];
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* 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): Effort | undefined {
|
||
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
|
||
if (supported.length === 0) return undefined;
|
||
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;
|
||
}
|
||
|
||
/** 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`).
|
||
*/
|
||
export function resolveTaskEffortLevel(model: Model | undefined, effort: TaskEffort): Effort | undefined {
|
||
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
|
||
if (supported.length === 0) return undefined;
|
||
switch (effort) {
|
||
case "lo":
|
||
return supported[0];
|
||
case "med":
|
||
return supported[(supported.length - 1) >> 1];
|
||
case "hi":
|
||
return supported[supported.length - 1];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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. Auto never provisions {@link Effort.Max} (the
|
||
* classifier ceiling is XHigh; only an explicit user request reaches Max), so a
|
||
* `defaultLevel` of `max` is capped at XHigh before clamping. 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);
|
||
}
|