diff --git a/packages/engine/src/__tests__/agent-tools.test.ts b/packages/engine/src/__tests__/agent-tools.test.ts index 26768064cc..d2436efdb2 100644 --- a/packages/engine/src/__tests__/agent-tools.test.ts +++ b/packages/engine/src/__tests__/agent-tools.test.ts @@ -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 = { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 8a958e0afe..630cd99639 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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) => { + 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", diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index dd33ebd54e..7fcb77e5aa 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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 { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index cd827692d9..b85de67990 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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";