Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 7 additions & 84 deletions src/agent/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,7 @@ describe("ChatDirector tool-only loop protection", () => {
const providerlessPolicy = { providerName: "test-provider" };

test("nudges once at the family threshold, after pending tools execute", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
providerlessPolicy,
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy });
const capabilities = makeCapabilities();

// Default family nudges at 12 consecutive tool-only turns.
Expand All @@ -122,18 +111,7 @@ describe("ChatDirector tool-only loop protection", () => {
});

test("the nudge is one-shot — it does not repeat on the next tool-only turn", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
providerlessPolicy,
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy });
const capabilities = makeCapabilities();

await runToolOnlyStreak(director, capabilities, 12);
Expand All @@ -144,18 +122,7 @@ describe("ChatDirector tool-only loop protection", () => {
});

test("pauses and stops issuing infers at the family pause threshold", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
providerlessPolicy,
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy });
const capabilities = makeCapabilities();

// Default family pauses at 20 consecutive tool-only turns.
Expand All @@ -169,18 +136,7 @@ describe("ChatDirector tool-only loop protection", () => {
});

test("resumes after the operator sends a new message", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
providerlessPolicy,
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy });
const capabilities = makeCapabilities();

await runToolOnlyStreak(director, capabilities, 20);
Expand All @@ -191,18 +147,7 @@ describe("ChatDirector tool-only loop protection", () => {
});

test("a dismissed ask_operator counts toward the streak like any other tool-only turn", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
providerlessPolicy,
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy });
const capabilities = makeCapabilities();

// 11 ordinary tool-only turns, then a turn whose only tool call is a
Expand Down Expand Up @@ -252,18 +197,7 @@ describe("ChatDirector tool-only loop protection", () => {
});

test("a busy-but-progressing session (text interleaved with tools) never trips", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
providerlessPolicy,
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy });
const capabilities = makeCapabilities();

let lastActions: ReactorAction[] = [];
Expand All @@ -278,18 +212,7 @@ describe("ChatDirector tool-only loop protection", () => {
});

test("grok's tightened thresholds fire earlier than the default family", async () => {
const director = createChatDirector(
"system",
[],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ providerName: "xai/default", model: "grok-4.5" },
);
const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: { providerName: "xai/default", model: "grok-4.5" } });
const capabilities = makeCapabilities();

// Grok nudges at 6, well below the default family's 12.
Expand Down
125 changes: 82 additions & 43 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,40 @@ function isCodeFile(path: string): boolean {
return CODE_FILE_EXT.test(path);
}

// Single implementation of "what does a manage_tasks tool call do to the
// task list", shared by the live decide() loop below and hydrateTasksFromTurns.
// Task state is owned by the director, not by the tool: manage_tasks's
// handler (src/agent/tools.ts) performs no side effect of its own — it
// parses the same arguments and returns a fixed "Tasks updated." string. The
// tool_call is therefore the authoritative event, and applying it here does
// not need to wait on a tool_result the handler never varies.
// Returns null when the call is not manage_tasks or its arguments don't
// parse, so callers can distinguish "no valid manage_tasks call here" from
// "a valid call that happened to be a no-op" — the latter still counts as an
// update for onTasksChange purposes.
function applyManageTasksToolCall(tasks: Task[], block: { name: string; arguments: unknown }): Task[] | null {
if (block.name !== "manage_tasks") return null;
const taskArgs = parseManageTasksArgs(block.arguments);
return taskArgs !== null ? applyManageTasks(tasks, taskArgs) : null;
}

export type ChatDirectorOptions = {
taskClassifier?: ((message: string, metadata: SessionMetadata) => Promise<TaskBoundary>) | undefined;
onActivateTools?: ((names: string[]) => void) | undefined;
inactivityTimeoutMs?: number | undefined;
totalTimeoutMs?: number | undefined;
workflowCoordinator?: WorkflowCoordinator | undefined;
onTasksChange: (tasks: Task[]) => void;
requestContinuation?: (() => void) | undefined;
provider?: { providerName: string; model?: string } | undefined;
};

// The constructor takes the resolved ModelFamilyPolicy rather than the raw
// `provider` input the factory function accepts and resolves on its behalf.
type ChatDirectorImplOptions = Omit<ChatDirectorOptions, "provider"> & {
modelFamilyPolicy?: ModelFamilyPolicy | undefined;
};

