FN-6091: add workflow_id support to task tools

Add workflow selection support across task creation, delegation, and updates.

- add workflow_id parameters and response text for engine and CLI task-creation/delegation tools
- allow fn_task_update to select or clear task workflows with reconciliation and validation
- cover workflow selection behavior with engine and extension tests and document the new tool parameters
- add a changeset for the published CLI package

Files changed:
 .changeset/fn-6091-workflow-id-agent-tools.md      |   5 +
 .../cli/skill/fusion/references/engine-tools.md    |   4 +-
 .../cli/skill/fusion/references/extension-tools.md |   9 +-
 .../skill/fusion/references/fusion-capabilities.md |   6 +-
 packages/cli/src/__tests__/extension.test.ts       | 189 +++++++++++++++++++++
 packages/cli/src/extension.ts                      |  71 +++++++-
 packages/engine/src/__tests__/agent-tools.test.ts  |  78 +++++++++
 packages/engine/src/agent-tools.ts                 |  32 +++-
 8 files changed, 374 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-6091

Fusion-Task-Lineage: 7ab2f36a-feeb-4888-9845-65294bacf1d0
This commit is contained in:
gsxdsm
2026-06-09 11:20:08 -07:00
parent 49bd88f4d0
commit 6271778dde
8 changed files with 374 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add `workflow_id` support to agent task creation, delegation, and update tools so agents can select or clear task workflows directly.

View File

@@ -11,7 +11,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
| Tool | Agent Types | Purpose | Parameters |
|---|---|---|---|
| `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`) |
| `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) |
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
@@ -44,7 +44,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_update_identity` | heartbeat | Update the current agent's own `soul`, `instructionsText`, or `memory` fields | `soul?` (string), `instructionsText?` (string), `memory?` (string) |
| `fn_reflect_on_performance` | executor, heartbeat (when reflection service enabled) | Generate reflection insights from prior runs | `focus_area?` (string) |
| `fn_list_agents` | triage, executor, heartbeat | List agents (optionally filtered) | `role?` (string), `state?` (string), `includeEphemeral?` (boolean) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]), `override?` (boolean) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]), `workflow_id?` (string), `override?` (boolean) |
| `fn_get_agent_config` | executor, heartbeat | Read full config for a direct-report agent | `agent_id` (string) |
| `fn_update_agent_config` | executor, heartbeat | Update config fields for a direct-report, non-ephemeral agent | `agent_id` (string), optional: `soul`, `instructions_text`, `instructions_path`, `heartbeat_procedure_path`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |
| `fn_agent_create` | executor, heartbeat | Create a non-ephemeral direct-report agent | `name` (string), `role` (string), optional: `soul`, `instructions_text`, `instructions_path`, `reportsTo`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |

View File

