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

@@ -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,