Skip to content

Commit bef3e48

Browse files
committed
feat(director): give Muse Spark tool-discipline rules
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
1 parent d9ec82c commit bef3e48

6 files changed

Lines changed: 70 additions & 2 deletions

File tree

src/agent/director.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,13 @@ class ChatDirectorImpl extends DefaultDirector {
499499
this.modelFamilyPolicy =
500500
options.modelFamilyPolicy ??
501501
resolveModelFamilyPolicy({ providerName: "" });
502+
// Family tool-discipline rules go at the tail of the system prompt.
503+
// Appending there is prefix-safe: measured on OpenCode Go Responses, tail
504+
// appends hold a 99.1% cache hit while an edit at the head drops it to 9%.
505+
const rules = this.modelFamilyPolicy.toolDisciplineRules;
506+
if (rules !== undefined && rules.length > 0) {
507+
this._systemPrompt = `${systemPrompt}\n\n${rules}`;
508+
}
502509
this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy();
503510
this.getLiveFleetCount = options.getLiveFleetCount;
504511
}

src/agent/model-family-policy.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,19 @@ describe("resolveModelFamilyPolicy", () => {
5252
expect(kimi.toolOnlyTurnNudgeAt).toBe(base.toolOnlyTurnNudgeAt);
5353
expect(kimi.subAgentStallTimeoutMs).toBe(base.subAgentStallTimeoutMs);
5454
});
55+
56+
test("muse spark carries tool-discipline rules; other families do not", () => {
57+
const muse = resolveModelFamilyPolicy({
58+
providerName: "opencode-go/abklabs",
59+
model: "muse-spark-1.3-contributor",
60+
});
61+
const base = resolveModelFamilyPolicy({
62+
providerName: "anthropic",
63+
model: "claude-sonnet-4",
64+
});
65+
expect(muse.family).toBe("muse");
66+
expect(muse.toolDisciplineRules).toContain("Batch independent tool calls");
67+
expect(muse.toolDisciplineRules).toContain("Never re-read a file");
68+
expect(base.toolDisciplineRules).toBeUndefined();
69+
});
5570
});

src/agent/model-family-policy.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export interface ModelFamilyPolicy {
2525
subAgentStallTimeoutMs: number;
2626
/** Grok's finish-bias residual (withhold from orchestrators; see provider-family.ts). */
2727
applyGrokFinishBias: boolean;
28+
/**
29+
* Tool-discipline rules appended to the system prompt for families that do
30+
* not self-terminate a tool loop. Empty for families that need none. Appended
31+
* at the tail so it cannot disturb the cached prompt prefix.
32+
*/
33+
toolDisciplineRules?: string;
2834
}
2935

3036
const DEFAULT_WRAP_UP_NUDGE_TEXT =
@@ -76,6 +82,23 @@ const GROK_POLICY: Omit<ModelFamilyPolicy, "family"> = {
7682
// once eval data exists.
7783
const KIMI_POLICY: Omit<ModelFamilyPolicy, "family"> = { ...DEFAULT_POLICY };
7884

85+
// Muse Spark does not reliably stop a tool loop at medium reasoning effort: on
86+
// a two-file fixture with a bounded fix it re-read files it had already read
87+
// and ran out the 8-turn ceiling without finishing. The same run with these
88+
// three rules appended finished in 3 turns on 4.3x fewer input tokens. At
89+
// minimal effort it terminates either way, so the rules earn their keep exactly
90+
// at the rungs where each wasted turn is most expensive. See CL-7869.
91+
const MUSE_TOOL_DISCIPLINE_RULES =
92+
"Tool discipline:\n" +
93+
"- Batch independent tool calls into a single turn.\n" +
94+
"- Never re-read a file you have already read this session.\n" +
95+
"- Do not narrate; act.";
96+
97+
const MUSE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
98+
...DEFAULT_POLICY,
99+
toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES,
100+
};
101+
79102
export function resolveModelFamilyPolicy(input: {
80103
providerName: string;
81104
model?: string;
@@ -96,6 +119,8 @@ export function resolveModelFamilyPolicy(input: {
96119
}
97120
case "kimi":
98121
return { family, ...KIMI_POLICY };
122+
case "muse":
123+
return { family, ...MUSE_POLICY };
99124
default:
100125
return { family: "default", ...DEFAULT_POLICY };
101126
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { hostQuirks } from "./openai-responses.js";
3+
4+
describe("OpenCode Go Responses quirks", () => {
5+
// Muse Spark batches independent tool calls into one turn by default — three
6+
// reads in a single response. Sending parallel_tool_calls: false collapses
7+
// that to one call per turn and triples the turn count on a bounded task.
8+
// Leaving the quirk unset is what keeps the gateway default. See CL-7869.
9+
test("leaves parallel_tool_calls unset so the gateway default stands", () => {
10+
expect(hostQuirks.parallelToolCalls).toBeUndefined();
11+
});
12+
});

src/provider/openai-responses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const OPENAI_SESSION_ID_OPTION = "openaiSessionId";
1111

1212
// Baked at the host so OpenCode Go Responses does not read source.quirks
1313
// (package createOpenAIResponsesAdapter would, and that is not the current Go wire).
14-
const hostQuirks: ResponsesQuirks = {
14+
export const hostQuirks: ResponsesQuirks = {
1515
path: "/responses",
1616
sessionIdOption: OPENAI_SESSION_ID_OPTION,
1717
headers: {

src/subagent/provider-family.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,16 @@ export function isKimiLeafProvider(input: {
2929
return false;
3030
}
3131

32+
/** True when the provider/model is the OpenCode Go Muse Spark family. */
33+
export function isMuseSparkLeafProvider(input: {
34+
providerName: string;
35+
model?: string;
36+
}): boolean {
37+
return input.model !== undefined && /^muse-spark/i.test(input.model.trim());
38+
}
39+
3240
/** Model families the shared directors branch on via ModelFamilyPolicy. */
33-
export type ModelFamily = "grok" | "kimi" | "default";
41+
export type ModelFamily = "grok" | "kimi" | "muse" | "default";
3442

3543
/**
3644
* Resolves a provider/model to a ModelFamily. Generalizes
@@ -44,6 +52,7 @@ export function detectModelFamily(input: {
4452
}): ModelFamily {
4553
if (isXaiGrokLeafProvider(input)) return "grok";
4654
if (isKimiLeafProvider(input)) return "kimi";
55+
if (isMuseSparkLeafProvider(input)) return "muse";
4756
return "default";
4857
}
4958

0 commit comments

Comments
 (0)