@@ -10,7 +10,7 @@ All tools are registered via the Fusion extension. They are available in any age
### fn_task_create
Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria.
Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -18,10 +18,11 @@ Create a new task on the Fusion task board. The task enters the planning column
| `depends` | array | — | Task IDs this depends on (e.g. ['FN-001', 'FN-002']) |
| `agentId` | string | — | Agent ID to assign this task to (e.g. 'agent-abc123') |
| `priority` | string(enum) | — | Task priority (low, normal, high, urgent) |
| `workflow_id` | string | — | Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). |
### fn_task_update
Update fields on an existing task. Supports modifying the title, description, dependencies, assigned agent, and priority after task creation.
Update fields on an existing task. Supports modifying the title, description, dependencies, assigned agent, priority, and workflow_id after task creation. Set workflow_id to a workflow ID to select it, or null to clear the workflow selection.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -32,6 +33,7 @@ Update fields on an existing task. Supports modifying the title, description, de
| `agentId` | union | — | Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123') |
| `nodeId` | union | — | Node ID override for this task, or null to clear |
| `priority` | string(enum) | — | Task priority (low, normal, high, urgent) |
| `workflow_id` | union | — | Workflow ID to select for this task (e.g. 'WF-003' or 'builtin:coding'), |
### fn_task_list
@@ -438,13 +440,14 @@ List all available agents in the system. Shows each agent's name, role, state, p
### fn_delegate_task
Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities.
Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agent_id` | string | ✓ | The agent ID to delegate work to |
| `description` | string | ✓ | What needs to be done |
| `dependencies` | array | — | Task IDs this new task depends on (e.g. [\"KB-001\"] |
| `workflow_id` | string | — | Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). |
| `override` | boolean | — | Set true to bypass executor-role assignment policy |
### fn_agent_show

View File

@@ -12,8 +12,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
<!-- BEGIN: fusion-capabilities-tool-table (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
| Tool | Purpose |
|------|---------|
| `fn_task_create` | Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria. |
| `fn_task_update` | Update fields on an existing task. Supports modifying the title, description, dependencies, assigned agent, and priority after task creation. |
| `fn_task_create` | Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs. |
| `fn_task_update` | Update fields on an existing task. Supports modifying the title, description, dependencies, assigned agent, priority, and workflow_id after task creation. Set workflow_id to a workflow ID to select it, or null to clear the workflow selection. |
| `fn_task_list` | List all tasks on the Fusion board, grouped by column. |
| `fn_task_show` | Show full details for a task including steps, progress, and log entries. |
| `fn_task_attach` | Attach a file to a task. Supports images (png, jpg, gif, webp) and text files (txt, log, json, yaml, yml, toml, csv, xml). |
@@ -69,7 +69,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_agent_create` | Create a new non-ephemeral agent. |
| `fn_agent_delete` | Delete a non-ephemeral agent. |
| `fn_list_agents` | List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. Use this to discover which agents exist and what they specialize in before delegating work. |
| `fn_delegate_task` | Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities. |
| `fn_delegate_task` | Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs. |
| `fn_agent_show` | Show detailed information about a single agent, including their role, state, position in the org hierarchy (reports-to, direct reports), skills, and current assignment. |
| `fn_agent_org_chart` | Show the organizational tree of agents, displaying the role hierarchy. Optionally filter to a subtree rooted at a specific agent. |
| `fn_skills_search` | Search the skills.sh directory for agent skills. Returns matching skills with names, sources (owner/repo), install counts, and install commands. Use fn_skills_install to install a selected skill. |

View File

