@quintinshaw/pi-dynamic-workflows 1.9.0 → 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 +95 -65
- 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 +16 -3
- 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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Workflows mode" input affordance, à la a smart input box:
|
|
3
|
+
*
|
|
4
|
+
* - While the editor text contains the word `workflow`/`workflows`, those letters
|
|
5
|
+
* render as a flowing rainbow, signalling that submitting will engage a workflow.
|
|
6
|
+
* - Pressing Backspace immediately after such a word toggles the highlight OFF
|
|
7
|
+
* (the word stays, but turns plain white) — a non-destructive "don't run a
|
|
8
|
+
* workflow after all". Re-typing a fresh trigger word turns it back on.
|
|
9
|
+
* - When the highlight is ON at submit time, the user's message is transformed to
|
|
10
|
+
* instruct Pi to actually run the workflow tool.
|
|
11
|
+
*
|
|
12
|
+
* Implementation: we replace the core editor with a thin subclass of the exported
|
|
13
|
+
* `CustomEditor` (which itself extends pi-tui's `Editor`), overriding only
|
|
14
|
+
* `render()` (to colorize) and `handleInput()` (for the Backspace toggle). All
|
|
15
|
+
* other editor behavior — history, autocomplete, paste, undo, multiline — is
|
|
16
|
+
* inherited untouched.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
21
|
+
|
|
22
|
+
// A trigger is `workflow`/`workflows` (substring, case-insensitive) that is NOT
|
|
23
|
+
// immediately preceded by `/` — so a slash command like `/workflows` or `/workflow`
|
|
24
|
+
// is left alone (not colored, not armed).
|
|
25
|
+
/** Matches a trigger anywhere in the text. */
|
|
26
|
+
const TRIGGER = /(?<!\/)workflows?/i;
|
|
27
|
+
/** Global variant for finding every occurrence to colorize. */
|
|
28
|
+
const TRIGGER_G = /(?<!\/)workflows?/gi;
|
|
29
|
+
/** True when the text immediately before the cursor ends with a trigger word. */
|
|
30
|
+
const TRIGGER_AT_END = /(?<!\/)workflows?$/i;
|
|
31
|
+
|
|
32
|
+
/** 256-color ring cycling through the spectrum — shifted by a tick to "flow". */
|
|
33
|
+
export const RAINBOW = [
|
|
34
|
+
196, 160, 202, 166, 208, 172, 214, 178, 220, 184, 226, 190, 118, 82, 46, 47, 48, 49, 50, 51, 45, 39, 33, 27, 21, 57,
|
|
35
|
+
93, 129, 165, 201, 198, 197,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function hasTrigger(text: string): boolean {
|
|
39
|
+
return TRIGGER.test(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function endsWithTrigger(textBeforeCursor: string): boolean {
|
|
43
|
+
return TRIGGER_AT_END.test(textBeforeCursor);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shared, mutable view of whether "workflows mode" is currently armed. */
|
|
47
|
+
export interface WorkflowModeState {
|
|
48
|
+
active: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface AnsiToken {
|
|
52
|
+
esc?: string;
|
|
53
|
+
ch?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Split a rendered line into ANSI-escape tokens (passed through verbatim) and
|
|
58
|
+
* single visible-character tokens. Handles CSI sequences (`\x1b[…m`, e.g. the
|
|
59
|
+
* cursor's inverse-video) and APC/OSC string sequences (e.g. the zero-width
|
|
60
|
+
* `CURSOR_MARKER` = `\x1b_pi:c\x07`) so colorization never corrupts them.
|
|
61
|
+
*/
|
|
62
|
+
export function tokenizeAnsi(line: string): AnsiToken[] {
|
|
63
|
+
const tokens: AnsiToken[] = [];
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < line.length) {
|
|
66
|
+
if (line[i] === "\x1b") {
|
|
67
|
+
let j = i + 1;
|
|
68
|
+
const next = line[j];
|
|
69
|
+
if (next === "[") {
|
|
70
|
+
// CSI: ends at a final byte in 0x40–0x7e.
|
|
71
|
+
j++;
|
|
72
|
+
while (j < line.length && !(line[j] >= "@" && line[j] <= "~")) j++;
|
|
73
|
+
j++;
|
|
74
|
+
} else if (next === "]" || next === "_" || next === "P" || next === "^") {
|
|
75
|
+
// String sequence: ends at BEL (\x07) or ST (\x1b\\).
|
|
76
|
+
j++;
|
|
77
|
+
while (j < line.length && line[j] !== "\x07" && !(line[j] === "\x1b" && line[j + 1] === "\\")) j++;
|
|
78
|
+
if (line[j] === "\x07") j++;
|
|
79
|
+
else if (line[j] === "\x1b") j += 2;
|
|
80
|
+
} else {
|
|
81
|
+
j++; // lone ESC + one byte
|
|
82
|
+
}
|
|
83
|
+
tokens.push({ esc: line.slice(i, j) });
|
|
84
|
+
i = j;
|
|
85
|
+
} else {
|
|
86
|
+
tokens.push({ ch: line[i] });
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return tokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Colorize every `workflow`/`workflows` occurrence in a rendered line with a
|
|
95
|
+
* flowing rainbow, leaving all ANSI escapes (cursor, markers) intact. Returns the
|
|
96
|
+
* line unchanged when it contains no trigger.
|
|
97
|
+
*/
|
|
98
|
+
export function colorizeWorkflow(line: string, tick: number, palette: number[] = RAINBOW): string {
|
|
99
|
+
const tokens = tokenizeAnsi(line);
|
|
100
|
+
const visible = tokens
|
|
101
|
+
.filter((t) => t.ch !== undefined)
|
|
102
|
+
.map((t) => t.ch)
|
|
103
|
+
.join("");
|
|
104
|
+
if (!TRIGGER.test(visible)) return line;
|
|
105
|
+
|
|
106
|
+
const ranges: Array<[number, number]> = [];
|
|
107
|
+
TRIGGER_G.lastIndex = 0;
|
|
108
|
+
for (let m = TRIGGER_G.exec(visible); m; m = TRIGGER_G.exec(visible)) {
|
|
109
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
110
|
+
}
|
|
111
|
+
const inRange = (idx: number) => ranges.some(([s, e]) => idx >= s && idx < e);
|
|
112
|
+
|
|
113
|
+
let out = "";
|
|
114
|
+
let vi = 0;
|
|
115
|
+
for (const t of tokens) {
|
|
116
|
+
if (t.esc !== undefined) {
|
|
117
|
+
out += t.esc;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (inRange(vi)) {
|
|
121
|
+
const color = palette[(vi + tick) % palette.length];
|
|
122
|
+
// Reset only the foreground (39) afterwards so a surrounding inverse-video
|
|
123
|
+
// (the cursor) is preserved.
|
|
124
|
+
out += `\x1b[38;5;${color}m${t.ch}\x1b[39m`;
|
|
125
|
+
} else {
|
|
126
|
+
out += t.ch ?? "";
|
|
127
|
+
}
|
|
128
|
+
vi++;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Backspace arrives as DEL (0x7f) or BS (0x08) depending on the terminal. */
|
|
134
|
+
function isBackspace(data: string): boolean {
|
|
135
|
+
return data === "\x7f" || data === "\b";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Editor that paints the trigger words and owns the on/off toggle. Reads/writes
|
|
140
|
+
* `state.active` so the extension's `input` handler can decide whether to force a
|
|
141
|
+
* workflow at submit time.
|
|
142
|
+
*/
|
|
143
|
+
export class WorkflowEditor extends CustomEditor {
|
|
144
|
+
private tick = 0;
|
|
145
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
146
|
+
/** Toggled off by Backspace-after-word; re-armed when a fresh trigger appears. */
|
|
147
|
+
private disabled = false;
|
|
148
|
+
private wasTriggered = false;
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
tui: TUI,
|
|
152
|
+
theme: EditorTheme,
|
|
153
|
+
keybindings: ConstructorParameters<typeof CustomEditor>[2],
|
|
154
|
+
private readonly modeState: WorkflowModeState,
|
|
155
|
+
) {
|
|
156
|
+
super(tui, theme, keybindings);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Highlighted/armed: a trigger is present and the user hasn't toggled it off. */
|
|
160
|
+
isActive(): boolean {
|
|
161
|
+
return !this.disabled && hasTrigger(this.getText());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override handleInput(data: string): void {
|
|
165
|
+
// First Backspace right after a trigger word disarms (non-destructive).
|
|
166
|
+
if (isBackspace(data) && this.isActive() && this.cursorAfterTrigger()) {
|
|
167
|
+
this.disabled = true;
|
|
168
|
+
this.syncState();
|
|
169
|
+
this.tui.requestRender();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const before = this.getText();
|
|
173
|
+
super.handleInput(data);
|
|
174
|
+
const after = this.getText();
|
|
175
|
+
if (after !== before) {
|
|
176
|
+
const now = hasTrigger(after);
|
|
177
|
+
// A freshly typed trigger re-arms a previously disabled box.
|
|
178
|
+
if (now && !this.wasTriggered) this.disabled = false;
|
|
179
|
+
this.wasTriggered = now;
|
|
180
|
+
}
|
|
181
|
+
this.syncState();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override render(width: number): string[] {
|
|
185
|
+
const lines = super.render(width);
|
|
186
|
+
// Keep the shared state current even for non-keystroke changes (history
|
|
187
|
+
// recall, programmatic setText) so the submit hook reads the right value.
|
|
188
|
+
this.syncState();
|
|
189
|
+
this.reconcileAnimation();
|
|
190
|
+
if (!this.isActive() || lines.length === 0) return lines;
|
|
191
|
+
// First and last lines are the editor's horizontal borders; only the text
|
|
192
|
+
// lines in between are colorized.
|
|
193
|
+
return lines.map((ln, i) => (i === 0 || i === lines.length - 1 ? ln : colorizeWorkflow(ln, this.tick)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Absolute text before the cursor, used to detect "right after the word". */
|
|
197
|
+
private cursorAfterTrigger(): boolean {
|
|
198
|
+
const lines = this.getLines();
|
|
199
|
+
const { line, col } = this.getCursor();
|
|
200
|
+
const before = lines.slice(0, line).join("\n") + (line > 0 ? "\n" : "") + (lines[line] ?? "").slice(0, col);
|
|
201
|
+
return endsWithTrigger(before);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private syncState(): void {
|
|
205
|
+
this.modeState.active = this.isActive();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private reconcileAnimation(): void {
|
|
209
|
+
const shouldRun = this.isActive() && this.focused;
|
|
210
|
+
if (shouldRun && !this.timer) {
|
|
211
|
+
this.timer = setInterval(() => {
|
|
212
|
+
this.tick = (this.tick + 1) % (RAINBOW.length * 6);
|
|
213
|
+
this.tui.requestRender();
|
|
214
|
+
}, 90);
|
|
215
|
+
// Don't keep the process alive for the animation.
|
|
216
|
+
(this.timer as { unref?: () => void }).unref?.();
|
|
217
|
+
} else if (!shouldRun && this.timer) {
|
|
218
|
+
clearInterval(this.timer);
|
|
219
|
+
this.timer = undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The directive appended to a submitted message when workflows mode is armed. */
|
|
225
|
+
export function buildForcedWorkflowPrompt(text: string): string {
|
|
226
|
+
return [
|
|
227
|
+
text,
|
|
228
|
+
"",
|
|
229
|
+
"[workflows mode] The user armed workflows mode for this message. Handle it by",
|
|
230
|
+
"running the `workflow` tool: decompose the request and orchestrate subagents",
|
|
231
|
+
"with a workflow script, rather than answering directly.",
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Install the workflows-mode editor and the submit-time forcing hook.
|
|
237
|
+
* Call once with the UI context (e.g. in `session_start`).
|
|
238
|
+
*/
|
|
239
|
+
export function installWorkflowEditor(pi: ExtensionAPI, ui: ExtensionUIContext): WorkflowModeState {
|
|
240
|
+
const state: WorkflowModeState = { active: false };
|
|
241
|
+
|
|
242
|
+
ui.setEditorComponent((tui, theme, keybindings) => new WorkflowEditor(tui, theme, keybindings, state));
|
|
243
|
+
|
|
244
|
+
// When armed at submit time, rewrite the user's message to force a workflow.
|
|
245
|
+
pi.on("input", (event: { source?: string; text?: string }) => {
|
|
246
|
+
if (event.source !== "interactive" || !state.active || !event.text) return { action: "continue" } as const;
|
|
247
|
+
state.active = false; // consume the arm for this submission
|
|
248
|
+
return { action: "transform", text: buildForcedWorkflowPrompt(event.text) } as const;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return state;
|
|
252
|
+
}
|
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>;
|