Skip to content

Commit 234fb47

Browse files
committed
Fix spawn_agent to thread director package turn budget
spawn_agent resolved a director's turn budget as Infinity because resolveDirectorDispatch never surfaced the package's nudge.maxTurns and agent-fleet.ts never passed it to resolveSubAgentMaxTurns, unlike task(). Also removes the false "hard cap 4 workers" prompt claim (no such cap exists), corrects the task-watchdog exemption comment (no-progress/thrash detectors were removed), and deletes the unread profile.maxTurns config knob.
1 parent 7a3efc9 commit 234fb47

11 files changed

Lines changed: 69 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Agent
1717

18+
- `spawn_agent` now threads the resolved director package's `nudge.maxTurns`
19+
budget the same way `task()` does, closing a parity gap where a director
20+
dispatched via `spawn_agent` resolved to an unbounded turn budget instead of
21+
its configured finite one. Removed the false "hard cap 4 workers" claim from
22+
director prompt text (no such cap exists anywhere in the fleet code). The
23+
unused `maxTurns` field on project/named profile files
24+
(`.corbits/profile.json`, `~/.corbits/profiles/<name>.json`) has been
25+
removed since nothing read it — a silently-ignored knob is worse than no
26+
knob.
27+
1828
- `evaluateSubAgentStop` now always requires the final assistant text; the
1929
omitted-text branch that unconditionally completed a tool-less turn is
2030
removed, so every call path gets the `incomplete-report` nudge and salvage

docs/IMPLEMENTATION.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ OpenAI-compatible `baseURL` values are normalized during provider resolution. A
286286

287287
### Profiles (`src/config/profiles.ts`)
288288

289-
Profiles supply per-project or named-profile overrides for `model`, `maxTurns`, and `systemPromptExtensions` (the only allowed keys; any other key is rejected on load).
289+
Profiles supply per-project or named-profile overrides for `model` and `systemPromptExtensions` (the only allowed keys; any other key is rejected on load).
290290

