Files
oh-my-pi/packages/coding-agent/src/thinking.ts
T
can1357 f9f6ed9e8d feat(coding-agent): replaced legacy pi/ role alias prefix with
- Replaced legacy `pi/` role alias prefix with canonical `@` syntax across model resolution, documentation, and tests.
- Added support for bare `*` default alias and multiple alias prefix detection with custom role resolution in `resolveConfiguredRolePattern()`.
- Enhanced thinking suffix parsing to accept unambiguous abbreviations (minimum 2 characters) for effort and level selectors.
- Extended `resolveCliModel()` and `filterAvailableModelsByEnabledPatterns()` to accept settings parameter for role alias resolution from `--model` flag.
2026-07-13 23:26:33 +02:00

236 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
/**
* 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;
}
/** 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;
}
/**
* 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);
}