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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/subagent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -293,6 +294,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 40,
maxTurns: 60,
thrashState: thrash,
Expand All @@ -304,6 +306,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 10,
maxTurns: 10,
}),
Expand All @@ -314,6 +317,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 5,
maxTurns: 10,
}),
Expand All @@ -333,6 +337,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 10,
maxTurns: 10,
thrashState: thrash,
Expand All @@ -344,6 +349,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 8,
maxTurns: 10,
thrashState: EMPTY_THRASH_STATE,
Expand All @@ -353,6 +359,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 9,
maxTurns: 10,
thrashState: EMPTY_THRASH_STATE,
Expand All @@ -361,6 +368,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 10,
maxTurns: 10,
thrashState: EMPTY_THRASH_STATE,
Expand All @@ -378,6 +386,7 @@ describe("sub-agent stop helpers", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: true,
lastAssistantText: "",
turnsCompleted: 5,
maxTurns: 20,
thrashState: thrash,
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 9 additions & 12 deletions src/subagent/stop-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
Expand Down
Loading