* The `openbot` tools that let an agent build, change, move and run workflows, as the canvas does.
*
* Each text here goes into the agent's context, so the tools stay small. A read leaves out positions,
* sizes, strokes and view-state fields, and an edit is a list of operations, not a full graph. The
* host places a new node when the agent gives no position. A request that the model can correct, such
* as an unknown node or a branch edge from a node that is not a condition, is a tool failure that it
* reads; other errors are faults.
*/
import { INPUT_LIMITS } from "@openbot/contracts/input-limits";
import {
isRoutineSchedule,
isWorkflowGraph,
WORKFLOW_BRANCHES,
WORKFLOW_MAX_COORDINATE,
WORKFLOW_NODE_KINDS,
WORKFLOW_PORT_TYPES,
WORKFLOW_PORTS,
type WorkflowEdge,
type WorkflowGraph,
type WorkflowNode,
type WorkflowNodeKind,
type WorkflowPoint,
type WorkflowRunDetail,
workflowGraphProblemAt,
} from "@openbot/contracts/ipc";
import { RoutineInputError } from "@openbot/team-client/routine-schedule";
import { Effect } from "effect";
import { z } from "zod";
import { openBotToolFailure, openBotToolResult } from "../agent/routine-tools";
import { ToolOperationFailed, toolStep } from "../agent/tool-operation";
import { runtimeFields } from "./workflow-graph";
import { WorkflowStoreError } from "./workflow-store";
import type { WorkflowsHandle } from "./workflows";
export type WorkflowTools = Pick<
WorkflowsHandle,
"list" | "get" | "create" | "rename" | "remove" | "saveGraph" | "startRun" | "runs" | "run"
>;
const identifier = z.string().min(1).max(INPUT_LIMITS.identifier);
const point = z.object({ x: z.number(), y: z.number() }).strict();
const fieldValue = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const schedule = z.object({ kind: z.string() }).catchall(z.unknown());
const AGENT_NODE_KINDS = WORKFLOW_NODE_KINDS.filter((kind) => kind !== "asset" && kind !== "image");
const OPS = ["add", "update", "move", "remove", "connect", "disconnect"] as const;
const opSchema = z
.object({
op: z.enum(OPS),
ref: z.string().optional().describe("add: name for later ops"),
node: z.string().optional().describe("id or ref"),
kind: z.enum(AGENT_NODE_KINDS).optional(),
fields: z.object({}).catchall(fieldValue).optional().describe("merged; null removes"),
schedule: schedule.optional(),
at: point.optional(),
from: z.string().optional(),
to: z.string().optional(),
branch: z.enum(WORKFLOW_BRANCHES).optional(),
type: z.enum(WORKFLOW_PORT_TYPES).optional(),
})
.strict();
export type WorkflowOp = z.infer<typeof opSchema>;
const listSchema = z.object({}).strict();
const readSchema = z.object({ workflowId: identifier, layout: z.boolean().optional() }).strict();
const editSchema = z
.object({
workflowId: identifier.optional(),
title: z.string().trim().min(1).max(INPUT_LIMITS.workflowTitle).optional(),
ops: z.array(opSchema).max(INPUT_LIMITS.workflowNodes).optional(),
tidy: z.boolean().optional(),
})
.strict();
const deleteSchema = z.object({ workflowId: identifier }).strict();
const runSchema = z.object({ workflowId: identifier, fromNodeId: identifier.optional() }).strict();
const runsSchema = z
.object({ workflowId: identifier, runId: identifier.optional(), limit: z.number().int().min(1).max(5).optional() })
.strict();
export const WORKFLOW_TOOL_DEFINITIONS = [
{
name: "list_workflows",
description: "List the workflows of this computer: id, title, step count and last run.",
shape: listSchema.shape,
},
{
name: "read_workflow",
description:
"Read a workflow's nodes, edges and triggers. Fields are the ones a run uses. layout adds positions and sizes.",
shape: readSchema.shape,
},
{
name: "edit_workflow",
description:
"Create a workflow, a graph of steps that a trigger starts (no workflowId, title required), or change one: rename, and apply ops in order to add, update, move or remove nodes and connect or disconnect them. One agent's scheduled task is a routine.",
shape: editSchema.shape,
},
{
name: "delete_workflow",
description: "Delete a workflow, its runs and versions. Stops its open steps.",
shape: deleteSchema.shape,
},
{
name: "run_workflow",
description: "Start a run of a workflow, or from one node with the newest answers of the nodes before it.",
shape: runSchema.shape,
},
{
name: "read_workflow_runs",
description:
"Read the newest runs of a workflow, or one run, with the kind, label, status and output of each step.",
shape: runsSchema.shape,
},
] as const;
export const WORKFLOW_TOOL_NAMES: ReadonlySet<string> = new Set(WORKFLOW_TOOL_DEFINITIONS.map((tool) => tool.name));
* The tools that change a workflow or start a run. A run can send messages and post to outside
* services, so only a person's request starts these, as for channels and webhook routines.
*/
export const WORKFLOW_CHANGE_TOOL_NAMES: ReadonlySet<string> = new Set([
"edit_workflow",
"delete_workflow",
"run_workflow",
]);
const LABEL_FIELD: Partial<Record<WorkflowNodeKind, string>> = {
trigger: "trigger",
agent: "agentId",
llm: "prompt",
condition: "rule",
approval: "question",
output: "target",
prompt: "text",
image: "prompt",
};
const LABEL_CHARS = 40;
* The size of each card on the canvas: the widths of "Size" in
* `packages/ui/src/features/workflows/workflow-nodes.css` and the inline widths of the trigger and output
* nodes. A card's height follows its content, so the heights are the usual ones. A resized card has `size`.
*/
const CARD_SIZE: Record<WorkflowNodeKind, { width: number; height: number }> = {
trigger: { width: 320, height: 210 },
agent: { width: 350, height: 230 },
llm: { width: 350, height: 230 },
condition: { width: 300, height: 200 },
approval: { width: 300, height: 180 },
output: { width: 320, height: 170 },
prompt: { width: 296, height: 151 },
image: { width: 300, height: 300 },
asset: { width: 240, height: 240 },
note: { width: 240, height: 240 },
shape: { width: 208, height: 128 },
text: { width: 240, height: 48 },
};
const COLUMN_GAP = 120;
const ROW_GAP = 64;
const ANNOTATION_KINDS: ReadonlySet<WorkflowNodeKind> = new Set(["text", "note", "shape"]);
const OUTPUT_CHARS = 600;
class WorkflowEditRefused extends Error {}
function refuse(message: string): never {
throw new WorkflowEditRefused(message);
}
function compactGraph(graph: WorkflowGraph, layout: boolean) {
return {
nodes: graph.nodes.map((node) => ({
id: node.id,
kind: node.kind,
fields: runtimeFields(node),
...(node.schedule ? { schedule: node.schedule } : {}),
...(node.locked ? { locked: true } : {}),
...(layout ? { at: node.at, size: cardSize(node) } : {}),
})),
edges: graph.edges.map((edge) => ({
from: edge.from,
to: edge.to,
...(edge.type === "text" ? {} : { type: edge.type }),
...(edge.branch ? { branch: edge.branch } : {}),
})),
};
}
const clampCoordinate = (value: number) => Math.max(-WORKFLOW_MAX_COORDINATE, Math.min(WORKFLOW_MAX_COORDINATE, value));
function cardSize(node: WorkflowNode): { width: number; height: number } {
const usual = CARD_SIZE[node.kind];
return { width: node.size?.width ?? usual.width, height: node.size?.height ?? usual.height };
}
function crowds(node: WorkflowNode, at: WorkflowPoint, other: WorkflowNode): boolean {
const size = cardSize(node);
const near = cardSize(other);
return (
at.x < other.at.x + near.width + COLUMN_GAP &&
other.at.x < at.x + size.width + COLUMN_GAP &&
at.y < other.at.y + near.height + ROW_GAP &&
other.at.y < at.y + size.height + ROW_GAP
);
}
function freePlace(node: WorkflowNode, at: WorkflowPoint, others: readonly WorkflowNode[]): WorkflowPoint {
let y = at.y;
for (let block = others.find((other) => crowds(node, { x: at.x, y }, other)); block; ) {
y = block.at.y + cardSize(block).height + ROW_GAP;
block = others.find((other) => crowds(node, { x: at.x, y }, other));
}
return { x: clampCoordinate(at.x), y: clampCoordinate(y) };
}
* A free place for a new node: one column right of its upstream node, at the same top, or below all nodes.
* The "no" child of a condition starts one row lower than its "yes" child, so the two follow the ports.
*/
function placeNode(
nodes: readonly WorkflowNode[],
node: WorkflowNode,
upstream: WorkflowNode | undefined,
branch: WorkflowEdge["branch"] | undefined,
): WorkflowPoint {
const others = nodes.filter((other) => other.id !== node.id);
if (upstream) {
const x = upstream.at.x + cardSize(upstream).width + COLUMN_GAP;
const y = upstream.at.y + (branch === "no" ? cardSize(node).height + ROW_GAP : 0);
return freePlace(node, { x, y }, others);
}
if (others.length === 0) return { x: 0, y: 0 };
const x = Math.min(...others.map((other) => other.at.x));
const y = Math.max(...others.map((other) => other.at.y + cardSize(other).height)) + ROW_GAP;
return freePlace(node, { x, y }, others);
}
* Lays out the `moving` nodes again from left to right: a column for each step after its inputs, a
* condition's "yes" child above its "no" child, and each child at the top of its first input. An input
* with no input of its own, such as a prompt, goes in the column before its first step. The other nodes
* stay, and a moving card goes below one that it would crowd.
*/
function layoutNodes(
nodes: readonly WorkflowNode[],
edges: readonly WorkflowEdge[],
moving: ReadonlySet<string>,
): WorkflowNode[] {
const flow = nodes.filter((node) => moving.has(node.id));
if (flow.length === 0) return [...nodes];
const inner = edges.filter((edge) => moving.has(edge.from) && moving.has(edge.to) && edge.from !== edge.to);
const inputs = (id: string) => inner.filter((edge) => edge.to === id);
const layer = new Map<string, number>();
while (layer.size < flow.length) {
const open = flow.filter((node) => !layer.has(node.id));
const ready = open.filter((node) => inputs(node.id).every((edge) => layer.has(edge.from)));
for (const node of ready.length > 0 ? ready : open.slice(0, 1))
layer.set(node.id, Math.max(-1, ...inputs(node.id).map((edge) => layer.get(edge.from) ?? -1)) + 1);
}
for (const node of flow) {
if (node.kind === "trigger" || inputs(node.id).length > 0) continue;
const next = inner.filter((edge) => edge.from === node.id).map((edge) => layer.get(edge.to) ?? 0);
if (next.length > 0) layer.set(node.id, Math.max(0, Math.min(...next) - 1));
}
const columns = Math.max(...layer.values()) + 1;
const left = Math.min(...flow.map((node) => node.at.x));
const top = Math.min(...flow.map((node) => node.at.y));
const xs: number[] = [];
for (let column = 0, x = left; column < columns; column += 1) {
xs.push(x);
const widths = flow.filter((node) => layer.get(node.id) === column).map((node) => cardSize(node).width);
x += Math.max(0, ...widths) + COLUMN_GAP;
}
const order = new Map(nodes.map((node, index) => [node.id, index]));
const kept = nodes.filter((node) => !moving.has(node.id));
const done = new Map<string, WorkflowNode>();
for (let column = 0; column < columns; column += 1) {
const ranked = flow
.filter((node) => layer.get(node.id) === column)
.map((node) => {
const placed = inputs(node.id).flatMap((edge) => {
const from = done.get(edge.from);
return from ? [{ y: from.at.y, branch: edge.branch === "no" ? 1 : 0 }] : [];
});
const first = placed.sort((a, b) => a.y - b.y || a.branch - b.branch)[0];
return { node, y: first?.y ?? top, branch: first?.branch ?? 0 };
})
.sort((a, b) => a.y - b.y || a.branch - b.branch || (order.get(a.node.id) ?? 0) - (order.get(b.node.id) ?? 0));
let below = Number.NEGATIVE_INFINITY;
for (const { node, y } of ranked) {
const at = freePlace(node, { x: xs[column] ?? left, y: Math.max(y, below) }, [...kept, ...done.values()]);
done.set(node.id, { ...node, at });
below = at.y + cardSize(node).height + ROW_GAP;
}
}
return nodes.map((node) => done.get(node.id) ?? node);
}
function mergeFields(fields: WorkflowNode["fields"], patch: WorkflowOp["fields"]): WorkflowNode["fields"] {
const next = { ...fields };
for (const [name, value] of Object.entries(patch ?? {})) {
if (value === null || value === undefined) delete next[name];
else next[name] = value;
}
return next;
}
function checkedSchedule(value: WorkflowOp["schedule"]) {
if (value === undefined) return undefined;
if (!isRoutineSchedule(value)) refuse("The schedule is not a valid routine schedule.");
return value;
}
* A new schedule makes a manual trigger a schedule trigger, and a schedule trigger with no schedule
* gets the default one, so that the trigger runs on the schedule that the agent gave.
*/
function withSchedule(node: WorkflowNode, scheduled: ReturnType<typeof checkedSchedule>): WorkflowNode {
if (node.kind !== "trigger") return node;
const fields = scheduled && node.fields.trigger === "manual" ? { ...node.fields, trigger: "schedule" } : node.fields;
const schedule =
scheduled ?? node.schedule ?? (fields.trigger === "schedule" ? { kind: "daily", time: "09:00" } : undefined);
return { ...node, fields, ...(schedule ? { schedule } : {}) };
}
function newNodeFields(kind: WorkflowNodeKind): WorkflowNode["fields"] {
if (kind === "trigger") return { trigger: "manual" };
if (kind === "output") return { target: "notify" };
return {};
}
function edgeId(from: string, to: string, type: string, branch: string | undefined): string {
return `${from}${branch ? `.${branch}` : ""}>${to}:${type}`;
}
* Applies the operations to a copy of the graph. Returns the new graph and the node id of each ref.
* Throws `WorkflowEditRefused` with a message for the model when an operation cannot apply.
*/
function applyWorkflowOps(
graph: WorkflowGraph,
ops: readonly WorkflowOp[],
agentIds: ReadonlySet<string>,
tidy = false,
): { graph: WorkflowGraph; ids: Record<string, string> } {
let nodes = graph.nodes.map((node) => ({ ...node }));
let edges = [...graph.edges];
const ids: Record<string, string> = {};
const resolve = (ref: string | undefined, op: number) => {
if (ref === undefined) refuse(`Op ${op}: name the node.`);
const id = ids[ref] ?? ref;
const node = nodes.find((item) => item.id === id);
if (!node) refuse(`Op ${op}: no node ${ref}.`);
return node;
};
const checkAgent = (fields: WorkflowNode["fields"], op: number) => {
const agentId = fields.agentId;
if (typeof agentId === "string" && agentId.length > 0 && !agentIds.has(agentId))
refuse(`Op ${op}: unknown agent ${agentId}.`);
};
const replace = (next: WorkflowNode) => {
nodes = nodes.map((node) => (node.id === next.id ? next : node));
};
const placed: { id: string; withFrom: boolean }[] = [];
const pinned = new Set<string>();
ops.forEach((op, index) => {
const n = index + 1;
switch (op.op) {
case "add": {
if (!op.kind) refuse(`Op ${n}: add needs kind.`);
if (op.ref !== undefined && ids[op.ref] !== undefined) refuse(`Op ${n}: ref ${op.ref} is already used.`);
const id = `${op.kind}-${crypto.randomUUID()}`;
const fields = mergeFields(newNodeFields(op.kind), op.fields);
checkAgent(fields, n);
const scheduled = checkedSchedule(op.schedule);
if (scheduled && op.kind !== "trigger") refuse(`Op ${n}: only a trigger has a schedule.`);
const node = withSchedule({ id, kind: op.kind, at: { x: 0, y: 0 }, fields }, scheduled);
const upstream = op.from === undefined ? undefined : resolve(op.from, n);
node.at = op.at
? { x: clampCoordinate(op.at.x), y: clampCoordinate(op.at.y) }
: placeNode(nodes, node, upstream, op.branch);
nodes.push(node);
if (op.at) pinned.add(id);
else placed.push({ id, withFrom: upstream !== undefined });
if (op.ref !== undefined) ids[op.ref] = id;
if (upstream) edges.push(newEdge(upstream, node, op.type, op.branch, n));
return;
}
case "update": {
const node = resolve(op.node, n);
const fields = mergeFields(node.fields, op.fields);
checkAgent(fields, n);
const scheduled = checkedSchedule(op.schedule);
if (scheduled && node.kind !== "trigger") refuse(`Op ${n}: only a trigger has a schedule.`);
replace(withSchedule({ ...node, fields }, scheduled));
return;
}
case "move": {
const node = resolve(op.node, n);
if (node.locked) refuse(`Op ${n}: node ${node.id} is locked.`);
if (!op.at) refuse(`Op ${n}: move needs at.`);
replace({ ...node, at: { x: clampCoordinate(op.at.x), y: clampCoordinate(op.at.y) } });
pinned.add(node.id);
return;
}
case "remove": {
const node = resolve(op.node, n);
if (node.locked) refuse(`Op ${n}: node ${node.id} is locked.`);
nodes = nodes.filter((item) => item.id !== node.id);
edges = edges.filter((edge) => edge.from !== node.id && edge.to !== node.id);
return;
}
case "connect": {
const from = resolve(op.from, n);
const to = resolve(op.to, n);
const edge = newEdge(from, to, op.type, op.branch, n);
if (!edges.some((item) => item.id === edge.id)) edges.push(edge);
return;
}
case "disconnect": {
const before = edges.length;
const from = resolve(op.from, n).id;
const to = resolve(op.to, n).id;
edges = edges.filter((edge) => edge.from !== from || edge.to !== to);
if (edges.length === before) refuse(`Op ${n}: no edge from ${op.from} to ${op.to}.`);
return;
}
}
});
const empty = graph.nodes.every((node) => ANNOTATION_KINDS.has(node.kind));
if (tidy || empty) {
const fixed = (node: WorkflowNode) =>
ANNOTATION_KINDS.has(node.kind) || node.locked === true || pinned.has(node.id);
const moving = new Set(
nodes
.filter((node) => !fixed(node) && (tidy || placed.some((item) => item.id === node.id)))
.map((node) => node.id),
);
nodes = layoutNodes(nodes, edges, moving);
} else nodes = arrangeInserted(nodes, edges, placed, pinned);
return { graph: { ...graph, nodes, edges }, ids };
}
* Places again each new node that a later op connected: a node inserted before existing nodes moves
* them and the nodes after them one column right, and a node added without from goes right of the
* node that a later connect gave it. Locked nodes and nodes that the agent placed stay.
*/
function arrangeInserted(
start: readonly WorkflowNode[],
edges: readonly WorkflowEdge[],
placed: readonly { id: string; withFrom: boolean }[],
pinned: ReadonlySet<string>,
): WorkflowNode[] {
let nodes = [...start];
const added = new Set(placed.map((item) => item.id));
const byId = (id: string) => nodes.find((node) => node.id === id);
for (const { id, withFrom } of placed) {
const node = byId(id);
const source = edges.find((edge) => edge.to === id);
const upstream = source ? byId(source.from) : undefined;
if (!node || !upstream) continue;
const later = edges.filter((edge) => edge.from === id && !added.has(edge.to)).map((edge) => edge.to);
if (withFrom && later.length === 0) continue;
const column = upstream.at.x + cardSize(upstream).width + COLUMN_GAP;
const nearest = Math.min(...later.map((target) => byId(target)?.at.x ?? Number.POSITIVE_INFINITY));
const shift = column + cardSize(node).width + COLUMN_GAP - nearest;
if (shift > 0) {
const moving = new Set<string>();
const queue = [...later];
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
if (next === id || moving.has(next)) continue;
moving.add(next);
queue.push(...edges.filter((edge) => edge.from === next).map((edge) => edge.to));
}
nodes = nodes.map((item) =>
moving.has(item.id) && !item.locked && !pinned.has(item.id)
? { ...item, at: { x: clampCoordinate(item.at.x + shift), y: item.at.y } }
: item,
);
}
const at = placeNode(nodes, node, upstream, source?.branch);
nodes = nodes.map((item) => (item.id === id ? { ...item, at } : item));
}
return nodes;
}
function newEdge(
from: WorkflowNode,
to: WorkflowNode,
type: WorkflowEdge["type"] | undefined,
branch: WorkflowEdge["branch"] | undefined,
op: number,
): WorkflowEdge {
if (from.id === to.id) refuse(`Op ${op}: a node cannot connect to itself.`);
const port =
type ?? WORKFLOW_PORTS[from.kind].out.find((candidate) => WORKFLOW_PORTS[to.kind].in.includes(candidate));
if (!port) refuse(`Op ${op}: ${from.kind} cannot connect to ${to.kind}.`);
return {
id: edgeId(from.id, to.id, port, branch),
from: from.id,
to: to.id,
type: port,
...(branch ? { branch } : {}),
};
}
const PROBLEM_TEXT: Record<string, string> = {
annotation: "text, note and shape nodes cannot connect",
port: "the nodes have no matching port of this type",
"condition-branch": "an edge from a condition needs branch yes or no",
branch: "only an edge from a condition has a branch",
duplicate: "the edge is there twice",
};
function graphProblem(graph: WorkflowGraph): string | null {
if (!isWorkflowGraph(graph)) return "The workflow is over a limit or has an invalid node or field.";
const found = workflowGraphProblemAt(graph);
if (!found) return null;
return `Edge ${found.edge.from} -> ${found.edge.to}: ${PROBLEM_TEXT[found.problem] ?? found.problem}.`;
}
function stepLabel(node: WorkflowNode | undefined) {
if (!node) return {};
const field = LABEL_FIELD[node.kind];
const value = field === undefined ? undefined : node.fields[field];
const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
return text
? { kind: node.kind, label: text.length > LABEL_CHARS ? `${text.slice(0, LABEL_CHARS)}…` : text }
: { kind: node.kind };
}
function compactRun(detail: WorkflowRunDetail, nodes: ReadonlyMap<string, WorkflowNode>) {
return {
runId: detail.run.id,
number: detail.run.number,
status: detail.run.status,
...(detail.run.error ? { error: detail.run.error } : {}),
startedAt: detail.run.startedAt,
steps: detail.steps.map((step) => ({
nodeId: step.nodeId,
...stepLabel(nodes.get(step.nodeId)),
status: step.status,
...(step.branch ? { branch: step.branch } : {}),
...(step.error ? { error: step.error } : {}),
...(step.output
? {
output: step.output.length > OUTPUT_CHARS ? `${step.output.slice(0, OUTPUT_CHARS)}… (cut)` : step.output,
}
: {}),
})),
};
}
export const handleWorkflowTool = Effect.fn("WorkflowTools.handle")(function* (
tool: string,
args: unknown,
workflows: WorkflowTools | null,
callerId: string,
callKey: string,
agentIds: ReadonlySet<string>,
confirmDelete: (title: string) => Effect.Effect<boolean, ToolOperationFailed>,
) {
if (!WORKFLOW_TOOL_NAMES.has(tool)) return null;
if (!workflows) return yield* new ToolOperationFailed({ cause: new Error("Workflows are unavailable.") });
const save = Effect.fn("WorkflowTools.save")(function* (
workflowId: string,
ops: readonly WorkflowOp[],
tidy: boolean,
) {
let detail = yield* workflows.get(workflowId);
for (let attempt = 0; attempt < 2; attempt += 1) {
const applied = yield* toolStep(() => applyWorkflowOps(detail.graph, ops, agentIds, tidy));
const problem = graphProblem(applied.graph);
if (problem) return yield* toolStep(() => refuse(problem));
const result = yield* workflows.saveGraph(
{ workflowId, baseRevision: detail.revision, graph: applied.graph },
callerId,
);
if (result.ok) return { revision: result.revision, ids: applied.ids };
detail = result.conflict;
}
return yield* toolStep(() => refuse("The workflow changed during the edit. Read it and try again."));
});
const operation = Effect.gen(function* () {
if (tool === "list_workflows") {
yield* toolStep(() => listSchema.parse(args ?? {}));
const summaries = yield* workflows.list();
return openBotToolResult(
summaries.map((summary) => ({
workflowId: summary.id,
title: summary.title,
steps: summary.steps,
...(summary.lastRun ? { lastRun: summary.lastRun } : {}),
})),
);
}
if (tool === "read_workflow") {
const input = yield* toolStep(() => readSchema.parse(args));
const detail = yield* workflows.get(input.workflowId);
return openBotToolResult({
title: detail.summary.title,
revision: detail.revision,
...compactGraph(detail.graph, input.layout === true),
triggers: detail.triggers.map((trigger) => ({
nodeId: trigger.nodeId,
type: trigger.type,
...(trigger.nextDueAt ? { nextDueAt: trigger.nextDueAt } : {}),
...(trigger.webhookUrl ? { webhookUrl: trigger.webhookUrl } : {}),
})),
});
}
if (tool === "edit_workflow") {
const input = yield* toolStep(() => editSchema.parse(args));
const ops = input.ops ?? [];
const tidy = input.tidy === true;
const workflowId = input.workflowId;
if (workflowId === undefined) {
if (input.title === undefined) return openBotToolFailure("A new workflow needs a title.");
const applied = yield* toolStep(() =>
applyWorkflowOps({ nodes: [], edges: [], strokes: [] }, ops, agentIds, tidy),
);
const problem = graphProblem(applied.graph);
if (problem) return openBotToolFailure(problem);
const created = yield* workflows.create({ title: input.title, graph: applied.graph }, callerId);
return openBotToolResult({
workflowId: created.summary.id,
revision: created.revision,
...(Object.keys(applied.ids).length > 0 ? { ids: applied.ids } : {}),
});
}
const saved = ops.length > 0 || tidy ? yield* save(workflowId, ops, tidy) : null;
if (input.title !== undefined) yield* workflows.rename({ workflowId, title: input.title });
if (!saved) return openBotToolResult({ workflowId });
return openBotToolResult({
workflowId,
revision: saved.revision,
...(Object.keys(saved.ids).length > 0 ? { ids: saved.ids } : {}),
});
}
if (tool === "delete_workflow") {
const input = yield* toolStep(() => deleteSchema.parse(args));
const detail = yield* workflows.get(input.workflowId);
if (!(yield* confirmDelete(detail.summary.title)))
return openBotToolFailure("The user declined the delete. Do not try again unless they ask.");
yield* workflows.remove(input.workflowId);
return openBotToolResult({ removed: input.workflowId });
}
if (tool === "run_workflow") {
const input = yield* toolStep(() => runSchema.parse(args));
const detail = yield* workflows.startRun({
workflowId: input.workflowId,
requestId: callKey,
...(input.fromNodeId === undefined ? {} : { fromNodeId: input.fromNodeId }),
});
return openBotToolResult({ runId: detail.run.id, number: detail.run.number, status: detail.run.status });
}
const input = yield* toolStep(() => runsSchema.parse(args));
const nodes = new Map((yield* workflows.get(input.workflowId)).graph.nodes.map((node) => [node.id, node]));
if (input.runId !== undefined) {
const detail = yield* workflows.run(input.runId);
if (detail.run.workflowId !== input.workflowId)
return openBotToolFailure(`No run ${input.runId} in this workflow.`);
return openBotToolResult(compactRun(detail, nodes));
}
const page = yield* workflows.runs({ workflowId: input.workflowId, limit: input.limit ?? 1 });
return openBotToolResult({ runs: page.runs.map((detail) => compactRun(detail, nodes)) });
});
return yield* operation.pipe(
Effect.catchTag("WorkflowFailed", (failure) =>
failure.cause instanceof WorkflowStoreError || failure.cause instanceof RoutineInputError
? Effect.succeed(openBotToolFailure(failure.cause.message))
: Effect.fail(new ToolOperationFailed({ cause: failure.cause })),
),
Effect.catchTag("ToolOperationFailed", (failure) =>
failure.cause instanceof WorkflowEditRefused
? Effect.succeed(openBotToolFailure(failure.cause.message))
: Effect.fail(failure),
),
);
});