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:
commit
cf34383104
4 changed files with 289 additions and 4 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue