feat(engine): agent-native workflow tools — promote, reconcile-aware select, CRUD, trait catalog (#1408)
This commit is contained in:
@@ -17,6 +17,11 @@ import {
|
||||
createResearchTools,
|
||||
createWorkflowListTool,
|
||||
createWorkflowSelectTool,
|
||||
createTaskPromoteTool,
|
||||
createWorkflowCreateTool,
|
||||
createWorkflowUpdateTool,
|
||||
createWorkflowDeleteTool,
|
||||
createTraitListTool,
|
||||
qmdAgentMemoryCollectionName,
|
||||
readAgentMemoryWorkspaceLongTerm,
|
||||
sendMessageParams,
|
||||
@@ -26,6 +31,12 @@ import * as core from "@fusion/core";
|
||||
import { ChatStore, Database } from "@fusion/core";
|
||||
import type { MessageStore, Message } from "@fusion/core";
|
||||
import { getEnabledPluginTools, getResearchToolSurfaceStatus } from "../tool-availability.js";
|
||||
import { promoteHeldTask } from "../hold-release.js";
|
||||
|
||||
vi.mock("../hold-release.js", () => ({
|
||||
promoteHeldTask: vi.fn(),
|
||||
}));
|
||||
const mockPromoteHeldTask = vi.mocked(promoteHeldTask);
|
||||
|
||||
const loggerSpies = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
@@ -384,24 +395,40 @@ describe("createWorkflowListTool", () => {
|
||||
|
||||
describe("createWorkflowSelectTool", () => {
|
||||
it("selects for the current task by default and reports enabled step count", async () => {
|
||||
const store = { selectTaskWorkflow: vi.fn().mockResolvedValue(["workflow:WF-003:lint"]) };
|
||||
const store = {
|
||||
selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ enabledWorkflowSteps: ["workflow:WF-003:lint"] }),
|
||||
};
|
||||
const tool = createWorkflowSelectTool(store as any, "FN-200");
|
||||
const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any);
|
||||
expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-200", "WF-003");
|
||||
expect(store.selectTaskWorkflowAndReconcile).toHaveBeenCalledWith("FN-200", "WF-003");
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("Selected workflow WF-003 for FN-200 (1 step enabled)");
|
||||
expect(result.details).toMatchObject({ taskId: "FN-200", workflowId: "WF-003" });
|
||||
});
|
||||
|
||||
it("honors an explicit task_id override", async () => {
|
||||
const store = { selectTaskWorkflow: vi.fn().mockResolvedValue([]) };
|
||||
const store = { selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ enabledWorkflowSteps: [] }) };
|
||||
const tool = createWorkflowSelectTool(store as any, "FN-200");
|
||||
await tool.execute("call-1", { workflow_id: "builtin:coding", task_id: "FN-999" } as any, undefined, undefined, {} as any);
|
||||
expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-999", "builtin:coding");
|
||||
expect(store.selectTaskWorkflowAndReconcile).toHaveBeenCalledWith("FN-999", "builtin:coding");
|
||||
});
|
||||
|
||||
it("surfaces the reconciliation re-home outcome", async () => {
|
||||
const store = {
|
||||
selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({
|
||||
enabledWorkflowSteps: [],
|
||||
reconciliation: { preserved: false, fromColumn: "review", toColumn: "intake" },
|
||||
}),
|
||||
};
|
||||
const tool = createWorkflowSelectTool(store as any, "FN-200");
|
||||
const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("Re-homed from 'review' to 'intake'");
|
||||
expect(result.details).toMatchObject({ reconciliation: { preserved: false, fromColumn: "review", toColumn: "intake" } });
|
||||
});
|
||||
|
||||
it("returns an error result when selection fails", async () => {
|
||||
const store = { selectTaskWorkflow: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) };
|
||||
const store = { selectTaskWorkflowAndReconcile: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) };
|
||||
const tool = createWorkflowSelectTool(store as any, "FN-200");
|
||||
const result = await tool.execute("call-1", { workflow_id: "WF-404" } as any, undefined, undefined, {} as any);
|
||||
expect((result as { isError?: boolean }).isError).toBe(true);
|
||||
@@ -410,6 +437,124 @@ describe("createWorkflowSelectTool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTaskPromoteTool", () => {
|
||||
beforeEach(() => mockPromoteHeldTask.mockReset());
|
||||
|
||||
it("promotes the current task by default and reports the destination column", async () => {
|
||||
const store = {} as any;
|
||||
mockPromoteHeldTask.mockResolvedValue({ released: true, toColumn: "ready" });
|
||||
const tool = createTaskPromoteTool(store, "FN-200");
|
||||
const result = await tool.execute("c", {} as any, undefined, undefined, {} as any);
|
||||
expect(mockPromoteHeldTask).toHaveBeenCalledWith(store, "FN-200");
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("Promoted FN-200 to column 'ready'");
|
||||
expect(result.details).toMatchObject({ taskId: "FN-200", released: true, toColumn: "ready" });
|
||||
});
|
||||
|
||||
it("honors an explicit task_id and surfaces a rejection as an error", async () => {
|
||||
const store = {} as any;
|
||||
mockPromoteHeldTask.mockResolvedValue({ released: false, rejection: "not-held" });
|
||||
const tool = createTaskPromoteTool(store, "FN-200");
|
||||
const result = await tool.execute("c", { task_id: "FN-999" } as any, undefined, undefined, {} as any);
|
||||
expect(mockPromoteHeldTask).toHaveBeenCalledWith(store, "FN-999");
|
||||
expect((result as { isError?: boolean }).isError).toBe(true);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toMatch(/not-held/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkflowCreateTool", () => {
|
||||
it("creates a workflow and returns the new id", async () => {
|
||||
const store = { createWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA" }) };
|
||||
const tool = createWorkflowCreateTool(store as any);
|
||||
const result = await tool.execute("c", { name: "QA", ir: { columns: [] } } as any, undefined, undefined, {} as any);
|
||||
expect(store.createWorkflowDefinition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "QA", ir: { columns: [] } }),
|
||||
);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("Created workflow WF-010 (QA)");
|
||||
});
|
||||
|
||||
it("returns an error result when creation fails", async () => {
|
||||
const store = { createWorkflowDefinition: vi.fn().mockRejectedValue(new Error("Workflow name is required")) };
|
||||
const tool = createWorkflowCreateTool(store as any);
|
||||
const result = await tool.execute("c", { name: "", ir: {} } as any, undefined, undefined, {} as any);
|
||||
expect((result as { isError?: boolean }).isError).toBe(true);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toMatch(/name is required/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkflowUpdateTool", () => {
|
||||
it("updates a workflow and reports the name", async () => {
|
||||
const store = { updateWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA v2" }) };
|
||||
const tool = createWorkflowUpdateTool(store as any);
|
||||
const result = await tool.execute("c", { workflow_id: "WF-010", name: "QA v2" } as any, undefined, undefined, {} as any);
|
||||
expect(store.updateWorkflowDefinition).toHaveBeenCalledWith("WF-010", expect.objectContaining({ name: "QA v2" }));
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("Updated workflow WF-010 (QA v2)");
|
||||
});
|
||||
|
||||
it("forwards rehome_to as rehomeTo", async () => {
|
||||
const store = { updateWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA" }) };
|
||||
const tool = createWorkflowUpdateTool(store as any);
|
||||
await tool.execute("c", { workflow_id: "WF-010", ir: { columns: [] }, rehome_to: "intake" } as any, undefined, undefined, {} as any);
|
||||
expect(store.updateWorkflowDefinition).toHaveBeenCalledWith("WF-010", expect.objectContaining({ rehomeTo: "intake" }));
|
||||
});
|
||||
|
||||
it("surfaces an OccupiedColumnsError as a structured retryable response", async () => {
|
||||
const err = new core.OccupiedColumnsError("WF-010", [{ columnId: "review", count: 2 }]);
|
||||
const store = { updateWorkflowDefinition: vi.fn().mockRejectedValue(err) };
|
||||
const tool = createWorkflowUpdateTool(store as any);
|
||||
const result = await tool.execute("c", { workflow_id: "WF-010", ir: { columns: [] } } as any, undefined, undefined, {} as any);
|
||||
expect((result as { isError?: boolean }).isError).toBe(true);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("review (2)");
|
||||
expect(text).toMatch(/rehome_to/);
|
||||
expect(result.details).toMatchObject({
|
||||
occupiedColumns: [{ columnId: "review", count: 2 }],
|
||||
workflowId: "WF-010",
|
||||
retryWith: "rehome_to",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkflowDeleteTool", () => {
|
||||
it("deletes a workflow", async () => {
|
||||
const store = { deleteWorkflowDefinition: vi.fn().mockResolvedValue(undefined) };
|
||||
const tool = createWorkflowDeleteTool(store as any);
|
||||
const result = await tool.execute("c", { workflow_id: "WF-010" } as any, undefined, undefined, {} as any);
|
||||
expect(store.deleteWorkflowDefinition).toHaveBeenCalledWith("WF-010");
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toContain("Deleted workflow WF-010");
|
||||
});
|
||||
|
||||
it("surfaces a built-in protection error", async () => {
|
||||
const store = {
|
||||
deleteWorkflowDefinition: vi.fn().mockRejectedValue(new Error("Built-in workflows cannot be deleted")),
|
||||
};
|
||||
const tool = createWorkflowDeleteTool(store as any);
|
||||
const result = await tool.execute("c", { workflow_id: "builtin:coding" } as any, undefined, undefined, {} as any);
|
||||
expect((result as { isError?: boolean }).isError).toBe(true);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toMatch(/cannot be deleted/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTraitListTool", () => {
|
||||
it("lists the trait catalog with ids, names, and flags in details", async () => {
|
||||
const tool = createTraitListTool();
|
||||
const result = await tool.execute("c", {} as any, undefined, undefined, {} as any);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).toMatch(/Available traits:/);
|
||||
const traits = (result.details as { traits?: Array<{ id: string; name: string; flags: unknown }> }).traits ?? [];
|
||||
expect(traits.length).toBeGreaterThan(0);
|
||||
expect(traits[0]).toHaveProperty("id");
|
||||
expect(traits[0]).toHaveProperty("name");
|
||||
expect(traits[0]).toHaveProperty("flags");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTaskLogToolWithContext", () => {
|
||||
it("returns a graceful archived read-only message instead of throwing", async () => {
|
||||
const store = {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core";
|
||||
import { listTraits } from "@fusion/core";
|
||||
import { promoteHeldTask } from "./hold-release.js";
|
||||
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||
import { ResearchProviderRegistry } from "./research/provider-registry.js";
|
||||
@@ -74,6 +76,47 @@ export const workflowSelectParams = Type.Object({
|
||||
),
|
||||
});
|
||||
|
||||
export const taskPromoteParams = Type.Object({
|
||||
task_id: Type.Optional(
|
||||
Type.String({ description: "Held task to promote. Defaults to the current task." }),
|
||||
),
|
||||
});
|
||||
|
||||
export const workflowCreateParams = Type.Object({
|
||||
name: Type.String({ description: "Workflow name (required, non-empty)." }),
|
||||
description: Type.Optional(Type.String({ description: "Optional human-readable description." })),
|
||||
ir: Type.Unknown({
|
||||
description:
|
||||
"Workflow graph (intermediate representation). Validated server-side; a malformed graph is rejected.",
|
||||
}),
|
||||
layout: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description: "Optional node layout map keyed by node id.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const workflowUpdateParams = Type.Object({
|
||||
workflow_id: Type.String({ description: "The workflow definition ID to update (built-ins cannot be edited)." }),
|
||||
name: Type.Optional(Type.String({ description: "New name." })),
|
||||
description: Type.Optional(Type.String({ description: "New description." })),
|
||||
ir: Type.Optional(Type.Unknown({ description: "Replacement workflow graph (validated server-side)." })),
|
||||
layout: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Replacement node layout map." })),
|
||||
rehome_to: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"When an IR update removes a column that still holds cards, supply the column id to re-home those occupants into. " +
|
||||
"Required to resolve an OccupiedColumns conflict; the target must exist in the new IR.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const workflowDeleteParams = Type.Object({
|
||||
workflow_id: Type.String({ description: "The workflow definition ID to delete (built-ins cannot be deleted)." }),
|
||||
});
|
||||
|
||||
export const traitListParams = Type.Object({});
|
||||
|
||||
export const reflectOnPerformanceParams = Type.Object({
|
||||
focus_area: Type.Optional(
|
||||
Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }),
|
||||
@@ -1006,13 +1049,23 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string
|
||||
execute: async (_id: string, params: Static<typeof workflowSelectParams>) => {
|
||||
const taskId = params.task_id?.trim() || currentTaskId;
|
||||
try {
|
||||
const enabled = await store.selectTaskWorkflow(taskId, params.workflow_id);
|
||||
const { enabledWorkflowSteps: enabled, reconciliation } =
|
||||
await store.selectTaskWorkflowAndReconcile(taskId, params.workflow_id);
|
||||
const stepSummary = `${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled`;
|
||||
// Surface the reconciliation outcome so the agent observes any re-home:
|
||||
// a preserved card stays put; an unpreserved card moves fromColumn→toColumn.
|
||||
const rehomeNote =
|
||||
reconciliation && !reconciliation.preserved && reconciliation.fromColumn !== reconciliation.toColumn
|
||||
? ` Re-homed from '${reconciliation.fromColumn}' to '${reconciliation.toColumn}'.`
|
||||
: reconciliation
|
||||
? ` Card preserved in '${reconciliation.toColumn}'.`
|
||||
: "";
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Selected workflow ${params.workflow_id} for ${taskId} (${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled).`,
|
||||
text: `Selected workflow ${params.workflow_id} for ${taskId} (${stepSummary}).${rehomeNote}`,
|
||||
}],
|
||||
details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled },
|
||||
details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled, reconciliation },
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
@@ -1026,6 +1079,239 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_task_promote` tool that manually releases a held task out of its
|
||||
* hold column — the agent-native equivalent of the dashboard's "promote" action.
|
||||
* Defaults to the current task. Wraps {@link promoteHeldTask}.
|
||||
*/
|
||||
export function createTaskPromoteTool(store: TaskStore, currentTaskId: string): ToolDefinition {
|
||||
return {
|
||||
name: "fn_task_promote",
|
||||
label: "Promote Held Task",
|
||||
description:
|
||||
"Manually promote a held task out of its hold column, releasing it regardless of the " +
|
||||
"hold's release kind (the explicit operator action a 'manual' hold waits for). Defaults " +
|
||||
"to the current task. Returns the destination column, or a rejection reason when the task " +
|
||||
"is not held or the destination is full.",
|
||||
parameters: taskPromoteParams,
|
||||
execute: async (_id: string, params: Static<typeof taskPromoteParams>) => {
|
||||
const taskId = params.task_id?.trim() || currentTaskId;
|
||||
try {
|
||||
const outcome = await promoteHeldTask(store, taskId);
|
||||
if (outcome.released) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Promoted ${taskId} to column '${outcome.toColumn}'.`,
|
||||
}],
|
||||
details: { taskId, released: true, toColumn: outcome.toColumn },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `ERROR: Could not promote ${taskId}: ${outcome.rejection ?? "unknown"}.`,
|
||||
}],
|
||||
details: { taskId, released: false, rejection: outcome.rejection },
|
||||
isError: true,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Failed to promote task: ${err?.message ?? err}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_workflow_create` tool — a thin wrapper over the store's workflow
|
||||
* definition create. The IR is validated server-side; a malformed graph rejects.
|
||||
*/
|
||||
export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
|
||||
return {
|
||||
name: "fn_workflow_create",
|
||||
label: "Create Workflow",
|
||||
description:
|
||||
"Create a new custom workflow definition from a name and a workflow graph (IR). " +
|
||||
"The IR is validated server-side. Returns the new workflow ID.",
|
||||
parameters: workflowCreateParams,
|
||||
execute: async (_id: string, params: Static<typeof workflowCreateParams>) => {
|
||||
try {
|
||||
const created = await store.createWorkflowDefinition({
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ir: params.ir as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
layout: params.layout as any,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Created workflow ${created.id} (${created.name}).` }],
|
||||
details: { workflowId: created.id, name: created.name },
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Failed to create workflow: ${err?.message ?? err}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_workflow_update` tool — a thin wrapper over the store's workflow
|
||||
* definition update. When an IR change removes a still-occupied column, the store
|
||||
* throws an OccupiedColumnsError; we surface it as a structured response carrying
|
||||
* the per-column occupant counts so the agent can retry with `rehome_to`.
|
||||
*/
|
||||
export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
|
||||
return {
|
||||
name: "fn_workflow_update",
|
||||
label: "Update Workflow",
|
||||
description:
|
||||
"Update a custom workflow definition (name/description/ir/layout). Built-ins cannot be edited. " +
|
||||
"If an IR change removes a column that still holds cards, the update is blocked and returns the " +
|
||||
"occupied columns — retry with rehome_to set to a column id that survives in the new IR.",
|
||||
parameters: workflowUpdateParams,
|
||||
execute: async (_id: string, params: Static<typeof workflowUpdateParams>) => {
|
||||
try {
|
||||
const updated = await store.updateWorkflowDefinition(params.workflow_id, {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ir: params.ir as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
layout: params.layout as any,
|
||||
rehomeTo: params.rehome_to,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Updated workflow ${updated.id} (${updated.name}).` }],
|
||||
details: { workflowId: updated.id, name: updated.name },
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
// Surface the typed OccupiedColumnsError as a structured, retryable result.
|
||||
if (err?.name === "OccupiedColumnsError") {
|
||||
const occupancies = err.occupancies ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", ");
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text:
|
||||
`ERROR: Update removes occupied column(s): ${summary}. ` +
|
||||
`Retry with rehome_to set to a surviving column id.`,
|
||||
}],
|
||||
details: { occupiedColumns: occupancies, workflowId: err.workflowId, retryWith: "rehome_to" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Failed to update workflow: ${err?.message ?? err}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_workflow_delete` tool — a thin wrapper over the store's workflow
|
||||
* definition delete. Surfaces built-in protection and not-found errors as
|
||||
* structured responses. (The store auto-re-homes occupants to the default
|
||||
* workflow on delete, so no rehome target is required here.)
|
||||
*/
|
||||
export function createWorkflowDeleteTool(store: TaskStore): ToolDefinition {
|
||||
return {
|
||||
name: "fn_workflow_delete",
|
||||
label: "Delete Workflow",
|
||||
description:
|
||||
"Delete a custom workflow definition. Built-ins cannot be deleted. Any tasks using it have " +
|
||||
"their selection cleared and are re-homed to the default workflow's entry column.",
|
||||
parameters: workflowDeleteParams,
|
||||
execute: async (_id: string, params: Static<typeof workflowDeleteParams>) => {
|
||||
try {
|
||||
await store.deleteWorkflowDefinition(params.workflow_id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Deleted workflow ${params.workflow_id}.` }],
|
||||
details: { workflowId: params.workflow_id },
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (err?.name === "OccupiedColumnsError") {
|
||||
const occupancies = err.occupancies ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", ");
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `ERROR: Delete blocked by occupied column(s): ${summary}.`,
|
||||
}],
|
||||
details: { occupiedColumns: occupancies, workflowId: err.workflowId },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Failed to delete workflow: ${err?.message ?? err}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_trait_list` tool that returns the trait catalog from
|
||||
* {@link listTraits} — the column-behavior building blocks (id, name, flags)
|
||||
* used when authoring workflow columns.
|
||||
*/
|
||||
export function createTraitListTool(): ToolDefinition {
|
||||
return {
|
||||
name: "fn_trait_list",
|
||||
label: "List Traits",
|
||||
description:
|
||||
"List the available column traits (the behavior building blocks for workflow columns): " +
|
||||
"id, name, description, and behavior flags. Use when authoring or updating a workflow IR.",
|
||||
parameters: traitListParams,
|
||||
execute: async () => {
|
||||
try {
|
||||
const traits = listTraits();
|
||||
if (traits.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No traits are registered." }],
|
||||
details: { traits: [] },
|
||||
};
|
||||
}
|
||||
const lines = traits.map(
|
||||
(t) => `- ${t.id}: ${t.name}${t.description ? ` — ${t.description}` : ""}`,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Available traits:\n${lines.join("\n")}` }],
|
||||
details: {
|
||||
traits: traits.map((t) => ({ id: t.id, name: t.name, description: t.description, flags: t.flags })),
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Failed to list traits: ${err?.message ?? err}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
|
||||
return {
|
||||
name: "fn_memory_search",
|
||||
|
||||
@@ -145,6 +145,11 @@ import {
|
||||
createTaskLogTool as sharedCreateTaskLogTool,
|
||||
createWorkflowListTool as sharedCreateWorkflowListTool,
|
||||
createWorkflowSelectTool as sharedCreateWorkflowSelectTool,
|
||||
createTaskPromoteTool as sharedCreateTaskPromoteTool,
|
||||
createWorkflowCreateTool as sharedCreateWorkflowCreateTool,
|
||||
createWorkflowUpdateTool as sharedCreateWorkflowUpdateTool,
|
||||
createWorkflowDeleteTool as sharedCreateWorkflowDeleteTool,
|
||||
createTraitListTool as sharedCreateTraitListTool,
|
||||
} from "./agent-tools.js";
|
||||
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
|
||||
@@ -4790,6 +4795,11 @@ export class TaskExecutor {
|
||||
this.createTaskDocumentReadTool(task.id),
|
||||
this.createWorkflowListTool(),
|
||||
this.createWorkflowSelectTool(task.id),
|
||||
this.createTaskPromoteTool(task.id),
|
||||
this.createWorkflowCreateTool(),
|
||||
this.createWorkflowUpdateTool(),
|
||||
this.createWorkflowDeleteTool(),
|
||||
this.createTraitListTool(),
|
||||
...(isResearchToolSurfaceEnabled(settings)
|
||||
? createResearchTools({
|
||||
store: this.store,
|
||||
@@ -6549,6 +6559,26 @@ export class TaskExecutor {
|
||||
return sharedCreateWorkflowSelectTool(this.store, taskId);
|
||||
}
|
||||
|
||||
private createTaskPromoteTool(taskId: string): ToolDefinition {
|
||||
return sharedCreateTaskPromoteTool(this.store, taskId);
|
||||
}
|
||||
|
||||
private createWorkflowCreateTool(): ToolDefinition {
|
||||
return sharedCreateWorkflowCreateTool(this.store);
|
||||
}
|
||||
|
||||
private createWorkflowUpdateTool(): ToolDefinition {
|
||||
return sharedCreateWorkflowUpdateTool(this.store);
|
||||
}
|
||||
|
||||
private createWorkflowDeleteTool(): ToolDefinition {
|
||||
return sharedCreateWorkflowDeleteTool(this.store);
|
||||
}
|
||||
|
||||
private createTraitListTool(): ToolDefinition {
|
||||
return sharedCreateTraitListTool();
|
||||
}
|
||||
|
||||
private createTaskAddDepTool(taskId: string): ToolDefinition {
|
||||
const store = this.store;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user