From 3822fb6ef3c958e214ce75eecfb2b05960bd2476 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 22:19:51 -0700 Subject: [PATCH 1/2] Remove the sub-agent maxTurns hard cap of 100 Long jobs need budgets above 100; the soft default of 30 and the integer floor of 1 stay. Omit=unbounded is deferred. --- CHANGELOG.md | 6 +++++ docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 2 +- docs/PRODUCT.md | 2 +- src/agent/profile-types.ts | 2 +- src/agent/prompts.ts | 2 +- src/config/settings.ts | 12 +++------ src/settings.test.ts | 55 +++++++++++++++++++++++++++++++++----- src/subagent/index.test.ts | 32 +++++++++++++++++++--- src/subagent/task-tool.ts | 2 +- 10 files changed, 92 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4dd6c34..949b7b1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Sub-agents + +- **Sub-agent `maxTurns` no longer hard-caps at 100.** Default remains 30 when + unset; values must still be integers ≥1. `task(maxTurns)`, profile + `maxTurns`, and `settings.subagentMaxTurns` may exceed 100 for long jobs. + ### TUI - **Taller live chain-of-thought preview.** Parent reasoning still paints diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 56b367063..e9907674b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,7 +106,7 @@ In TUI chat mode there is no completion gate — the session stays open across t Two directors, selected by role: - **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. -- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once then salvages as **incomplete-report**. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 5 consecutive identical tool-call fingerprints (**no-progress**, mirroring the director-level `IDENTICAL_REPEAT_MIN` threshold), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely: +- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once then salvages as **incomplete-report**. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 5 consecutive identical tool-call fingerprints (**no-progress**, mirroring the director-level `IDENTICAL_REPEAT_MIN` threshold), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`; floor ≥1, no hard upper cap), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely: `runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — format chars (ZWSP, BOM, bidi marks, soft hyphen, …) stripped then whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. `inference.thinking.delta` is sampled the same way on its own buffer, but with digit runs folded to one placeholder and a shorter window (>= 4 chars repeated >= 32 times), gated to periods <= 16 chars once folded: thinking is never rendered to the user, so a monotonic counter (e.g. `0/1 1/2 2/3 …`, which stays non-periodic and escapes the raw-text check) can be caught, but folding still erases real information — a healthy templated enumeration line becomes byte-identical to its neighbors once digits are erased, so the period-length cap only lets counter-shaped folded periods (a handful of chars) through and refuses the much longer periods a folded prose line produces. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. When both report-forced and re-read-nudge apply, report-forced wins (near-budget wrap-up is more urgent than a mid-run redirect). Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`): fingerprints cover prompt + agent + intent + success_criteria + do_not (not maxTurns/description/tier). After thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is hard-blocked for the rest of the parent chat; change at least one fingerprint field to force a re-run. Turn-budget salvage still invites a higher maxTurns for a few same-brief retries without a successful complete, then flips the parent hint to stop and change approach (soft — further identical dispatches are still admitted). A successful complete resets the same-brief retry budget. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 5d6700d78..b2d8e9085 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -246,7 +246,7 @@ Provider and model configuration lives in JSON settings files. The global file h On expiry the call returns a normal tool-error result ("MCP tool `` timed out after ``s — the server may be wedged; retry or continue without it") that the model can react to; the turn itself is never aborted. `tools.maxTimeoutMs`, if set, still caps `mcp.timeoutMs`. - Optional `subagentMaxTurns` (integer **1–100**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; values above **100** are rejected on `task` and clamped for profiles. Always applies — the primary session is always orchestrator-capable (CL-5814). + Optional `subagentMaxTurns` (integer **≥1**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; there is no hard upper cap (values are floor-sanitized to ≥1). Always applies — the primary session is always orchestrator-capable (CL-5814). Optional `sessionMode` is **deprecated**. Legacy values (`single` | `orchestrator`) may still appear on disk and load without error; resolve always returns **orchestrator**. There is no first-run mode picker and no Settings row. Both the interactive TUI (`runTUI`) and the non-TUI product path (`runExec` / `corbits exec`) are orchestrator-only. Exec bootstrap is otherwise a forked copy of the TUI path (shared stack, intentional deltas documented under Architecture → Exec Runner). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 51083fc28..c4d2af273 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -159,7 +159,7 @@ Corbits Code fans work out to short-lived **sub-agents** — child agents with t - **Tasks** are checklist items owned by one agent via `manage_tasks`. - **Sub-agents** are spawned with the `task` tool (wire name kept; meaning is "spawn a child agent," not "add a checklist item"). -Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; cap 100), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). Progressive re-read thrash also hard-stops a worker that keeps re-reading the same path (or the same grep) past a limit. Look _volume_ is not a stop — an implement may read hundreds of files before the first edit. Before a hard stop, a soft mid-run nudge asks implement workers to edit or wrap up (explore workers: expand findings / change approach — never forced to edit). Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. +Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; no hard upper cap), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). Progressive re-read thrash also hard-stops a worker that keeps re-reading the same path (or the same grep) past a limit. Look _volume_ is not a stop — an implement may read hundreds of files before the first edit. Before a hard stop, a soft mid-run nudge asks implement workers to edit or wrap up (explore workers: expand findings / change approach — never forced to edit). Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. The parent tracks same-brief fingerprints for the session (`src/subagent/brief-dispatch.ts`): after thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is refused — change prompt, agent, intent, success_criteria, and/or do_not to unlock a new run (`maxTurns` or tier alone does not). Turn-budget salvage still allows a few same-brief retries with a higher `maxTurns`, then flips the parent hint to stop and change approach; a successful complete resets the same-brief retry budget. ## Roadmap (planned, not yet shipped) diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index ab6063fbf..1e65de6f6 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -74,7 +74,7 @@ export interface AgentProfile { // leaf-task agents should leave this unset. orchestrator?: boolean; // Optional inference-turn budget when this profile is dispatched via task(agent=...). - // Clamped to the global cap at dispatch time; task(maxTurns) overrides when set. + // Floor-sanitized (≥1) at dispatch time; task(maxTurns) overrides when set. maxTurns?: number; // Where the profile came from, for search_agents labeling (e.g. "claude", // "plugin:", "local"). Omitted for built-in defaults. diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 2e176be38..ff02c512e 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -152,7 +152,7 @@ export function buildGuidelines( "- Break multi-step or parallel work into focused `task` dispatches with distinct lenses; prefer several parallel task calls when jobs are independent.", "- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.", "- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.", - "- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, cap 100). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.", + "- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, no hard upper cap). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.", "- After thrash / no-progress / repetition / never-acted salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.", "- Use manage_tasks for your own coordination checklist; spawning workers is `task`, not manage_tasks.", "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", diff --git a/src/config/settings.ts b/src/config/settings.ts index 4350398d5..c6a052e88 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -284,11 +284,11 @@ export function shellEnvFromSettings( } export const DEFAULT_SUBAGENT_MAX_TURNS = 30; -export const MAX_SUBAGENT_MAX_TURNS_CAP = 100; +/** Floor-only sanitization: ≥1 integer. No upper hard cap. */ export function clampSubAgentMaxTurns(value: number): number { if (!Number.isFinite(value)) return DEFAULT_SUBAGENT_MAX_TURNS; - return Math.min(MAX_SUBAGENT_MAX_TURNS_CAP, Math.max(1, Math.floor(value))); + return Math.max(1, Math.floor(value)); } export function resolveDefaultSubAgentMaxTurns(settings?: Settings | null): number { @@ -307,12 +307,6 @@ export function validateTaskMaxTurns(value: number): TaskMaxTurnsValidation { if (value < 1) { return { ok: false, message: "maxTurns must be at least 1." }; } - if (value > MAX_SUBAGENT_MAX_TURNS_CAP) { - return { - ok: false, - message: `maxTurns cannot exceed ${MAX_SUBAGENT_MAX_TURNS_CAP}.`, - }; - } return { ok: true, value }; } @@ -531,7 +525,7 @@ export function isSettings(value: unknown): value is Settings { if (s.mcpServers !== undefined && normalizeMcpServers(s.mcpServers) === undefined) return false; if (s.subagentMaxTurns !== undefined) { const n = s.subagentMaxTurns; - if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_SUBAGENT_MAX_TURNS_CAP) { + if (typeof n !== "number" || !Number.isInteger(n) || n < 1) { return false; } } diff --git a/src/settings.test.ts b/src/settings.test.ts index 2377fe89d..9ffa5578a 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -17,7 +17,6 @@ import { saveLocalSettings, type Settings, DEFAULT_SUBAGENT_MAX_TURNS, - MAX_SUBAGENT_MAX_TURNS_CAP, resolveDefaultSubAgentMaxTurns, resolveSubAgentMaxTurns, clampSubAgentMaxTurns, @@ -947,17 +946,35 @@ describe("subagentMaxTurns", () => { expect(resolveSubAgentMaxTurns({ settings, profileMaxTurns: 55, taskMaxTurns: 70 })).toBe(70); }); - test("clampSubAgentMaxTurns enforces floor and cap", () => { + test("clampSubAgentMaxTurns enforces floor only", () => { expect(clampSubAgentMaxTurns(0)).toBe(1); - expect(clampSubAgentMaxTurns(150)).toBe(MAX_SUBAGENT_MAX_TURNS_CAP); + expect(clampSubAgentMaxTurns(-5)).toBe(1); + expect(clampSubAgentMaxTurns(150)).toBe(150); + expect(clampSubAgentMaxTurns(500)).toBe(500); }); - test("validateTaskMaxTurns rejects out of range", () => { - expect(validateTaskMaxTurns(101).ok).toBe(false); + test("validateTaskMaxTurns accepts values above 100 and rejects below 1", () => { + const high = validateTaskMaxTurns(500); + expect(high.ok).toBe(true); + if (high.ok) { + expect(high.value).toBe(500); + } + expect(validateTaskMaxTurns(101).ok).toBe(true); expect(validateTaskMaxTurns(0).ok).toBe(false); + expect(validateTaskMaxTurns(-1).ok).toBe(false); expect(validateTaskMaxTurns(50).ok).toBe(true); }); + test("resolveSubAgentMaxTurns keeps high task and profile budgets", () => { + expect(resolveSubAgentMaxTurns({ taskMaxTurns: 500 })).toBe(500); + expect(resolveSubAgentMaxTurns({ profileMaxTurns: 250 })).toBe(250); + expect( + resolveSubAgentMaxTurns({ + settings: { providers: {}, subagentMaxTurns: 400 }, + }), + ).toBe(400); + }); + test("loadSettings round-trips subagentMaxTurns", async () => { const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); try { @@ -969,6 +986,17 @@ describe("subagentMaxTurns", () => { } }); + test("loadSettings round-trips subagentMaxTurns above 100", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await saveGlobalSettings(path, { ...firepass, subagentMaxTurns: 500 }); + expect(await loadSettings(path)).toEqual({ ...firepass, subagentMaxTurns: 500 }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + test("rejects invalid subagentMaxTurns in settings", () => { expect( isSettings({ @@ -979,10 +1007,25 @@ describe("subagentMaxTurns", () => { expect( isSettings({ providers: firepass.providers, - subagentMaxTurns: 101, + subagentMaxTurns: 1.5, }), ).toBe(false); }); + + test("accepts subagentMaxTurns above 100 in settings", () => { + expect( + isSettings({ + providers: firepass.providers, + subagentMaxTurns: 500, + }), + ).toBe(true); + expect( + isSettings({ + providers: firepass.providers, + subagentMaxTurns: 101, + }), + ).toBe(true); + }); }); describe("saveGlobalSettings", () => { diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 4630b273d..4dbb17c07 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -1727,7 +1727,31 @@ describe("createTaskTool", () => { expect(out).toContain("unavailable"); }); - test("rejects task maxTurns above the cap", async () => { + test("accepts task maxTurns above 100", async () => { + let captured: RunSubAgentParams | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.corbits", + provider, + run: async (params) => { + captured = params; + return "done"; + }, + }); + + const result = await callTask(tool, { + description: "Long job", + prompt: "Work", + maxTurns: 500, + intent: "explore", + }); + + expect(result).not.toContain("Error:"); + expect(captured?.maxTurns).toBe(500); + }); + + test("rejects task maxTurns below 1", async () => { const tool = createTaskTool({ permissionGate: testPermissionGate, cwd: "/repo", @@ -1737,14 +1761,14 @@ describe("createTaskTool", () => { }); const result = await callTask(tool, { - description: "Too long", + description: "Too short", prompt: "Work", - maxTurns: 101, + maxTurns: 0, intent: "explore", }); expect(result).toContain("Error:"); - expect(result).toContain("100"); + expect(result).toContain("at least 1"); }); test("uses profile maxTurns when task omits maxTurns", async () => { diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 431949922..e03efe48c 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -127,7 +127,7 @@ export const taskToolDefinition: ToolDefinition = { maxTurns: { type: "number", description: - "Optional inference-turn budget for this worker only (not the parent session limit). Defaults to settings or 30; hard cap 100.", + "Optional inference-turn budget for this worker only (not the parent session limit). Defaults to settings or 30; minimum 1.", }, }, required: ["description", "prompt"], From 7593c611a14593b16a093ff503d8a9387e428788 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 05:33:42 -0700 Subject: [PATCH 2/2] Keep the maxTurns changelog note under Unreleased 0.2.104 is already tagged. Folding this into that section would claim a released cut contains work that is not in it. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27827e0ed..c591450ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. -## [0.2.104] - 2026-08-23 +## [Unreleased] ### Sub-agents @@ -19,6 +19,7 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename unset; values must still be integers ≥1. `task(maxTurns)`, profile `maxTurns`, and `settings.subagentMaxTurns` may exceed 100 for long jobs. +## [0.2.104] - 2026-08-23 ### TUI - **Taller live chain-of-thought preview.** Parent reasoning still paints