Skip to content

Commit bc2c622

Browse files
committed
Give the session cap precedence over the open-task nudge
The cap policy's halt and abort decisions reuse the same wait/reply/done action shapes as an ordinary end-of-turn yield, so the open-task nudge (and the workflow nudge and goal continue-rule) could not tell a capped turn apart from a plain one and rewrote its wait into a fresh infer(), silently swallowing the cap and continuing the loop. Detect the moment the cap fires and return its actions untouched before any continuation rewrite runs. Claude-Session: https://claude.ai/code/session_01PiLfSXwRgDgMp5wC3SVAZn
1 parent e83549f commit bc2c622

2 files changed

Lines changed: 52 additions & 0 deletions

File tree

src/agent/director.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,18 @@ class ChatDirectorImpl extends DefaultDirector {
652652
this.compaction.noteInferenceDone(event, state?.turns?.length ?? 0);
653653
}
654654

655+
const capAlreadyWarned = this.sessionCapWarned;
655656
const base = await super.decide(event, state, capabilities);
656657
const baseActions = Array.isArray(base) ? base : [base];
657658

659+
// The session cap's halt/abort reuses the same wait/reply/done shapes as
660+
// an ordinary end-of-turn (see afterInferenceDone above), so every
661+
// continuation rewrite below — open-task nudge, workflow nudge, goal
662+
// continue-rule — would otherwise mistake it for a plain yield and
663+
// replace it with a fresh infer(), silently swallowing the cap. Detect
664+
// the false-to-true transition and return the cap's own actions as-is.
665+
if (!capAlreadyWarned && this.sessionCapWarned) return base;
666+
658667
this.compaction.noteIdleTurn(event, baseActions);
659668
const compacted = this.compaction.interceptActions(event, baseActions, capabilities);
660669
if (compacted !== null) return compacted;

src/director.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,5 +820,48 @@ describe("session turn and token caps", () => {
820820
expect(isHalt(actions)).toBe(false);
821821
}
822822
});
823+
824+
const manageTasksEvent = (status: "todo" | "doing" | "done") =>
825+
makeInferenceDoneEvent([
826+
{ id: "m", name: "manage_tasks", args: { action: "create", tasks: [{ id: "t1", title: "work", status }] } },
827+
]);
828+
829+
test("the turn cap still halts an interactive session when a task is left open on the capping turn", async () => {
830+
const director = createChatDirector(
831+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
832+
{ maxTurns: 2, interactive: true },
833+
);
834+
// Turn 1: leaves a task open, well under the cap.
835+
await director.decide(manageTasksEvent("doing"), stateWithTokens(0), mockCapabilities);
836+
// Turn 2: reaches the cap while the task is still open. Without the fix,
837+
// the open-task nudge rewrites the halt's wait into a fresh infer(),
838+
// swallowing the cap and continuing the loop.
839+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
840+
expect(isHalt(actions)).toBe(true);
841+
expect(actions.some((a) => a.type === "infer")).toBe(false);
842+
});
843+
844+
test("the turn cap still aborts a headless run when a task is left open on the capping turn", async () => {
845+
const director = createChatDirector(
846+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
847+
{ maxTurns: 2, interactive: false },
848+
);
849+
await director.decide(manageTasksEvent("doing"), stateWithTokens(0), mockCapabilities);
850+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
851+
expect(isAbort(actions)).toBe(true);
852+
expect(actions.some((a) => a.type === "infer")).toBe(false);
853+
});
854+
855+
test("the open-task nudge still fires normally when no cap is breached", async () => {
856+
const director = createChatDirector(
857+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
858+
{ maxTurns: 100, interactive: true },
859+
);
860+
await director.decide(manageTasksEvent("doing"), stateWithTokens(0), mockCapabilities);
861+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
862+
expect(actions.some((a) => a.type === "infer")).toBe(true);
863+
expect(isHalt(actions)).toBe(false);
864+
expect(isAbort(actions)).toBe(false);
865+
});
823866
});
824867

0 commit comments

Comments
 (0)