Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
format, so it was declared but unreachable. Fail-closed behavior is
unchanged — a profile-sourced orchestrator is still denied `task`/
`search_agents` with no supported opt-in.
- Removed the default 30-turn leaf sub-agent ceiling; an unset `maxTurns` now runs unbounded (explicit budgets still apply).
- Deleted two unenforced orchestrator prompt rules: a "4 workers at once" fan-out cap and a same-agent lane-disjointness rule.

## [0.2.108] - 2026-08-24

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
Two directors, selected by role:

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

Expand Down
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Corbits Code fans work out to short-lived **sub-agents** — child agents with t
- **Tasks** are checklist items owned by one agent via `manage_tasks`.
- **Sub-agents** are spawned with the `task` tool (wire name kept; meaning is "spawn a child agent," not "add a checklist item").

Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; no hard upper cap), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). File work done through the shell counts as real work here even though the prompt asks for the typed tools: a prompt violation earns a correction, not a verdict that the work never happened. Re-read counts never hard-stop a worker, and look _volume_ is not a stop either — an implement may read hundreds of files before the first edit, and a repeating read cycle is caught by fingerprint detection instead. Near the turn budget a one-shot nudge asks the worker to wrap up and write its report. Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone.
Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (no default cap; unbounded unless `maxTurns` is set per dispatch, by profile, or by `settings.subagentMaxTurns`), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). File work done through the shell counts as real work here even though the prompt asks for the typed tools: a prompt violation earns a correction, not a verdict that the work never happened. Re-read counts never hard-stop a worker, and look _volume_ is not a stop either — an implement may read hundreds of files before the first edit, and a repeating read cycle is caught by fingerprint detection instead. Near the turn budget a one-shot nudge asks the worker to wrap up and write its report. Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone.
The parent tracks same-brief fingerprints for the session (`src/subagent/brief-dispatch.ts`): after no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is refused — change prompt, agent, intent, success_criteria, and/or do_not to unlock a new run (`maxTurns` or tier alone does not). Turn-budget salvage still allows a few same-brief retries with a higher `maxTurns`, then flips the parent hint to stop and change approach; a successful complete resets the same-brief retry budget.

## Roadmap (planned, not yet shipped)
Expand Down
2 changes: 0 additions & 2 deletions src/agent/directors/skywalker/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ describe("skywalkerPackage", () => {
expect(p).toContain("fan-out");
expect(p).toContain("0–1 worker");
expect(p).toContain("2–4 workers");
expect(p).toContain("split ownership by path/package");
expect(p).toContain("at most 4 workers at once");
});

