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:
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user