Skip to content

Commit 2623660

Browse files
committed
fix(director): harden coordinator seam with fallback and validation
1 parent 48b2d6f commit 2623660

2 files changed

Lines changed: 228 additions & 14 deletions

File tree

‎src/agent/director.ts‎

Lines changed: 107 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,81 @@ 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 > MAX_WORKFLOW_DIRECTIVE_CHARS) {
600+
logger.warn`workflow-coordinator-directive-truncated chars=${String(directive.length)} max=${String(MAX_WORKFLOW_DIRECTIVE_CHARS)}`;
601+
return `${directive.slice(0, MAX_WORKFLOW_DIRECTIVE_CHARS)}\n…[truncated]`;
602+
}
603+
return directive;
604+
} catch (err) {
605+
logger.warn`workflow-coordinator-directive-threw error=${err instanceof Error ? err.message : String(err)}`;
606+
return null;
607+
}
608+
}
609+
610+
private coordinatorCurrentStepIsGate(): boolean {
611+
try {
612+
return this.workflowCoordinator?.currentStepIsGate() === true;
613+
} catch (err) {
614+
logger.warn`workflow-coordinator-gate-threw error=${err instanceof Error ? err.message : String(err)}`;
615+
return false;
616+
}
617+
}
618+
619+
private coordinatorCurrentStepId(): string | null {
620+
try {
621+
return this.workflowCoordinator?.currentStepId() ?? null;
622+
} catch (err) {
623+
logger.warn`workflow-coordinator-step-id-threw error=${err instanceof Error ? err.message : String(err)}`;
624+
return null;
625+
}
626+
}
627+
628+
private coordinatorHandleToolDone(
629+
name: string | undefined,
630+
args: unknown,
631+
isError: boolean,
632+
): boolean {
633+
try {
634+
return (
635+
this.workflowCoordinator?.handleToolDone(name, args, isError) === true
636+
);
637+
} catch (err) {
638+
logger.warn`workflow-coordinator-handleToolDone-threw error=${err instanceof Error ? err.message : String(err)}`;
639+
return false;
640+
}
641+
}
642+
547643
updateToolDefinitions(toolDefinitions: ToolDefinition[]): void {
548644
const before = toolSetDigest(this._toolDefinitions);
549645
const after = toolSetDigest(toolDefinitions);
@@ -624,7 +720,7 @@ class ChatDirectorImpl extends DefaultDirector {
624720
private withCurrentTools(
625721
result: ReactorAction | ReactorAction[],
626722
): ReactorAction | ReactorAction[] {
627-
const active = this.workflowCoordinator?.isActive() === true;
723+
const active = this.coordinatorIsActive();
628724
// submit_output rides on the wire every turn, workflow or not, so
629725
// activating a workflow never grows the tools array and busts the cache
630726
// prefix. Outside a workflow it is a harmless no-op the director ignores
@@ -635,9 +731,7 @@ class ChatDirectorImpl extends DefaultDirector {
635731
? this._toolDefinitions
636732
: [...this._toolDefinitions, submitOutputDefinition];
637733

638-
const directive = active
639-
? (this.workflowCoordinator?.directive() ?? null)
640-
: null;
734+
const directive = active ? this.coordinatorDirective() : null;
641735

642736
const rewrite = (action: ReactorAction): ReactorAction => {
643737
if (action.type !== "infer") return action;
@@ -803,7 +897,7 @@ class ChatDirectorImpl extends DefaultDirector {
803897
this.pendingToolOnlyNudge = true;
804898
}
805899

806-
if (this.workflowCoordinator?.isActive()) {
900+
if (this.coordinatorIsActive()) {
807901
if (hasToolCalls) {
808902
this.workflowIdleTurns = 0;
809903
} else {
@@ -847,7 +941,7 @@ class ChatDirectorImpl extends DefaultDirector {
847941
) {
848942
const call = this.workflowCalls.get(event.result.callId);
849943
this.workflowCalls.delete(event.result.callId);
850-
const advanced = this.workflowCoordinator?.handleToolDone(
944+
const advanced = this.coordinatorHandleToolDone(
851945
call?.name,
852946
call?.args,
853947
event.result.isError === true,
@@ -953,8 +1047,8 @@ class ChatDirectorImpl extends DefaultDirector {
9531047
);
9541048
if (toolOnlyRewrite !== null) return toolOnlyRewrite;
9551049

956-
const coordinator = this.workflowCoordinator;
957-
if (coordinator?.isActive() && !coordinator.currentStepIsGate()) {
1050+
const coordinatorActive = this.coordinatorIsActive();
1051+
if (coordinatorActive && !this.coordinatorCurrentStepIsGate()) {
9581052
const hasTerminal = baseActions.some(
9591053
(a) => a.type === "wait" || a.type === "reply",
9601054
);
@@ -975,7 +1069,7 @@ class ChatDirectorImpl extends DefaultDirector {
9751069
),
9761070
];
9771071
}
978-
const stepId = coordinator.currentStepId();
1072+
const stepId = this.coordinatorCurrentStepId();
9791073
const stepClause =
9801074
stepId !== null
9811075
? `call submit_output with { "step": "${stepId}" } now`
@@ -1000,7 +1094,7 @@ class ChatDirectorImpl extends DefaultDirector {
10001094
// yielding there with open tasks is not an invariant breach — leave it to
10011095
// the workflow runtime and do not nudge.
10021096
const atWorkflowGate =
1003-
coordinator?.isActive() === true && coordinator.currentStepIsGate();
1097+
coordinatorActive && this.coordinatorCurrentStepIsGate();
10041098
if (!atWorkflowGate && hasActiveTasks(this.tasks)) {
10051099
const hasTerminal = baseActions.some(
10061100
(a) => a.type === "wait" || a.type === "reply",
@@ -1022,10 +1116,9 @@ class ChatDirectorImpl extends DefaultDirector {
10221116
// Inside a workflow the terminal action is submit_output with the
10231117
// current step id, so point the nudge at it rather than the general
10241118
// manage_tasks guidance.
1025-
const nudge =
1026-
coordinator?.isActive() === true
1027-
? WORKFLOW_OPEN_TASK_NUDGE
1028-
: IDLE_OPEN_TASK_NUDGE;
1119+
const nudge = coordinatorActive
1120+
? WORKFLOW_OPEN_TASK_NUDGE
1121+
: IDLE_OPEN_TASK_NUDGE;
10291122
return [...passThrough, inferWithNudge(capabilities, nudge)];
10301123
}
10311124
this.logTerminationWithOpenTasks("idle-stall");

‎src/director.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, test, expect } from "bun:test";
22
import { createChatDirector, askOperatorDefinition } from "./agent/director.js";
3+
import type { WorkflowCoordinator } from "./workflows/coordinator.js";
34
import { createAgentToolset } from "./agent/tools.js";
45
import { createAdvertisedToolset } from "./session/assemble-runtime.js";
56
import { createPermissionGate } from "./permission/gate.js";
@@ -1453,6 +1454,126 @@ describe("CL-7919 coordinator shape", () => {
14531454
const infer = actions.find((a) => a.type === "infer");
14541455
expect(inferEphemeralText(infer)).toBeUndefined();
14551456
});
1457+
1458+
// A throwing coordinator degrades to plain inference: decide() resolves
1459+
// with an infer free of the workflow directive instead of rejecting.
1460+
test("a throwing directive falls back to plain inference", async () => {
1461+
const { MAX_WORKFLOW_DIRECTIVE_CHARS } =
1462+
await import("./agent/director.js");
1463+
expect(MAX_WORKFLOW_DIRECTIVE_CHARS).toBeGreaterThan(0);
1464+
const director = createChatDirector("base-prompt", [], {
1465+
onTasksChange: () => undefined,
1466+
});
1467+
director.setWorkflowCoordinator({
1468+
directive: () => {
1469+
throw new Error("boom");
1470+
},
1471+
isActive: () => true,
1472+
currentStepIsGate: () => false,
1473+
currentStepId: () => "a",
1474+
handleToolDone: () => false,
1475+
} as unknown as WorkflowCoordinator);
1476+
const actions = actionsArray(
1477+
await director.decide(
1478+
makeMessageReceivedEvent("hello"),
1479+
mockState,
1480+
capabilitiesWithInferArgs,
1481+
),
1482+
);
1483+
const infer = actions.find((a) => a.type === "infer");
1484+
expect(infer).toBeDefined();
1485+
expect(inferEphemeralText(infer)).toBeUndefined();
1486+
});
1487+
1488+
// Every per-turn consult is guarded, not just directive(): a coordinator
1489+
// whose rails all throw still lets decide() (including the tool.done
1490+
// handleToolDone path) resolve to the plain loop.
1491+
test("throwing idle rails and handleToolDone fall back to the plain loop", async () => {
1492+
const director = createChatDirector("base-prompt", [], {
1493+
onTasksChange: () => undefined,
1494+
});
1495+
director.setWorkflowCoordinator({
1496+
directive: () => {
1497+
throw new Error("directive boom");
1498+
},
1499+
isActive: () => {
1500+
throw new Error("active boom");
1501+
},
1502+
currentStepIsGate: () => {
1503+
throw new Error("gate boom");
1504+
},
1505+
currentStepId: () => {
1506+
throw new Error("step boom");
1507+
},
1508+
handleToolDone: () => {
1509+
throw new Error("tool boom");
1510+
},
1511+
} as unknown as WorkflowCoordinator);
1512+
const fromMessage = actionsArray(
1513+
await director.decide(
1514+
makeMessageReceivedEvent("hello"),
1515+
mockState,
1516+
capabilitiesWithInferArgs,
1517+
),
1518+
);
1519+
expect(fromMessage.find((a) => a.type === "infer")).toBeDefined();
1520+
const fromToolDone = actionsArray(
1521+
await director.decide(
1522+
{
1523+
type: "tool.done",
1524+
result: { callId: "missing", content: "ok" },
1525+
} as unknown as ReactorInboundEvent,
1526+
mockState,
1527+
capabilitiesWithInferArgs,
1528+
),
1529+
);
1530+
expect(fromToolDone.length).toBeGreaterThan(0);
1531+
});
1532+
1533+
// The setter is the shape boundary: a lookalike missing coordinator
1534+
// members is rejected with a clear error instead of failing a turn later.
1535+
test("setWorkflowCoordinator rejects a misshapen coordinator", async () => {
1536+
const director = createChatDirector("base-prompt", [], {
1537+
onTasksChange: () => undefined,
1538+
});
1539+
expect(() =>
1540+
director.setWorkflowCoordinator({
1541+
isActive: () => true,
1542+
} as unknown as Parameters<typeof director.setWorkflowCoordinator>[0]),
1543+
).toThrow(/setWorkflowCoordinator.*invalid coordinator/);
1544+
});
1545+
1546+
// An oversized directive is capped with a marker, never dropped: the turn
1547+
// still carries workflow guidance within the bound.
1548+
test("an oversized directive is capped with a truncation marker", async () => {
1549+
const { MAX_WORKFLOW_DIRECTIVE_CHARS } =
1550+
await import("./agent/director.js");
1551+
const director = createChatDirector("base-prompt", [], {
1552+
onTasksChange: () => undefined,
1553+
});
1554+
const oversized = `prefix ${"x".repeat(MAX_WORKFLOW_DIRECTIVE_CHARS + 100)}`;
1555+
director.setWorkflowCoordinator({
1556+
directive: () => oversized,
1557+
isActive: () => true,
1558+
currentStepIsGate: () => false,
1559+
currentStepId: () => "a",
1560+
handleToolDone: () => false,
1561+
} as unknown as WorkflowCoordinator);
1562+
const actions = actionsArray(
1563+
await director.decide(
1564+
makeMessageReceivedEvent("hello"),
1565+
mockState,
1566+
capabilitiesWithInferArgs,
1567+
),
1568+
);
1569+
const text = inferEphemeralText(actions.find((a) => a.type === "infer"));
1570+
expect(text).toBeDefined();
1571+
expect(text).toContain("…[truncated]");
1572+
expect(text?.startsWith("prefix")).toBe(true);
1573+
expect(text?.length ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(
1574+
MAX_WORKFLOW_DIRECTIVE_CHARS + "…[truncated]".length + 1,
1575+
);
1576+
});
14561577
});
14571578

14581579
describe("submit_output workflow handler", () => {

0 commit comments

Comments
 (0)