feat(engine,dashboard): expose workflow authoring tools to chat and planning agents

This commit is contained in:
gsxdsm
2026-06-04 22:54:49 -07:00
parent d8ff8db759
commit 8a5fa66def
12 changed files with 201 additions and 13 deletions

View File

@@ -8,6 +8,6 @@ Prompt nodes carry an execution profile: run on a chosen model, as a named agent
CLI nodes can run arbitrary commands (not just named scripts); the first run of an exact command pauses the task for explicit user approval. The task modal's input/approval banner is interactive — reply-and-resume for user-input nodes, approve-and-run for CLI commands.
Agents reach workflows too: new `fn_workflow_list` and `fn_workflow_select` task tools give agents the same list/select capability as the dashboard picker. Built-in workflows are now read-only in the editor (palette/inspector disabled, with a "Duplicate to edit" action), and a node's "Auto-approve requests" toggle now actually bypasses the CLI first-run approval pause.
Agents reach workflows too: the `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_select`, `fn_workflow_create`, `fn_workflow_update`, and `fn_workflow_delete` tools (plus `fn_trait_list` for the column vocabulary) give agents the same author/list/select capability as the dashboard. These are exposed not only to the task executor but also to the chat and planning agents, so you can author and edit workflows directly in a chat or planning conversation; a guard test locks all six tool names to each lane to prevent silent exposure drift. Built-in workflows are now read-only in the editor (palette/inspector disabled, with a "Duplicate to edit" action), and a node's "Auto-approve requests" toggle now actually bypasses the CLI first-run approval pause.
Also fixes a latent persistence bug where `pausedReason` was written to the in-memory task and read by queries but never stored by the task upsert or mapped back on read — so it was lost on every reload. This silently broke any pause/resume that depends on the reason (workflow CLI-approval and await-input nodes, token-budget pauses, worktrunk failures). The approve-CLI endpoint now derives the approved command solely from the task's pausedReason (ignoring any caller-supplied command), await-input nodes only resume when this node actually paused the task (not on a pre-existing steering comment), and write-capable custom nodes are refused until a task worktree exists so they never mutate the shared repo root.

View File

@@ -15,14 +15,14 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
| `fn_workflow_list` | executor, chat, planning | List the project's custom workflows (read-only built-ins plus user definitions) | none |
| `fn_workflow_get` | executor, chat, planning | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
| `fn_workflow_select` | executor, chat, planning | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
| `fn_workflow_create` | executor, chat, planning | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
| `fn_workflow_update` | executor, chat, planning | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
| `fn_workflow_delete` | executor, chat, planning | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) |
| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none |
| `fn_trait_list` | executor, chat, planning | List the registered column trait catalog (built-in and plugin traits) | none |
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |

View File

