diff --git a/CHANGELOG.md b/CHANGELOG.md index 173a9d3bc..7e3d0a8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename format, so it was declared but unreachable. Fail-closed behavior is unchanged — a profile-sourced orchestrator is still denied `task`/ `search_agents` with no supported opt-in. +- Removed the default 30-turn leaf sub-agent ceiling; an unset `maxTurns` now runs unbounded (explicit budgets still apply). +- Deleted two unenforced orchestrator prompt rules: a "4 workers at once" fan-out cap and a same-agent lane-disjointness rule. ## [0.2.108] - 2026-08-24 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f50d0b42..bd82a59b1 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` / `apply_patch`, **and** from file work done through `run_shell` — `sed -i`, redirection, `tee`, `cp`/`mv` — classified by `classifyShellFileEvidence` in `src/shell/run-shell-authz.ts` over the same subject expansion the auto-shell policy uses, so a worker that edits with shell is not reported as having done nothing (CL-6937)). 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) 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 looping child cannot burn tokens indefinitely. Re-read counts are **not** a stop signal: `src/subagent/thrash.ts` keeps read/edit bookkeeping only to serve the `requireEdit` / `requireEvidence` checks above, because the fingerprint period detector already catches a genuinely repeating read cycle on the evidence that it repeats, while a raw count cannot separate four reads across real progress from four reads in a loop (CL-6936). A third 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` / `apply_patch`, **and** from file work done through `run_shell` — `sed -i`, redirection, `tee`, `cp`/`mv` — classified by `classifyShellFileEvidence` in `src/shell/run-shell-authz.ts` over the same subject expansion the auto-shell policy uses, so a worker that edits with shell is not reported as having done nothing (CL-6937)). 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) or after the leaf turn budget (**turn-budget**, unbounded by default, opt in 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 looping child cannot burn tokens indefinitely. Re-read counts are **not** a stop signal: `src/subagent/thrash.ts` keeps read/edit bookkeeping only to serve the `requireEdit` / `requireEvidence` checks above, because the fingerprint period detector already catches a genuinely repeating read cycle on the evidence that it repeats, while a raw count cannot separate four reads across real progress from four reads in a loop (CL-6936). A third 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. 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/PRODUCT.md b/docs/PRODUCT.md index 412e24f62..68a87739e 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; 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). File work done through the shell counts as real work here even though the prompt asks for the typed tools: a prompt violation earns a correction, not a verdict that the work never happened. Re-read counts never hard-stop a worker, and look _volume_ is not a stop either — an implement may read hundreds of files before the first edit, and a repeating read cycle is caught by fingerprint detection instead. Near the turn budget a one-shot nudge asks the worker to wrap up and write its report. 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 (no default cap; unbounded unless `maxTurns` is set per dispatch, by profile, or by `settings.subagentMaxTurns`), 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). File work done through the shell counts as real work here even though the prompt asks for the typed tools: a prompt violation earns a correction, not a verdict that the work never happened. Re-read counts never hard-stop a worker, and look _volume_ is not a stop either — an implement may read hundreds of files before the first edit, and a repeating read cycle is caught by fingerprint detection instead. Near the turn budget a one-shot nudge asks the worker to wrap up and write its report. 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 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/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index f0363acb3..dfe12bc5d 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -97,8 +97,6 @@ describe("skywalkerPackage", () => { expect(p).toContain("fan-out"); expect(p).toContain("0–1 worker"); expect(p).toContain("2–4 workers"); - expect(p).toContain("split ownership by path/package"); - expect(p).toContain("at most 4 workers at once"); }); test("systemPrompt anti-cascade keeps digs out of fleets", () => { diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 1501d061a..5bfa987dd 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -61,8 +61,7 @@ Scale fan-out to the ask — do not spawn 10+ workers for a simple request: - Tiny single-file / one-route asks: **DIY on the parent** with write_file/edit_file; skip spawn, skip explore, skip critique. Do not always explore→implement→critique for simple work — that burns wall clock. - Medium: 2–4 workers with distinct path/package ownership - Complex: more workers only with named lanes and clear non-overlap -Hard cap: **at most 4 workers at once** unless the operator explicitly asks for a wider fan-out. Prefer synthesizing early returns over launching a second wave. -Cap default fan-out. Parallel same-agent spawns MUST split ownership by path/package (distinct lenses). +Prefer synthesizing early returns over launching a second wave. # Anti-cascade (stall / dig / diagnose) diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index e008b6a17..f673128ab 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, 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.", + "- Pass `maxTurns` on `task` when a job needs a bounded inference budget (unset is unbounded). 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 7bf098c48..56883a34a 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -285,17 +285,16 @@ export function shellEnvFromSettings( return local?.env; } -export const DEFAULT_SUBAGENT_MAX_TURNS = 30; - /** Floor-only sanitization: ≥1 integer. No upper hard cap. */ export function clampSubAgentMaxTurns(value: number): number { - if (!Number.isFinite(value)) return DEFAULT_SUBAGENT_MAX_TURNS; + if (!Number.isFinite(value)) return Infinity; return Math.max(1, Math.floor(value)); } +/** No explicit subagentMaxTurns means unbounded; operators opt in to a ceiling. */ export function resolveDefaultSubAgentMaxTurns(settings?: Settings | null): number { if (settings?.subagentMaxTurns === undefined) { - return DEFAULT_SUBAGENT_MAX_TURNS; + return Infinity; } return clampSubAgentMaxTurns(settings.subagentMaxTurns); } diff --git a/src/settings.test.ts b/src/settings.test.ts index 9ffa5578a..ddfad9e68 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -16,7 +16,6 @@ import { saveGlobalSettings, saveLocalSettings, type Settings, - DEFAULT_SUBAGENT_MAX_TURNS, resolveDefaultSubAgentMaxTurns, resolveSubAgentMaxTurns, clampSubAgentMaxTurns, @@ -934,9 +933,9 @@ describe("lastChangelogVersion", () => { }); describe("subagentMaxTurns", () => { - test("defaults to 30 when unset", () => { - expect(resolveDefaultSubAgentMaxTurns(null)).toBe(DEFAULT_SUBAGENT_MAX_TURNS); - expect(resolveDefaultSubAgentMaxTurns({ providers: {} })).toBe(30); + test("is unbounded when unset", () => { + expect(resolveDefaultSubAgentMaxTurns(null)).toBe(Infinity); + expect(resolveDefaultSubAgentMaxTurns({ providers: {} })).toBe(Infinity); }); test("resolveSubAgentMaxTurns precedence", () => { diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 5584b21b9..57e78a497 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -10,7 +10,6 @@ import { createSubAgentRunController, createSubAgentSessionStore, createSubAgentSpawnRegistryPlugin, - DEFAULT_SUBAGENT_MAX_TURNS, DEFAULT_SUBAGENT_REPEAT_LIMIT, disposeSubAgentSession, evaluateSubAgentStop, @@ -148,14 +147,12 @@ describe("sub-agent teardown", () => { }); describe("sub-agent stop helpers", () => { - test("default turn budget is tight enough to bound runaway cost", () => { - expect(DEFAULT_SUBAGENT_MAX_TURNS).toBe(30); - expect(subAgentTurnLimitExceeded(DEFAULT_SUBAGENT_MAX_TURNS, DEFAULT_SUBAGENT_MAX_TURNS)).toBe( - true, - ); - expect( - subAgentTurnLimitExceeded(DEFAULT_SUBAGENT_MAX_TURNS - 1, DEFAULT_SUBAGENT_MAX_TURNS), - ).toBe(false); + const TEST_MAX_TURNS = 30; + + test("explicit turn budget hard-stops at the limit; unbounded (Infinity) never does", () => { + expect(subAgentTurnLimitExceeded(TEST_MAX_TURNS, TEST_MAX_TURNS)).toBe(true); + expect(subAgentTurnLimitExceeded(TEST_MAX_TURNS - 1, TEST_MAX_TURNS)).toBe(false); + expect(subAgentTurnLimitExceeded(1_000_000, Infinity)).toBe(false); }); test("no-progress trips at the default repeat limit", () => { @@ -174,7 +171,7 @@ describe("sub-agent stop helpers", () => { hasToolCalls: true, everHadToolCalls: true, turnsCompleted: consecutive, - maxTurns: DEFAULT_SUBAGENT_MAX_TURNS, + maxTurns: TEST_MAX_TURNS, consecutiveIdentical: consecutive, repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT, }), @@ -189,7 +186,7 @@ describe("sub-agent stop helpers", () => { hasToolCalls: true, everHadToolCalls: true, turnsCompleted: 6, - maxTurns: DEFAULT_SUBAGENT_MAX_TURNS, + maxTurns: TEST_MAX_TURNS, consecutiveIdentical: 6, repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT, }), @@ -1415,7 +1412,7 @@ describe("createTaskTool", () => { expect(result).toContain("done"); expect(captured).toBeDefined(); - expect(captured?.maxTurns).toBe(30); + expect(captured?.maxTurns).toBe(Infinity); }); test("uses settings subagentMaxTurns when task and profile omit maxTurns", async () => { diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 5d4c79096..d40c3d3fd 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -90,8 +90,6 @@ export { type TaskBriefFingerprintInput, } from "./brief-dispatch.js"; -export { DEFAULT_SUBAGENT_MAX_TURNS } from "../config/settings.js"; - export { SubAgentDirector } from "./nudge-director.js"; export { diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 26bb0cef2..b654583db 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -129,7 +129,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; minimum 1.", + "Optional inference-turn budget for this worker only (not the parent session limit). Unset is unbounded; minimum 1 when set.", }, }, required: ["description", "prompt"],