Skip to content
Closed
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ 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

- Removed the `never-edited`, `never-acted`, `no-ship`, and `no-progress` leaf
salvage classes and the sticky hard-block that refused an identical
re-dispatch after any of them fired. A worker sharing a directory can issue
real edits that a concurrent writer absorbs, leaving no net diff — that is
not a failure, and no salvage class now treats it as one. `turn-budget`,
`deadline`, `stalled`, `cancelled`, `incomplete-report`, and `repetition`
are unaffected.

## [0.2.108] - 2026-08-24

### Agent
Expand Down
6 changes: 3 additions & 3 deletions src/agent/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,13 +840,13 @@ describe("ChatDirector tool-only loop protection", () => {
expect(actions.some((a) => a.type === "infer")).toBe(true);
});

test("after a hard-block salvage, Skywalker is nudged once and unique reads do not pause", async () => {
test("after a repetition salvage, Skywalker is nudged once and unique reads do not pause", async () => {
const director = createChatDirector("system", [], {
onTasksChange: () => {},
provider: providerlessPolicy,
});
const capabilities = makeCapabilities();
const salvage = forcedStopReport("no-ship", "mapped the tree, never edited");
const salvage = forcedStopReport("repetition", "looped mid-stream");
await director.decide(
{
type: "inference.done",
Expand Down Expand Up @@ -1071,7 +1071,7 @@ describe("ChatDirector tool-only loop protection", () => {
await director.decide(taskTurn(id), mockState, capabilities);
const result = actionsArray(
await director.decide(
taskDoneEvent(id, { content: forcedStopReport("no-progress", "x") }),
taskDoneEvent(id, { content: forcedStopReport("turn-budget", "x") }),
mockState,
capabilities,
),
Expand Down
4 changes: 2 additions & 2 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from "../subagent/stop-policy.js";
import { PRESENT_VIEW_PRIMITIVES_GUIDANCE } from "./tool-schema-normalize.js";
import { isOperatorOriginated } from "./message-provenance.js";
import { classifyBriefSalvage, isHardBlockSalvage } from "../subagent/brief-dispatch.js";
import { classifyBriefSalvage } from "../subagent/brief-dispatch.js";
import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js";

// Fired when turnsSinceUserMessage reaches TURNS_SINCE_USER_MESSAGE_BACKSTOP.
Expand Down Expand Up @@ -935,7 +935,7 @@ class ChatDirectorImpl extends DefaultDirector {
this.pendingTaskCallIds.delete(event.result.callId);
const body = typeof event.result.content === "string" ? event.result.content : "";
const salvage = classifyBriefSalvage(body);
if (salvage !== null && isHardBlockSalvage(salvage) && !this.salvageNudgeFired) {
if (salvage === "repetition" && !this.salvageNudgeFired) {
this.salvageNudgeFired = true;
this.pendingSalvageNudge = PRIMARY_SALVAGE_NUDGE;
}
Expand Down
2 changes: 1 addition & 1 deletion src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function buildGuidelines(
"- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.",
"- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.",
"- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, no hard upper cap). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.",
"- After thrash / no-progress / repetition / never-acted salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.",
"- After a repetition salvage, re-dispatching an identical brief (prompt/agent/intent/success_criteria/do_not) unchanged will likely loop again — change the brief before retrying.",
"- Use manage_tasks for your own coordination checklist; spawning workers is `task`, not manage_tasks.",
"- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.",
]),
Expand Down
106 changes: 14 additions & 92 deletions src/subagent/brief-dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
/**
* Parent-side re-dispatch caps for task briefs (CL-4343 + CL-5203).
* Parent-side re-dispatch tracking for task briefs (CL-4343 + CL-5203).
*
* Leaf stops already salvage no-progress / turn-budget / etc. This
* module tracks how often the *parent* re-spawns the same brief so:
* - hard-block-class salvages refuse an identical re-dispatch for the rest of
* the parent chat session (sticky until the fingerprint changes)
* - turn-budget salvage flips from "raise maxTurns" to "stop" after enough
* same-brief dispatches without a successful complete
* Leaf stops already salvage turn-budget / deadline / etc. This module
* tracks how often the *parent* re-spawns the same brief so turn-budget
* salvage flips from "raise maxTurns" to "stop" after enough same-brief
* dispatches without a successful complete. No salvage class refuses
* re-dispatch (CL-6994) — every dispatch is admitted.
*
* Session-scoped: one ledger per createTaskTool instance (parent chat tool).
*/
Expand All @@ -15,20 +14,12 @@ import type { TaskIntent } from "./report.js";
import {
isDeadlineSubAgentReport,
isForcedStopSubAgentReport,
isNeverActedSubAgentReport,
isNeverEditedSubAgentReport,
isNoProgressSubAgentReport,
isNoShipSubAgentReport,
isRepetitionSubAgentReport,
isTurnBudgetSubAgentReport,
} from "./stop-policy.js";

/** Salvage classes that must not be re-dispatched with an identical brief. */
export type HardBlockSalvage =
"no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";

export type BriefSalvageKind =
HardBlockSalvage | "turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report";
"turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report" | "repetition";

export interface TaskBriefFingerprintInput {
prompt: string;
Expand All @@ -41,8 +32,6 @@ export interface TaskBriefFingerprintInput {
export interface BriefDispatchRecord {
/** How many times this fingerprint has been accepted for run (including first). */
dispatchCount: number;
/** Last salvage class observed for this fingerprint, if any. */
lastSalvage?: BriefSalvageKind;
}

/**
Expand All @@ -52,18 +41,6 @@ export interface BriefDispatchRecord {
*/
export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3;

const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>([
"no-ship",
"no-progress",
"repetition",
"never-acted",
"never-edited",
]);

export function isHardBlockSalvage(kind: BriefSalvageKind): kind is HardBlockSalvage {
return HARD_BLOCK_SALVAGES.has(kind);
}

/** True when the worker returned a stall salvage report. */
export function isStalledSubAgentReport(report: string): boolean {
return isForcedStopSubAgentReport(report, "stalled");
Expand All @@ -85,11 +62,7 @@ export function isIncompleteReportSubAgentReport(report: string): boolean {
*/
export function classifyBriefSalvage(report: string): BriefSalvageKind | null {
// Order: more specific salvage phrases first.
if (isNoShipSubAgentReport(report)) return "no-ship";
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";
if (isDeadlineSubAgentReport(report)) return "deadline";
if (isStalledSubAgentReport(report)) return "stalled";
Expand Down Expand Up @@ -127,13 +100,8 @@ function serializeList(items: readonly string[] | undefined): string {

export interface BriefDispatchLedger {
get: (fingerprint: string) => BriefDispatchRecord | undefined;
/**
* Pre-run gate. Returns ok with the 1-based dispatch count that will be used,
* or a reject message for the parent tool result.
*/
admit: (
fingerprint: string,
) => { ok: true; dispatchCount: number } | { ok: false; message: string };
/** Pre-run gate. Always admits; returns the 1-based dispatch count that will be used. */
admit: (fingerprint: string) => { ok: true; dispatchCount: number };
/** Record the outcome of an admitted run (salvage kind or null on success). */
recordOutcome: (fingerprint: string, salvage: BriefSalvageKind | null) => void;
/**
Expand All @@ -153,77 +121,31 @@ export function createBriefDispatchLedger(): BriefDispatchLedger {

admit(fingerprint) {
const existing = byFingerprint.get(fingerprint);
if (existing?.lastSalvage !== undefined && isHardBlockSalvage(existing.lastSalvage)) {
return {
ok: false,
message: hardBlockMessage(existing.lastSalvage, existing.dispatchCount),
};
}
const nextCount = (existing?.dispatchCount ?? 0) + 1;
byFingerprint.set(fingerprint, {
dispatchCount: nextCount,
...(existing?.lastSalvage !== undefined ? { lastSalvage: existing.lastSalvage } : {}),
});
byFingerprint.set(fingerprint, { dispatchCount: nextCount });
return { ok: true, dispatchCount: nextCount };
},

recordOutcome(fingerprint, salvage) {
const existing = byFingerprint.get(fingerprint);
if (existing === undefined) {
// admit() always runs first in production; keep defensive for unit tests.
byFingerprint.set(fingerprint, {
dispatchCount: salvage === null ? 0 : 1,
...(salvage !== null ? { lastSalvage: salvage } : {}),
});
return;
}
// A successful complete resets the same-brief retry budget. Any other
// salvage leaves dispatchCount as admit() already recorded it.
if (salvage === null) {
// CL-6710: a successful complete clears the sticky hard-block too.
// Two concurrent identical-brief dispatches can both admit; if one
// salvages and the other succeeds, the success proves the brief is
// re-dispatchable, so it must not leave the sibling's hard-block
// standing for the rest of the session.
byFingerprint.set(fingerprint, { dispatchCount: 0 });
return;
}
byFingerprint.set(fingerprint, {
dispatchCount: existing.dispatchCount,
lastSalvage: salvage,
});
},

release(fingerprint) {
const existing = byFingerprint.get(fingerprint);
if (existing === undefined) return;
if (existing.dispatchCount <= 1) {
if (existing.lastSalvage !== undefined) {
byFingerprint.set(fingerprint, {
dispatchCount: 0,
lastSalvage: existing.lastSalvage,
});
} else {
byFingerprint.delete(fingerprint);
}
byFingerprint.delete(fingerprint);
return;
}
byFingerprint.set(fingerprint, {
dispatchCount: existing.dispatchCount - 1,
...(existing.lastSalvage !== undefined ? { lastSalvage: existing.lastSalvage } : {}),
});
byFingerprint.set(fingerprint, { dispatchCount: existing.dispatchCount - 1 });
},
};
}

function hardBlockMessage(salvage: HardBlockSalvage, priorDispatches: number): string {
return (
`Error: refused re-dispatch of an identical task brief after a ${salvage} salvage ` +
`(already dispatched ${priorDispatches} time${priorDispatches === 1 ? "" : "s"}). ` +
`Change the brief (prompt, agent, intent, success_criteria, and/or do_not) before retrying — ` +
`raising maxTurns alone will not unlock this fingerprint. ` +
`To force a re-run of the same work, alter at least one of those fields so the fingerprint changes.`
);
}

/**
* Whether turn-budget parent hint should recommend stopping rather than
* re-dispatching with a higher maxTurns.
Expand Down
Loading
Loading