@@ -28,6 +28,7 @@ vi.mock("../commands/task.js", () => ({
import kbExtension from "../extension.js";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js";
@@ -94,6 +95,47 @@ async function seedAgent(
return agent.id;
}
function linearWorkflowIr(name: string): WorkflowIr {
return {
version: "v1",
name,
nodes: [
{ id: "start", kind: "start" },
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "lint", condition: "success" },
{ from: "lint", to: "spec", condition: "success" },
{ from: "spec", to: "end", condition: "success" },
],
};
}
async function seedWorkflow(cwd: string, name = "QA workflow"): Promise<string> {
const store = new TaskStore(cwd);
await store.init();
try {
const workflow = await store.createWorkflowDefinition({ name, ir: linearWorkflowIr(name) });
return workflow.id;
} finally {
store.close();
}
}
async function readTaskWorkflowState(cwd: string, taskId: string) {
const store = new TaskStore(cwd);
await store.init();
try {
const task = await store.getTask(taskId);
const selection = store.getTaskWorkflowSelection(taskId);
return { task, selection };
} finally {
store.close();
}
}
async function removeDirWithRetries(path: string) {
const maxAttempts = 4;
@@ -332,6 +374,47 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(show.details.task.priority).toBe("urgent");
});
it("creates a task with workflow_id selected and materialized", async () => {
const workflowId = await seedWorkflow(tmpDir, "Explicit create workflow");
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-workflow",
{ description: "Workflow task", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain(`(workflow: ${workflowId})`);
const { task, selection } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("creates a task without workflow_id using the project default workflow", async () => {
const workflowId = await seedWorkflow(tmpDir, "Default create workflow");
const store = new TaskStore(tmpDir);
await store.init();
try {
await store.setDefaultWorkflowId(workflowId);
} finally {
store.close();
}
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-default-workflow",
{ description: "Default workflow task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const { task, selection } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("creates a task with dependencies", async () => {
const tool = api.tools.get("fn_task_create")!;
const first = await tool.execute(
@@ -554,6 +637,90 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(show.details.task.priority).toBe("high");
});
it("updates task workflow_id through workflow reconciliation", async () => {
const workflowId = await seedWorkflow(tmpDir, "Update workflow");
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const selectSpy = vi.spyOn(TaskStore.prototype, "selectTaskWorkflowAndReconcile");
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.updatedFields).toEqual(["workflowId"]);
expect(selectSpy).toHaveBeenCalledWith("FN-001", workflowId);
selectSpy.mockRestore();
const { task, selection } = await readTaskWorkflowState(tmpDir, "FN-001");
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("clears task workflow_id with null", async () => {
const workflowId = await seedWorkflow(tmpDir, "Clear workflow");
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original", workflow_id: workflowId }, undefined, undefined, makeCtx(tmpDir));
const clearSpy = vi.spyOn(TaskStore.prototype, "clearTaskWorkflowSelection");
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", workflow_id: null },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.updatedFields).toEqual(["workflowId"]);
expect(clearSpy).toHaveBeenCalledWith("FN-001");
clearSpy.mockRestore();
const { task, selection } = await readTaskWorkflowState(tmpDir, "FN-001");
expect(selection).toBeUndefined();
expect(task.enabledWorkflowSteps ?? []).toEqual([]);
});
it("returns an error for unknown workflow_id updates", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", workflow_id: "WF-404" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("WF-404");
});
it("treats empty-string workflow_id as not provided", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", title: "Retitled", workflow_id: " " },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.updatedFields).toEqual(["title"]);
const { selection } = await readTaskWorkflowState(tmpDir, "FN-001");
expect(selection).toBeUndefined();
});
it("rejects invalid priority value", () => {
const updateTool = api.tools.get("fn_task_update") as any;
const prioritySchema = updateTool.parameters.properties.priority;
@@ -3187,6 +3354,28 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(task!.column).toBe("todo");
});
it("delegates task with workflow_id selected and materialized", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-workflow-target" });
const workflowId = await seedWorkflow(tmpDir, "Delegate workflow");
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-workflow",
{ agent_id: agentId, description: "Do workflow work", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.content[0].text).toContain(`(workflow: ${workflowId})`);
const { task, selection } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("todo");
expect(task.assignedAgentId).toBe(agentId);
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("rejects unknown agent", async () => {
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(

View File

@@ -493,7 +493,8 @@ export default function kbExtension(pi: ExtensionAPI) {
description:
"Create a new task on the Fusion task board. The task enters the planning column " +
"where the AI planning agent will plan it into a full prompt with steps, " +
"file scope, and acceptance criteria.",
"file scope, and acceptance criteria. Optionally pass workflow_id to select " +
"a workflow at creation time; use fn_workflow_list to discover valid IDs.",
promptSnippet: "Create a task on the Fusion AI-orchestrated task board",
promptGuidelines: [
"Use fn_task_create for task tracking — be descriptive so the planning agent can write a good plan.",
@@ -514,6 +515,13 @@ export default function kbExtension(pi: ExtensionAPI) {
priority: Type.Optional(
StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema,
),
workflow_id: Type.Optional(
Type.String({
description:
"Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " +
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -541,12 +549,14 @@ export default function kbExtension(pi: ExtensionAPI) {
projectSettings,
globalSettings,
);
const workflowId = params.workflow_id?.trim() || undefined;
const task = await store.createTask({
description: params.description.trim(),
dependencies: params.depends,
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
priority: params.priority as TaskPriority | undefined,
...(workflowId ? { workflowId } : {}),
source: { sourceType: "api" },
githubTracking: resolvedTracking.enabled
? {
@@ -568,7 +578,7 @@ export default function kbExtension(pi: ExtensionAPI) {
{
type: "text",
text:
`Created ${task.id}: ${label}\n` +
`Created ${task.id}: ${label}${workflowId ? ` (workflow: ${workflowId})` : ""}\n` +
`Column: triage\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
@@ -608,10 +618,12 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "fn: Update Task",
description:
"Update fields on an existing task. Supports modifying the title, " +
"description, dependencies, assigned agent, and priority after task creation.",
"description, dependencies, assigned agent, priority, and workflow_id after task creation. " +
"Set workflow_id to a workflow ID to select it, or null to clear the workflow selection.",
promptSnippet: "Update fields on an existing Fusion task",
promptGuidelines: [
"Use fn_task_update to modify task title, description, dependencies, assigned agent, or priority after creation.",
"Use fn_task_update to modify task title, description, dependencies, assigned agent, priority, or workflow_id after creation.",
"Set workflow_id to null to clear a task's workflow selection and enabled workflow steps.",
"At least one field must be provided to update.",
],
parameters: Type.Object({
@@ -639,6 +651,14 @@ export default function kbExtension(pi: ExtensionAPI) {
priority: Type.Optional(
StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema,
),
workflow_id: Type.Optional(
Type.Union([Type.String(), Type.Null()], {
description:
"Workflow ID to select for this task (e.g. 'WF-003' or 'builtin:coding'), " +
"or null to clear the workflow selection and revert to the project default. " +
"Use fn_workflow_list to discover valid IDs.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -704,16 +724,39 @@ export default function kbExtension(pi: ExtensionAPI) {
updates.priority = params.priority;
updatedFields.push("priority");
}
if (params.workflow_id !== undefined) {
if (params.workflow_id === null) {
await store.clearTaskWorkflowSelection(task.id);
updatedFields.push("workflowId");
} else {
const workflowId = params.workflow_id.trim();
if (workflowId.length > 0) {
try {
await store.selectTaskWorkflowAndReconcile(task.id, workflowId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `ERROR: ${message}` }],
isError: true,
details: { error: message },
};
}
updatedFields.push("workflowId");
}
}
}
if (updatedFields.length === 0) {
return {
content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId, priority." }],
content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId, priority, workflow_id." }],
isError: true,
details: { error: "No fields provided" },
};
}
await store.updateTask(params.id, updates);
if (Object.keys(updates).length > 0) {
await store.updateTask(params.id, updates);
}
return {
content: [
@@ -3912,7 +3955,9 @@ export default function kbExtension(pi: ExtensionAPI) {
description:
"Create a new task and assign it to a specific agent for execution. The task goes to " +
"'todo' and will be picked up by the target agent on their next heartbeat cycle. " +
"Use fn_list_agents first to find available agents and their capabilities.",
"Use fn_list_agents first to find available agents and their capabilities. " +
"Optionally pass workflow_id to select a workflow at creation time; use " +
"fn_workflow_list to discover valid IDs.",
promptSnippet: "Delegate a task to a specific Fusion agent",
promptGuidelines: [
"Use fn_list_agents first to find available agents and their capabilities",
@@ -3927,6 +3972,13 @@ export default function kbExtension(pi: ExtensionAPI) {
dependencies: Type.Optional(
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"]" }),
),
workflow_id: Type.Optional(
Type.String({
description:
"Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " +
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
}),
),
override: Type.Optional(
Type.Boolean({ description: "Set true to bypass executor-role assignment policy" }),
),
@@ -3952,11 +4004,13 @@ export default function kbExtension(pi: ExtensionAPI) {
try {
// Create task assigned to the target agent
const store = await getStore(ctx.cwd);
const workflowId = params.workflow_id?.trim() || undefined;
const task = await store.createTask({
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
...(workflowId ? { workflowId } : {}),
source: {
sourceType: "api",
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
@@ -3964,10 +4018,11 @@ export default function kbExtension(pi: ExtensionAPI) {
});
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
const workflow = workflowId ? ` (workflow: ${workflowId})` : "";
return {
content: [{
type: "text" as const,
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}${workflow}. ` +
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
}],
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },

View File

@@ -170,6 +170,42 @@ describe("createTaskCreateTool", () => {
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
});
it("passes workflow_id through as workflowId", async () => {
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockResolvedValue({ id: "PROJ-099", description: "Test", dependencies: [], column: "triage" }),
};
const tool = createTaskCreateTool(store as any);
const result = await tool.execute(
"call-1",
{ description: "Test", workflow_id: " WF-003 " } as any,
undefined,
undefined,
{} as any,
);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
workflowId: "WF-003",
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
const responseText = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(responseText).toContain("(workflow: WF-003)");
});
it("omits workflowId when workflow_id is not provided", async () => {
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockResolvedValue({ id: "PROJ-100", description: "Test", dependencies: [], column: "triage" }),
};
const tool = createTaskCreateTool(store as any);
await tool.execute("call-1", { description: "Test" } as any, undefined, undefined, {} as any);
expect(store.createTask).toHaveBeenCalledWith(expect.not.objectContaining({
workflowId: expect.anything(),
}), expect.anything());
});
it("passes explicit provenance to store.createTask", async () => {
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
@@ -300,6 +336,48 @@ describe("createDelegateTaskTool", () => {
}), expect.any(Object));
});
it("passes workflow_id through as workflowId for delegated tasks", async () => {
const agentStore = {
getAgent: vi.fn().mockResolvedValue({ id: "agent-1", name: "Worker", role: "executor", state: "idle" }),
};
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockResolvedValue({ id: "FN-103", dependencies: [], description: "Delegated" }),
};
const tool = createDelegateTaskTool(agentStore as any, taskStore as any);
const result = await tool.execute(
"call-1",
{ agent_id: "agent-1", description: "Delegated", workflow_id: " builtin:coding " } as any,
undefined,
undefined,
{} as any,
);
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
workflowId: "builtin:coding",
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
const responseText = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(responseText).toContain("(workflow: builtin:coding)");
});
it("omits workflowId for delegated tasks when workflow_id is not provided", async () => {
const agentStore = {
getAgent: vi.fn().mockResolvedValue({ id: "agent-1", name: "Worker", role: "executor", state: "idle" }),
};
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockResolvedValue({ id: "FN-104", dependencies: [], description: "Delegated" }),
};
const tool = createDelegateTaskTool(agentStore as any, taskStore as any);
await tool.execute("call-1", { agent_id: "agent-1", description: "Delegated" } as any, undefined, undefined, {} as any);
expect(taskStore.createTask).toHaveBeenCalledWith(expect.not.objectContaining({
workflowId: expect.anything(),
}), expect.anything());
});
it("uses linked-existing wording when delegated task is a deterministic duplicate", async () => {
vi.spyOn(core, "runDeterministicDuplicateGuard").mockResolvedValueOnce({
action: "duplicate",

View File

@@ -43,6 +43,13 @@ export const taskCreateParams = Type.Object({
description: "Task priority (low, normal, high, urgent)",
}),
),
workflow_id: Type.Optional(
Type.String({
description:
"Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " +
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
}),
),
});
export const taskLogParams = Type.Object({
@@ -212,6 +219,13 @@ export const delegateTaskParams = Type.Object({
dependencies: Type.Optional(
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }),
),
workflow_id: Type.Optional(
Type.String({
description:
"Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " +
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
}),
),
override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })),
});
@@ -819,15 +833,19 @@ export function createTaskCreateTool(
"Before creating, scan existing open tasks for similar work — if an open task " +
"already covers this, do not create a duplicate. " +
"Optionally set dependencies (e.g., the new task depends on the current one, " +
"or the current task should wait for the new one).",
"or the current task should wait for the new one). " +
"Optionally pass workflow_id to select a workflow at creation time; use " +
"fn_workflow_list to discover valid IDs.",
parameters: taskCreateParams,
execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
try {
const workflowId = params.workflow_id?.trim() || undefined;
const { task, wasDuplicate } = await createAgentTask(store, {
description: params.description,
dependencies: params.dependencies,
column: "triage",
priority: params.priority,
...(workflowId ? { workflowId } : {}),
source: provenance ? {
sourceType: provenance.sourceType,
sourceAgentId: provenance.sourceAgentId,
@@ -836,10 +854,11 @@ export function createTaskCreateTool(
} : undefined,
}, options);
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
const workflow = workflowId ? ` (workflow: ${workflowId})` : "";
return {
content: [{
type: "text" as const,
text: `${wasDuplicate ? "Linked existing" : "Created"} ${task.id}: ${params.description}${deps}`,
text: `${wasDuplicate ? "Linked existing" : "Created"} ${task.id}: ${params.description}${deps}${workflow}`,
}],
details: { taskId: task.id },
};
@@ -2787,7 +2806,9 @@ export function createDelegateTaskTool(
description:
"Create a new task and assign it to a specific agent for execution. The task goes to " +
"'todo' and will be picked up by the target agent on their next heartbeat cycle. " +
"Use fn_list_agents first to find available agents and their capabilities.",
"Use fn_list_agents first to find available agents and their capabilities. " +
"Optionally pass workflow_id to select a workflow at creation time; use " +
"fn_workflow_list to discover valid IDs.",
parameters: delegateTaskParams,
execute: async (_id: string, params: Static<typeof delegateTaskParams>) => {
// Validate target agent exists
@@ -2817,12 +2838,14 @@ export function createDelegateTaskTool(
}
try {
const workflowId = params.workflow_id?.trim() || undefined;
// Create task assigned to the target agent
const { task, wasDuplicate } = await createAgentTask(taskStore, {
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
...(workflowId ? { workflowId } : {}),
source: {
sourceType: "api",
...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}),
@@ -2830,10 +2853,11 @@ export function createDelegateTaskTool(
}, options);
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
const workflow = workflowId ? ` (workflow: ${workflowId})` : "";
return {
content: [{
type: "text" as const,
text: `Delegated to ${agent.name} (${agent.id}): ${wasDuplicate ? "Linked existing" : "Created"} ${task.id}${deps}. ` +
text: `Delegated to ${agent.name} (${agent.id}): ${wasDuplicate ? "Linked existing" : "Created"} ${task.id}${deps}${workflow}. ` +
`The task will be picked up by ${agent.name} on their next heartbeat cycle.`,
}],
details: { taskId: task.id, agentId: agent.id, agentName: agent.name },