Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

Knowledge from smoke test discoveries — these are the most common
mistakes when writing workflow bundles.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:57:49 +00:00
8 changed files with 192 additions and 275 deletions
@@ -70,10 +70,10 @@ const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 600));
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
+86 -16
View File
@@ -183,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
// Required named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
export const run: WorkflowFn;
\`\`\`
## WorkflowDescriptor
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
\`\`\`typescript
type WorkflowDescriptor = {
description: string;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
graph: {
edges: Array<{
from: string;
to: string;
condition: string;
conditionDescription: string | null;
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
## WorkflowFn
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## ModeratorTable
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
\`\`\`typescript
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
const table: ModeratorTable<MyMeta> = {
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
firstRole: [{ condition: "FALLBACK", role: END }],
};
\`\`\`
## AdapterFn / AdapterBinding
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
\`\`\`typescript
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
\`\`\`
## Role Definition
@@ -230,15 +258,16 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + ModeratorTable + descriptor)
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 3. Build the ESM bundle
bun run build
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 5. Test
# 6. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -246,5 +275,46 @@ uncaged-workflow live --latest
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
## Pitfalls
### Lazy initialization is mandatory
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
\`\`\`typescript
// ❌ WRONG — breaks register
const provider = { apiKey: process.env.MY_KEY! };
const adapter = createAdapter(provider);
// ✅ CORRECT — only reads env when run() is called
function createLazyAdapter(): AdapterFn {
let cached: Provider | null = null;
return (prompt, schema) => {
return async (ctx, runtime) => {
if (!cached) cached = { apiKey: process.env.MY_KEY! };
// ... use cached provider
};
};
}
\`\`\`
### Bundle import restrictions
The bundle validator only allows these import specifiers:
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
- \`@uncaged/workflow-*\` packages
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
### No default exports
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
### Single-file ESM
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
`;
}
-1
View File
@@ -11,7 +11,6 @@
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.2",
"elkjs": "^0.11.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
@@ -2,6 +2,7 @@ import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
getSmoothStepPath,
} from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
@@ -20,28 +21,31 @@ export function ConditionEdge(props: EdgeProps) {
data,
markerEnd,
} = props;
const edgeData = data as (ConditionEdgeData & { elkLabelX?: number | null; elkLabelY?: number | null }) | undefined;
const edgeData = data as ConditionEdgeData | undefined;
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: isSelfLoop ? 20 : 8,
offset: isSelfLoop ? 50 : undefined,
});
const [path, labelX, labelY] = isSelfLoop
? getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 20,
})
: getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
const strokeDasharray = isFallback ? "5 4" : undefined;
const label = edgeData?.condition ?? "";
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
return (
<>
@@ -51,21 +55,19 @@ export function ConditionEdge(props: EdgeProps) {
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
/>
{label !== "" && (
{edgeData && !isFallback && edgeData.condition !== "" && (
<EdgeLabelRenderer>
<div
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-surface)",
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
whiteSpace: "nowrap",
zIndex: 10,
color: "var(--color-text)",
}}
title={edgeData?.conditionDescription ?? undefined}
title={edgeData.conditionDescription ?? undefined}
>
{label}
{edgeData.condition}
</div>
</EdgeLabelRenderer>
)}
@@ -1,6 +1,6 @@
import Dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import type { WorkflowGraphEdge } from "../../api.ts";
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
@@ -37,10 +37,6 @@ function nodeSize(id: string): { width: number; height: number } {
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
}
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
@@ -72,24 +68,14 @@ function buildTerminalNode(
};
}
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): Edge<ConditionEdgeData> {
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
const isFallback = e.condition === "FALLBACK";
const key = edgeKey(e);
const elkEdge = elkEdgeMap.get(key);
// Extract ELK's computed label position
let labelX: number | null = null;
let labelY: number | null = null;
if (elkEdge?.labels && elkEdge.labels.length > 0) {
const label = elkEdge.labels[0];
if (label.x !== undefined && label.y !== undefined) {
labelX = label.x + (label.width ?? 0) / 2;
labelY = label.y + (label.height ?? 0) / 2;
}
}
return {
id: key,
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
@@ -97,111 +83,45 @@ function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
elkLabelX: labelX,
elkLabelY: labelY,
} as ConditionEdgeData,
};
}
const elk = new ELK();
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
const ids = collectNodeIds(input.edges);
const elkNodes: ElkNode[] = [];
for (const id of ids) {
const size = nodeSize(id);
elkNodes.push({ id, width: size.width, height: size.height });
}
const elkEdges: ElkExtendedEdge[] = input.edges
.filter((e) => e.from !== e.to)
.map((e) => ({
id: edgeKey(e),
sources: [e.from],
targets: [e.to],
labels: e.condition !== ""
? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }]
: [],
}));
const graph: ElkNode = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
// Node spacing
"elk.spacing.nodeNode": "30",
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
// Edge spacing — keep edges apart from each other and from nodes
"elk.spacing.edgeNode": "25",
"elk.spacing.edgeEdge": "15",
"elk.layered.spacing.edgeNodeBetweenLayers": "25",
"elk.layered.spacing.edgeEdgeBetweenLayers": "15",
// Edge routing
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.mergeEdges": "false",
// Node placement
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
// Edge label placement
"elk.edgeLabels.placement": "CENTER",
// Crossing minimization
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
// Compaction
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
// Cycle breaking — keep main flow top-to-bottom
"elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
},
children: elkNodes,
edges: elkEdges,
};
const laid = await elk.layout(graph);
// Build map of ELK edge results for label positions
const elkEdgeMap = new Map<string, ElkExtendedEdge>();
for (const e of laid.edges ?? []) {
elkEdgeMap.set(e.id, e);
}
const nodes: Node[] = [];
for (const child of laid.children ?? []) {
const pos = { x: child.x ?? 0, y: child.y ?? 0 };
const state = input.nodeStates.get(child.id) ?? "default";
if (child.id === START_ID || child.id === END_ID) {
nodes.push(buildTerminalNode(child.id, pos, state));
} else {
nodes.push(buildRoleNode(child.id, pos, input.roles, state));
}
}
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
return { nodes, edges };
}
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
export function useLayout(input: LayoutInput): LayoutResult {
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
return useMemo(() => {
const ids = collectNodeIds(input.edges);
const edgeJson = JSON.stringify(input.edges);
const roleJson = JSON.stringify(input.roles);
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
useEffect(() => {
let cancelled = false;
const parsed = {
edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[],
roles: JSON.parse(roleJson) as Record<string, { description: string }>,
nodeStates: input.nodeStates,
};
computeLayout(parsed).then((result) => {
if (!cancelled) setLayout(result);
});
return () => {
cancelled = true;
};
}, [edgeJson, roleJson, input.nodeStates]);
for (const id of ids) {
const size = nodeSize(id);
g.setNode(id, { width: size.width, height: size.height });
}
for (const e of input.edges) {
if (e.from === e.to) {
continue;
}
g.setEdge(e.from, e.to, {}, edgeKey(e));
}
return layout;
Dagre.layout(g);
const nodes: Node[] = [];
for (const id of ids) {
const dagNode = g.node(id);
const size = nodeSize(id);
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, pos, state));
} else {
nodes.push(buildRoleNode(id, pos, input.roles, state));
}
}
const edges: Edge[] = input.edges.map(buildEdge);
return { nodes, edges };
}, [input.edges, input.roles, input.nodeStates]);
}
@@ -6,11 +6,9 @@ import {
type NodeTypes,
type OnNodeClick,
ReactFlow,
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx";
@@ -39,30 +37,12 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node
onRoleClick(node.id);
}
function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const { fitView } = useReactFlow();
const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
// Re-fit when layout changes (ELK is async)
// Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes
useEffect(() => {
if (layout.nodes.length > 0) {
let cancelled = false;
requestAnimationFrame(() => {
if (cancelled) return;
setTimeout(() => {
if (!cancelled) fitView({ padding: 0.1, duration: 300 });
}, 300);
});
return () => {
cancelled = true;
};
}
}, [layout.nodes, layout.edges, fitView]);
const styledEdges = useMemo(
() =>
layout.edges.map((e) => ({
@@ -77,25 +57,15 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
[layout.edges],
);
// Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView
const layoutKey = useMemo(
() => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","),
[layout.nodes],
);
return (
<ReactFlow
key={layoutKey}
nodes={layout.nodes}
edges={styledEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClickHandler}
fitView
fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 1.5 }}
minZoom={0.1}
maxZoom={1.5}
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
fitViewOptions={{ padding: 0.15 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
@@ -107,11 +77,3 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
</ReactFlow>
);
}
export function WorkflowGraph(props: Props) {
return (
<ReactFlowProvider>
<WorkflowGraphInner {...props} />
</ReactFlowProvider>
);
}
@@ -45,45 +45,38 @@ function ExpandedWorkflowBody({
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = versionCount(detail);
const hasGraph = descriptor !== null && edgeCount > 0;
return (
<div
className="pt-3 border-t flex gap-4"
style={{ borderColor: "var(--color-border)" }}
>
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</p>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</p>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</div>
{hasGraph ? (
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
</p>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
{descriptor !== null && edgeCount > 0 ? (
<div
className="rounded-lg border overflow-hidden flex-1"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
@@ -94,7 +87,7 @@ function ExpandedWorkflowBody({
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div style={{ height: 600, width: "100%" }}>
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
@@ -155,17 +148,18 @@ export function WorkflowList({ agent }: Props) {
);
function toggleExpanded(name: string) {
const wasExpanded = expanded.has(name);
let shouldLoad = false;
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
return next;
}
next.add(name);
shouldLoad = true;
return next;
});
if (!wasExpanded) {
if (shouldLoad) {
ensureDetailLoaded(name);
}
}
+1 -31
View File
@@ -299,37 +299,7 @@ async function driveWorkflowGenerator(params: {
});
}
const iterResult = await Promise.race([
gen.next(),
new Promise<never>((_, reject) => {
if (executeOptions.signal.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
executeOptions.signal.addEventListener(
"abort",
() => reject(new DOMException("The operation was aborted", "AbortError")),
{ once: true },
);
}),
]).catch((e) => {
if (e instanceof DOMException && e.name === "AbortError") {
return { done: true as const, value: { returnCode: 130, summary: "thread aborted" } };
}
throw e;
});
if (executeOptions.signal.aborted || (iterResult.done && iterResult.value.returnCode === 130)) {
return await finalizeAbortedThread({
cas,
bundleDir,
threadId,
startHash,
chain,
logger,
abortLogTag: "H4KQ7RW3",
});
}
const iterResult = await gen.next();
if (iterResult.done) {
logger("F3HN8QKP", `thread ${threadId} generator finished`);