291291
- Project profile: `.corbits/profile.json` in the repo root — committed, credential-free.
292292
- Named profiles: `~/.corbits/profiles/<name>.json` — user-level overrides, inherited via the `profile` key or the `--profile` flag.
@@ -295,12 +295,11 @@ Profiles supply per-project or named-profile overrides for `model`, `maxTurns`,
295295
{
296296
"profile": "work",
297297
"model": "claude-opus-4-8",
298-
"maxTurns": 50,
299298
"systemPromptExtensions": ["no-destructive-migrations"]
300299
}
301300
```
302301

303-
`resolveProfile` merges a named profile with the project profile, with **project profile field values overriding the named profile's**. The resolved `model` / `maxTurns` feed into provider resolution and the director; `systemPromptExtensions` are appended to the system prompt. Workflow profile metadata is deprecated because workflows are started only by explicit slash commands. CLI flags (`--model`, `--profile`) still win over profile values during config resolution.
302+
`resolveProfile` merges a named profile with the project profile, with **project profile field values overriding the named profile's**. The resolved `model` feeds into provider resolution and the director; `systemPromptExtensions` are appended to the system prompt. Workflow profile metadata is deprecated because workflows are started only by explicit slash commands. CLI flags (`--model`, `--profile`) still win over profile values during config resolution.
304303

305304
### Provider Configuration
306305

src/agent/directors/skywalker/package.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Before responding, classify:
9797
9898
Tiny / single-file / one-route / clear bounded edit: write_file/edit_file/delete_file on this session. Do not spawn.
9999
100-
Substantial / multi-file / parallel lanes / long-running: spawn build (hard cap 4). Keep long-blocking jobs off the parent so Enter can steer.
100+
Substantial / multi-file / parallel lanes / long-running: spawn build. Keep long-blocking jobs off the parent so Enter can steer.
101101
102102
Docs/design (PRODUCT.md, ARCHITECTURE.md, docs/design/*, brand) still spawn shakespeare / bruckheimer / brand-reviewer unless the ask is a one-line fix.
103103

src/agent/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function buildHarnessFacts(
6464
"- Change files with write_file/edit_file and remove files with delete_file; shell file-writes and deletions are blocked.",
6565
]
6666
: [
67-
"- Change files with write_file/edit_file and remove files with delete_file for tiny/single-file/one-route bounded edits. Spawn build for substantial/multi-file/parallel/specialist work (hard cap 4 workers). Docs/design still spawn shakespeare/bruckheimer/brand-reviewer except one-line fixes.",
67+
"- Change files with write_file/edit_file and remove files with delete_file for tiny/single-file/one-route bounded edits. Spawn build for substantial/multi-file/parallel/specialist work. Docs/design still spawn shakespeare/bruckheimer/brand-reviewer except one-line fixes.",
6868
"- Shell file-writes and deletions are blocked; never use echo/heredoc/sed/rm as a substitute for product tools. Path tools are the DIY surface.",
6969
]),
7070
"- Use the provided tools for file reads/searches instead of shelling out as a substitute.",

src/config.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -768,12 +768,11 @@ describe("loadConfig", () => {
768768
await mkdir(join(cwd, ".corbits"), { recursive: true });
769769
await writeFile(
770770
join(cwd, ".corbits", "profile.json"),
771-
JSON.stringify({ model: "profile-model", maxTurns: 25, systemPromptExtensions: ["ext1"] }),
771+
JSON.stringify({ model: "profile-model", systemPromptExtensions: ["ext1"] }),
772772
);
773773
const config = await loadConfig(["--cwd", cwd, "task"], { globalSettingsPath: globalPath });
774774
assertConfigured(config);
775775
expect(config.model).toBe("profile-model");
776-
expect(config.maxTurns).toBe(25);
777776
expect(config.systemPromptExtensions).toEqual(["ext1"]);
778777
} finally {
779778
await rm(cwd, { recursive: true, force: true });

src/config/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,6 @@ export interface Config {
323323
providers: ProviderCatalogEntry[];
324324
profile?: string;
325325
systemPromptExtensions?: string[];
326-
maxTurns?: number;
327326
// Per-call inactivity timeout in ms (default 120_000 in the harness). Tune
328327
// higher for reasoning models with long silent-thinking stretches.
329328
inactivityTimeoutMs?: number;
@@ -768,7 +767,6 @@ export async function loadConfig(
768767
...(profile.systemPromptExtensions !== undefined
769768
? { systemPromptExtensions: profile.systemPromptExtensions }
770769
: {}),
771-
...(profile.maxTurns !== undefined ? { maxTurns: profile.maxTurns } : {}),
772770
...(profile.inactivityTimeoutMs !== undefined
773771
? { inactivityTimeoutMs: profile.inactivityTimeoutMs }
774772
: {}),

src/config/profiles.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { SETTINGS_DIR_NAME } from "../branding.js";
77
const ProfileSchema = type({
88
"profile?": "string",
99
"model?": "string",
10-
"maxTurns?": "number.integer >= 1",
1110
"systemPromptExtensions?": "string[]",
1211
"workflow?": "string",
1312
// Per-call inactivity timeout in milliseconds. If the provider yields no
@@ -87,7 +86,6 @@ export async function resolveProfile(cwd: string, profileName?: string): Promise
8786
const merged: ProfileConfig = { ...namedProfile };
8887
if (projectProfile !== null && projectProfile !== undefined) {
8988
if (projectProfile.model !== undefined) merged.model = projectProfile.model;
90-
if (projectProfile.maxTurns !== undefined) merged.maxTurns = projectProfile.maxTurns;
9189
if (projectProfile.systemPromptExtensions !== undefined) {
9290
merged.systemPromptExtensions = projectProfile.systemPromptExtensions;
9391
}

src/profiles.test.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ test("loadProfile parses valid profile", async () => {
3030
const dir = makeTmp();
3131
await mkdir(dir, { recursive: true });
3232
const path = join(dir, "profile.json");
33-
await writeFile(path, JSON.stringify({ model: "claude-opus-4-8", maxTurns: 50 }));
33+
await writeFile(path, JSON.stringify({ model: "claude-opus-4-8" }));
3434
const result = await loadProfile(path);
35-
expect(result).toEqual({ model: "claude-opus-4-8", maxTurns: 50 });
35+
expect(result).toEqual({ model: "claude-opus-4-8" });
3636
});
3737

3838
test("loadProfile parses systemPromptExtensions", async () => {
@@ -52,14 +52,6 @@ test("loadProfile rejects unknown keys", async () => {
5252
await expect(loadProfile(path)).rejects.toThrow(/unknownKey must be removed/);
5353
});
5454

55-
test("loadProfile rejects invalid maxTurns", async () => {
56-
const dir = makeTmp();
57-
await mkdir(dir, { recursive: true });
58-
const path = join(dir, "profile.json");
59-
await writeFile(path, JSON.stringify({ maxTurns: -1 }));
60-
await expect(loadProfile(path)).rejects.toThrow(/maxTurns/);
61-
});
62-
6355
test("loadProfile rejects non-array systemPromptExtensions", async () => {
6456
const dir = makeTmp();
6557
await mkdir(dir, { recursive: true });
@@ -89,11 +81,11 @@ test("resolveProfile applies project profile fields", async () => {
8981
await mkdir(dir, { recursive: true });
9082
await writeFile(
9183
join(dir, "profile.json"),
92-
JSON.stringify({ model: "claude-sonnet", maxTurns: 30 }),
84+
JSON.stringify({ model: "claude-sonnet", systemPromptExtensions: ["ext1"] }),
9385
);
9486
const result = await resolveProfile(cwd);
9587
expect(result.model).toBe("claude-sonnet");
96-
expect(result.maxTurns).toBe(30);
88+
expect(result.systemPromptExtensions).toEqual(["ext1"]);
9789
});
9890

9991
test("resolveProfile surfaces profile name when set", async () => {
@@ -112,7 +104,7 @@ test("resolveProfile: project profile fields override named profile fields", asy
112104
await mkdir(namedDir, { recursive: true });
113105
await writeFile(
114106
join(namedDir, "work.json"),
115-
JSON.stringify({ model: "base-model", maxTurns: 10 }),
107+
JSON.stringify({ model: "base-model", systemPromptExtensions: ["ext1"] }),
116108
);
117109
const localDir = join(cwd, ".corbits");
118110
await mkdir(localDir, { recursive: true });
@@ -128,8 +120,7 @@ test("resolveProfile: project profile fields override named profile fields", asy
128120
const namedProfile = await loadProfile(join(namedDir, "work.json"));
129121
const merged = { ...namedProfile };
130122
if (projectProfile?.model !== undefined) merged.model = projectProfile.model;
131-
if (projectProfile?.maxTurns !== undefined) merged.maxTurns = projectProfile.maxTurns;
132123
expect(merged.model).toBe("override-model");
133-
// maxTurns not in project profile so named profile value survives
134-
expect(merged.maxTurns).toBe(10);
124+
// systemPromptExtensions not in project profile so named profile value survives
125+
expect(merged.systemPromptExtensions).toEqual(["ext1"]);
135126
});

src/subagent/agent-fleet.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ function fleetResult(callId: string, content: string): ToolResult {
283283
}
284284

285285
/** Resolve agent=/intent= to a closed director. Mirrors task()'s director-only branch. */
286-
function resolveDirectorDispatch(
286+
export function resolveDirectorDispatch(
287287
agentId: string | undefined,
288288
intent: TaskIntent | undefined,
289289
):
@@ -293,6 +293,7 @@ function resolveDirectorDispatch(
293293
systemPromptRole: string;
294294
capabilities: ReturnType<typeof packageToCapabilities>;
295295
roleDefault: ReturnType<typeof defaultEffortForDirector>;
296+
profileMaxTurns: number | undefined;
296297
}
297298
| { ok: false; error: string } {
298299
if (agentId !== undefined && agentId.length > 0) {
@@ -311,6 +312,7 @@ function resolveDirectorDispatch(
311312
systemPromptRole: formatDirectorSystemPrompt(pkg),
312313
capabilities: packageToCapabilities(pkg),
313314
roleDefault: defaultEffortForDirector(pkg),
315+
profileMaxTurns: pkg.nudge?.maxTurns,
314316
};
315317
}
316318
if (intent !== undefined) {
@@ -323,6 +325,7 @@ function resolveDirectorDispatch(
323325
systemPromptRole: formatDirectorSystemPrompt(pkg),
324326
capabilities: packageToCapabilities(pkg),
325327
roleDefault: defaultEffortForDirector(pkg),
328+
profileMaxTurns: pkg.nudge?.maxTurns,
326329
};
327330
}
328331
return {
@@ -383,6 +386,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
383386
const resolvedMaxTurns = resolveSubAgentMaxTurns({
384387
...(settings !== undefined ? { settings } : {}),
385388
...(taskMaxTurns !== undefined ? { taskMaxTurns } : {}),
389+
...(resolved.profileMaxTurns !== undefined
390+
? { profileMaxTurns: resolved.profileMaxTurns }
391+
: {}),
386392
});
387393

388394
let provider: SubAgentProvider = resolveDep(deps.provider);

src/subagent/spawn-budget.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { resolveSubAgentMaxTurns } from "../config/settings.js";
3+
import { resolveDirector } from "../agent/directors/registry.js";
4+
import { resolveDirectorDispatch } from "./agent-fleet.js";
5+
6+
describe("spawn_agent vs task() turn budget parity", () => {
7+
for (const id of ["intern", "explore", "build", "critique", "greybeard"]) {
8+
test(`${id}: spawn_agent and task() resolve the same finite budget`, () => {
9+
const resolved = resolveDirector({ agentId: id });
10+
expect(resolved.ok).toBe(true);
11+
const pkgMax = resolved.ok ? resolved.package.nudge?.maxTurns : undefined;
12+
expect(Number.isFinite(pkgMax)).toBe(true);
13+
14+
// task() path: passes profileMaxTurns (task-tool.ts:595)
15+
const viaTask = resolveSubAgentMaxTurns({ profileMaxTurns: pkgMax as number });
16+
expect(viaTask).toBe(pkgMax as number);
17+
18+
// spawn_agent path: resolveDirectorDispatch now surfaces the same
19+
// package budget, and agent-fleet.ts threads it through.
20+
const dispatch = resolveDirectorDispatch(id, undefined);
21+
expect(dispatch.ok).toBe(true);
22+
const viaSpawn = resolveSubAgentMaxTurns({
23+
...(dispatch.ok && dispatch.profileMaxTurns !== undefined
24+
? { profileMaxTurns: dispatch.profileMaxTurns }
25+
: {}),
26+
});
27+
expect(viaSpawn).toBe(pkgMax as number);
28+
expect(viaSpawn).toBe(viaTask);
29+
});
30+
}
31+
32+
test("non-director dispatch with no explicit maxTurns remains unbounded", () => {
33+
// No agent/intent resolved to a director means no package budget exists;
34+
// this is intentional and must not gain a default cap.
35+
const viaSpawn = resolveSubAgentMaxTurns({});
36+
expect(viaSpawn).toBe(Infinity);
37+
});
38+
});

0 commit comments

Comments
 (0)