Skip to content

Commit a45ec38

Browse files
committed
Send the tool-discipline rules instead of only composing them
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
1 parent 20cbc8f commit a45ec38

4 files changed

Lines changed: 71 additions & 16 deletions

File tree

src/agent/director.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,23 @@ class ChatDirectorImpl extends DefaultDirector {
482482
toolDefinitions: ToolDefinition[],
483483
options: ChatDirectorImplOptions,
484484
) {
485-
super(systemPrompt, toolDefinitions, {});
486-
this._systemPrompt = systemPrompt;
485+
// Compose before super(). The base director keeps its own copy of the
486+
// system prompt and sets options.systemPrompt from it on every ordinary
487+
// turn, so withCurrentTools' `?? this._systemPrompt` fallback never fires
488+
// and anything appended after super() is built but never sent.
489+
const familyPolicy =
490+
options.modelFamilyPolicy ??
491+
resolveModelFamilyPolicy({ providerName: "" });
492+
const disciplineRules = familyPolicy.toolDisciplineRules;
493+
// Family tool-discipline rules go at the tail. Appending there is
494+
// prefix-safe: measured on OpenCode Go Responses, tail appends hold a
495+
// 99.1% cache hit while an edit at the head drops it to 9%.
496+
const composedPrompt =
497+
disciplineRules !== undefined && disciplineRules.length > 0
498+
? `${systemPrompt}\n\n${disciplineRules}`
499+
: systemPrompt;
500+
super(composedPrompt, toolDefinitions, {});
501+
this._systemPrompt = composedPrompt;
487502
this._toolDefinitions = toolDefinitions;
488503
this.inactivityTimeoutMs = options.inactivityTimeoutMs;
489504
this.totalTimeoutMs = options.totalTimeoutMs;
@@ -493,19 +508,10 @@ class ChatDirectorImpl extends DefaultDirector {
493508
this.onTasksChange = options.onTasksChange;
494509
this.compaction = createCompactionGovernor(
495510
options.requestContinuation,
496-
systemPrompt,
511+
composedPrompt,
497512
toolDefinitions,
498513
);
499-
this.modelFamilyPolicy =
500-
options.modelFamilyPolicy ??
501-
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-
}
514+
this.modelFamilyPolicy = familyPolicy;
509515
this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy();
510516
this.getLiveFleetCount = options.getLiveFleetCount;
511517
}

src/director.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,3 +1661,43 @@ describe("chatDirector spacer echo", () => {
16611661
expect(exhausted.some((a) => a.type === "reply")).toBe(true);
16621662
});
16631663
});
1664+
1665+
// The rules are only worth anything if they reach the model. An earlier cut of
1666+
// this change appended them to the director's own copy of the system prompt
1667+
// AFTER calling super(), so the base director kept sending the original and
1668+
// the whole feature was a no-op that every existing test passed.
1669+
describe("tool-discipline rules on the wire", () => {
1670+
async function promptSentFor(model: string): Promise<string | undefined> {
1671+
const director = createChatDirector("BASE PROMPT", [], {
1672+
onTasksChange: () => undefined,
1673+
provider: { providerName: "opencode-go", model },
1674+
});
1675+
const event = {
1676+
type: "message.received",
1677+
message: { role: "user", content: "hi" },
1678+
} as unknown as ReactorInboundEvent;
1679+
const actions = actionsArray(
1680+
await director.decide(event, mockState, mockCapabilities),
1681+
);
1682+
const infer = actions.find((a) => a.type === "infer") as
1683+
| { options?: ExtendedInferenceOptions }
1684+
| undefined;
1685+
return infer?.options?.systemPrompt;
1686+
}
1687+
1688+
test("a Muse Spark session sends the rules, not just the base prompt", async () => {
1689+
const prompt = await promptSentFor("muse-spark-1.3-contributor");
1690+
expect(prompt).toContain("BASE PROMPT");
1691+
expect(prompt).toContain("Batch independent tool calls");
1692+
expect(prompt).toContain("Never re-read a file");
1693+
});
1694+
1695+
test("the rules ride at the tail, where they cannot disturb the cache prefix", async () => {
1696+
const prompt = await promptSentFor("muse-spark-1.3-contributor");
1697+
expect(prompt?.startsWith("BASE PROMPT")).toBe(true);
1698+
});
1699+
1700+
test("a family with no rules sends the prompt untouched", async () => {
1701+
expect(await promptSentFor("claude-sonnet-4")).toBe("BASE PROMPT");
1702+
});
1703+
});

src/subagent/nudge-director.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,20 @@ export class SubAgentDirector extends DefaultDirector {
234234
requireEvidence = false,
235235
requirePlanSubstance = false,
236236
retryPolicy: RetryPolicy = createCorbitsRetryPolicy(),
237+
toolDisciplineRules?: string,
237238
) {
238-
super(systemPrompt, toolDefinitions, {});
239-
this._systemPrompt = systemPrompt;
239+
// Composed before super() for the same reason as ChatDirectorImpl: the base
240+
// director keeps its own copy and sends that, so anything appended after
241+
// super() is never on the wire.
242+
const composedPrompt =
243+
toolDisciplineRules !== undefined && toolDisciplineRules.length > 0
244+
? `${systemPrompt}\n\n${toolDisciplineRules}`
245+
: systemPrompt;
246+
super(composedPrompt, toolDefinitions, {});
247+
this._systemPrompt = composedPrompt;
240248
this.compaction = createCompactionGovernor(
241249
requestContinuation,
242-
systemPrompt,
250+
composedPrompt,
243251
toolDefinitions,
244252
);
245253
this.stallTimeoutMs = stallTimeoutMs;

src/subagent/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,7 @@ async function runSubAgentInner(
995995
providerId: params.provider.providerName,
996996
admission: params.admission ?? getProcessAdmissionQueue(),
997997
}),
998+
modelFamilyPolicy.toolDisciplineRules,
998999
);
9991000
director.observeForcedStop((reason) => {
10001001
directorForcedStopReason = reason;

0 commit comments

Comments
 (0)