From a723342a782d07a53a4a4f2a85988482c646f766 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:01:41 -0700 Subject: [PATCH 1/2] Give Muse Spark tool-discipline rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At medium reasoning effort the model does not reliably stop a tool loop: on a two-file fixture with a bounded fix it re-read files it had already read and ran out an 8-turn ceiling without finishing. The same run with three rules appended finished in 3 turns on 4.3x fewer input tokens. The rules ride at the tail of the system prompt, which is prefix-safe — tail appends hold a 99.1% cache hit while a head edit drops it to 9%. Adds a `muse` model family and pins the Responses quirks: the model batches independent tool calls on its own, and sending parallel_tool_calls: false would collapse that to one call per turn. CL-7869 --- src/agent/director.ts | 7 +++++++ src/agent/model-family-policy.test.ts | 15 +++++++++++++++ src/agent/model-family-policy.ts | 25 +++++++++++++++++++++++++ src/provider/openai-responses.test.ts | 12 ++++++++++++ src/provider/openai-responses.ts | 2 +- src/subagent/provider-family.ts | 11 ++++++++++- 6 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/provider/openai-responses.test.ts diff --git a/src/agent/director.ts b/src/agent/director.ts index a9f653a4a..9c007a06c 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -499,6 +499,13 @@ class ChatDirectorImpl extends DefaultDirector { this.modelFamilyPolicy = options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" }); + // Family tool-discipline rules go at the tail of the system prompt. + // Appending there is prefix-safe: measured on OpenCode Go Responses, tail + // appends hold a 99.1% cache hit while an edit at the head drops it to 9%. + const rules = this.modelFamilyPolicy.toolDisciplineRules; + if (rules !== undefined && rules.length > 0) { + this._systemPrompt = `${systemPrompt}\n\n${rules}`; + } this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy(); this.getLiveFleetCount = options.getLiveFleetCount; } diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index acd455cb0..ed2843717 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -87,4 +87,19 @@ describe("resolveModelFamilyPolicy", () => { expect(policy.advertisedToolDeny).toEqual([]); } }); + + test("muse spark carries tool-discipline rules; other families do not", () => { + const muse = resolveModelFamilyPolicy({ + providerName: "opencode-go/abklabs", + model: "muse-spark-1.3-contributor", + }); + const base = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + }); + expect(muse.family).toBe("muse"); + expect(muse.toolDisciplineRules).toContain("Batch independent tool calls"); + expect(muse.toolDisciplineRules).toContain("Never re-read a file"); + expect(base.toolDisciplineRules).toBeUndefined(); + }); }); diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index b13626c27..e77057594 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -32,6 +32,12 @@ export interface ModelFamilyPolicy { * denied. Orchestrators keep the full surface. */ advertisedToolDeny: readonly string[]; + /** + * Tool-discipline rules appended to the system prompt for families that do + * not self-terminate a tool loop. Empty for families that need none. Appended + * at the tail so it cannot disturb the cached prompt prefix. + */ + toolDisciplineRules?: string; } const DEFAULT_WRAP_UP_NUDGE_TEXT = @@ -86,6 +92,23 @@ const GROK_POLICY: Omit = { // once eval data exists. const KIMI_POLICY: Omit = { ...DEFAULT_POLICY }; +// Muse Spark does not reliably stop a tool loop at medium reasoning effort: on +// a two-file fixture with a bounded fix it re-read files it had already read +// and ran out the 8-turn ceiling without finishing. The same run with these +// three rules appended finished in 3 turns on 4.3x fewer input tokens. At +// minimal effort it terminates either way, so the rules earn their keep exactly +// at the rungs where each wasted turn is most expensive. See CL-7869. +const MUSE_TOOL_DISCIPLINE_RULES = + "Tool discipline:\n" + + "- Batch independent tool calls into a single turn.\n" + + "- Never re-read a file you have already read this session.\n" + + "- Do not narrate; act."; + +const MUSE_POLICY: Omit = { + ...DEFAULT_POLICY, + toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES, +}; + export function resolveModelFamilyPolicy(input: { providerName: string; model?: string; @@ -111,6 +134,8 @@ export function resolveModelFamilyPolicy(input: { ...KIMI_POLICY, advertisedToolDeny: orchestrator ? [] : ["skill_search"], }; + case "muse": + return { family, ...MUSE_POLICY }; default: return { family: "default", ...DEFAULT_POLICY }; } diff --git a/src/provider/openai-responses.test.ts b/src/provider/openai-responses.test.ts new file mode 100644 index 000000000..db102096e --- /dev/null +++ b/src/provider/openai-responses.test.ts @@ -0,0 +1,12 @@ +import { describe, test, expect } from "bun:test"; +import { hostQuirks } from "./openai-responses.js"; + +describe("OpenCode Go Responses quirks", () => { + // Muse Spark batches independent tool calls into one turn by default — three + // reads in a single response. Sending parallel_tool_calls: false collapses + // that to one call per turn and triples the turn count on a bounded task. + // Leaving the quirk unset is what keeps the gateway default. See CL-7869. + test("leaves parallel_tool_calls unset so the gateway default stands", () => { + expect(hostQuirks.parallelToolCalls).toBeUndefined(); + }); +}); diff --git a/src/provider/openai-responses.ts b/src/provider/openai-responses.ts index e2df30168..23452c2c2 100644 --- a/src/provider/openai-responses.ts +++ b/src/provider/openai-responses.ts @@ -11,7 +11,7 @@ export const OPENAI_SESSION_ID_OPTION = "openaiSessionId"; // Baked at the host so OpenCode Go Responses does not read source.quirks // (package createOpenAIResponsesAdapter would, and that is not the current Go wire). -const hostQuirks: ResponsesQuirks = { +export const hostQuirks: ResponsesQuirks = { path: "/responses", sessionIdOption: OPENAI_SESSION_ID_OPTION, headers: { diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 0dfe3e0d9..6165fabf2 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -29,8 +29,16 @@ export function isKimiLeafProvider(input: { return false; } +/** True when the provider/model is the OpenCode Go Muse Spark family. */ +export function isMuseSparkLeafProvider(input: { + providerName: string; + model?: string; +}): boolean { + return input.model !== undefined && /^muse-spark/i.test(input.model.trim()); +} + /** Model families the shared directors branch on via ModelFamilyPolicy. */ -export type ModelFamily = "grok" | "kimi" | "default"; +export type ModelFamily = "grok" | "kimi" | "muse" | "default"; /** * Resolves a provider/model to a ModelFamily. Generalizes @@ -44,6 +52,7 @@ export function detectModelFamily(input: { }): ModelFamily { if (isXaiGrokLeafProvider(input)) return "grok"; if (isKimiLeafProvider(input)) return "kimi"; + if (isMuseSparkLeafProvider(input)) return "muse"; return "default"; } From cb0665f871cafd425e3b4e3caa6dc32f84faf086 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:18:02 -0700 Subject: [PATCH 2/2] Send the tool-discipline rules instead of only composing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules were appended to the director's own copy of the system prompt after calling super(). The base director keeps its own copy and sets options.systemPrompt from it on every ordinary turn, so withCurrentTools' `?? this._systemPrompt` fallback never fired and the composed prompt was built but never sent. A Muse Spark session received the base prompt verbatim: the whole change was a no-op, and every existing test passed. Compose before super() so the base director holds the composed prompt, and give the compaction governor the same text it will actually be estimating. Sub-agent leaves get the rules too. They are the case the ticket names — a spawned worker running to its turn budget — and SubAgentDirector never received them at all. Adds the test that would have caught this: assert the prompt on the emitted infer action, not the prompt the director was constructed with. CL-7869 --- src/agent/director.ts | 32 ++++++++++++++++----------- src/director.test.ts | 40 ++++++++++++++++++++++++++++++++++ src/subagent/nudge-director.ts | 14 +++++++++--- src/subagent/run.ts | 1 + 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/agent/director.ts b/src/agent/director.ts index 9c007a06c..bf548acba 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -482,8 +482,23 @@ class ChatDirectorImpl extends DefaultDirector { toolDefinitions: ToolDefinition[], options: ChatDirectorImplOptions, ) { - super(systemPrompt, toolDefinitions, {}); - this._systemPrompt = systemPrompt; + // Compose before super(). The base director keeps its own copy of the + // system prompt and sets options.systemPrompt from it on every ordinary + // turn, so withCurrentTools' `?? this._systemPrompt` fallback never fires + // and anything appended after super() is built but never sent. + const familyPolicy = + options.modelFamilyPolicy ?? + resolveModelFamilyPolicy({ providerName: "" }); + const disciplineRules = familyPolicy.toolDisciplineRules; + // Family tool-discipline rules go at the tail. Appending there is + // prefix-safe: measured on OpenCode Go Responses, tail appends hold a + // 99.1% cache hit while an edit at the head drops it to 9%. + const composedPrompt = + disciplineRules !== undefined && disciplineRules.length > 0 + ? `${systemPrompt}\n\n${disciplineRules}` + : systemPrompt; + super(composedPrompt, toolDefinitions, {}); + this._systemPrompt = composedPrompt; this._toolDefinitions = toolDefinitions; this.inactivityTimeoutMs = options.inactivityTimeoutMs; this.totalTimeoutMs = options.totalTimeoutMs; @@ -493,19 +508,10 @@ class ChatDirectorImpl extends DefaultDirector { this.onTasksChange = options.onTasksChange; this.compaction = createCompactionGovernor( options.requestContinuation, - systemPrompt, + composedPrompt, toolDefinitions, ); - this.modelFamilyPolicy = - options.modelFamilyPolicy ?? - resolveModelFamilyPolicy({ providerName: "" }); - // Family tool-discipline rules go at the tail of the system prompt. - // Appending there is prefix-safe: measured on OpenCode Go Responses, tail - // appends hold a 99.1% cache hit while an edit at the head drops it to 9%. - const rules = this.modelFamilyPolicy.toolDisciplineRules; - if (rules !== undefined && rules.length > 0) { - this._systemPrompt = `${systemPrompt}\n\n${rules}`; - } + this.modelFamilyPolicy = familyPolicy; this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy(); this.getLiveFleetCount = options.getLiveFleetCount; } diff --git a/src/director.test.ts b/src/director.test.ts index 6b31ddb6e..7eeaf2568 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -1661,3 +1661,43 @@ describe("chatDirector spacer echo", () => { expect(exhausted.some((a) => a.type === "reply")).toBe(true); }); }); + +// The rules are only worth anything if they reach the model. An earlier cut of +// this change appended them to the director's own copy of the system prompt +// AFTER calling super(), so the base director kept sending the original and +// the whole feature was a no-op that every existing test passed. +describe("tool-discipline rules on the wire", () => { + async function promptSentFor(model: string): Promise { + const director = createChatDirector("BASE PROMPT", [], { + onTasksChange: () => undefined, + provider: { providerName: "opencode-go", model }, + }); + const event = { + type: "message.received", + message: { role: "user", content: "hi" }, + } as unknown as ReactorInboundEvent; + const actions = actionsArray( + await director.decide(event, mockState, mockCapabilities), + ); + const infer = actions.find((a) => a.type === "infer") as + | { options?: ExtendedInferenceOptions } + | undefined; + return infer?.options?.systemPrompt; + } + + test("a Muse Spark session sends the rules, not just the base prompt", async () => { + const prompt = await promptSentFor("muse-spark-1.3-contributor"); + expect(prompt).toContain("BASE PROMPT"); + expect(prompt).toContain("Batch independent tool calls"); + expect(prompt).toContain("Never re-read a file"); + }); + + test("the rules ride at the tail, where they cannot disturb the cache prefix", async () => { + const prompt = await promptSentFor("muse-spark-1.3-contributor"); + expect(prompt?.startsWith("BASE PROMPT")).toBe(true); + }); + + test("a family with no rules sends the prompt untouched", async () => { + expect(await promptSentFor("claude-sonnet-4")).toBe("BASE PROMPT"); + }); +}); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 5dcff3b50..a711d06a7 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -234,12 +234,20 @@ export class SubAgentDirector extends DefaultDirector { requireEvidence = false, requirePlanSubstance = false, retryPolicy: RetryPolicy = createCorbitsRetryPolicy(), + toolDisciplineRules?: string, ) { - super(systemPrompt, toolDefinitions, {}); - this._systemPrompt = systemPrompt; + // Composed before super() for the same reason as ChatDirectorImpl: the base + // director keeps its own copy and sends that, so anything appended after + // super() is never on the wire. + const composedPrompt = + toolDisciplineRules !== undefined && toolDisciplineRules.length > 0 + ? `${systemPrompt}\n\n${toolDisciplineRules}` + : systemPrompt; + super(composedPrompt, toolDefinitions, {}); + this._systemPrompt = composedPrompt; this.compaction = createCompactionGovernor( requestContinuation, - systemPrompt, + composedPrompt, toolDefinitions, ); this.stallTimeoutMs = stallTimeoutMs; diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 5d3dd94b3..5fc981d19 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -1032,6 +1032,7 @@ async function runSubAgentInner( providerId: params.provider.providerName, admission: params.admission ?? getProcessAdmissionQueue(), }), + modelFamilyPolicy.toolDisciplineRules, ); director.observeForcedStop((reason) => { directorForcedStopReason = reason;