Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
15 changes: 15 additions & 0 deletions src/agent/model-family-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
25 changes: 25 additions & 0 deletions src/agent/model-family-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -86,6 +92,23 @@ const GROK_POLICY: Omit<ModelFamilyPolicy, "family"> = {
// once eval data exists.
const KIMI_POLICY: Omit<ModelFamilyPolicy, "family"> = { ...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<ModelFamilyPolicy, "family"> = {
...DEFAULT_POLICY,
toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES,
};

export function resolveModelFamilyPolicy(input: {
providerName: string;
model?: string;
Expand All @@ -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 };
}
Expand Down
40 changes: 40 additions & 0 deletions src/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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");
});
});
12 changes: 12 additions & 0 deletions src/provider/openai-responses.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion src/provider/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
14 changes: 11 additions & 3 deletions src/subagent/nudge-director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/subagent/provider-family.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
}

Expand Down
1 change: 1 addition & 0 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,7 @@ async function runSubAgentInner(
providerId: params.provider.providerName,
admission: params.admission ?? getProcessAdmissionQueue(),
}),
modelFamilyPolicy.toolDisciplineRules,
);
director.observeForcedStop((reason) => {
directorForcedStopReason = reason;
Expand Down
Loading