feat(engine,dashboard): expose workflow authoring tools to chat and planning agents
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -382,6 +382,7 @@ describe("WorkflowNodeEditor — U5 auto-layout", () => {
|
||||
function stepwiseDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-STEP",
|
||||
kind: "workflow",
|
||||
name: "Stepwise",
|
||||
description: "",
|
||||
ir: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,11 @@ export {
|
||||
createWorkflowListTool,
|
||||
createWorkflowGetTool,
|
||||
createWorkflowSelectTool,
|
||||
createWorkflowCreateTool,
|
||||
createWorkflowUpdateTool,
|
||||
createWorkflowDeleteTool,
|
||||
createTraitListTool,
|
||||
createWorkflowAuthoringTools,
|
||||
taskCreateParams,
|
||||
taskDocumentReadParams,
|
||||
taskDocumentWriteParams,
|
||||
|
||||
Reference in New Issue
Block a user