@@ -382,6 +382,7 @@ describe("WorkflowNodeEditor — U5 auto-layout", () => {
function stepwiseDef(): WorkflowDefinition {
return {
id: "WF-STEP",
kind: "workflow",
name: "Stepwise",
description: "",
ir: {

View File

@@ -97,6 +97,22 @@ function createChatManagerWithoutAgentStore(): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test");
}
// Minimal stand-in TaskStore. The workflow tool factories only capture the
// store reference at build time, so name-membership assertions never touch it.
const mockTaskStore = {} as any;
function createChatManagerWithTaskStore(): ChatManager {
return new ChatManager(
mockChatStore as any,
"/tmp/test",
mockAgentStore as any,
undefined,
undefined,
undefined,
mockTaskStore,
);
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("ChatManager.sendMessage", () => {
@@ -361,6 +377,37 @@ 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 () => {
let capturedTools: Array<{ name: string }> = [];
__setCreateFnAgent(async (options: any) => {
capturedTools = options.customTools ?? [];
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "ok" }] },
},
};
});
const chatManager = createChatManagerWithTaskStore();
await chatManager.sendMessage("chat-001", "Author me a workflow");
const names = capturedTools.map((t) => t.name);
for (const required of [
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_select",
]) {
expect(names).toContain(required);
}
});
it("persists and clears durable in-flight generation snapshots during streaming", async () => {
let onTextCb: ((delta: string) => void) | undefined;

View File

@@ -388,6 +388,28 @@ describe("planning module", () => {
expect(customToolNames).toContain("fn_task_get");
});
// U11 / R12 drift guard: the planning lane must expose all six
// fn_workflow_* tools so planning agents can author workflows.
it("exposes all six fn_workflow_* tools to the planning agent", async () => {
const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES));
__setCreateFnAgent(createFnAgentSpy as any);
await createSession(getUniqueIp(), initialPlan, MOCK_TASK_STORE, TEST_ROOT_DIR);
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as { customTools?: Array<{ name: string }> };
const customToolNames = callArg.customTools?.map((tool) => tool.name) ?? [];
for (const required of [
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_select",
]) {
expect(customToolNames).toContain(required);
}
});
it("cleans up session on agent failure", async () => {
__setCreateFnAgent(async () => {
throw new Error("Agent creation failed");

View File

@@ -76,6 +76,7 @@ export async function createProjectScopedChatManager(options: {
options.pluginRunner,
() => options.store.getSettings(),
options.messageStore,
options.store,
);
}
@@ -100,6 +101,8 @@ export function getOrCreateScopedChatManager(
agentStore,
pluginRunner,
() => store.getSettings(),
undefined,
store,
);
scopedChatManagerCache.set(key, manager);
return manager;

View File

@@ -24,6 +24,7 @@ import type {
ChatSessionCreateInput,
MessageStore,
Settings,
TaskStore,
} from "@fusion/core";
import { summarizeTitle } from "@fusion/core";
import { EventEmitter } from "node:events";
@@ -40,6 +41,7 @@ import {
extractRuntimeModel,
createSendMessageTool,
createReadMessagesTool,
createWorkflowAuthoringTools,
} from "@fusion/engine";
import * as engineModule from "@fusion/engine";
@@ -733,6 +735,10 @@ export class ChatManager {
| "chatRoomSummaryMaxChars"
> | undefined,
private messageStore?: MessageStore,
// Scoped task store for the chat's project — enables workflow-authoring
// tools (fn_workflow_*). Optional so existing test/construction sites that
// don't author workflows keep working.
private taskStore?: TaskStore,
) {}
private queueInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void {
@@ -1604,13 +1610,22 @@ export class ChatManager {
createSendMessageTool(this.messageStore, agent.id),
createReadMessagesTool(this.messageStore, agent.id),
]
: undefined;
: [];
// Expose workflow-authoring tools (fn_workflow_*) when a scoped task store
// is available. The chat lane has no ambient task, so fn_workflow_select
// has no default target — an agent must pass an explicit task_id.
const workflowTools = this.taskStore
? createWorkflowAuthoringTools(this.taskStore, "")
: [];
const customTools = [...messagingTools, ...workflowTools];
const sessionOptions = {
cwd: this.rootDir,
systemPrompt,
tools: "coding" as const,
...(messagingTools ? { customTools: messagingTools } : {}),
...(customTools.length > 0 ? { customTools } : {}),
sessionManager,
...(effectiveModelProvider && effectiveModelId
? {

View File

@@ -31,10 +31,17 @@ import {
resetDiagnosticsSink,
nonfatal,
} from "./ai-session-diagnostics.js";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import {
createFnAgent as engineCreateFnAgent,
createWorkflowAuthoringTools,
} from "@fusion/engine";
import * as engineModule from "@fusion/engine";
import { createPlanningBoardTools } from "./planning-board-tools.js";
// The planning lane has no ambient task; fn_workflow_select therefore has no
// default target and an agent must pass an explicit task_id.
const PLANNING_NO_AMBIENT_TASK_ID = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any;
@@ -839,7 +846,10 @@ export async function createSession(
systemPrompt,
tools: "readonly",
builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS],
customTools: [...createPlanningBoardTools(store)],
customTools: [
...createPlanningBoardTools(store),
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID),
],
onThinking: () => {
// Non-streaming path ignores thinking output
},
@@ -1380,7 +1390,10 @@ async function createPlanningAgent(
systemPrompt,
tools: "readonly",
builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS],
customTools: [...createPlanningBoardTools(store)],
customTools: [
...createPlanningBoardTools(store),
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID),
],
...(modelProvider && modelId
? {
defaultProvider: modelProvider,

View File

@@ -1107,6 +1107,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
options?.pluginRunner,
() => store.getSettings(),
options?.engine?.getMessageStore(),
store,
);
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import {
createWorkflowAuthoringTools,
createWorkflowListTool,
createWorkflowGetTool,
createWorkflowSelectTool,
createWorkflowCreateTool,
createWorkflowUpdateTool,
createWorkflowDeleteTool,
} from "../index.js";
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 six
* `fn_workflow_*` tools. The lanes assemble 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",
] as const;
// Minimal stand-in; the factories only capture the store reference at build
// time, so no methods are exercised by name-membership assertions.
const fakeStore = {} as unknown as TaskStore;
describe("workflow tool exposure (engine factories)", () => {
it("createWorkflowAuthoringTools exposes all six fn_workflow_* tools plus fn_trait_list", () => {
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");
});
it("each fn_workflow_* factory produces a tool with the expected name", () => {
expect(createWorkflowListTool(fakeStore).name).toBe("fn_workflow_list");
expect(createWorkflowGetTool(fakeStore).name).toBe("fn_workflow_get");
expect(createWorkflowSelectTool(fakeStore, "FN-1").name).toBe("fn_workflow_select");
expect(createWorkflowCreateTool(fakeStore).name).toBe("fn_workflow_create");
expect(createWorkflowUpdateTool(fakeStore).name).toBe("fn_workflow_update");
expect(createWorkflowDeleteTool(fakeStore).name).toBe("fn_workflow_delete");
});
});

View File

@@ -1400,6 +1400,31 @@ 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).
*
* 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).
*/
export function createWorkflowAuthoringTools(
store: TaskStore,
currentTaskId: string,
): ToolDefinition[] {
return [
createWorkflowListTool(store),
createWorkflowGetTool(store),
createWorkflowSelectTool(store, currentTaskId),
createWorkflowCreateTool(store),
createWorkflowUpdateTool(store),
createWorkflowDeleteTool(store),
createTraitListTool(),
];
}
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
return {
name: "fn_memory_search",

View File

@@ -10,6 +10,11 @@ export {
createWorkflowListTool,
createWorkflowGetTool,
createWorkflowSelectTool,
createWorkflowCreateTool,
createWorkflowUpdateTool,
createWorkflowDeleteTool,
createTraitListTool,
createWorkflowAuthoringTools,
taskCreateParams,
taskDocumentReadParams,
taskDocumentWriteParams,