Skip to content

Commit 8871c17

Browse files
fix(director): harden coordinator seam with fallback and validation (#1050)
* fix(director): harden coordinator seam with fallback and validation * fix(director): guard coordinator step id and empty directive (#1059)
1 parent 48b2d6f commit 8871c17

2 files changed

Lines changed: 301 additions & 14 deletions

File tree

src/agent/director.ts

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,30 @@ const MAX_SPACER_ECHO_NUDGES = 2;
147147

148148
const 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+
150174
const 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

Comments
 (0)