Merge pull request #4114 from NilsR0711/fix/reactive-subagent-model-wiring

fix(gsd): wire subagent_model preference through to dispatch prompt builders
This commit is contained in:
Jeremy McSpadden 2026-04-13 10:37:48 -05:00 committed by GitHub
commit cf34383104
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",
);
});