FN-7245: expose workflow authoring tools to agents

Expose workflow authoring and selection tools through agent-visible extension surfaces.

- Register workflow list/get/create/update/delete/settings/select and trait-list tools in the published pi extension.
- Export shared workflow tool schemas/factories and include workflow settings in centralized authoring tool sets.
- Update action-gate classifications, permission examples, docs, tests, and the changeset for the new agent workflow surface.

Files changed:
 .changeset/FN-7245-workflow-tools.md               |   7 +
 docs/agents.md                                     |   4 +-
 docs/cli-reference.md                              |  17 ++
 docs/workflow-steps.md                             |   9 +-
 .../src/__tests__/extension-workflow-tools.test.ts | 244 +++++++++++++++++++++
 packages/cli/src/__tests__/extension.test.ts       |   8 +
 packages/cli/src/extension.ts                      | 142 ++++++++++++
 packages/core/src/types.ts                         |   6 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   |   9 +-
 .../planning-document-tools-exposure.test.ts       |  19 +-
 .../engine/src/__tests__/agent-action-gate.test.ts |   8 +
 .../agent-workflow-tools-exposure.test.ts          |  30 ++-
 .../src/__tests__/gating-classifications.test.ts   |  13 +-
 .../src/__tests__/permanent-agent-gating.test.ts   |   4 +-
 packages/engine/src/agent-tools.ts                 |  13 +-
 packages/engine/src/gating-classifications.ts      |   6 +-
 packages/engine/src/index.ts                       |   7 +
 17 files changed, 506 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-7245

Fusion-Task-Lineage: 82501602-7177-4ee8-a67b-32de35aa73de

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 16:35:51 -07:00
parent 5b668d297f
commit 42226edde1
17 changed files with 506 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Expose workflow authoring tools through the published agent extension API.
category: feature
dev: Registers fn_workflow_create/update/delete/settings/get/select/list and fn_trait_list in the pi extension.

View File

@@ -32,9 +32,9 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them.
- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command.
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`.
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change.
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. Lanes without an ambient task, including dashboard chat/planning and published/pi extension calls outside a task, must pass an explicit `task_id`; task-bound executor paths may default to the current task.
- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency.
- Permanent/custom heartbeat agents receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session.
- Permanent/custom heartbeat agents and the published/pi extension receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task, while no-task extension/chat/planning lanes expose `fn_workflow_select` but require explicit `task_id`. Prompt-injectable lanes strip workflow approval-bypass flags during `fn_workflow_create`/`fn_workflow_update`; executor-owner paths are the only authoring path that may preserve those flags. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session.
- `agent.taskId` is an active-execution linkage, not durable ownership. It may legitimately point at a `todo`/`triage` task only while the agent has live run or executor-active proof; task-move sync and self-healing clear stale parked, terminal, or unresolved links otherwise. `fn_list_agents` and `fn_agent_show` therefore include column context in the human-readable `Current Task` line, such as `(triage)`, `(in-progress)`, `(not active — done)`, or `(unresolved)`, so coordinators can distinguish transient planning ownership from drift.
### Artifact registry tools

View File

@@ -4,6 +4,23 @@
Fusion’s command-line interface is exposed through the `fn` command.
<!--
FNXC:AgentTools 2026-06-29-22:31:
The published CLI/pi extension must document its agent-facing workflow authoring surface so operators know agents can inspect, create, update, configure, and delete custom workflows without using the dashboard editor.
-->
## Published agent extension workflow tools
The published `@runfusion/fusion` CLI bundle also exposes the pi extension tool surface used by external agents. Alongside task and coordination helpers, agents can now author and manage workflow definitions:
- `fn_workflow_list` / `fn_workflow_get` — discover built-in and custom workflows and inspect a workflow's IR before editing.
- `fn_workflow_create` / `fn_workflow_update` — create or revise custom workflow definitions through Fusion's central workflow validator.
- `fn_workflow_settings` — read and write typed per-project values for a workflow's declared settings.
- `fn_workflow_delete` — delete custom workflows; built-in workflows remain protected.
- `fn_trait_list` — list the column trait vocabulary used when authoring workflow columns.
Agents should still use `fn_workflow_select` only when assigning a workflow to an explicit task context or to a task they created.
## Global Usage
```bash

