@quintinshaw/pi-dynamic-workflows 1.9.1 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +16 -0
- package/dist/display.d.ts +2 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/run-persistence.d.ts +2 -0
- package/dist/task-panel.d.ts +14 -5
- package/dist/task-panel.js +35 -27
- package/dist/workflow-editor.d.ts +74 -0
- package/dist/workflow-editor.js +228 -0
- package/dist/workflow-manager.d.ts +38 -3
- package/dist/workflow-manager.js +66 -11
- package/dist/workflow-tool.d.ts +7 -0
- package/dist/workflow-tool.js +65 -67
- package/dist/workflow-ui.d.ts +1 -0
- package/dist/workflow-ui.js +27 -5
- package/dist/workflow.d.ts +9 -0
- package/dist/workflow.js +11 -4
- package/extensions/workflow.ts +12 -0
- package/package.json +1 -1
- package/src/agent.ts +16 -0
- package/src/display.ts +2 -0
- package/src/index.ts +13 -2
- package/src/run-persistence.ts +2 -0
- package/src/task-panel.ts +39 -28
- package/src/workflow-editor.ts +252 -0
- package/src/workflow-manager.ts +91 -14
- package/src/workflow-tool.ts +68 -66
- package/src/workflow-ui.ts +27 -5
- package/src/workflow.ts +27 -5
package/src/workflow-manager.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
|
-
import type {
|
|
6
|
+
import type { WorkflowAgent } from "./agent.js";
|
|
7
|
+
import { preview, type WorkflowSnapshot } from "./display.js";
|
|
7
8
|
import { WorkflowError, WorkflowErrorCode } from "./errors.js";
|
|
8
9
|
import {
|
|
9
10
|
createRunPersistence,
|
|
@@ -27,6 +28,27 @@ export interface ManagedRun {
|
|
|
27
28
|
args?: unknown;
|
|
28
29
|
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
29
30
|
journal: JournalEntry[];
|
|
31
|
+
/**
|
|
32
|
+
* True when the run was started in the background (or resumed) and the caller is
|
|
33
|
+
* not awaiting its result inline. Only background runs deliver their result back
|
|
34
|
+
* into the conversation; a foreground sync run already returns it as the tool
|
|
35
|
+
* result, so re-delivering would duplicate it.
|
|
36
|
+
*/
|
|
37
|
+
background: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Per-execution options shared by sync, background, and resume runs. */
|
|
41
|
+
export interface ExecOptions {
|
|
42
|
+
/** Replay these journaled agent results for the unchanged prefix (resume). */
|
|
43
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
44
|
+
/** Cap on total agents for this run. */
|
|
45
|
+
maxAgents?: number;
|
|
46
|
+
/** Per-agent timeout in milliseconds. */
|
|
47
|
+
agentTimeoutMs?: number;
|
|
48
|
+
/** Host signal (e.g. tool/Esc) that should abort this run when fired. */
|
|
49
|
+
externalSignal?: AbortSignal;
|
|
50
|
+
/** Called with the live snapshot on every progress event. */
|
|
51
|
+
onProgress?: (snapshot: WorkflowSnapshot) => void;
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
export interface WorkflowManagerOptions {
|
|
@@ -34,6 +56,10 @@ export interface WorkflowManagerOptions {
|
|
|
34
56
|
concurrency?: number;
|
|
35
57
|
/** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
|
|
36
58
|
loadSavedWorkflow?: (name: string) => string | undefined;
|
|
59
|
+
/** Inject a custom agent runner (tests); defaults to a real subagent session. */
|
|
60
|
+
agent?: Pick<WorkflowAgent, "run">;
|
|
61
|
+
/** The session's main model (provider/id), for auto-tiering explore agents. */
|
|
62
|
+
mainModel?: string;
|
|
37
63
|
}
|
|
38
64
|
|
|
39
65
|
export class WorkflowManager extends EventEmitter {
|
|
@@ -42,15 +68,25 @@ export class WorkflowManager extends EventEmitter {
|
|
|
42
68
|
private cwd: string;
|
|
43
69
|
private concurrency: number;
|
|
44
70
|
private loadSavedWorkflow?: (name: string) => string | undefined;
|
|
71
|
+
private agent?: Pick<WorkflowAgent, "run">;
|
|
72
|
+
/** The session's main model (provider/id), for auto-tiering explore agents. */
|
|
73
|
+
private mainModel?: string;
|
|
45
74
|
|
|
46
75
|
constructor(options: WorkflowManagerOptions = {}) {
|
|
47
76
|
super();
|
|
48
77
|
this.cwd = options.cwd ?? process.cwd();
|
|
49
78
|
this.concurrency = options.concurrency ?? 8;
|
|
50
79
|
this.loadSavedWorkflow = options.loadSavedWorkflow;
|
|
80
|
+
this.agent = options.agent;
|
|
81
|
+
this.mainModel = options.mainModel;
|
|
51
82
|
this.persistence = createRunPersistence(this.cwd);
|
|
52
83
|
}
|
|
53
84
|
|
|
85
|
+
/** Set the session's main model (provider/id). Used to auto-tier explore agents. */
|
|
86
|
+
setMainModel(spec: string | undefined): void {
|
|
87
|
+
this.mainModel = spec;
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
/**
|
|
55
91
|
* Start a workflow in the background.
|
|
56
92
|
* Returns immediately with a run ID; the workflow executes asynchronously.
|
|
@@ -79,6 +115,7 @@ export class WorkflowManager extends EventEmitter {
|
|
|
79
115
|
script,
|
|
80
116
|
args,
|
|
81
117
|
journal: [],
|
|
118
|
+
background: true,
|
|
82
119
|
};
|
|
83
120
|
|
|
84
121
|
this.runs.set(runId, managed);
|
|
@@ -104,15 +141,25 @@ export class WorkflowManager extends EventEmitter {
|
|
|
104
141
|
}
|
|
105
142
|
|
|
106
143
|
/**
|
|
107
|
-
* Execute a workflow synchronously (blocking)
|
|
144
|
+
* Execute a workflow synchronously (blocking) while still tracking it like a
|
|
145
|
+
* background run, so the `/workflows` navigator and the live task panel see it.
|
|
146
|
+
* `onProgress` fires on every progress event with the current snapshot, letting
|
|
147
|
+
* a caller (e.g. the workflow tool) drive its own inline display.
|
|
108
148
|
*/
|
|
109
|
-
async runSync(script: string, args?: unknown): Promise<WorkflowRunResult> {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
async runSync(script: string, args?: unknown, exec: ExecOptions = {}): Promise<WorkflowRunResult> {
|
|
150
|
+
const managed = this.createManaged(script, args);
|
|
151
|
+
this.runs.set(managed.runId, managed);
|
|
152
|
+
// Persist the initial state immediately so listRuns()/the task panel can see
|
|
153
|
+
// the run the moment it starts, not only after the first agent journals.
|
|
154
|
+
this.persistRun(managed);
|
|
155
|
+
return this.executeRun(managed, script, args, exec);
|
|
156
|
+
}
|
|
113
157
|
|
|
114
|
-
|
|
115
|
-
|
|
158
|
+
/** Build a fresh managed run with an empty snapshot. */
|
|
159
|
+
private createManaged(script: string, args?: unknown): ManagedRun {
|
|
160
|
+
const parsed = parseWorkflowScript(script);
|
|
161
|
+
return {
|
|
162
|
+
runId: generateRunId(),
|
|
116
163
|
status: "running",
|
|
117
164
|
snapshot: {
|
|
118
165
|
name: parsed.meta.name,
|
|
@@ -125,29 +172,38 @@ export class WorkflowManager extends EventEmitter {
|
|
|
125
172
|
doneCount: 0,
|
|
126
173
|
errorCount: 0,
|
|
127
174
|
},
|
|
128
|
-
controller,
|
|
175
|
+
controller: new AbortController(),
|
|
129
176
|
startedAt: new Date(),
|
|
130
177
|
script,
|
|
131
178
|
args,
|
|
132
179
|
journal: [],
|
|
180
|
+
background: false,
|
|
133
181
|
};
|
|
134
|
-
|
|
135
|
-
this.runs.set(runId, managed);
|
|
136
|
-
return this.executeRun(managed, script, args);
|
|
137
182
|
}
|
|
138
183
|
|
|
139
184
|
private async executeRun(
|
|
140
185
|
managed: ManagedRun,
|
|
141
186
|
script: string,
|
|
142
187
|
args?: unknown,
|
|
143
|
-
|
|
188
|
+
exec: ExecOptions = {},
|
|
144
189
|
): Promise<WorkflowRunResult> {
|
|
190
|
+
const { resumeJournal, maxAgents, agentTimeoutMs, externalSignal, onProgress } = exec;
|
|
191
|
+
const progress = () => onProgress?.(managed.snapshot);
|
|
192
|
+
// Let a host abort (e.g. Esc during a blocking tool call) cancel this run.
|
|
193
|
+
if (externalSignal) {
|
|
194
|
+
if (externalSignal.aborted) managed.controller.abort();
|
|
195
|
+
else externalSignal.addEventListener("abort", () => managed.controller.abort(), { once: true });
|
|
196
|
+
}
|
|
145
197
|
try {
|
|
146
198
|
const result = await runWorkflow(script, {
|
|
147
199
|
cwd: this.cwd,
|
|
148
200
|
args,
|
|
201
|
+
agent: this.agent,
|
|
202
|
+
mainModel: this.mainModel,
|
|
149
203
|
signal: managed.controller.signal,
|
|
150
204
|
concurrency: this.concurrency,
|
|
205
|
+
maxAgents,
|
|
206
|
+
agentTimeoutMs,
|
|
151
207
|
loadSavedWorkflow: this.loadSavedWorkflow,
|
|
152
208
|
resumeJournal,
|
|
153
209
|
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
@@ -160,6 +216,7 @@ export class WorkflowManager extends EventEmitter {
|
|
|
160
216
|
onLog: (message) => {
|
|
161
217
|
managed.snapshot.logs.push(message);
|
|
162
218
|
this.emit("log", { runId: managed.runId, message });
|
|
219
|
+
progress();
|
|
163
220
|
},
|
|
164
221
|
onPhase: (title) => {
|
|
165
222
|
managed.snapshot.currentPhase = title;
|
|
@@ -167,6 +224,7 @@ export class WorkflowManager extends EventEmitter {
|
|
|
167
224
|
managed.snapshot.phases.push(title);
|
|
168
225
|
}
|
|
169
226
|
this.emit("phase", { runId: managed.runId, title });
|
|
227
|
+
progress();
|
|
170
228
|
},
|
|
171
229
|
onAgentStart: (event) => {
|
|
172
230
|
managed.snapshot.agents.push({
|
|
@@ -175,8 +233,10 @@ export class WorkflowManager extends EventEmitter {
|
|
|
175
233
|
phase: event.phase,
|
|
176
234
|
prompt: event.prompt,
|
|
177
235
|
status: "running",
|
|
236
|
+
model: event.model,
|
|
178
237
|
});
|
|
179
238
|
this.emit("agentStart", { runId: managed.runId, ...event });
|
|
239
|
+
progress();
|
|
180
240
|
},
|
|
181
241
|
onAgentEnd: (event) => {
|
|
182
242
|
const agent = [...managed.snapshot.agents]
|
|
@@ -184,8 +244,17 @@ export class WorkflowManager extends EventEmitter {
|
|
|
184
244
|
.find((a) => a.label === event.label && a.status === "running");
|
|
185
245
|
if (agent) {
|
|
186
246
|
agent.status = event.result === null ? "error" : "done";
|
|
247
|
+
agent.resultPreview = preview(event.result);
|
|
248
|
+
agent.tokens = event.tokens;
|
|
249
|
+
if (event.model) agent.model = event.model;
|
|
187
250
|
}
|
|
188
251
|
this.emit("agentEnd", { runId: managed.runId, ...event });
|
|
252
|
+
progress();
|
|
253
|
+
},
|
|
254
|
+
onTokenUsage: (usage) => {
|
|
255
|
+
managed.snapshot.tokenUsage = usage;
|
|
256
|
+
this.emit("tokenUsage", { runId: managed.runId, usage });
|
|
257
|
+
progress();
|
|
189
258
|
},
|
|
190
259
|
});
|
|
191
260
|
|
|
@@ -241,6 +310,13 @@ export class WorkflowManager extends EventEmitter {
|
|
|
241
310
|
})),
|
|
242
311
|
logs: managed.snapshot.logs,
|
|
243
312
|
result: managed.result?.result,
|
|
313
|
+
tokenUsage: managed.snapshot.tokenUsage
|
|
314
|
+
? {
|
|
315
|
+
input: managed.snapshot.tokenUsage.input,
|
|
316
|
+
output: managed.snapshot.tokenUsage.output,
|
|
317
|
+
total: managed.snapshot.tokenUsage.total,
|
|
318
|
+
}
|
|
319
|
+
: undefined,
|
|
244
320
|
startedAt: managed.startedAt.toISOString(),
|
|
245
321
|
updatedAt: new Date().toISOString(),
|
|
246
322
|
completedAt: managed.status === "completed" ? new Date().toISOString() : undefined,
|
|
@@ -292,13 +368,14 @@ export class WorkflowManager extends EventEmitter {
|
|
|
292
368
|
script: persisted.script,
|
|
293
369
|
args: persisted.args,
|
|
294
370
|
journal: persisted.journal ?? [],
|
|
371
|
+
background: true,
|
|
295
372
|
};
|
|
296
373
|
this.runs.set(runId, managed);
|
|
297
374
|
|
|
298
375
|
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e] as const));
|
|
299
376
|
this.emit("resumed", { runId });
|
|
300
377
|
// Run in the background; executeRun records status/errors on the managed run.
|
|
301
|
-
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => {});
|
|
378
|
+
void this.executeRun(managed, persisted.script, persisted.args, { resumeJournal }).catch(() => {});
|
|
302
379
|
return true;
|
|
303
380
|
}
|
|
304
381
|
|
package/src/workflow-tool.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
+
import { listAvailableModelSpecs } from "./agent.js";
|
|
4
5
|
import {
|
|
5
6
|
createToolUpdateWorkflowDisplay,
|
|
6
7
|
createWorkflowSnapshot,
|
|
7
|
-
preview,
|
|
8
8
|
recomputeWorkflowSnapshot,
|
|
9
9
|
renderWorkflowText,
|
|
10
10
|
type WorkflowSnapshot,
|
|
11
11
|
} from "./display.js";
|
|
12
12
|
import { WorkflowError, WorkflowErrorCode } from "./errors.js";
|
|
13
|
-
import { parseWorkflowScript,
|
|
13
|
+
import { parseWorkflowScript, type WorkflowRunResult } from "./workflow.js";
|
|
14
14
|
import { WorkflowManager } from "./workflow-manager.js";
|
|
15
15
|
import { createWorkflowStorage, type WorkflowStorage } from "./workflow-saved.js";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Per-agent model-routing policy handed to the workflow author (the model). It
|
|
19
|
+
* states the rule and lists the user's currently available models, then lets the
|
|
20
|
+
* author choose each agent's model via opts.model — no hardcoded family mapping.
|
|
21
|
+
*/
|
|
22
|
+
function modelRoutingGuideline(): string {
|
|
23
|
+
const available = listAvailableModelSpecs();
|
|
24
|
+
const list = available.length
|
|
25
|
+
? `The user's currently available models (route only to these) are: ${available.join(", ")}.`
|
|
26
|
+
: "Use models the user has configured.";
|
|
27
|
+
return [
|
|
28
|
+
"For workflow, decide each agent's model yourself via opts.model, following this policy:",
|
|
29
|
+
"If the user named a specific model, use exactly that.",
|
|
30
|
+
"Otherwise, for exploration/search/inventory/gathering agents, pick a model one tier BELOW the main model in the SAME family (e.g. Claude→Haiku, ChatGPT/GPT→a mini, DeepSeek→a lighter/flash variant), choosing the closest match from the available list.",
|
|
31
|
+
"For analysis/synthesis/judgment/decision/verification agents, omit opts.model so the agent runs on the main model.",
|
|
32
|
+
"Never route to a model that is not in the available list; if no suitable lighter sibling exists, omit opts.model (use the main model).",
|
|
33
|
+
list,
|
|
34
|
+
].join(" ");
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
const workflowToolSchema = Type.Object({
|
|
18
38
|
script: Type.String({
|
|
19
39
|
description: [
|
|
@@ -28,7 +48,8 @@ const workflowToolSchema = Type.Object({
|
|
|
28
48
|
),
|
|
29
49
|
background: Type.Optional(
|
|
30
50
|
Type.Boolean({
|
|
31
|
-
description:
|
|
51
|
+
description:
|
|
52
|
+
"Run the workflow in the background. Default: true — the tool returns immediately with a run ID, the turn ends so the user isn't blocked, and the result is delivered back into the conversation when it finishes. Set to false only when you need the result inline in this same turn (the call will block until the workflow completes).",
|
|
32
53
|
}),
|
|
33
54
|
),
|
|
34
55
|
maxAgents: Type.Optional(
|
|
@@ -62,8 +83,13 @@ export interface WorkflowToolOptions {
|
|
|
62
83
|
|
|
63
84
|
export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition<typeof workflowToolSchema, any> {
|
|
64
85
|
const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
|
|
65
|
-
const manager =
|
|
66
|
-
|
|
86
|
+
const manager =
|
|
87
|
+
options.manager ??
|
|
88
|
+
new WorkflowManager({
|
|
89
|
+
cwd: options.cwd,
|
|
90
|
+
concurrency: options.concurrency,
|
|
91
|
+
loadSavedWorkflow: (name: string) => storage.load(name)?.script,
|
|
92
|
+
});
|
|
67
93
|
|
|
68
94
|
return defineTool({
|
|
69
95
|
name: "workflow",
|
|
@@ -87,38 +113,35 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
87
113
|
"For workflow, failed agent(), parallel(), or pipeline() branches return null and log the failure unless the workflow is aborted. Check for nulls before synthesizing conclusions.",
|
|
88
114
|
"For workflow, include a final synthesis/assertion agent when combining multiple subagent results; return a compact JSON-serializable value with ok/verdict plus the important outputs.",
|
|
89
115
|
"For workflow, if agent() needs machine-readable output, pass a plain JSON Schema via opts.schema; agent() will return the validated object. Use JSON Schema syntax, not TypeScript or TypeBox constructors.",
|
|
116
|
+
modelRoutingGuideline(),
|
|
90
117
|
"For workflow, do not assume the parent assistant has repository code context inside subagents; include enough task context and relevant paths in each agent prompt.",
|
|
91
|
-
"For workflow,
|
|
118
|
+
"For workflow, runs are background by default: the tool returns immediately with a run ID, the turn ends so the user isn't blocked, and the result is delivered back into the conversation when the run finishes. Pass background: false only when you must use the result inline in this same turn (it will block).",
|
|
92
119
|
"For workflow, you may call `await workflow('saved-name', argsObject)` to run a saved workflow inline and use its result; nesting is one level deep only, and the global 16-concurrent / 1000-total caps hold across the nesting.",
|
|
93
120
|
],
|
|
94
121
|
parameters: workflowToolSchema,
|
|
95
122
|
prepareArguments(args) {
|
|
96
123
|
return normalizeWorkflowToolArgs(args);
|
|
97
124
|
},
|
|
98
|
-
async execute(_toolCallId, params, signal, onUpdate,
|
|
125
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
99
126
|
const script = normalizeWorkflowScript(params.script);
|
|
100
127
|
const parsed = parseWorkflowScript(script);
|
|
101
128
|
|
|
102
|
-
// Background execution
|
|
103
|
-
|
|
129
|
+
// Background execution is the default: return immediately so the turn ends
|
|
130
|
+
// and the user isn't blocked. The result is delivered back into the
|
|
131
|
+
// conversation when the run finishes (see installResultDelivery). Only an
|
|
132
|
+
// explicit `background: false` blocks for the result inline.
|
|
133
|
+
if (params.background ?? true) {
|
|
104
134
|
const { runId } = manager.startInBackground(script, params.args);
|
|
105
135
|
return {
|
|
106
|
-
content: [
|
|
107
|
-
{
|
|
108
|
-
type: "text",
|
|
109
|
-
text: [
|
|
110
|
-
`Workflow "${parsed.meta.name}" started in background.`,
|
|
111
|
-
`Run ID: ${runId}`,
|
|
112
|
-
`Use /workflows status ${runId} to check progress.`,
|
|
113
|
-
`Use /workflows stop ${runId} to cancel.`,
|
|
114
|
-
].join("\n"),
|
|
115
|
-
},
|
|
116
|
-
],
|
|
136
|
+
content: [{ type: "text", text: backgroundStartedText(parsed.meta.name, runId) }],
|
|
117
137
|
details: { runId, background: true },
|
|
118
138
|
};
|
|
119
139
|
}
|
|
120
140
|
|
|
121
|
-
// Synchronous execution (blocking)
|
|
141
|
+
// Synchronous execution (blocking) — but routed through the manager so the
|
|
142
|
+
// run shows up live in the /workflows navigator and the task panel while it
|
|
143
|
+
// runs, then stays in history afterwards. We still block on the result and
|
|
144
|
+
// return it inline, so the model gets the full output in the same turn.
|
|
122
145
|
let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta);
|
|
123
146
|
const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, {
|
|
124
147
|
key: "workflow",
|
|
@@ -128,55 +151,15 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
128
151
|
showResultPreviews: false,
|
|
129
152
|
});
|
|
130
153
|
|
|
131
|
-
const update = () => {
|
|
132
|
-
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
133
|
-
display.update(snapshot);
|
|
134
|
-
};
|
|
135
|
-
|
|
136
154
|
let result: WorkflowRunResult;
|
|
137
155
|
try {
|
|
138
|
-
result = await
|
|
139
|
-
cwd: options.cwd ?? ctx.cwd,
|
|
140
|
-
args: params.args,
|
|
141
|
-
signal,
|
|
142
|
-
concurrency: options.concurrency,
|
|
156
|
+
result = await manager.runSync(script, params.args, {
|
|
143
157
|
maxAgents: params.maxAgents,
|
|
144
158
|
agentTimeoutMs: params.agentTimeoutMs,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
snapshot
|
|
148
|
-
update();
|
|
149
|
-
},
|
|
150
|
-
onPhase(title) {
|
|
151
|
-
snapshot.currentPhase = title;
|
|
152
|
-
if (!snapshot.phases.includes(title)) snapshot.phases.push(title);
|
|
153
|
-
update();
|
|
154
|
-
},
|
|
155
|
-
onAgentStart(event) {
|
|
156
|
-
if (signal?.aborted) throw new Error("Workflow was aborted");
|
|
157
|
-
snapshot.agents.push({
|
|
158
|
-
id: snapshot.agents.length + 1,
|
|
159
|
-
label: event.label,
|
|
160
|
-
phase: event.phase,
|
|
161
|
-
prompt: event.prompt,
|
|
162
|
-
status: "running",
|
|
163
|
-
});
|
|
164
|
-
update();
|
|
165
|
-
},
|
|
166
|
-
onAgentEnd(event) {
|
|
167
|
-
const agent = [...snapshot.agents]
|
|
168
|
-
.reverse()
|
|
169
|
-
.find((item) => item.label === event.label && item.status === "running");
|
|
170
|
-
if (agent) {
|
|
171
|
-
agent.status = event.result === null ? "error" : "done";
|
|
172
|
-
agent.resultPreview = preview(event.result);
|
|
173
|
-
agent.tokens = event.tokens;
|
|
174
|
-
}
|
|
175
|
-
update();
|
|
176
|
-
},
|
|
177
|
-
onTokenUsage(usage) {
|
|
178
|
-
snapshot.tokenUsage = usage;
|
|
179
|
-
update();
|
|
159
|
+
externalSignal: signal,
|
|
160
|
+
onProgress(live) {
|
|
161
|
+
snapshot = recomputeWorkflowSnapshot(live);
|
|
162
|
+
display.update(snapshot);
|
|
180
163
|
},
|
|
181
164
|
});
|
|
182
165
|
} catch (error) {
|
|
@@ -245,6 +228,25 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
245
228
|
});
|
|
246
229
|
}
|
|
247
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The tool result returned when a workflow starts in the background. It both
|
|
233
|
+
* informs the model and tells it to reassure the user: the run continues on its
|
|
234
|
+
* own and the conversation will resume automatically when it finishes, so the
|
|
235
|
+
* user can just wait here (or go do something else).
|
|
236
|
+
*/
|
|
237
|
+
export function backgroundStartedText(name: string, runId: string): string {
|
|
238
|
+
return [
|
|
239
|
+
`Workflow "${name}" started in the background.`,
|
|
240
|
+
`Run ID: ${runId}`,
|
|
241
|
+
"It keeps running on its own. When it finishes, the result is delivered back",
|
|
242
|
+
"here and the conversation continues automatically — the user does not need to",
|
|
243
|
+
"do anything. Tell the user they can simply wait here for it to finish (it will",
|
|
244
|
+
"resume the conversation by itself), or keep chatting / working on other things",
|
|
245
|
+
"in the meantime; either way the result will come back to this conversation.",
|
|
246
|
+
`They can also track or cancel it with /workflows status ${runId} or /workflows stop ${runId}.`,
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
248
250
|
function normalizeWorkflowToolArgs(args: unknown): WorkflowToolInput {
|
|
249
251
|
if (!args || typeof args !== "object") throw new Error("workflow requires an object argument with a script string");
|
|
250
252
|
const value = args as Record<string, unknown>;
|
package/src/workflow-ui.ts
CHANGED
|
@@ -63,6 +63,14 @@ interface AgentRow {
|
|
|
63
63
|
status: string;
|
|
64
64
|
phase?: string;
|
|
65
65
|
tokens?: number;
|
|
66
|
+
model?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Short, human-friendly model label: drop the provider prefix for display. */
|
|
70
|
+
function shortModel(model: string | undefined): string | undefined {
|
|
71
|
+
if (!model) return undefined;
|
|
72
|
+
const slash = model.indexOf("/");
|
|
73
|
+
return slash > 0 ? model.slice(slash + 1) : model;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/** Reads run/phase/agent data from the manager, preferring live snapshots. */
|
|
@@ -127,7 +135,7 @@ export class NavigatorModel {
|
|
|
127
135
|
if (!snap) return [];
|
|
128
136
|
return snap.agents
|
|
129
137
|
.filter((a) => (a.phase ?? "(no phase)") === phase)
|
|
130
|
-
.map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens }));
|
|
138
|
+
.map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens, model: a.model }));
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
agentDetail(runId: string, agentId: number): WorkflowAgentSnapshot | undefined {
|
|
@@ -150,6 +158,7 @@ function persistedToSnapshot(p: PersistedRunState): WorkflowSnapshot {
|
|
|
150
158
|
resultPreview:
|
|
151
159
|
a.result == null ? undefined : String(typeof a.result === "string" ? a.result : JSON.stringify(a.result)),
|
|
152
160
|
error: a.error,
|
|
161
|
+
model: a.model,
|
|
153
162
|
})),
|
|
154
163
|
agentCount: p.agents.length,
|
|
155
164
|
runningCount: p.agents.filter((a) => a.status === "running").length,
|
|
@@ -293,8 +302,9 @@ export function renderNavigator(
|
|
|
293
302
|
lines.push(theme.bold(`${model.runName(state.runId)} › ${state.phase}`));
|
|
294
303
|
agents.forEach((a, i) => {
|
|
295
304
|
const icon = STATUS_ICON[a.status] ?? "?";
|
|
296
|
-
const
|
|
297
|
-
|
|
305
|
+
const mdl = shortModel(a.model);
|
|
306
|
+
const meta = [mdl, a.tokens ? fmtTokens(a.tokens) : undefined].filter(Boolean).join(" · ");
|
|
307
|
+
lines.push(sel(i, `${icon} ${a.label}${meta ? dim(` ${meta}`) : ""}`));
|
|
298
308
|
});
|
|
299
309
|
} else if (state.kind === "detail" && state.runId && state.agentId != null) {
|
|
300
310
|
const a = model.agentDetail(state.runId, state.agentId);
|
|
@@ -302,6 +312,7 @@ export function renderNavigator(
|
|
|
302
312
|
if (a) {
|
|
303
313
|
const body: string[] = [];
|
|
304
314
|
body.push(dim("Status: ") + (a.status ?? ""));
|
|
315
|
+
if (a.model) body.push(dim("Model: ") + (shortModel(a.model) ?? ""));
|
|
305
316
|
if (a.error) body.push(dim("Error: ") + a.error);
|
|
306
317
|
body.push("", dim("Prompt:"));
|
|
307
318
|
body.push(...wrap(a.prompt ?? "", width));
|
|
@@ -454,9 +465,20 @@ export function openWorkflowNavigator(
|
|
|
454
465
|
if (id) ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id}`, "info");
|
|
455
466
|
break;
|
|
456
467
|
}
|
|
457
|
-
case "restart":
|
|
458
|
-
|
|
468
|
+
case "restart": {
|
|
469
|
+
// Restart re-runs the whole workflow from scratch as a fresh
|
|
470
|
+
// background run (per-agent restart isn't meaningful — agents are
|
|
471
|
+
// driven by the script). The new run auto-delivers when it finishes.
|
|
472
|
+
const id = state.activeRunId(model);
|
|
473
|
+
const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
|
|
474
|
+
if (!run?.script) {
|
|
475
|
+
ui.notify(id ? `Cannot restart ${id} (no script saved)` : "No run selected to restart", "warning");
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
const { runId: newId } = manager.startInBackground(run.script, run.args);
|
|
479
|
+
ui.notify(`Restarted ${run.workflowName || "workflow"} as ${newId}`, "info");
|
|
459
480
|
break;
|
|
481
|
+
}
|
|
460
482
|
case "save": {
|
|
461
483
|
const id = state.activeRunId(model);
|
|
462
484
|
const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
|
package/src/workflow.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface SharedRuntime {
|
|
|
48
48
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
49
49
|
args?: unknown;
|
|
50
50
|
agent?: Pick<WorkflowAgent, "run">;
|
|
51
|
+
/** The session's main model (provider/id), shown in /workflows for default agents. */
|
|
52
|
+
mainModel?: string;
|
|
51
53
|
concurrency?: number;
|
|
52
54
|
tokenBudget?: number | null;
|
|
53
55
|
signal?: AbortSignal;
|
|
@@ -72,7 +74,14 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
72
74
|
onLog?: (message: string) => void;
|
|
73
75
|
onPhase?: (title: string) => void;
|
|
74
76
|
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
75
|
-
onAgentEnd?: (event: {
|
|
77
|
+
onAgentEnd?: (event: {
|
|
78
|
+
label: string;
|
|
79
|
+
phase?: string;
|
|
80
|
+
result: unknown;
|
|
81
|
+
tokens?: number;
|
|
82
|
+
worktree?: string;
|
|
83
|
+
model?: string;
|
|
84
|
+
}) => void;
|
|
76
85
|
onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
|
|
77
86
|
}
|
|
78
87
|
|
|
@@ -96,6 +105,12 @@ export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema |
|
|
|
96
105
|
label?: string;
|
|
97
106
|
phase?: string;
|
|
98
107
|
schema?: TSchemaDef;
|
|
108
|
+
/**
|
|
109
|
+
* Run this agent on a specific model (`provider/modelId` or a bare `modelId`).
|
|
110
|
+
* The workflow author chooses per-agent models per the routing policy in the
|
|
111
|
+
* tool guidelines (e.g. a lighter model for exploration, the main model for
|
|
112
|
+
* analysis). When omitted, the session's main model is used.
|
|
113
|
+
*/
|
|
99
114
|
model?: string;
|
|
100
115
|
isolation?: "worktree";
|
|
101
116
|
agentType?: string;
|
|
@@ -203,6 +218,10 @@ export async function runWorkflow<T = unknown>(
|
|
|
203
218
|
const requestedLabel = agentOptions.label?.trim();
|
|
204
219
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
205
220
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
221
|
+
// For display in /workflows: the model this agent runs on — its explicit/phase
|
|
222
|
+
// spec, else the session's main model. The real resolved id overrides this via
|
|
223
|
+
// onModelResolved once the subagent session is created.
|
|
224
|
+
let displayModel = modelSpec ?? options.mainModel;
|
|
206
225
|
|
|
207
226
|
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
208
227
|
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
@@ -215,8 +234,8 @@ export async function runWorkflow<T = unknown>(
|
|
|
215
234
|
if (cached && cached.hash === callHash) {
|
|
216
235
|
shared.agentCount++;
|
|
217
236
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
|
|
218
|
-
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model:
|
|
219
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
237
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: displayModel });
|
|
238
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0, model: displayModel });
|
|
220
239
|
return cached.result;
|
|
221
240
|
}
|
|
222
241
|
|
|
@@ -225,7 +244,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
225
244
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
|
|
226
245
|
const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
|
|
227
246
|
|
|
228
|
-
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model:
|
|
247
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: displayModel });
|
|
229
248
|
|
|
230
249
|
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
231
250
|
let worktree: Worktree | undefined;
|
|
@@ -262,6 +281,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
262
281
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
263
282
|
model: modelSpec,
|
|
264
283
|
cwd: runCwd,
|
|
284
|
+
onModelResolved: (id: string) => {
|
|
285
|
+
displayModel = id;
|
|
286
|
+
},
|
|
265
287
|
onUsage: (u: AgentUsage) => {
|
|
266
288
|
usage = u;
|
|
267
289
|
},
|
|
@@ -274,7 +296,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
274
296
|
|
|
275
297
|
const tokens = recordTokens(result);
|
|
276
298
|
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
277
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
|
|
299
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd, model: displayModel });
|
|
278
300
|
return result;
|
|
279
301
|
} catch (error) {
|
|
280
302
|
if (options.signal?.aborted) throw error;
|