1- import { DefaultDirector } from "@intx/inference" ;
1+ import { DefaultDirector , type DefaultDirectorPolicy } from "@intx/inference" ;
22import { getLogger } from "@intx/log" ;
33import type {
44 ReactorDirector ,
@@ -9,6 +9,7 @@ import type {
99 ToolDefinition ,
1010 InferenceOptions ,
1111 ConversationTurn ,
12+ TokenUsage ,
1213} from "@intx/types/runtime" ;
1314import {
1415 type SessionMetadata ,
@@ -27,6 +28,31 @@ const RETRY_POLICY = createIntercodeRetryPolicy();
2728
2829const 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+
3056function 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
0 commit comments