fix(gsd): wire subagent_model preference through to dispatch prompt builders

reactive_execution.subagent_model was validated and stored but never
passed to the prompt builders that generate subagent dispatch instructions.
The executing agent therefore autonomously chose its default model instead
of the configured preference.

- buildReactiveExecutePrompt: add subagentModel? param, inject into
  instruction string; auto-dispatch passes reactiveConfig.subagent_model
  with fallback to resolveModelWithFallbacksForUnit("subagent")
- buildParallelResearchSlicesPrompt: same pattern, resolves from
  models.subagent preference
- buildGateEvaluatePrompt: same pattern
- system-context: inject configured subagent model into system prompt
  so the executing agent always knows which model to use for subagents

Closes #4078

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nils Reeh 2026-04-13 14:13:16 +02:00
parent a75167fab2
commit 1a635ac72c
4 changed files with 289 additions and 4 deletions

View file

@ -52,6 +52,7 @@ import {
checkNeedsReassessment,
checkNeedsRunUat,
} from "./auto-prompts.js";
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
// ─── Types ────────────────────────────────────────────────────────────────
@ -423,6 +424,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
midTitle,
researchReadySlices,
basePath,
resolveModelWithFallbacksForUnit("subagent")?.primary,
),
};
},
@ -510,6 +512,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
sid,
sTitle,
basePath,
resolveModelWithFallbacksForUnit("subagent")?.primary,
),
};
},
@ -548,6 +551,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
const sid = state.activeSlice.id;
const sTitle = state.activeSlice.title;
const maxParallel = reactiveConfig.max_parallel ?? 2;
const subagentModel = reactiveConfig.subagent_model ?? resolveModelWithFallbacksForUnit("subagent")?.primary;
// Dry-run mode: max_parallel=1 means graph is derived and logged but
// execution remains sequential
@ -618,6 +622,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
sTitle,
selected,
basePath,
subagentModel,
),
};
} catch (err) {

View file

@ -1926,6 +1926,7 @@ export async function buildReassessRoadmapPrompt(
export async function buildReactiveExecutePrompt(
mid: string, midTitle: string, sid: string, sTitle: string,
readyTaskIds: string[], base: string,
subagentModel?: string,
): Promise<string> {
const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js");
@ -1970,10 +1971,11 @@ export async function buildReactiveExecutePrompt(
{ carryForwardPaths: depPaths },
);
const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
subagentSections.push([
`### ${tid}: ${tTitle}`,
"",
"Use this as the prompt for a `subagent` call:",
`Use this as the prompt for a \`subagent\` call${modelSuffix}:`,
"",
"```",
taskPrompt,
@ -2049,15 +2051,17 @@ export async function buildParallelResearchSlicesPrompt(
midTitle: string,
slices: Array<{ id: string; title: string }>,
basePath: string,
subagentModel?: string,
): Promise<string> {
// Build individual research-slice prompts for each slice
const subagentSections: string[] = [];
const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
for (const slice of slices) {
const slicePrompt = await buildResearchSlicePrompt(mid, midTitle, slice.id, slice.title, basePath);
subagentSections.push([
`### ${slice.id}: ${slice.title}`,
"",
"Use this as the prompt for a `subagent` call (agent: `gsd-executor` or the default agent):",
`Use this as the prompt for a \`subagent\` call${modelSuffix} (agent: \`gsd-executor\` or the default agent):`,
"",
"```",
slicePrompt,
@ -2077,6 +2081,7 @@ export async function buildParallelResearchSlicesPrompt(
export async function buildGateEvaluatePrompt(
mid: string, midTitle: string, sid: string, sTitle: string,
base: string,
subagentModel?: string,
): Promise<string> {
// Pull only the gates this turn actually owns (Q3/Q4). Filter via the
// registry so that scope:"slice" gates owned by other turns (Q8) can't
@ -2128,10 +2133,11 @@ export async function buildGateEvaluatePrompt(
"- `findings`: detailed markdown findings (or empty if omitted)",
].join("\n");
const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
subagentSections.push([
`### ${def.id}: ${def.question}`,
"",
"Use this as the prompt for a `subagent` call:",
`Use this as the prompt for a \`subagent\` call${modelSuffix}:`,
"",
"```",
subPrompt,

View file

@ -9,6 +9,7 @@ import { debugTime } from "../debug-logger.js";
import { loadPrompt, getTemplatesDir } from "../prompt-loader.js";
import { readForensicsMarker } from "../forensics.js";
import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js";
import { resolveModelWithFallbacksForUnit } from "../preferences-models.js";
import { resolveSkillReference } from "../preferences-skills.js";
import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js";
import { ensureCodebaseMapFresh, readCodebaseMap } from "../codebase-generator.js";
@ -175,7 +176,13 @@ export async function buildBeforeAgentStartResult(
const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd(), event.prompt) : null;
const worktreeBlock = buildWorktreeContextBlock();
const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
const subagentModelConfig = resolveModelWithFallbacksForUnit("subagent");
const subagentModelBlock = subagentModelConfig
? `\n\n## Subagent Model\n\nWhen spawning subagents via the \`subagent\` tool, always pass \`model: "${subagentModelConfig.primary}"\` in the tool call parameters. Never omit this — always specify it explicitly.`
: "";
const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${subagentModelBlock}`;
stopContextTimer({
systemPromptSize: fullSystem.length,

View file

@ -0,0 +1,267 @@
/**
* Regression tests for subagent model preference wiring.
*
* Fixes: subagent_model config in reactive_execution was validated and stored
* but never passed through to subagent dispatch instruction strings, so the
* executing agent autonomously chose "sonnet" instead of the configured model.
*
* Issue: gsd-build/gsd-2#4078
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { validatePreferences } from "../preferences-validation.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const promptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8");
const dispatchSrc = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8");
// ─── Preference Validation ────────────────────────────────────────────────
test("reactive_execution: subagent_model is preserved in validated preferences", () => {
const result = validatePreferences({
reactive_execution: {
enabled: true,
max_parallel: 2,
isolation_mode: "same-tree",
subagent_model: "claude-opus-4-6",
},
});
assert.equal(result.errors.length, 0);
assert.equal(
result.preferences.reactive_execution?.subagent_model,
"claude-opus-4-6",
"subagent_model should be preserved through validation",
);
});
test("reactive_execution: subagent_model rejects empty string", () => {
const result = validatePreferences({
reactive_execution: {
enabled: true,
max_parallel: 2,
isolation_mode: "same-tree",
subagent_model: "",
} as any,
});
assert.ok(
result.errors.some((e) => e.includes("subagent_model")),
"empty subagent_model should produce a validation error",
);
});
// ─── Structural: Prompt Builders Accept subagentModel ────────────────────
test("buildReactiveExecutePrompt: accepts subagentModel parameter", () => {
const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt");
assert.ok(fnStart !== -1, "buildReactiveExecutePrompt should be exported");
const signature = promptsSrc.slice(fnStart, fnStart + 300);
assert.ok(
signature.includes("subagentModel"),
"buildReactiveExecutePrompt should accept a subagentModel parameter",
);
});
test("buildParallelResearchSlicesPrompt: accepts subagentModel parameter", () => {
const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt");
assert.ok(fnStart !== -1, "buildParallelResearchSlicesPrompt should be exported");
const signature = promptsSrc.slice(fnStart, fnStart + 300);
assert.ok(
signature.includes("subagentModel"),
"buildParallelResearchSlicesPrompt should accept a subagentModel parameter",
);
});
test("buildGateEvaluatePrompt: accepts subagentModel parameter", () => {
const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt");
assert.ok(fnStart !== -1, "buildGateEvaluatePrompt should be exported");
const signature = promptsSrc.slice(fnStart, fnStart + 300);
assert.ok(
signature.includes("subagentModel"),
"buildGateEvaluatePrompt should accept a subagentModel parameter",
);
});
// ─── Structural: Instruction Strings Inject Model ────────────────────────
test("buildReactiveExecutePrompt: instruction string uses subagentModel when set", () => {
const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt");
const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1);
const fnBody = promptsSrc.slice(fnStart, fnEnd);
assert.ok(
fnBody.includes("subagentModel"),
"buildReactiveExecutePrompt body should reference subagentModel",
);
// The instruction line must be dynamic (not a plain string literal)
assert.ok(
!fnBody.includes('"Use this as the prompt for a `subagent` call:"'),
"instruction should not be a plain static string — model must be injectable",
);
});
test("buildParallelResearchSlicesPrompt: instruction string uses subagentModel when set", () => {
const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt");
const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1);
const fnBody = promptsSrc.slice(fnStart, fnEnd);
assert.ok(
fnBody.includes("subagentModel"),
"buildParallelResearchSlicesPrompt body should reference subagentModel",
);
});
test("buildGateEvaluatePrompt: instruction string uses subagentModel when set", () => {
const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt");
const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1);
const fnBody = promptsSrc.slice(fnStart, fnEnd);
assert.ok(
fnBody.includes("subagentModel"),
"buildGateEvaluatePrompt body should reference subagentModel",
);
});
// ─── Structural: Dispatch Wires Model to Prompt Builders ─────────────────
test("auto-dispatch: passes model to buildReactiveExecutePrompt", () => {
// Find the reactive-execute dispatch rule
const ruleStart = dispatchSrc.indexOf("reactive-execute (parallel dispatch)");
assert.ok(ruleStart !== -1, "reactive-execute dispatch rule should exist");
const ruleBlock = dispatchSrc.slice(ruleStart, ruleStart + 1000);
assert.ok(
ruleBlock.includes("subagent_model") || ruleBlock.includes("subagentModel"),
"reactive-execute rule should resolve and pass the subagent model",
);
});
test("auto-dispatch: passes model to buildParallelResearchSlicesPrompt", () => {
const callIdx = dispatchSrc.indexOf("buildParallelResearchSlicesPrompt(");
assert.ok(callIdx !== -1, "buildParallelResearchSlicesPrompt call should exist");
// The call site should pass a model argument (not just 4 args)
const callSite = dispatchSrc.slice(callIdx, callIdx + 300);
assert.ok(
callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"),
"buildParallelResearchSlicesPrompt call should include model argument",
);
});
test("auto-dispatch: passes model to buildGateEvaluatePrompt", () => {
const callIdx = dispatchSrc.indexOf("buildGateEvaluatePrompt(");
assert.ok(callIdx !== -1, "buildGateEvaluatePrompt call should exist");
const callSite = dispatchSrc.slice(callIdx, callIdx + 300);
assert.ok(
callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"),
"buildGateEvaluatePrompt call should include model argument",
);
});
// ─── Integration: Prompt Output Contains Model String ────────────────────
test("buildReactiveExecutePrompt: output contains model string when subagentModel provided", async (t) => {
const { buildReactiveExecutePrompt } = await import("../auto-prompts.ts");
const repo = mkdtempSync(join(tmpdir(), "gsd-subagent-model-reactive-"));
t.after(() => rmSync(repo, { recursive: true, force: true }));
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(join(gsd, "tasks"), { recursive: true });
writeFileSync(
join(gsd, "S01-PLAN.md"),
[
"# S01: Test Slice",
"",
"**Goal:** Verify model injection",
"**Demo:** Model appears in subagent prompt",
"",
"## Tasks",
"",
"- [ ] **T01: Task One** `est:15m`",
" Do something.",
"",
].join("\n"),
);
writeFileSync(
join(gsd, "tasks", "T01-PLAN.md"),
[
"# T01: Task One",
"",
"## Description",
"Do something.",
"",
"## Inputs",
"",
"- `src/config.json` — Config",
"",
"## Expected Output",
"",
"- `src/out.ts` — Result",
].join("\n"),
);
const prompt = await buildReactiveExecutePrompt(
"M001", "Test Milestone", "S01", "Test Slice",
["T01"], repo, "claude-opus-4-6",
);
assert.ok(
prompt.includes('model: "claude-opus-4-6"'),
`Prompt should contain model instruction. Got:\n${prompt.slice(0, 500)}`,
);
});
test("buildReactiveExecutePrompt: no model instruction when subagentModel omitted", async (t) => {
const { buildReactiveExecutePrompt } = await import("../auto-prompts.ts");
const repo = mkdtempSync(join(tmpdir(), "gsd-subagent-model-none-"));
t.after(() => rmSync(repo, { recursive: true, force: true }));
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(join(gsd, "tasks"), { recursive: true });
writeFileSync(
join(gsd, "S01-PLAN.md"),
[
"# S01: Test Slice",
"",
"**Goal:** Verify no model when omitted",
"**Demo:** No model string",
"",
"## Tasks",
"",
"- [ ] **T01: Task One** `est:15m`",
" Do something.",
"",
].join("\n"),
);
writeFileSync(
join(gsd, "tasks", "T01-PLAN.md"),
[
"# T01: Task One",
"",
"## Description",
"Do something.",
"",
"## Inputs",
"",
"- `src/config.json` — Config",
"",
"## Expected Output",
"",
"- `src/out.ts` — Result",
].join("\n"),
);
const prompt = await buildReactiveExecutePrompt(
"M001", "Test Milestone", "S01", "Test Slice",
["T01"], repo,
// no subagentModel
);
assert.ok(
!prompt.includes('with model:'),
"Prompt should not contain model instruction when subagentModel is omitted",
);
});