Skip to content

Commit 9fbbbed

Browse files
committed
Merge origin/main into cl-6925-drop-the-15s-default-run_shell-timeout
Keep both Unreleased notes: run_shell timeout and maxTurns cap.
2 parents d83c8b3 + 0a07298 commit 9fbbbed

10 files changed

Lines changed: 93 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2121
timeout and does not invent one on its own. Abort and the output-byte cap are
2222
unchanged.
2323

24+
### Sub-agents
25+
26+
- **Sub-agent `maxTurns` no longer hard-caps at 100.** Default remains 30 when
27+
unset; values must still be integers ≥1. `task(maxTurns)`, profile
28+
`maxTurns`, and `settings.subagentMaxTurns` may exceed 100 for long jobs.
29+
2430
## [0.2.104] - 2026-08-23
31+
2532
### TUI
2633

2734
- **Taller live chain-of-thought preview.** Parent reasoning still paints

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
106106
Two directors, selected by role:
107107

108108
- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode.
109-
- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once then salvages as **incomplete-report**. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 5 consecutive identical tool-call fingerprints (**no-progress**, mirroring the director-level `IDENTICAL_REPEAT_MIN` threshold), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely:
109+
- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once then salvages as **incomplete-report**. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 5 consecutive identical tool-call fingerprints (**no-progress**, mirroring the director-level `IDENTICAL_REPEAT_MIN` threshold), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`; floor ≥1, no hard upper cap), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely:
110110
`runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — format chars (ZWSP, BOM, bidi marks, soft hyphen, …) stripped then whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. `inference.thinking.delta` is sampled the same way on its own buffer, but with digit runs folded to one placeholder and a shorter window (>= 4 chars repeated >= 32 times), gated to periods <= 16 chars once folded: thinking is never rendered to the user, so a monotonic counter (e.g. `0/1 1/2 2/3 …`, which stays non-periodic and escapes the raw-text check) can be caught, but folding still erases real information — a healthy templated enumeration line becomes byte-identical to its neighbors once digits are erased, so the period-length cap only lets counter-shaped folded periods (a handful of chars) through and refuses the much longer periods a folded prose line produces. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. When both report-forced and re-read-nudge apply, report-forced wins (near-budget wrap-up is more urgent than a mid-run redirect). Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator.
111111
Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`): fingerprints cover prompt + agent + intent + success_criteria + do_not (not maxTurns/description/tier). After thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is hard-blocked for the rest of the parent chat; change at least one fingerprint field to force a re-run. Turn-budget salvage still invites a higher maxTurns for a few same-brief retries without a successful complete, then flips the parent hint to stop and change approach (soft — further identical dispatches are still admitted). A successful complete resets the same-brief retry budget.
112112

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ Provider and model configuration lives in JSON settings files. The global file h
246246

247247
On expiry the call returns a normal tool-error result ("MCP tool `<name>` timed out after `<n>`s — the server may be wedged; retry or continue without it") that the model can react to; the turn itself is never aborted. `tools.maxTimeoutMs`, if set, still caps `mcp.timeoutMs`.
248248

249-
Optional `subagentMaxTurns` (integer **1–100**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; values above **100** are rejected on `task` and clamped for profiles. Always applies — the primary session is always orchestrator-capable (CL-5814).
249+
Optional `subagentMaxTurns` (integer **≥1**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; there is no hard upper cap (values are floor-sanitized to ≥1). Always applies — the primary session is always orchestrator-capable (CL-5814).
250250

251251
Optional `sessionMode` is **deprecated**. Legacy values (`single` | `orchestrator`) may still appear on disk and load without error; resolve always returns **orchestrator**. There is no first-run mode picker and no Settings row. Both the interactive TUI (`runTUI`) and the non-TUI product path (`runExec` / `corbits exec`) are orchestrator-only. Exec bootstrap is otherwise a forked copy of the TUI path (shared stack, intentional deltas documented under Architecture → Exec Runner).
252252

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Corbits Code fans work out to short-lived **sub-agents** — child agents with t
159159
- **Tasks** are checklist items owned by one agent via `manage_tasks`.
160160
- **Sub-agents** are spawned with the `task` tool (wire name kept; meaning is "spawn a child agent," not "add a checklist item").
161161

162-
Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; cap 100), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). Progressive re-read thrash also hard-stops a worker that keeps re-reading the same path (or the same grep) past a limit. Look _volume_ is not a stop — an implement may read hundreds of files before the first edit. Before a hard stop, a soft mid-run nudge asks implement workers to edit or wrap up (explore workers: expand findings / change approach — never forced to edit). Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone.
162+
Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; no hard upper cap), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). Progressive re-read thrash also hard-stops a worker that keeps re-reading the same path (or the same grep) past a limit. Look _volume_ is not a stop — an implement may read hundreds of files before the first edit. Before a hard stop, a soft mid-run nudge asks implement workers to edit or wrap up (explore workers: expand findings / change approach — never forced to edit). Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone.
163163
The parent tracks same-brief fingerprints for the session (`src/subagent/brief-dispatch.ts`): after thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is refused — change prompt, agent, intent, success_criteria, and/or do_not to unlock a new run (`maxTurns` or tier alone does not). Turn-budget salvage still allows a few same-brief retries with a higher `maxTurns`, then flips the parent hint to stop and change approach; a successful complete resets the same-brief retry budget.
164164

165165
## Roadmap (planned, not yet shipped)

src/agent/profile-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export interface AgentProfile {
7474
// leaf-task agents should leave this unset.
7575
orchestrator?: boolean;
7676
// Optional inference-turn budget when this profile is dispatched via task(agent=...).
77-
// Clamped to the global cap at dispatch time; task(maxTurns) overrides when set.
77+
// Floor-sanitized (≥1) at dispatch time; task(maxTurns) overrides when set.
7878
maxTurns?: number;
7979
// Where the profile came from, for search_agents labeling (e.g. "claude",
8080
// "plugin:<id>", "local"). Omitted for built-in defaults.

src/agent/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export function buildGuidelines(
152152
"- Break multi-step or parallel work into focused `task` dispatches with distinct lenses; prefer several parallel task calls when jobs are independent.",
153153
"- 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.",
154154
"- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.",
155-
"- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, cap 100). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.",
155+
"- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, no hard upper cap). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.",
156156
"- After thrash / no-progress / repetition / never-acted salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.",
157157
"- Use manage_tasks for your own coordination checklist; spawning workers is `task`, not manage_tasks.",
158158
"- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.",

src/config/settings.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,11 @@ export function shellEnvFromSettings(
286286
}
287287

288288
export const DEFAULT_SUBAGENT_MAX_TURNS = 30;
289-
export const MAX_SUBAGENT_MAX_TURNS_CAP = 100;
290289

290+
/** Floor-only sanitization: ≥1 integer. No upper hard cap. */
291291
export function clampSubAgentMaxTurns(value: number): number {
292292
if (!Number.isFinite(value)) return DEFAULT_SUBAGENT_MAX_TURNS;
293-
return Math.min(MAX_SUBAGENT_MAX_TURNS_CAP, Math.max(1, Math.floor(value)));
293+
return Math.max(1, Math.floor(value));
294294
}
295295

296296
export function resolveDefaultSubAgentMaxTurns(settings?: Settings | null): number {
@@ -309,12 +309,6 @@ export function validateTaskMaxTurns(value: number): TaskMaxTurnsValidation {
309309
if (value < 1) {
310310
return { ok: false, message: "maxTurns must be at least 1." };
311311
}
312-
if (value > MAX_SUBAGENT_MAX_TURNS_CAP) {
313-
return {
314-
ok: false,
315-
message: `maxTurns cannot exceed ${MAX_SUBAGENT_MAX_TURNS_CAP}.`,
316-
};
317-
}
318312
return { ok: true, value };
319313
}
320314

@@ -533,7 +527,7 @@ export function isSettings(value: unknown): value is Settings {
533527
if (s.mcpServers !== undefined && normalizeMcpServers(s.mcpServers) === undefined) return false;
534528
if (s.subagentMaxTurns !== undefined) {
535529
const n = s.subagentMaxTurns;
536-
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_SUBAGENT_MAX_TURNS_CAP) {
530+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
537531
return false;
538532
}
539533
}

src/settings.test.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
saveLocalSettings,
1818
type Settings,
1919
DEFAULT_SUBAGENT_MAX_TURNS,
20-
MAX_SUBAGENT_MAX_TURNS_CAP,
2120
resolveDefaultSubAgentMaxTurns,
2221
resolveSubAgentMaxTurns,
2322
clampSubAgentMaxTurns,
@@ -947,17 +946,35 @@ describe("subagentMaxTurns", () => {
947946
expect(resolveSubAgentMaxTurns({ settings, profileMaxTurns: 55, taskMaxTurns: 70 })).toBe(70);
948947
});
949948

950-
test("clampSubAgentMaxTurns enforces floor and cap", () => {
949+
test("clampSubAgentMaxTurns enforces floor only", () => {
951950
expect(clampSubAgentMaxTurns(0)).toBe(1);
952-
expect(clampSubAgentMaxTurns(150)).toBe(MAX_SUBAGENT_MAX_TURNS_CAP);
951+
expect(clampSubAgentMaxTurns(-5)).toBe(1);
952+
expect(clampSubAgentMaxTurns(150)).toBe(150);
953+
expect(clampSubAgentMaxTurns(500)).toBe(500);
953954
});
954955

955-
test("validateTaskMaxTurns rejects out of range", () => {
956-
expect(validateTaskMaxTurns(101).ok).toBe(false);
956+
test("validateTaskMaxTurns accepts values above 100 and rejects below 1", () => {
957+
const high = validateTaskMaxTurns(500);
958+
expect(high.ok).toBe(true);
959+
if (high.ok) {
960+
expect(high.value).toBe(500);
961+
}
962+
expect(validateTaskMaxTurns(101).ok).toBe(true);
957963
expect(validateTaskMaxTurns(0).ok).toBe(false);
964+
expect(validateTaskMaxTurns(-1).ok).toBe(false);
958965
expect(validateTaskMaxTurns(50).ok).toBe(true);
959966
});
960967

968+
test("resolveSubAgentMaxTurns keeps high task and profile budgets", () => {
969+
expect(resolveSubAgentMaxTurns({ taskMaxTurns: 500 })).toBe(500);
970+
expect(resolveSubAgentMaxTurns({ profileMaxTurns: 250 })).toBe(250);
971+
expect(
972+
resolveSubAgentMaxTurns({
973+
settings: { providers: {}, subagentMaxTurns: 400 },
974+
}),
975+
).toBe(400);
976+
});
977+
961978
test("loadSettings round-trips subagentMaxTurns", async () => {
962979
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
963980
try {
@@ -969,6 +986,17 @@ describe("subagentMaxTurns", () => {
969986
}
970987
});
971988

989+
test("loadSettings round-trips subagentMaxTurns above 100", async () => {
990+
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
991+
try {
992+
const path = join(dir, ".corbits", "settings.json");
993+
await saveGlobalSettings(path, { ...firepass, subagentMaxTurns: 500 });
994+
expect(await loadSettings(path)).toEqual({ ...firepass, subagentMaxTurns: 500 });
995+
} finally {
996+
await rm(dir, { recursive: true, force: true });
997+
}
998+
});
999+
9721000
test("rejects invalid subagentMaxTurns in settings", () => {
9731001
expect(
9741002
isSettings({
@@ -979,10 +1007,25 @@ describe("subagentMaxTurns", () => {
9791007
expect(
9801008
isSettings({
9811009
providers: firepass.providers,
982-
subagentMaxTurns: 101,
1010+
subagentMaxTurns: 1.5,
9831011
}),
9841012
).toBe(false);
9851013
});
1014+
1015+
test("accepts subagentMaxTurns above 100 in settings", () => {
1016+
expect(
1017+
isSettings({
1018+
providers: firepass.providers,
1019+
subagentMaxTurns: 500,
1020+
}),
1021+
).toBe(true);
1022+
expect(
1023+
isSettings({
1024+
providers: firepass.providers,
1025+
subagentMaxTurns: 101,
1026+
}),
1027+
).toBe(true);
1028+
});
9861029
});
9871030

9881031
describe("saveGlobalSettings", () => {

0 commit comments

Comments
 (0)