diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3aa8a1c71..bbd6f2038 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement); explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. 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`) — 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. 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 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. +- **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 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 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. 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. 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. @@ -130,7 +130,7 @@ The ChatDirector counts consecutive assistant turns that contain tool calls and #### Sub-agent stall management -`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`. Any real activity between pings resets the streak, so a leaf that is genuinely working through a slow single turn is never penalized. +`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 (the one-shot wrap-up nudge a few turns before the turn-budget cap) and stall nudging are independent one-shot signals that can both fire across a run — one is turn-count driven, the other wall-clock driven — but neither is a competing stop reason in the sense no-progress/thrash/turn-budget are. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 1b9e9a43e..85f0fa9cd 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -143,7 +143,7 @@ Corbits Code can fan work out to short-lived **sub-agents** — child agents wit - **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 shows who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Leaf workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; cap 100), or when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement). 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 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 shows who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Leaf workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; cap 100), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). 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. ## Roadmap (planned, not yet shipped) diff --git a/src/subagent/brief-dispatch.ts b/src/subagent/brief-dispatch.ts index 8540b1855..fd7e18672 100644 --- a/src/subagent/brief-dispatch.ts +++ b/src/subagent/brief-dispatch.ts @@ -16,6 +16,7 @@ import { parseSubAgentReport } from "./report.js"; import { isDeadlineSubAgentReport, isNeverActedSubAgentReport, + isNeverEditedSubAgentReport, isNoProgressSubAgentReport, isRepetitionSubAgentReport, isThrashSubAgentReport, @@ -27,7 +28,8 @@ export type HardBlockSalvage = | "thrash" | "no-progress" | "repetition" - | "never-acted"; + | "never-acted" + | "never-edited"; export type BriefSalvageKind = | HardBlockSalvage @@ -63,6 +65,7 @@ const HARD_BLOCK_SALVAGES = new Set([ "no-progress", "repetition", "never-acted", + "never-edited", ]); export function isHardBlockSalvage(kind: BriefSalvageKind): kind is HardBlockSalvage { @@ -89,6 +92,7 @@ export function classifyBriefSalvage(report: string): BriefSalvageKind | null { // Order: more specific salvage phrases first. if (isThrashSubAgentReport(report)) return "thrash"; if (isRepetitionSubAgentReport(report)) return "repetition"; + if (isNeverEditedSubAgentReport(report)) return "never-edited"; if (isNeverActedSubAgentReport(report)) return "never-acted"; if (isNoProgressSubAgentReport(report)) return "no-progress"; if (isTurnBudgetSubAgentReport(report)) return "turn-budget"; diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index c72443371..330df428c 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -227,6 +227,60 @@ describe("sub-agent stop helpers", () => { ).toBe("never-acted"); }); + test("evaluateSubAgentStop returns never-edited when requireEdit and tools never wrote files", () => { + const thrashState = { + totalToolCalls: 4, + readCounts: new Map([["src/a.ts", 2]]), + editedPaths: new Set(), + }; + expect( + evaluateSubAgentStop({ + hasToolCalls: false, + everHadToolCalls: true, + turnsCompleted: 5, + maxTurns: 30, + consecutiveIdentical: 0, + repeatLimit: 2, + thrashState, + requireEdit: true, + }), + ).toBe("never-edited"); + }); + + test("evaluateSubAgentStop still completes implement when an edit path was recorded", () => { + const thrashState = { + totalToolCalls: 4, + readCounts: new Map([["src/a.ts", 1]]), + editedPaths: new Set(["src/a.ts"]), + }; + expect( + evaluateSubAgentStop({ + hasToolCalls: false, + everHadToolCalls: true, + turnsCompleted: 5, + maxTurns: 30, + consecutiveIdentical: 0, + repeatLimit: 2, + thrashState, + requireEdit: true, + }), + ).toBe("complete"); + }); + + test("evaluateSubAgentStop ignores requireEdit when the run never used tools (never-acted wins)", () => { + expect( + evaluateSubAgentStop({ + hasToolCalls: false, + everHadToolCalls: false, + turnsCompleted: 1, + maxTurns: 10, + consecutiveIdentical: 0, + repeatLimit: 2, + requireEdit: true, + }), + ).toBe("never-acted"); + }); + test("evaluateSubAgentStop prefers no-progress over turn-budget", () => { expect( evaluateSubAgentStop({ @@ -414,6 +468,10 @@ describe("sub-agent stop helpers", () => { expect(neverParsed.blockers).toContain("unexecuted"); expect(neverActed.toLowerCase()).not.toContain("summarize what you found"); + const neverEdited = forcedStopReport("never-edited", "I mapped the files; ready to code next"); + 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"); @@ -1658,8 +1716,8 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { expect(ledger.admit(other).ok).toBe(true); }); - test("hard-blocks no-progress, repetition, never-acted; not turn-budget", () => { - for (const salvage of ["no-progress", "repetition", "never-acted"] as const) { + test("hard-blocks no-progress, repetition, never-acted, never-edited; not turn-budget", () => { + for (const salvage of ["no-progress", "repetition", "never-acted", "never-edited"] as const) { const ledger = createBriefDispatchLedger(); const fp = fingerprintTaskBrief({ prompt: `job ${salvage}` }); expect(ledger.admit(fp).ok).toBe(true); @@ -1713,6 +1771,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { 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"); + expect(classifyBriefSalvage(forcedStopReport("never-edited", "x"))).toBe("never-edited"); expect(classifyBriefSalvage(forcedStopReport("turn-budget", "x"))).toBe("turn-budget"); expect(classifyBriefSalvage("## Summary\nDone\n\n## Findings\nok\n\n## Blockers\nNone\n\n## Paths\n")).toBeNull(); }); diff --git a/src/subagent/index.ts b/src/subagent/index.ts index ca62b6817..ff0f7e5ef 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -54,6 +54,7 @@ export { forcedStopReport, isDeadlineSubAgentReport, isNeverActedSubAgentReport, + isNeverEditedSubAgentReport, isNoProgressSubAgentReport, isRepetitionSubAgentReport, isThrashSubAgentReport, diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 4038af483..eff0598f3 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -69,6 +69,8 @@ export class SubAgentDirector extends DefaultDirector { private readonly compaction: CompactionGovernor; private readonly maxTurns: number; private readonly repeatLimit: number; + /** When true (intent=implement), tool-less finish without edits salvages as never-edited. */ + private readonly requireEdit: boolean; private turnsCompleted = 0; private everHadToolCalls = false; private streak: ToolCallStreak = { @@ -108,6 +110,7 @@ export class SubAgentDirector extends DefaultDirector { repeatLimit: number = DEFAULT_SUBAGENT_REPEAT_LIMIT, stallTimeoutMs?: number, now: () => number = Date.now, + requireEdit: boolean = false, ) { super(systemPrompt, toolDefinitions, {}); this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions); @@ -116,6 +119,7 @@ export class SubAgentDirector extends DefaultDirector { this.stallTimeoutMs = stallTimeoutMs; this.now = now; this.lastActivityAt = now(); + this.requireEdit = requireEdit; } override async decide( @@ -166,6 +170,7 @@ export class SubAgentDirector extends DefaultDirector { consecutiveIdentical: this.streak.consecutiveIdentical, repeatLimit: this.repeatLimit, thrashState: this.thrashState, + requireEdit: this.requireEdit, }); if (stop === "complete") { @@ -188,6 +193,7 @@ export class SubAgentDirector extends DefaultDirector { stop === "no-progress" || stop === "turn-budget" || stop === "never-acted" || + stop === "never-edited" || stop === "thrash" ) { const checkpoint = @@ -195,9 +201,11 @@ export class SubAgentDirector extends DefaultDirector { ? "subagent-no-progress" : stop === "never-acted" ? "subagent-never-acted" - : stop === "thrash" - ? "subagent-thrash" - : "subagent-turn-budget"; + : stop === "never-edited" + ? "subagent-never-edited" + : stop === "thrash" + ? "subagent-thrash" + : "subagent-turn-budget"; const terminal: ReactorAction[] = [ capabilities.checkpoint(checkpoint), capabilities.reply(forcedStopReport(stop, lastText(content))), diff --git a/src/subagent/repetition.test.ts b/src/subagent/repetition.test.ts index e7d931c6a..0adaef0be 100644 --- a/src/subagent/repetition.test.ts +++ b/src/subagent/repetition.test.ts @@ -94,6 +94,18 @@ describe("repetition check accounting at the cycle-text cap", () => { }); }); + + test("flags a loop that injects zero-width spaces between identical windows", () => { + // Without format-char stripping, ZWSP breaks byte periodicity and the + // detector misses the loop (observed in live thrash fleets). + const window = "I'll open the remaining source files and implement the activity preview. "; + const zwsp = "\u200B"; + const text = (window + zwsp).repeat(12); + const hit = detectRepetition(text); + expect(hit).not.toBeNull(); + expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold); + }); + describe("appendCycleText", () => { test("keeps only the tail past the cap", () => { const text = appendCycleText("a".repeat(10), "b".repeat(10), 15); diff --git a/src/subagent/repetition.ts b/src/subagent/repetition.ts index fc2a1c675..c78384455 100644 --- a/src/subagent/repetition.ts +++ b/src/subagent/repetition.ts @@ -46,8 +46,14 @@ export type RepetitionHit = { // byte-periodic anyway — the period just spans the oscillation. The cost is // that a loop driven by a strictly monotonic counter escapes, but that shape // is indistinguishable from a legitimate numbered list. +// +// Format / invisible separators (ZWSP, BOM, soft hyphen, bidi marks, …) are +// stripped so a model that injects them between identical windows cannot +// evade the detector. Observed thrash loops used U+200B between repeats. function normalize(text: string): string { - return text.replace(/\s+/g, " "); + return text + .replace(/[\u200B-\u200D\uFEFF\u00AD\u2060\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "") + .replace(/\s+/g, " "); } function prefixFunction(s: string): Int32Array { diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 7c8dc48a0..46603d80b 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -410,6 +410,8 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { maxTurns, DEFAULT_SUBAGENT_REPEAT_LIMIT, modelFamilyPolicy.subAgentStallTimeoutMs, + Date.now, + params.intent === "implement", ), }); diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 31abada22..c897d9d1e 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -132,6 +132,7 @@ export type SubAgentStopReason = | "turn-budget" | "no-progress" | "never-acted" + | "never-edited" | "thrash" | "report-forced"; @@ -143,8 +144,8 @@ export type SubAgentStopReason = * turn-budget (hard cap). "report-forced" is not a competing stop reason — * it is a one-shot signal, forceReportWithin turns before the cap, telling * the caller to inject a wrap-up nudge and keep running; turn-budget remains - * reachable afterward. Tool-less turns always end the leaf as complete or - * never-acted. + * reachable afterward. Tool-less turns always end the leaf as complete, + * never-acted, or never-edited. */ export function evaluateSubAgentStop(input: { hasToolCalls: boolean; @@ -157,11 +158,25 @@ export function evaluateSubAgentStop(input: { /** When set, progressive thrash / force-report are evaluated after no-progress. */ thrashState?: ThrashState; thrashConfig?: Partial; + /** + * When true (intent=implement), a tool-using run that never wrote/edited a + * file is not a successful complete — salvage as never-edited so the parent + * does not treat a pure-explore "plan" as shipped work. + */ + requireEdit?: boolean; }): SubAgentStopReason | null { - // A tool-less turn always ends the leaf; classify success vs never-acted by - // whether the run used tools at all (planning-only prose is not a successful implement). + // A tool-less turn always ends the leaf. Planning-only prose is never-acted; + // implement intent that only read/searched (no edit_file/write_file/delete_file) + // is never-edited — both hard-block identical re-dispatch. if (!input.hasToolCalls) { - return input.everHadToolCalls ? "complete" : "never-acted"; + if (!input.everHadToolCalls) return "never-acted"; + if ( + input.requireEdit === true && + (input.thrashState === undefined || input.thrashState.editedPaths.size === 0) + ) { + return "never-edited"; + } + return "complete"; } // No-progress is more specific than thrash or the turn budget when both could apply. if (subAgentNoProgress(input.consecutiveIdentical, input.repeatLimit)) return "no-progress"; @@ -245,6 +260,7 @@ export function forcedStopReport( | "no-progress" | "turn-budget" | "never-acted" + | "never-edited" | "cancelled" | "deadline" | "thrash" @@ -259,15 +275,17 @@ export function forcedStopReport( ? "Stopped: progressive thrash (re-read pressure without finishing)." : reason === "never-acted" ? "Stopped: completed without using any tools." - : 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)." - : "Turn budget reached before finishing."; + : 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)." + : "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." @@ -275,7 +293,9 @@ export function forcedStopReport( ? "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 === "never-acted" ? "Leaf 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 === "cancelled" + : reason === "never-edited" + ? "Leaf 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 leaf mid-run; parent may re-dispatch with the partial findings below." : reason === "deadline" ? "Leaf wall-clock deadline elapsed mid-run; parent may re-dispatch with a longer deadline or a narrower scope for the remaining work." @@ -312,6 +332,12 @@ export function isNeverActedSubAgentReport(report: string): boolean { return parsed.summary.includes("without using any tools"); } +/** True when implement intent finished without any write/edit tools. */ +export function isNeverEditedSubAgentReport(report: string): boolean { + const parsed = parseSubAgentReport(report); + return parsed.summary.includes("without writing any files"); +} + /** True when the worker returned a deadline salvage report for the parent. */ export function isDeadlineSubAgentReport(report: string): boolean { const parsed = parseSubAgentReport(report); @@ -340,6 +366,9 @@ export const TURN_BUDGET_STOP_PARENT_HINT = const NEVER_ACTED_PARENT_HINT = "[Sub-agent finished without using any tools (planning/prose only). Treat findings as unexecuted; re-dispatch with a tighter brief if the work still needs doing. An identical brief will be refused.]"; +const NEVER_EDITED_PARENT_HINT = + "[Sub-agent finished implement intent without writing any files (read/search only). Treat findings as unexecuted; re-dispatch with an edit-first brief. An identical brief will be refused.]"; + const DEADLINE_PARENT_HINT = "[Sub-agent hit an explicit wall-clock deadline before finishing. Continue from Findings rather than redoing completed work; re-dispatch with continuation context and a longer deadline only if more wall-clock time is warranted.]"; @@ -384,6 +413,11 @@ export function appendNeverActedParentHint(report: string): string { return `${NEVER_ACTED_PARENT_HINT}\n\n${report}`; } +export function appendNeverEditedParentHint(report: string): string { + if (!isNeverEditedSubAgentReport(report)) return report; + return `${NEVER_EDITED_PARENT_HINT}\n\n${report}`; +} + export function appendDeadlineParentHint(report: string): string { if (!isDeadlineSubAgentReport(report)) return report; return `${DEADLINE_PARENT_HINT}\n\n${report}`; @@ -416,12 +450,14 @@ export function appendSubAgentParentHints( options: SubAgentParentHintOptions = {}, ): string { return appendDeadlineParentHint( - appendNeverActedParentHint( - appendTurnBudgetParentHint( - appendNoProgressParentHint( - appendThrashParentHint(appendRepetitionParentHint(report)), + appendNeverEditedParentHint( + appendNeverActedParentHint( + appendTurnBudgetParentHint( + appendNoProgressParentHint( + appendThrashParentHint(appendRepetitionParentHint(report)), + ), + options, ), - options, ), ), );