From 7da94c9223206f2a58361eb125af79a09d9c9033 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Mon, 28 Sep 2026 12:52:05 +0200 Subject: [PATCH] fix: deep custom config should be optional --- README.md | 2 +- docs/multi-work.md | 2 +- examples/custom.opencode.jsonc | 5 +++-- src/config.ts | 4 ++-- src/index.ts | 17 +++++++++------- tests/plugin.test.ts | 37 ++++++++++++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e470156..65f9cd3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Both installers use `~/.config/opencode`, respect `XDG_CONFIG_HOME`, and accept See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. In GPT mode with the `openai` provider, set top-level `lunaPriority` to `false` to avoid requesting priority pricing. Other providers do not accept this option. Custom top-level and per-agent `provider` and `lunaPriority` fields are invalid. -Custom configurations require the six worker assignments and use `provider/model` model names. +Custom configurations require the four `fast-*` and `good-*` assignments; `deep-lo` and `deep-hi` are optional. Assignments use `provider/model` model names and require both `model` and `effort` when supplied. If either deep assignment is omitted, both deep workers are disabled and orchestration uses `good-lo`. The legacy optional `coordinate` assignment is accepted for compatibility but ignored. Orchestration inherits `deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable. diff --git a/docs/multi-work.md b/docs/multi-work.md index e78de5d..b8cd437 100644 --- a/docs/multi-work.md +++ b/docs/multi-work.md @@ -3,7 +3,7 @@ `multi-work` is a **primary-only** agent for coordinating independent work in the current Herdr root checkout or new Herdr-managed Git worktrees. Like `coordinate`, it uses the configured `deep-lo` model and effort, falling back -to `good-lo` when `deep-lo` is unavailable. No extra custom model assignment is +to `good-lo` when `deep-lo` is unavailable or either custom deep assignment is omitted. No extra custom model assignment is required. ```text diff --git a/examples/custom.opencode.jsonc b/examples/custom.opencode.jsonc index a1f6cbb..04a11ae 100644 --- a/examples/custom.opencode.jsonc +++ b/examples/custom.opencode.jsonc @@ -3,8 +3,9 @@ // Required for multi-work -> coordinate -> workers. Apply in worktrees too. // Multi-work and coordinate use deep-lo, falling back to good-lo when unavailable. "experimental": { "subagent_depth": 2 }, - // All six worker assignments are required. Models use provider/model names. - // An unavailable deep-lo omits deep workers and uses good-lo for orchestration. + // Fast and good assignments are required; both deep assignments are optional. + // Omitting either deep assignment disables both deep workers and uses good-lo. + // Models use provider/model names; each assignment needs model and effort. "plugins": [ { "package": "stanp-opencode", diff --git a/src/config.ts b/src/config.ts index 9952230..0a220e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,8 +57,8 @@ export const settings = z.discriminatedUnion("mode", [ "fast-hi": customAssignment, "good-lo": customAssignment, "good-hi": customAssignment, - "deep-lo": customAssignment, - "deep-hi": customAssignment, + "deep-lo": customAssignment.optional(), + "deep-hi": customAssignment.optional(), // Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo. coordinate: customAssignment.optional(), }), diff --git a/src/index.ts b/src/index.ts index 3764eed..ea13340 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,17 +204,20 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission model: assignment.model.slice(slash + 1), }; }; - const availableFor = ( - assignment: (typeof assignments)[(typeof workerNames)[number]], - ) => { + const availableFor = (assignment: { model: string }) => { const { provider, model } = coordinates(assignment); return models.get(modelKey(provider, model)); }; - const deep = availableFor(assignments["deep-lo"]) !== undefined; + const deepLo = assignments["deep-lo"]; + const deep = + deepLo !== undefined && + assignments["deep-hi"] !== undefined && + availableFor(deepLo) !== undefined; const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); - const coordinate = assignments[deep ? "deep-lo" : "good-lo"]; + const coordinate = deep ? deepLo : assignments["good-lo"]; workers = [coordinate, ...candidates.map((name) => assignments[name])].every( - (assignment) => availableFor(assignment) !== undefined, + (assignment) => + assignment !== undefined && availableFor(assignment) !== undefined, ) ? candidates : []; @@ -222,7 +225,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission for (const name of workers) active.add(name); if (workers.length === 0) return; for (const name of workers) { - const assignment = assignments[name]; + const assignment = assignments[name]!; const available = availableFor(assignment)!; editor.update(name, (agent) => { const hasVariant = available.model.variants.some( diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 98071c7..1ad82f6 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -439,6 +439,26 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo ).toBe(false); }); +test("custom deep assignments are optional and a partial deep tier falls back to good-lo", async () => { + for (const omitted of [["deep-lo", "deep-hi"], ["deep-lo"], ["deep-hi"]] as const) { + const agents: Partial> = customAssignments(); + for (const name of omitted) delete agents[name]; + const result = await host(allModels, { mode: "custom", agents }); + expect(result.agents.get("coordinate")?.model).toMatchObject({ + id: "gpt-6-sol", + variant: "low", + }); + expect(result.agents.get("coordinate")?.system).toContain("Deep agents are unavailable"); + for (const name of ["fast-lo", "fast-hi", "good-lo", "good-hi"]) + expect(result.agents.has(name)).toBe(true); + expect(result.agents.has("deep-lo")).toBe(false); + expect(result.agents.has("deep-hi")).toBe(false); + expect((await result.tools("coordinate")).subagent?.input.properties).toMatchObject({ + agent: { type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }, + }); + } +}); + test("registers the embedded anti-overengineering skill and makes it available to coordinate", async () => { const result = await host(); const skill = result.skills.get("anti-overengineering")!; @@ -499,6 +519,23 @@ test("custom assignments resolve duplicate model IDs by provider and refresh pro test("custom configuration requires qualified models and rejects obsolete fields", () => { expect(() => settings.parse({ mode: "custom", agents: customAssignments() })).not.toThrow(); + for (const omitted of [["deep-lo", "deep-hi"], ["deep-lo"], ["deep-hi"]] as const) { + const agents: Partial> = customAssignments(); + for (const name of omitted) delete agents[name]; + expect(() => settings.parse({ mode: "custom", agents })).not.toThrow(); + } + for (const name of ["fast-lo", "fast-hi", "good-lo", "good-hi"] as const) { + const agents: Partial> = customAssignments(); + delete agents[name]; + expect(() => settings.parse({ mode: "custom", agents })).toThrow(); + } + for (const name of ["deep-lo", "deep-hi"] as const) + expect(() => + settings.parse({ + mode: "custom", + agents: { ...customAssignments(), [name]: { model: "test/model" } }, + }), + ).toThrow(); for (const model of ["bare", "/model", "provider/"]) expect(() => settings.parse({ -- 2.54.0