Skip to content

Commit e17f4f4

Browse files
Merge pull request #491 from corbitsdev/cl-6548-completeness-wrap-up-does-not-prove-critique-finishes-a-real
Stop treating a wrap-up envelope as a finished critique without reads
2 parents dde6a87 + 2183755 commit e17f4f4

5 files changed

Lines changed: 131 additions & 1 deletion

File tree

src/subagent/index.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
preferCompletedSubAgentReply,
3535
resolveSubAgentCatchOutcome,
3636
resolveSubAgentDeadlineMs,
37+
shouldRequireEvidence,
3738
subAgentToolName,
3839
SUBAGENT_DEADLINE_MARGIN_MS,
3940
SUBAGENT_PLUGIN_SPAWN_TEARDOWN_LIMITS,
@@ -45,6 +46,8 @@ import {
4546
} from "./index.js";
4647

4748
import { type } from "arktype";
49+
import { formatDirectorSystemPrompt } from "../agent/directors/identity.js";
50+
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
4851
import type {
4952
ReactorAction,
5053
ReactorCapabilities,
@@ -277,6 +280,91 @@ describe("sub-agent stop helpers", () => {
277280
).toBe("complete");
278281
});
279282

283+
test("shouldRequireEvidence is armed for CritiqueDirector prompt", () => {
284+
expect(
285+
shouldRequireEvidence({
286+
systemPromptRole: formatDirectorSystemPrompt(DIRECTOR_REGISTRY.critique),
287+
}),
288+
).toBe(true);
289+
expect(
290+
shouldRequireEvidence({
291+
systemPromptRole: DIRECTOR_REGISTRY.critique.systemPrompt,
292+
}),
293+
).toBe(true);
294+
});
295+
296+
test("shouldRequireEvidence is off for greybeard even with intent=review", () => {
297+
expect(
298+
shouldRequireEvidence({
299+
intent: "review",
300+
systemPromptRole: formatDirectorSystemPrompt(DIRECTOR_REGISTRY.greybeard),
301+
}),
302+
).toBe(false);
303+
});
304+
305+
test("evaluateSubAgentStop does not complete a review/critique with empty readCounts even with a full envelope", () => {
306+
const thrashState = {
307+
totalToolCalls: 1,
308+
readCounts: new Map(),
309+
editedPaths: new Set<string>(),
310+
};
311+
expect(
312+
evaluateSubAgentStop({
313+
hasToolCalls: false,
314+
everHadToolCalls: true,
315+
turnsCompleted: 2,
316+
maxTurns: 10,
317+
consecutiveIdentical: 0,
318+
repeatLimit: 2,
319+
lastAssistantText: FULL_REPORT_ENVELOPE,
320+
thrashState,
321+
requireEvidence: true,
322+
}),
323+
).toBe("incomplete-report");
324+
});
325+
326+
test("evaluateSubAgentStop completes a review when readCounts has file evidence", () => {
327+
const thrashState = {
328+
totalToolCalls: 1,
329+
readCounts: new Map([["src/gate.ts", 1]]),
330+
editedPaths: new Set<string>(),
331+
};
332+
expect(
333+
evaluateSubAgentStop({
334+
hasToolCalls: false,
335+
everHadToolCalls: true,
336+
turnsCompleted: 2,
337+
maxTurns: 10,
338+
consecutiveIdentical: 0,
339+
repeatLimit: 2,
340+
lastAssistantText: FULL_REPORT_ENVELOPE,
341+
thrashState,
342+
requireEvidence: true,
343+
}),
344+
).toBe("complete");
345+
});
346+
347+
test("evaluateSubAgentStop completes greybeard spawn-only envelope when requireEvidence is off", () => {
348+
const thrashState = {
349+
totalToolCalls: 1,
350+
readCounts: new Map(),
351+
editedPaths: new Set<string>(),
352+
};
353+
expect(
354+
evaluateSubAgentStop({
355+
hasToolCalls: false,
356+
everHadToolCalls: true,
357+
turnsCompleted: 2,
358+
maxTurns: 10,
359+
consecutiveIdentical: 0,
360+
repeatLimit: 2,
361+
lastAssistantText: FULL_REPORT_ENVELOPE,
362+
thrashState,
363+
requireEvidence: false,
364+
}),
365+
).toBe("complete");
366+
});
367+
280368
test("evaluateSubAgentStop returns never-acted when the run never used tools", () => {
281369
expect(
282370
evaluateSubAgentStop({

src/subagent/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export {
116116
coreSubAgentWebTools,
117117
createSubAgentRunController,
118118
runSubAgent,
119+
shouldRequireEvidence,
119120
type SubAgentRunController,
120121
} from "./run.js";
121122

src/subagent/nudge-director.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export class SubAgentDirector extends DefaultDirector {
9393
private readonly repeatLimit: number;
9494
/** When true (intent=implement), tool-less finish without edits salvages as never-edited. */
9595
private readonly requireEdit: boolean;
96+
/** When true (CritiqueDirector), empty readCounts is not a successful complete. */
97+
private readonly requireEvidence: boolean;
9698
private turnsCompleted = 0;
9799
private everHadToolCalls = false;
98100
private streak: ToolCallStreak = {
@@ -148,6 +150,7 @@ export class SubAgentDirector extends DefaultDirector {
148150
stallTimeoutMs?: number,
149151
now: () => number = Date.now,
150152
requireEdit: boolean = false,
153+
requireEvidence: boolean = false,
151154
) {
152155
super(systemPrompt, toolDefinitions, {});
153156
this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions);
@@ -157,6 +160,7 @@ export class SubAgentDirector extends DefaultDirector {
157160
this.now = now;
158161
this.lastActivityAt = now();
159162
this.requireEdit = requireEdit;
163+
this.requireEvidence = requireEvidence;
160164
}
161165

162166
override async decide(
@@ -217,6 +221,7 @@ export class SubAgentDirector extends DefaultDirector {
217221
repeatLimit: this.repeatLimit,
218222
thrashState: this.thrashState,
219223
requireEdit: this.requireEdit,
224+
requireEvidence: this.requireEvidence,
220225
lastAssistantText: this.lastAssistantText,
221226
incompleteReportNudgeFired: this.incompleteReportNudgeFired,
222227
});

src/subagent/run.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
} from "./dispose.js";
9090
import { createTaskTool } from "./task-tool.js";
9191
import type { RunSubAgentParams, SubAgentProvider } from "./types.js";
92+
import type { TaskIntent } from "./report.js";
9293
import { runWithSubAgentIdentity } from "./identity-context.js";
9394

9495
export type {
@@ -223,6 +224,21 @@ export function createSubAgentRunController(
223224
};
224225
}
225226

227+
/**
228+
* Arm requireEvidence only for CritiqueDirector. Greybeard is also
229+
* intent=review and may spawn-only then envelope; that is not a fake
230+
* review — do not pull it into the empty-readCounts gate.
231+
*/
232+
export function shouldRequireEvidence(input: {
233+
intent?: TaskIntent;
234+
systemPromptRole?: string;
235+
}): boolean {
236+
return (
237+
typeof input.systemPromptRole === "string" &&
238+
input.systemPromptRole.includes("CritiqueDirector")
239+
);
240+
}
241+
226242
// Spin up an isolated, autonomous agent loop, hand it one task, and return
227243
// its final report. `params.cwd` is either the dispatcher's own cwd (shared
228244
// mode) or a worktree snapshotted from the dispatcher's last commit
@@ -417,6 +433,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
417433
modelFamilyPolicy.subAgentStallTimeoutMs,
418434
Date.now,
419435
params.intent === "implement",
436+
shouldRequireEvidence(params),
420437
),
421438
});
422439

src/subagent/stop-policy.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ export type SubAgentStopReason =
275275
* (Summary, Findings, Blockers, Paths). Omitting `lastAssistantText`
276276
* still completes (back-compat). Missing envelope nudges
277277
* once (`incomplete-report`) then salvages (`incomplete-report-stop`).
278+
* When `requireEvidence` is set (CritiqueDirector), an empty `readCounts`
279+
* is not complete even with all four headings — same incomplete-report
280+
* nudge then salvage, so a wrap-up envelope cannot fake a real review.
278281
*/
279282
export function evaluateSubAgentStop(input: {
280283
hasToolCalls: boolean;
@@ -293,6 +296,13 @@ export function evaluateSubAgentStop(input: {
293296
* does not treat a pure-explore "plan" as shipped work.
294297
*/
295298
requireEdit?: boolean;
299+
/**
300+
* When true (CritiqueDirector leaf), a tool-using run that never
301+
* read or searched a file is not a successful complete — even a four-heading
302+
* envelope is incomplete-report so the parent does not treat a wrap-up
303+
* narration as a finished review.
304+
*/
305+
requireEvidence?: boolean;
296306
/**
297307
* Final assistant text of this turn. When omitted, a tool-less turn after
298308
* tools still completes (back-compat for existing unit tests). When provided,
@@ -307,7 +317,8 @@ export function evaluateSubAgentStop(input: {
307317
// read/searched (no edit_file/write_file/delete_file) is never-edited —
308318
// both hard-block identical re-dispatch. After those, a tool-less turn
309319
// following tools is complete only with a report envelope (or when
310-
// lastAssistantText is omitted).
320+
// lastAssistantText is omitted). CritiqueDirector additionally requires
321+
// at least one read/search in thrashState.readCounts.
311322
if (!input.hasToolCalls) {
312323
if (!input.everHadToolCalls) return "never-acted";
313324
if (
@@ -324,6 +335,14 @@ export function evaluateSubAgentStop(input: {
324335
? "incomplete-report-stop"
325336
: "incomplete-report";
326337
}
338+
if (
339+
input.requireEvidence === true &&
340+
(input.thrashState === undefined || input.thrashState.readCounts.size === 0)
341+
) {
342+
return input.incompleteReportNudgeFired === true
343+
? "incomplete-report-stop"
344+
: "incomplete-report";
345+
}
327346
return "complete";
328347
}
329348
// No-progress is more specific than thrash or the turn budget when both could apply.

0 commit comments

Comments
 (0)