From cb36b4da7ae5ee3c2354fd1ce2ec5aa5f39d9d56 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 09:33:22 -0700 Subject: [PATCH] fix(agent): match muse-wonder models and scope muse rules to leaves --- src/agent/director.test.ts | 60 +++++++++++++++++++++++---- src/agent/director.ts | 7 +++- src/agent/model-family-policy.test.ts | 27 ++++++++---- src/agent/model-family-policy.ts | 10 ++++- src/provider/reasoning-effort.test.ts | 48 ++++++++++++--------- src/provider/reasoning-effort.ts | 2 +- src/subagent/provider-family.test.ts | 21 ++++++++++ src/subagent/provider-family.ts | 5 ++- 8 files changed, 139 insertions(+), 41 deletions(-) diff --git a/src/agent/director.test.ts b/src/agent/director.test.ts index 040f21159..3ab402ed7 100644 --- a/src/agent/director.test.ts +++ b/src/agent/director.test.ts @@ -2656,7 +2656,43 @@ describe("chatDirector spacer echo", () => { // them AFTER super(), so the base director kept sending the original and // the feature was a silent no-op. describe("tool-discipline rules on the wire", () => { - async function promptSentFor(model: string): Promise { + async function subAgentPromptSentFor( + model: string, + orchestrator = false, + ): Promise { + const { SubAgentDirector } = await import("../subagent/nudge-director.js"); + const { resolveModelFamilyPolicy } = + await import("./model-family-policy.js"); + const policy = resolveModelFamilyPolicy({ + providerName: "opencode-go", + model, + orchestrator, + }); + const director = new SubAgentDirector( + "BASE PROMPT", + [], + undefined, + undefined, + Date.now, + false, + false, + undefined, + policy.toolDisciplineRules, + ); + const event = { + type: "message.received", + message: { role: "user", content: "hi" }, + } as unknown as ReactorInboundEvent; + const actions = actionsArray( + await director.decide(event, mockState, stubReactorCapabilities), + ); + const infer = actions.find((a) => a.type === "infer") as + | { options?: ExtendedInferenceOptions } + | undefined; + return infer?.options?.systemPrompt; + } + + async function chatPromptSentFor(model: string): Promise { const director = createChatDirector("BASE PROMPT", [], { provider: { providerName: "opencode-go", model }, }); @@ -2665,7 +2701,7 @@ describe("tool-discipline rules on the wire", () => { message: { role: "user", content: "hi" }, } as unknown as ReactorInboundEvent; const actions = actionsArray( - await director.decide(event, stubReactorState, stubReactorCapabilities), + await director.decide(event, mockState, stubReactorCapabilities), ); const infer = actions.find((a) => a.type === "infer") as | { options?: ExtendedInferenceOptions } @@ -2673,13 +2709,23 @@ describe("tool-discipline rules on the wire", () => { return infer?.options?.systemPrompt; } - test("a Muse Spark session appends rules at the tail, leaving the base prefix intact", async () => { - const prompt = await promptSentFor("muse-spark-1.3-contributor"); - expect(prompt?.startsWith("BASE PROMPT")).toBe(true); - expect(prompt?.length ?? 0).toBeGreaterThan("BASE PROMPT".length); + test("a Muse leaf appends rules at the tail, leaving the base prefix intact", async () => { + for (const model of ["muse-spark-1.3-contributor", "muse-wonder-1.0"]) { + const prompt = await subAgentPromptSentFor(model, false); + expect(prompt?.startsWith("BASE PROMPT")).toBe(true); + expect(prompt?.length ?? 0).toBeGreaterThan("BASE PROMPT".length); + } + }); + + test("a Muse orchestrator and primary ChatDirector withhold leaf tool-discipline rules", async () => { + for (const model of ["muse-spark-1.3-contributor", "muse-wonder-1.0"]) { + expect(await subAgentPromptSentFor(model, true)).toBe("BASE PROMPT"); + expect(await chatPromptSentFor(model)).toBe("BASE PROMPT"); + } }); test("a family with no rules sends the prompt untouched", async () => { - expect(await promptSentFor("claude-sonnet-4")).toBe("BASE PROMPT"); + expect(await subAgentPromptSentFor("claude-sonnet-4")).toBe("BASE PROMPT"); + expect(await chatPromptSentFor("claude-sonnet-4")).toBe("BASE PROMPT"); }); }); diff --git a/src/agent/director.ts b/src/agent/director.ts index b3ed393fd..12c267449 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -1290,9 +1290,12 @@ export function createChatDirector( return new ChatDirectorImpl(systemPrompt, toolDefinitions, { ...rest, // `provider` is raw {providerName, model} input; the constructor wants - // the resolved ModelFamilyPolicy. + // the resolved ModelFamilyPolicy. ChatDirector is always the primary + // orchestrator, so leaf-only residuals are withheld. modelFamilyPolicy: - provider !== undefined ? resolveModelFamilyPolicy(provider) : undefined, + provider !== undefined + ? resolveModelFamilyPolicy({ ...provider, orchestrator: true }) + : undefined, // Seed the live source id (replaces getProviderId), refreshed on every // inference completion so /model switches remap retry stamping. sessionProviderName: provider?.providerName, diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index 1267e06e8..1841b26f6 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -90,17 +90,30 @@ describe("resolveModelFamilyPolicy", () => { } }); - test("muse spark carries tool-discipline rules; other families do not", () => { - const muse = resolveModelFamilyPolicy({ - providerName: "opencode-go/acme", - model: "muse-spark-1.3-contributor", - }); + test("muse leaves carry tool-discipline rules; muse orchestrators and other families do not", () => { + for (const model of [ + "muse-spark-1.3-contributor", + "muse-wonder-1.0", + ] as const) { + const leaf = resolveModelFamilyPolicy({ + providerName: "meta-api", + model, + orchestrator: false, + }); + const orchestrator = resolveModelFamilyPolicy({ + providerName: "meta-api", + model, + orchestrator: true, + }); + expect(leaf.family).toBe("muse"); + expect(leaf.toolDisciplineRules?.length).toBeGreaterThan(0); + expect(orchestrator.family).toBe("muse"); + expect(orchestrator.toolDisciplineRules).toBeUndefined(); + } const base = resolveModelFamilyPolicy({ providerName: "anthropic", model: "claude-sonnet-4", }); - expect(muse.family).toBe("muse"); - expect(muse.toolDisciplineRules?.length).toBeGreaterThan(0); expect(base.toolDisciplineRules).toBeUndefined(); }); diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 5b7324a29..af80c66bf 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -38,7 +38,7 @@ export interface ModelFamilyPolicy { * not self-terminate a tool loop. Appended at the tail to keep the cached * prompt prefix intact. */ - toolDisciplineRules?: string; + toolDisciplineRules?: string | undefined; /** * Provider-family residual appended once to the leaf system prompt * (tool-budget text for grok, XML task_guidance for claude, narrate @@ -224,7 +224,13 @@ export function resolveModelFamilyPolicy(input: { ...KIMI_POLICY, }; case "muse": - return { family, ...MUSE_POLICY }; + return { + family, + ...MUSE_POLICY, + toolDisciplineRules: orchestrator + ? undefined + : MUSE_POLICY.toolDisciplineRules, + }; case "claude": // Like grok's finish-bias, task_guidance only makes sense on leaf // workers — orchestrators get the permissive default. diff --git a/src/provider/reasoning-effort.test.ts b/src/provider/reasoning-effort.test.ts index 266fb1b6c..9e7c9a5fe 100644 --- a/src/provider/reasoning-effort.test.ts +++ b/src/provider/reasoning-effort.test.ts @@ -127,27 +127,33 @@ describe("supportedEfforts", () => { expect(supportedEfforts("deepseek-v3")).toEqual(["low", "medium", "high"]); }); - // Every catalog Muse Spark id reaches supportedEfforts unnormalized; two - // ids pin the family rule. - test.each(["muse-spark-1.3-contributor", "muse-spark-1.3-contributor-free"])( - "Muse Spark id %s supports minimal through high", - (model) => { - expect(supportedEfforts(model)).toEqual([ - "minimal", - "low", - "medium", - "high", - ]); - expect(defaultEffortForModel(model)).toBe("low"); - }, - ); + // Every catalog Muse id reaches supportedEfforts unnormalized; spark and + // wonder ids pin the family rule. + test.each([ + "muse-spark-1.3-contributor", + "muse-spark-1.3-contributor-free", + "muse-wonder-1.0", + ])("Muse id %s supports minimal through high", (model) => { + expect(supportedEfforts(model)).toEqual([ + "minimal", + "low", + "medium", + "high", + ]); + expect(defaultEffortForModel(model)).toBe("low"); + }); - test("a model merely containing muse-spark is not matched", () => { + test("a model merely containing muse- is not matched", () => { expect(supportedEfforts("not-muse-spark-1.3")).toEqual([ "low", "medium", "high", ]); + expect(supportedEfforts("not-muse-wonder-1.0")).toEqual([ + "low", + "medium", + "high", + ]); }); }); @@ -175,11 +181,13 @@ describe("validateEffort", () => { expect(validateEffort("grok-4.5", "xhigh").ok).toBe(false); }); - test("accepts minimal on Muse Spark and rejects none", () => { - expect(validateEffort("muse-spark-1.3-contributor", "minimal")).toEqual({ - ok: true, - }); - expect(validateEffort("muse-spark-1.3-contributor", "none").ok).toBe(false); + test("accepts minimal on Muse models and rejects none", () => { + for (const model of ["muse-spark-1.3-contributor", "muse-wonder-1.0"]) { + expect(validateEffort(model, "minimal")).toEqual({ + ok: true, + }); + expect(validateEffort(model, "none").ok).toBe(false); + } }); test("rejects medium on glm-5.3 family", () => { diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index 8019aed9d..e87e019d2 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -88,7 +88,7 @@ const MUSE_SPARK_EFFORTS: readonly ReasoningEffort[] = DEFAULT_EFFORTS; // and nothing normalizes the model string first. Mirrors the grok/kimi checks // in src/subagent/provider-family.ts. function isMuseSparkModel(model: string): boolean { - return /^muse-spark/i.test(model.trim()); + return /^muse-/i.test(model.trim()); } // grok-4.6/4.7 accept xhigh (CODEX_EFFORTS); grok-4.5 and composer stay on diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index 63454ff19..938d55c3d 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -6,6 +6,7 @@ import { isDeepSeekLeafProvider, isGptProvider, isKimiLeafProvider, + isMuseSparkLeafProvider, isXaiGrokLeafProvider, shouldApplyGrokAntiThrash, } from "./provider-family.js"; @@ -67,6 +68,21 @@ describe("isKimiLeafProvider", () => { ); }); +describe("isMuseSparkLeafProvider", () => { + test.each<[ProviderRow, boolean]>([ + [ + { providerName: "opencode-go", model: "muse-spark-1.3-contributor" }, + true, + ], + [{ providerName: "meta-api", model: "muse-wonder-1.0" }, true], + [{ providerName: "meta/default", model: "MUSE-WONDER-1.0" }, true], + [{ providerName: "meta-api", model: "not-muse-wonder-1.0" }, false], + [{ providerName: "meta-api" }, false], + ])("matches %j -> %s", (row, expected) => { + expect(isMuseSparkLeafProvider(row)).toBe(expected); + }); +}); + describe("detectModelFamily", () => { test.each<[ProviderRow, ReturnType]>([ [{ providerName: "xai/default", model: "grok-4.5" }, "grok"], @@ -74,6 +90,11 @@ describe("detectModelFamily", () => { [{ providerName: "xai/default", model: "grok-4.7" }, "grok"], [{ providerName: "moonshot", model: "kimi-k2" }, "kimi"], [{ providerName: "opencode-go", model: "kimi-k3" }, "kimi"], + [ + { providerName: "opencode-go", model: "muse-spark-1.3-contributor" }, + "muse", + ], + [{ providerName: "meta-api", model: "muse-wonder-1.0" }, "muse"], [{ providerName: "anthropic", model: "claude-sonnet-4" }, "claude"], [{ providerName: "openai-compat", model: "claude-opus-4-6" }, "claude"], [{ providerName: "openai", model: "gpt-5.6" }, "gpt"], diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 0f30a49e2..75de1fb23 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -43,12 +43,13 @@ export function isKimiLeafProvider(input: { return false; } -/** True when the provider/model is the OpenCode Go Muse Spark family. */ +// Model-id prefix match: the family ships across OpenCode Go, Zen, and Meta +// catalogs under muse-spark and muse-wonder ids without a shared provider name. export function isMuseSparkLeafProvider(input: { providerName: string; model?: string; }): boolean { - return input.model !== undefined && /^muse-spark/i.test(input.model.trim()); + return input.model !== undefined && /^muse-/i.test(input.model.trim()); } /** True when the provider/model is Anthropic's Claude family. */