From 1a635ac72cc382041f3fdbac80ecee2a25450936 Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Mon, 13 Apr 2026 14:13:16 +0200 Subject: [PATCH] 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 --- src/resources/extensions/gsd/auto-dispatch.ts | 5 + src/resources/extensions/gsd/auto-prompts.ts | 12 +- .../gsd/bootstrap/system-context.ts | 9 +- .../gsd/tests/subagent-model-dispatch.test.ts | 267 ++++++++++++++++++ 4 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 1b495c417..5598d05e1 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -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) { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 28217afd6..557cd77e7 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -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 { 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 { // 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 { // 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, diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 3a336f9ee..bf31468e9 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -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, diff --git a/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts b/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts new file mode 100644 index 000000000..9c17c13a0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts @@ -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", + ); +});