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:
7
.changeset/FN-7245-workflow-tools.md
Normal file
7
.changeset/FN-7245-workflow-tools.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
244
packages/cli/src/__tests__/extension-workflow-tools.test.ts
Normal file
244
packages/cli/src/__tests__/extension-workflow-tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user