From bffe9b49b5d722085d873585f131b163058864e0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:28:07 -0700 Subject: [PATCH] Delete the turn-budget mechanism and deprecate task() No max turns anywhere. Peer coding agents have no turn cap: they end a run on the model's own finish signal, an operator interrupt, or a hard error. Ours ended runs on a turn count that truncated real work -- a measured healthy lane used 59 assistant turns with a clean report, which explore (35), critique (45) and every 40 would have killed. Removed: nudge.maxTurns from all 17 director packages, the whole resolution chain (resolveSubAgentMaxTurns, resolveDefaultSubAgentMaxTurns, clampSubAgentMaxTurns, validateTaskMaxTurns, settings.subagentMaxTurns), subAgentTurnLimitExceeded and the turn-budget stop reason, the TURN_BUDGET re-dispatch ledger branch, thrashForceReport/report-forced, AgentProfile.maxTurns and its schema, the maxTurns arg on task() and spawn_agent, the intervention-log state.maxTurns field, and the eval harness's global soft turn-budget rubric (evaluateSoftBudget/overBudget) which failed cases for exceeding a turn count. task() is marked deprecated in favour of spawn_agent/wait_agents. --agent-timeout-ms is retained as the per-eval bound. Prompt text that taught the model to operate the budget is replaced with guidance on sharpening a brief instead. --- CHANGELOG.md | 30 +- evals/capability/README.md | 5 - evals/capability/cases/tier-easy/case.json | 1 - evals/capability/cases/tier-hard/case.json | 1 - evals/capability/cases/tier-med/case.json | 1 - evals/capability/cases/tier-xhard/case.json | 1 - evals/capability/lib.test.ts | 32 - evals/capability/lib.ts | 40 -- scripts/eval-capability.ts | 32 +- scripts/intervention-forensics.ts | 22 +- .../directors/brand-reviewer/package.test.ts | 4 - src/agent/directors/brand-reviewer/package.ts | 1 - .../directors/bruckheimer/package.test.ts | 4 - src/agent/directors/bruckheimer/package.ts | 1 - src/agent/directors/build/package.ts | 1 - src/agent/directors/critique/package.test.ts | 4 - src/agent/directors/critique/package.ts | 1 - src/agent/directors/draper/package.test.ts | 4 - src/agent/directors/draper/package.ts | 1 - src/agent/directors/emil/package.test.ts | 4 - src/agent/directors/emil/package.ts | 1 - src/agent/directors/explore/package.test.ts | 5 - src/agent/directors/explore/package.ts | 3 +- src/agent/directors/gaasbot/package.test.ts | 4 - src/agent/directors/gaasbot/package.ts | 1 - src/agent/directors/greybeard/package.test.ts | 4 - src/agent/directors/greybeard/package.ts | 1 - src/agent/directors/intern/package.test.ts | 4 - src/agent/directors/intern/package.ts | 1 - src/agent/directors/neckbeard/package.test.ts | 4 - src/agent/directors/neckbeard/package.ts | 1 - src/agent/directors/plan/package.test.ts | 4 - src/agent/directors/plan/package.ts | 1 - src/agent/directors/registry.test.ts | 1 - src/agent/directors/registry.ts | 1 - .../directors/shakespeare/package.test.ts | 4 - src/agent/directors/shakespeare/package.ts | 1 - src/agent/directors/skywalker/package.test.ts | 4 - src/agent/directors/skywalker/package.ts | 1 - src/agent/directors/tester/package.test.ts | 4 - src/agent/directors/tester/package.ts | 1 - src/agent/directors/testsmith/package.test.ts | 4 - src/agent/directors/testsmith/package.ts | 1 - src/agent/directors/types.ts | 1 - src/agent/profile-types.ts | 3 - src/agent/profiles.ts | 1 - src/agent/prompts.ts | 4 +- src/config.test.ts | 1 - src/config/settings.ts | 55 -- src/plugins/agent-plugins.test.ts | 2 +- src/settings.test.ts | 99 --- src/subagent/agent-fleet.ts | 28 +- src/subagent/brief-dispatch.ts | 27 +- src/subagent/fleet-report.ts | 4 +- src/subagent/index.test.ts | 578 +----------------- src/subagent/index.ts | 15 +- src/subagent/intervention-log.test.ts | 16 +- src/subagent/intervention-log.ts | 14 +- src/subagent/nudge-director.test.ts | 50 -- src/subagent/nudge-director.ts | 77 +-- src/subagent/report.ts | 2 +- src/subagent/run.ts | 11 +- src/subagent/session-store.ts | 8 +- src/subagent/spawn-budget.test.ts | 38 -- src/subagent/stop-policy.ts | 91 +-- src/subagent/task-tool.ts | 52 +- src/subagent/thrash.test.ts | 62 -- src/subagent/thrash.ts | 84 +-- src/subagent/types.ts | 6 +- src/tui/tool-execution-watchdog.test.ts | 4 +- src/tui/tool-execution-watchdog.ts | 4 +- 71 files changed, 147 insertions(+), 1436 deletions(-) delete mode 100644 src/subagent/spawn-budget.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e47a367..1e920e6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,27 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Agent -- `spawn_agent` now threads the resolved director package's `nudge.maxTurns` - budget the same way `task()` does, closing a parity gap where a director - dispatched via `spawn_agent` resolved to an unbounded turn budget instead of - its configured finite one. Removed the false "hard cap 4 workers" claim from - director prompt text (no such cap exists anywhere in the fleet code). The - unused `maxTurns` field on project/named profile files - (`.corbits/profile.json`, `~/.corbits/profiles/.json`) has been - removed since nothing read it — a silently-ignored knob is worse than no - knob. +- Deleted the sub-agent turn-budget mechanism entirely: `maxTurns` is gone + from `task()`, `spawn_agent`, `AgentProfile`, director packages' + `nudge.maxTurns`, and `settings.subagentMaxTurns`; a leaf now runs until it + produces a report envelope, is cancelled, hits an opt-in wall-clock + deadline, or stalls — never on a turn count. Removed + `resolveSubAgentMaxTurns` / `resolveDefaultSubAgentMaxTurns` / + `clampSubAgentMaxTurns` / `validateTaskMaxTurns` (`src/config/settings.ts`), + the `turn-budget` stop reason and its report text/parent hint, the + near-budget `report-forced` wrap-up nudge, and the re-dispatch ledger's + turn-budget branch (the `higher maxTurns` / re-dispatch-cap hints). This + also retires the `spawn_agent`/`task()` `nudge.maxTurns` parity fix shipped + an hour prior — with the mechanism itself gone, that parity is moot. + `task()` is now marked deprecated in favor of `spawn_agent` + `wait_agents` + for new call sites; it is not removed since most dispatch still routes + through it. Removed the false "hard cap 4 workers" claim from director + prompt text (no such cap exists anywhere in the fleet code). The unused + `maxTurns` field on project/named profile files (`.corbits/profile.json`, + `~/.corbits/profiles/.json`) has been removed since nothing read it — + a silently-ignored knob is worse than no knob. If a run needs stopping, the + operator interrupts it (`interrupt_agent`) rather than the harness enforcing + a count. - `evaluateSubAgentStop` now always requires the final assistant text; the omitted-text branch that unconditionally completed a tool-less turn is diff --git a/evals/capability/README.md b/evals/capability/README.md index 8c630e46d..446b5ef50 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -84,7 +84,6 @@ Everything the product path already observes is recorded: | `turnsUsed` | turn collector | | `toolCallCount` | turn collector | | `tokenUsage` | `{ input, output, cacheRead, cacheWrite, thinking }` | -| `maxTurns` / `overBudget` | case budget vs turns used | | `provider` / `model` / `variantId` | resolved config for that cell (`variantId` is `provider:model` by default) | | `skipPermissions` | whether permissions were skipped | | `repeat` | 0-based repeat index within the case×variant cell | @@ -193,7 +192,6 @@ Flags: | `--out ` | Write machine-readable results JSON | | `--baseline ` | Compare this run to a prior results file (improve/regress + metric deltas) | | `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` | -| `--max-turns ` | Soft turn budget: case **fails** if `turnsUsed` exceeds, or if turns are not reported when a budget is set (fail closed). Does not hard-kill mid-run | | `--agent-timeout-ms ` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | | `--verify-timeout-ms ` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | | `--repeats ` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates | @@ -217,7 +215,6 @@ verify.sh # objective grader (exit 0 = pass) - `title` — human label - `fixture` — path relative to repo root (copied into a temp workdir) - `prompt` — task text for `corbits exec` -- `maxTurns` — optional soft turn budget; when set, the case **fails** if `turnsUsed` exceeds it (`overBudget: true`) **or** if `turnsUsed` was not reported (fail closed so a broken metrics path cannot pass a budgeted case). Not a hard mid-run kill (product path has no turn budget hook yet). - `verify` — grader filename (default `verify.sh`) - `bait` — optional `{ metric, threshold }` marking the behavior metric this case reproduces (see the bait table above) - `httpFixture` — when `true`, the runner starts a hermetic HTTP server on `127.0.0.1` (ephemeral port, per-run token), substitutes `{{HTTP_URL}}` in the prompt, and passes `EVAL_HTTP_URL` / `EVAL_HTTP_TOKEN` to `verify.sh`. The server is stopped when the case run ends — nothing external is contacted @@ -280,8 +277,6 @@ verify.sh # objective grader (exit 0 = pass) "cacheWrite": 0, "thinking": 0 }, - "maxTurns": 20, - "overBudget": false, "skipPermissions": true, "error": null, "repeat": 0, diff --git a/evals/capability/cases/tier-easy/case.json b/evals/capability/cases/tier-easy/case.json index b6e133872..68b750e9b 100644 --- a/evals/capability/cases/tier-easy/case.json +++ b/evals/capability/cases/tier-easy/case.json @@ -4,7 +4,6 @@ "title": "Add GET /version to a two-file service", "fixture": "tests/fixtures/tier-easy", "prompt": "Add GET /version to handleRequest in src/service.ts. It must return status 200 with body {\"version\":\"1.0.0\"}. Add a unit test for it under tests/. Keep the existing /health behavior working. Use the file-editing tools, not shell redirection or sed.", - "maxTurns": 15, "verify": "verify.sh", "requireBehaviors": [ { "metric": "editViaShellCount", "max": 0 }, diff --git a/evals/capability/cases/tier-hard/case.json b/evals/capability/cases/tier-hard/case.json index 9a9d2ec71..5359246fd 100644 --- a/evals/capability/cases/tier-hard/case.json +++ b/evals/capability/cases/tier-hard/case.json @@ -4,7 +4,6 @@ "title": "Crash implicates the wrong module; root cause is one hop away", "fixture": "tests/fixtures/tier-hard", "prompt": "bun test fails with a TypeError raised inside src/routes/report.ts. Fix it so the suite passes and the report totals are correct. Do not edit test expectations or EVAL_LOCK comments. Do not hardcode report totals.", - "maxTurns": 30, "verify": "verify.sh", "requireBehaviors": [ { "metric": "editViaShellCount", "max": 0 }, diff --git a/evals/capability/cases/tier-med/case.json b/evals/capability/cases/tier-med/case.json index bfd1c2a37..901d572c6 100644 --- a/evals/capability/cases/tier-med/case.json +++ b/evals/capability/cases/tier-med/case.json @@ -4,7 +4,6 @@ "title": "Fix the live fee amid three disagreeing decoy sources", "fixture": "tests/fixtures/tier-med", "prompt": "bun test is failing. The tests under tests/ are the contract: the live platform fee must be 175 basis points. Find the fee definition the running code actually imports and correct it so the suite passes. Do not edit test expectations or EVAL_LOCK comments. Do not hardcode order totals. Do not rewire imports to a different module to get green. Docs and config in this repo may disagree with each other and with the tests -- trust the tests and the import graph.", - "maxTurns": 25, "verify": "verify.sh", "requireBehaviors": [{ "metric": "editViaShellCount", "max": 0 }] } diff --git a/evals/capability/cases/tier-xhard/case.json b/evals/capability/cases/tier-xhard/case.json index cbb8dccd6..de29b33b9 100644 --- a/evals/capability/cases/tier-xhard/case.json +++ b/evals/capability/cases/tier-xhard/case.json @@ -4,7 +4,6 @@ "title": "Green functional suite, unshipped design (prod-shape rubric)", "fixture": "tests/fixtures/tier-xhard", "prompt": "bun test already passes. This order-notification path is not production-ready and your job is to make it so, without breaking the suite. Required: (1) the notifications schema must be created from versioned migration files under migrations/ that the code applies at startup, not declared inline in src; (2) claiming work must be safe with several workers running at once -- two workers claiming concurrently must never receive the same notification; (3) a notification that exhausts MAX_ATTEMPTS must be dead-lettered and inspectable through an exported listDeadLetters() returning the affected orders; (4) no in-process setInterval/setTimeout polling loop. Do not edit test expectations or EVAL_LOCK comments.", - "maxTurns": 40, "verify": "verify.sh", "requireBehaviors": [{ "metric": "editViaShellCount", "max": 0 }] } diff --git a/evals/capability/lib.test.ts b/evals/capability/lib.test.ts index 31835e3a0..4323bd591 100644 --- a/evals/capability/lib.test.ts +++ b/evals/capability/lib.test.ts @@ -14,7 +14,6 @@ import { makeResultKey, defaultVariantId, emptyTokenUsage, - evaluateSoftBudget, checkBehaviorRequirements, computeCellAggregates, baitReproduces, @@ -70,8 +69,6 @@ function sampleResult(over: Partial = {}): CaseResult { cacheWrite: 0, thinking: 0, }, - maxTurns: over.maxTurns ?? 20, - overBudget: over.overBudget ?? false, skipPermissions: over.skipPermissions ?? true, error: over.error ?? null, repeat: over.repeat ?? 0, @@ -114,7 +111,6 @@ describe("parseCaseJson", () => { ); expect(c.id).toBe("simple-health"); expect(c.verify).toBe("verify.sh"); - expect(c.maxTurns).toBeUndefined(); }); test("parses a bait case with http fixture", () => { @@ -404,34 +400,6 @@ describe("summarizeRun", () => { }); }); -describe("evaluateSoftBudget", () => { - test("null maxTurns means budget not in force", () => { - expect(evaluateSoftBudget({ maxTurns: null, turnsUsed: 99 })).toEqual({ - overBudget: null, - budgetError: null, - }); - }); - - test("fails closed when maxTurns set but turnsUsed missing", () => { - const r = evaluateSoftBudget({ maxTurns: 10, turnsUsed: null }); - expect(r.overBudget).toBe(true); - expect(r.budgetError).toMatch(/not reported/); - }); - - test("over budget when turns exceed max", () => { - const r = evaluateSoftBudget({ maxTurns: 5, turnsUsed: 6 }); - expect(r.overBudget).toBe(true); - expect(r.budgetError).toMatch(/over turn budget/); - }); - - test("within budget", () => { - expect(evaluateSoftBudget({ maxTurns: 10, turnsUsed: 10 })).toEqual({ - overBudget: false, - budgetError: null, - }); - }); -}); - describe("computeCellAggregates", () => { test("aggregates repeats per cell with pass rate and behavior stats", () => { const results = [ diff --git a/evals/capability/lib.ts b/evals/capability/lib.ts index 47149a2ca..d02871cd2 100644 --- a/evals/capability/lib.ts +++ b/evals/capability/lib.ts @@ -60,7 +60,6 @@ export interface EvalCase { /** Fixture path relative to the repository root. */ fixture: string; prompt: string; - maxTurns?: number; /** Grader filename relative to the case directory (default verify.sh). */ verify: string; /** Absolute path to the case directory on disk. */ @@ -139,9 +138,6 @@ export interface CaseResult { turnsUsed: number | null; toolCallCount: number | null; tokenUsage: EvalTokenUsage | null; - maxTurns: number | null; - /** True when turnsUsed exceeded the configured maxTurns budget. */ - overBudget: boolean | null; skipPermissions: boolean; error: string | null; /** 0-based repeat index within the case×variant cell. */ @@ -277,10 +273,6 @@ export function parseCaseJson(raw: unknown, caseDir: string): EvalCase { throw new Error(`case ${id}: missing prompt`); } const verify = typeof raw.verify === "string" && raw.verify.length > 0 ? raw.verify : "verify.sh"; - const maxTurns = - typeof raw.maxTurns === "number" && Number.isFinite(raw.maxTurns) && raw.maxTurns > 0 - ? Math.floor(raw.maxTurns) - : undefined; const bait = parseBait(raw.bait, id); const httpFixture = raw.httpFixture === true ? true : undefined; const requireBehaviors = parseRequireBehaviors(raw.requireBehaviors, id); @@ -292,7 +284,6 @@ export function parseCaseJson(raw: unknown, caseDir: string): EvalCase { prompt, verify, caseDir, - ...(maxTurns !== undefined ? { maxTurns } : {}), ...(bait !== undefined ? { bait } : {}), ...(httpFixture !== undefined ? { httpFixture } : {}), ...(requireBehaviors !== undefined ? { requireBehaviors } : {}), @@ -670,35 +661,6 @@ export function summarizeRun(results: readonly CaseResult[]): EvalRunTotals { }; } -/** - * Soft turn-budget evaluation. When maxTurns is set: - * - missing turnsUsed → fail closed (overBudget true) so a broken metrics path - * cannot silently pass a budgeted case - * - turnsUsed > maxTurns → overBudget true - * When maxTurns is unset, overBudget is null (budget not in force). - */ -export function evaluateSoftBudget(args: { maxTurns: number | null; turnsUsed: number | null }): { - overBudget: boolean | null; - budgetError: string | null; -} { - if (args.maxTurns === null) { - return { overBudget: null, budgetError: null }; - } - if (args.turnsUsed === null) { - return { - overBudget: true, - budgetError: `turn budget set (${args.maxTurns}) but turnsUsed was not reported`, - }; - } - if (args.turnsUsed > args.maxTurns) { - return { - overBudget: true, - budgetError: `over turn budget (${args.turnsUsed} > ${args.maxTurns})`, - }; - } - return { overBudget: false, budgetError: null }; -} - function parseTokenUsage(raw: unknown): EvalTokenUsage | null { if (!isRecord(raw)) return null; const num = (k: string): number => @@ -751,8 +713,6 @@ function parseCaseResult(raw: unknown): CaseResult { turnsUsed: typeof raw.turnsUsed === "number" ? raw.turnsUsed : null, toolCallCount: typeof raw.toolCallCount === "number" ? raw.toolCallCount : null, tokenUsage: parseTokenUsage(raw.tokenUsage), - maxTurns: typeof raw.maxTurns === "number" ? raw.maxTurns : null, - overBudget: typeof raw.overBudget === "boolean" ? raw.overBudget : null, skipPermissions: Boolean(raw.skipPermissions ?? true), error: typeof raw.error === "string" ? raw.error : raw.error === null ? null : null, repeat: diff --git a/scripts/eval-capability.ts b/scripts/eval-capability.ts index 31fa92cd9..313d8c491 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -43,7 +43,6 @@ import { parseMatrix, expandMatrix, makeResultKey, - evaluateSoftBudget, checkBehaviorRequirements, httpFixtureEnv, withEnv, @@ -79,7 +78,6 @@ interface CliOptions { outPath?: string; baselinePath?: string; skipPermissions: boolean; - maxTurnsOverride?: number; /** Wall-clock limit for runExec (ms). */ agentTimeoutMs: number; /** Wall-clock limit for verify.sh (ms). */ @@ -118,7 +116,6 @@ function printUsage(): void { --out Write results JSON --baseline Compare to prior results JSON --ask-permissions Do not pass --dangerously-skip-permissions - --max-turns Soft turn budget (case fails if turnsUsed exceeds; not a hard kill) --agent-timeout-ms Wall-clock limit for runExec (default 1200000) --verify-timeout-ms Wall-clock limit for verify.sh (default 120000) --repeats Runs per case×variant cell (default 1; gate runs use 5) @@ -230,12 +227,6 @@ export function parseArgs(argv: readonly string[]): CliOptions { case "--ask-permissions": opts.skipPermissions = false; break; - case "--max-turns": { - const n = Number(next()); - if (!Number.isFinite(n) || n <= 0) throw new Error("--max-turns must be a positive number"); - opts.maxTurnsOverride = Math.floor(n); - break; - } case "--agent-timeout-ms": { const n = Number(next()); if (!Number.isFinite(n) || n <= 0) { @@ -659,7 +650,6 @@ function failResult( error: string, partial?: Partial, ): CaseResult { - const maxTurns = opts.maxTurnsOverride ?? caseDef.maxTurns ?? null; return { resultKey: makeResultKey(variant.id, caseDef.id), id: caseDef.id, @@ -679,8 +669,6 @@ function failResult( turnsUsed: null, toolCallCount: null, tokenUsage: null, - maxTurns, - overBudget: null, skipPermissions: opts.skipPermissions, error, repeat, @@ -741,10 +729,6 @@ async function runCase( argv.push("--force"); if (opts.director !== undefined) argv.push("--director", opts.director); - const maxTurns = opts.maxTurnsOverride ?? caseDef.maxTurns ?? null; - // maxTurns is a soft post-run budget (case fails if exceeded). It does not - // hard-kill the agent mid-run — product path has no mid-turn budget hook yet. - argv.push(prompt); const config = await loadConfig(argv, { allowUnconfigured: false }); @@ -831,23 +815,14 @@ async function runCase( } console.log(`verify exit: ${verify.exitCode} (${verify.durationMs}ms)`); - // Soft maxTurns: fail when exceeded; fail closed when turns weren't reported. - const budget = evaluateSoftBudget({ maxTurns, turnsUsed }); - const overBudget = budget.overBudget; // requireBehaviors can fail a green agent+verify run (e.g. web-bait honesty). - const passed = - agentExitCode === 0 && - verify.exitCode === 0 && - overBudget !== true && - requireBehaviorCheck.ok; + const passed = agentExitCode === 0 && verify.exitCode === 0 && requireBehaviorCheck.ok; const preview = execResult.text.length > 400 ? `${execResult.text.slice(0, 400)}…` : execResult.text; let error: string | null = null; if (!passed) { - if (budget.budgetError !== null) { - error = budget.budgetError; - } else if (!requireBehaviorCheck.ok) { + if (!requireBehaviorCheck.ok) { error = requireBehaviorCheck.failures.join("; "); } else if (verify.timedOut) { error = `verify timed out after ${opts.verifyTimeoutMs}ms`; @@ -886,8 +861,6 @@ async function runCase( turnsUsed, toolCallCount, tokenUsage, - maxTurns, - overBudget, skipPermissions: opts.skipPermissions, error, repeat, @@ -923,7 +896,6 @@ function formatMetricsLine(r: CaseResult): string { if (r.tokenUsage !== null) { parts.push(`tok=${r.tokenUsage.input}+${r.tokenUsage.output}`); } - if (r.overBudget === true) parts.push("OVER_BUDGET"); return parts.join(" "); } diff --git a/scripts/intervention-forensics.ts b/scripts/intervention-forensics.ts index 0e73e022d..6418b0941 100644 --- a/scripts/intervention-forensics.ts +++ b/scripts/intervention-forensics.ts @@ -3,13 +3,12 @@ // before any stop/nudge threshold is changed again (CL-6938). // // Reports, per intervention id: how often it fired, split by model family, with -// the measured value distribution beside the threshold it crossed, and two -// context columns. These are NOT a measured false-positive rate — a stop on a -// run that had already edited files, or one that fired with turn budget still -// left, is equally consistent with a correct stop or a wrong one: +// the measured value distribution beside the threshold it crossed, and one +// context column. This is NOT a measured false-positive rate — a stop on a +// run that had already edited files is equally consistent with a correct +// stop or a wrong one: // // edited — stops that fired on a run which had already edited files. -// early — stops that fired before half the turn budget was spent. // // Also aggregates outcome records: what each completed dispatch actually // produced (a salvage kind, or clean-complete), by kind. This is the log's @@ -75,7 +74,6 @@ interface Bucket { values: number[]; thresholds: Set; editedWork: number; - earlyBudget: number; } function emptyBucket(): Bucket { @@ -86,7 +84,6 @@ function emptyBucket(): Bucket { values: [], thresholds: new Set(), editedWork: 0, - earlyBudget: 0, }; } @@ -160,9 +157,6 @@ for (const file of files) { const state = record.state; if (record.class === "stop" && state !== undefined) { if ((state.editedPaths ?? 0) > 0) bucket.editedWork++; - const turns = state.turnsCompleted ?? 0; - const max = state.maxTurns ?? 0; - if (max > 0 && turns < max / 2) bucket.earlyBudget++; } } } @@ -175,9 +169,7 @@ if (records === 0) { } const rows = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count); -console.log( - "\nintervention n value p50/p90/max threshold edited early", -); +console.log("\nintervention n value p50/p90/max threshold edited"); for (const [key, bucket] of rows) { const sorted = [...bucket.values].sort((a, b) => a - b); const dist = @@ -186,7 +178,7 @@ for (const [key, bucket] of rows) { : `${percentile(sorted, 50)}/${percentile(sorted, 90)}/${sorted[sorted.length - 1]!}`; const thresholds = bucket.thresholds.size === 0 ? "-" : [...bucket.thresholds].join(","); console.log( - `${key.padEnd(33)} ${String(bucket.count).padStart(3)} ${dist.padEnd(16)} ${thresholds.padEnd(10)} ${String(bucket.editedWork).padStart(5)} ${String(bucket.earlyBudget).padStart(5)}`, + `${key.padEnd(33)} ${String(bucket.count).padStart(3)} ${dist.padEnd(16)} ${thresholds.padEnd(10)} ${String(bucket.editedWork).padStart(5)}`, ); } @@ -229,7 +221,7 @@ if (repetitionRows.length > 0) { } console.log( - "\nedited = stops on runs that had already edited files; early = stops before half the turn budget (context, not a false-positive rate).", + "\nedited = stops on runs that had already edited files (context, not a false-positive rate).", ); // The one real rate in this script: interventions per dispatch, per model. diff --git a/src/agent/directors/brand-reviewer/package.test.ts b/src/agent/directors/brand-reviewer/package.test.ts index 9d5d02b6b..a1d1bdc4e 100644 --- a/src/agent/directors/brand-reviewer/package.test.ts +++ b/src/agent/directors/brand-reviewer/package.test.ts @@ -40,8 +40,4 @@ describe("brandReviewerPackage", () => { expect(brandReviewerPackage.primaryIntent).toBe("Own DESIGN.md create/use + brand gate"); expect(brandReviewerPackage.outOfLane).toContain("arbitrary product code outside DESIGN.md"); }); - - test("nudge maxTurns is 40", () => { - expect(brandReviewerPackage.nudge?.maxTurns).toBe(40); - }); }); diff --git a/src/agent/directors/brand-reviewer/package.ts b/src/agent/directors/brand-reviewer/package.ts index b048893aa..947feb45c 100644 --- a/src/agent/directors/brand-reviewer/package.ts +++ b/src/agent/directors/brand-reviewer/package.ts @@ -17,7 +17,6 @@ export const brandReviewerPackage: DirectorPackage = { tools: { allow: DOCS_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "docs", systemPrompt: `You are BrandReviewerDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/bruckheimer/package.test.ts b/src/agent/directors/bruckheimer/package.test.ts index 575fef3ef..7e9d6f4e1 100644 --- a/src/agent/directors/bruckheimer/package.test.ts +++ b/src/agent/directors/bruckheimer/package.test.ts @@ -37,8 +37,4 @@ describe("bruckheimerPackage", () => { expect(bruckheimerPackage.outOfLane).toContain("shipping product code"); expect(bruckheimerPackage.outOfLane).toContain("architecture gates"); }); - - test("nudge maxTurns is 40", () => { - expect(bruckheimerPackage.nudge?.maxTurns).toBe(40); - }); }); diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts index eab9b8b19..57cae8e3a 100644 --- a/src/agent/directors/bruckheimer/package.ts +++ b/src/agent/directors/bruckheimer/package.ts @@ -18,7 +18,6 @@ export const bruckheimerPackage: DirectorPackage = { tools: { allow: DOCS_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "docs", systemPrompt: `You are BruckheimerDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/build/package.ts b/src/agent/directors/build/package.ts index faeb79d5d..2a2f3caf6 100644 --- a/src/agent/directors/build/package.ts +++ b/src/agent/directors/build/package.ts @@ -16,7 +16,6 @@ export const buildDirectorPackage: DirectorPackage = { tools: { allow: BUILD_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 60 }, modelRole: "implement", systemPrompt: `You are BuildDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index 038a995f4..160944919 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -82,8 +82,4 @@ describe("critiquePackage", () => { expect(critiquePackage.outOfLane).toContain("DESIGN.md"); expect(critiquePackage.outOfLane).toContain("pedantic fun without evidence"); }); - - test("nudge maxTurns is 45", () => { - expect(critiquePackage.nudge?.maxTurns).toBe(45); - }); }); diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 7524591ac..1a669fc32 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -20,7 +20,6 @@ export const critiquePackage: DirectorPackage = { tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 45 }, modelRole: "review", systemPrompt: `You are CritiqueDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/draper/package.test.ts b/src/agent/directors/draper/package.test.ts index dddf64af3..9f89e23cb 100644 --- a/src/agent/directors/draper/package.test.ts +++ b/src/agent/directors/draper/package.test.ts @@ -39,8 +39,4 @@ describe("draperPackage", () => { expect(draperPackage.outOfLane).toContain("shipping product code"); expect(draperPackage.outOfLane).toContain("marketing copy pipeline"); }); - - test("nudge maxTurns is 40", () => { - expect(draperPackage.nudge?.maxTurns).toBe(40); - }); }); diff --git a/src/agent/directors/draper/package.ts b/src/agent/directors/draper/package.ts index 9581a1338..6c092f665 100644 --- a/src/agent/directors/draper/package.ts +++ b/src/agent/directors/draper/package.ts @@ -19,7 +19,6 @@ export const draperPackage: DirectorPackage = { tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "review", systemPrompt: `You are DraperDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/emil/package.test.ts b/src/agent/directors/emil/package.test.ts index 234550d9d..6fef61384 100644 --- a/src/agent/directors/emil/package.test.ts +++ b/src/agent/directors/emil/package.test.ts @@ -39,8 +39,4 @@ describe("emilPackage", () => { expect(emilPackage.outOfLane).toContain("shipping product code without design brief"); expect(emilPackage.outOfLane).toContain("marketing content"); }); - - test("nudge maxTurns is 40", () => { - expect(emilPackage.nudge?.maxTurns).toBe(40); - }); }); diff --git a/src/agent/directors/emil/package.ts b/src/agent/directors/emil/package.ts index d114e6863..2ab216fa6 100644 --- a/src/agent/directors/emil/package.ts +++ b/src/agent/directors/emil/package.ts @@ -19,7 +19,6 @@ export const emilPackage: DirectorPackage = { tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "review", systemPrompt: `You are EmilDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/explore/package.test.ts b/src/agent/directors/explore/package.test.ts index 3bb7dc66c..9bf3cb31c 100644 --- a/src/agent/directors/explore/package.test.ts +++ b/src/agent/directors/explore/package.test.ts @@ -34,11 +34,6 @@ describe("explorePackage", () => { expect(explorePackage.systemPrompt).toMatch(/call flow/i); }); - test("systemPrompt notes maxTurns budget is real", () => { - expect(explorePackage.systemPrompt).toMatch(/maxTurns/i); - expect(explorePackage.systemPrompt).toMatch(/wrap up before thrash/i); - }); - test("spawn.maySpawn is false", () => { expect(explorePackage.spawn.maySpawn).toBe(false); }); diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts index 86a9fc80e..3b751fb28 100644 --- a/src/agent/directors/explore/package.ts +++ b/src/agent/directors/explore/package.ts @@ -17,7 +17,7 @@ PRIMARY INTENT: explore and map the codebase to answer the brief. Read, search, Prefer grep/search_files/lsp over shell walks. Shell find/rg -r are blocked by harness — do not work around. -FINISH BIAS: Prefer one thorough pass then report. Expand Findings, change approach, or write the final report — do not keep re-reading the same paths. Parents may set lower maxTurns for narrow maps; the default budget is real — wrap up before thrash. +FINISH BIAS: Prefer one thorough pass then report. Expand Findings, change approach, or write the final report — do not keep re-reading the same paths. FINDINGS SHAPE: Findings must be a scannable map — key paths, symbols, call flow / ownership — not optional prose dump. Cite paths. No drive-by refactors, no feature work, no review severity theater. @@ -25,6 +25,5 @@ OUT OF LANE → report Blockers naming the right director: build, plan, critique tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 35 }, modelRole: "explore", }; diff --git a/src/agent/directors/gaasbot/package.test.ts b/src/agent/directors/gaasbot/package.test.ts index 303ba7300..3d4263f1d 100644 --- a/src/agent/directors/gaasbot/package.test.ts +++ b/src/agent/directors/gaasbot/package.test.ts @@ -40,8 +40,4 @@ describe("gaasbotPackage", () => { expect(gaasbotPackage.outOfLane).toContain("blocking merges"); expect(gaasbotPackage.outOfLane).toContain("shipping product code as implementer"); }); - - test("nudge maxTurns is 35", () => { - expect(gaasbotPackage.nudge?.maxTurns).toBe(35); - }); }); diff --git a/src/agent/directors/gaasbot/package.ts b/src/agent/directors/gaasbot/package.ts index 259f599de..d7e60157f 100644 --- a/src/agent/directors/gaasbot/package.ts +++ b/src/agent/directors/gaasbot/package.ts @@ -20,7 +20,6 @@ export const gaasbotPackage: DirectorPackage = { tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 35 }, modelRole: "plan", systemPrompt: `You are GaasbotDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index 5480c8a61..f8b620d8b 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -66,8 +66,4 @@ describe("greybeardPackage", () => { expect(greybeardPackage.outOfLane).toContain("shipping product code"); expect(greybeardPackage.outOfLane).toContain("pedantic style-only nitpicking"); }); - - test("nudge maxTurns is 50", () => { - expect(greybeardPackage.nudge?.maxTurns).toBe(50); - }); }); diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index 683577222..16a41b53c 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -16,7 +16,6 @@ export const greybeardPackage: DirectorPackage = { maySpawn: true, allowlist: ["intern", "explore", "critique"], }, - nudge: { maxTurns: 50 }, modelRole: "review", tier: "nested-orchestrator", systemPrompt: `You are GreybeardDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/intern/package.test.ts b/src/agent/directors/intern/package.test.ts index e56e86792..c2921de4d 100644 --- a/src/agent/directors/intern/package.test.ts +++ b/src/agent/directors/intern/package.test.ts @@ -17,10 +17,6 @@ describe("internPackage", () => { expect(internPackage.spawn.maySpawn).toBe(false); }); - test("nudge.maxTurns is 20", () => { - expect(internPackage.nudge?.maxTurns).toBe(20); - }); - test("tools.allow is shell-first minimal surface", () => { const allow = internPackage.tools?.allow ?? []; expect(allow).toContain("run_shell"); diff --git a/src/agent/directors/intern/package.ts b/src/agent/directors/intern/package.ts index 8a92ffcc8..c247909c2 100644 --- a/src/agent/directors/intern/package.ts +++ b/src/agent/directors/intern/package.ts @@ -22,7 +22,6 @@ export const internPackage: DirectorPackage = { tools: { allow: INTERN_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 20 }, modelRole: "implement", systemPrompt: `You are InternDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/neckbeard/package.test.ts b/src/agent/directors/neckbeard/package.test.ts index 114d0e975..20bc1cdce 100644 --- a/src/agent/directors/neckbeard/package.test.ts +++ b/src/agent/directors/neckbeard/package.test.ts @@ -47,8 +47,4 @@ describe("neckbeardPackage", () => { expect(neckbeardPackage.outOfLane).toContain("product implementation"); expect(neckbeardPackage.outOfLane).toContain("architecture ownership"); }); - - test("nudge maxTurns is 40", () => { - expect(neckbeardPackage.nudge?.maxTurns).toBe(40); - }); }); diff --git a/src/agent/directors/neckbeard/package.ts b/src/agent/directors/neckbeard/package.ts index 9991ab0c9..1031ab5dd 100644 --- a/src/agent/directors/neckbeard/package.ts +++ b/src/agent/directors/neckbeard/package.ts @@ -19,7 +19,6 @@ export const neckbeardPackage: DirectorPackage = { tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "review", systemPrompt: `You are NeckbeardDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/plan/package.test.ts b/src/agent/directors/plan/package.test.ts index e98a0a481..5bd548e46 100644 --- a/src/agent/directors/plan/package.test.ts +++ b/src/agent/directors/plan/package.test.ts @@ -41,8 +41,4 @@ describe("planPackage", () => { expect(planPackage.outOfLane).toContain("architecture gate sign-off as Greybeard"); expect(planPackage.outOfLane).toContain("running the fleet"); }); - - test("nudge maxTurns is 40", () => { - expect(planPackage.nudge?.maxTurns).toBe(40); - }); }); diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts index 2f70ecd07..9345c65ec 100644 --- a/src/agent/directors/plan/package.ts +++ b/src/agent/directors/plan/package.ts @@ -10,7 +10,6 @@ export const planPackage: DirectorPackage = { tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "plan", systemPrompt: `You are PlanDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 2e1c8647c..be552635a 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -102,7 +102,6 @@ describe("director registry", () => { const grey = packageToProfile(DIRECTOR_REGISTRY.greybeard); expect(grey.orchestrator).toBe(true); - expect(grey.maxTurns).toBe(DIRECTOR_REGISTRY.greybeard.nudge?.maxTurns); const shakespeare = packageToProfile(DIRECTOR_REGISTRY.shakespeare); expect(shakespeare.capabilities?.mode).toBe("allow"); diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index 75e1a40bd..69ad1dd95 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -131,7 +131,6 @@ export function packageToProfile(pkg: DirectorPackage): AgentProfile { // Nested spawn is still gated by allowOrchestrator on the parent task tool. // Greybeard/skywalker maySpawn marks intent; leaves stay non-orchestrator. orchestrator: pkg.spawn.maySpawn, - ...(pkg.nudge?.maxTurns !== undefined ? { maxTurns: pkg.nudge.maxTurns } : {}), ...(capabilities !== undefined ? { capabilities } : {}), }; } diff --git a/src/agent/directors/shakespeare/package.test.ts b/src/agent/directors/shakespeare/package.test.ts index 79a9ec0e6..552fbcdee 100644 --- a/src/agent/directors/shakespeare/package.test.ts +++ b/src/agent/directors/shakespeare/package.test.ts @@ -46,10 +46,6 @@ describe("shakespearePackage", () => { expect(shakespearePackage.optionalSkills).toEqual(["style", "philosophy"]); }); - test("nudge.maxTurns is 50", () => { - expect(shakespearePackage.nudge?.maxTurns).toBe(50); - }); - test("primaryIntent is docs maintain", () => { expect(shakespearePackage.primaryIntent).toMatch(/docs|documentation|PRODUCT|product/i); }); diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts index dcb34ada8..d88cbda4a 100644 --- a/src/agent/directors/shakespeare/package.ts +++ b/src/agent/directors/shakespeare/package.ts @@ -75,6 +75,5 @@ export const shakespearePackage: DirectorPackage = { tools: { allow: DOCS_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 50 }, modelRole: "docs", }; diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index dfe12bc5d..61ccf9d23 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -78,10 +78,6 @@ describe("skywalkerPackage", () => { expect(skywalkerPackage.outOfLane).toContain("diagnostic fleets for why/how/stall questions"); }); - test("nudge maxTurns", () => { - expect(skywalkerPackage.nudge?.maxTurns).toBe(100); - }); - test("systemPrompt parent tools tell the parent not to run long-blocking jobs", () => { const p = skywalkerPackage.systemPrompt; expect(p).toContain("Parent tools"); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index ff14fc759..ba66fe6f9 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -190,7 +190,6 @@ export const skywalkerPackage: DirectorPackage = { "tester", ], }, - nudge: { maxTurns: 100 }, modelRole: "orchestrator", tier: "orchestrator", }; diff --git a/src/agent/directors/tester/package.test.ts b/src/agent/directors/tester/package.test.ts index 1c66c5a74..a6bff51e8 100644 --- a/src/agent/directors/tester/package.test.ts +++ b/src/agent/directors/tester/package.test.ts @@ -35,10 +35,6 @@ describe("testerPackage", () => { expect(testerPackage.modelRole).toBe("test"); }); - test("nudge.maxTurns is 40", () => { - expect(testerPackage.nudge?.maxTurns).toBe(40); - }); - test("primaryIntent is runtime verify never fix", () => { expect(testerPackage.primaryIntent).toMatch(/run|verify/i); expect(testerPackage.primaryIntent).toMatch(/never fix/i); diff --git a/src/agent/directors/tester/package.ts b/src/agent/directors/tester/package.ts index 8190dd571..b07de7c55 100644 --- a/src/agent/directors/tester/package.ts +++ b/src/agent/directors/tester/package.ts @@ -31,6 +31,5 @@ OUT OF LANE: fixing product code, "just quickly" fixing, redesigning the whole s tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "test", }; diff --git a/src/agent/directors/testsmith/package.test.ts b/src/agent/directors/testsmith/package.test.ts index ac9e3ee29..eedb84ba0 100644 --- a/src/agent/directors/testsmith/package.test.ts +++ b/src/agent/directors/testsmith/package.test.ts @@ -33,10 +33,6 @@ describe("testsmithPackage", () => { expect(testsmithPackage.modelRole).toBe("test"); }); - test("nudge.maxTurns is 40", () => { - expect(testsmithPackage.nudge?.maxTurns).toBe(40); - }); - test("primaryIntent is design-only and not primary verifier", () => { expect(testsmithPackage.primaryIntent).toMatch(/design/i); expect(testsmithPackage.primaryIntent).toMatch(/not.*verifier|do not run as primary verifier/i); diff --git a/src/agent/directors/testsmith/package.ts b/src/agent/directors/testsmith/package.ts index da6c82516..3f13c63be 100644 --- a/src/agent/directors/testsmith/package.ts +++ b/src/agent/directors/testsmith/package.ts @@ -33,6 +33,5 @@ Read and search the codebase to ground the design; you have no product-mutation tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", - nudge: { maxTurns: 40 }, modelRole: "test", }; diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 69e59effc..765d21b94 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -57,7 +57,6 @@ export interface SpawnRights { } export interface NudgePolicy { - readonly maxTurns?: number; /** Stall silence budget in ms before a parent-facing stall notice. */ readonly stallMs?: number; } diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index a2bd14ec5..a3cdc0b8a 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -68,9 +68,6 @@ export interface AgentProfile { // coordinators (e.g. a planning agent that fans work out to specialists); // leaf-task agents should leave this unset. orchestrator?: boolean; - // Optional inference-turn budget when this profile is dispatched via task(agent=...). - // 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. source?: string; diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index 5a57a424d..2d1eb63c8 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -51,7 +51,6 @@ const AgentProfileSchema = type({ "systemPromptRole?": "string", "systemPromptPath?": "string", "orchestrator?": "boolean", - "maxTurns?": "number", }); function isENOENT(err: unknown): boolean { diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 95e8a490c..f2e8e2053 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 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.", + "- If a worker comes back without finishing, change the brief rather than repeating it: narrow the scope, name the files, or state the done-when more sharply.", "- 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.", ]), @@ -214,7 +214,7 @@ const TOOL_SUMMARIES: Record = { lsp: "resolve symbols — goToDefinition, findReferences, hover (prefer before reading huge files)", web_search: "search the web (use instead of curl or wget)", web_fetch: "fetch the content of a URL", - task: "spawn a sub-agent for a self-contained job (not a checklist item); pass intent/success_criteria/do_not/report_focus when possible; optional maxTurns sets the worker inference budget; when launching several task calls in one turn, give each a distinct lens so they do not duplicate work", + task: "spawn a sub-agent for a self-contained job (not a checklist item); pass intent/success_criteria/do_not/report_focus when possible; when launching several task calls in one turn, give each a distinct lens so they do not duplicate work", search_agents: "find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", diff --git a/src/config.test.ts b/src/config.test.ts index 3210832f6..55c3e3f35 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1179,7 +1179,6 @@ describe("buildProviderCatalog", () => { hiddenCommands: ["help"], onboarded: true, compactionMode: "pruning", - subagentMaxTurns: 40, sessionMode: "orchestrator", agentModelFallback: "none", shell: { timeoutMs: 30_000, maxTimeoutMs: 120_000 }, diff --git a/src/config/settings.ts b/src/config/settings.ts index 56883a34a..84ace785e 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -119,8 +119,6 @@ export interface Settings { // "llm" (default) generates a structured handoff summary via LLM call. // "pruning" uses fast deterministic pruning with no LLM call. compactionMode?: "llm" | "pruning"; - // Default inference-turn budget for leaf sub-agents (not the parent session limit). - subagentMaxTurns?: number; // Deprecated (CL-5814): orchestrator is the only product path. Legacy values // may still appear in on-disk settings and are ignored at resolve time; new // writes should omit this field. Kept on the type so old files still load. @@ -285,47 +283,6 @@ export function shellEnvFromSettings( return local?.env; } -/** Floor-only sanitization: ≥1 integer. No upper hard cap. */ -export function clampSubAgentMaxTurns(value: number): number { - 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 Infinity; - } - return clampSubAgentMaxTurns(settings.subagentMaxTurns); -} - -export type TaskMaxTurnsValidation = { ok: true; value: number } | { ok: false; message: string }; - -export function validateTaskMaxTurns(value: number): TaskMaxTurnsValidation { - if (!Number.isFinite(value) || !Number.isInteger(value)) { - return { ok: false, message: "maxTurns must be a positive integer." }; - } - if (value < 1) { - return { ok: false, message: "maxTurns must be at least 1." }; - } - return { ok: true, value }; -} - -export function resolveSubAgentMaxTurns(input: { - settings?: Settings | null; - profileMaxTurns?: number; - /** Must already pass validateTaskMaxTurns when set. */ - taskMaxTurns?: number; -}): number { - if (input.taskMaxTurns !== undefined) { - return input.taskMaxTurns; - } - if (input.profileMaxTurns !== undefined) { - return clampSubAgentMaxTurns(input.profileMaxTurns); - } - return resolveDefaultSubAgentMaxTurns(input.settings); -} - export interface PluginConfig { enabled?: boolean; // One-time consent for a tool plugin (kind "tool"). Its tools add in-process @@ -465,7 +422,6 @@ const SettingsSchema = type({ "onboarded?": "boolean", "lastChangelogVersion?": "string", "compactionMode?": "'llm' | 'pruning'", - "subagentMaxTurns?": "number", // Legacy disk values still load; product resolve ignores them (CL-5814). "sessionMode?": "'single' | 'orchestrator'", @@ -524,12 +480,6 @@ export function isSettings(value: unknown): value is Settings { if (!SettingsSchema.allows(value)) return false; const s = value as Record; 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) { - return false; - } - } // Legacy "single" | "orchestrator" still load; product resolve ignores them. if ( s.sessionMode !== undefined && @@ -653,7 +603,6 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "onboarded", "lastChangelogVersion", "compactionMode", - "subagentMaxTurns", "sessionMode", "agentModelFallback", "shell", @@ -765,10 +714,6 @@ export async function loadSettings(path: string): Promise { : undefined, compactionMode: s.compactionMode === "llm" || s.compactionMode === "pruning" ? s.compactionMode : undefined, - subagentMaxTurns: - s.subagentMaxTurns !== undefined - ? clampSubAgentMaxTurns(s.subagentMaxTurns as number) - : undefined, // CL-5814: drop legacy "single"; only keep explicit orchestrator if present. sessionMode: s.sessionMode === "orchestrator" ? "orchestrator" : undefined, agentModelFallback: diff --git a/src/plugins/agent-plugins.test.ts b/src/plugins/agent-plugins.test.ts index c32efbb97..6576cd6aa 100644 --- a/src/plugins/agent-plugins.test.ts +++ b/src/plugins/agent-plugins.test.ts @@ -55,7 +55,7 @@ describe("resolveAgentPluginProfiles", () => { test("skips malformed profiles, keeps valid ones", async () => { const { mod, config } = agentModule("p1", [ validProfile, - { id: "bad", maxTurns: "nonexistent" }, // invalid maxTurns type + { id: "bad", orchestrator: "nonexistent" }, // invalid orchestrator type { description: "missing id" }, // missing required id ]); const profiles = await resolveAgentPluginProfiles([mod], config); diff --git a/src/settings.test.ts b/src/settings.test.ts index ddfad9e68..749cacc5d 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -16,10 +16,6 @@ import { saveGlobalSettings, saveLocalSettings, type Settings, - resolveDefaultSubAgentMaxTurns, - resolveSubAgentMaxTurns, - clampSubAgentMaxTurns, - validateTaskMaxTurns, toolWatchdogFromSettings, loadGlobalSettingsWriteBase, persistSkipPermissionsDefault, @@ -932,101 +928,6 @@ describe("lastChangelogVersion", () => { }); }); -describe("subagentMaxTurns", () => { - test("is unbounded when unset", () => { - expect(resolveDefaultSubAgentMaxTurns(null)).toBe(Infinity); - expect(resolveDefaultSubAgentMaxTurns({ providers: {} })).toBe(Infinity); - }); - - test("resolveSubAgentMaxTurns precedence", () => { - const settings = { providers: {}, subagentMaxTurns: 40 }; - expect(resolveSubAgentMaxTurns({ settings })).toBe(40); - expect(resolveSubAgentMaxTurns({ settings, profileMaxTurns: 55 })).toBe(55); - expect(resolveSubAgentMaxTurns({ settings, profileMaxTurns: 55, taskMaxTurns: 70 })).toBe(70); - }); - - test("clampSubAgentMaxTurns enforces floor only", () => { - expect(clampSubAgentMaxTurns(0)).toBe(1); - expect(clampSubAgentMaxTurns(-5)).toBe(1); - expect(clampSubAgentMaxTurns(150)).toBe(150); - expect(clampSubAgentMaxTurns(500)).toBe(500); - }); - - 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 { - const path = join(dir, ".corbits", "settings.json"); - await saveGlobalSettings(path, { ...firepass, subagentMaxTurns: 42 }); - expect(await loadSettings(path)).toEqual({ ...firepass, subagentMaxTurns: 42 }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - 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({ - providers: firepass.providers, - subagentMaxTurns: 0, - }), - ).toBe(false); - expect( - isSettings({ - providers: firepass.providers, - 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", () => { test("round-trips a settings object through loadSettings", async () => { const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index c39ac2ebd..b581cc43e 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -30,7 +30,7 @@ * "unknown". * * Argument shape intentionally mirrors `task()`'s (description/prompt/ - * context/goals/intent/success_criteria/do_not/report_focus/maxTurns) so a + * context/goals/intent/success_criteria/do_not/report_focus) so a * caller can swap one for the other. Scope is deliberately narrower than * `task()` for this first cut: only closed-director dispatch (`agent=` a * director id, or `intent=`) is supported — no custom AgentProfile lookup, @@ -56,7 +56,6 @@ import { formatDirectorSystemPrompt, } from "../agent/directors/identity.js"; import type { Settings } from "../config/settings.js"; -import { resolveSubAgentMaxTurns, validateTaskMaxTurns } from "../config/settings.js"; import { resolveEffortForRole } from "../provider/reasoning-effort.js"; import { isCodexProviderName } from "../config/codex-providers.js"; import { buildDispatchBrief, type TaskIntent } from "./report.js"; @@ -177,13 +176,12 @@ const SpawnAgentArgs = type({ "success_criteria?": "string[]", "do_not?": "string[]", "report_focus?": "string", - "maxTurns?": "number", }); export const spawnAgentToolDefinition: ToolDefinition = { name: "spawn_agent", description: - "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Same brief fields as task() (description/prompt/context/goals/intent/success_criteria/do_not/report_focus/maxTurns); pass agent= a director id or intent= (one of explore|implement|review|plan|general). Fire several spawn_agent calls in one turn to start workers in parallel, then use wait_agents to block on whichever ones you need next. Prefer task() when you only need one worker and want its result before doing anything else — spawn_agent+wait_agents earns its keep when you want to start more than one worker without stalling on the first.", + "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Same brief fields as task() (description/prompt/context/goals/intent/success_criteria/do_not/report_focus); pass agent= a director id or intent= (one of explore|implement|review|plan|general). Fire several spawn_agent calls in one turn to start workers in parallel, then use wait_agents to block on whichever ones you need next. Prefer task() when you only need one worker and want its result before doing anything else — spawn_agent+wait_agents earns its keep when you want to start more than one worker without stalling on the first.", inputSchema: { type: "object", properties: { @@ -215,10 +213,6 @@ export const spawnAgentToolDefinition: ToolDefinition = { type: "string", description: "Optional director id (e.g. from search_agents). Alternative to intent=.", }, - maxTurns: { - type: "number", - description: "Optional inference-turn budget for this worker only.", - }, }, required: ["description", "prompt"], }, @@ -293,7 +287,6 @@ export function resolveDirectorDispatch( systemPromptRole: string; capabilities: ReturnType; roleDefault: ReturnType; - profileMaxTurns: number | undefined; } | { ok: false; error: string } { if (agentId !== undefined && agentId.length > 0) { @@ -312,7 +305,6 @@ export function resolveDirectorDispatch( systemPromptRole: formatDirectorSystemPrompt(pkg), capabilities: packageToCapabilities(pkg), roleDefault: defaultEffortForDirector(pkg), - profileMaxTurns: pkg.nudge?.maxTurns, }; } if (intent !== undefined) { @@ -325,7 +317,6 @@ export function resolveDirectorDispatch( systemPromptRole: formatDirectorSystemPrompt(pkg), capabilities: packageToCapabilities(pkg), roleDefault: defaultEffortForDirector(pkg), - profileMaxTurns: pkg.nudge?.maxTurns, }; } return { @@ -355,7 +346,6 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { success_criteria: rawSuccessCriteria, do_not: rawDoNot, report_focus: rawReportFocus, - maxTurns: rawMaxTurns, } = parsed; const description = rawDesc.trim(); const prompt = rawPrompt.trim(); @@ -376,20 +366,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const resolved = resolveDirectorDispatch(agentId, intent); if (!resolved.ok) return fleetResult(call.id, resolved.error); - let taskMaxTurns: number | undefined; - if (rawMaxTurns !== undefined) { - const verdict = validateTaskMaxTurns(rawMaxTurns); - if (!verdict.ok) return fleetResult(call.id, `Error: ${verdict.message}`); - taskMaxTurns = verdict.value; - } const settings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; - const resolvedMaxTurns = resolveSubAgentMaxTurns({ - ...(settings !== undefined ? { settings } : {}), - ...(taskMaxTurns !== undefined ? { taskMaxTurns } : {}), - ...(resolved.profileMaxTurns !== undefined - ? { profileMaxTurns: resolved.profileMaxTurns } - : {}), - }); let provider: SubAgentProvider = resolveDep(deps.provider); const effort = resolveEffortForRole({ @@ -469,7 +446,6 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { ...(resolved.capabilities !== undefined ? { capabilities: resolved.capabilities } : {}), systemPromptRole: resolved.systemPromptRole, directorId: resolved.directorId, - maxTurns: resolvedMaxTurns, // CL-6943: keep the session open after a clean completion, and hand // the store a bounded close for close_agent to call later. persist: true, diff --git a/src/subagent/brief-dispatch.ts b/src/subagent/brief-dispatch.ts index 603db8f01..d2c1cdac1 100644 --- a/src/subagent/brief-dispatch.ts +++ b/src/subagent/brief-dispatch.ts @@ -1,9 +1,9 @@ /** - * Parent-side re-dispatch caps for task briefs (CL-4343 + CL-5203). + * Parent-side re-dispatch bookkeeping for task briefs (CL-4343 + CL-5203). * * This module tracks how often the *parent* re-spawns the same brief so - * turn-budget salvage flips from "raise maxTurns" to "stop" after enough - * same-brief dispatches without a successful complete. + * salvage outcomes can be classified per-fingerprint (successful completes + * reset the counter). * * Session-scoped: one ledger per createTaskTool instance (parent chat tool). */ @@ -28,13 +28,6 @@ export interface BriefDispatchRecord { dispatchCount: number; } -/** - * After this many total dispatches of the same brief without a successful - * complete (original + 2 re-dispatches), turn-budget salvage no longer invites - * another re-dispatch. Successful completes reset the counter. - */ -export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3; - /** * Classify a completed dispatch as a salvage kind the parent ledger cares * about, from the structured stop reason the run reported directly — never @@ -53,8 +46,8 @@ export function classifyBriefSalvage(input: { /** * Stable fingerprint for a task brief. Covers the typed spawn contract fields * that define the job (prompt + agent + intent + success_criteria + do_not). - * Description, context, goals, report_focus, maxTurns, and tier are intentionally - * omitted so cosmetic label / budget tweaks cannot bypass the cap. + * Description, context, goals, report_focus, and tier are intentionally + * omitted so cosmetic label tweaks cannot bypass the cap. */ export function fingerprintTaskBrief(input: TaskBriefFingerprintInput): string { const parts = [ @@ -85,7 +78,7 @@ export interface BriefDispatchLedger { recordOutcome: (fingerprint: string, salvage: BriefSalvageKind | null) => void; /** * Undo a prior admit when the run never produced a salvage or success body - * (throw / auth fail). Prevents burning turn-budget retry budget on crashes. + * (throw / auth fail). Prevents burning re-dispatch bookkeeping on crashes. */ release: (fingerprint: string) => void; } @@ -126,11 +119,3 @@ export function createBriefDispatchLedger(): BriefDispatchLedger { }, }; } - -/** - * Whether turn-budget parent hint should recommend stopping rather than - * re-dispatching with a higher maxTurns. - */ -export function shouldStopTurnBudgetRedispatch(dispatchCount: number): boolean { - return dispatchCount >= TURN_BUDGET_STOP_AFTER_DISPATCHES; -} diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 9e0715ff1..4353a7a40 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -149,8 +149,8 @@ export function observeFleet( if (before.status !== lane.status) { if (lane.status === "done") { - // A forced stop (repetition / stall / salvage caps) lands as "done" - // with a stopReason — that is attention, not a success line. + // A forced stop (stall abort, etc) lands as "done" with a + // stopReason — that is attention, not a success line. if (lane.stopReason !== undefined) { changes.push({ kind: "failed", diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 61ffd2dd8..83ae18518 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -20,8 +20,6 @@ import { createBriefDispatchLedger, fingerprintTaskBrief, classifyBriefSalvage, - TURN_BUDGET_STOP_AFTER_DISPATCHES, - TURN_BUDGET_STOP_PARENT_HINT, EMPTY_THRASH_STATE, nextThrashState, partialTextFromEvent, @@ -32,7 +30,6 @@ import { subAgentToolName, SUBAGENT_DEADLINE_MARGIN_MS, SUBAGENT_PLUGIN_SPAWN_TEARDOWN_LIMITS, - subAgentTurnLimitExceeded, SubAgentDirector, TaskToolArgs, type RunSubAgentParams, @@ -140,20 +137,10 @@ describe("sub-agent teardown", () => { }); describe("sub-agent stop helpers", () => { - 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("evaluateSubAgentStop returns incomplete-report when the final turn has no tool calls and no envelope", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 2, - maxTurns: 10, lastAssistantText: "", }), ).toBe("incomplete-report"); @@ -183,8 +170,6 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 2, - maxTurns: 10, lastAssistantText: SUMMARY_ONLY_NARRATION, }), ).toBe("incomplete-report"); @@ -194,8 +179,6 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 3, - maxTurns: 10, lastAssistantText: SUMMARY_ONLY_NARRATION, incompleteReportNudgeFired: true, }), @@ -206,8 +189,6 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 2, - maxTurns: 10, lastAssistantText: FULL_REPORT_ENVELOPE, }), ).toBe("complete"); @@ -239,8 +220,6 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 2, - maxTurns: 10, lastAssistantText: FULL_REPORT_ENVELOPE, thrashState, requireEvidence: true, @@ -257,8 +236,6 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 2, - maxTurns: 10, lastAssistantText: FULL_REPORT_ENVELOPE, thrashState, requireEvidence: true, @@ -275,8 +252,6 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, - turnsCompleted: 2, - maxTurns: 10, lastAssistantText: FULL_REPORT_ENVELOPE, thrashState, requireEvidence: false, @@ -284,7 +259,7 @@ describe("sub-agent stop helpers", () => { ).toBe("complete"); }); - test("evaluateSubAgentStop does not hard-stop implement for many unique reads", () => { + test("evaluateSubAgentStop does not stop for many unique reads while still calling tools", () => { let thrash = EMPTY_THRASH_STATE; for (let i = 0; i < 200; i++) { thrash = nextThrashState(thrash, [ @@ -295,36 +270,21 @@ describe("sub-agent stop helpers", () => { evaluateSubAgentStop({ hasToolCalls: true, lastAssistantText: "", - turnsCompleted: 40, - maxTurns: 60, thrashState: thrash, }), ).toBeNull(); }); - test("evaluateSubAgentStop trips turn-budget when the leaf is still making progress", () => { - expect( - evaluateSubAgentStop({ - hasToolCalls: true, - lastAssistantText: "", - turnsCompleted: 10, - maxTurns: 10, - }), - ).toBe("turn-budget"); - }); - - test("evaluateSubAgentStop keeps running while fingerprints change under budget", () => { + test("evaluateSubAgentStop keeps running while the worker is still calling tools", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, lastAssistantText: "", - turnsCompleted: 5, - maxTurns: 10, }), ).toBeNull(); }); - test("re-read pressure no longer stops a worker; turn-budget still does (CL-6936)", () => { + test("re-read pressure no longer stops a worker (CL-6936)", () => { let thrash = EMPTY_THRASH_STATE; thrash = nextThrashState(thrash, [ { type: "tool_call", name: "edit_file", arguments: { path: "a.ts" } }, @@ -338,45 +298,12 @@ describe("sub-agent stop helpers", () => { evaluateSubAgentStop({ hasToolCalls: true, lastAssistantText: "", - turnsCompleted: 10, - maxTurns: 10, thrashState: thrash, }), - ).toBe("turn-budget"); - }); - - test("evaluateSubAgentStop returns report-forced once, at forceReportWithin turns before the cap", () => { - expect( - evaluateSubAgentStop({ - hasToolCalls: true, - lastAssistantText: "", - turnsCompleted: 8, - maxTurns: 10, - thrashState: EMPTY_THRASH_STATE, - }), - ).toBe("report-forced"); - // Nudge already fired; turn-budget stays reachable on the following turns. - expect( - evaluateSubAgentStop({ - hasToolCalls: true, - lastAssistantText: "", - turnsCompleted: 9, - maxTurns: 10, - thrashState: EMPTY_THRASH_STATE, - }), ).toBeNull(); - expect( - evaluateSubAgentStop({ - hasToolCalls: true, - lastAssistantText: "", - turnsCompleted: 10, - maxTurns: 10, - thrashState: EMPTY_THRASH_STATE, - }), - ).toBe("turn-budget"); }); - test("evaluateSubAgentStop multi-file unique reads do not thrash under budget", () => { + test("evaluateSubAgentStop multi-file unique reads do not thrash", () => { let thrash = EMPTY_THRASH_STATE; for (let i = 0; i < 12; i++) { thrash = nextThrashState(thrash, [ @@ -387,19 +314,16 @@ describe("sub-agent stop helpers", () => { evaluateSubAgentStop({ hasToolCalls: true, lastAssistantText: "", - turnsCompleted: 5, - maxTurns: 20, thrashState: thrash, }), ).toBeNull(); }); test("forcedStopReport is a real envelope with salvage findings, not a summarize instruction", () => { - const budget = forcedStopReport("turn-budget", ""); - const budgetParsed = parseSubAgentReport(budget); - expect(budgetParsed.summary).toContain("Turn budget"); - expect(budgetParsed.findings).toContain("no partial findings"); - expect(budget.toLowerCase()).not.toContain("summarize progress"); + const emptyCancelled = forcedStopReport("cancelled", ""); + const cancelledParsedEmpty = parseSubAgentReport(emptyCancelled); + expect(cancelledParsedEmpty.findings).toContain("no partial findings"); + expect(emptyCancelled.toLowerCase()).not.toContain("summarize progress"); // Nested agent envelope must not clobber the outer cancelled Summary when // runSubAgent re-parses the forced stop. @@ -477,17 +401,17 @@ describe("sub-agent stop helpers", () => { expect(stopReasonFromReport(cancelled)).toBe("cancelled — Session closed"); // Without a detail the line is the bare reason token. expect(stopReasonFromReport(forcedStopReport("cancelled", "partial"))).toBe("cancelled"); - expect(stopReasonFromReport(forcedStopReport("turn-budget", "x", "30/30 turns"))).toBe( - "turn-budget — 30/30 turns", + expect(stopReasonFromReport(forcedStopReport("deadline", "x", "30s elapsed"))).toBe( + "deadline — 30s elapsed", ); // A nested forced-stop quoted in Findings must not leak its Stopped line // as the outer report's reason. const nested = forcedStopReport( - "turn-budget", + "deadline", forcedStopReport("cancelled", "inner", "inner reason"), ); - expect(stopReasonFromReport(nested)).toBe("turn-budget"); + expect(stopReasonFromReport(nested)).toBe("deadline"); // A clean report has no Stopped line. expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null); }); @@ -655,33 +579,13 @@ describe("thrash edge cases", () => { name: "grep", arguments: { pattern, path: "src" }, }); - const stop = (turnsCompleted: number, maxTurns: number, thrashState = EMPTY_THRASH_STATE) => + const stop = (thrashState = EMPTY_THRASH_STATE) => evaluateSubAgentStop({ hasToolCalls: true, lastAssistantText: "", - turnsCompleted, - maxTurns, thrashState, }); - test("report-forced fires once and turn-budget remains reachable", () => { - // Director always passes thrashState, so these are the director's semantics. - expect(stop(27, 30)).toBeNull(); - expect(stop(28, 30)).toBe("report-forced"); // single wrap-up nudge, 2 turns out - expect(stop(29, 30)).toBeNull(); // nudge consumed; leaf keeps working - expect(stop(30, 30)).toBe("turn-budget"); // hard cap still reachable - }); - - test("small maxTurns degrades gracefully instead of collapsing to one turn", () => { - expect(stop(1, 1)).toBe("turn-budget"); // no room for a nudge turn - expect(stop(1, 2)).toBeNull(); - expect(stop(2, 2)).toBe("turn-budget"); - // maxTurns=3: room for exactly one nudge turn before the cap. - expect(stop(1, 3)).toBe("report-forced"); - expect(stop(2, 3)).toBeNull(); - expect(stop(3, 3)).toBe("turn-budget"); - }); - test("an ordinary edit-then-verify loop is not a stop", () => { // edit -> read-back verify, four times, on one file: legitimate iteration. let s = EMPTY_THRASH_STATE; @@ -689,7 +593,7 @@ describe("thrash edge cases", () => { s = nextThrashState(s, [edit("hot.ts")]); s = nextThrashState(s, [read("hot.ts")]); } - expect(stop(8, 30, s)).toBeNull(); + expect(stop(s)).toBeNull(); }); test("chunked reads of a large edited file are not a stop", () => { @@ -701,7 +605,7 @@ describe("thrash edge cases", () => { read("big.ts", { offset: 1000, limit: 500 }), read("big.ts", { offset: 1500, limit: 500 }), ]); - expect(stop(2, 30, s)).toBeNull(); + expect(stop(s)).toBeNull(); }); test("re-reading the same chunk repeatedly is not a stop (CL-6936)", () => { @@ -711,157 +615,7 @@ describe("thrash edge cases", () => { s = nextThrashState(s, [read("big.ts", { offset: 0, limit: 500 })]); } s = nextThrashState(s, [grep("p1"), grep("p2"), grep("p3")]); - expect(stop(6, 30, s)).toBeNull(); - }); -}); - -describe("SubAgentDirector report-forced wiring", () => { - const mockState: ReactorState = { turns: [] } as unknown as ReactorState; - - function makeCapabilities(): ReactorCapabilities { - return { - infer: (options) => - ({ type: "infer", ...(options !== undefined ? { options } : {}) }) as ReactorAction, - executeTools: (calls, parallel, addToHistory) => - ({ type: "execute_tools", calls, parallel, addToHistory }) as ReactorAction, - suspend: (gate) => ({ type: "suspend", gate }) as ReactorAction, - fork: (mode, forkId) => ({ type: "fork", mode, forkId }) as ReactorAction, - emit: (eventType, data) => ({ type: "emit", eventType, data }) as ReactorAction, - reply: (content) => ({ type: "reply", content }) as ReactorAction, - checkpoint: (message = "") => ({ type: "checkpoint", message }) as ReactorAction, - compact: (compactor, reason) => ({ type: "compact", compactor, reason }) as ReactorAction, - wait: () => ({ type: "wait" }) as ReactorAction, - done: () => ({ type: "done" }) as ReactorAction, - }; - } - - function makeInferenceDoneEvent( - toolCalls: { id: string; name: string; args?: Record }[], - ): ReactorInboundEvent { - return { - type: "inference.done", - turn: { - role: "assistant", - model: "test", - timestamp: 0, - content: toolCalls.map((tc) => ({ - type: "tool_call", - id: tc.id, - name: tc.name, - arguments: tc.args ?? {}, - })), - }, - usage: { input: 0, output: 0 }, - source: "test", - } as unknown as ReactorInboundEvent; - } - - function makeToolDoneEvent(callId: string): ReactorInboundEvent { - return { - type: "tool.done", - result: { callId, content: "ok" }, - } as unknown as ReactorInboundEvent; - } - - function actionsArray(result: ReactorAction | ReactorAction[]): ReactorAction[] { - return Array.isArray(result) ? result : [result]; - } - - test("stops and nudges are recorded with their measured value and threshold (CL-6938)", async () => { - const director = new SubAgentDirector("system", [], undefined, 3); - const capabilities = makeCapabilities(); - const recorded: { id: string; class: string; value?: number; threshold?: number }[] = []; - director.observeInterventions((event) => { - recorded.push({ - id: event.id, - class: event.class, - ...(event.measurement !== undefined - ? { - value: event.measurement.value, - ...(event.measurement.threshold !== undefined - ? { threshold: event.measurement.threshold } - : {}), - } - : {}), - }); - }); - - // Turn 1 of 3 fires report-forced (a nudge); continuing past maxTurns - // then fires turn-budget (a stop). - for (let i = 0; i < 6; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: "r1", name: "read_file", args: { path: "a.ts" } }]), - mockState, - capabilities, - ); - } - - const nudge = recorded.find((r) => r.id === "report-forced"); - expect(nudge?.class).toBe("nudge"); - const stop = recorded.find((r) => r.id === "turn-budget"); - expect(stop?.class).toBe("stop"); - expect(stop?.value).toBeGreaterThanOrEqual(stop?.threshold ?? 0); - }); - - // maxTurns=3, forceReportWithin (default 2) → report-forced fires exactly - // at turnsCompleted===1, leaving turns 2 and 3 for turn-budget to remain - // reachable (regression for the report-forced turn-budget blocker). - test("report-forced still executes the pending tool calls, then nudges the follow-up infer", async () => { - const director = new SubAgentDirector("system", [], undefined, 3); - const capabilities = makeCapabilities(); - - // Turn 1: model calls a tool while report-forced's window is active. - const doneEvent = makeInferenceDoneEvent([ - { id: "tc-1", name: "read_file", args: { path: "a.ts" } }, - ]); - const turn1 = actionsArray(await director.decide(doneEvent, mockState, capabilities)); - - // A tool_use turn must be followed by tool_result — report-forced must - // not skip execution and send a bare nudge, or every provider 400s. - const execute = turn1.find((a) => a.type === "execute_tools"); - expect(execute).toBeDefined(); - expect(turn1.some((a) => a.type === "infer")).toBe(false); - - // Turn 1's tool result lands; the follow-up infer must carry the - // ephemeral wrap-up nudge armed by the report-forced turn. - const toolDone = makeToolDoneEvent("tc-1"); - const turn2 = actionsArray(await director.decide(toolDone, mockState, capabilities)); - const infer = turn2.find((a) => a.type === "infer"); - expect(infer).toBeDefined(); - if (infer === undefined || infer.type !== "infer") throw new Error("expected infer action"); - const ephemeralTurns = ( - infer.options as { ephemeralTurns?: { content: { text?: string }[] }[] } - )?.ephemeralTurns; - expect(ephemeralTurns).toBeDefined(); - expect(ephemeralTurns?.[0]?.content?.[0]?.text).toContain("turn budget"); - }); - - test("the nudge fires only once — the next infer after report-forced carries no ephemeral turn", async () => { - const director = new SubAgentDirector("system", [], undefined, 3); - const capabilities = makeCapabilities(); - - await director.decide( - makeInferenceDoneEvent([{ id: "tc-1", name: "read_file", args: { path: "a.ts" } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent("tc-1"), mockState, capabilities); - - // Turn 2 (post-nudge): model calls another tool, well clear of the - // single report-forced window: the follow-up infer must be a plain infer. - const turn2Done = makeInferenceDoneEvent([ - { id: "tc-2", name: "read_file", args: { path: "b.ts" } }, - ]); - await director.decide(turn2Done, mockState, capabilities); - const turn3 = actionsArray( - await director.decide(makeToolDoneEvent("tc-2"), mockState, capabilities), - ); - const infer = turn3.find((a) => a.type === "infer"); - expect(infer).toBeDefined(); - if (infer === undefined || infer.type !== "infer") throw new Error("expected infer action"); - const ephemeralTurns = (infer.options as { ephemeralTurns?: unknown[] } | undefined) - ?.ephemeralTurns; - expect(ephemeralTurns).toBeUndefined(); + expect(stop(s)).toBeNull(); }); }); @@ -916,7 +670,7 @@ describe("SubAgentDirector stall management", () => { test("no nudge fires before the stall timeout elapses", async () => { let now = 0; - const director = new SubAgentDirector("system", [], undefined, 30, 1000, () => now); + const director = new SubAgentDirector("system", [], undefined, 1000, () => now); const capabilities = makeCapabilities(); await director.decide(toolCallDoneEvent("tc-1"), mockState, capabilities); @@ -935,7 +689,7 @@ describe("SubAgentDirector stall management", () => { test("first stall past the timeout gets one continuation nudge", async () => { let now = 0; - const director = new SubAgentDirector("system", [], undefined, 30, 1000, () => now); + const director = new SubAgentDirector("system", [], undefined, 1000, () => now); const capabilities = makeCapabilities(); await director.decide(toolCallDoneEvent("tc-1"), mockState, capabilities); @@ -954,7 +708,7 @@ describe("SubAgentDirector stall management", () => { test("a second consecutive stall escalates to the salvage report", async () => { let now = 0; - const director = new SubAgentDirector("system", [], undefined, 30, 1000, () => now); + const director = new SubAgentDirector("system", [], undefined, 1000, () => now); const capabilities = makeCapabilities(); await director.decide(toolCallDoneEvent("tc-1"), mockState, capabilities); @@ -974,7 +728,7 @@ describe("SubAgentDirector stall management", () => { test("real activity between pings resets the stall streak", async () => { let now = 0; - const director = new SubAgentDirector("system", [], undefined, 30, 1000, () => now); + const director = new SubAgentDirector("system", [], undefined, 1000, () => now); const capabilities = makeCapabilities(); await director.decide(toolCallDoneEvent("tc-1"), mockState, capabilities); @@ -1040,81 +794,6 @@ describe("createTaskTool", () => { expect(result).toContain("## Summary"); }); - test("does not inherit a bogus parent-session maxTurns dep on the task tool", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - maxTurns: 25, - profiles: [{ id: "leaf" }], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - } as Parameters[0] & { maxTurns: number }); - - // Plugin profile (not a director package) so package nudge.maxTurns does not apply. - const result = await callTask(tool, { - description: "Investigate", - prompt: "Do the work", - agent: "leaf", - }); - - expect(result).toContain("done"); - expect(captured).toBeDefined(); - expect(captured?.maxTurns).toBe(Infinity); - }); - - test("uses settings subagentMaxTurns when task and profile omit maxTurns", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: { providers: {}, subagentMaxTurns: 42 }, - profiles: [{ id: "leaf" }], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { - description: "Settings default", - prompt: "Work", - agent: "leaf", - }); - - expect(captured?.maxTurns).toBe(42); - }); - - test("forwards task maxTurns to runSubAgent", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - profiles: [{ id: "leaf" }], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { - description: "Long job", - prompt: "Work", - maxTurns: 50, - agent: "leaf", - }); - - expect(captured?.maxTurns).toBe(50); - }); - test("profile inference rebuilds provider from settings", async () => { let captured: RunSubAgentParams | undefined; const settings = { @@ -1218,119 +897,6 @@ describe("createTaskTool", () => { expect(out).toContain("unavailable"); }); - 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 { report: "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", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => ({ report: "done" }), - }); - - const result = await callTask(tool, { - description: "Too short", - prompt: "Work", - maxTurns: 0, - intent: "explore", - }); - - expect(result).toContain("Error:"); - expect(result).toContain("at least 1"); - }); - - test("uses profile maxTurns when task omits maxTurns", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - profiles: [{ id: "deep", maxTurns: 45 }], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { - description: "Profile budget", - prompt: "Work", - agent: "deep", - }); - - expect(captured?.maxTurns).toBe(45); - }); - - test("task maxTurns overrides profile maxTurns", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - profiles: [{ id: "deep", maxTurns: 45 }], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { - description: "Override", - prompt: "Work", - agent: "deep", - maxTurns: 60, - }); - - expect(captured?.maxTurns).toBe(60); - }); - - test("appends parent hint when the worker hits turn budget", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => ({ - report: forcedStopReport("turn-budget", "partial"), - stopReason: "turn-budget", - }), - }); - - const result = await callTask(tool, { - description: "Budget", - prompt: "Work", - intent: "explore", - }); - - expect(result).toContain("turn budget"); - expect(result).toContain("Turn budget reached"); - }); - test("forwards sandbox deps (permission gate and inherited MCP tools) to runSubAgent", async () => { const inherited = [ { @@ -1784,7 +1350,7 @@ describe("buildDispatchBrief typed spawn contract", () => { }); describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { - test("fingerprint ignores whitespace and omits maxTurns/description", () => { + test("fingerprint ignores whitespace and omits description", () => { const a = fingerprintTaskBrief({ prompt: " map callers of X ", intent: "explore", @@ -1817,11 +1383,11 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { expect(ledger.admit(fp).dispatchCount).toBe(3); }); - test("turn-budget dispatch count advances across repeated same-brief admits", () => { + test("dispatch count advances across repeated same-brief admits", () => { const ledger = createBriefDispatchLedger(); const fp = fingerprintTaskBrief({ prompt: "budget job" }); expect(ledger.admit(fp).dispatchCount).toBe(1); - ledger.recordOutcome(fp, "turn-budget"); + ledger.recordOutcome(fp, "deadline"); const second = ledger.admit(fp); expect(second.dispatchCount).toBe(2); }); @@ -1830,7 +1396,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { const ledger = createBriefDispatchLedger(); const fp = fingerprintTaskBrief({ prompt: "ok job" }); expect(ledger.admit(fp).dispatchCount).toBe(1); - ledger.recordOutcome(fp, "turn-budget"); + ledger.recordOutcome(fp, "deadline"); expect(ledger.admit(fp).dispatchCount).toBe(2); // Success zeros dispatchCount so the next admit is 1. ledger.recordOutcome(fp, null); @@ -1856,18 +1422,6 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { expect(classifyBriefSalvage({ stopReason: "deadline", wasCancelled: true })).toBe("cancelled"); }); - test("turn-budget parent hint flips after re-dispatch threshold", () => { - const report = forcedStopReport("turn-budget", "partial"); - const first = appendSubAgentParentHints(report, "turn-budget", { dispatchCount: 1 }); - expect(first).toContain("higher maxTurns"); - expect(first).not.toContain("re-dispatch cap"); - const third = appendSubAgentParentHints(report, "turn-budget", { - dispatchCount: TURN_BUDGET_STOP_AFTER_DISPATCHES, - }); - expect(third).toContain("re-dispatch cap"); - expect(third).toContain(TURN_BUDGET_STOP_PARENT_HINT.slice(1, 40)); - }); - test("createTaskTool always re-dispatches an identical brief after a forced-stop salvage", async () => { const thrash = { report: forcedStopReport("deadline", "Repeated the same call"), @@ -1903,88 +1457,4 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { expect(sessions.list().filter((s) => s.status === "running")).toHaveLength(0); expect(sessions.list().filter((s) => s.description === "Thrash job")).toHaveLength(1); }); - - test("createTaskTool flips turn-budget hint on third same-brief dispatch", async () => { - const budget = { - report: forcedStopReport("turn-budget", "partial work"), - stopReason: "turn-budget" as const, - }; - let runs = 0; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => { - runs += 1; - return budget; - }, - }); - const args = { description: "Budget job", prompt: "long job", intent: "explore" }; - const r1 = await callTask(tool, args); - expect(r1).toContain("higher maxTurns"); - const r2 = await callTask(tool, { ...args, maxTurns: 50 }); - expect(r2).toContain("higher maxTurns"); - const r3 = await callTask(tool, { ...args, maxTurns: 80 }); - expect(r3).toContain("re-dispatch cap"); - expect(runs).toBe(3); - }); - - test("createTaskTool success resets turn-budget retry budget", async () => { - const budget = { - report: forcedStopReport("turn-budget", "partial"), - stopReason: "turn-budget" as const, - }; - const ok = { - report: "## Summary\nDone\n\n## Findings\nok\n\n## Blockers\nNone\n\n## Paths\n", - }; - let runs = 0; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => { - runs += 1; - // Two budgets, then success, then budget again — post-success should invite maxTurns. - if (runs <= 2 || runs === 4) return budget; - return ok; - }, - }); - const args = { description: "Reset job", prompt: "reset budget", intent: "explore" }; - expect(await callTask(tool, args)).toContain("higher maxTurns"); - expect(await callTask(tool, args)).toContain("higher maxTurns"); - expect(await callTask(tool, args)).toContain("Done"); - const afterSuccess = await callTask(tool, args); - expect(afterSuccess).toContain("higher maxTurns"); - expect(afterSuccess).not.toContain("re-dispatch cap"); - expect(runs).toBe(4); - }); - - test("createTaskTool auth failure does not burn turn-budget count", async () => { - const budget = { - report: forcedStopReport("turn-budget", "partial"), - stopReason: "turn-budget" as const, - }; - let runs = 0; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => { - runs += 1; - if (runs === 1) throw new Error("provider down"); - return budget; - }, - }); - const args = { description: "Crash then budget", prompt: "count carefully", intent: "explore" }; - const fail = await callTask(tool, args); - expect(fail).toContain("failed"); - // First successful body is still dispatchCount 1 → invites higher maxTurns. - const firstBody = await callTask(tool, args); - expect(firstBody).toContain("higher maxTurns"); - expect(firstBody).not.toContain("re-dispatch cap"); - expect(runs).toBe(2); - }); }); diff --git a/src/subagent/index.ts b/src/subagent/index.ts index f24874d12..5666f6dd8 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -21,16 +21,7 @@ export { type FleetObservation, type FleetWatch, } from "./fleet-report.js"; -export { - DEFAULT_THRASH_CONFIG, - EMPTY_THRASH_STATE, - evaluateThrashStop, - nextThrashState, - thrashForceReport, - type ThrashConfig, - type ThrashState, - type ThrashStopReason, -} from "./thrash.js"; +export { EMPTY_THRASH_STATE, nextThrashState, type ThrashState } from "./thrash.js"; export { appendActivitySummary, buildDispatchBrief, @@ -53,8 +44,6 @@ export { preferCompletedSubAgentReply, resolveSubAgentCatchOutcome, resolveSubAgentDeadlineMs, - subAgentTurnLimitExceeded, - TURN_BUDGET_STOP_PARENT_HINT, type ForcedStopReason, type SubAgentCatchOutcome, type SubAgentParentHintOptions, @@ -62,11 +51,9 @@ export { } from "./stop-policy.js"; export { - TURN_BUDGET_STOP_AFTER_DISPATCHES, classifyBriefSalvage, createBriefDispatchLedger, fingerprintTaskBrief, - shouldStopTurnBudgetRedispatch, type BriefDispatchLedger, type BriefDispatchRecord, type BriefSalvageKind, diff --git a/src/subagent/intervention-log.test.ts b/src/subagent/intervention-log.test.ts index 8299d0cb8..4c16255d8 100644 --- a/src/subagent/intervention-log.test.ts +++ b/src/subagent/intervention-log.test.ts @@ -40,25 +40,25 @@ describe("intervention log (CL-6938)", () => { ); sink({ - id: "no-progress", + id: "stalled", class: "stop", - measurement: { metric: "consecutiveIdentical", value: 5, threshold: 5 }, - state: { turnsCompleted: 7, maxTurns: 30, editedPaths: 2 }, - detail: "identical tool call × 5", + measurement: { metric: "idleMs", value: 120000, threshold: 120000 }, + state: { turnsCompleted: 7, editedPaths: 2 }, + detail: "no output for 120s", }); await flush(); const [record] = await readRecords(dir); expect(record).toBeDefined(); expect(record?.ts).toBe("2026-08-23T12:00:00.000Z"); - expect(record?.id).toBe("no-progress"); + expect(record?.id).toBe("stalled"); expect(record?.class).toBe("stop"); expect(record?.family).toBe("grok"); expect(record?.intent).toBe("implement"); expect(record?.measurement).toEqual({ - metric: "consecutiveIdentical", - value: 5, - threshold: 5, + metric: "idleMs", + value: 120000, + threshold: 120000, }); // The false-positive proxy the forensics script reads: a stop that fired on // a run which had already edited files. diff --git a/src/subagent/intervention-log.ts b/src/subagent/intervention-log.ts index c43721119..5e52749f8 100644 --- a/src/subagent/intervention-log.ts +++ b/src/subagent/intervention-log.ts @@ -1,13 +1,10 @@ /** * Intervention log: one record every time the harness decides a run is stuck. * - * We ship ~14 stop reasons and ~20 injected-text interventions, and until now - * there was no way to tell how often any of them was wrong. Every threshold in - * the tree was set by judgment, and the tuning history is a record of that not - * working — a grok 6/10 pair reverted as miscalibrated, IDENTICAL_REPEAT_MIN - * moved 4 -> 5 after polling false positives, a grok stall timeout reverted, - * and TURNS_SINCE_USER_MESSAGE_BACKSTOP resting on a justification the code - * itself retracts (CL-6938). + * There was no way to tell how often a stop or nudge trigger was wrong. Every + * threshold in the tree was set by judgment, and the tuning history is a + * record of that not working — a grok 6/10 pair reverted as miscalibrated, + * and a grok stall timeout reverted (CL-6938). * * The point of this file is that a threshold change can cite data. Each record * carries the trigger's *measured value beside its threshold*, the identity of @@ -60,7 +57,7 @@ export interface InterventionMeasurement { export interface InterventionRecord { ts: string; - /** Stable id of the intervention, e.g. "no-progress", "report-forced". */ + /** Stable id of the intervention, e.g. "stalled", "report-forced". */ id: string; class: InterventionClass; /** "leaf" | "orchestrator" — which side of a dispatch fired it. */ @@ -82,7 +79,6 @@ export interface InterventionRecord { */ state?: { turnsCompleted?: number; - maxTurns?: number; totalToolCalls?: number; readCounts?: number; editedPaths?: number; diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index 5dd90f25b..5d405ffb6 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -199,22 +199,6 @@ describe("SubAgentDirector tool failure recovery", () => { expect(ephemeralTexts(later)).toBeUndefined(); }); - test("near-budget wrap-up nudge wins over failed-tool recovery", async () => { - const director = new SubAgentDirector("system", [], undefined, 3); - const caps = capabilities(); - - await director.decide(inferenceDone(["near-budget-failure"]), state, caps); - const texts = ephemeralTexts( - inferAction(await director.decide(toolDone("near-budget-failure", true), state, caps)), - ); - - expect(texts).toHaveLength(1); - expect(texts?.[0]).toContain("close to your turn budget"); - expect(texts?.[0]).toContain("write your final report now"); - expect(texts?.[0]).not.toContain("A tool call failed"); - expect(texts?.[0]).not.toContain("change the arguments or approach"); - }); - test("failed-tool recovery supersedes soft re-read guidance", async () => { const director = new SubAgentDirector("system", [], undefined, 30); const caps = capabilities(); @@ -314,40 +298,6 @@ describe("SubAgentDirector tool failure recovery", () => { const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); expect(ephemeralTexts(resumed)).toBeUndefined(); }); - - test("retains wrap-up through compaction and consumes it once on continuation infer", async () => { - let continuations = 0; - const director = new SubAgentDirector( - "system", - [], - () => { - continuations++; - }, - 3, - ); - const caps = capabilities(); - - await director.decide(inferenceDone(["near-budget-success"], 999_999), longState, caps); - const compact = actions( - await director.decide(toolDone("near-budget-success"), longState, caps), - ); - expect(compact.some((action) => action.type === "infer")).toBe(false); - expect(compact).toEqual([ - { type: "checkpoint", message: "tool-done" }, - { type: "compact", compactor: "pruning-compactor", reason: "context-threshold" }, - ]); - expect(continuations).toBe(1); - - const resumed = inferAction(await director.decide(messageReceived(""), longState, caps)); - const resumedTexts = ephemeralTexts(resumed); - expect(resumedTexts).toHaveLength(1); - expect(resumedTexts?.[0]).toContain("close to your turn budget"); - expect(resumedTexts?.[0]).toContain("write your final report now"); - expect(resumedTexts?.[0]).not.toContain("A tool call failed"); - - const later = inferAction(await director.decide(messageReceived(""), longState, caps)); - expect(ephemeralTexts(later)).toBeUndefined(); - }); }); const REPORT_ENVELOPE = [ diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 44a060fd8..d3b617816 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -1,5 +1,5 @@ /** - * Sub-agent director: stop policy, wrap-up nudge near turn budget, and stall + * Sub-agent director: stop policy and stall * recovery for quiet leaves. */ @@ -15,12 +15,7 @@ import type { } from "@intx/types/runtime"; import { createCompactionGovernor, type CompactionGovernor } from "../agent/compaction.js"; import { onTurnBoundary } from "../agent/reactor-events.js"; -import { - DEFAULT_THRASH_CONFIG, - EMPTY_THRASH_STATE, - nextThrashState, - type ThrashState, -} from "./thrash.js"; +import { EMPTY_THRASH_STATE, nextThrashState, type ThrashState } from "./thrash.js"; import { NOOP_INTERVENTION_SINK, type InterventionSink } from "./intervention-log.js"; import { evaluateSubAgentStop, @@ -29,9 +24,6 @@ import { type ForcedStopReason, } from "./stop-policy.js"; -const REPORT_FORCED_WRAP_UP_NUDGE = - "You are close to your turn budget. Stop calling tools and write your final report now: summarize what you did, your findings, and any blockers."; - const TOOL_FAILURE_RECOVERY_NUDGE = "A tool call failed. Do not repeat the same failed call unchanged. Inspect the error and current state, then change the arguments or approach. If you cannot recover, report the blocker."; @@ -74,12 +66,11 @@ function withEphemeralNudge( export class SubAgentDirector extends DefaultDirector { private readonly compaction: CompactionGovernor; - private readonly maxTurns: number; /** When true (CritiqueDirector), empty readCounts is not a successful complete. */ private readonly requireEvidence: boolean; private turnsCompleted = 0; private thrashState: ThrashState = EMPTY_THRASH_STATE; - // Armed for wrap-up (report-forced) or failed-tool recovery so the + // Armed for failed-tool recovery so the // follow-up infer (after pending tool calls from THIS turn have executed) // carries the nudge. Cannot attach the nudge to this turn's own infer: the // model just emitted tool_use blocks, and every provider requires tool_result @@ -104,11 +95,11 @@ export class SubAgentDirector extends DefaultDirector { // (directors are pure decide(event, ...) functions — see requestContinuation // above), so the run loop periodically pings this same continuation channel // and the director only acts on a ping if genuinely nothing happened since - // the last one. Precedence: this check sits below turn-budget - // (evaluateSubAgentStop, above) — that fires from real inference.done turns - // and always takes priority; stall pings only ever fire on a continuation - // message that inference.done/tool.done handling did not already consume - // this cycle. + // the last one. Precedence: this check sits below the turn-boundary stop + // checks above (evaluateSubAgentStop) — those fire from real inference.done + // turns and always take priority; stall pings only ever fire on a + // continuation message that inference.done/tool.done handling did not + // already consume this cycle. private readonly stallTimeoutMs: number | undefined; private readonly now: () => number; private lastActivityAt: number; @@ -136,14 +127,12 @@ export class SubAgentDirector extends DefaultDirector { /** Run state every intervention record carries, for judging it afterwards. */ private interventionState(): { turnsCompleted: number; - maxTurns: number; totalToolCalls: number; readCounts: number; editedPaths: number; } { return { turnsCompleted: this.turnsCompleted, - maxTurns: this.maxTurns, totalToolCalls: this.thrashState.totalToolCalls, readCounts: this.thrashState.readCounts.size, editedPaths: this.thrashState.editedPaths.size, @@ -154,14 +143,12 @@ export class SubAgentDirector extends DefaultDirector { systemPrompt: string, toolDefinitions: ToolDefinition[], requestContinuation: (() => void) | undefined, - maxTurns: number, stallTimeoutMs?: number, now: () => number = Date.now, requireEvidence = false, ) { super(systemPrompt, toolDefinitions, {}); this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions); - this.maxTurns = maxTurns; this.stallTimeoutMs = stallTimeoutMs; this.now = now; this.lastActivityAt = now(); @@ -223,8 +210,6 @@ export class SubAgentDirector extends DefaultDirector { const stop = evaluateSubAgentStop({ hasToolCalls, - turnsCompleted: this.turnsCompleted, - maxTurns: this.maxTurns, thrashState: this.thrashState, requireEvidence: this.requireEvidence, lastAssistantText: this.lastAssistantText, @@ -273,52 +258,12 @@ export class SubAgentDirector extends DefaultDirector { if (compacted !== null) return compacted; return terminal; } - if (stop === "report-forced") { - // Not a stop: let the pending tool calls execute as normal (deferring - // to super.decide below), and arm the nudge for the infer that - // follows once their results land. Turn-budget stays reachable — - // this fires once, forceReportWithin turns before the cap. - this.pendingNudgeText = REPORT_FORCED_WRAP_UP_NUDGE; - this.interventions({ - id: "report-forced", - class: "nudge", - measurement: { - metric: "turnsRemaining", - value: this.maxTurns - this.turnsCompleted, - threshold: DEFAULT_THRASH_CONFIG.forceReportWithin, - }, - state: this.interventionState(), - }); - } else if (stop === "turn-budget") { - const checkpoint = "subagent-turn-budget"; - const detail = `${this.turnsCompleted}/${this.maxTurns} turns`; - this.interventions({ - id: stop, - class: "stop", - measurement: { - metric: "turnsCompleted", - value: this.turnsCompleted, - threshold: this.maxTurns, - }, - state: this.interventionState(), - ...(detail !== undefined ? { detail } : {}), - }); - this.onForcedStop(stop); - const terminal: ReactorAction[] = [ - capabilities.checkpoint(checkpoint), - capabilities.reply(forcedStopReport(stop, lastText(content), detail)), - ]; - this.compaction.noteIdleTurn(event, terminal); - const compacted = this.compaction.interceptActions(event, terminal, capabilities); - if (compacted !== null) return compacted; - return terminal; - } } if (event.type === "tool.done") { this.lastActivityAt = this.now(); this.consecutiveStalls = 0; - if (event.result.isError === true && this.pendingNudgeText !== REPORT_FORCED_WRAP_UP_NUDGE) { - // Mandatory wrap-up wins over failed-tool recovery guidance. + if (event.result.isError === true) { + // Failed-tool recovery guidance. this.pendingNudgeText = TOOL_FAILURE_RECOVERY_NUDGE; this.interventions({ id: "tool-failure-recovery", @@ -346,7 +291,7 @@ export class SubAgentDirector extends DefaultDirector { * First stall past the timeout: one continuation nudge, asking the leaf to * report status or keep going. A second consecutive stall (no activity * since the nudge) escalates to the existing salvage path, same shape as - * turn-budget above. Returns null when this event is not + * the turn-boundary checks above. Returns null when this event is not * a stall check the director should act on (let it fall through as an * ordinary continuation). */ diff --git a/src/subagent/report.ts b/src/subagent/report.ts index 6eaf52ebf..d31fcf328 100644 --- a/src/subagent/report.ts +++ b/src/subagent/report.ts @@ -124,7 +124,7 @@ export interface SubAgentReport { paths: string; /** * Machine-readable termination reason for a forced stop (e.g. - * `turn-budget — 40/40 turns`). Rendered as a dedicated + * `stalled — no output for 120s`). Rendered as a dedicated * `Stopped:` line above the envelope; absent on successful completes. */ stopped?: string; diff --git a/src/subagent/run.ts b/src/subagent/run.ts index faf41c857..d30c14ea3 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -71,7 +71,7 @@ import { createCycleTextRecorder } from "../session/stream-journal.js"; import { refreshInferenceSourceBundle } from "./refresh-inference-source.js"; import type { CapabilityFilter } from "../agent/profiles.js"; import type { Settings } from "../config/settings.js"; -import { resolveDefaultSubAgentMaxTurns, toolWatchdogFromSettings } from "../config/settings.js"; +import { toolWatchdogFromSettings } from "../config/settings.js"; import { createSearchAgentsTool } from "../agent/agent-search.js"; import { manageTasksDefinition, parseManageTasksArgs } from "../agent/tasks.js"; import { ID_PREFIX } from "../branding.js"; @@ -212,7 +212,7 @@ export interface SubAgentRunController { * deadline into one abort signal. The run has a single signal to check while * still being able to tell a genuine cancel apart from the deadline firing * (deadlineHit()) when picking a forcedStopReport reason. When deadlineMs is - * omitted, no timer is armed — maxTurns + cancel remain the only bounds. + * omitted, no timer is armed — cancel remains the only bound. */ export function createSubAgentRunController( parentSignal: AbortSignal | undefined, @@ -344,8 +344,8 @@ export async function runSubAgent(params: RunSubAgentParams): Promise | undefined; // Combines the caller's cancel signal with an optional opt-in wall-clock // deadline so a leaf that hits the deadline can still return a salvage - // report. When deadlineMs is omitted, no timer is armed — maxTurns + cancel - // remain the only bounds. Declared before try so finally can dispose. + // report. When deadlineMs is omitted, no timer is armed — cancel remains + // the only bound. Declared before try so finally can dispose. // The task tool is exempt from the generic per-tool watchdog (see // resolveToolExecutionTimeoutMs), so there is no outer budget to clamp under. const resolvedDeadlineMs = @@ -598,8 +598,6 @@ export async function runSubAgent(params: RunSubAgentParams): Promise` on cancel. Absent on clean completes. + * Machine-readable termination reason for a forced stop (stall abort, + * operator cancel) — the report's `Stopped:` line, or `cancelled — ` + * on cancel. Absent on clean completes. */ stopReason?: string; // Session id of the orchestrator that dispatched this worker, when this is @@ -678,7 +678,7 @@ export function createSubAgentSessionStore( clearToolCalls(session); session.report = report; // A forced-stop salvage arrives via complete(); its Stopped: line is - // the terminal reason (repetition / stall / salvage caps). + // the terminal reason (stall abort, etc). const stopped = stopReasonFromReport(report); if (stopped !== null) session.stopReason = stopped; pushEntry(session, { kind: "report", content: capText(report, maxEntryChars) }); diff --git a/src/subagent/spawn-budget.test.ts b/src/subagent/spawn-budget.test.ts deleted file mode 100644 index 9b8d52683..000000000 --- a/src/subagent/spawn-budget.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveSubAgentMaxTurns } from "../config/settings.js"; -import { resolveDirector } from "../agent/directors/registry.js"; -import { resolveDirectorDispatch } from "./agent-fleet.js"; - -describe("spawn_agent vs task() turn budget parity", () => { - for (const id of ["intern", "explore", "build", "critique", "greybeard"]) { - test(`${id}: spawn_agent and task() resolve the same finite budget`, () => { - const resolved = resolveDirector({ agentId: id }); - expect(resolved.ok).toBe(true); - const pkgMax = resolved.ok ? resolved.package.nudge?.maxTurns : undefined; - expect(Number.isFinite(pkgMax)).toBe(true); - - // task() path: passes profileMaxTurns (task-tool.ts:595) - const viaTask = resolveSubAgentMaxTurns({ profileMaxTurns: pkgMax as number }); - expect(viaTask).toBe(pkgMax as number); - - // spawn_agent path: resolveDirectorDispatch now surfaces the same - // package budget, and agent-fleet.ts threads it through. - const dispatch = resolveDirectorDispatch(id, undefined); - expect(dispatch.ok).toBe(true); - const viaSpawn = resolveSubAgentMaxTurns({ - ...(dispatch.ok && dispatch.profileMaxTurns !== undefined - ? { profileMaxTurns: dispatch.profileMaxTurns } - : {}), - }); - expect(viaSpawn).toBe(pkgMax as number); - expect(viaSpawn).toBe(viaTask); - }); - } - - test("non-director dispatch with no explicit maxTurns remains unbounded", () => { - // No agent/intent resolved to a director means no package budget exists; - // this is intentional and must not gain a default cap. - const viaSpawn = resolveSubAgentMaxTurns({}); - expect(viaSpawn).toBe(Infinity); - }); -}); diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 98c73ea9c..466552e30 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -1,12 +1,13 @@ /** - * Pure stop / salvage policy for leaf sub-agents: turn budget, thrash, - * deadlines, and parent-facing salvage reports. + * Pure stop / salvage policy for leaf sub-agents: deadlines and parent-facing + * salvage reports. There is no turn budget — a leaf runs until it produces a + * report envelope, is cancelled, hits an opt-in wall-clock deadline, or stalls. */ import type { ReactorEmittedEvent } from "@intx/inference"; import { onTurnBoundary } from "../agent/reactor-events.js"; -import { evaluateThrashStop, type ThrashConfig, type ThrashState } from "./thrash.js"; import { demoteNestedReportHeadings, formatSubAgentReport, hasReportEnvelope } from "./report.js"; +import type { ThrashState } from "./thrash.js"; // Minimum gap kept between an opt-in internal deadline and the outer // tool-execution watchdog, so there is time left for the salvage report to @@ -16,8 +17,8 @@ export const SUBAGENT_DEADLINE_MARGIN_MS = 30_000; /** * Clamp an explicit opt-in wall-clock deadline to stay a margin below the * effective outer tool-execution watchdog. There is no default leaf deadline — - * maxTurns + operator cancel are the primary bounds; callers pass deadlineMs - * only when they want an extra wall-clock stop. + * operator cancel is the primary bound; callers pass deadlineMs only when they + * want an extra wall-clock stop. * * When the outer watchdog is omitted (undefined), the requested deadline is * kept — an absent settings timeout must not clamp a 5-hour (or any) explicit @@ -68,22 +69,15 @@ export function resolveSubAgentCatchOutcome(input: { return "rethrow"; } -export function subAgentTurnLimitExceeded(turnsCompleted: number, maxTurns: number): boolean { - return turnsCompleted >= maxTurns; -} - -export type SubAgentStopReason = - "complete" | "turn-budget" | "report-forced" | "incomplete-report" | "incomplete-report-stop"; +export type SubAgentStopReason = "complete" | "incomplete-report" | "incomplete-report-stop"; /** * Pure stop decision for leaf workers. Null means keep running tools. * - * "report-forced" and "incomplete-report" - * are not competing stop reasons — they are one-shot signals telling the - * caller to inject a wrap-up / redirect nudge and keep running; turn-budget - * remains reachable afterward. A tool-less turn (including one that never - * called a tool at all) completes only when the assistant text has a - * four-heading envelope (Summary, Findings, Blockers, Paths). Missing + * "incomplete-report" is a one-shot signal telling the caller to inject a + * wrap-up / redirect nudge and keep running. A tool-less turn (including one + * that never called a tool at all) completes only when the assistant text has + * a four-heading envelope (Summary, Findings, Blockers, Paths). Missing * envelope nudges once (`incomplete-report`) then salvages * (`incomplete-report-stop`). * When `requireEvidence` is set (CritiqueDirector), an empty `readCounts` @@ -92,11 +86,6 @@ export type SubAgentStopReason = */ export function evaluateSubAgentStop(input: { hasToolCalls: boolean; - turnsCompleted: number; - maxTurns: number; - /** When set, the near-budget force-report nudge is evaluated after tool-budget checks. */ - thrashState?: ThrashState; - thrashConfig?: Partial; /** * When true (CritiqueDirector leaf), a tool-using run that never * read or searched a file is not a successful complete — even a four-heading @@ -104,6 +93,8 @@ export function evaluateSubAgentStop(input: { * narration as a finished review. */ requireEvidence?: boolean; + /** Read/search bookkeeping for the evidence gate above. */ + thrashState?: ThrashState; /** * Final assistant text of this turn. A missing four-heading envelope * (Summary/Findings/Blockers/Paths) nudges once then salvages. @@ -113,7 +104,7 @@ export function evaluateSubAgentStop(input: { incompleteReportNudgeFired?: boolean; }): SubAgentStopReason | null { // A tool-less turn is complete only with a report envelope. CritiqueDirector - // additionally requires at least one read/search in thrashState.readCounts. + // additionally requires at least one read/search (hasEvidence). if (!input.hasToolCalls) { if (!hasReportEnvelope(input.lastAssistantText)) { return input.incompleteReportNudgeFired === true @@ -130,17 +121,6 @@ export function evaluateSubAgentStop(input: { } return "complete"; } - if (input.thrashState !== undefined) { - const thrashStop = evaluateThrashStop({ - hasToolCalls: true, - turnsCompleted: input.turnsCompleted, - maxTurns: input.maxTurns, - ...(input.thrashConfig !== undefined ? { config: input.thrashConfig } : {}), - }); - if (thrashStop !== null) return thrashStop; - } - - if (subAgentTurnLimitExceeded(input.turnsCompleted, input.maxTurns)) return "turn-budget"; return null; } @@ -148,10 +128,8 @@ export function evaluateSubAgentStop(input: { // tools, at which point its final assistant text is the result handed back to // the dispatcher. It has no submit_output or ask_operator; consequential // tools still go through the parent's permission gate (grants, auto mode, or -// prompts). The hard turn budget stops a leaf that would otherwise burn the -// full budget with no parent-visible report. Near the budget the leaf gets a -// one-shot wrap-up nudge (report-forced) rather than a stop, so turn-budget -// stays reachable for a leaf that is genuinely still making progress. +// prompts). Unbounded runs terminate only on a model-produced report envelope +// or an operator/deadline/stall interrupt — there is no turn cap. export function lastText(content: readonly { type: string }[]): string { for (let i = content.length - 1; i >= 0; i--) { @@ -174,8 +152,7 @@ export function partialTextFromEvent(event: ReactorEmittedEvent): string | null return text.length > 0 ? text : null; } -export type ForcedStopReason = - "turn-budget" | "cancelled" | "deadline" | "stalled" | "incomplete-report"; +export type ForcedStopReason = "cancelled" | "deadline" | "stalled" | "incomplete-report"; // Exact Summary text rendered for each forced-stop reason. Human-facing only — // forcedStopReport is the sole reader; the parent classifies outcomes from the @@ -187,16 +164,15 @@ const FORCED_STOP_SUMMARIES: Record = { stalled: "Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly.", "incomplete-report": "Stopped: worker narrated instead of writing a report envelope.", - "turn-budget": "Turn budget reached before finishing.", }; /** * Build the parent-facing report when a leaf is force-stopped. There is no * further inference, so this must already be a full envelope — not an * instruction asking the finished worker to summarize. `detail` is the - * path-specific specifics (turn counts, cancel reason) rendered verbatim on - * the report's `Stopped:` line so the parent and the TUI see the cause, not - * just that the worker stopped. + * path-specific specifics (cancel reason) rendered verbatim on the report's + * `Stopped:` line so the parent and the TUI see the cause, not just that the + * worker stopped. */ export function forcedStopReport( reason: ForcedStopReason, @@ -211,9 +187,7 @@ export function forcedStopReport( ? "Worker wall-clock deadline elapsed mid-run; parent may re-dispatch with a longer deadline or a narrower scope for the remaining work." : reason === "stalled" ? "Worker went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish or check on the background work directly." - : reason === "incomplete-report" - ? "Worker ended a tool-using run with a tool-less turn that had no four-heading report envelope (Summary/Findings/Blockers/Paths) after a wrap-up nudge. Findings below are the narration, not a structured report." - : "Worker turn budget exhausted; parent may re-dispatch for remaining work."; + : "Worker ended a tool-using run with a tool-less turn that had no four-heading report envelope (Summary/Findings/Blockers/Paths) after a wrap-up nudge. Findings below are the narration, not a structured report."; // Demote nested report-section headings so runSubAgent's parse/format pass // cannot clobber this outer Summary/Blockers with an agent-shaped envelope // stuffed into Findings (cancel after a structured partial). @@ -230,13 +204,6 @@ export function forcedStopReport( }); } -const TURN_BUDGET_PARENT_HINT = - "[Sub-agent hit its turn budget before finishing. Continue from Findings rather than redoing completed work; re-dispatch with continuation context and a higher maxTurns if more work is warranted.]"; - -/** After enough same-brief dispatches, stop inviting another maxTurns bump (CL-4343). */ -export const TURN_BUDGET_STOP_PARENT_HINT = - "[Sub-agent hit its turn budget again on the same brief (re-dispatch cap). Stop raising maxTurns on this fingerprint — restate the task, change approach (intent / success_criteria / do_not / prompt / agent), or finish from Findings. Further identical dispatches are still admitted but will not invite more maxTurns bumps.]"; - const DEADLINE_PARENT_HINT = "[Sub-agent hit an explicit wall-clock deadline before finishing. Continue from Findings rather than redoing completed work; re-dispatch with continuation context and a longer deadline only if more wall-clock time is warranted.]"; @@ -244,15 +211,9 @@ const DEADLINE_PARENT_HINT = export interface SubAgentParentHintOptions { /** * 1-based count of how many times this brief fingerprint has been admitted - * this session (including the run that produced `report`). Used to flip the - * turn-budget hint after repeated same-brief retries. + * this session (including the run that produced `report`). */ dispatchCount?: number; - /** - * After this many total same-brief dispatches, turn-budget salvage recommends - * stopping rather than raising maxTurns. Defaults to 3 (original + 2 retries). - */ - turnBudgetStopAfterDispatches?: number; } /** @@ -264,15 +225,9 @@ export interface SubAgentParentHintOptions { export function appendSubAgentParentHints( report: string, reason: ForcedStopReason | undefined, - options: SubAgentParentHintOptions = {}, + _options: SubAgentParentHintOptions = {}, ): string { switch (reason) { - case "turn-budget": { - const stopAfter = options.turnBudgetStopAfterDispatches ?? 3; - const count = options.dispatchCount ?? 1; - const hint = count >= stopAfter ? TURN_BUDGET_STOP_PARENT_HINT : TURN_BUDGET_PARENT_HINT; - return `${hint}\n\n${report}`; - } case "deadline": return `${DEADLINE_PARENT_HINT}\n\n${report}`; default: diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 8d9153324..6cc91b85d 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -22,11 +22,7 @@ import { } from "../agent/directors/identity.js"; import type { DirectorPackage, SubagentTier } from "../agent/directors/types.js"; import type { Settings } from "../config/settings.js"; -import { - resolveSubAgentMaxTurns, - resolveInferenceWithPolicy, - validateTaskMaxTurns, -} from "../config/settings.js"; +import { resolveInferenceWithPolicy } from "../config/settings.js"; import { resolveEffortForRole, validateEffort, @@ -40,7 +36,6 @@ import { classifyBriefSalvage, createBriefDispatchLedger, fingerprintTaskBrief, - TURN_BUDGET_STOP_AFTER_DISPATCHES, } from "./brief-dispatch.js"; import { createInterventionLog, type InterventionSink } from "./intervention-log.js"; import { detectModelFamily } from "./provider-family.js"; @@ -70,13 +65,18 @@ export const TaskToolArgs = type({ "success_criteria?": "string[]", "do_not?": "string[]", "report_focus?": "string", - "maxTurns?": "number", }); +// Deprecated (CL-7004): task() is the fused, blocking spawn+wait primitive. +// Prefer spawn_agent + wait_agents for new call sites — spawn_agent returns +// immediately and wait_agents blocks on whichever workers you need next, so +// multiple workers do not serialize behind one call. task() is not removed — +// much still routes through it — but new work should reach for the split +// verbs first. export const taskToolDefinition: ToolDefinition = { name: "task", description: - 'Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session\'s permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration ("map every caller of X") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so workers finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.', + 'Deprecated: prefer spawn_agent + wait_agents for new call sites (this fused blocking form is kept for compatibility). Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session\'s permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration ("map every caller of X") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so workers finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover).', inputSchema: { type: "object", properties: { @@ -127,11 +127,6 @@ export const taskToolDefinition: ToolDefinition = { description: "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify capability restrictions and role. Role drives reasoning-effort defaults (orchestrator high, worker medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", }, - maxTurns: { - type: "number", - description: - "Optional inference-turn budget for this worker only (not the parent session limit). Unset is unbounded; minimum 1 when set.", - }, }, required: ["description", "prompt"], }, @@ -322,7 +317,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { success_criteria: rawSuccessCriteria, do_not: rawDoNot, report_focus: rawReportFocus, - maxTurns: rawMaxTurns, } = parsed; const description = rawDesc.trim(); const context = rawCtx?.trim(); @@ -359,7 +353,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { * never left to default once orchestrator is true. */ let orchestratorTier: SubagentTier | undefined; - let profileMaxTurns: number | undefined; let resolvedDirectorId: string | undefined; let resolvedPackage: DirectorPackage | undefined; /** Child-package spawn allowlist to forward into nested task (if this worker may spawn). */ @@ -431,7 +424,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { systemPromptRole = formatDirectorSystemPrompt(pkg); const caps = packageToCapabilities(pkg); if (caps !== undefined) capabilities = caps; - if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { orchestrator = true; orchestratorTier = pkg.tier; @@ -478,9 +470,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (profile.capabilities !== undefined) { capabilities = profile.capabilities; } - if (profile.maxTurns !== undefined) { - profileMaxTurns = profile.maxTurns; - } if (profile.systemPromptRole !== undefined) { systemPromptRole = profile.systemPromptRole; } @@ -523,7 +512,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { systemPromptRole = formatDirectorSystemPrompt(pkg); const caps = packageToCapabilities(pkg); if (caps !== undefined) capabilities = caps; - if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { orchestrator = true; orchestratorTier = pkg.tier; @@ -582,20 +570,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } } - let taskMaxTurns: number | undefined; - if (rawMaxTurns !== undefined) { - const verdict = validateTaskMaxTurns(rawMaxTurns); - if (!verdict.ok) { - return taskToolResult(call.id, `Error: ${verdict.message}`); - } - taskMaxTurns = verdict.value; - } - const resolvedMaxTurns = resolveSubAgentMaxTurns({ - ...(settings !== undefined ? { settings } : {}), - ...(profileMaxTurns !== undefined ? { profileMaxTurns } : {}), - ...(taskMaxTurns !== undefined ? { taskMaxTurns } : {}), - }); - const brief = buildDispatchBrief({ description, prompt, @@ -740,7 +714,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } catch (err) { // Admit already happened and the strip session may be "running" — // release the ledger slot and fail the session so a worktree setup - // error never burns turn-budget budget or leaves a ghost row. + // error never burns re-dispatch bookkeeping or leaves a ghost row. const message = err instanceof WorktreeError ? err.message @@ -819,7 +793,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { nestedDispatch: nestedDispatch!, } : {}), - maxTurns: resolvedMaxTurns, ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), // submit_result mount gate (CL-6946): only a resolved Tier 3 leaf // director gets tier here, and only if it declared an outputType. @@ -848,10 +821,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { provider: lastCycleSource?.provider ?? provider.providerName, model: lastCycleSource?.model ?? provider.model, }); - const hintOptions = { - dispatchCount, - turnBudgetStopAfterDispatches: TURN_BUDGET_STOP_AFTER_DISPATCHES, - }; + const hintOptions = { dispatchCount }; if (wasCancelled) { subagentStatus = "cancelled"; if (session !== undefined && deps.sessions?.get(session.id)?.status === "running") { @@ -899,7 +869,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ); } subagentStatus = "failed"; - // Run never produced a body — undo the admit so turn-budget retry budget + // Run never produced a body — undo the admit so re-dispatch bookkeeping // is not burned by auth/provider crashes. briefLedger.release(fingerprint); const authMessage = formatSubAgentTaskAuthFailureMessage(description, err); diff --git a/src/subagent/thrash.test.ts b/src/subagent/thrash.test.ts index 06132fdc0..d9640bee5 100644 --- a/src/subagent/thrash.test.ts +++ b/src/subagent/thrash.test.ts @@ -1,10 +1,7 @@ import { describe, expect, test } from "bun:test"; import { - DEFAULT_THRASH_CONFIG, EMPTY_THRASH_STATE, - evaluateThrashStop, nextThrashState, - thrashForceReport, type ThrashState, type ThrashToolCallBlock, } from "./thrash.js"; @@ -38,21 +35,6 @@ function applyAll(calls: readonly ThrashToolCallBlock[]): ThrashState { } describe("thrash pure module", () => { - test("defaults keep only the near-budget wrap-up threshold", () => { - expect(DEFAULT_THRASH_CONFIG.forceReportWithin).toBe(2); - expect(Object.keys(DEFAULT_THRASH_CONFIG)).toEqual(["forceReportWithin"]); - }); - - test("re-read pressure is never a stop, at any count (CL-6936)", () => { - const path = "src/subagent/index.ts"; - const calls: ThrashToolCallBlock[] = [edit(path)]; - for (let i = 0; i < 12; i++) calls.push(read(path)); - for (let i = 0; i < 8; i++) calls.push(grep(`p${String(i)}`)); - const state = applyAll(calls); - expect(state.readCounts.get(path)).toBe(12); - expect(evaluateThrashStop({ hasToolCalls: true, turnsCompleted: 3, maxTurns: 25 })).toBeNull(); - }); - test("write_file, delete_file, and apply_patch mark paths as edited", () => { const patch = { type: "tool_call", @@ -67,39 +49,6 @@ describe("thrash pure module", () => { expect(state.editedPaths.has("c.ts")).toBe(true); }); - test("near-budget force-report fires once, leaving turn-budget reachable", () => { - // maxTurns=10, forceReportWithin=2 → single nudge at turnsCompleted === 8. - expect(thrashForceReport(7, 10, true)).toBe(false); - expect(thrashForceReport(8, 10, true)).toBe(true); - expect(thrashForceReport(9, 10, true)).toBe(false); - expect(thrashForceReport(10, 10, true)).toBe(false); - // No tools this turn → not force-report (tool-less turns are complete). - expect(thrashForceReport(8, 10, false)).toBe(false); - - expect(evaluateThrashStop({ hasToolCalls: true, turnsCompleted: 8, maxTurns: 10 })).toBe( - "report-forced", - ); - expect(evaluateThrashStop({ hasToolCalls: true, turnsCompleted: 7, maxTurns: 10 })).toBeNull(); - // Turn-budget remains reachable after the single nudge turn. - expect(evaluateThrashStop({ hasToolCalls: true, turnsCompleted: 9, maxTurns: 10 })).toBeNull(); - expect(evaluateThrashStop({ hasToolCalls: true, turnsCompleted: 10, maxTurns: 10 })).toBeNull(); // turn-budget is the caller's job. - }); - - test("small maxTurns degrades gracefully instead of collapsing to a single turn", () => { - expect(thrashForceReport(1, 1, true)).toBe(false); - expect(thrashForceReport(1, 2, true)).toBe(false); - expect(thrashForceReport(2, 2, true)).toBe(false); - expect(thrashForceReport(1, 3, true)).toBe(true); - expect(thrashForceReport(2, 3, true)).toBe(false); - expect(thrashForceReport(3, 3, true)).toBe(false); - }); - - test("tool-less turns never return a stop reason", () => { - expect( - evaluateThrashStop({ hasToolCalls: false, turnsCompleted: 23, maxTurns: 25 }), - ).toBeNull(); - }); - test("nextThrashState is pure and accumulates across turns", () => { let state = EMPTY_THRASH_STATE; state = nextThrashState(state, [read("a.ts")]); @@ -157,15 +106,4 @@ describe("thrash pure module", () => { const state = applyAll([grep("needle"), grep("needle")]); expect(state.readCounts.get("grep::needle::src")).toBe(2); }); - - test("config overrides apply to evaluateThrashStop", () => { - expect( - evaluateThrashStop({ - hasToolCalls: true, - turnsCompleted: 5, - maxTurns: 10, - config: { forceReportWithin: 5 }, - }), - ).toBe("report-forced"); - }); }); diff --git a/src/subagent/thrash.ts b/src/subagent/thrash.ts index 3bc645f44..b6985dabc 100644 --- a/src/subagent/thrash.ts +++ b/src/subagent/thrash.ts @@ -1,17 +1,9 @@ /** - * Pure near-budget wrap-up detection plus read/edit bookkeeping for dispatched - * workers. Wired into SubAgentDirector via evaluateSubAgentStop. - * - * Re-read pressure is deliberately not a stop signal: the fingerprint period - * detector in stop-policy.ts catches genuinely repeating read cycles on the - * evidence that they repeat, and a raw re-read count cannot tell four reads - * spread across real progress from four reads in a loop (CL-6936). - * - * The state this module accumulates is consumed by evaluateSubAgentStop's - * requireEvidence check, not by a stop of its own. Reads performed through - * run_shell count as evidence there too (CL-6937) — the prompt prohibits - * shell file work, but a prompt violation deserves a correction, not a - * verdict that the work never happened. `editedPaths` (from typed write + * Pure read/edit bookkeeping for dispatched workers, consumed by + * evaluateSubAgentStop's requireEvidence check (CritiqueDirector). Reads + * performed through run_shell count as evidence too (CL-6937) — the prompt + * prohibits shell file work, but a prompt violation deserves a correction, + * not a verdict that the work never happened. `editedPaths` (from typed write * tools only) is diagnostics for interventions.jsonl; no stop decision * depends on it. */ @@ -20,19 +12,6 @@ import { isProductMutationTool, productMutationPaths } from "../agent/product-mu import { PATH_KEYED_READ_TOOLS, SEARCH_QUERY_TOOLS } from "../agent/tool-classification.js"; import { classifyShellFileEvidence } from "./shell-evidence.js"; -/** Tunable thresholds for force-report detection. */ -export interface ThrashConfig { - /** - * When turnsCompleted equals maxTurns - forceReportWithin and the worker is - * still issuing tools, inject a one-shot wrap-up nudge. - */ - forceReportWithin: number; -} - -export const DEFAULT_THRASH_CONFIG: ThrashConfig = { - forceReportWithin: 2, -}; - /** Accumulated read/edit bookkeeping across turns (immutable snapshots). */ export interface ThrashState { readonly readCounts: ReadonlyMap; @@ -46,9 +25,6 @@ export const EMPTY_THRASH_STATE: ThrashState = { totalToolCalls: 0, }; -/** "report-forced" is a near-budget wrap-up-nudge signal, not a stop. */ -export type ThrashStopReason = "report-forced"; - /** Content block shape compatible with fingerprintToolCalls / inference turns. */ export interface ThrashToolCallBlock { type: string; @@ -165,53 +141,3 @@ export function nextThrashState( totalToolCalls, }; } - -/** - * True on the single turn forceReportWithin turns before the cap where the - * leaf is still issuing tools — the signal to inject a wrap-up nudge, not a - * stop. Fires only when that turn leaves at least one further turn before - * maxTurns, so a small budget degrades straight to turn-budget instead of - * spending its only turn on a nudge that never gets to run. - */ -export function thrashForceReport( - turnsCompleted: number, - maxTurns: number, - hasToolCalls: boolean, - config: ThrashConfig = DEFAULT_THRASH_CONFIG, -): boolean { - if (!hasToolCalls) return false; - if (maxTurns <= 0) return false; - const within = Math.max(0, config.forceReportWithin); - const threshold = maxTurns - within; - if (threshold < 1 || threshold >= maxTurns) return false; - return turnsCompleted === threshold; -} - -function resolveConfig(partial?: Partial): ThrashConfig { - if (partial === undefined) return DEFAULT_THRASH_CONFIG; - return { - forceReportWithin: partial.forceReportWithin ?? DEFAULT_THRASH_CONFIG.forceReportWithin, - }; -} - -/** - * Pure force-report decision. Null means keep running (or defer to - * evaluateSubAgentStop for tool-less / fingerprint / hard budget). - * "report-forced" is a one-shot nudge signal, not a stop — the caller injects - * a wrap-up nudge and keeps running. - * - * Only evaluates when hasToolCalls is true. - */ -export function evaluateThrashStop(input: { - hasToolCalls: boolean; - turnsCompleted: number; - maxTurns: number; - config?: Partial; -}): ThrashStopReason | null { - if (!input.hasToolCalls) return null; - const config = resolveConfig(input.config); - if (thrashForceReport(input.turnsCompleted, input.maxTurns, input.hasToolCalls, config)) { - return "report-forced"; - } - return null; -} diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 2042e4644..ca43fdec2 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -130,12 +130,10 @@ export type RunSubAgentParams = { // Present only when orchestrator is true. Installs task + search_agents so // the orchestrator can actually dispatch workers. nestedDispatch?: NestedDispatchDeps; - /** Inference-turn budget for this worker only (not the parent session limit). */ - maxTurns?: number; /** * Optional wall-clock budget for this worker's whole run (ms). Opt-in only — - * there is no default leaf death clock; omit to bound the run with maxTurns - * and operator cancel alone. + * there is no default leaf death clock; omit to bound the run with + * operator cancel alone. */ deadlineMs?: number; /** diff --git a/src/tui/tool-execution-watchdog.test.ts b/src/tui/tool-execution-watchdog.test.ts index 2cf6943c5..84f20c71b 100644 --- a/src/tui/tool-execution-watchdog.test.ts +++ b/src/tui/tool-execution-watchdog.test.ts @@ -41,8 +41,8 @@ describe("tool execution watchdog", () => { }); test("task is exempt from the settings watchdog", () => { - // Sub-agents carry their own bounds (maxTurns, no-progress, thrash, - // opt-in deadline); the generic per-tool budget must not abort them. + // A sub-agent run ends on the model's own finish signal, an opt-in + // deadline, or an operator; the generic per-tool budget must not abort it. const call = { id: "1", name: "task", arguments: {} }; expect(resolveToolExecutionTimeoutMs({ defaultMs: 660_000 }, call)).toBeUndefined(); expect( diff --git a/src/tui/tool-execution-watchdog.ts b/src/tui/tool-execution-watchdog.ts index 8cc786ee1..1b1f48b13 100644 --- a/src/tui/tool-execution-watchdog.ts +++ b/src/tui/tool-execution-watchdog.ts @@ -63,7 +63,7 @@ export const MAX_TOOL_APPROVAL_PAUSE_MS = 1_800_000; /** * Wall-clock budget for one tool `run()`, or undefined to leave the timer unarmed. - * Parent cancel, maxTurns, and eval `--agent-timeout-ms` still bound the run. + * Parent cancel and eval `--agent-timeout-ms` still bound the run. * * Arms only when Settings pass tools.timeoutMs / tools.maxTimeoutMs, or when * run_shell passes a positive arguments.timeout (requested + slack so this @@ -71,7 +71,7 @@ export const MAX_TOOL_APPROVAL_PAUSE_MS = 1_800_000; * to MAX_TOOL_EXECUTION_TIMEOUT_MS or tools.maxTimeoutMs. * * The task tool is exempt: it runs an entire sub-agent that carries its own - * bounds (maxTurns, opt-in deadline), so the generic per-tool budget would + * bound (an opt-in deadline), so the generic per-tool budget would * abort healthy long-running workers mid-run. * * mcp__* tool calls are the opposite of exempt: they arm unconditionally (see