// spawn-model.ts — WORKER/REVIEWER MODEL RESOLUTION (KEY: AUTOPILOT-80). // // AUTOPILOT-79 let an ITEM carry a `model` override, but only WORKER spawns // (auto-dispatch, re-dispatch, spec pass, queue_dispatch) read it — reviewer // spawns (autoReview, queue_review) always inherited the session's model, and // there was no config-wide default at all. AUTOPILOT-80 adds a THIRD tier — a // config-wide default (autopilot.config.json top-level `model`) — and routes // EVERY backend.spawn call site, worker AND reviewer, through the SAME // precedence: an explicit per-call param wins, then the item's own override, // then the config default, else the child inherits exactly as before either // feature existed. // // AUTOPILOT-110 adds the LEVEL MAP (`models`): a // user-managed mapping from a NAMED level ("high"/"medium"/"low") to a model, // so capability choices are expressed as a level and re-pointed in ONE place // instead of hardcoding vendor ids into every item scope. An item or a call // carries a `tier` (a level NAME); the level is resolved through the map at // dispatch time. A raw `provider/id[:thinking]` stays the escape hatch and // still wins — an unconfigured map behaves exactly as before. // // THE MAP IS OWNED BY THE ACTUATOR (KEY: AUTOPILOT-111): // `actuator.config.json` → `models` — a SIBLING of autopilot.config.json in // the SAME resolved state dir — is the ONE place to re-point model choices. // ADR-0001 puts new user-facing capability in the actuator (the extension is // bugfix-only), so this repo READS that map instead of keeping a second one. // The duplication that defeated the single-place goal is CLOSED; this repo's // own autopilot.config.json `models` key is kept working only as a strictly // secondary fallback. DO NOT re-add a harness-owned map. // // THE CHAIN (AUTOPILOT-110): // call.model > item.model > call tier > item tier > config.model // > config default tier (models.default / models.reviewDefault) > inherit // At every rung an EXPLICIT MODEL beats a TIER (a vendor string is more // specific than a capability name), and a more SPECIFIC rung beats a less // specific one. The default-tier rung is ROLE-aware: a reviewer resolves // through models.reviewDefault, a worker through models.default — reviewing // and implementing are different capability needs. // // The level -> model LOOKUP lives in ONE function (resolveModelLevel) and the // map's SOURCE is resolved in ONE place — config.ts's loadAutopilotConfig // reads the ACTUATOR's actuator.config.json `models` (the owner, a sibling // file in the same state dir) and merges this repo's own `models` under it as // a fallback. resolveSpawnModel itself keeps reading the in-memory // config.models, so every call site stays a single read (KEY: AUTOPILOT-111). // // A tiny LEAF module: it imports only ./repo-keys.ts (itself a node-builtins- // only leaf, so nothing can cycle back through here) — src/config.ts and // src/framework/* + src/tools/* can all import it without a cycle. import { repoPolicyKeys } from "./repo-keys.ts"; /** A LEVEL's model (the object form of a level definition) — `thinking` is * applied as the `:thinking` suffix when the level resolves. */ export interface ModelLevel { model: string; thinking?: string; } /** A level definition: the plain-string shorthand `"provider/id[:thinking]"` * or the object form `{ model, thinking? }`. */ export type ModelLevelDef = string | ModelLevel; /** Keys inside `models` that are NOT level names. `default` / `reviewDefault` * name a LEVEL (the worker / reviewer default tier); `repos` holds per-repo * level overrides. A level cannot be named one of these. */ export const MODEL_LEVEL_RESERVED_KEYS: readonly string[] = ["default", "reviewDefault", "repos"]; /** The user's LEVEL MAP: level name → model, plus the optional worker / * reviewer default tier names and per-repo overrides. OWNED by the actuator * (actuator.config.json `models`, KEY: AUTOPILOT-111); this repo's own * autopilot.config.json `models` is kept only as a fallback. */ export interface ModelsConfig { /** Default WORKER tier (a level name). */ default?: string; /** Default REVIEWER tier (a level name) — honoured independently of the * worker default. */ reviewDefault?: string; /** Per-repo level overrides, keyed the same way `shipping.repos` is * (origin slug → bare repo name → basename). Each entry is itself a * ModelsConfig. */ repos?: Record; /** Level name → definition. */ [level: string]: unknown; } export interface ResolveSpawnModelInput { /** An explicit per-call raw model param (queue_dispatch/queue_review's * `model`) — the escape hatch; always wins. */ param?: string; /** An explicit per-call TIER param (a level name) — the caller's capability * choice for THIS spawn; beats the item's standing tier, is beaten by an * explicit model. */ tier?: string; /** The queue item's own overrides: a raw `model` (AUTOPILOT-79) and/or a * standing `tier` (AUTOPILOT-110). */ item?: { model?: string | null; tier?: string | null } | null; /** The loaded config: the raw `model` default (AUTOPILOT-80) and the level * map (AUTOPILOT-110). */ config?: { model?: string; models?: ModelsConfig } | null; /** Whether this spawn is a WORKER or REVIEWER spawn — selects which config * default tier (models.default vs models.reviewDefault) applies at the * config rung. Defaults to "worker". */ role?: "worker" | "review"; /** The repo the work lands in; used to match a per-repo level override * (models.repos). Optional — no repo, no override. */ repo?: string | null; } export type SpawnModelSource = | "via param" | "via item" | "via param tier" | "via item tier" | "via config" | "via config tier" | "inherit"; /** A successful resolution. `model === undefined` means every backend drops * the field and the child inherits the session's model, byte-identical to a * launch before any model feature existed. */ export interface ResolvedSpawnModel { ok: true; model: string | undefined; source: SpawnModelSource; /** The LEVEL name the model resolved through — present only for a tier * source (AUTOPILOT-110). */ tier?: string; /** The line that SELECTED the level, e.g. "call tier param", "item.tier", * "models.default", "models.reviewDefault". */ tierFrom?: string; /** The map entry whose LEVEL DEFINITION resolved the model, e.g. * `models['high']` or `models.repos['acme/thing']['high']`. */ tierEntry?: string; } /** A FAILED resolution — an unknown (or invalid) level. Returned, never * silently downgraded to inherit: a typo must be visible, not spawn the * session model behind the caller's back. */ export interface SpawnModelError { ok: false; error: string; } export type SpawnModelResolution = ResolvedSpawnModel | SpawnModelError; /** param(model) > item.model > param tier > item tier > config.model > * config default tier > inherit — the ONE precedence rule every * backend.spawn call site resolves through (worker AND reviewer). */ export function resolveSpawnModel(input: ResolveSpawnModelInput): SpawnModelResolution { // 1. an explicit per-call model — the escape hatch, always wins. if (input.param) return { ok: true, model: input.param, source: "via param" }; // 2. the item's own explicit model (raw beats a tier). if (input.item?.model) return { ok: true, model: input.item.model, source: "via item" }; // The effective per-repo override (models.repos) — resolved ONCE so the // level lookups and the default-tier lookup cannot match different keys. const models = input.config?.models; const override = repoOverride(models, input.repo ?? null); // 3. the call's TIER param — the caller's capability choice for THIS spawn. if (input.tier) return tierResolution(input.tier, "via param tier", "call tier param", models, override); // 4. the item's standing tier. if (input.item?.tier) return tierResolution(input.item.tier, "via item tier", "item.tier", models, override); // 5. the legacy raw config default (AUTOPILOT-80) — unchanged when no map // is configured, so an existing config keeps behaving identically. if (input.config?.model) return { ok: true, model: input.config.model, source: "via config" }; // 6. the config default tier — ROLE-aware: a reviewer default is independent // of the worker default. A per-repo override may re-point either default. const role: "worker" | "review" = input.role === "review" ? "review" : "worker"; const defaultTier = role === "review" ? (override?.config.reviewDefault ?? models?.reviewDefault) : (override?.config.default ?? models?.default); if (typeof defaultTier === "string" && defaultTier.trim()) { return tierResolution(defaultTier.trim(), "via config tier", role === "review" ? "models.reviewDefault" : "models.default", models, override); } // 7. inherit — nothing configured anywhere. return { ok: true, model: undefined, source: "inherit" }; } function tierResolution( level: string, source: SpawnModelSource, tierFrom: string, models: ModelsConfig | undefined, override: RepoOverride | null, ): SpawnModelResolution { const r = resolveLevel(level, models, override); if (!r.ok) return r; return { ok: true, model: r.model, source, tier: r.tier, tierFrom, tierEntry: r.tierEntry }; } /** THE level → model LOOKUP (AUTOPILOT-110) — the ONE place a level NAME is * turned into a `provider/id[:thinking]` string. Unknown levels FAIL LOUDLY, * listing the configured keys: a silent fall-back to inherit would make a * typo invisible. Never throws. */ export function resolveModelLevel( level: string, models: ModelsConfig | undefined, repo?: string | null, ): { ok: true; model: string; tier: string; tierEntry: string } | SpawnModelError { return resolveLevel(level, models, repoOverride(models, repo ?? null)); } function resolveLevel( level: string, models: ModelsConfig | undefined, override: RepoOverride | null, ): { ok: true; model: string; tier: string; tierEntry: string } | SpawnModelError { const base = nonReservedLevels(models); const repoLevels = override ? nonReservedLevels(override.config) : null; // A per-repo override wins over the base map for the SAME level name. const inRepo = repoLevels !== null && level in repoLevels; const def = inRepo ? repoLevels![level] : base[level]; const keys = Object.keys({ ...base, ...(repoLevels ?? {}) }).sort(); if (def === undefined) { const listed = keys.length ? keys.join(", ") : "(none — add a `models` map to actuator.config.json)"; return { ok: false, error: `unknown model level '${level}' — configured levels: ${listed}` }; } const parsed = parseLevelDef(def); if (!parsed.ok) return { ok: false, error: `model level '${level}' is invalid: ${parsed.reason}` }; const tierEntry = inRepo ? `models.repos['${override!.key}']['${level}']` : `models['${level}']`; return { ok: true, model: parsed.model, tier: level, tierEntry }; } /** A per-repo level override (models.repos) whose key MATCHED the repo, keyed * the same way `shipping.repos` is (origin slug → bare repo name → basename). * An override may re-point level definitions AND the default/reviewDefault * tier names. */ interface RepoOverride { key: string; config: ModelsConfig } function repoOverride(models: ModelsConfig | undefined, repo: string | null | undefined): RepoOverride | null { const repos = models?.repos; if (!repo || !repos || typeof repos !== "object") return null; for (const key of repoPolicyKeys(repo)) { const override = (repos as Record)[key]; if (override && typeof override === "object") return { key, config: override as ModelsConfig }; } return null; } function nonReservedLevels(models: ModelsConfig | undefined): Record { const out: Record = {}; if (!models || typeof models !== "object") return out; for (const [k, v] of Object.entries(models)) { if (MODEL_LEVEL_RESERVED_KEYS.includes(k)) continue; out[k] = v; } return out; } /** Normalize a level definition (string shorthand OR object form) into the * final `provider/id[:thinking]` string. An object's `thinking` is applied as * the suffix, REPLACING any `:thinking` already inline on the model. */ function parseLevelDef(def: unknown): { ok: true; model: string } | { ok: false; reason: string } { if (typeof def === "string") { const m = normalizeModel(def); return m.ok ? { ok: true, model: m.model } : { ok: false, reason: m.reason }; } if (def && typeof def === "object" && !Array.isArray(def)) { const obj = def as { model?: unknown; thinking?: unknown }; const m = normalizeModel(obj.model); if (!m.ok) return { ok: false, reason: `the {model, thinking?} form needs a valid model — ${m.reason}` }; const thinking = typeof obj.thinking === "string" ? obj.thinking.trim() : ""; return { ok: true, model: thinking ? withThinking(m.model, thinking) : m.model }; } return { ok: false, reason: "must be a 'provider/id[:thinking]' string or an object { model, thinking? }" }; } /** Why a level DEFINITION is invalid, or null when it is valid — the SAME * validation resolveLevel applies at resolution time (parseLevelDef). Exported * so the config loader can DROP and REPORT a malformed entry in the ACTUATOR's * map at load (AUTOPILOT-111): a bad entry in another system's file must be * reported, not fatal — a valid local fallback still applies. Never throws. */ export function levelDefProblem(def: unknown): string | null { const parsed = parseLevelDef(def); return parsed.ok ? null : parsed.reason; } /** Append — or REPLACE — the `:thinking` suffix of a `provider/id` model. */ function withThinking(model: string, thinking: string): string { const slash = model.lastIndexOf("/"); const colon = model.lastIndexOf(":"); const base = colon > slash ? model.slice(0, colon) : model; return `${base}:${thinking}`; } /** A short human note naming the TIER and the map entry it resolved through * (AUTOPILOT-110), for a dispatch/review receipt. Empty for a non-tier * resolution. */ export function tierNote(r: ResolvedSpawnModel): string { if (!r.tier) return ""; return ` (tier '${r.tier}' from ${r.tierFrom}${r.tierEntry ? ` → ${r.tierEntry}` : ""})`; } /** Validate a TIER (a level name): a non-empty string. Deliberately NOT * checked against the map here — a level is resolved against the config in * effect at DISPATCH time (the map may change between add and dispatch), and * an unknown level fails LOUDLY at resolution (resolveModelLevel). Returns * the trimmed tier, or the reason it is refused. */ export function normalizeTier(v: unknown): { ok: true; tier: string } | { ok: false; reason: string } { const tier = typeof v === "string" ? v.trim() : ""; if (!tier) return { ok: false, reason: "tier must be a non-empty level name (e.g. high|medium|low — the levels defined under actuator.config.json `models`)" }; return { ok: true, tier }; } /** Validate a WORKER/REVIEWER MODEL OVERRIDE (KEY: AUTOPILOT-79/80): a * non-empty `provider/id[:thinking]` string — the shape both runtimes take * verbatim (pi-subagents' per-run `model`, `opencode run --model`). * Deliberately NOT checked against a model registry: the runtime rejects an * unknown model at launch, and this repo must not carry a second copy of * that catalogue. Returns the trimmed model, or the reason the value is * refused. Shared by tools/queue-ops.ts (param/item validation, moved here * from queue-ops.ts at AUTOPILOT-80 so config.ts can reuse it without an * import cycle) and config.ts (the top-level config default). */ export function normalizeModel(v: unknown): { ok: true; model: string } | { ok: false; reason: string } { const model = typeof v === "string" ? v.trim() : ""; if (!model) return { ok: false, reason: "model must be a non-empty 'provider/id[:thinking]' string (omit it to inherit the session's model)" }; if (!model.includes("/")) return { ok: false, reason: `model '${model}' must be 'provider/id[:thinking]' — the provider prefix is missing (omit it to inherit the session's model)` }; return { ok: true, model }; }