From d9efb0853aa821545bd2f840ac29989df6c69d09 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 12:08:38 -0700 Subject: [PATCH] Delete the re-read thrash hard stop (CL-6936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reReadLimit 4 hard-stopped a worker for reading one path four times past 8 total tool calls, reported as thrash — a sticky hard block that refuses identical re-dispatch. Read pressure only decayed when a product mutation tool edited that exact path, so reading file A while editing file B, four whole-file reads spread across a long task, and re-running a grep to verify an edit all tripped it on healthy work. The fingerprint period detector in stop-policy.ts already catches a genuinely repeating read cycle, and catches it on the evidence that it repeats rather than on a raw count. Re-read counting is strictly weaker and its verdict is more severe, so this deletes it rather than retuning the number. - thrash.ts keeps only near-budget force-report plus the read/edit bookkeeping that requireEdit / requireEvidence consume; the edit-decay of read counts goes too, since an edit erasing read evidence could only ever weaken the requireEvidence check. - "thrash" and "re-read-nudge" leave SubAgentStopReason/ForcedStopReason and the SubAgentDirector wiring; the Summary-substring classifier in brief-dispatch.ts is left for CL-6939. Net -487 lines. --- CHANGELOG.md | 13 ++ docs/ARCHITECTURE.md | 6 +- docs/PRODUCT.md | 4 +- src/subagent/index.test.ts | 247 ++------------------- src/subagent/index.ts | 2 - src/subagent/nudge-director.ts | 44 +--- src/subagent/stop-policy.ts | 92 ++++---- src/subagent/task-tool.ts | 2 +- src/subagent/thrash.test.ts | 379 ++++----------------------------- src/subagent/thrash.ts | 134 ++---------- 10 files changed, 151 insertions(+), 772 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bef55786..f7e04de12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### Agent + +- **Re-read pressure no longer stops a worker.** The `reReadLimit` thrash hard + stop and its soft `re-read-nudge` are removed: reading one file four times + while editing another, paging a large file, or re-running a grep to verify an + edit could all end a healthy worker with a sticky hard block that refused + re-dispatch. Fingerprint period detection already catches a genuinely + repeating read cycle, on the evidence that it repeats. `src/subagent/thrash.ts` + now only tracks read/edit evidence for the `intent=implement` and critique + completeness checks, plus the near-budget wrap-up nudge. + ## [0.2.105] - 2026-08-23 ### Agent diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 729091f96..909f3a65d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,8 +106,8 @@ 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`). 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: - `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. +- **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) 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: + `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. #### Model-family policy (`src/agent/model-family-policy.ts`) @@ -152,7 +152,7 @@ Because the operator explicitly wants long autonomous runs to keep going, reachi `SubAgentDirector` tracks `lastActivityAt`, updated on every real `inference.done` and `tool.done`. Directors are pure `decide(event, ...)` functions with no timer of their own and the reactor has no proactive "idle" event, so a genuinely silent leaf (e.g. parked on a long-running background command with nothing else to do) produces no event for the director to react to. `runSubAgent` (`src/subagent/index.ts`) arms an external interval, at `subAgentStallTimeoutMs`, that pings the same content-less continuation channel the compaction governor uses to re-enter an idle reactor (`requestContinuation`). The director only acts on a ping if the elapsed time since `lastActivityAt` has crossed the timeout — a ping delivered while a tool call is still executing simply queues until that cycle finishes, so "no pending harness-tracked work" falls out of when the check can run at all rather than needing separate bookkeeping. The first stall past the timeout gets one continuation nudge (asking the leaf to check on the background work or report status); a second **consecutive** stall (no activity since that nudge) escalates to the existing salvage path, returning a `stalled` `forcedStopReport` with the same structured shape (summary/findings/blockers) as `no-progress` / `turn-budget` / `thrash` / `never-acted` / `never-edited`. Any real activity between pings resets the streak, so a leaf that is genuinely working through a slow single turn is never penalized. -**Precedence**: stall detection sits **below** no-progress, thrash, and turn-budget — those are evaluated from real `inference.done` turns inside `evaluateSubAgentStop` and always take priority; the stall check only ever fires on a continuation ping that inference/tool-result handling did not already consume that cycle. Report-forced (near-budget wrap-up) and re-read-nudge (mid-run soft re-read redirect) are independent one-shot signals that can both fire across a run — one is turn-count driven, the other re-read-pressure driven — but neither is a competing stop reason in the sense no-progress/thrash/turn-budget are. Stall nudging is wall-clock driven and likewise independent of both. +**Precedence**: stall detection sits **below** no-progress and turn-budget — those are evaluated from real `inference.done` turns inside `evaluateSubAgentStop` and always take priority; the stall check only ever fires on a continuation ping that inference/tool-result handling did not already consume that cycle. Report-forced (near-budget wrap-up) is an independent one-shot, turn-count-driven signal, not a competing stop reason in the sense no-progress/turn-budget are. Stall nudging is wall-clock driven and likewise independent of both. The reactor only persists a response turn to `turns.jsonl` on `inference.done`, so a cycle that is cancelled, aborted, errors, or is otherwise interrupted mid-stream would leave nothing behind. A cycle-text recorder (`src/session/stream-journal.ts`) closes that gap by buffering the in-flight cycle's streamed text in memory — no writes on the happy path — and appending one JSON record (`{reason, chars, text}`) to `partial.jsonl`, alongside `turns.jsonl` in the session context dir, on abnormal cycle end. It is wired into the sub-agent run loop, the exec runner (flushed on failed sends), and the TUI runner (flushed on interrupt and on session rotation, before the context dir is repointed). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index c4d2af273..fc306d1a9 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -159,8 +159,8 @@ 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). 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. -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. +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). Re-read counts never hard-stop a worker, and look _volume_ is not a stop either — an implement may read hundreds of files before the first edit, and a repeating read cycle is caught by fingerprint detection instead. Near the turn budget a one-shot nudge asks the worker to wrap up and write its report. Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. +The parent tracks same-brief fingerprints for the session (`src/subagent/brief-dispatch.ts`): after no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is refused — change prompt, agent, intent, success_criteria, and/or do_not to unlock a new run (`maxTurns` or tier alone does not). Turn-budget salvage still allows a few same-brief retries with a higher `maxTurns`, then flips the parent hint to stop and change approach; a successful complete resets the same-brief retry budget. ## Roadmap (planned, not yet shipped) diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 4dbb17c07..5ce3baab9 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -24,7 +24,6 @@ import { appendDeadlineParentHint, appendNeverActedParentHint, appendSubAgentParentHints, - appendThrashParentHint, createBriefDispatchLedger, fingerprintTaskBrief, classifyBriefSalvage, @@ -535,7 +534,7 @@ describe("sub-agent stop helpers", () => { ).toBeNull(); }); - test("evaluateSubAgentStop prefers no-progress over thrash", () => { + test("evaluateSubAgentStop prefers no-progress over the turn budget", () => { let thrash = EMPTY_THRASH_STATE; for (let i = 0; i < 4; i++) { thrash = nextThrashState(thrash, [ @@ -561,23 +560,16 @@ describe("sub-agent stop helpers", () => { ).toBe("no-progress"); }); - test("evaluateSubAgentStop returns thrash before turn-budget", () => { + test("re-read pressure no longer stops a worker; turn-budget still does (CL-6936)", () => { let thrash = EMPTY_THRASH_STATE; thrash = nextThrashState(thrash, [ { type: "tool_call", name: "edit_file", arguments: { path: "a.ts" } }, ]); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 8; i++) { thrash = nextThrashState(thrash, [ { type: "tool_call", name: "read_file", arguments: { path: "a.ts" } }, ]); } - // Push totalToolCalls to the reReadMinTotalTools gate (8) so the edited - // path's re-read pressure alone is not enough to trip thrash. - for (let i = 0; i < 3; i++) { - thrash = nextThrashState(thrash, [ - { type: "tool_call", name: "grep", arguments: { pattern: `p${i}`, path: "src" } }, - ]); - } expect( evaluateSubAgentStop({ hasToolCalls: true, @@ -588,7 +580,7 @@ describe("sub-agent stop helpers", () => { repeatLimit: 2, thrashState: thrash, }), - ).toBe("thrash"); + ).toBe("turn-budget"); }); test("evaluateSubAgentStop returns report-forced once, at forceReportWithin turns before the cap", () => { @@ -687,14 +679,6 @@ describe("sub-agent stop helpers", () => { expect(neverEdited).toContain("without writing any files"); expect(appendSubAgentParentHints(neverEdited)).toContain("edit-first"); - const thrashReport = forcedStopReport("thrash", "Re-read a.ts after edit"); - const thrashParsed = parseSubAgentReport(thrashReport); - expect(thrashParsed.summary).toContain("progressive thrash"); - expect(thrashParsed.findings).toContain("a.ts"); - expect(thrashParsed.blockers).toContain("Re-read pressure"); - expect(appendThrashParentHint(thrashReport)).toContain("progressive thrash"); - expect(appendSubAgentParentHints(thrashReport)).toContain("identical brief"); - // Nested agent envelope must not clobber the outer never-acted Summary when // runSubAgent re-parses the forced stop (the common planning-only path). const nestedEnvelope = [ @@ -1025,7 +1009,7 @@ describe("thrash edge cases", () => { expect(stop(3, 3)).toBe("turn-budget"); }); - test("an ordinary edit-then-verify loop does not thrash", () => { + 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; for (let i = 0; i < 4; i++) { @@ -1035,7 +1019,7 @@ describe("thrash edge cases", () => { expect(stop(8, 30, s)).toBeNull(); }); - test("chunked reads of a large edited file do not trip thrash (offset-aware key)", () => { + test("chunked reads of a large edited file are not a stop", () => { let s = EMPTY_THRASH_STATE; s = nextThrashState(s, [edit("big.ts")]); s = nextThrashState(s, [ @@ -1047,14 +1031,14 @@ describe("thrash edge cases", () => { expect(stop(2, 30, s)).toBeNull(); }); - test("re-reading the same chunk repeatedly amid enough tool volume still thrashes", () => { + test("re-reading the same chunk repeatedly is not a stop (CL-6936)", () => { let s = EMPTY_THRASH_STATE; s = nextThrashState(s, [edit("big.ts")]); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 8; i++) { s = nextThrashState(s, [read("big.ts", { offset: 0, limit: 500 })]); } s = nextThrashState(s, [grep("p1"), grep("p2"), grep("p3")]); - expect(stop(6, 30, s)).toBe("thrash"); + expect(stop(6, 30, s)).toBeNull(); }); }); @@ -1172,208 +1156,6 @@ describe("SubAgentDirector report-forced wiring", () => { }); }); -describe("SubAgentDirector re-read-nudge wiring (CL-5813)", () => { - 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]; - } - - /** - * Soft re-read needs count>=3 on one path and total tools >= 8. Drive that - * over a few turns, then assert the follow-up infer carries the implement - * nudge, and that a further climb to hard thrash still stops the leaf. - */ - test("soft re-read injects implement wording once, then hard thrash still stops", async () => { - // requireEdit=true → implement wording - const director = new SubAgentDirector( - "system", - [], - undefined, - 30, - 2, - undefined, - Date.now, - true, - ); - const capabilities = makeCapabilities(); - - // Turns 1–3: three reads of the same path (still under soft min tools). - for (let i = 1; i <= 3; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: `r${i}`, name: "read_file", args: { path: "hot.ts" } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent(`r${i}`), mockState, capabilities); - } - - // Turns 4–7: greps to clear reReadMinTotalTools (3 reads + 5 greps = 8). - // Soft fires on the turn that crosses total=8 with count=3. - for (let i = 1; i <= 4; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: `g${i}`, name: "grep", args: { pattern: `p${i}` } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent(`g${i}`), mockState, capabilities); - } - - // 5th grep: total tools = 8, soft re-read should arm. - const softDone = makeInferenceDoneEvent([{ id: "g5", name: "grep", args: { pattern: "p5" } }]); - const softTurn = actionsArray(await director.decide(softDone, mockState, capabilities)); - // Soft is not a stop — tools still execute. - expect(softTurn.find((a) => a.type === "execute_tools")).toBeDefined(); - expect(softTurn.some((a) => a.type === "reply")).toBe(false); - - const afterSoft = actionsArray( - await director.decide(makeToolDoneEvent("g5"), mockState, capabilities), - ); - const softInfer = afterSoft.find((a) => a.type === "infer"); - expect(softInfer).toBeDefined(); - if (softInfer === undefined || softInfer.type !== "infer") throw new Error("expected infer"); - const softEphemeral = ( - softInfer.options as { ephemeralTurns?: { content: { text?: string }[] }[] } - )?.ephemeralTurns; - expect(softEphemeral?.[0]?.content?.[0]?.text).toContain("Edit a file"); - expect(softEphemeral?.[0]?.content?.[0]?.text).not.toContain("Expand Findings"); - - // One more read of hot.ts → hard thrash stop. - const hardDone = makeInferenceDoneEvent([ - { id: "r4", name: "read_file", args: { path: "hot.ts" } }, - ]); - const hardTurn = actionsArray(await director.decide(hardDone, mockState, capabilities)); - expect(hardTurn.some((a) => a.type === "reply")).toBe(true); - const checkpoint = hardTurn.find((a) => a.type === "checkpoint"); - expect(checkpoint).toBeDefined(); - if (checkpoint === undefined || checkpoint.type !== "checkpoint") { - throw new Error("expected checkpoint"); - } - expect(checkpoint.message).toBe("subagent-thrash"); - }); - - test("explore intent uses non-edit soft re-read wording", async () => { - // requireEdit=false (default) → explore wording - const director = new SubAgentDirector("system", [], undefined, 30); - const capabilities = makeCapabilities(); - - for (let i = 1; i <= 3; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: `r${i}`, name: "read_file", args: { path: "hot.ts" } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent(`r${i}`), mockState, capabilities); - } - for (let i = 1; i <= 4; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: `g${i}`, name: "grep", args: { pattern: `p${i}` } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent(`g${i}`), mockState, capabilities); - } - await director.decide( - makeInferenceDoneEvent([{ id: "g5", name: "grep", args: { pattern: "p5" } }]), - mockState, - capabilities, - ); - const afterSoft = actionsArray( - await director.decide(makeToolDoneEvent("g5"), mockState, capabilities), - ); - const infer = afterSoft.find((a) => a.type === "infer"); - expect(infer).toBeDefined(); - if (infer === undefined || infer.type !== "infer") throw new Error("expected infer"); - const text = (infer.options as { ephemeralTurns?: { content: { text?: string }[] }[] }) - ?.ephemeralTurns?.[0]?.content?.[0]?.text; - expect(text).toContain("Expand Findings"); - expect(text).not.toContain("Edit a file"); - }); - - test("soft re-read nudge fires only once even while pressure stays soft", async () => { - const director = new SubAgentDirector("system", [], undefined, 30); - const capabilities = makeCapabilities(); - - for (let i = 1; i <= 3; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: `r${i}`, name: "read_file", args: { path: "hot.ts" } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent(`r${i}`), mockState, capabilities); - } - for (let i = 1; i <= 5; i++) { - await director.decide( - makeInferenceDoneEvent([{ id: `g${i}`, name: "grep", args: { pattern: `p${i}` } }]), - mockState, - capabilities, - ); - await director.decide(makeToolDoneEvent(`g${i}`), mockState, capabilities); - } - - // Soft already fired on g5. Another grep keeps soft pressure (still 3 reads) - // but the follow-up infer must not re-nudge. - await director.decide( - makeInferenceDoneEvent([{ id: "g6", name: "grep", args: { pattern: "p6" } }]), - mockState, - capabilities, - ); - const second = actionsArray( - await director.decide(makeToolDoneEvent("g6"), mockState, capabilities), - ); - const infer = second.find((a) => a.type === "infer"); - expect(infer).toBeDefined(); - if (infer === undefined || infer.type !== "infer") throw new Error("expected infer"); - const ephemeral = (infer.options as { ephemeralTurns?: unknown[] } | undefined)?.ephemeralTurns; - expect(ephemeral).toBeUndefined(); - }); -}); - describe("SubAgentDirector stall management", () => { const mockState: ReactorState = { turns: [] } as unknown as ReactorState; @@ -2371,7 +2153,6 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { }); test("classifyBriefSalvage maps forced-stop envelopes", () => { - expect(classifyBriefSalvage(forcedStopReport("thrash", "x"))).toBe("thrash"); expect(classifyBriefSalvage(forcedStopReport("no-progress", "x"))).toBe("no-progress"); expect(classifyBriefSalvage(forcedStopReport("repetition", "x"))).toBe("repetition"); expect(classifyBriefSalvage(forcedStopReport("never-acted", "x"))).toBe("never-acted"); @@ -2403,8 +2184,8 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { expect(third).toContain(TURN_BUDGET_STOP_PARENT_HINT.slice(1, 40)); }); - test("createTaskTool refuses identical re-dispatch after thrash salvage", async () => { - const thrash = forcedStopReport("thrash", "Re-read pressure"); + test("createTaskTool refuses identical re-dispatch after no-progress salvage", async () => { + const thrash = forcedStopReport("no-progress", "Repeated the same call"); let runs = 0; const sessions = createSubAgentSessionStore(); const tool = createTaskTool({ @@ -2424,14 +2205,14 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { intent: "implement", }; const first = await callTask(tool, args); - expect(first).toContain("progressive thrash"); + expect(first).toContain("no progress"); expect(first).toContain("identical brief"); expect(runs).toBe(1); expect(sessions.list().filter((s) => s.status === "running")).toHaveLength(0); const second = await callTask(tool, args); expect(second).toContain("refused re-dispatch"); - expect(second).toContain("thrash"); + expect(second).toContain("no-progress"); expect(runs).toBe(1); // Refuse must not leave a ghost running session on the Agents strip. expect(sessions.list().filter((s) => s.status === "running")).toHaveLength(0); @@ -2449,7 +2230,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { prompt: "do the thrashy work with a narrower scope", success_criteria: ["one file"], }); - expect(third).toContain("progressive thrash"); + expect(third).toContain("no progress"); expect(runs).toBe(2); }); diff --git a/src/subagent/index.ts b/src/subagent/index.ts index b7af0d5d8..2899ebb58 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -27,8 +27,6 @@ export { evaluateThrashStop, nextThrashState, thrashForceReport, - thrashFromReRead, - thrashSoftReRead, type ThrashConfig, type ThrashState, type ThrashStopReason, diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index c78fc22ea..d0c62d26b 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -32,17 +32,6 @@ const REPORT_FORCED_WRAP_UP_NUDGE = 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."; -/** Implement leaves: soft re-read pressure should push toward edit or wrap-up. */ -const RE_READ_NUDGE_IMPLEMENT = - "You are re-reading the same paths without finishing. Edit a file to make progress, or stop tooling and write your final report now."; - -/** - * Explore / non-implement leaves: same soft re-read pressure, but do not force - * edit behavior — expand findings, change approach, or report. - */ -const RE_READ_NUDGE_EXPLORE = - "You are re-reading the same paths. Expand Findings, change approach, or write your final report — do not keep re-reading the same files."; - /** Tool-less mid-run narration after tools, without a report envelope. One-shot. */ const INCOMPLETE_REPORT_NUDGE = "Write your final report now using ## Summary, ## Findings, ## Blockers, and ## Paths. Do not narrate status. No more tools unless one lookup is required to cite a line."; @@ -95,7 +84,7 @@ export class SubAgentDirector extends DefaultDirector { consecutiveIdentical: 0, }; private thrashState: ThrashState = EMPTY_THRASH_STATE; - // Armed for wrap-up (report-forced), failed-tool recovery, or re-read so the + // Armed for wrap-up (report-forced) or 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 @@ -110,9 +99,6 @@ export class SubAgentDirector extends DefaultDirector { // turn boundary so a later overflow cannot resurrect a nudge the model // already completed. private lastConsumedNudgeText: string | null = null; - // Soft re-read-nudge is one-shot per run; thrash hard-stop still fires later - // if the leaf ignores it and keeps re-reading. - private reReadNudgeFired = false; // Soft incomplete-report wrap-up is one-shot per run; a second tool-less // narration without the envelope salvages as incomplete-report. private incompleteReportNudgeFired = false; @@ -123,7 +109,7 @@ 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 no-progress / thrash / + // the last one. Precedence: this check sits below no-progress / // turn-budget (evaluateSubAgentStop, above) — 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 @@ -254,21 +240,11 @@ export class SubAgentDirector extends DefaultDirector { // follows once their results land. Turn-budget stays reachable — // this fires once, forceReportWithin turns before the cap. this.pendingNudgeText = REPORT_FORCED_WRAP_UP_NUDGE; - } else if (stop === "re-read-nudge") { - // Soft mid-run redirect. One-shot; hard thrash still stops the worker - // if the same path/grep keeps repeating after the nudge. - if (!this.reReadNudgeFired) { - this.reReadNudgeFired = true; - this.pendingNudgeText = this.requireEdit - ? RE_READ_NUDGE_IMPLEMENT - : RE_READ_NUDGE_EXPLORE; - } } else if ( stop === "no-progress" || stop === "turn-budget" || stop === "never-acted" || stop === "never-edited" || - stop === "thrash" || stop === "no-ship" ) { const checkpoint = @@ -278,11 +254,9 @@ export class SubAgentDirector extends DefaultDirector { ? "subagent-never-acted" : stop === "never-edited" ? "subagent-never-edited" - : stop === "thrash" - ? "subagent-thrash" - : stop === "no-ship" - ? "subagent-no-ship" - : "subagent-turn-budget"; + : stop === "no-ship" + ? "subagent-no-ship" + : "subagent-turn-budget"; const detail = stop === "no-progress" ? `identical tool call × ${this.streak.consecutiveIdentical}` @@ -303,7 +277,7 @@ export class SubAgentDirector extends DefaultDirector { this.lastActivityAt = this.now(); this.consecutiveStalls = 0; if (event.result.isError === true && this.pendingNudgeText !== REPORT_FORCED_WRAP_UP_NUDGE) { - // Recovery is more specific than re-read guidance, but mandatory wrap-up wins. + // Mandatory wrap-up wins over failed-tool recovery guidance. this.pendingNudgeText = TOOL_FAILURE_RECOVERY_NUDGE; } } @@ -326,7 +300,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 - * no-progress/turn-budget/thrash above. Returns null when this event is not + * no-progress/turn-budget above. Returns null when this event is not * a stall check the director should act on (let it fall through as an * ordinary continuation). */ @@ -363,8 +337,8 @@ export class SubAgentDirector extends DefaultDirector { /** * Rewrite the infer action in a fall-through actions batch to carry the - * armed nudge, once — this matches the infer after report-forced, - * re-read-nudge, or failed-tool recovery once pending tool results reach zero. + * armed nudge, once — this matches the infer after report-forced or + * failed-tool recovery once pending tool results reach zero. */ private applyPendingNudge( actions: ReactorAction[], diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 9085cb9a4..4b211455b 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -265,10 +265,8 @@ export type SubAgentStopReason = | "no-progress" | "never-acted" | "never-edited" - | "thrash" | "no-ship" | "report-forced" - | "re-read-nudge" | "incomplete-report" | "incomplete-report-stop"; @@ -276,12 +274,12 @@ export type SubAgentStopReason = * Pure stop decision for leaf workers. Null means keep running tools. * * Precedence when tools are still firing: - * no-progress (identical fingerprints) > thrash (re-read pressure) > - * turn-budget (hard cap). Look volume never hard-stops. - * "report-forced", "re-read-nudge", "no-ship-nudge", and "incomplete-report" + * no-progress (identical fingerprints) > turn-budget (hard cap). + * Look volume never hard-stops. + * "report-forced", "no-ship-nudge", 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 - * and thrash remain reachable afterward. Tool-less turns end as never-acted + * remains reachable afterward. Tool-less turns end as never-acted * or never-edited when those apply; otherwise a tool-less turn after tools * completes only when the assistant text has a four-heading envelope * (Summary, Findings, Blockers, Paths). Omitting `lastAssistantText` @@ -299,7 +297,7 @@ export function evaluateSubAgentStop(input: { maxTurns: number; consecutiveIdentical: number; repeatLimit: number; - /** When set, progressive thrash / force-report are evaluated after no-progress. */ + /** When set, the near-budget force-report nudge is evaluated after no-progress. */ thrashState?: ThrashState; thrashConfig?: Partial; /** @@ -354,11 +352,10 @@ export function evaluateSubAgentStop(input: { } return "complete"; } - // No-progress is more specific than thrash or the turn budget when both could apply. + // No-progress is more specific than the turn budget when both could apply. if (subAgentNoProgress(input.consecutiveIdentical, input.repeatLimit)) return "no-progress"; if (input.thrashState !== undefined) { const thrashStop = evaluateThrashStop({ - state: input.thrashState, hasToolCalls: true, turnsCompleted: input.turnsCompleted, maxTurns: input.maxTurns, @@ -399,8 +396,8 @@ export function nextToolCallStreak( // result is a never-acted salvage report rather than a successful implement. // It has no submit_output or ask_operator; consequential tools still go through // the parent's permission gate (grants, auto mode, or prompts). Hard stops also -// fire on identical tool fingerprints (no-progress), progressive re-read thrash, -// and the hard turn budget so a thrashing leaf cannot burn the full budget +// fire on identical tool fingerprints (no-progress) and the hard turn budget +// so a looping leaf cannot 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. @@ -433,7 +430,6 @@ export type ForcedStopReason = | "never-edited" | "cancelled" | "deadline" - | "thrash" | "no-ship" | "stalled" | "repetition" @@ -455,47 +451,43 @@ export function forcedStopReport( const summary = reason === "no-progress" ? "Stopped: repeated the same tool calls with no progress." - : reason === "thrash" - ? "Stopped: progressive thrash (re-read pressure without finishing)." - : reason === "no-ship" - ? "Stopped: implement intent searched many files without writing any." - : reason === "never-acted" - ? "Stopped: completed without using any tools." - : reason === "never-edited" - ? "Stopped: implement intent finished without writing any files." - : reason === "cancelled" - ? "Stopped: cancelled by operator before finishing." - : reason === "deadline" - ? "Stopped: wall-clock deadline reached before finishing." - : reason === "stalled" - ? "Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly." - : reason === "repetition" - ? "Stopped: degenerate repetition in streamed output (same window looping mid-turn)." - : reason === "incomplete-report" - ? "Stopped: worker narrated instead of writing a report envelope." - : "Turn budget reached before finishing."; + : reason === "no-ship" + ? "Stopped: implement intent searched many files without writing any." + : reason === "never-acted" + ? "Stopped: completed without using any tools." + : reason === "never-edited" + ? "Stopped: implement intent finished without writing any files." + : reason === "cancelled" + ? "Stopped: cancelled by operator before finishing." + : reason === "deadline" + ? "Stopped: wall-clock deadline reached before finishing." + : reason === "stalled" + ? "Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly." + : reason === "repetition" + ? "Stopped: degenerate repetition in streamed output (same window looping mid-turn)." + : reason === "incomplete-report" + ? "Stopped: worker narrated instead of writing a report envelope." + : "Turn budget reached before finishing."; const blockers = reason === "no-progress" ? "Identical tool-call fingerprint repeated consecutively; parent must not re-dispatch the identical brief (it will be refused) — tighten success_criteria/do_not or change approach." - : reason === "thrash" - ? "Re-read pressure (same path after edit, or heavy re-reads amid high tool volume); parent must not re-dispatch the identical brief (it will be refused) — re-dispatch only with a narrower scope, success_criteria, and do_not rather than more turns alone." - : reason === "no-ship" - ? "Implement searched many files without writing any; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, tighter success_criteria, and do_not. Do not search the repo yourself first." - : reason === "never-acted" - ? "Worker returned planning/prose only (zero tool calls in the run); parent must not re-dispatch the identical brief (it will be refused) — re-dispatch only with a tighter brief, or treat findings as unexecuted." - : reason === "never-edited" - ? "Worker used tools but never called edit_file/write_file/delete_file under intent=implement; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, or treat findings as unexecuted." - : reason === "cancelled" - ? "Operator or parent cancelled the worker mid-run; parent may re-dispatch with the partial findings below." - : reason === "deadline" - ? "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 === "repetition" - ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns alone." - : 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."; + : reason === "no-ship" + ? "Implement searched many files without writing any; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, tighter success_criteria, and do_not. Do not search the repo yourself first." + : reason === "never-acted" + ? "Worker returned planning/prose only (zero tool calls in the run); parent must not re-dispatch the identical brief (it will be refused) — re-dispatch only with a tighter brief, or treat findings as unexecuted." + : reason === "never-edited" + ? "Worker used tools but never called edit_file/write_file/delete_file under intent=implement; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, or treat findings as unexecuted." + : reason === "cancelled" + ? "Operator or parent cancelled the worker mid-run; parent may re-dispatch with the partial findings below." + : reason === "deadline" + ? "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 === "repetition" + ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns alone." + : 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."; // 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 (never-acted planning envelopes; cancel after a diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index e03efe48c..7035aff07 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -73,7 +73,7 @@ export const TaskToolArgs = type({ 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). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns alone does not unlock it. 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.', + '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). After no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns alone does not unlock it. 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.', inputSchema: { type: "object", properties: { diff --git a/src/subagent/thrash.test.ts b/src/subagent/thrash.test.ts index eb448d43f..f0c678fe8 100644 --- a/src/subagent/thrash.test.ts +++ b/src/subagent/thrash.test.ts @@ -5,8 +5,6 @@ import { evaluateThrashStop, nextThrashState, thrashForceReport, - thrashFromReRead, - thrashSoftReRead, type ThrashState, type ThrashToolCallBlock, } from "./thrash.js"; @@ -40,143 +38,36 @@ function applyAll(calls: readonly ThrashToolCallBlock[]): ThrashState { } describe("thrash pure module", () => { - test("defaults are conservative (reReadLimit 4, soft 3, forceReportWithin 2)", () => { - expect(DEFAULT_THRASH_CONFIG.reReadLimit).toBe(4); - expect(DEFAULT_THRASH_CONFIG.reReadSoftLimit).toBe(3); - expect(DEFAULT_THRASH_CONFIG.reReadSoftLimit).toBeLessThan(DEFAULT_THRASH_CONFIG.reReadLimit); + test("defaults keep only the near-budget wrap-up threshold", () => { expect(DEFAULT_THRASH_CONFIG.forceReportWithin).toBe(2); - expect(DEFAULT_THRASH_CONFIG.reReadMinTotalTools).toBeGreaterThanOrEqual( - DEFAULT_THRASH_CONFIG.reReadLimit, - ); - }); - - test("re-read after edit of the same path trips thrash once total tool volume clears the gate", () => { - const path = "src/subagent/index.ts"; - // One edit + reReadLimit reads of the same path, padded with enough other - // tool calls to clear reReadMinTotalTools (the edited-path branch is - // gated on total tool volume just like the non-edited branch). - const calls: ThrashToolCallBlock[] = [edit(path)]; - for (let i = 0; i < DEFAULT_THRASH_CONFIG.reReadLimit; i++) { - calls.push(read(path)); - } - for (let i = calls.length; i < DEFAULT_THRASH_CONFIG.reReadMinTotalTools; i++) { - calls.push(grep(`p${i}`)); - } - const state = applyAll(calls); - expect(state.totalToolCalls).toBeGreaterThanOrEqual(DEFAULT_THRASH_CONFIG.reReadMinTotalTools); - expect(thrashFromReRead(state)).toBe(true); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 3, - maxTurns: 25, - }), - ).toBe("thrash"); + expect(Object.keys(DEFAULT_THRASH_CONFIG)).toEqual(["forceReportWithin"]); }); - test("re-read after edit stays under thrash while total tool volume is low", () => { + 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 < DEFAULT_THRASH_CONFIG.reReadLimit; i++) { - calls.push(read(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.totalToolCalls).toBeLessThan(DEFAULT_THRASH_CONFIG.reReadMinTotalTools); - expect(thrashFromReRead(state)).toBe(false); - }); - - test("write_file and delete_file also mark paths as edited for re-read thrash", () => { - const written = applyAll([ - write("a.ts"), - read("a.ts"), - read("a.ts"), - read("a.ts"), - read("a.ts"), - grep("p1"), - grep("p2"), - grep("p3"), - ]); - expect(thrashFromReRead(written)).toBe(true); - - const deleted = applyAll([ - del("b.ts"), - read("b.ts"), - read("b.ts"), - read("b.ts"), - read("b.ts"), - grep("p1"), - grep("p2"), - grep("p3"), - ]); - expect(thrashFromReRead(deleted)).toBe(true); + expect(state.readCounts.get(path)).toBe(12); + expect(evaluateThrashStop({ hasToolCalls: true, turnsCompleted: 3, maxTurns: 25 })).toBeNull(); }); - test("apply_patch marks envelope paths as edited for re-read thrash", () => { - const patch = `*** Begin Patch -*** Update File: a.ts -@@ --old -+new -*** End Patch -`; - const patched = applyAll([ - { - type: "tool_call", - name: "apply_patch", - arguments: { input: patch }, + test("write_file, delete_file, and apply_patch mark paths as edited", () => { + const patch = { + type: "tool_call", + name: "apply_patch", + arguments: { + input: "*** Begin Patch\n*** Update File: c.ts\n@@\n-old\n+new\n*** End Patch\n", }, - read("a.ts"), - read("a.ts"), - read("a.ts"), - read("a.ts"), - grep("p1"), - grep("p2"), - grep("p3"), - ]); - expect(thrashFromReRead(patched)).toBe(true); - }); - - test("multi-file unique reads do NOT thrash", () => { - const calls: ThrashToolCallBlock[] = []; - for (let i = 0; i < 20; i++) { - calls.push(read(`src/file-${i}.ts`)); - } - const state = applyAll(calls); - expect(state.readCounts.size).toBe(20); - expect(thrashFromReRead(state)).toBe(false); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 5, - maxTurns: 25, - }), - ).toBeNull(); - }); - - test("few re-reads of one path without edit stay under thrash", () => { - // 3 reads < reReadLimit=4, even with other tools mixed in. - const state = applyAll([read("big.ts"), grep("x"), read("big.ts"), grep("y"), read("big.ts")]); - expect(thrashFromReRead(state)).toBe(false); - }); - - test("re-reads without edit only thrash past reReadLimit and min total tools", () => { - const under = applyAll([read("a.ts"), read("a.ts"), read("a.ts"), read("a.ts")]); - // 4 reads of same path but totalToolCalls (4) < reReadMinTotalTools (8). - expect(under.totalToolCalls).toBe(4); - expect(thrashFromReRead(under)).toBe(false); - - const calls: ThrashToolCallBlock[] = []; - for (let i = 0; i < 4; i++) calls.push(read("a.ts")); - for (let i = 0; i < 4; i++) calls.push(grep(`p${i}`)); - const over = applyAll(calls); - expect(over.totalToolCalls).toBe(8); - expect(thrashFromReRead(over)).toBe(true); + } satisfies ThrashToolCallBlock; + const state = applyAll([write("a.ts"), del("b.ts"), patch]); + expect(state.editedPaths.has("a.ts")).toBe(true); + expect(state.editedPaths.has("b.ts")).toBe(true); + expect(state.editedPaths.has("c.ts")).toBe(true); }); test("near-budget force-report fires once, leaving turn-budget reachable", () => { - const state = applyAll([read("a.ts")]); // maxTurns=10, forceReportWithin=2 → single nudge at turnsCompleted === 8. expect(thrashForceReport(7, 10, true)).toBe(false); expect(thrashForceReport(8, 10, true)).toBe(true); @@ -185,168 +76,41 @@ describe("thrash pure module", () => { // No tools this turn → not force-report (tool-less is complete/never-acted). expect(thrashForceReport(8, 10, false)).toBe(false); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 8, - maxTurns: 10, - }), - ).toBe("report-forced"); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 7, - maxTurns: 10, - }), - ).toBeNull(); + 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({ - state, - hasToolCalls: true, - turnsCompleted: 9, - maxTurns: 10, - }), - ).toBeNull(); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 10, - maxTurns: 10, - }), - ).toBeNull(); // evaluateThrashStop only owns thrash / report-forced; turn-budget is the caller's job. + 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", () => { - // No room for a nudge turn: go straight to the caller's turn-budget check. expect(thrashForceReport(1, 1, true)).toBe(false); expect(thrashForceReport(1, 2, true)).toBe(false); expect(thrashForceReport(2, 2, true)).toBe(false); - // maxTurns=3 has room for exactly one nudge turn before the cap. expect(thrashForceReport(1, 3, true)).toBe(true); expect(thrashForceReport(2, 3, true)).toBe(false); expect(thrashForceReport(3, 3, true)).toBe(false); }); - test("soft re-read pressure fires re-read-nudge before hard thrash (CL-5813)", () => { - // 3 reads of one path + 5 greps = 8 tools → soft (limit 3), not hard (limit 4). - const softCalls: ThrashToolCallBlock[] = []; - for (let i = 0; i < 3; i++) softCalls.push(read("hot.ts")); - for (let i = 0; i < 5; i++) softCalls.push(grep(`p${i}`)); - const soft = applyAll(softCalls); - expect(soft.totalToolCalls).toBe(8); - expect(thrashFromReRead(soft)).toBe(false); - expect(thrashSoftReRead(soft)).toBe(true); - expect( - evaluateThrashStop({ - state: soft, - hasToolCalls: true, - turnsCompleted: 5, - maxTurns: 30, - }), - ).toBe("re-read-nudge"); - - // One more read of the same path crosses hard thrash. - const hard = nextThrashState(soft, [read("hot.ts")]); - expect(thrashFromReRead(hard)).toBe(true); - expect(thrashSoftReRead(hard)).toBe(false); - expect( - evaluateThrashStop({ - state: hard, - hasToolCalls: true, - turnsCompleted: 6, - maxTurns: 30, - }), - ).toBe("thrash"); - }); - - test("soft re-read still requires min total tools", () => { - // 3 reads only — under reReadMinTotalTools. - const under = applyAll([read("a.ts"), read("a.ts"), read("a.ts")]); - expect(thrashSoftReRead(under)).toBe(false); + test("tool-less turns never return a stop reason", () => { expect( - evaluateThrashStop({ - state: under, - hasToolCalls: true, - turnsCompleted: 3, - maxTurns: 30, - }), + evaluateThrashStop({ hasToolCalls: false, turnsCompleted: 23, maxTurns: 25 }), ).toBeNull(); }); - test("report-forced is preferred over re-read-nudge when both apply", () => { - const softCalls: ThrashToolCallBlock[] = []; - for (let i = 0; i < 3; i++) softCalls.push(read("hot.ts")); - for (let i = 0; i < 5; i++) softCalls.push(grep(`p${i}`)); - const soft = applyAll(softCalls); - // maxTurns=10, forceReportWithin=2 → report-forced at turnsCompleted === 8. - expect( - evaluateThrashStop({ - state: soft, - hasToolCalls: true, - turnsCompleted: 8, - maxTurns: 10, - }), - ).toBe("report-forced"); - }); - - test("thrash is preferred over report-forced when both apply", () => { - const path = "hot.ts"; - const state = applyAll([ - edit(path), - read(path), - read(path), - read(path), - read(path), - grep("p1"), - grep("p2"), - grep("p3"), - ]); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 24, - maxTurns: 25, - }), - ).toBe("thrash"); - }); - - test("tool-less turns never return thrash stop reasons", () => { - const state = applyAll([edit("a.ts"), read("a.ts"), read("a.ts"), read("a.ts"), read("a.ts")]); - expect( - evaluateThrashStop({ - state, - hasToolCalls: false, - turnsCompleted: 24, - maxTurns: 25, - }), - ).toBeNull(); - }); - - test("nextThrashState is pure and accumulates across turns, decaying reads on edit", () => { + test("nextThrashState is pure and accumulates across turns", () => { let state = EMPTY_THRASH_STATE; state = nextThrashState(state, [read("a.ts")]); - // The edit decays the prior read count for a.ts back to zero — the file - // changed, so the read before it no longer counts as re-read pressure. + // An edit no longer erases read evidence: readCounts is the requireEvidence + // record, not a thrash counter (CL-6936). state = nextThrashState(state, [edit("a.ts")]); - expect(state.readCounts.has("a.ts")).toBe(false); + expect(state.readCounts.get("a.ts")).toBe(1); state = nextThrashState(state, [read("a.ts"), read("a.ts")]); - state = nextThrashState(state, [read("a.ts")]); expect(state.readCounts.get("a.ts")).toBe(3); expect(state.editedPaths.has("a.ts")).toBe(true); - expect(state.totalToolCalls).toBe(5); - // Below reReadLimit (4) post-decay, and below the total-tool-volume gate. - expect(thrashFromReRead(state)).toBe(false); - - state = nextThrashState(state, [read("a.ts")]); - state = nextThrashState(state, [grep("p1"), grep("p2"), grep("p3")]); - expect(state.readCounts.get("a.ts")).toBe(4); - expect(state.totalToolCalls).toBe(9); - expect(thrashFromReRead(state)).toBe(true); + expect(state.totalToolCalls).toBe(4); }); test("nextThrashState ignores non-tool blocks and normalizes JSON-string args", () => { @@ -362,85 +126,30 @@ describe("thrash pure module", () => { expect(state.totalToolCalls).toBe(1); }); - test("an ordinary edit-then-verify loop does not thrash", () => { - // edit -> read-back verify, four times, on one file: legitimate iteration. - // Each edit decays the prior read count for the path, so re-read pressure - // never accumulates across cycles. - let state = EMPTY_THRASH_STATE; - for (let i = 0; i < 4; i++) { - state = nextThrashState(state, [edit("hot.ts")]); - state = nextThrashState(state, [read("hot.ts")]); - } - expect(thrashFromReRead(state)).toBe(false); - expect( - evaluateThrashStop({ state, hasToolCalls: true, turnsCompleted: 8, maxTurns: 30 }), - ).toBeNull(); - }); - - test("chunked reads of a large edited file key by offset, not just path", () => { - let state = EMPTY_THRASH_STATE; - state = nextThrashState(state, [edit("big.ts")]); - state = nextThrashState(state, [ + test("chunked reads key by offset, whole-file reads key by path", () => { + const state = applyAll([ read("big.ts", { offset: 0, limit: 500 }), read("big.ts", { offset: 500, limit: 500 }), - read("big.ts", { offset: 1000, limit: 500 }), - read("big.ts", { offset: 1500, limit: 500 }), + read("big.ts"), ]); - // Four distinct chunks, none repeated — not re-read pressure. - expect(thrashFromReRead(state)).toBe(false); - expect( - evaluateThrashStop({ state, hasToolCalls: true, turnsCompleted: 2, maxTurns: 30 }), - ).toBeNull(); + expect(state.readCounts.get("big.ts")).toBe(1); + expect(state.readCounts.get("big.ts::0:500")).toBe(1); + expect(state.readCounts.get("big.ts::500:500")).toBe(1); }); - test("re-reading the same chunk repeatedly still trips thrash", () => { - let state = EMPTY_THRASH_STATE; - state = nextThrashState(state, [edit("big.ts")]); - for (let i = 0; i < DEFAULT_THRASH_CONFIG.reReadLimit; i++) { - state = nextThrashState(state, [read("big.ts", { offset: 0, limit: 500 })]); - } - for (let i = 0; i < 3; i++) { - state = nextThrashState(state, [grep(`p${i}`)]); - } - expect(thrashFromReRead(state)).toBe(true); - }); - - test("unique reads at any volume are not a stop", () => { - const calls: ThrashToolCallBlock[] = []; - for (let i = 0; i < 200; i++) { - calls.push(read(`src/file-${i}.ts`)); - } - const state = applyAll(calls); - expect( - evaluateThrashStop({ - state, - hasToolCalls: true, - turnsCompleted: 40, - maxTurns: 60, - }), - ).toBeNull(); - }); - - test("same grep pattern counts as re-read pressure", () => { - const calls: ThrashToolCallBlock[] = []; - for (let i = 0; i < DEFAULT_THRASH_CONFIG.reReadLimit; i++) { - calls.push(grep("formatModelPicker")); - } - for (let i = 0; i < 4; i++) calls.push(read(`pad-${i}.ts`)); - const state = applyAll(calls); - expect(thrashFromReRead(state)).toBe(true); + test("greps count toward read evidence keyed by pattern and path", () => { + const state = applyAll([grep("needle"), grep("needle")]); + expect(state.readCounts.get("grep::needle::src")).toBe(2); }); test("config overrides apply to evaluateThrashStop", () => { - const state = applyAll([read("a.ts"), read("a.ts")]); expect( evaluateThrashStop({ - state, hasToolCalls: true, - turnsCompleted: 1, - maxTurns: 25, - config: { reReadLimit: 2, reReadMinTotalTools: 1 }, + turnsCompleted: 5, + maxTurns: 10, + config: { forceReportWithin: 5 }, }), - ).toBe("thrash"); + ).toBe("report-forced"); }); }); diff --git a/src/subagent/thrash.ts b/src/subagent/thrash.ts index ea5a7f9d6..2fa3a9ed0 100644 --- a/src/subagent/thrash.ts +++ b/src/subagent/thrash.ts @@ -1,32 +1,20 @@ /** - * Pure progressive thrash detection for dispatched workers. + * Pure near-budget wrap-up detection plus read/edit bookkeeping for dispatched + * workers. Wired into SubAgentDirector via evaluateSubAgentStop. * - * Tracks re-read pressure (same path or same grep) and near-budget tools-only - * spin. No look-volume quota — unique reads are legal at any count. - * 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). * - * Precedence: no-progress > thrash > turn-budget. Soft re-read-nudge and - * report-forced are one-shot wrap-up / redirect nudges, not stops. + * The state this module accumulates is consumed by evaluateSubAgentStop's + * requireEdit / requireEvidence checks, not by a stop of its own. */ import { isProductMutationTool, productMutationPaths } from "../agent/product-mutation-tools.js"; -/** Tunable thresholds for thrash / force-report detection. */ +/** Tunable thresholds for force-report detection. */ export interface ThrashConfig { - /** Same path read this many times triggers hard re-read thrash stop. */ - reReadLimit: number; - /** - * Soft re-read pressure threshold (must be < reReadLimit). Crossing it injects - * a one-shot mid-run nudge without stopping the leaf; hard thrash still fires - * if the leaf keeps re-reading past reReadLimit. - */ - reReadSoftLimit: number; - /** - * Without a prior edit of the path, re-read pressure also requires at least - * this many total tool calls in the run (keeps multi-chunk legitimate reads - * and multi-file explore from tripping early). - */ - reReadMinTotalTools: number; /** * When turnsCompleted equals maxTurns - forceReportWithin and the worker is * still issuing tools, inject a one-shot wrap-up nudge. @@ -34,15 +22,11 @@ export interface ThrashConfig { forceReportWithin: number; } -/** Conservative defaults: 20 unique single-path reads must not thrash. */ export const DEFAULT_THRASH_CONFIG: ThrashConfig = { - reReadLimit: 4, - reReadSoftLimit: 3, - reReadMinTotalTools: 8, forceReportWithin: 2, }; -/** Accumulated thrash bookkeeping across turns (immutable snapshots). */ +/** Accumulated read/edit bookkeeping across turns (immutable snapshots). */ export interface ThrashState { readonly readCounts: ReadonlyMap; readonly editedPaths: ReadonlySet; @@ -55,13 +39,8 @@ export const EMPTY_THRASH_STATE: ThrashState = { totalToolCalls: 0, }; -/** - * Thrash-module stop reasons. - * - "thrash" is a real stop (same-path / same-grep re-read) - * - "report-forced" is a near-budget wrap-up-nudge signal - * - "re-read-nudge" is a mid-run redirect (one-shot, not a stop) - */ -export type ThrashStopReason = "thrash" | "report-forced" | "re-read-nudge"; +/** "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 { @@ -105,9 +84,8 @@ function searchKey(name: string, args: Record): string { } /** - * Re-read tracking key for a path. Chunked reads (offset/limit set) key by - * chunk so paging through a large file does not look like re-reading the same - * span; a whole-file read keys by path alone. + * Read-tracking key for a path. Chunked reads (offset/limit set) key by chunk, + * a whole-file read keys by path alone. */ function readKey(path: string, args: Record): string { const { offset, limit } = args; @@ -115,23 +93,10 @@ function readKey(path: string, args: Record): string { return `${path}::${String(offset ?? 0)}:${String(limit ?? "")}`; } -/** True when a read-tracking key belongs to the given path (any chunk). */ -function keyBelongsToPath(key: string, path: string): boolean { - return key === path || key.startsWith(`${path}::`); -} - -/** Drop every read-count entry (all chunk keys) for a path that was just edited. */ -function decayReadsForPath(readCounts: Map, path: string): void { - for (const key of readCounts.keys()) { - if (keyBelongsToPath(key, path)) readCounts.delete(key); - } -} - /** - * Advance thrash bookkeeping from one turn's content (or an explicit tool list). - * Only `tool_call` blocks are counted; path strings are used as given (no resolve). - * An edit decays prior read pressure on its path — the file changed, so earlier - * reads no longer count toward re-read thrash. + * Advance read/edit bookkeeping from one turn's content (or an explicit tool + * list). Only `tool_call` blocks are counted; path strings are used as given + * (no resolve). */ export function nextThrashState( prev: ThrashState, @@ -160,11 +125,7 @@ export function nextThrashState( const paths = productMutationPaths(name, args); if (paths.length > 0) { if (editedPaths === null) editedPaths = new Set(prev.editedPaths); - if (readCounts === null) readCounts = new Map(prev.readCounts); - for (const edited of paths) { - editedPaths.add(edited); - decayReadsForPath(readCounts, edited); - } + for (const edited of paths) editedPaths.add(edited); } } } @@ -180,48 +141,6 @@ export function nextThrashState( }; } -/** - * True when any path's re-read count meets `limit` and total tool volume clears - * the min-tools gate. Shared by hard thrash and soft re-read-nudge. - */ -function reReadPressureAt(state: ThrashState, limit: number, minTotalTools: number): boolean { - if (state.totalToolCalls < minTotalTools) return false; - for (const count of state.readCounts.values()) { - if (count >= limit) return true; - } - return false; -} - -/** - * True when re-read pressure indicates progressive thrash. Gated on total - * tool volume regardless of whether the path was edited — an ordinary - * edit-then-verify loop decays its read count on each edit (see - * decayReadsForPath) and so rarely reaches reReadLimit at all, but the volume - * gate is a second line of defense against classifying a low-activity run as - * thrash from re-read count alone. - */ -export function thrashFromReRead( - state: ThrashState, - config: ThrashConfig = DEFAULT_THRASH_CONFIG, -): boolean { - return reReadPressureAt(state, config.reReadLimit, config.reReadMinTotalTools); -} - -/** - * True when re-read pressure has crossed the soft threshold but not yet hard - * thrash. Used to inject a one-shot mid-run redirect before the leaf burns - * the rest of its budget re-reading the same paths. - */ -export function thrashSoftReRead( - state: ThrashState, - config: ThrashConfig = DEFAULT_THRASH_CONFIG, -): boolean { - const soft = Math.min(config.reReadSoftLimit, config.reReadLimit - 1); - if (soft < 1) return false; - if (thrashFromReRead(state, config)) return false; - return reReadPressureAt(state, soft, config.reReadMinTotalTools); -} - /** * 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 @@ -246,24 +165,19 @@ export function thrashForceReport( function resolveConfig(partial?: Partial): ThrashConfig { if (partial === undefined) return DEFAULT_THRASH_CONFIG; return { - reReadLimit: partial.reReadLimit ?? DEFAULT_THRASH_CONFIG.reReadLimit, - reReadSoftLimit: partial.reReadSoftLimit ?? DEFAULT_THRASH_CONFIG.reReadSoftLimit, - reReadMinTotalTools: partial.reReadMinTotalTools ?? DEFAULT_THRASH_CONFIG.reReadMinTotalTools, forceReportWithin: partial.forceReportWithin ?? DEFAULT_THRASH_CONFIG.forceReportWithin, }; } /** - * Pure thrash / force-report / soft re-read decision. Null means keep running - * (or defer to evaluateSubAgentStop for tool-less / fingerprint / hard budget). - * "thrash" is a real stop; "report-forced" and "re-read-nudge" are one-shot - * nudge signals, not stops — the caller injects a nudge and keeps running. + * 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 — tool-less endings are not thrash. - * Prefers thrash > report-forced > re-read-nudge. + * Only evaluates when hasToolCalls is true. */ export function evaluateThrashStop(input: { - state: ThrashState; hasToolCalls: boolean; turnsCompleted: number; maxTurns: number; @@ -271,10 +185,8 @@ export function evaluateThrashStop(input: { }): ThrashStopReason | null { if (!input.hasToolCalls) return null; const config = resolveConfig(input.config); - if (thrashFromReRead(input.state, config)) return "thrash"; if (thrashForceReport(input.turnsCompleted, input.maxTurns, input.hasToolCalls, config)) { return "report-forced"; } - if (thrashSoftReRead(input.state, config)) return "re-read-nudge"; return null; }