View File

@@ -30,13 +30,16 @@ Fusion workflows define the task lifecycle policy that moves work from an idea t
### Selecting workflows
Operators can select workflows in the dashboard wherever the task or board workflow selector is shown. Agents and automation can discover and assign them with the workflow tools:
Operators can select workflows in the dashboard wherever the task or board workflow selector is shown. Agents and automation can discover, author, tune, and assign them with the workflow tools:
- `fn_workflow_list` — list built-in and custom workflow definitions.
- `fn_workflow_list` / `fn_workflow_get` — list built-in and custom workflow definitions and inspect a definition's IR before editing.
- `fn_trait_list` — list the column trait vocabulary needed when authoring workflow IR columns.
- `fn_workflow_create` / `fn_workflow_update` / `fn_workflow_delete` — create, edit, or delete custom workflow definitions. Built-in workflow definitions are protected, malformed IRs are rejected by the central validator, and prompt-injectable lanes strip approval-bypass flags before saving.
- `fn_workflow_settings` — read or write typed per-project values for a workflow's declared settings; invalid values reject atomically without partial persistence.
- `fn_workflow_select` — assign a workflow to the current or named task.
- `workflow_id` on `fn_task_create` / delegation tools — create a task with a workflow already selected.
Agent-initiated workflow assignment is intentionally narrow: an agent may select or change a task's workflow only when the user explicitly requested that workflow, or when the agent created that task itself (for example by passing `workflow_id` to `fn_task_create` / delegation tools). Executors should not call `fn_workflow_select` to reroute the task they are currently executing unless that task's instructions or a user steering comment explicitly asks for the workflow change.
Agent-initiated workflow assignment is intentionally narrow: an agent may select or change a task's workflow only when the user explicitly requested that workflow, or when the agent created that task itself (for example by passing `workflow_id` to `fn_task_create` / delegation tools). Executors should not call `fn_workflow_select` to reroute the task they are currently executing unless that task's instructions or a user steering comment explicitly asks for the workflow change. Lanes without an ambient task, such as dashboard chat, planning, and published/pi extension calls made outside a task, must pass an explicit `task_id` to `fn_workflow_select`; task-bound executor lanes may default to the current task.
Decision-only or investigation tasks can also declare `noCommitsExpected` / `**No commits expected:** true`; that marker does not change workflow selection by itself. Tasks without an explicit workflow request or creator-owned workflow selection stay on the project default (`builtin:coding`).

View File

