Skip to content

Commit e83549f

Browse files
committed
Install turn and token caps on the main chat director
DefaultDirector's afterInferenceDone hook is the runtime's designed extension point for turn/token/time/cost caps, but ChatDirectorImpl passed it an empty policy, so a runaway loop or a stuck goal run had no ceiling on inference calls or spend. Interactive sessions warn once and stay alive (send another message to keep going); non-interactive runs hard-stop, since there's no operator to hand a continueable warning to. Claude-Session: https://claude.ai/code/session_01PiLfSXwRgDgMp5wC3SVAZn
1 parent 4acaa61 commit e83549f

2 files changed

Lines changed: 133 additions & 2 deletions

File tree

src/agent/director.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DefaultDirector } from "@intx/inference";
1+
import { DefaultDirector, type DefaultDirectorPolicy } from "@intx/inference";
22
import { getLogger } from "@intx/log";
33
import type {
44
ReactorDirector,
@@ -9,6 +9,7 @@ import type {
99
ToolDefinition,
1010
InferenceOptions,
1111
ConversationTurn,
12+
TokenUsage,
1213
} from "@intx/types/runtime";
1314
import {
1415
type SessionMetadata,
@@ -27,6 +28,31 @@ const RETRY_POLICY = createIntercodeRetryPolicy();
2728

2829
const logger = getLogger(["intercode", "agent", "director"]);
2930

31+
// Stability backstop, not a UX-facing turn limit: without these, a runaway
32+
// loop or a stuck goal run has no ceiling on inference calls or spend. 500
33+
// turns and 10M tokens sit well above any normal session so the cap only
34+
// fires on genuinely runaway behavior.
35+
const DEFAULT_SESSION_TURN_CAP = 500;
36+
const DEFAULT_SESSION_TOKEN_BUDGET = 10_000_000;
37+
38+
export type SessionCapsOptions = {
39+
/** Overrides DEFAULT_SESSION_TURN_CAP; fed by the resolved profile's maxTurns. */
40+
maxTurns?: number;
41+
/** Overrides DEFAULT_SESSION_TOKEN_BUDGET. */
42+
maxTokens?: number;
43+
/**
44+
* Interactive sessions (TUI) stay alive and warn once, so the operator can
45+
* keep going with a plain message. Non-interactive runs (headless, an
46+
* unattended goal) hard-stop instead, since there is no operator to hand
47+
* the warning to.
48+
*/
49+
interactive?: boolean;
50+
};
51+
52+
function sessionTokenTotal(usage: TokenUsage): number {
53+
return usage.input + usage.output + usage.cacheRead + usage.cacheWrite + usage.thinking;
54+
}
55+
3056
function isInternalRecoveryAbort(event: Extract<ReactorInboundEvent, { type: "inference.error" }>): boolean {
3157
return isInternalRecoveryAbortRaw(event.error.raw);
3258
}
@@ -325,6 +351,7 @@ class ChatDirectorImpl extends DefaultDirector {
325351
private tasks: Task[] = [];
326352
private readonly onTasksChange: ((tasks: Task[]) => void) | undefined;
327353
private turnCount = 0;
354+
private sessionCapWarned = false;
328355
private currentTaskLabel: string | undefined;
329356
private lastTaskSummary: string | undefined;
330357
private startedAt = Date.now();
@@ -341,8 +368,41 @@ class ChatDirectorImpl extends DefaultDirector {
341368
workflowCoordinator?: WorkflowCoordinator,
342369
onTasksChange?: (tasks: Task[]) => void,
343370
requestContinuation?: () => void,
371+
sessionCaps?: SessionCapsOptions,
344372
) {
345-
super(systemPrompt, toolDefinitions, {});
373+
const maxTurns = sessionCaps?.maxTurns ?? DEFAULT_SESSION_TURN_CAP;
374+
const maxTokens = sessionCaps?.maxTokens ?? DEFAULT_SESSION_TOKEN_BUDGET;
375+
const interactive = sessionCaps?.interactive ?? true;
376+
// Reads `this.turnCount`/`this.sessionCapWarned`, which do not exist until
377+
// super() returns, but the hook itself only runs on a later decide() call
378+
// (after construction has fully completed) — defining it here just wires
379+
// it into the policy super() needs up front.
380+
const policy: DefaultDirectorPolicy = {
381+
afterInferenceDone: (state: ReactorState) => {
382+
if (this.sessionCapWarned) return { type: "continue" };
383+
const tokensUsed = sessionTokenTotal(
384+
state.tokenUsage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
385+
);
386+
const overTurns = this.turnCount >= maxTurns;
387+
const overTokens = tokensUsed >= maxTokens;
388+
if (!overTurns && !overTokens) return { type: "continue" };
389+
this.sessionCapWarned = true;
390+
const reason = overTurns
391+
? `Session turn cap reached (${this.turnCount}/${maxTurns} inference turns).`
392+
: `Session token budget reached (${tokensUsed}/${maxTokens} tokens).`;
393+
if (interactive) {
394+
return {
395+
type: "halt",
396+
reason: `${reason} Send another message to keep going.`,
397+
};
398+
}
399+
return {
400+
type: "abort",
401+
reason: `${reason} Stopping this run to avoid runaway cost.`,
402+
};
403+
},
404+
};
405+
super(systemPrompt, toolDefinitions, policy);
346406
this._systemPrompt = systemPrompt;
347407
this._toolDefinitions = toolDefinitions;
348408
this.inactivityTimeoutMs = inactivityTimeoutMs;
@@ -678,6 +738,7 @@ export function createChatDirector(
678738
workflowCoordinator?: WorkflowCoordinator,
679739
onTasksChange?: (tasks: Task[]) => void,
680740
requestContinuation?: () => void,
741+
sessionCaps?: SessionCapsOptions,
681742
): ChatDirector {
682743
return new ChatDirectorImpl(
683744
systemPrompt,
@@ -689,6 +750,7 @@ export function createChatDirector(
689750
workflowCoordinator,
690751
onTasksChange,
691752
requestContinuation,
753+
sessionCaps,
692754
);
693755
}
694756

src/director.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,3 +753,72 @@ describe("goal continue-rule", () => {
753753
});
754754
});
755755

756+
describe("session turn and token caps", () => {
757+
const textTurn = (): ReactorInboundEvent =>
758+
({
759+
type: "inference.done",
760+
turn: { role: "assistant", model: "test", timestamp: 0, content: [{ type: "text", text: "done" }] },
761+
usage: { input: 10, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
762+
source: { model: "test-model" },
763+
}) as unknown as ReactorInboundEvent;
764+
765+
const stateWithTokens = (totalInput: number): ReactorState =>
766+
({
767+
tokenUsage: { input: totalInput, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
768+
}) as unknown as ReactorState;
769+
770+
const isHalt = (a: ReactorAction[]): boolean =>
771+
a.some((x) => x.type === "wait") && a.some((x) => x.type === "reply") && !a.some((x) => x.type === "done");
772+
const isAbort = (a: ReactorAction[]): boolean =>
773+
a.some((x) => x.type === "done") && a.some((x) => x.type === "reply");
774+
775+
test("interactive session surfaces a continueable warning once the turn cap is reached", async () => {
776+
const director = createChatDirector(
777+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
778+
{ maxTurns: 1, interactive: true },
779+
);
780+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
781+
expect(isHalt(actions)).toBe(true);
782+
});
783+
784+
test("a warned interactive session keeps going instead of warning on every turn", async () => {
785+
const director = createChatDirector(
786+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
787+
{ maxTurns: 1, interactive: true },
788+
);
789+
const first = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
790+
expect(isHalt(first)).toBe(true);
791+
792+
const second = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
793+
expect(isHalt(second)).toBe(false);
794+
expect(isAbort(second)).toBe(false);
795+
});
796+
797+
test("headless (non-interactive) run hard-stops when the turn cap is reached", async () => {
798+
const director = createChatDirector(
799+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
800+
{ maxTurns: 1, interactive: false },
801+
);
802+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(0), mockCapabilities));
803+
expect(isAbort(actions)).toBe(true);
804+
});
805+
806+
test("token budget hard-stops a headless run the same way as the turn cap", async () => {
807+
const director = createChatDirector(
808+
"base", [], undefined, undefined, undefined, undefined, undefined, undefined, undefined,
809+
{ maxTokens: 100, interactive: false },
810+
);
811+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(500), mockCapabilities));
812+
expect(isAbort(actions)).toBe(true);
813+
});
814+
815+
test("default caps do not fire during ordinary short sessions", async () => {
816+
const director = createChatDirector("base", []);
817+
for (let i = 0; i < 5; i++) {
818+
const actions = actionsArray(await director.decide(textTurn(), stateWithTokens(1000), mockCapabilities));
819+
expect(isAbort(actions)).toBe(false);
820+
expect(isHalt(actions)).toBe(false);
821+
}
822+
});
823+
});
824+

0 commit comments

Comments
 (0)