diff --git a/.changeset/FN-7245-workflow-tools.md b/.changeset/FN-7245-workflow-tools.md new file mode 100644 index 0000000000..b7d2961b9d --- /dev/null +++ b/.changeset/FN-7245-workflow-tools.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 5f14f8d207..fa1e89cbb8 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -32,9 +32,9 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - 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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8d7d47df7e..fda8eb7e0e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4,6 +4,23 @@ Fusion’s command-line interface is exposed through the `fn` command. + + +## 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 diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 955dfe7f7c..dfa5928190 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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`). diff --git a/packages/cli/src/__tests__/extension-workflow-tools.test.ts b/packages/cli/src/__tests__/extension-workflow-tools.test.ts new file mode 100644 index 0000000000..545ba7dbca --- /dev/null +++ b/packages/cli/src/__tests__/extension-workflow-tools.test.ts @@ -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; +} + +function createMockAPI() { + const tools = new Map(); + 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 { + 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; + + 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); + }); +}); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 38072c0258..4c11296876 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -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", diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index f0bc620f9b..b08fa60822 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index dadd3c4a39..bbfe3c5275 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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", diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index f845ada078..5be3670927 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -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); } diff --git a/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts b/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts index b3951a84f1..b1d22e3653 100644 --- a/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts +++ b/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts @@ -35,6 +35,17 @@ async function waitFor(condition: () => boolean): Promise { 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 () => { diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 123e0c7c30..dd7f6e30dc 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -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", diff --git a/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts b/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts index 1410947abd..38ca9aafa9 100644 --- a/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts +++ b/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts @@ -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", () => { diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 6bd91c5f4e..3366a1d23d 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -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) => { diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index cea4c28794..41127060fd 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -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", () => { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 95ff91030e..0c4477afdc 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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(), ]; } diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 4d1604c490..46c64726fb 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -140,8 +140,10 @@ export const READONLY_FN_TOOLS: ReadonlySet = 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. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b80f750aad..ea4ca66b3e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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";