Skip to content

Commit 4b543f4

Browse files
Delete the turn-budget mechanism and deprecate task() (#625)
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.
1 parent 16e1e41 commit 4b543f4

71 files changed

Lines changed: 147 additions & 1436 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

1616
### Agent
1717

18-
- `spawn_agent` now threads the resolved director package's `nudge.maxTurns`
19-
budget the same way `task()` does, closing a parity gap where a director
20-
dispatched via `spawn_agent` resolved to an unbounded turn budget instead of
21-
its configured finite one. Removed the false "hard cap 4 workers" claim from
22-
director prompt text (no such cap exists anywhere in the fleet code). The
23-
unused `maxTurns` field on project/named profile files
24-
(`.corbits/profile.json`, `~/.corbits/profiles/<name>.json`) has been
25-
removed since nothing read it — a silently-ignored knob is worse than no
26-
knob.
18+
- Deleted the sub-agent turn-budget mechanism entirely: `maxTurns` is gone
19+
from `task()`, `spawn_agent`, `AgentProfile`, director packages'
20+
`nudge.maxTurns`, and `settings.subagentMaxTurns`; a leaf now runs until it
21+
produces a report envelope, is cancelled, hits an opt-in wall-clock
22+
deadline, or stalls — never on a turn count. Removed
23+
`resolveSubAgentMaxTurns` / `resolveDefaultSubAgentMaxTurns` /
24+
`clampSubAgentMaxTurns` / `validateTaskMaxTurns` (`src/config/settings.ts`),
25+
the `turn-budget` stop reason and its report text/parent hint, the
26+
near-budget `report-forced` wrap-up nudge, and the re-dispatch ledger's
27+
turn-budget branch (the `higher maxTurns` / re-dispatch-cap hints). This
28+
also retires the `spawn_agent`/`task()` `nudge.maxTurns` parity fix shipped
29+
an hour prior — with the mechanism itself gone, that parity is moot.
30+
`task()` is now marked deprecated in favor of `spawn_agent` + `wait_agents`
31+
for new call sites; it is not removed since most dispatch still routes
32+
through it. Removed the false "hard cap 4 workers" claim from director
33+
prompt text (no such cap exists anywhere in the fleet code). The unused
34+
`maxTurns` field on project/named profile files (`.corbits/profile.json`,
35+
`~/.corbits/profiles/<name>.json`) has been removed since nothing read it —
36+
a silently-ignored knob is worse than no knob. If a run needs stopping, the
37+
operator interrupts it (`interrupt_agent`) rather than the harness enforcing
38+
a count.
2739

2840
- `evaluateSubAgentStop` now always requires the final assistant text; the
2941
omitted-text branch that unconditionally completed a tool-less turn is

evals/capability/README.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ Everything the product path already observes is recorded:
8484
| `turnsUsed` | turn collector |
8585
| `toolCallCount` | turn collector |
8686
| `tokenUsage` | `{ input, output, cacheRead, cacheWrite, thinking }` |
87-
| `maxTurns` / `overBudget` | case budget vs turns used |
8887
| `provider` / `model` / `variantId` | resolved config for that cell (`variantId` is `provider:model` by default) |
8988
| `skipPermissions` | whether permissions were skipped |
9089
| `repeat` | 0-based repeat index within the case×variant cell |
@@ -193,7 +192,6 @@ Flags:
193192
| `--out <path>` | Write machine-readable results JSON |
194193
| `--baseline <path>` | Compare this run to a prior results file (improve/regress + metric deltas) |
195194
| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` |
196-
| `--max-turns <n>` | 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 |
197195
| `--agent-timeout-ms <n>` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) |
198196
| `--verify-timeout-ms <n>` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) |
199197
| `--repeats <n>` | 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)
217215
- `title` — human label
218216
- `fixture` — path relative to repo root (copied into a temp workdir)
219217
- `prompt` — task text for `corbits exec`
220-
- `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).
221218
- `verify` — grader filename (default `verify.sh`)
222219
- `bait` — optional `{ metric, threshold }` marking the behavior metric this case reproduces (see the bait table above)
223220
- `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)
280277
"cacheWrite": 0,
281278
"thinking": 0
282279
},
283-
"maxTurns": 20,
284-
"overBudget": false,
285280
"skipPermissions": true,
286281
"error": null,
287282
"repeat": 0,

evals/capability/cases/tier-easy/case.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"title": "Add GET /version to a two-file service",
55
"fixture": "tests/fixtures/tier-easy",
66
"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.",
7-
"maxTurns": 15,
87
"verify": "verify.sh",
98
"requireBehaviors": [
109
{ "metric": "editViaShellCount", "max": 0 },

evals/capability/cases/tier-hard/case.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"title": "Crash implicates the wrong module; root cause is one hop away",
55
"fixture": "tests/fixtures/tier-hard",
66
"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.",
7-
"maxTurns": 30,
87
"verify": "verify.sh",
98
"requireBehaviors": [
109
{ "metric": "editViaShellCount", "max": 0 },

evals/capability/cases/tier-med/case.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"title": "Fix the live fee amid three disagreeing decoy sources",
55
"fixture": "tests/fixtures/tier-med",
66
"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.",
7-
"maxTurns": 25,
87
"verify": "verify.sh",
98
"requireBehaviors": [{ "metric": "editViaShellCount", "max": 0 }]
109
}

evals/capability/cases/tier-xhard/case.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"title": "Green functional suite, unshipped design (prod-shape rubric)",
55
"fixture": "tests/fixtures/tier-xhard",
66
"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.",
7-
"maxTurns": 40,
87
"verify": "verify.sh",
98
"requireBehaviors": [{ "metric": "editViaShellCount", "max": 0 }]
109
}

evals/capability/lib.test.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
makeResultKey,
1515
defaultVariantId,
1616
emptyTokenUsage,
17-
evaluateSoftBudget,
1817
checkBehaviorRequirements,
1918
computeCellAggregates,
2019
baitReproduces,
@@ -70,8 +69,6 @@ function sampleResult(over: Partial<CaseResult> = {}): CaseResult {
7069
cacheWrite: 0,
7170
thinking: 0,
7271
},
73-
maxTurns: over.maxTurns ?? 20,
74-
overBudget: over.overBudget ?? false,
7572
skipPermissions: over.skipPermissions ?? true,
7673
error: over.error ?? null,
7774
repeat: over.repeat ?? 0,
@@ -114,7 +111,6 @@ describe("parseCaseJson", () => {
114111
);
115112
expect(c.id).toBe("simple-health");
116113
expect(c.verify).toBe("verify.sh");
117-
expect(c.maxTurns).toBeUndefined();
118114
});
119115

120116
test("parses a bait case with http fixture", () => {
@@ -404,34 +400,6 @@ describe("summarizeRun", () => {
404400
});
405401
});
406402

407-
describe("evaluateSoftBudget", () => {
408-
test("null maxTurns means budget not in force", () => {
409-
expect(evaluateSoftBudget({ maxTurns: null, turnsUsed: 99 })).toEqual({
410-
overBudget: null,
411-
budgetError: null,
412-
});
413-
});
414-
415-
test("fails closed when maxTurns set but turnsUsed missing", () => {
416-
const r = evaluateSoftBudget({ maxTurns: 10, turnsUsed: null });
417-
expect(r.overBudget).toBe(true);
418-
expect(r.budgetError).toMatch(/not reported/);
419-
});
420-
421-
test("over budget when turns exceed max", () => {
422-
const r = evaluateSoftBudget({ maxTurns: 5, turnsUsed: 6 });
423-
expect(r.overBudget).toBe(true);
424-
expect(r.budgetError).toMatch(/over turn budget/);
425-
});
426-
427-
test("within budget", () => {
428-
expect(evaluateSoftBudget({ maxTurns: 10, turnsUsed: 10 })).toEqual({
429-
overBudget: false,
430-
budgetError: null,
431-
});
432-
});
433-
});
434-
435403
describe("computeCellAggregates", () => {
436404
test("aggregates repeats per cell with pass rate and behavior stats", () => {
437405
const results = [

evals/capability/lib.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ export interface EvalCase {
6060
/** Fixture path relative to the repository root. */
6161
fixture: string;
6262
prompt: string;
63-
maxTurns?: number;
6463
/** Grader filename relative to the case directory (default verify.sh). */
6564
verify: string;
6665
/** Absolute path to the case directory on disk. */
@@ -139,9 +138,6 @@ export interface CaseResult {
139138
turnsUsed: number | null;
140139
toolCallCount: number | null;
141140
tokenUsage: EvalTokenUsage | null;
142-
maxTurns: number | null;
143-
/** True when turnsUsed exceeded the configured maxTurns budget. */
144-
overBudget: boolean | null;
145141
skipPermissions: boolean;
146142
error: string | null;
147143
/** 0-based repeat index within the case×variant cell. */
@@ -277,10 +273,6 @@ export function parseCaseJson(raw: unknown, caseDir: string): EvalCase {
277273
throw new Error(`case ${id}: missing prompt`);
278274
}
279275
const verify = typeof raw.verify === "string" && raw.verify.length > 0 ? raw.verify : "verify.sh";
280-
const maxTurns =
281-
typeof raw.maxTurns === "number" && Number.isFinite(raw.maxTurns) && raw.maxTurns > 0
282-
? Math.floor(raw.maxTurns)
283-
: undefined;
284276
const bait = parseBait(raw.bait, id);
285277
const httpFixture = raw.httpFixture === true ? true : undefined;
286278
const requireBehaviors = parseRequireBehaviors(raw.requireBehaviors, id);
@@ -292,7 +284,6 @@ export function parseCaseJson(raw: unknown, caseDir: string): EvalCase {
292284
prompt,
293285
verify,
294286
caseDir,
295-
...(maxTurns !== undefined ? { maxTurns } : {}),
296287
...(bait !== undefined ? { bait } : {}),
297288
...(httpFixture !== undefined ? { httpFixture } : {}),
298289
...(requireBehaviors !== undefined ? { requireBehaviors } : {}),
@@ -670,35 +661,6 @@ export function summarizeRun(results: readonly CaseResult[]): EvalRunTotals {
670661
};
671662
}
672663

673-
/**
674-
* Soft turn-budget evaluation. When maxTurns is set:
675-
* - missing turnsUsed → fail closed (overBudget true) so a broken metrics path
676-
* cannot silently pass a budgeted case
677-
* - turnsUsed > maxTurns → overBudget true
678-
* When maxTurns is unset, overBudget is null (budget not in force).
679-
*/
680-
export function evaluateSoftBudget(args: { maxTurns: number | null; turnsUsed: number | null }): {
681-
overBudget: boolean | null;
682-
budgetError: string | null;
683-
} {
684-
if (args.maxTurns === null) {
685-
return { overBudget: null, budgetError: null };
686-
}
687-
if (args.turnsUsed === null) {
688-
return {
689-
overBudget: true,
690-
budgetError: `turn budget set (${args.maxTurns}) but turnsUsed was not reported`,
691-
};
692-
}
693-
if (args.turnsUsed > args.maxTurns) {
694-
return {
695-
overBudget: true,
696-
budgetError: `over turn budget (${args.turnsUsed} > ${args.maxTurns})`,
697-
};
698-
}
699-
return { overBudget: false, budgetError: null };
700-
}
701-
702664
function parseTokenUsage(raw: unknown): EvalTokenUsage | null {
703665
if (!isRecord(raw)) return null;
704666
const num = (k: string): number =>
@@ -751,8 +713,6 @@ function parseCaseResult(raw: unknown): CaseResult {
751713
turnsUsed: typeof raw.turnsUsed === "number" ? raw.turnsUsed : null,
752714
toolCallCount: typeof raw.toolCallCount === "number" ? raw.toolCallCount : null,
753715
tokenUsage: parseTokenUsage(raw.tokenUsage),
754-
maxTurns: typeof raw.maxTurns === "number" ? raw.maxTurns : null,
755-
overBudget: typeof raw.overBudget === "boolean" ? raw.overBudget : null,
756716
skipPermissions: Boolean(raw.skipPermissions ?? true),
757717
error: typeof raw.error === "string" ? raw.error : raw.error === null ? null : null,
758718
repeat:

scripts/eval-capability.ts

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import {
4343
parseMatrix,
4444
expandMatrix,
4545
makeResultKey,
46-
evaluateSoftBudget,
4746
checkBehaviorRequirements,
4847
httpFixtureEnv,
4948
withEnv,
@@ -79,7 +78,6 @@ interface CliOptions {
7978
outPath?: string;
8079
baselinePath?: string;
8180
skipPermissions: boolean;
82-
maxTurnsOverride?: number;
8381
/** Wall-clock limit for runExec (ms). */
8482
agentTimeoutMs: number;
8583
/** Wall-clock limit for verify.sh (ms). */
@@ -118,7 +116,6 @@ function printUsage(): void {
118116
--out <path> Write results JSON
119117
--baseline <path> Compare to prior results JSON
120118
--ask-permissions Do not pass --dangerously-skip-permissions
121-
--max-turns <n> Soft turn budget (case fails if turnsUsed exceeds; not a hard kill)
122119
--agent-timeout-ms <n> Wall-clock limit for runExec (default 1200000)
123120
--verify-timeout-ms <n> Wall-clock limit for verify.sh (default 120000)
124121
--repeats <n> Runs per case×variant cell (default 1; gate runs use 5)
@@ -230,12 +227,6 @@ export function parseArgs(argv: readonly string[]): CliOptions {
230227
case "--ask-permissions":
231228
opts.skipPermissions = false;
232229
break;
233-
case "--max-turns": {
234-
const n = Number(next());
235-
if (!Number.isFinite(n) || n <= 0) throw new Error("--max-turns must be a positive number");
236-
opts.maxTurnsOverride = Math.floor(n);
237-
break;
238-
}
239230
case "--agent-timeout-ms": {
240231
const n = Number(next());
241232
if (!Number.isFinite(n) || n <= 0) {
@@ -659,7 +650,6 @@ function failResult(
659650
error: string,
660651
partial?: Partial<CaseResult>,
661652
): CaseResult {
662-
const maxTurns = opts.maxTurnsOverride ?? caseDef.maxTurns ?? null;
663653
return {
664654
resultKey: makeResultKey(variant.id, caseDef.id),
665655
id: caseDef.id,
@@ -679,8 +669,6 @@ function failResult(
679669
turnsUsed: null,
680670
toolCallCount: null,
681671
tokenUsage: null,
682-
maxTurns,
683-
overBudget: null,
684672
skipPermissions: opts.skipPermissions,
685673
error,
686674
repeat,
@@ -741,10 +729,6 @@ async function runCase(
741729
argv.push("--force");
742730
if (opts.director !== undefined) argv.push("--director", opts.director);
743731

744-
const maxTurns = opts.maxTurnsOverride ?? caseDef.maxTurns ?? null;
745-
// maxTurns is a soft post-run budget (case fails if exceeded). It does not
746-
// hard-kill the agent mid-run — product path has no mid-turn budget hook yet.
747-
748732
argv.push(prompt);
749733

750734
const config = await loadConfig(argv, { allowUnconfigured: false });
@@ -831,23 +815,14 @@ async function runCase(
831815
}
832816
console.log(`verify exit: ${verify.exitCode} (${verify.durationMs}ms)`);
833817

834-
// Soft maxTurns: fail when exceeded; fail closed when turns weren't reported.
835-
const budget = evaluateSoftBudget({ maxTurns, turnsUsed });
836-
const overBudget = budget.overBudget;
837818
// requireBehaviors can fail a green agent+verify run (e.g. web-bait honesty).
838-
const passed =
839-
agentExitCode === 0 &&
840-
verify.exitCode === 0 &&
841-
overBudget !== true &&
842-
requireBehaviorCheck.ok;
819+
const passed = agentExitCode === 0 && verify.exitCode === 0 && requireBehaviorCheck.ok;
843820
const preview =
844821
execResult.text.length > 400 ? `${execResult.text.slice(0, 400)}…` : execResult.text;
845822

846823
let error: string | null = null;
847824
if (!passed) {
848-
if (budget.budgetError !== null) {
849-
error = budget.budgetError;
850-
} else if (!requireBehaviorCheck.ok) {
825+
if (!requireBehaviorCheck.ok) {
851826
error = requireBehaviorCheck.failures.join("; ");
852827
} else if (verify.timedOut) {
853828
error = `verify timed out after ${opts.verifyTimeoutMs}ms`;
@@ -886,8 +861,6 @@ async function runCase(
886861
turnsUsed,
887862
toolCallCount,
888863
tokenUsage,
889-
maxTurns,
890-
overBudget,
891864
skipPermissions: opts.skipPermissions,
892865
error,
893866
repeat,
@@ -923,7 +896,6 @@ function formatMetricsLine(r: CaseResult): string {
923896
if (r.tokenUsage !== null) {
924897
parts.push(`tok=${r.tokenUsage.input}+${r.tokenUsage.output}`);
925898
}
926-
if (r.overBudget === true) parts.push("OVER_BUDGET");
927899
return parts.join(" ");
928900
}
929901

0 commit comments

Comments
 (0)