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
60 changes: 53 additions & 7 deletions src/agent/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
async function subAgentPromptSentFor(
model: string,
orchestrator = false,
): Promise<string | undefined> {
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<string | undefined> {
const director = createChatDirector("BASE PROMPT", [], {
provider: { providerName: "opencode-go", model },
});
Expand All @@ -2665,21 +2701,31 @@ 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 }
| undefined;
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");
});
});
7 changes: 5 additions & 2 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 20 additions & 7 deletions src/agent/model-family-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
10 changes: 8 additions & 2 deletions src/agent/model-family-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 28 additions & 20 deletions src/provider/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
});

Expand Down Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/provider/reasoning-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/subagent/provider-family.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isDeepSeekLeafProvider,
isGptProvider,
isKimiLeafProvider,
isMuseSparkLeafProvider,
isXaiGrokLeafProvider,
shouldApplyGrokAntiThrash,
} from "./provider-family.js";
Expand Down Expand Up @@ -67,13 +68,33 @@ 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<typeof detectModelFamily>]>([
[{ providerName: "xai/default", model: "grok-4.5" }, "grok"],
[{ providerName: "xai/default", model: "grok-4.6" }, "grok"],
[{ 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"],
Expand Down
5 changes: 3 additions & 2 deletions src/subagent/provider-family.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading