Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Press `Esc` to cancel a running workflow. Active subagents are aborted and surfa

## Workflow script shape

A workflow is plain JavaScript. The first statement must export literal metadata:
A workflow is plain JavaScript. The first statement must export literal metadata. `name` and `description` are required; `phases` is optional documentation for an expected outline. The live progress view is driven by `phase(...)` calls at runtime:

```js
export const meta = {
Expand All @@ -73,6 +73,8 @@ const summary = await agent(
return { inventory, summary }
```

Phases are discovered as the script runs, so conditional and loop-created phases work naturally. If a branch is skipped, its phase does not show up as an empty progress row.

### Available globals

| Global | Description |
Expand Down
30 changes: 22 additions & 8 deletions src/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function createWorkflowSnapshot(meta: WorkflowMeta): WorkflowSnapshot {
return {
name: meta.name,
description: meta.description,
phases: meta.phases?.map((phase) => phase.title) ?? [],
phases: [],
logs: [],
agents: [],
agentCount: 0,
Expand Down Expand Up @@ -104,7 +104,7 @@ export function createToolUpdateWorkflowDisplay(
const emit = (snapshot: WorkflowSnapshot, completed = false) => {
if (streamToolUpdates) {
onUpdate?.({
content: [{ type: "text", text: renderWorkflowText(snapshot, completed) }],
content: [{ type: "text", text: renderWorkflowText(snapshot, completed, options) }],
details: snapshot,
});
}
Expand Down Expand Up @@ -137,13 +137,19 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo
: "";
const lines = [`◆ Workflow: ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state})`];

const phaseNames = snapshot.phases.length
? snapshot.phases
: unique(snapshot.agents.map((agent) => agent.phase).filter(Boolean) as string[]);
const agentPhaseNames = snapshot.agents
.map((agent) => agent.phase)
.filter((phase): phase is string => Boolean(phase));
const phaseNames = unique([
...snapshot.phases,
...(snapshot.currentPhase ? [snapshot.currentPhase] : []),
...agentPhaseNames,
]);
const rendered = new Set<WorkflowAgentSnapshot>();

for (const phase of phaseNames) {
const agents = snapshot.agents.filter((agent) => agent.phase === phase);
if (agents.length === 0 && snapshot.currentPhase !== phase) continue;
for (const agent of agents) rendered.add(agent);
const done = agents.filter((agent) => agent.status === "done").length;
const running = agents.filter((agent) => agent.status === "running").length;
Expand Down Expand Up @@ -174,13 +180,21 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo
}
}

for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${log}`);
const visibleLogs = snapshot.logs.slice(-maxLogs);
if (visibleLogs.length) {
if (lines.length > 1) lines.push("");
for (const log of visibleLogs) lines.push(` log: ${log}`);
}
return lines;
}

export function renderWorkflowText(snapshot: WorkflowSnapshot, completed = false): string {
export function renderWorkflowText(
snapshot: WorkflowSnapshot,
completed = false,
options: WorkflowDisplayOptions = {},
): string {
const header = completed ? "Workflow completed" : "Workflow running";
return [header, ...renderWorkflowLines(snapshot)].join("\n");
return [header, ...renderWorkflowLines(snapshot, options)].join("\n");
}

function statusLine(snapshot: WorkflowSnapshot, completed: boolean): string {
Expand Down
35 changes: 22 additions & 13 deletions src/workflow-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const workflowToolSchema = Type.Object({
script: Type.String({
description: [
"Required raw JavaScript workflow script, with no Markdown fences.",
"First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description', phases: [{ title: 'Phase' }] }",
"First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. meta.phases is optional documentation; live progress is driven by phase(title).",
"Use phase('Name'), agent(prompt, opts), parallel(arrayOfFunctions), pipeline(items, ...stages), log(message), args, and budget. The workflow must call agent() at least once.",
"parallel() requires functions, not promises: await parallel(items.map(item => () => agent(...))).",
].join(" "),
Expand All @@ -30,6 +30,14 @@ export type WorkflowToolInput = {
args?: unknown;
};

const workflowDisplayOptions = {
key: "workflow",
streamToolUpdates: true,
maxAgents: 4,
maxLogs: 1,
showResultPreviews: false,
} as const;

export interface WorkflowToolOptions {
cwd?: string;
concurrency?: number;
Expand All @@ -41,16 +49,17 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
label: "Workflow",
description: [
"Execute a deterministic JavaScript workflow that orchestrates multiple subagents with agent(), parallel(), and pipeline().",
"script is required raw JavaScript. It must start with export const meta = { name, description, phases? } and must call agent() at least once.",
"script is required raw JavaScript. It must start with export const meta = { name, description } and must call agent() at least once; phases are optional metadata.",
].join(" "),
promptSnippet:
"Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description', phases: [{ title: 'Phase' }] }.",
"Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Use phase(title) at runtime to create progress groups.",
promptGuidelines: [
"Use workflow only when the user explicitly asks for a workflow, workflows, fan-out, or multi-agent orchestration.",
"For workflow, always pass one raw JavaScript string in the required script parameter; do not include Markdown fences or prose around the script.",
"For workflow, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description', phases: [{ title: 'Phase name' }] }`; meta.name and meta.description are required non-empty strings.",
"For workflow, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description' }`; meta.name and meta.description are required non-empty strings, and meta.phases is optional metadata for a stable upfront outline.",
"For workflow, write plain JavaScript after the meta export. Do not use TypeScript syntax, imports, require(), fs, Date.now(), Math.random(), or new Date().",
"For workflow, available globals are agent(prompt, opts), parallel(thunks), pipeline(items, ...stages), phase(title), log(message), args, cwd, process.cwd(), and budget. Every workflow must call agent() at least once; do not use workflow only to declare phases or return a static object.",
"For workflow, call phase(title) when a new group of work starts. Phase names may be conditional or built in a loop; do not predeclare speculative phases just in case.",
"For workflow, prefer it for decomposable work: repository inspection, independent research/checks, multi-perspective review, or fan-out/fan-in synthesis. Do not use it for a single quick file read/edit or when ordinary tools are enough.",
"For workflow, parallel() takes functions, not promises: use `await parallel(items.map(item => () => agent('...', { label: '...' })))`, never `await parallel(items.map(item => agent(...)))`. Results are returned in input order.",
"For workflow, pipeline(items, ...stages) runs each item through stages sequentially, while different items may run concurrently. Each stage receives (previousValue, originalItem, index).",
Expand All @@ -68,19 +77,18 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
const script = normalizeWorkflowScript(params.script);
const parsed = parseWorkflowScript(script);
let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta);
const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, {
key: "workflow",
streamToolUpdates: true,
maxAgents: 4,
maxLogs: 1,
showResultPreviews: false,
});
const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, workflowDisplayOptions);

const update = () => {
snapshot = recomputeWorkflowSnapshot(snapshot);
display.update(snapshot);
};

const recordPhase = (title: string | undefined) => {
if (!title) return;
if (!snapshot.phases.includes(title)) snapshot.phases.push(title);
};

let result: WorkflowRunResult;
try {
result = await runWorkflow(script, {
Expand All @@ -98,11 +106,12 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
},
onPhase(title) {
snapshot.currentPhase = title;
if (!snapshot.phases.includes(title)) snapshot.phases.push(title);
recordPhase(title);
update();
},
onAgentStart(event) {
if (signal?.aborted) throw new Error("Workflow was aborted");
recordPhase(event.phase);
snapshot.agents.push({
id: snapshot.agents.length + 1,
label: event.label,
Expand Down Expand Up @@ -172,7 +181,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
renderResult(result, { isPartial }, theme) {
const snapshot = result.details as WorkflowSnapshot | undefined;
if (snapshot?.name) {
return new Text(renderWorkflowText(snapshot, !isPartial), 0, 0);
return new Text(renderWorkflowText(snapshot, !isPartial, workflowDisplayOptions), 0, 0);
}
const text = result.content?.[0];
return new Text(text?.type === "text" ? text.text : theme.fg("muted", "workflow"), 0, 0);
Expand Down
2 changes: 1 addition & 1 deletion src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body:

const first = ast.body?.[0] as AnyNode | undefined;
if (first?.type !== "ExportNamedDeclaration") {
throw new Error("`export const meta = { name, description, phases }` must be the first statement in the script");
throw new Error("`export const meta = { name, description }` must be the first statement in the script");
}

const declaration = first.declaration as AnyNode | null;
Expand Down
118 changes: 118 additions & 0 deletions tests/workflow-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createWorkflowSnapshot,
recomputeWorkflowSnapshot,
renderWorkflowLines,
renderWorkflowText,
type WorkflowAgentSnapshot,
type WorkflowSnapshot,
} from "../src/display.js";

function snapshot(overrides: Partial<WorkflowSnapshot> = {}): WorkflowSnapshot {
return recomputeWorkflowSnapshot({
name: "demo_workflow",
phases: [],
logs: [],
agents: [],
agentCount: 0,
runningCount: 0,
doneCount: 0,
errorCount: 0,
...overrides,
});
}

function agent(overrides: Partial<WorkflowAgentSnapshot> = {}): WorkflowAgentSnapshot {
return {
id: 1,
label: "scan repo",
phase: "Scan",
prompt: "Scan the repo",
status: "done",
...overrides,
};
}

test("createWorkflowSnapshot does not pre-render declared phases", () => {
const value = createWorkflowSnapshot({
name: "demo_workflow",
description: "A useful workflow",
phases: [{ title: "Scan" }, { title: "Review" }],
});

assert.deepEqual(value.phases, []);
});

test("renderWorkflowLines hides empty phase rows", () => {
const lines = renderWorkflowLines(
snapshot({
phases: ["Scan", "Review"],
agents: [agent()],
}),
);

assert.ok(lines.some((line) => line.includes("Scan 1/1")));
assert.ok(!lines.some((line) => line.includes("Review 0/0")));
});

test("renderWorkflowLines keeps the current empty phase visible", () => {
const lines = renderWorkflowLines(
snapshot({
phases: ["Scan"],
currentPhase: "Scan",
}),
);

assert.ok(lines.some((line) => line.includes("▶ Scan 0/0")));
});

test("renderWorkflowLines groups agents by phase even when the phase was not pre-recorded", () => {
const lines = renderWorkflowLines(
snapshot({
phases: ["Scan"],
agents: [agent({ id: 2, label: "review diff", phase: "Review" })],
}),
);

assert.ok(lines.some((line) => line.includes("Review 1/1")));
assert.ok(!lines.some((line) => line.trim() === "Unphased"));
});

test("renderWorkflowLines renders runtime-created phases from the phase list", () => {
const lines = renderWorkflowLines(
snapshot({
phases: ["Inspect API"],
agents: [agent({ label: "inspect api", phase: "Inspect API" })],
}),
);

assert.ok(lines.some((line) => line.includes("Inspect API 1/1")));
});

test("renderWorkflowText respects log limits", () => {
const text = renderWorkflowText(
snapshot({
logs: ["first", "second", "third"],
}),
true,
{ maxLogs: 1 },
);

assert.doesNotMatch(text, /log: first/);
assert.doesNotMatch(text, /log: second/);
assert.match(text, /log: third/);
});

test("renderWorkflowLines separates logs from progress", () => {
const lines = renderWorkflowLines(
snapshot({
agents: [agent()],
logs: ["finished scan"],
}),
);

const logIndex = lines.findIndex((line) => line.includes("log: finished scan"));
assert.ok(logIndex > 0);
assert.equal(lines[logIndex - 1], "");
});
58 changes: 58 additions & 0 deletions tests/workflow-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { runWorkflow } from "../src/workflow.js";

const fakeAgent = {
async run(prompt: string): Promise<string> {
return `result:${prompt}`;
},
};

test("runWorkflow accepts metadata without phases and records runtime phases", async () => {
const result = await runWorkflow(
`export const meta = {
name: 'dynamic_demo',
description: 'Use runtime phases'
}

phase('Scan')
const scan = await agent('scan', { label: 'scan' })
return { scan }
`,
{ agent: fakeAgent },
);

assert.deepEqual(result.phases, ["Scan"]);
assert.equal(result.agentCount, 1);
assert.equal((result.result as { scan: string }).scan, "result:scan");
});

test("runWorkflow records loop-created phases without skipped conditional phases", async () => {
const result = await runWorkflow(
`export const meta = {
name: 'loop_demo',
description: 'Create phases from work items',
phases: [{ title: 'Review' }]
}

if (args.needsReview) {
phase('Review')
await agent('review', { label: 'review' })
}

for (const area of args.areas) {
phase('Inspect ' + area)
await agent('inspect ' + area, { label: 'inspect ' + area })
}

return { ok: true }
`,
{
args: { needsReview: false, areas: ["API", "UI"] },
agent: fakeAgent,
},
);

assert.deepEqual(result.phases, ["Inspect API", "Inspect UI"]);
assert.equal(result.agentCount, 2);
});
12 changes: 12 additions & 0 deletions tests/workflow-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createWorkflowTool } from "../src/workflow-tool.js";

test("createWorkflowTool describes phases as optional and dynamic", () => {
const tool = createWorkflowTool();

assert.match(tool.promptSnippet ?? "", /export const meta = \{ name: 'short_snake_case', description:/);
assert.doesNotMatch(tool.promptSnippet ?? "", /phases: \[/);
assert.ok(tool.promptGuidelines?.some((line) => line.includes("meta.phases is optional metadata")));
assert.ok(tool.promptGuidelines?.some((line) => line.includes("Phase names may be conditional or built in a loop")));
});
Loading