test("systemPrompt anti-cascade keeps digs out of fleets", () => {
Expand Down
3 changes: 1 addition & 2 deletions src/agent/directors/skywalker/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ Scale fan-out to the ask — do not spawn 10+ workers for a simple request:
- Tiny single-file / one-route asks: **DIY on the parent** with write_file/edit_file; skip spawn, skip explore, skip critique. Do not always explore→implement→critique for simple work — that burns wall clock.
- Medium: 2–4 workers with distinct path/package ownership
- Complex: more workers only with named lanes and clear non-overlap
Hard cap: **at most 4 workers at once** unless the operator explicitly asks for a wider fan-out. Prefer synthesizing early returns over launching a second wave.
Cap default fan-out. Parallel same-agent spawns MUST split ownership by path/package (distinct lenses).
Prefer synthesizing early returns over launching a second wave.

# Anti-cascade (stall / dig / diagnose)

Expand Down
2 changes: 1 addition & 1 deletion src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function buildGuidelines(
"- Break multi-step or parallel work into focused `task` dispatches with distinct lenses; prefer several parallel task calls when jobs are independent.",
"- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.",
"- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.",
"- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, no hard upper cap). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.",
"- Pass `maxTurns` on `task` when a job needs a bounded inference budget (unset is unbounded). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.",
"- After thrash / no-progress / repetition / never-acted salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.",
"- Use manage_tasks for your own coordination checklist; spawning workers is `task`, not manage_tasks.",
"- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.",
Expand Down
7 changes: 3 additions & 4 deletions src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,17 +285,16 @@ export function shellEnvFromSettings(
return local?.env;
}

export const DEFAULT_SUBAGENT_MAX_TURNS = 30;

/** Floor-only sanitization: ≥1 integer. No upper hard cap. */
export function clampSubAgentMaxTurns(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_SUBAGENT_MAX_TURNS;
if (!Number.isFinite(value)) return Infinity;
return Math.max(1, Math.floor(value));
}

/** No explicit subagentMaxTurns means unbounded; operators opt in to a ceiling. */
export function resolveDefaultSubAgentMaxTurns(settings?: Settings | null): number {
if (settings?.subagentMaxTurns === undefined) {
return DEFAULT_SUBAGENT_MAX_TURNS;
return Infinity;
}
return clampSubAgentMaxTurns(settings.subagentMaxTurns);
}
Expand Down
7 changes: 3 additions & 4 deletions src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
saveGlobalSettings,
saveLocalSettings,
type Settings,
DEFAULT_SUBAGENT_MAX_TURNS,
resolveDefaultSubAgentMaxTurns,
resolveSubAgentMaxTurns,
clampSubAgentMaxTurns,
Expand Down Expand Up @@ -934,9 +933,9 @@ describe("lastChangelogVersion", () => {
});

describe("subagentMaxTurns", () => {
test("defaults to 30 when unset", () => {
expect(resolveDefaultSubAgentMaxTurns(null)).toBe(DEFAULT_SUBAGENT_MAX_TURNS);
expect(resolveDefaultSubAgentMaxTurns({ providers: {} })).toBe(30);
test("is unbounded when unset", () => {
expect(resolveDefaultSubAgentMaxTurns(null)).toBe(Infinity);
expect(resolveDefaultSubAgentMaxTurns({ providers: {} })).toBe(Infinity);
});

test("resolveSubAgentMaxTurns precedence", () => {
Expand Down
21 changes: 9 additions & 12 deletions src/subagent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
createSubAgentRunController,
createSubAgentSessionStore,
createSubAgentSpawnRegistryPlugin,
DEFAULT_SUBAGENT_MAX_TURNS,
DEFAULT_SUBAGENT_REPEAT_LIMIT,
disposeSubAgentSession,
evaluateSubAgentStop,
Expand Down Expand Up @@ -148,14 +147,12 @@ describe("sub-agent teardown", () => {
});

describe("sub-agent stop helpers", () => {
test("default turn budget is tight enough to bound runaway cost", () => {
expect(DEFAULT_SUBAGENT_MAX_TURNS).toBe(30);
expect(subAgentTurnLimitExceeded(DEFAULT_SUBAGENT_MAX_TURNS, DEFAULT_SUBAGENT_MAX_TURNS)).toBe(
true,
);
expect(
subAgentTurnLimitExceeded(DEFAULT_SUBAGENT_MAX_TURNS - 1, DEFAULT_SUBAGENT_MAX_TURNS),
).toBe(false);
const TEST_MAX_TURNS = 30;

test("explicit turn budget hard-stops at the limit; unbounded (Infinity) never does", () => {
expect(subAgentTurnLimitExceeded(TEST_MAX_TURNS, TEST_MAX_TURNS)).toBe(true);
expect(subAgentTurnLimitExceeded(TEST_MAX_TURNS - 1, TEST_MAX_TURNS)).toBe(false);
expect(subAgentTurnLimitExceeded(1_000_000, Infinity)).toBe(false);
});

test("no-progress trips at the default repeat limit", () => {
Expand All @@ -174,7 +171,7 @@ describe("sub-agent stop helpers", () => {
hasToolCalls: true,
everHadToolCalls: true,
turnsCompleted: consecutive,
maxTurns: DEFAULT_SUBAGENT_MAX_TURNS,
maxTurns: TEST_MAX_TURNS,
consecutiveIdentical: consecutive,
repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT,
}),
Expand All @@ -189,7 +186,7 @@ describe("sub-agent stop helpers", () => {
hasToolCalls: true,
everHadToolCalls: true,
turnsCompleted: 6,
maxTurns: DEFAULT_SUBAGENT_MAX_TURNS,
maxTurns: TEST_MAX_TURNS,
consecutiveIdentical: 6,
repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT,
}),
Expand Down Expand Up @@ -1415,7 +1412,7 @@ describe("createTaskTool", () => {

expect(result).toContain("done");
expect(captured).toBeDefined();
expect(captured?.maxTurns).toBe(30);
expect(captured?.maxTurns).toBe(Infinity);
});

test("uses settings subagentMaxTurns when task and profile omit maxTurns", async () => {
Expand Down
2 changes: 0 additions & 2 deletions src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ export {
type TaskBriefFingerprintInput,
} from "./brief-dispatch.js";

export { DEFAULT_SUBAGENT_MAX_TURNS } from "../config/settings.js";

export { SubAgentDirector } from "./nudge-director.js";

export {
Expand Down
2 changes: 1 addition & 1 deletion src/subagent/task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const taskToolDefinition: ToolDefinition = {
maxTurns: {
type: "number",
description:
"Optional inference-turn budget for this worker only (not the parent session limit). Defaults to settings or 30; minimum 1.",
"Optional inference-turn budget for this worker only (not the parent session limit). Unset is unbounded; minimum 1 when set.",
},
},
required: ["description", "prompt"],
Expand Down
Loading