From 958f953b986eff38ff4158c8d3586c6afdecd397 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 18:00:44 +0000 Subject: [PATCH] refactor(orchestrate): collapse model config to ORCHESTRATE_MODEL_CATALOG Drop the per-role env vars and the catalog mode flag. A single JSON array replaces the built-in catalog outright when set, which removes merge precedence entirely: the configured list is the complete menu and its defaultFor entries supply every role default, including the root planner. Co-authored-by: Jonny Alexander Power --- orchestrate/README.md | 65 ++- orchestrate/skills/orchestrate/SKILL.md | 2 +- .../orchestrate/references/dispatcher.md | 2 +- .../scripts/__tests__/models-catalog.test.ts | 27 +- .../__tests__/models-env-overrides.test.ts | 352 +++++++--------- .../skills/orchestrate/scripts/cli/task.ts | 2 +- .../skills/orchestrate/scripts/models.ts | 391 +++++++----------- 7 files changed, 321 insertions(+), 520 deletions(-) diff --git a/orchestrate/README.md b/orchestrate/README.md index dc5dafe..4c19fb7 100644 --- a/orchestrate/README.md +++ b/orchestrate/README.md @@ -10,65 +10,52 @@ The skill itself lives in [`skills/orchestrate/SKILL.md`](./skills/orchestrate/S - A Cursor API key in `CURSOR_API_KEY`. - Optional Slack app and bot token if you want a Slack thread mirroring the run. -## Model configuration (optional) +## Model catalog (optional) -The built-in `MODEL_CATALOG` is merged with environment config into an **effective catalog**. That merged catalog is what planners see when they set `tasks[].model`, what `bun cli.ts models` prints, and what supplies role defaults when `tasks[].model` is omitted. Use it to steer cost without editing the plugin. - -### Role defaults - -| Env var | Role | -| --- | --- | -| `ORCHESTRATE_MODEL_WORKER` | worker default | -| `ORCHESTRATE_MODEL_SUBPLANNER` | subplanner default | -| `ORCHESTRATE_MODEL_VERIFIER` | verifier default | -| `ORCHESTRATE_MODEL_ROOT` | kickoff `--model` default | - -Each value may be a catalog slug (`composer-2-fast`), a bare model id (`composer-2.5`), or a JSON entry: - -```bash -export ORCHESTRATE_MODEL_WORKER=composer-2-fast -export ORCHESTRATE_MODEL_SUBPLANNER='{"id":"composer-2.5","params":[{"id":"fast","value":"true"}]}' -``` - -Anything named this way joins the effective catalog, so planners can select it by slug and it resolves back to the full selection (params included). A JSON entry may also carry `slug`, `summary`, `use`, `speed`, and `strengths` — worth setting, since planners choose by the capability prose: - -```bash -export ORCHESTRATE_MODEL_WORKER='{"slug":"house-worker","id":"composer-2.5","summary":"House worker model.","use":"Use for all bounded implementation work.","speed":"fast","strengths":["throughput"]}' -``` - -### Adding models - -`ORCHESTRATE_MODEL_CATALOG` takes a JSON array of entries. Entries with an `id` define a model; entries with only a `slug` pull in a built-in by reference. `defaultFor` claims a role. +`ORCHESTRATE_MODEL_CATALOG` replaces the built-in model catalog with your own. When it is set, that list is the complete menu: it is what planners choose `tasks[].model` from, what `bun cli.ts models` prints, and where role defaults come from. Nothing is merged with the built-in catalog, so what you write is exactly what runs. Use it to steer cost without editing the plugin. ```bash export ORCHESTRATE_MODEL_CATALOG='[ {"id":"composer-2.5","summary":"Cheap, fast worker.","defaultFor":["worker"]}, - {"slug":"claude-opus-4-8","defaultFor":["subplanner","verifier"]} + {"slug":"claude-opus-4-8","defaultFor":["subplanner","verifier","root"]} ]' ``` -### Restricting the menu +### Entries + +An entry either **defines** a model or **references** a built-in one: + +- `"id"` (plus optional `"params"`) defines a model. `"slug"` names it for `tasks[].model`, defaulting to the id. +- `"slug"` alone pulls in the built-in profile of that name, so you can curate a subset without retyping SDK params. Run `bun cli.ts models` with the variable unset to see the built-in slugs. + +`"defaultFor"` claims roles: `worker`, `subplanner`, `verifier`, and `root` (the kickoff planner, i.e. the `--model` default). Every role except `root` needs a default somewhere in the list. -`ORCHESTRATE_MODEL_CATALOG_MODE=env-only` drops the built-in catalog, so planners see exactly the models you list (built-ins can be pulled back in by slug). Every role needs a default in this mode; the CLI exits with a config error if one is missing. +Optional prose fields are worth filling in, because planners choose by capability, not by name: ```bash -export ORCHESTRATE_MODEL_CATALOG_MODE=env-only export ORCHESTRATE_MODEL_CATALOG='[ - {"slug":"composer-2-fast","defaultFor":["worker"]}, + { + "slug":"house-worker", + "id":"composer-2.5", + "params":[{"id":"fast","value":"true"}], + "summary":"House worker model.", + "use":"Use for all bounded implementation work.", + "speed":"fast", + "strengths":["throughput"], + "defaultFor":["worker"] + }, {"slug":"claude-opus-4-8","defaultFor":["subplanner","verifier"]} ]' ``` -### Precedence and limits +### Precedence 1. Explicit `tasks[].model` in the plan -2. Role env var (`ORCHESTRATE_MODEL_`) -3. `defaultFor` on an `ORCHESTRATE_MODEL_CATALOG` entry -4. `defaultFor` in the built-in catalog +2. The `defaultFor` entry for that task's role -Run `bun cli.ts models` to see the effective catalog, and `bun cli.ts models --check` to probe every entry (including your env-defined ones) against `/v1/agents`. +Run `bun cli.ts models` to print the effective catalog, and `bun cli.ts models --check` to probe every entry against `/v1/agents`. Malformed config (bad JSON, unknown slug reference, duplicate slug, two entries claiming one role, missing role default) exits 2 at startup with the offending index named, rather than failing mid-run. -Two caveats. This shapes what planners choose from, but a planner can still write any model id into `tasks[].model`, so it is guidance rather than a spend ceiling. And each spawned agent reads its own environment: set these as Cursor Cloud secrets for the repo so subplanners and workers inherit them, not just in the dispatcher's local shell. +Two caveats. This shapes what planners choose from, but a planner can still write any model id into `tasks[].model`, so it is guidance rather than a spend ceiling. And each spawned agent reads its own environment: set the variable as a Cursor Cloud secret for the repo so subplanners and workers inherit it, not just in the dispatcher's local shell. ## Cursor API key diff --git a/orchestrate/skills/orchestrate/SKILL.md b/orchestrate/skills/orchestrate/SKILL.md index ab9cd18..29283c8 100644 --- a/orchestrate/skills/orchestrate/SKILL.md +++ b/orchestrate/skills/orchestrate/SKILL.md @@ -14,7 +14,7 @@ An explicit `/orchestrate ` fans out a large task across parallel Cursor c - `CURSOR_API_KEY` must be a personal/user key. Create it from [Cursor Dashboard > Integrations](https://cursor.com/dashboard/integrations), then read `cursor-sdk` Auth before using it. - `SLACK_BOT_TOKEN` is optional. When set, pass `--slack-channel ` to `kickoff` or the first `run --root`, or set `SLACK_CHANNEL_ID`. The script stores the channel in `plan.slackChannel`, posts the kickoff thread there, mirrors task status, and reads Andon reactions. When the token is unset, the script logs once and runs without Slack visibility; correctness does not change. -- Model config is optional. `ORCHESTRATE_MODEL_WORKER`, `_SUBPLANNER`, `_VERIFIER`, and `_ROOT` set role defaults; `ORCHESTRATE_MODEL_CATALOG` adds entries; `ORCHESTRATE_MODEL_CATALOG_MODE=env-only` restricts planners to exactly the listed models. All of it merges into the catalog `bun cli.ts models` prints, which is the list planners pick `tasks[].model` from. See the plugin README. +- `ORCHESTRATE_MODEL_CATALOG` is optional. When set, its JSON array replaces the built-in model catalog outright: it becomes the list planners pick `tasks[].model` from, and its `defaultFor` entries supply each role's default. `bun cli.ts models` prints whichever catalog is in effect. See the plugin README. ## Core principles diff --git a/orchestrate/skills/orchestrate/references/dispatcher.md b/orchestrate/skills/orchestrate/references/dispatcher.md index c2a54d2..aeec581 100644 --- a/orchestrate/skills/orchestrate/references/dispatcher.md +++ b/orchestrate/skills/orchestrate/references/dispatcher.md @@ -16,7 +16,7 @@ One-time setup: run `bun install` inside this skill's `scripts/` directory if `n bun cli.ts kickoff "" [--repo ] [--ref main] [--model claude-opus-4-8] [--slack-channel C123] [--dispatcher-name "Alex"] ``` -`--model` accepts a catalog slug, bare model id, or JSON `ModelSelection`. When omitted, it uses `ORCHESTRATE_MODEL_ROOT`, else `claude-opus-4-8`. Role defaults and the catalog planners choose from are configurable via `ORCHESTRATE_MODEL_WORKER` / `_SUBPLANNER` / `_VERIFIER`, `ORCHESTRATE_MODEL_CATALOG`, and `ORCHESTRATE_MODEL_CATALOG_MODE` (see the plugin README). Run `bun cli.ts models` to print the effective catalog; a bad config exits 2 with the offending env var named. +`--model` accepts a catalog slug, bare model id, or JSON `ModelSelection`. When omitted, it uses the catalog entry marked `defaultFor: ["root"]`, else `claude-opus-4-8`. A repo can replace the whole catalog, including every role default, with `ORCHESTRATE_MODEL_CATALOG` (see the plugin README). Run `bun cli.ts models` to print the effective catalog; a bad config exits 2 with the offending entry named. The CLI reads `CURSOR_API_KEY`, auto-detects the repo from `git config --get remote.origin.url`, builds the spawn prompt, spawns via `cursor-sdk`, and prints `{ agentId, runId, status, url, dispatcherFirstName }` JSON. Slack is optional. If `SLACK_BOT_TOKEN` is set, also pass `--slack-channel ` or set `SLACK_CHANNEL_ID`; otherwise kickoff fails before spawning. If the token is unset, Slack stays disabled. diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts index f38fff6..de23c07 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts @@ -4,33 +4,22 @@ import { defaultModelForType, isKnownModel, MODEL_CATALOG, - MODEL_ENV_BY_TYPE, - MODEL_ENV_ROOT, + MODEL_ENV_CATALOG, resolveModelSelection, } from "../models.ts"; -const ENV_KEYS = [ - MODEL_ENV_BY_TYPE.worker, - MODEL_ENV_BY_TYPE.subplanner, - MODEL_ENV_BY_TYPE.verifier, - MODEL_ENV_ROOT, -] as const; - -const savedEnv: Record = {}; +let savedCatalogEnv: string | undefined; +// These assertions describe the built-in catalog, so an env-provided catalog +// from the surrounding shell must not leak in. beforeEach(() => { - for (const key of ENV_KEYS) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } + savedCatalogEnv = process.env[MODEL_ENV_CATALOG]; + delete process.env[MODEL_ENV_CATALOG]; }); afterEach(() => { - for (const key of ENV_KEYS) { - const prior = savedEnv[key]; - if (prior === undefined) delete process.env[key]; - else process.env[key] = prior; - } + if (savedCatalogEnv === undefined) delete process.env[MODEL_ENV_CATALOG]; + else process.env[MODEL_ENV_CATALOG] = savedCatalogEnv; }); describe("MODEL_CATALOG", () => { diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/models-env-overrides.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/models-env-overrides.test.ts index 962cd97..ac55b7c 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/models-env-overrides.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/models-env-overrides.test.ts @@ -2,295 +2,231 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { assertModelEnvConfig, - buildEffectiveCatalog, DEFAULT_ROOT_MODEL, defaultModelForType, defaultRootModel, effectiveModelCatalog, formatModelSelectionLabel, isKnownModel, - MODEL_ENV_BY_TYPE, MODEL_ENV_CATALOG, - MODEL_ENV_CATALOG_MODE, - MODEL_ENV_ROOT, ModelConfigError, parseModelSelectionJson, renderModelCatalog, resolveModelSelection, + usingEnvCatalog, } from "../models.ts"; -const ENV_KEYS = [ - MODEL_ENV_BY_TYPE.worker, - MODEL_ENV_BY_TYPE.subplanner, - MODEL_ENV_BY_TYPE.verifier, - MODEL_ENV_ROOT, - MODEL_ENV_CATALOG, - MODEL_ENV_CATALOG_MODE, -] as const; +let saved: string | undefined; -const savedEnv: Record = {}; +function setCatalog(entries: unknown): void { + process.env[MODEL_ENV_CATALOG] = JSON.stringify(entries); +} beforeEach(() => { - for (const key of ENV_KEYS) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } + saved = process.env[MODEL_ENV_CATALOG]; + delete process.env[MODEL_ENV_CATALOG]; }); afterEach(() => { - for (const key of ENV_KEYS) { - const prior = savedEnv[key]; - if (prior === undefined) delete process.env[key]; - else process.env[key] = prior; - } + if (saved === undefined) delete process.env[MODEL_ENV_CATALOG]; + else process.env[MODEL_ENV_CATALOG] = saved; }); -describe("per-role env overrides", () => { - test("catalog defaults apply when env is unset", () => { +describe("built-in catalog (ORCHESTRATE_MODEL_CATALOG unset)", () => { + test("role defaults come from MODEL_CATALOG", () => { + expect(usingEnvCatalog()).toBe(false); expect(defaultModelForType("worker")).toBe("gpt-5.5-high-fast"); expect(defaultModelForType("subplanner")).toBe( "claude-opus-4-8-thinking-xhigh" ); expect(defaultModelForType("verifier")).toBe("claude-opus-4-8"); + expect(defaultRootModel()).toBe(DEFAULT_ROOT_MODEL); }); - test("catalog slug in env overrides defaultFor", () => { - process.env.ORCHESTRATE_MODEL_WORKER = "composer-2-fast"; - expect(defaultModelForType("worker")).toBe("composer-2-fast"); - expect(resolveModelSelection("composer-2-fast")).toEqual({ - id: "composer-2", - params: [{ id: "fast", value: "true" }], - }); - expect(defaultModelForType("verifier")).toBe("claude-opus-4-8"); + test("whitespace-only value is treated as unset", () => { + process.env[MODEL_ENV_CATALOG] = " "; + expect(usingEnvCatalog()).toBe(false); + expect(defaultModelForType("worker")).toBe("gpt-5.5-high-fast"); }); - test("whitespace-only env is treated as unset", () => { - process.env.ORCHESTRATE_MODEL_WORKER = " "; - expect(defaultModelForType("worker")).toBe("gpt-5.5-high-fast"); + test("rendered catalog has no exact-menu preamble", () => { + expect(renderModelCatalog()).not.toContain("exact model menu"); }); +}); - test("bare unknown id joins the catalog and round-trips", () => { - process.env.ORCHESTRATE_MODEL_WORKER = "composer-2.5"; - expect(defaultModelForType("worker")).toBe("composer-2.5"); - // Merged in, so a planner copying the slug resolves back to the same model. +describe("ORCHESTRATE_MODEL_CATALOG replaces the built-in catalog", () => { + test("only the listed models are published", () => { + setCatalog([ + { id: "composer-2.5", summary: "Cheap worker.", defaultFor: ["worker"] }, + { slug: "claude-opus-4-8", defaultFor: ["subplanner", "verifier"] }, + ]); + expect(usingEnvCatalog()).toBe(true); + expect(effectiveModelCatalog().map(m => m.slug)).toEqual([ + "composer-2.5", + "claude-opus-4-8", + ]); + expect(isKnownModel("gpt-5.5-high-fast")).toBe(false); expect(isKnownModel("composer-2.5")).toBe(true); - expect(resolveModelSelection("composer-2.5")).toEqual({ - id: "composer-2.5", - }); }); - test("JSON entry keeps params and gets a slug", () => { - process.env.ORCHESTRATE_MODEL_WORKER = - '{"id":"composer-2.5","params":[{"id":"fast","value":"true"}]}'; - const slug = defaultModelForType("worker"); - expect(slug).toBe("composer-2.5"); - expect(resolveModelSelection(slug)).toEqual({ - id: "composer-2.5", - params: [{ id: "fast", value: "true" }], - }); + test("entries define role defaults", () => { + setCatalog([ + { id: "composer-2.5", defaultFor: ["worker"] }, + { slug: "claude-opus-4-8", defaultFor: ["subplanner", "verifier"] }, + ]); + expect(defaultModelForType("worker")).toBe("composer-2.5"); + expect(defaultModelForType("subplanner")).toBe("claude-opus-4-8"); + expect(defaultModelForType("verifier")).toBe("claude-opus-4-8"); }); - test("JSON entry can name its own slug and prose", () => { - process.env.ORCHESTRATE_MODEL_WORKER = JSON.stringify({ - slug: "composer-cheap", - id: "composer-2.5", + test("slug-only entry pulls in a built-in with its params", () => { + setCatalog([{ slug: "composer-2-fast", defaultFor: ["worker"] }]); + expect(resolveModelSelection("composer-2-fast")).toEqual({ + id: "composer-2", params: [{ id: "fast", value: "true" }], - summary: "House worker model.", - use: "Use for all bounded implementation work.", - speed: "fast", - strengths: ["throughput"], }); - expect(defaultModelForType("worker")).toBe("composer-cheap"); - expect(resolveModelSelection("composer-cheap")).toEqual({ + expect(defaultModelForType("worker")).toBe("composer-2-fast"); + }); + + test("entry keeps params and round-trips by slug", () => { + setCatalog([ + { + slug: "house-worker", + id: "composer-2.5", + params: [{ id: "fast", value: "true" }], + summary: "House worker model.", + use: "Use for all bounded implementation work.", + speed: "fast", + strengths: ["throughput"], + defaultFor: ["worker"], + }, + ]); + expect(resolveModelSelection("house-worker")).toEqual({ id: "composer-2.5", params: [{ id: "fast", value: "true" }], }); const text = renderModelCatalog(); - expect(text).toContain("`composer-cheap` — House worker model."); + expect(text).toContain("exact model menu"); + expect(text).toContain("`house-worker` — House worker model."); expect(text).toContain("(default for worker)"); expect(text).toContain("speed: fast; strengths: throughput"); }); - test("invalid JSON selection throws a clear error", () => { - expect(() => parseModelSelectionJson("{not-json")).toThrow( - /invalid model selection JSON/ - ); - expect(() => parseModelSelectionJson('{"params":[]}')).toThrow( - /expected \{"id"/ - ); - expect(() => - parseModelSelectionJson('{"id":"x","params":[{"id":1,"value":"a"}]}') - ).toThrow(/each params entry/); + test("slug defaults to the model id and prose is synthesized", () => { + setCatalog([{ id: "composer-2.5", defaultFor: ["worker"] }]); + const [entry] = effectiveModelCatalog(); + expect(entry.slug).toBe("composer-2.5"); + expect(entry.summary).toContain("composer-2.5"); + expect(entry.speed).toBe("medium"); }); - test("defaultRootModel reads ORCHESTRATE_MODEL_ROOT", () => { + test("root default is configurable, else falls back", () => { + setCatalog([{ id: "composer-2.5", defaultFor: ["worker"] }]); expect(defaultRootModel()).toBe(DEFAULT_ROOT_MODEL); - process.env.ORCHESTRATE_MODEL_ROOT = "composer-2-fast"; - expect(defaultRootModel()).toBe("composer-2-fast"); - process.env.ORCHESTRATE_MODEL_ROOT = '{"id":"composer-2.5"}'; - expect(resolveModelSelection(defaultRootModel())).toEqual({ - id: "composer-2.5", - }); - }); - - test("rendered catalog moves the default label to the override", () => { - process.env.ORCHESTRATE_MODEL_WORKER = "composer-2-fast"; - const text = renderModelCatalog(); - const composerLine = text - .split("\n") - .find(l => l.includes("`composer-2-fast`")); - const gptLine = text - .split("\n") - .find(l => l.includes("`gpt-5.5-high-fast`")); - expect(composerLine).toContain("(default for worker)"); - expect(gptLine).not.toContain("default for worker"); - }); - - test("formatModelSelectionLabel renders params", () => { - expect( - formatModelSelectionLabel({ - id: "composer-2.5", - params: [{ id: "fast", value: "true" }], - }) - ).toBe("composer-2.5 (fast=true)"); + setCatalog([ + { id: "composer-2.5", defaultFor: ["worker"] }, + { slug: "claude-opus-4-8", defaultFor: ["root"] }, + ]); + expect(defaultRootModel()).toBe("claude-opus-4-8"); }); }); -describe("ORCHESTRATE_MODEL_CATALOG", () => { - test("adds entries alongside the built-in catalog", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { id: "composer-2.5", summary: "Cheap worker." }, - ]); - const slugs = effectiveModelCatalog().map(m => m.slug); - expect(slugs).toContain("composer-2.5"); - expect(slugs).toContain("gpt-5.5-high-fast"); +describe("catalog config errors", () => { + test("missing required role fails fast with an actionable message", () => { + setCatalog([{ id: "composer-2.5", defaultFor: ["worker"] }]); + expect(() => assertModelEnvConfig()).toThrow(ModelConfigError); + expect(() => assertModelEnvConfig()).toThrow( + /no default for subplanner, verifier/ + ); + expect(() => defaultModelForType("verifier")).toThrow( + /"defaultFor": \["verifier"\]/ + ); }); - test("entry defaultFor claims a role", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ + test("assertModelEnvConfig passes when every role resolves", () => { + setCatalog([ { id: "composer-2.5", defaultFor: ["worker"] }, + { slug: "claude-opus-4-8", defaultFor: ["subplanner", "verifier"] }, ]); - expect(defaultModelForType("worker")).toBe("composer-2.5"); + expect(() => assertModelEnvConfig()).not.toThrow(); }); - test("role env var wins over an entry defaultFor", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { id: "composer-2.5", defaultFor: ["worker"] }, - ]); - process.env.ORCHESTRATE_MODEL_WORKER = "composer-2-fast"; - expect(defaultModelForType("worker")).toBe("composer-2-fast"); - }); + test("non-array, empty, and malformed JSON are rejected", () => { + process.env[MODEL_ENV_CATALOG] = '{"id":"x"}'; + expect(() => effectiveModelCatalog()).toThrow(/expected a JSON array/); - test("two entries claiming one role is a config error", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { id: "composer-2.5", defaultFor: ["worker"] }, - { id: "grok-4-5", defaultFor: ["worker"] }, - ]); - expect(() => buildEffectiveCatalog()).toThrow(/claim the worker default/); - }); + setCatalog([]); + expect(() => effectiveModelCatalog()).toThrow(/at least one entry/); - test("entry can override a built-in slug's selection", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { slug: "composer-2-fast", id: "composer-2.5" }, - ]); - expect(resolveModelSelection("composer-2-fast")).toEqual({ - id: "composer-2.5", - }); + process.env[MODEL_ENV_CATALOG] = "[{id:}]"; + expect(() => effectiveModelCatalog()).toThrow(ModelConfigError); }); - test("slug-only entry referencing an unknown model is rejected", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { slug: "not-a-builtin" }, - ]); - expect(() => buildEffectiveCatalog()).toThrow( + test("unknown slug reference is rejected", () => { + setCatalog([{ slug: "not-a-builtin" }]); + expect(() => effectiveModelCatalog()).toThrow( /not a built-in MODEL_CATALOG slug/ ); }); - test("malformed config surfaces as ModelConfigError", () => { - process.env.ORCHESTRATE_MODEL_CATALOG = '{"id":"x"}'; - expect(() => buildEffectiveCatalog()).toThrow(ModelConfigError); - expect(() => buildEffectiveCatalog()).toThrow(/expected a JSON array/); - - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { id: "x", speed: "blistering" }, - ]); - expect(() => buildEffectiveCatalog()).toThrow(/"speed" must be/); - - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { id: "x", defaultFor: ["planner"] }, - ]); - expect(() => buildEffectiveCatalog()).toThrow(/"defaultFor" entries/); + test("duplicate slugs are rejected", () => { + setCatalog([{ id: "composer-2.5" }, { id: "composer-2.5" }]); + expect(() => effectiveModelCatalog()).toThrow(/duplicate slug/); }); -}); -describe("ORCHESTRATE_MODEL_CATALOG_MODE=env-only", () => { - test("drops built-ins so only listed models are published", () => { - process.env.ORCHESTRATE_MODEL_CATALOG_MODE = "env-only"; - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ + test("two entries claiming one role is rejected", () => { + setCatalog([ { id: "composer-2.5", defaultFor: ["worker"] }, - { slug: "claude-opus-4-8", defaultFor: ["subplanner", "verifier"] }, + { id: "grok-4-5", defaultFor: ["worker"] }, ]); - const slugs = effectiveModelCatalog().map(m => m.slug); - expect(slugs).toEqual(["composer-2.5", "claude-opus-4-8"]); - expect(slugs).not.toContain("gpt-5.5-high-fast"); - expect(isKnownModel("gpt-5.5-high-fast")).toBe(false); + expect(() => effectiveModelCatalog()).toThrow(/claim the worker default/); }); - test("slug reference pulls a built-in back in with its params", () => { - process.env.ORCHESTRATE_MODEL_CATALOG_MODE = "env-only"; - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { slug: "composer-2-fast", defaultFor: ["worker"] }, - ]); - process.env.ORCHESTRATE_MODEL_SUBPLANNER = "claude-opus-4-8"; - process.env.ORCHESTRATE_MODEL_VERIFIER = "claude-opus-4-8"; - expect(resolveModelSelection("composer-2-fast")).toEqual({ - id: "composer-2", - params: [{ id: "fast", value: "true" }], - }); - expect(defaultModelForType("worker")).toBe("composer-2-fast"); - expect(defaultModelForType("subplanner")).toBe("claude-opus-4-8"); - }); + test("bad field values are rejected", () => { + setCatalog([{ id: "x", speed: "blistering" }]); + expect(() => effectiveModelCatalog()).toThrow(/"speed" must be/); - test("rendered catalog tells planners the menu is exact", () => { - process.env.ORCHESTRATE_MODEL_CATALOG_MODE = "env-only"; - process.env.ORCHESTRATE_MODEL_CATALOG = JSON.stringify([ - { id: "composer-2.5", defaultFor: ["worker", "subplanner", "verifier"] }, - ]); - const text = renderModelCatalog(); - expect(text).toContain("exact model menu"); - expect(text).not.toContain("gpt-5.5-high-fast"); + setCatalog([{ id: "x", defaultFor: ["planner"] }]); + expect(() => effectiveModelCatalog()).toThrow(/"defaultFor" entries/); + + setCatalog([{ id: "x", strengths: "fast" }]); + expect(() => effectiveModelCatalog()).toThrow(/"strengths" must be/); + + setCatalog([{ summary: "no id or slug" }]); + expect(() => effectiveModelCatalog()).toThrow(/entry needs "id"/); }); +}); - test("missing role default fails fast with an actionable message", () => { - process.env.ORCHESTRATE_MODEL_CATALOG_MODE = "env-only"; - process.env.ORCHESTRATE_MODEL_WORKER = "composer-2.5"; - expect(() => assertModelEnvConfig()).toThrow(ModelConfigError); - expect(() => assertModelEnvConfig()).toThrow( - /leaves no default for subplanner, verifier/ +describe("selection parsing", () => { + test("invalid JSON selection throws a clear error", () => { + expect(() => parseModelSelectionJson("{not-json")).toThrow( + /invalid model selection JSON/ ); - expect(() => defaultModelForType("verifier")).toThrow( - /ORCHESTRATE_MODEL_VERIFIER/ + expect(() => parseModelSelectionJson('{"params":[]}')).toThrow( + /expected \{"id"/ ); + expect(() => + parseModelSelectionJson('{"id":"x","params":[{"id":1,"value":"a"}]}') + ).toThrow(/each params entry/); }); - test("assertModelEnvConfig passes when every role resolves", () => { - process.env.ORCHESTRATE_MODEL_CATALOG_MODE = "env-only"; - process.env.ORCHESTRATE_MODEL_WORKER = "composer-2.5"; - process.env.ORCHESTRATE_MODEL_SUBPLANNER = "claude-opus-4-8"; - process.env.ORCHESTRATE_MODEL_VERIFIER = "claude-opus-4-8"; - expect(() => assertModelEnvConfig()).not.toThrow(); - }); - - test("unknown mode value is rejected", () => { - process.env.ORCHESTRATE_MODEL_CATALOG_MODE = "exclusive"; - expect(() => buildEffectiveCatalog()).toThrow(/is not valid/); + test("JSON selection passes through resolveModelSelection", () => { + expect( + resolveModelSelection( + '{"id":"composer-2.5","params":[{"id":"fast","value":"true"}]}' + ) + ).toEqual({ + id: "composer-2.5", + params: [{ id: "fast", value: "true" }], + }); }); - test("merge mode is the default and keeps built-ins", () => { - expect(buildEffectiveCatalog().mode).toBe("merge"); - expect(effectiveModelCatalog().map(m => m.slug)).toContain( - "gpt-5.5-high-fast" - ); + test("formatModelSelectionLabel renders params", () => { + expect( + formatModelSelectionLabel({ + id: "composer-2.5", + params: [{ id: "fast", value: "true" }], + }) + ).toBe("composer-2.5 (fast=true)"); }); }); diff --git a/orchestrate/skills/orchestrate/scripts/cli/task.ts b/orchestrate/skills/orchestrate/scripts/cli/task.ts index 47232f0..939a0c4 100644 --- a/orchestrate/skills/orchestrate/scripts/cli/task.ts +++ b/orchestrate/skills/orchestrate/scripts/cli/task.ts @@ -110,7 +110,7 @@ export function registerTaskCommands(program: Command): void { .option("--ref ", "Starting git ref for the cloud workspace", "main") .option( "--model ", - "Model id for the root planner (catalog slug, bare id, or JSON ModelSelection). Defaults to ORCHESTRATE_MODEL_ROOT, else claude-opus-4-8.", + 'Model id for the root planner (catalog slug, bare id, or JSON ModelSelection). Defaults to the catalog entry marked defaultFor ["root"], else claude-opus-4-8.', defaultRootModel() ) .option( diff --git a/orchestrate/skills/orchestrate/scripts/models.ts b/orchestrate/skills/orchestrate/scripts/models.ts index a4cb261..ed17bfb 100644 --- a/orchestrate/skills/orchestrate/scripts/models.ts +++ b/orchestrate/skills/orchestrate/scripts/models.ts @@ -2,9 +2,21 @@ import type { ModelSelection } from "@cursor/sdk"; import type { TaskType } from "./adapters/types.ts"; -// Built-in model catalog. `buildEffectiveCatalog` merges this with the -// ORCHESTRATE_MODEL_* env config to produce what planners actually see; -// `defaultFor` supplies the fallback when `tasks[].model` is omitted. +// Built-in model catalog, used when ORCHESTRATE_MODEL_CATALOG is unset. +// `defaultFor` supplies the model for a role when `tasks[].model` is omitted. + +/** Roles a catalog entry can be the default for. `root` is the kickoff planner. */ +export type CatalogRole = TaskType | "root"; + +export const CATALOG_ROLES: CatalogRole[] = [ + "worker", + "subplanner", + "verifier", + "root", +]; + +/** Roles that must resolve for a run to be spawnable. */ +const REQUIRED_ROLES: TaskType[] = ["worker", "subplanner", "verifier"]; export interface ModelProfile { /** User-facing slug for `tasks[].model` and `--model` flags. */ @@ -15,26 +27,16 @@ export interface ModelProfile { strengths: string[]; speed: "fast" | "medium" | "slow"; use: string; - /** Task types this profile is the default for. */ - defaultFor?: TaskType[]; + /** Roles this profile is the default for. */ + defaultFor?: CatalogRole[]; } -/** Env vars that override catalog `defaultFor` when set (non-empty). */ -export const MODEL_ENV_BY_TYPE: Record = { - worker: "ORCHESTRATE_MODEL_WORKER", - subplanner: "ORCHESTRATE_MODEL_SUBPLANNER", - verifier: "ORCHESTRATE_MODEL_VERIFIER", -}; - -/** Env var that overrides the kickoff `--model` default for the root planner. */ -export const MODEL_ENV_ROOT = "ORCHESTRATE_MODEL_ROOT"; - -/** Env var holding a JSON array of extra catalog entries. */ +/** + * Env var holding the whole catalog as JSON. When set it replaces + * MODEL_CATALOG outright; entries may still reference a built-in by slug. + */ export const MODEL_ENV_CATALOG = "ORCHESTRATE_MODEL_CATALOG"; -/** Env var selecting `merge` (default) or `env-only` catalog construction. */ -export const MODEL_ENV_CATALOG_MODE = "ORCHESTRATE_MODEL_CATALOG_MODE"; - export const DEFAULT_ROOT_MODEL = "claude-opus-4-8"; // `slug` is the stable authoring name; `selection` is the canonical SDK form. @@ -183,18 +185,11 @@ export const MODEL_CATALOG: ModelProfile[] = [ }, ]; -/** Raised when the ORCHESTRATE_MODEL_* environment config is not usable. */ +/** Raised when ORCHESTRATE_MODEL_CATALOG is not usable. */ export class ModelConfigError extends Error {} -const ALL_TASK_TYPES: TaskType[] = ["worker", "subplanner", "verifier"]; - const SPEEDS = new Set(["fast", "medium", "slow"]); -function readEnvOverride(name: string): string | undefined { - const raw = process.env[name]?.trim(); - return raw || undefined; -} - function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -247,12 +242,7 @@ export function normalizeModelSelection( return selection; } -/** - * Parse a JSON `ModelSelection` (`{"id":"…","params":[…]}`). - * - * JSON form is for models not yet in MODEL_CATALOG (e.g. composer-2.5) where - * a bare `{ id: slug }` would lose required params or fail `invalid_model`. - */ +/** Parse a JSON `ModelSelection` (`{"id":"…","params":[…]}`). */ export function parseModelSelectionJson( raw: string, ctx = "invalid model selection JSON" @@ -273,23 +263,6 @@ export function formatModelSelectionLabel(selection: ModelSelection): string { return `${selection.id} (${params})`; } -export type CatalogMode = "merge" | "env-only"; - -/** - * `env-only` drops MODEL_CATALOG from the effective catalog so a team can - * publish an exact menu of models. Built-in entries can still be pulled back - * in by slug reference from ORCHESTRATE_MODEL_CATALOG or a role env var. - */ -export function readCatalogMode(): CatalogMode { - const raw = readEnvOverride(MODEL_ENV_CATALOG_MODE); - if (!raw) return "merge"; - const normalized = raw.toLowerCase(); - if (normalized === "merge" || normalized === "env-only") return normalized; - throw new ModelConfigError( - `${MODEL_ENV_CATALOG_MODE}="${raw}" is not valid. Use "merge" (default) or "env-only".` - ); -} - function parseSpeed(value: unknown, ctx: string): ModelProfile["speed"] { if (value === undefined) return "medium"; if (typeof value === "string" && SPEEDS.has(value as ModelProfile["speed"])) { @@ -308,26 +281,29 @@ function parseStrengths(value: unknown, ctx: string): string[] { return value as string[]; } -function parseDefaultFor(value: unknown, ctx: string): TaskType[] | undefined { +function parseDefaultFor( + value: unknown, + ctx: string +): CatalogRole[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) { throw new ModelConfigError( - `${ctx}: "defaultFor" must be an array of ${ALL_TASK_TYPES.join(" | ")}` + `${ctx}: "defaultFor" must be an array of ${CATALOG_ROLES.join(" | ")}` ); } - const types: TaskType[] = []; + const roles: CatalogRole[] = []; for (const item of value) { if ( typeof item !== "string" || - !ALL_TASK_TYPES.includes(item as TaskType) + !CATALOG_ROLES.includes(item as CatalogRole) ) { throw new ModelConfigError( - `${ctx}: "defaultFor" entries must be one of ${ALL_TASK_TYPES.join(", ")}` + `${ctx}: "defaultFor" entries must be one of ${CATALOG_ROLES.join(", ")}` ); } - types.push(item as TaskType); + roles.push(item as CatalogRole); } - return types; + return roles; } function optionalString( @@ -342,225 +318,138 @@ function optionalString( return value.trim(); } -/** A parsed env entry: either a reference to an existing slug, or a profile. */ -type CatalogEntryInput = - | { kind: "ref"; slug: string; defaultFor?: TaskType[] } - | { kind: "profile"; profile: ModelProfile }; +function builtinBySlug(slug: string): ModelProfile | undefined { + return MODEL_CATALOG.find(m => m.slug === slug); +} -function parseEntryObject( +/** + * Parse one ORCHESTRATE_MODEL_CATALOG entry. An entry with `id` (or + * `selection`) defines a model; an entry with only `slug` pulls in the + * built-in profile of that name, so operators can list a curated subset + * without retyping SDK params. + */ +function parseCatalogEntry( raw: Record, ctx: string -): CatalogEntryInput { +): ModelProfile { const slug = optionalString(raw.slug, "slug", ctx); const defaultFor = parseDefaultFor(raw.defaultFor, ctx); - const hasSelection = raw.selection !== undefined || raw.id !== undefined; - if (!hasSelection) { + + if (raw.selection === undefined && raw.id === undefined) { if (!slug) { throw new ModelConfigError( `${ctx}: entry needs "id" (or "selection"), or a "slug" that references a built-in model` ); } - return { kind: "ref", slug, defaultFor }; + const builtin = builtinBySlug(slug); + if (!builtin) { + throw new ModelConfigError( + `${ctx}: "${slug}" is not a built-in MODEL_CATALOG slug. Give the entry an "id" to define a new model, or use a known slug.` + ); + } + return { ...builtin, defaultFor: defaultFor ?? builtin.defaultFor }; } + const selection = normalizeModelSelection( raw.selection ?? { id: raw.id, params: raw.params }, ctx ); - const label = formatModelSelectionLabel(selection); return { - kind: "profile", - profile: { - slug: slug ?? selection.id, - selection, - summary: - optionalString(raw.summary, "summary", ctx) ?? - `Operator-configured model (${label}).`, - strengths: parseStrengths(raw.strengths, ctx), - speed: parseSpeed(raw.speed, ctx), - use: - optionalString(raw.use, "use", ctx) ?? - "Configured for this repo via orchestrate model env config. Prefer it unless the task needs a listed specialist.", - defaultFor, - }, + slug: slug ?? selection.id, + selection, + summary: + optionalString(raw.summary, "summary", ctx) ?? + `Operator-configured model (${formatModelSelectionLabel(selection)}).`, + strengths: parseStrengths(raw.strengths, ctx), + speed: parseSpeed(raw.speed, ctx), + use: + optionalString(raw.use, "use", ctx) ?? + "Configured for this repo via ORCHESTRATE_MODEL_CATALOG. Prefer it unless the task needs a listed specialist.", + defaultFor, }; } -/** Role env vars accept a slug, a bare model id, or a JSON entry. */ -function parseRoleEnvValue(raw: string, envName: string): CatalogEntryInput { - if (looksLikeSelectionJson(raw)) { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - throw new ModelConfigError(`${envName}: ${errText(err)}`); - } - if (!isPlainObject(parsed)) { - throw new ModelConfigError(`${envName}: expected a JSON object`); - } - return parseEntryObject(parsed, envName); - } - return { kind: "ref", slug: raw }; +function readCatalogEnv(): string | undefined { + const raw = process.env[MODEL_ENV_CATALOG]?.trim(); + return raw || undefined; } -function builtinBySlug(slug: string): ModelProfile | undefined { - return MODEL_CATALOG.find(m => m.slug === slug); +/** True when this repo publishes its own catalog instead of the built-in one. */ +export function usingEnvCatalog(): boolean { + return readCatalogEnv() !== undefined; } /** - * Resolve a slug reference. Role env vars may name a model that isn't in the - * built-in catalog (`composer-2.5`); it becomes a bare `{ id }` entry. - * ORCHESTRATE_MODEL_CATALOG refs must name a built-in, since a typo there - * would silently publish a non-existent model to planners. + * The catalog planners choose from. ORCHESTRATE_MODEL_CATALOG replaces + * MODEL_CATALOG outright when set; there is no merging, so the env value is + * the complete menu. Built per call so env changes apply without reload. */ -function resolveRef( - slug: string, - ctx: string, - synthesizeIfUnknown: boolean -): ModelProfile { - const builtin = builtinBySlug(slug); - if (builtin) return { ...builtin }; - if (!synthesizeIfUnknown) { +export function effectiveModelCatalog(): ModelProfile[] { + const raw = readCatalogEnv(); + if (!raw) return MODEL_CATALOG; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new ModelConfigError(`${MODEL_ENV_CATALOG}: ${errText(err)}`); + } + if (!Array.isArray(parsed)) { throw new ModelConfigError( - `${ctx}: "${slug}" is not a built-in MODEL_CATALOG slug. Give the entry an "id" to define a new model, or use a known slug.` + `${MODEL_ENV_CATALOG}: expected a JSON array of model entries` ); } - return { - slug, - selection: { id: slug }, - summary: `Operator-configured model (${slug}).`, - strengths: [], - speed: "medium", - use: "Configured for this repo via orchestrate model env config. Prefer it unless the task needs a listed specialist.", - }; -} - -export interface EffectiveCatalog { - mode: CatalogMode; - catalog: ModelProfile[]; - /** Effective role defaults, by slug. Missing when nothing claims the role. */ - defaults: Partial>; -} - -/** - * Merge MODEL_CATALOG with the ORCHESTRATE_MODEL_* env config into the catalog - * planners actually see. Built per call so env changes apply without reload. - */ -export function buildEffectiveCatalog(): EffectiveCatalog { - const mode = readCatalogMode(); - const bySlug = new Map(); - const order: string[] = []; - const upsert = (profile: ModelProfile): void => { - if (!bySlug.has(profile.slug)) order.push(profile.slug); - bySlug.set(profile.slug, profile); - }; - - if (mode === "merge") { - for (const profile of MODEL_CATALOG) upsert({ ...profile }); + if (!parsed.length) { + throw new ModelConfigError( + `${MODEL_ENV_CATALOG}: needs at least one entry. Unset it to use the built-in catalog.` + ); } - const declared: Partial> = {}; + const catalog: ModelProfile[] = []; + const bySlug = new Map(); + const claimedBy = new Map(); - const rawCatalog = readEnvOverride(MODEL_ENV_CATALOG); - if (rawCatalog) { - let parsed: unknown; - try { - parsed = JSON.parse(rawCatalog); - } catch (err) { - throw new ModelConfigError(`${MODEL_ENV_CATALOG}: ${errText(err)}`); + parsed.forEach((item, i) => { + const ctx = `${MODEL_ENV_CATALOG}[${i}]`; + if (!isPlainObject(item)) { + throw new ModelConfigError(`${ctx}: expected a JSON object`); } - if (!Array.isArray(parsed)) { + const profile = parseCatalogEntry(item, ctx); + + const priorIndex = bySlug.get(profile.slug); + if (priorIndex !== undefined) { throw new ModelConfigError( - `${MODEL_ENV_CATALOG}: expected a JSON array of model entries` + `${ctx}: duplicate slug "${profile.slug}" (also at index ${priorIndex})` ); } - parsed.forEach((item, i) => { - const ctx = `${MODEL_ENV_CATALOG}[${i}]`; - if (!isPlainObject(item)) { - throw new ModelConfigError(`${ctx}: expected a JSON object`); - } - const entry = parseEntryObject(item, ctx); - const profile = - entry.kind === "ref" - ? { - ...resolveRef(entry.slug, ctx, false), - defaultFor: entry.defaultFor, - } - : entry.profile; - upsert(profile); - for (const type of profile.defaultFor ?? []) { - const prior = declared[type]; - if (prior && prior !== profile.slug) { - throw new ModelConfigError( - `${MODEL_ENV_CATALOG}: two entries claim the ${type} default ("${prior}" and "${profile.slug}")` - ); - } - declared[type] = profile.slug; - } - }); - } - - // Role env vars are the most specific signal, so they win over any - // defaultFor declared inside ORCHESTRATE_MODEL_CATALOG. - for (const type of ALL_TASK_TYPES) { - const envName = MODEL_ENV_BY_TYPE[type]; - const raw = readEnvOverride(envName); - if (!raw) continue; - const entry = parseRoleEnvValue(raw, envName); - const profile = - entry.kind === "ref" - ? resolveRef(entry.slug, envName, true) - : entry.profile; - const existing = bySlug.get(profile.slug); - upsert(entry.kind === "ref" && existing ? existing : profile); - declared[type] = profile.slug; - } - - // Inherit unclaimed roles from whatever remains in the catalog. - for (const type of ALL_TASK_TYPES) { - if (declared[type]) continue; - const inherited = order - .map(slug => bySlug.get(slug)) - .find(profile => profile?.defaultFor?.includes(type)); - if (inherited) declared[type] = inherited.slug; - } + bySlug.set(profile.slug, i); - // Re-stamp defaultFor so rendering reflects the resolved defaults. - const rolesBySlug = new Map(); - for (const type of ALL_TASK_TYPES) { - const slug = declared[type]; - if (!slug) continue; - const roles = rolesBySlug.get(slug) ?? []; - roles.push(type); - rolesBySlug.set(slug, roles); - } - - const catalog: ModelProfile[] = []; - for (const slug of order) { - const profile = bySlug.get(slug); - if (!profile) continue; - const roles = rolesBySlug.get(slug); - catalog.push({ ...profile, defaultFor: roles?.length ? roles : undefined }); - } + for (const role of profile.defaultFor ?? []) { + const prior = claimedBy.get(role); + if (prior) { + throw new ModelConfigError( + `${MODEL_ENV_CATALOG}: two entries claim the ${role} default ("${prior}" and "${profile.slug}")` + ); + } + claimedBy.set(role, profile.slug); + } + catalog.push(profile); + }); - return { mode, catalog, defaults: declared }; + return catalog; } -/** The catalog planners see: MODEL_CATALOG plus/minus the env config. */ -export function effectiveModelCatalog(): ModelProfile[] { - return buildEffectiveCatalog().catalog; +function defaultSlugForRole(role: CatalogRole): string | undefined { + return effectiveModelCatalog().find(m => m.defaultFor?.includes(role))?.slug; } -/** - * Fallback model slug when `tasks[].model` is omitted. Resolves against the - * effective catalog, so env config participates. - */ +/** Model slug for a task type when `tasks[].model` is omitted. */ export function defaultModelForType(type: TaskType): string { - const { defaults, mode } = buildEffectiveCatalog(); - const slug = defaults[type]; + const slug = defaultSlugForRole(type); if (slug) return slug; - if (mode === "env-only") { + if (usingEnvCatalog()) { throw new ModelConfigError( - `no ${type} default: ${MODEL_ENV_CATALOG_MODE}=env-only drops MODEL_CATALOG, so set ${MODEL_ENV_BY_TYPE[type]} or give an ${MODEL_ENV_CATALOG} entry "defaultFor": ["${type}"]` + `${MODEL_ENV_CATALOG} has no ${type} default. Add "defaultFor": ["${type}"] to one entry.` ); } throw new ModelConfigError( @@ -568,9 +457,12 @@ export function defaultModelForType(type: TaskType): string { ); } -/** Kickoff `--model` default: ORCHESTRATE_MODEL_ROOT, else the built-in root model. */ +/** + * Kickoff `--model` default. Honors a `defaultFor: ["root"]` catalog entry, + * else falls back to the built-in root model. + */ export function defaultRootModel(): string { - return readEnvOverride(MODEL_ENV_ROOT) ?? DEFAULT_ROOT_MODEL; + return defaultSlugForRole("root") ?? DEFAULT_ROOT_MODEL; } export function isKnownModel(slug: string): boolean { @@ -594,41 +486,38 @@ export function resolveModelSelection(spec: string): ModelSelection { return profile ? profile.selection : { id: trimmed }; } -/** Effective default label per task type (catalog entry or env override). */ -export function effectiveDefaultLabelForType(type: TaskType): string { - return defaultModelForType(type); -} - /** - * Fail fast on unusable ORCHESTRATE_MODEL_* config instead of surfacing it as - * a spawn error halfway through a run. + * Fail fast on unusable catalog config instead of surfacing it as a spawn + * error halfway through a run. */ export function assertModelEnvConfig(): void { - const { defaults, mode } = buildEffectiveCatalog(); - if (mode !== "env-only") return; - const missing = ALL_TASK_TYPES.filter(type => !defaults[type]); + if (!usingEnvCatalog()) return; + const catalog = effectiveModelCatalog(); + const missing = REQUIRED_ROLES.filter( + role => !catalog.some(m => m.defaultFor?.includes(role)) + ); if (!missing.length) return; throw new ModelConfigError( - `${MODEL_ENV_CATALOG_MODE}=env-only leaves no default for ${missing.join(", ")}. Set ${missing - .map(type => MODEL_ENV_BY_TYPE[type]) - .join(", ")}, or add "defaultFor" to an ${MODEL_ENV_CATALOG} entry.` + `${MODEL_ENV_CATALOG} has no default for ${missing.join(", ")}. Add "defaultFor": [${missing + .map(role => `"${role}"`) + .join(", ")}] across your entries.` ); } export function renderModelCatalog(): string { - const { catalog, mode } = buildEffectiveCatalog(); + const catalog = effectiveModelCatalog(); const lines: string[] = []; - if (mode === "env-only") { + if (usingEnvCatalog()) { lines.push( "This repo publishes an exact model menu. Use only the slugs listed below; do not reach for models outside this list." ); lines.push(""); } for (const m of catalog) { - const defaults = m.defaultFor?.length + const roles = m.defaultFor?.length ? ` (default for ${m.defaultFor.join(", ")})` : ""; - lines.push(`- \`${m.slug}\` — ${m.summary}${defaults}`); + lines.push(`- \`${m.slug}\` — ${m.summary}${roles}`); const strengths = m.strengths.length ? `; strengths: ${m.strengths.join(", ")}` : ""; -- 2.51.2