class ChatDirectorImpl extends DefaultDirector {
private readonly workflowCalls = new Map<string, { name: string; args: unknown }>();
private readonly lspTriggerCalls = new Set<string>();
Expand Down Expand Up @@ -341,29 +375,18 @@ class ChatDirectorImpl extends DefaultDirector {
private pendingToolOnlyNudge = false;
private pausedForToolOnly = false;

constructor(
systemPrompt: string,
toolDefinitions: ToolDefinition[],
taskClassifier?: (message: string, metadata: SessionMetadata) => Promise<TaskBoundary>,
onActivateTools?: (names: string[]) => void,
inactivityTimeoutMs?: number,
totalTimeoutMs?: number,
workflowCoordinator?: WorkflowCoordinator,
onTasksChange?: (tasks: Task[]) => void,
requestContinuation?: () => void,
modelFamilyPolicy?: ModelFamilyPolicy,
) {
constructor(systemPrompt: string, toolDefinitions: ToolDefinition[], options: ChatDirectorImplOptions) {
super(systemPrompt, toolDefinitions, {});
this._systemPrompt = systemPrompt;
this._toolDefinitions = toolDefinitions;
this.inactivityTimeoutMs = inactivityTimeoutMs;
this.totalTimeoutMs = totalTimeoutMs;
this.taskClassifier = taskClassifier;
this.onActivateTools = onActivateTools;
this.workflowCoordinator = workflowCoordinator;
this.onTasksChange = onTasksChange;
this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions);
this.modelFamilyPolicy = modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" });
this.inactivityTimeoutMs = options.inactivityTimeoutMs;
this.totalTimeoutMs = options.totalTimeoutMs;
this.taskClassifier = options.taskClassifier;
this.onActivateTools = options.onActivateTools;
this.workflowCoordinator = options.workflowCoordinator;
this.onTasksChange = options.onTasksChange;
this.compaction = createCompactionGovernor(options.requestContinuation, systemPrompt, toolDefinitions);
this.modelFamilyPolicy = options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" });
}

setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void {
Expand All @@ -386,6 +409,15 @@ class ChatDirectorImpl extends DefaultDirector {
return [...this.tasks];
}

// A resumed session's task list lives in the transcript, not in the freshly
// constructed director. Without this the chrome panel would read an empty
// list until the model happened to call manage_tasks again, disagreeing
// with the task block already painted in the transcript.
restoreTasks(tasks: Task[]): void {
this.tasks = [...tasks];
this.onTasksChange?.(this.tasks);
}

// The status bar's context meter falls back to this when a provider omits
// or zeroes usage on the latest turn — a local lower-then-corrected bound
// beats displaying a number the provider never actually reported.
Expand Down Expand Up @@ -617,9 +649,9 @@ class ChatDirectorImpl extends DefaultDirector {
for (const block of event.turn.content) {
if (block.type !== "tool_call") continue;
if (block.name === "manage_tasks") {
const taskArgs = parseManageTasksArgs(block.arguments);
if (taskArgs !== null) {
this.tasks = applyManageTasks(this.tasks, taskArgs);
const next = applyManageTasksToolCall(this.tasks, block);
if (next !== null) {
this.tasks = next;
this.onTasksChange?.(this.tasks);
}
} else if (block.name === "read_file" || block.name === "edit_file") {
Expand Down Expand Up @@ -775,27 +807,33 @@ class ChatDirectorImpl extends DefaultDirector {
export function createChatDirector(
systemPrompt: string,
toolDefinitions: ToolDefinition[],
taskClassifier?: (message: string, metadata: SessionMetadata) => Promise<TaskBoundary>,
onActivateTools?: (names: string[]) => void,
inactivityTimeoutMs?: number,
totalTimeoutMs?: number,
workflowCoordinator?: WorkflowCoordinator,
onTasksChange?: (tasks: Task[]) => void,
requestContinuation?: () => void,
provider?: { providerName: string; model?: string },
options: ChatDirectorOptions,
): ChatDirector {
return new ChatDirectorImpl(
systemPrompt,
toolDefinitions,
taskClassifier,
onActivateTools,
inactivityTimeoutMs,
totalTimeoutMs,
workflowCoordinator,
onTasksChange,
requestContinuation,
provider !== undefined ? resolveModelFamilyPolicy(provider) : undefined,
);
const { provider, ...rest } = options;
return new ChatDirectorImpl(systemPrompt, toolDefinitions, {
...rest,
// `provider` is raw {providerName, model} input; the constructor wants
// the resolved ModelFamilyPolicy, not the input it was resolved from.
modelFamilyPolicy: provider !== undefined ? resolveModelFamilyPolicy(provider) : undefined,
});
}

// Uses the same applyManageTasksToolCall a live session's decide() loop uses,
// so hydrate necessarily reaches the same task state live decide() would
// have produced from this transcript: the tool_call is the authoritative
// event (see applyManageTasksToolCall), and there is only the one function
// that knows how to turn a manage_tasks call into a task list.
export function hydrateTasksFromTurns(turns: ConversationTurn[]): Task[] {
let tasks: Task[] = [];
for (const turn of turns) {
if (turn.role !== "assistant") continue;
for (const block of turn.content) {
if (block.type !== "tool_call") continue;
const next = applyManageTasksToolCall(tasks, block);
if (next !== null) tasks = next;
}
}
return tasks;
}

export interface ChatDirector extends ReactorDirector {
Expand All @@ -804,5 +842,6 @@ export interface ChatDirector extends ReactorDirector {
setGoalGovernor(goal: GoalGovernor | undefined): void;
getGoalGovernor(): GoalGovernor | undefined;
getTasks(): Task[];
restoreTasks(tasks: Task[]): void;
getContextEstimate(): { tokens: number; isEstimate: boolean };
}
Loading
Loading