diff --git a/src/agent/director.ts b/src/agent/director.ts index a9f653a4a..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,12 +508,10 @@ class ChatDirectorImpl extends DefaultDirector { this.onTasksChange = options.onTasksChange; this.compaction = createCompactionGovernor( options.requestContinuation, - systemPrompt, + composedPrompt, toolDefinitions, ); - this.modelFamilyPolicy = - options.modelFamilyPolicy ?? - resolveModelFamilyPolicy({ providerName: "" }); + this.modelFamilyPolicy = familyPolicy; 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/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/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/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/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"; } 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;