feat(engine): add fn_workflow_list and fn_workflow_select agent tools

Agent-native parity: users can list workflows and select one for a task in
the dashboard, so agents should be able to as well. Adds two task-session
tools — fn_workflow_list (read: built-ins + user definitions) and
fn_workflow_select (assign a workflow to a task, defaulting to the current
one) — wired into the executor's customTools and exported from the engine
index. Covered by unit tests against mock stores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 15:31:11 -07:00
parent 291072446d
commit 5c02271551
4 changed files with 164 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ import {
createReadMessagesTool,
createPostRoomMessageTool,
createResearchTools,
createWorkflowListTool,
createWorkflowSelectTool,
qmdAgentMemoryCollectionName,
readAgentMemoryWorkspaceLongTerm,
sendMessageParams,
@@ -355,6 +357,59 @@ describe("createTaskLogTool", () => {
});
});
describe("createWorkflowListTool", () => {
it("lists workflows with ids and surfaces them in details", async () => {
const store = {
listWorkflowDefinitions: vi.fn().mockResolvedValue([
{ id: "builtin:coding", name: "Coding (built-in)", description: "The standard pipeline" },
{ id: "WF-003", name: "QA", description: "" },
]),
};
const tool = createWorkflowListTool(store as any);
const result = await tool.execute("call-1", {} as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("builtin:coding: Coding (built-in) — The standard pipeline");
expect(text).toContain("WF-003: QA");
expect(result.details).toEqual({ workflowIds: ["builtin:coding", "WF-003"] });
});
it("reports when no workflows exist", async () => {
const store = { listWorkflowDefinitions: vi.fn().mockResolvedValue([]) };
const tool = createWorkflowListTool(store as any);
const result = await tool.execute("call-1", {} as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/no workflows/i);
});
});
describe("createWorkflowSelectTool", () => {
it("selects for the current task by default and reports enabled step count", async () => {
const store = { selectTaskWorkflow: vi.fn().mockResolvedValue(["workflow:WF-003:lint"]) };
const tool = createWorkflowSelectTool(store as any, "FN-200");
const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any);
expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-200", "WF-003");
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Selected workflow WF-003 for FN-200 (1 step enabled)");
expect(result.details).toMatchObject({ taskId: "FN-200", workflowId: "WF-003" });
});
it("honors an explicit task_id override", async () => {
const store = { selectTaskWorkflow: vi.fn().mockResolvedValue([]) };
const tool = createWorkflowSelectTool(store as any, "FN-200");
await tool.execute("call-1", { workflow_id: "builtin:coding", task_id: "FN-999" } as any, undefined, undefined, {} as any);
expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-999", "builtin:coding");
});
it("returns an error result when selection fails", async () => {
const store = { selectTaskWorkflow: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) };
const tool = createWorkflowSelectTool(store as any, "FN-200");
const result = await tool.execute("call-1", { workflow_id: "WF-404" } as any, undefined, undefined, {} as any);
expect((result as { isError?: boolean }).isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/Workflow not found: WF-404/);
});
});
describe("createTaskLogToolWithContext", () => {
it("returns a graceful archived read-only message instead of throwing", async () => {
const store = {

View File

@@ -61,6 +61,19 @@ export const taskDocumentReadParams = Type.Object({
),
});
export const workflowListParams = Type.Object({});
export const workflowSelectParams = Type.Object({
workflow_id: Type.String({
description:
"The workflow definition ID to select (e.g. 'WF-003', or a 'builtin:*' id). " +
"Use fn_workflow_list to discover available IDs.",
}),
task_id: Type.Optional(
Type.String({ description: "Task to assign the workflow to. Defaults to the current task." }),
),
});
export const reflectOnPerformanceParams = Type.Object({
focus_area: Type.Optional(
Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }),
@@ -933,6 +946,86 @@ export function createTaskDocumentReadTool(store: TaskStore, taskId: string): To
};
}
/**
* Create a `fn_workflow_list` tool that lists the workflows available for a
* project (read-only built-ins plus user-authored definitions). Agent-native
* parity with the dashboard's workflow picker.
*/
export function createWorkflowListTool(store: TaskStore): ToolDefinition {
return {
name: "fn_workflow_list",
label: "List Workflows",
description:
"List the custom workflows available for this project — read-only built-ins " +
"(ids starting with 'builtin:') and user-authored definitions. Use before " +
"fn_workflow_select to discover valid workflow IDs.",
parameters: workflowListParams,
execute: async () => {
try {
const workflows = await store.listWorkflowDefinitions();
if (workflows.length === 0) {
return {
content: [{ type: "text" as const, text: "No workflows are defined for this project." }],
details: {},
};
}
const lines = workflows.map(
(w) => `- ${w.id}: ${w.name}${w.description ? ` — ${w.description}` : ""}`,
);
return {
content: [{ type: "text" as const, text: `Available workflows:\n${lines.join("\n")}` }],
details: { workflowIds: workflows.map((w) => w.id) },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `ERROR: Failed to list workflows: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Create a `fn_workflow_select` tool that assigns a workflow definition to a
* task (defaulting to the current task). Mirrors the dashboard's per-task
* workflow selection so an agent can set up a task the same way a user can.
*/
export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string): ToolDefinition {
return {
name: "fn_workflow_select",
label: "Select Workflow",
description:
"Assign a custom workflow to a task by its workflow ID. Defaults to the " +
"current task when task_id is omitted. Note: selecting a workflow does not " +
"retroactively change a pipeline already running — it applies when the task " +
"next executes its steps. Use fn_workflow_list to find valid IDs.",
parameters: workflowSelectParams,
execute: async (_id: string, params: Static<typeof workflowSelectParams>) => {
const taskId = params.task_id?.trim() || currentTaskId;
try {
const enabled = await store.selectTaskWorkflow(taskId, params.workflow_id);
return {
content: [{
type: "text" as const,
text: `Selected workflow ${params.workflow_id} for ${taskId} (${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled).`,
}],
details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `ERROR: Failed to select workflow: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
return {
name: "fn_memory_search",

View File

@@ -135,6 +135,8 @@ import {
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool,
createTaskLogTool as sharedCreateTaskLogTool,
createWorkflowListTool as sharedCreateWorkflowListTool,
createWorkflowSelectTool as sharedCreateWorkflowSelectTool,
} from "./agent-tools.js";
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
@@ -4609,6 +4611,8 @@ export class TaskExecutor {
this.createSpawnAgentTool(task.id, worktreePath, settings, taskEnv),
this.createTaskDocumentWriteTool(task.id),
this.createTaskDocumentReadTool(task.id),
this.createWorkflowListTool(),
this.createWorkflowSelectTool(task.id),
...(isResearchToolSurfaceEnabled(settings)
? createResearchTools({
store: this.store,
@@ -6360,6 +6364,14 @@ export class TaskExecutor {
return sharedCreateTaskDocumentReadTool(this.store, taskId);
}
private createWorkflowListTool(): ToolDefinition {
return sharedCreateWorkflowListTool(this.store);
}
private createWorkflowSelectTool(taskId: string): ToolDefinition {
return sharedCreateWorkflowSelectTool(this.store, taskId);
}
private createTaskAddDepTool(taskId: string): ToolDefinition {
const store = this.store;
return {

View File

@@ -7,10 +7,14 @@ export {
createTaskLogTool,
createSendMessageTool,
createReadMessagesTool,
createWorkflowListTool,
createWorkflowSelectTool,
taskCreateParams,
taskDocumentReadParams,
taskDocumentWriteParams,
taskLogParams,
workflowListParams,
workflowSelectParams,
executeApprovedAgentProvisioning,
} from "./agent-tools.js";
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";