@@ -147,6 +147,30 @@ const MAX_SPACER_ECHO_NUDGES = 2;
147147
148148const SPACER_ECHO_NUDGE = "Continue the task. Do not repeat internal markers." ;
149149
150+ // Upper bound for a coordinator directive injected into the next infer.
151+ // Directives are small step blocks; anything beyond this is a runaway prompt
152+ // or a misbehaving host object. Capped with a marker (never dropped) and
153+ // logged so the workflow owner can see the trim.
154+ export const MAX_WORKFLOW_DIRECTIVE_CHARS = 8_000 ;
155+
156+ // Runtime shape guard for the host-owned live object. TypeScript covers
157+ // in-repo callers; this covers JS hosts handing back a lookalike with a
158+ // missing or non-function member, which would otherwise reject decide()
159+ // a turn later at the first consult site.
160+ function isWorkflowCoordinatorLike (
161+ value : unknown ,
162+ ) : value is WorkflowCoordinator {
163+ if ( typeof value !== "object" || value === null ) return false ;
164+ const candidate = value as Record < string , unknown > ;
165+ return (
166+ typeof candidate . directive === "function" &&
167+ typeof candidate . isActive === "function" &&
168+ typeof candidate . currentStepIsGate === "function" &&
169+ typeof candidate . currentStepId === "function" &&
170+ typeof candidate . handleToolDone === "function"
171+ ) ;
172+ }
173+
150174const IDLE_OPEN_TASK_NUDGE =
151175 "\n\nYou are ending your turn while tasks are still open (todo/doing). " +
152176 "Finish the remaining work and mark each task done or cancelled with " +
@@ -541,9 +565,88 @@ class ChatDirectorImpl extends DefaultDirector {
541565 }
542566
543567 setWorkflowCoordinator ( coordinator : WorkflowCoordinator | undefined ) : void {
568+ if ( coordinator !== undefined && ! isWorkflowCoordinatorLike ( coordinator ) ) {
569+ throw new Error (
570+ "setWorkflowCoordinator: invalid coordinator — expected a WorkflowCoordinator " +
571+ "with directive(), isActive(), currentStepIsGate(), currentStepId(), " +
572+ "and handleToolDone() functions." ,
573+ ) ;
574+ }
544575 this . workflowCoordinator = coordinator ;
545576 }
546577
578+ // Coordinator consults are best-effort per-turn rails: a throwing host
579+ // object must degrade to plain inference, never reject decide(). Each
580+ // helper below catches, warns once per call, and returns the plain-loop
581+ // fallback so the session keeps running.
582+ private coordinatorIsActive ( ) : boolean {
583+ try {
584+ return this . workflowCoordinator ?. isActive ( ) === true ;
585+ } catch ( err ) {
586+ logger . warn `workflow-coordinator-isActive-threw error=${ err instanceof Error ? err . message : String ( err ) } ` ;
587+ return false ;
588+ }
589+ }
590+
591+ private coordinatorDirective ( ) : string | null {
592+ try {
593+ const directive = this . workflowCoordinator ?. directive ( ) ?? null ;
594+ if ( directive === null ) return null ;
595+ if ( typeof directive !== "string" ) {
596+ logger . warn `workflow-coordinator-directive-not-string` ;
597+ return null ;
598+ }
599+ if ( directive . length === 0 ) return null ;
600+ if ( directive . length > MAX_WORKFLOW_DIRECTIVE_CHARS ) {
601+ logger . warn `workflow-coordinator-directive-truncated chars=${ String ( directive . length ) } max=${ String ( MAX_WORKFLOW_DIRECTIVE_CHARS ) } ` ;
602+ return `${ directive . slice ( 0 , MAX_WORKFLOW_DIRECTIVE_CHARS ) } \n…[truncated]` ;
603+ }
604+ return directive ;
605+ } catch ( err ) {
606+ logger . warn `workflow-coordinator-directive-threw error=${ err instanceof Error ? err . message : String ( err ) } ` ;
607+ return null ;
608+ }
609+ }
610+
611+ private coordinatorCurrentStepIsGate ( ) : boolean {
612+ try {
613+ return this . workflowCoordinator ?. currentStepIsGate ( ) === true ;
614+ } catch ( err ) {
615+ logger . warn `workflow-coordinator-gate-threw error=${ err instanceof Error ? err . message : String ( err ) } ` ;
616+ return false ;
617+ }
618+ }
619+
620+ private coordinatorCurrentStepId ( ) : string | null {
621+ try {
622+ const stepId = this . workflowCoordinator ?. currentStepId ( ) ?? null ;
623+ if ( stepId === null ) return null ;
624+ if ( typeof stepId !== "string" ) {
625+ logger . warn `workflow-coordinator-step-id-not-string` ;
626+ return null ;
627+ }
628+ return stepId ;
629+ } catch ( err ) {
630+ logger . warn `workflow-coordinator-step-id-threw error=${ err instanceof Error ? err . message : String ( err ) } ` ;
631+ return null ;
632+ }
633+ }
634+
635+ private coordinatorHandleToolDone (
636+ name : string | undefined ,
637+ args : unknown ,
638+ isError : boolean ,
639+ ) : boolean {
640+ try {
641+ return (
642+ this . workflowCoordinator ?. handleToolDone ( name , args , isError ) === true
643+ ) ;
644+ } catch ( err ) {
645+ logger . warn `workflow-coordinator-handleToolDone-threw error=${ err instanceof Error ? err . message : String ( err ) } ` ;
646+ return false ;
647+ }
648+ }
649+
547650 updateToolDefinitions ( toolDefinitions : ToolDefinition [ ] ) : void {
548651 const before = toolSetDigest ( this . _toolDefinitions ) ;
549652 const after = toolSetDigest ( toolDefinitions ) ;
@@ -624,7 +727,7 @@ class ChatDirectorImpl extends DefaultDirector {
624727 private withCurrentTools (
625728 result : ReactorAction | ReactorAction [ ] ,
626729 ) : ReactorAction | ReactorAction [ ] {
627- const active = this . workflowCoordinator ?. isActive ( ) === true ;
730+ const active = this . coordinatorIsActive ( ) ;
628731 // submit_output rides on the wire every turn, workflow or not, so
629732 // activating a workflow never grows the tools array and busts the cache
630733 // prefix. Outside a workflow it is a harmless no-op the director ignores
@@ -635,9 +738,7 @@ class ChatDirectorImpl extends DefaultDirector {
635738 ? this . _toolDefinitions
636739 : [ ...this . _toolDefinitions , submitOutputDefinition ] ;
637740
638- const directive = active
639- ? ( this . workflowCoordinator ?. directive ( ) ?? null )
640- : null ;
741+ const directive = active ? this . coordinatorDirective ( ) : null ;
641742
642743 const rewrite = ( action : ReactorAction ) : ReactorAction => {
643744 if ( action . type !== "infer" ) return action ;
@@ -803,7 +904,7 @@ class ChatDirectorImpl extends DefaultDirector {
803904 this . pendingToolOnlyNudge = true ;
804905 }
805906
806- if ( this . workflowCoordinator ?. isActive ( ) ) {
907+ if ( this . coordinatorIsActive ( ) ) {
807908 if ( hasToolCalls ) {
808909 this . workflowIdleTurns = 0 ;
809910 } else {
@@ -847,7 +948,7 @@ class ChatDirectorImpl extends DefaultDirector {
847948 ) {
848949 const call = this . workflowCalls . get ( event . result . callId ) ;
849950 this . workflowCalls . delete ( event . result . callId ) ;
850- const advanced = this . workflowCoordinator ?. handleToolDone (
951+ const advanced = this . coordinatorHandleToolDone (
851952 call ?. name ,
852953 call ?. args ,
853954 event . result . isError === true ,
@@ -953,8 +1054,8 @@ class ChatDirectorImpl extends DefaultDirector {
9531054 ) ;
9541055 if ( toolOnlyRewrite !== null ) return toolOnlyRewrite ;
9551056
956- const coordinator = this . workflowCoordinator ;
957- if ( coordinator ?. isActive ( ) && ! coordinator . currentStepIsGate ( ) ) {
1057+ const coordinatorActive = this . coordinatorIsActive ( ) ;
1058+ if ( coordinatorActive && ! this . coordinatorCurrentStepIsGate ( ) ) {
9581059 const hasTerminal = baseActions . some (
9591060 ( a ) => a . type === "wait" || a . type === "reply" ,
9601061 ) ;
@@ -975,7 +1076,7 @@ class ChatDirectorImpl extends DefaultDirector {
9751076 ) ,
9761077 ] ;
9771078 }
978- const stepId = coordinator . currentStepId ( ) ;
1079+ const stepId = this . coordinatorCurrentStepId ( ) ;
9791080 const stepClause =
9801081 stepId !== null
9811082 ? `call submit_output with { "step": "${ stepId } " } now`
@@ -1000,7 +1101,7 @@ class ChatDirectorImpl extends DefaultDirector {
10001101 // yielding there with open tasks is not an invariant breach — leave it to
10011102 // the workflow runtime and do not nudge.
10021103 const atWorkflowGate =
1003- coordinator ?. isActive ( ) === true && coordinator . currentStepIsGate ( ) ;
1104+ coordinatorActive && this . coordinatorCurrentStepIsGate ( ) ;
10041105 if ( ! atWorkflowGate && hasActiveTasks ( this . tasks ) ) {
10051106 const hasTerminal = baseActions . some (
10061107 ( a ) => a . type === "wait" || a . type === "reply" ,
@@ -1022,10 +1123,9 @@ class ChatDirectorImpl extends DefaultDirector {
10221123 // Inside a workflow the terminal action is submit_output with the
10231124 // current step id, so point the nudge at it rather than the general
10241125 // manage_tasks guidance.
1025- const nudge =
1026- coordinator ?. isActive ( ) === true
1027- ? WORKFLOW_OPEN_TASK_NUDGE
1028- : IDLE_OPEN_TASK_NUDGE ;
1126+ const nudge = coordinatorActive
1127+ ? WORKFLOW_OPEN_TASK_NUDGE
1128+ : IDLE_OPEN_TASK_NUDGE ;
10291129 return [ ...passThrough , inferWithNudge ( capabilities , nudge ) ] ;
10301130 }
10311131 this . logTerminationWithOpenTasks ( "idle-stall" ) ;
0 commit comments