@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import kbExtension, { closeCachedStores } from "../extension.js";
import { TaskStore, type WorkflowIr } from "@fusion/core";
interface RegisteredTool {
name: string;
label: string;
description: string;
promptGuidelines?: string[];
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
return {
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
function makeCtx(cwd: string, taskId?: string) {
return { cwd, ...(taskId ? { taskId } : {}) } as any;
}
function workflowIr(name: string): WorkflowIr {
return {
version: "v2",
name,
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{
id: "plan",
kind: "prompt",
column: "todo",
config: { name: "Plan", prompt: "Plan the work", autoApprove: true },
},
{
id: "lint",
kind: "optional-group",
column: "todo",
config: {
name: "Lint",
defaultOn: true,
template: {
nodes: [{ id: "lint-step", kind: "gate", config: { name: "Lint", scriptName: "lint", cliSkipApproval: true } }],
edges: [],
},
},
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "plan", condition: "success" },
{ from: "plan", to: "lint", condition: "success" },
{ from: "lint", to: "end", condition: "success" },
],
settings: [
{ id: "workflowStepTimeoutMs", name: "Step timeout (ms)", type: "number", default: 360000 },
],
} as WorkflowIr;
}
async function readWorkflow(cwd: string, workflowId: string): Promise<any> {
const store = new TaskStore(cwd);
await store.init();
try {
return await store.getWorkflowDefinition(workflowId);
} finally {
await store.close();
}
}
describe("pi extension workflow authoring tools", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "fn-7245-cli-workflow-"));
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await closeCachedStores();
await rm(tmpDir, { recursive: true, force: true });
});
it("registers the full workflow authoring surface in the published API", () => {
/*
FNXC:WorkflowAuthoringTools 2026-06-29-22:48:
FN-7245 requires published/pi agents to see the same workflow authoring vocabulary as engine lanes, including trait discovery and settings, instead of relying on task workflow-selection references alone.
*/
expect([...api.tools.keys()].sort()).toEqual(expect.arrayContaining([
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list",
"fn_workflow_select",
]));
expect(api.tools.get("fn_workflow_select")?.promptGuidelines?.join(" ")).toMatch(/Provide task_id unless/i);
});
it("creates workflows through engine validation and strips approval-bypass flags", async () => {
const createTool = api.tools.get("fn_workflow_create")!;
const result = await createTool.execute(
"create-workflow",
{ name: "Approval-safe workflow", ir: workflowIr("Approval-safe workflow") },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.content[0].text).toContain("approval-bypass flags removed");
const persisted = await readWorkflow(tmpDir, result.details.workflowId);
expect(JSON.stringify(persisted.ir)).not.toContain("autoApprove");
expect(JSON.stringify(persisted.ir)).not.toContain("cliSkipApproval");
});
it("surfaces malformed IRs and built-in edits as structured tool errors", async () => {
const createTool = api.tools.get("fn_workflow_create")!;
const malformed = await createTool.execute(
"bad-workflow",
{ name: "Bad workflow", ir: { version: "v2", name: "Bad", nodes: [], edges: [] } },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(malformed.isError).toBe(true);
expect(malformed.content[0].text).toMatch(/ERROR: Failed to create workflow/i);
const updateTool = api.tools.get("fn_workflow_update")!;
const builtinEdit = await updateTool.execute(
"builtin-edit",
{ workflow_id: "builtin:coding", name: "Nope" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(builtinEdit.isError).toBe(true);
expect(builtinEdit.content[0].text).toMatch(/built-?in/i);
});
it("keeps workflow settings writes atomic on typed rejection and exposes trait vocabulary", async () => {
const createTool = api.tools.get("fn_workflow_create")!;
const created = await createTool.execute(
"create-settings-workflow",
{ name: "Settings workflow", ir: workflowIr("Settings workflow") },
undefined,
undefined,
makeCtx(tmpDir),
);
const workflowId = created.details.workflowId;
const settingsTool = api.tools.get("fn_workflow_settings")!;
const valid = await settingsTool.execute(
"settings-valid",
{ action: "set", workflow_id: workflowId, values: { workflowStepTimeoutMs: 5000 } },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(valid.isError).not.toBe(true);
expect(valid.details.stored).toEqual({ workflowStepTimeoutMs: 5000 });
const invalid = await settingsTool.execute(
"settings-invalid",
{ action: "set", workflow_id: workflowId, values: { workflowStepTimeoutMs: "fast" } },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(invalid.isError).toBe(true);
expect(invalid.details.rejections[0]).toMatchObject({ settingId: "workflowStepTimeoutMs", code: "type-mismatch" });
const afterInvalid = await settingsTool.execute(
"settings-get",
{ action: "get", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(afterInvalid.details.stored).toEqual({ workflowStepTimeoutMs: 5000 });
const traits = await api.tools.get("fn_trait_list")!.execute("traits", {}, undefined, undefined, makeCtx(tmpDir));
expect(traits.isError).not.toBe(true);
expect(traits.details.traits.length).toBeGreaterThan(0);
expect(traits.details.traits[0]).toHaveProperty("id");
});
it("requires explicit task_id for workflow selection without an ambient task but defaults when task-bound", async () => {
const createTask = api.tools.get("fn_task_create")!;
const task = await createTask.execute("task", { description: "Needs workflow" }, undefined, undefined, makeCtx(tmpDir));
const createWorkflow = await api.tools.get("fn_workflow_create")!.execute(
"workflow",
{ name: "Selectable workflow", ir: workflowIr("Selectable workflow") },
undefined,
undefined,
makeCtx(tmpDir),
);
const selectTool = api.tools.get("fn_workflow_select")!;
const noTask = await selectTool.execute(
"select-no-task",
{ workflow_id: createWorkflow.details.workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(noTask.isError).toBe(true);
expect(noTask.content[0].text).toMatch(/task_id is required/i);
const ambient = await selectTool.execute(
"select-ambient",
{ workflow_id: createWorkflow.details.workflowId },
undefined,
undefined,
makeCtx(tmpDir, task.details.taskId),
);
expect(ambient.isError).not.toBe(true);
expect(ambient.details.taskId).toBe(task.details.taskId);
});
});

View File

@@ -272,6 +272,14 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
describe("registration", () => {
it("registers all expected tools", () => {
const expected = [
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list",
"fn_workflow_select",
"fn_task_create",
"fn_task_update",
"fn_task_list",

View File

@@ -52,6 +52,15 @@ import {
fetchWebContent,
assertNoSecretPlaintext,
emitGoalRetrievalAudit,
createWorkflowAuthoringTools,
workflowListParams,
workflowGetParams,
workflowSelectParams,
workflowCreateParams,
workflowUpdateParams,
workflowDeleteParams,
workflowSettingsParams,
traitListParams,
} from "@fusion/engine";
import * as dashboard from "@fusion/dashboard";
import { resolve, basename, extname, join } from "node:path";
@@ -526,6 +535,90 @@ async function fetchGitHubIssueViaGh(
}
}
type EngineWorkflowToolName =
| "fn_workflow_list"
| "fn_workflow_get"
| "fn_workflow_create"
| "fn_workflow_update"
| "fn_workflow_delete"
| "fn_workflow_settings"
| "fn_trait_list"
| "fn_workflow_select";
const workflowExtensionToolSpecs: Array<{
name: EngineWorkflowToolName;
label: string;
description: string;
promptSnippet: string;
promptGuidelines: string[];
parameters: TSchema;
}> = [
{
name: "fn_workflow_list",
label: "fn: List Workflows",
description: "List built-in and custom Fusion workflow definitions available in this project.",
promptSnippet: "List Fusion workflow definitions available in this project",
promptGuidelines: ["Use before selecting or editing workflows to discover valid workflow IDs."],
parameters: workflowListParams,
},
{
name: "fn_workflow_get",
label: "fn: Get Workflow",
description: "Fetch a Fusion workflow definition by ID, including its resolved workflow IR.",
promptSnippet: "Fetch a Fusion workflow definition by ID",
promptGuidelines: ["Use after fn_workflow_list to inspect the current IR before updating a workflow."],
parameters: workflowGetParams,
},
{
name: "fn_workflow_create",
label: "fn: Create Workflow",
description: "Create a custom Fusion workflow definition from a validated workflow IR.",
promptSnippet: "Create a custom Fusion workflow definition",
promptGuidelines: ["Use fn_trait_list first when choosing column traits for a new workflow IR."],
parameters: workflowCreateParams,
},
{
name: "fn_workflow_update",
label: "fn: Update Workflow",
description: "Update a custom Fusion workflow definition's metadata, IR, or layout.",
promptSnippet: "Update a custom Fusion workflow definition",
promptGuidelines: ["Fetch the existing workflow first and preserve intentional IR fields when editing."],
parameters: workflowUpdateParams,
},
{
name: "fn_workflow_delete",
label: "fn: Delete Workflow",
description: "Delete a custom Fusion workflow definition; built-in workflows are protected.",
promptSnippet: "Delete a custom Fusion workflow definition",
promptGuidelines: ["Only delete custom workflows; built-in workflows are protected."],
parameters: workflowDeleteParams,
},
{
name: "fn_workflow_settings",
label: "fn: Workflow Settings",
description: "Read or write per-project values for a workflow's declared settings.",
promptSnippet: "Read or write per-project values for a workflow's declared settings",
promptGuidelines: ["Use fn_workflow_get to inspect setting declarations before writing values."],
parameters: workflowSettingsParams,
},
{
name: "fn_trait_list",
label: "fn: List Workflow Traits",
description: "List column traits available when authoring Fusion workflow IR columns.",
promptSnippet: "List column traits available for Fusion workflow authoring",
promptGuidelines: ["Use when authoring or updating workflow IR column traits."],
parameters: traitListParams,
},
{
name: "fn_workflow_select",
label: "fn: Select Task Workflow",
description: "Assign a workflow definition to a task by workflow ID.",
promptSnippet: "Assign a Fusion workflow to a task",
promptGuidelines: ["Provide task_id unless the current agent context is already bound to the intended task."],
parameters: workflowSelectParams,
},
];
// ── Extension entry point ──────────────────────────────────────────
export default function kbExtension(pi: ExtensionAPI) {
@@ -538,6 +631,55 @@ export default function kbExtension(pi: ExtensionAPI) {
// Tests may provide partial @fusion/dashboard mocks without this export.
}
for (const spec of workflowExtensionToolSpecs) {
pi.registerTool({
name: spec.name,
label: spec.label,
description: spec.description,
promptSnippet: spec.promptSnippet,
promptGuidelines: spec.promptGuidelines,
parameters: spec.parameters,
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
/*
FNXC:WorkflowAuthoringTools 2026-06-29-22:20:
The pi extension must expose the same workflow authoring surface agents get in engine lanes: list, get, create, update, delete, settings, trait vocabulary, and task selection when an ambient or explicit task target exists.
Reuse engine factories per call so validation and store-side behavior stay centralized; the extension only adapts cwd/task context to ExtensionAPI.registerTool.
FNXC:WorkflowAuthoringTools 2026-06-29-22:42:
The published/pi extension is prompt-injectable rather than executor-owner controlled, so workflow create/update must strip approval-bypass flags before persistence. Only task executor owner paths may keep cliSkipApproval/autoApprove intact.
FNXC:WorkflowAuthoringTools 2026-06-29-23:06:
fn_workflow_select may default only in task-bound extension contexts; no-task published API calls must pass task_id explicitly so an empty ambient task cannot accidentally route the wrong card.
*/
const store = await getStore(ctx.cwd);
const extensionContext = ctx as typeof ctx & { taskId?: string };
const currentTaskId = typeof extensionContext.taskId === "string" ? extensionContext.taskId : "";
const workflowTools = createWorkflowAuthoringTools(store, currentTaskId, { stripApprovalFlags: true });
const tool = workflowTools.find((candidate) => candidate.name === spec.name);
if (!tool) {
return {
content: [{ type: "text" as const, text: `ERROR: Workflow tool '${spec.name}' is not available.` }],
details: {},
isError: true,
};
}
if (spec.name === "fn_workflow_select" && !currentTaskId) {
const explicitTaskId = typeof (params as { task_id?: unknown }).task_id === "string"
? (params as { task_id: string }).task_id.trim()
: "";
if (!explicitTaskId) {
return {
content: [{ type: "text" as const, text: "ERROR: task_id is required when fn_workflow_select is called outside a task-bound extension context." }],
details: { error: "task_id_required" },
isError: true,
};
}
}
return tool.execute(toolCallId, params as never, _signal, _onUpdate, ctx);
},
});
}
// ── fn_task_create ───────────────────────────────────────────────
pi.registerTool({

View File

@@ -6275,7 +6275,8 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record<
file_write_delete: ["write", "edit", "fn_task_attach"],
command_execution: ["bash (non-git)", "fn_run_verification", "fn_acquire_repo_worktree", "read", "find", "grep", "ls"],
network_api: ["fn_research_run (web/research)", "fn_research_cancel", "fn_web_fetch", "worktrunk_install"],
/* FNXC:ToolGovernance 2026-06-27-16:51: Dashboard policy examples must mirror action-gate mutation exports. Identity reflection is exempt heartbeat coordination, so it is intentionally not advertised as task_agent_mutation. */
/* FNXC:ToolGovernance 2026-06-27-16:51: Dashboard policy examples must mirror action-gate mutation exports. Identity reflection is exempt heartbeat coordination, so it is intentionally not advertised as task_agent_mutation.
* FNXC:WorkflowAuthoringTools 2026-06-29-23:40: Published workflow authoring tools are now agent-visible, so policy examples include the mutating workflow create/update/delete/settings/select surface operators can approve or block. */
task_agent_mutation: [
"fn_task_create",
"fn_delegate_task",
@@ -6303,6 +6304,9 @@ export const AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES: readonly string[] = [
"fn_heartbeat_done",
"fn_task_document_write",
"fn_task_document_read",
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",
"fn_memory_search",
"fn_memory_get",
"fn_memory_append",

View File

@@ -389,9 +389,10 @@ describe("ChatManager.sendMessage", () => {
expect(assistantCall?.[1].content).toBe("Hello world!");
});
// U11 / R12 drift guard: the chat lane must expose all six fn_workflow_*
// tools to the agent when a scoped task store is available.
it("exposes all six fn_workflow_* tools to the chat agent when a task store is present", async () => {
// U11 / R12 drift guard: the chat lane must expose workflow discovery,
// mutation, settings, selection, and trait vocabulary to the agent when a
// scoped task store is available.
it("exposes the full workflow authoring surface to the chat agent when a task store is present", async () => {
let capturedTools: Array<{ name: string }> = [];
__setCreateFnAgent(async (options: any) => {
capturedTools = options.customTools ?? [];
@@ -412,9 +413,11 @@ describe("ChatManager.sendMessage", () => {
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_select",
"fn_trait_list",
]) {
expect(names).toContain(required);
}

View File

@@ -35,6 +35,17 @@ async function waitFor(condition: () => boolean): Promise<void> {
throw new Error("Timed out waiting for condition");
}
const REQUIRED_WORKFLOW_AUTHORING_TOOLS = [
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_select",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list",
] as const;
describe("planning task-document tools", () => {
let rootDir: string;
let globalDir: string;
@@ -55,7 +66,7 @@ describe("planning task-document tools", () => {
rmSync(globalDir, { recursive: true, force: true });
});
it("exposes task-document tools from both planning customTools assembly sites", async () => {
it("exposes task-document and workflow authoring tools from both planning customTools assembly sites", async () => {
const capturedNonStreaming: any[] = [];
__setCreateFnAgent(async (options: any) => {
capturedNonStreaming.push(options);
@@ -67,6 +78,9 @@ describe("planning task-document tools", () => {
const nonStreamingToolNames = capturedNonStreaming[0]?.customTools?.map((tool: any) => tool.name) ?? [];
expect(nonStreamingToolNames).toContain("fn_task_document_write");
expect(nonStreamingToolNames).toContain("fn_task_document_read");
for (const toolName of REQUIRED_WORKFLOW_AUTHORING_TOOLS) {
expect(nonStreamingToolNames).toContain(toolName);
}
const capturedStreaming: any[] = [];
__resetPlanningState();
@@ -92,6 +106,9 @@ describe("planning task-document tools", () => {
const streamingToolNames = capturedStreaming[0]?.customTools?.map((tool: any) => tool.name) ?? [];
expect(streamingToolNames).toContain("fn_task_document_write");
expect(streamingToolNames).toContain("fn_task_document_read");
for (const toolName of REQUIRED_WORKFLOW_AUTHORING_TOOLS) {
expect(streamingToolNames).toContain(toolName);
}
});
it("uses explicit task_id when planning document tools write and read task documents", async () => {

View File

@@ -135,6 +135,14 @@ describe("agent-action-gate", () => {
expect(blockedDecision.disposition).toBe("block");
});
it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("allows workflow discovery tool %s as a known coordination exemption", (toolName) => {
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy })).toMatchObject({
category: "exempt",
disposition: "allow",
operation: toolName,
});
});
it.each([
"fn_workflow_create",
"fn_workflow_update",

View File

@@ -13,26 +13,27 @@ import type { TaskStore } from "@fusion/core";
/**
* U11 / R12 drift guard (engine half): the workflow-authoring tool surface that
* chat, planning, and the task executor all share must always expose the core
* `fn_workflow_*` authoring tools. The lanes assemble most of their toolset
* from `createWorkflowAuthoringTools` (chat/planning) and the executor mirrors
* the same factories — so asserting factory completeness here guards every
* lane's source of truth. `fn_workflow_settings` remains a standalone factory
* that heartbeat/executor wire explicitly. Lane-wiring (that chat/planning
* actually pass these to createFnAgent) is asserted in packages/dashboard's
* exposure test.
* chat, planning, and the task executor all share must always expose workflow
* discovery, mutation, settings, selection, and trait vocabulary tools.
* The lanes assemble most of their toolset from `createWorkflowAuthoringTools`
* (chat/planning) and the executor mirrors the same factories — so asserting
* factory completeness here guards every lane's source of truth. Lane-wiring
* (that chat/planning actually pass these to createFnAgent) is asserted in
* packages/dashboard's exposure test.
*
* We invoke the REAL factories with a fake store — never mock the factories
* themselves — so a renamed/removed tool name is caught.
*/
const REQUIRED_WORKFLOW_TOOLS = [
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_select",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list",
] as const;
// Minimal stand-in; the factories only capture the store reference at build
@@ -40,12 +41,9 @@ const REQUIRED_WORKFLOW_TOOLS = [
const fakeStore = {} as unknown as TaskStore;
describe("workflow tool exposure (engine factories)", () => {
it("createWorkflowAuthoringTools exposes all six fn_workflow_* tools plus fn_trait_list", () => {
it("createWorkflowAuthoringTools exposes the complete workflow authoring surface", () => {
const names = createWorkflowAuthoringTools(fakeStore, "FN-1").map((t) => t.name);
for (const required of REQUIRED_WORKFLOW_TOOLS) {
expect(names).toContain(required);
}
expect(names).toContain("fn_trait_list");
expect(names).toEqual(REQUIRED_WORKFLOW_TOOLS);
});
it("each fn_workflow_* factory produces a tool with the expected name", () => {

View File

@@ -143,7 +143,9 @@ describe("gating-classifications parity", () => {
"fn_task_log",
"fn_task_search",
"fn_task_show",
"fn_trait_list",
"fn_update_identity",
"fn_workflow_get",
"fn_workflow_list",
"grep",
"ls",
@@ -274,18 +276,19 @@ describe("gating-classifications parity", () => {
expect(permanent).toMatchObject({ category, disposition: "block", recognized: true });
});
it("recognizes fn_workflow_list as read-only coordination instead of an unknown fallback", () => {
const permanent = classifyPermanentAgentToolCall("fn_workflow_list");
it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("recognizes %s as read-only coordination instead of an unknown fallback", (toolName) => {
const permanent = classifyPermanentAgentToolCall(toolName);
const action = evaluateAgentActionGate({
agentId: "a1",
toolName: "fn_workflow_list",
toolName,
args: {},
permissionPolicy: blockedPolicy,
});
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
expect(permanent).toEqual({ category: "none", recognized: true });
expect(action).toMatchObject({ category: "exempt", disposition: "allow" });
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_workflow_list")).toBe(true);
expect(action).toMatchObject({ category: "exempt", disposition: "allow", operation: toolName });
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(true);
});
it.each(["fn_task_search", "fn_task_get", "fn_task_list", "fn_task_show"] as const)("classifies task read tool %s as read-only", (toolName) => {

View File

@@ -134,8 +134,8 @@ describe("permanent-agent-gating", () => {
expect(decision).toMatchObject({ category, recognized: true, disposition: "block" });
});
it("classifies fn_workflow_list as recognized readonly", () => {
expect(classifyPermanentAgentToolCall("fn_workflow_list")).toEqual({ category: "none", recognized: true });
it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("classifies %s as recognized readonly", (toolName) => {
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
});
it("uses only canonical action-category names", () => {

View File

@@ -2267,13 +2267,13 @@ export function createTraitListTool(): ToolDefinition {
/**
* Assemble the full workflow-authoring tool surface for a single store-scoped
* lane (chat / planning / executor): the six `fn_workflow_*` tools plus
* `fn_trait_list` (trait vocabulary needed to author/update workflow IR).
* lane (chat / planning / executor): workflow discovery, selection, mutation,
* settings, and `fn_trait_list` (trait vocabulary needed to author/update
* workflow IR).
*
* Centralizing the list keeps the chat and planning lanes from drifting away
* from the executor's set as new workflow tools are added. `currentTaskId` is
* the default task for `fn_workflow_select`; lanes with no ambient task pass a
* placeholder (an agent can still target any task via the `task_id` param).
* FNXC:WorkflowAuthoringTools 2026-06-29-23:38:
* Prompt-injectable chat/planning lanes need settings and trait discovery next to create/update/delete so agents can author policy-aware workflows without falling back to unpublished surfaces.
* Centralizing the list keeps those lanes from drifting away as new workflow authoring tools are added. `currentTaskId` is the default task for `fn_workflow_select`; lanes with no ambient task pass a placeholder (an agent can still target any task via the `task_id` param).
*/
/**
* Options for the workflow authoring tool set.
@@ -2300,6 +2300,7 @@ export function createWorkflowAuthoringTools(
createWorkflowCreateTool(store, opts),
createWorkflowUpdateTool(store, opts),
createWorkflowDeleteTool(store),
createWorkflowSettingsTool(store),
createTraitListTool(),
];
}

View File

@@ -140,8 +140,10 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
"fn_insight_run_show",
"fn_goal_list",
"fn_goal_show",
// FNXC:ToolGovernance 2026-06-27-12:06: Workflow listing is read-only discovery and must stay positively recognized instead of relying on an unknown-tool fallback.
// FNXC:ToolGovernance 2026-06-29-23:36: Workflow and trait discovery tools are read-only authoring support. Positively classify list/get/trait vocabulary so newly exposed published and prompt-injectable lanes never rely on unknown-tool fallback.
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",
"fn_mission_list",
"fn_mission_show",
"fn_list_agents",
@@ -201,6 +203,8 @@ export const COORDINATION_EXEMPT_TOOLS = [
"fn_agent_show",
"fn_agent_org_chart",
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",
/**
* FNXC:ToolGovernance 2026-06-28-00:00:
* FN-7191 requires fn_ask_question to bypass permanent-agent approval gates like other user-messaging coordination tools; membership here makes the action gate classify it as exempt/allow even under locked-down policies.

View File

@@ -24,6 +24,7 @@ export {
createWorkflowCreateTool,
createWorkflowUpdateTool,
createWorkflowDeleteTool,
createWorkflowSettingsTool,
createTraitListTool,
createWorkflowAuthoringTools,
taskCreateParams,
@@ -42,7 +43,13 @@ export {
taskLogParams,
askQuestionParams,
workflowListParams,
workflowGetParams,
workflowSelectParams,
workflowCreateParams,
workflowUpdateParams,
workflowDeleteParams,
workflowSettingsParams,
traitListParams,
executeApprovedAgentProvisioning,
} from "./agent-tools.js";
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";