From 0922c116645293aa16da5ef53c9b8ec8e6690ad0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 03:08:29 -0700 Subject: [PATCH] Remove back-compat branch in evaluateSubAgentStop's stop decision lastAssistantText is now required so every call path gets the incomplete-report nudge instead of letting omitted text complete a tool-less turn unconditionally. --- CHANGELOG.md | 9 +++++++++ src/subagent/index.test.ts | 14 ++++++++++++-- src/subagent/stop-policy.ts | 21 +++++++++------------ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0206e0f42..0b7c92f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ 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 + +- `evaluateSubAgentStop` now always requires the final assistant text; the + omitted-text branch that unconditionally completed a tool-less turn is + removed, so every call path gets the `incomplete-report` nudge and salvage + when a tool-using run ends in envelope-less narration. + ## [0.2.109] - 2026-08-24 ### Agent diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index d9b3a8d66..61ffd2dd8 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -148,14 +148,15 @@ describe("sub-agent stop helpers", () => { expect(subAgentTurnLimitExceeded(1_000_000, Infinity)).toBe(false); }); - test("evaluateSubAgentStop returns complete when tools were used and the final turn has none", () => { + test("evaluateSubAgentStop returns incomplete-report when the final turn has no tool calls and no envelope", () => { expect( evaluateSubAgentStop({ hasToolCalls: false, turnsCompleted: 2, maxTurns: 10, + lastAssistantText: "", }), - ).toBe("complete"); + ).toBe("incomplete-report"); }); const SUMMARY_ONLY_NARRATION = [ @@ -293,6 +294,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 40, maxTurns: 60, thrashState: thrash, @@ -304,6 +306,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 10, maxTurns: 10, }), @@ -314,6 +317,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 5, maxTurns: 10, }), @@ -333,6 +337,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 10, maxTurns: 10, thrashState: thrash, @@ -344,6 +349,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 8, maxTurns: 10, thrashState: EMPTY_THRASH_STATE, @@ -353,6 +359,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 9, maxTurns: 10, thrashState: EMPTY_THRASH_STATE, @@ -361,6 +368,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 10, maxTurns: 10, thrashState: EMPTY_THRASH_STATE, @@ -378,6 +386,7 @@ describe("sub-agent stop helpers", () => { expect( evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted: 5, maxTurns: 20, thrashState: thrash, @@ -649,6 +658,7 @@ describe("thrash edge cases", () => { const stop = (turnsCompleted: number, maxTurns: number, thrashState = EMPTY_THRASH_STATE) => evaluateSubAgentStop({ hasToolCalls: true, + lastAssistantText: "", turnsCompleted, maxTurns, thrashState, diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 3d435c138..98c73ea9c 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -83,9 +83,9 @@ export type SubAgentStopReason = * caller to inject a wrap-up / redirect nudge and keep running; turn-budget * remains reachable afterward. A tool-less turn (including one that never * called a tool at all) completes only when the assistant text has a - * four-heading envelope (Summary, Findings, Blockers, Paths). Omitting - * `lastAssistantText` still completes (back-compat). Missing envelope nudges - * once (`incomplete-report`) then salvages (`incomplete-report-stop`). + * four-heading envelope (Summary, Findings, Blockers, Paths). Missing + * envelope nudges once (`incomplete-report`) then salvages + * (`incomplete-report-stop`). * When `requireEvidence` is set (CritiqueDirector), an empty `readCounts` * is not complete even with all four headings — same incomplete-report * nudge then salvage, so a wrap-up envelope cannot fake a real review. @@ -105,20 +105,17 @@ export function evaluateSubAgentStop(input: { */ requireEvidence?: boolean; /** - * Final assistant text of this turn. When omitted, a tool-less turn after - * tools still completes (back-compat for existing unit tests). When provided, - * a missing four-heading envelope (Summary/Findings/Blockers/Paths) nudges - * once then salvages. + * Final assistant text of this turn. A missing four-heading envelope + * (Summary/Findings/Blockers/Paths) nudges once then salvages. */ - lastAssistantText?: string; + lastAssistantText: string; /** True after the one-shot incomplete-report wrap-up nudge has been injected. */ incompleteReportNudgeFired?: boolean; }): SubAgentStopReason | null { - // A tool-less turn is complete only with a report envelope (or when - // lastAssistantText is omitted). CritiqueDirector additionally requires at - // least one read/search in thrashState.readCounts. + // A tool-less turn is complete only with a report envelope. CritiqueDirector + // additionally requires at least one read/search in thrashState.readCounts. if (!input.hasToolCalls) { - if (input.lastAssistantText !== undefined && !hasReportEnvelope(input.lastAssistantText)) { + if (!hasReportEnvelope(input.lastAssistantText)) { return input.incompleteReportNudgeFired === true ? "incomplete-report-stop" : "incomplete-report";