pi-workflow-engine 0.3.0 → 0.5.0

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.
Files changed (39) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +369 -0
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  3. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  4. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  5. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  6. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +83 -0
  7. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  10. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  11. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  15. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  16. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
  17. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  19. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  20. package/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +8 -2
  21. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
  23. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
  24. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
  25. package/.pi/settings.json +3 -0
  26. package/README.md +101 -27
  27. package/USAGE.md +183 -0
  28. package/assets/preview-candidate-core.png +0 -0
  29. package/assets/preview-candidate-relic-emblem.png +0 -0
  30. package/assets/preview-candidate-robot-squad.png +0 -0
  31. package/assets/preview.png +0 -0
  32. package/package.json +33 -6
  33. package/src/agent-runner.ts +0 -122
  34. package/src/concurrency.ts +0 -55
  35. package/src/discovery.ts +0 -54
  36. package/src/index.ts +0 -141
  37. package/src/ui/workflow-widget.ts +0 -143
  38. package/workflows/ping.ts +0 -28
  39. /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
@@ -0,0 +1,369 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { Type } from "typebox";
3
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
6
+ import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
7
+ import { registerDynamax } from "./src/dynamax.ts";
8
+ import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
9
+
10
+ /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
11
+ const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url));
12
+
13
+ function summarize(result: unknown): string {
14
+ if (result && typeof result === "object" && typeof (result as { summary?: unknown }).summary === "string") {
15
+ return (result as { summary: string }).summary;
16
+ }
17
+ return typeof result === "string" ? result : "Workflow finished.";
18
+ }
19
+
20
+ function formatMessageContent(name: string, result: unknown, perf?: WorkflowPerfDetails): string {
21
+ const perfLine = formatPerfLine(perf);
22
+ return `## Workflow: ${name}\n\n${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}`;
23
+ }
24
+
25
+ function workflowEnvelope(name: string, result: unknown, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
26
+ return { name, result, completedAt: Date.now(), perf };
27
+ }
28
+
29
+ function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
30
+ if (!snapshot?.enabled) return undefined;
31
+ return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
32
+ }
33
+
34
+ function formatPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
35
+ if (!perf) return undefined;
36
+ const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
37
+ return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
38
+ }
39
+
40
+ type DiscoveryModule = typeof import("./src/discovery.ts");
41
+ type EngineModule = typeof import("./src/engine.ts");
42
+ type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
43
+
44
+ async function loadDiscovery(): Promise<DiscoveryModule> {
45
+ return await import("./src/discovery.ts");
46
+ }
47
+
48
+ async function loadEngine(): Promise<EngineModule> {
49
+ return await import("./src/engine.ts");
50
+ }
51
+
52
+ async function loadInlineWorkflow(): Promise<InlineWorkflowModule> {
53
+ return await import("./src/inline-workflow.ts");
54
+ }
55
+
56
+ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSink | undefined> {
57
+ const enabled = options.perf ?? process.env.PI_WORKFLOW_PERF === "1";
58
+ if (!enabled) return undefined;
59
+ const { createPerfRecorder } = await import("./src/perf.ts");
60
+ return createPerfRecorder(true);
61
+ }
62
+
63
+ const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
64
+
65
+ export interface WorkflowInvocation {
66
+ name: string;
67
+ args: string;
68
+ options: WorkflowRunOptions;
69
+ refreshDiscovery?: boolean;
70
+ authorBrief?: string;
71
+ }
72
+
73
+ export function parseWorkflowInvocation(input: string): WorkflowInvocation {
74
+ const trimmed = input.trim();
75
+ const space = trimmed.indexOf(" ");
76
+ const name = space === -1 ? trimmed : trimmed.slice(0, space);
77
+ const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
78
+ const { args, options, refreshDiscovery } = parseWorkflowOptions(rest);
79
+ return { name, args, options, refreshDiscovery };
80
+ }
81
+
82
+ function parseWorkflowOptions(input: string): { args: string; options: WorkflowRunOptions; refreshDiscovery?: boolean } {
83
+ const tokens = input.split(/\s+/).filter(Boolean);
84
+ const kept: string[] = [];
85
+ const options: WorkflowRunOptions = {};
86
+ let refreshDiscovery = false;
87
+ for (let i = 0; i < tokens.length; i++) {
88
+ const token = tokens[i];
89
+ if (token === "--inspect") {
90
+ options.inspect = true;
91
+ continue;
92
+ }
93
+ if (token === "--refresh") {
94
+ refreshDiscovery = true;
95
+ continue;
96
+ }
97
+ if (token === "--perf") {
98
+ options.perf = true;
99
+ continue;
100
+ }
101
+ if (token.startsWith("--concurrency=")) {
102
+ options.concurrency = parseNumericOption(token.slice("--concurrency=".length));
103
+ continue;
104
+ }
105
+ if (token === "--concurrency") {
106
+ const next = tokens[i + 1];
107
+ options.concurrency = parseNumericOption(next);
108
+ if (next !== undefined) i++;
109
+ continue;
110
+ }
111
+ if (token.startsWith("--parallel-limit=")) {
112
+ options.parallelSubmissionLimit = parseNumericOption(token.slice("--parallel-limit=".length));
113
+ continue;
114
+ }
115
+ if (token === "--parallel-limit") {
116
+ const next = tokens[i + 1];
117
+ options.parallelSubmissionLimit = parseNumericOption(next);
118
+ if (next !== undefined) i++;
119
+ continue;
120
+ }
121
+ kept.push(token);
122
+ }
123
+ return { args: kept.join(" ").trim(), options, refreshDiscovery: refreshDiscovery || undefined };
124
+ }
125
+
126
+ function parseNumericOption(value: string | undefined): number | undefined {
127
+ if (value === undefined) return undefined;
128
+ const parsed = Number(value);
129
+ return Number.isFinite(parsed) ? parsed : undefined;
130
+ }
131
+
132
+ function compactInlinePreview(script: string | undefined): string {
133
+ if (!script) return "";
134
+ const compact = script.replace(/\s+/g, " ").trim();
135
+ return compact.length > 60 ? `${compact.slice(0, 57)}…` : compact;
136
+ }
137
+
138
+ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
139
+ return `dynamax author and run a temporary one-shot inline workflow.
140
+
141
+ User brief:
142
+ ${brief.trim()}
143
+
144
+ Use the workflow tool with a script argument, not a saved workflow name.
145
+ The script must start with export const meta = { ... } and default-export an async workflow function.
146
+ Use the injected Type object for schemas. Do not import anything or use dynamic import().
147
+ Set thinkingLevel explicitly on each agent() call.
148
+ Do not edit files unless the user explicitly requested edits.`;
149
+ }
150
+
151
+ export interface WorkflowToolRequestParams {
152
+ readonly name?: string;
153
+ readonly script?: string;
154
+ }
155
+
156
+ export type WorkflowToolRequest =
157
+ | { readonly kind: "named"; readonly name: string }
158
+ | { readonly kind: "inline"; readonly script: string }
159
+ | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
160
+
161
+ export interface WorkflowToolErrorResult {
162
+ readonly content: Array<{ readonly type: "text"; readonly text: string }>;
163
+ readonly details: { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
164
+ }
165
+
166
+ const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script.";
167
+
168
+ export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
169
+ const name = params.name?.trim() ?? "";
170
+ const script = params.script?.trim() ?? "";
171
+ const hasName = name.length > 0;
172
+ const hasScript = script.length > 0;
173
+ if (hasName === hasScript) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
174
+ return hasName ? { kind: "named", name } : { kind: "inline", script };
175
+ }
176
+
177
+ export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
178
+ return { content: [{ type: "text", text: INVALID_WORKFLOW_INVOCATION_MESSAGE }], details: { error: "invalid_workflow_invocation" } };
179
+ }
180
+
181
+ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult {
182
+ return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
183
+ }
184
+
185
+ async function pickWorkflow(
186
+ workflows: ReadonlyMap<string, WorkflowModule>,
187
+ ctx: ExtensionCommandContext,
188
+ ): Promise<WorkflowInvocation | undefined> {
189
+ const choices = [AUTHOR_TEMP_WORKFLOW_CHOICE, ...[...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`)];
190
+ const choice = await ctx.ui.select("Run workflow", choices);
191
+ if (!choice) return undefined;
192
+
193
+ if (choice === AUTHOR_TEMP_WORKFLOW_CHOICE) {
194
+ const brief = await ctx.ui.editor(
195
+ "Describe temporary workflow",
196
+ "Goal:\n\nAgents to run:\n- \n\nFinal output should include:\n- summary\n- findings\n- next steps\n",
197
+ );
198
+ const trimmed = brief?.trim();
199
+ if (!trimmed) return undefined;
200
+ return { name: "", args: "", options: {}, authorBrief: trimmed };
201
+ }
202
+
203
+ const separator = choice.indexOf(" — ");
204
+ const name = separator === -1 ? choice : choice.slice(0, separator);
205
+ const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
206
+ const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
207
+ return { name, args, options: { inspect } };
208
+ }
209
+
210
+ async function sendWorkflowResult(
211
+ pi: ExtensionAPI,
212
+ ctx: ExtensionCommandContext,
213
+ name: string,
214
+ mod: WorkflowModule,
215
+ args: string,
216
+ options: WorkflowRunOptions,
217
+ perfRecorder?: PerfSink,
218
+ ): Promise<void> {
219
+ const { runWorkflow } = await loadEngine();
220
+ let perfSnapshot: PerfSnapshot | undefined;
221
+ const result = await runWorkflow(ctx, mod, args, {
222
+ ...options,
223
+ perf: options.perf ?? perfRecorder !== undefined,
224
+ perfRecorder,
225
+ onPerfSnapshot(snapshot) {
226
+ perfSnapshot = snapshot;
227
+ options.onPerfSnapshot?.(snapshot);
228
+ },
229
+ });
230
+ const perf = compactPerfSnapshot(perfSnapshot);
231
+ pi.sendMessage(
232
+ { customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
233
+ { triggerTurn: false },
234
+ );
235
+ }
236
+
237
+ export default function workflowEngine(pi: ExtensionAPI): void {
238
+ registerDynamax(pi);
239
+
240
+ pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
241
+ const details = message.details;
242
+ if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
243
+ return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
244
+ });
245
+
246
+ // /workflow <name> [args] — user-invoked.
247
+ pi.registerCommand("workflow", {
248
+ description: "Run a multi-agent workflow: /workflow <name> [args]",
249
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
250
+ const direct = parseWorkflowInvocation(args);
251
+ const perfRecorder = await createInvocationPerf(direct.options);
252
+ const { discoverWorkflows } = await loadDiscovery();
253
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { refresh: direct.refreshDiscovery, perf: perfRecorder });
254
+ const available = [...workflows.keys()].join(", ") || "(none)";
255
+ const invocation = direct.name ? direct : ctx.hasUI ? await pickWorkflow(workflows, ctx) : undefined;
256
+
257
+ if (!invocation) {
258
+ ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
259
+ return;
260
+ }
261
+
262
+ if (invocation.authorBrief) {
263
+ pi.sendUserMessage(buildTemporaryWorkflowAuthorPrompt(invocation.authorBrief));
264
+ return;
265
+ }
266
+
267
+ const mod = workflows.get(invocation.name);
268
+ if (!mod) {
269
+ ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
270
+ return;
271
+ }
272
+
273
+ await sendWorkflowResult(pi, ctx, invocation.name, mod, invocation.args, invocation.options, perfRecorder);
274
+ },
275
+ });
276
+
277
+ // workflow tool — lets the host agent fan out mid-conversation.
278
+ pi.registerTool({
279
+ name: "workflow",
280
+ label: "Workflow",
281
+ description:
282
+ "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
283
+ promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
284
+ promptGuidelines: [
285
+ "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
286
+ "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
287
+ "Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
288
+ "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
289
+ "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
290
+ ],
291
+ parameters: Type.Object({
292
+ name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
293
+ script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
294
+ args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
295
+ concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
296
+ parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
297
+ perf: Type.Optional(Type.Boolean({ description: "Include workflow performance timing aggregates in the result details" })),
298
+ }),
299
+ renderCall(args, theme) {
300
+ const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
301
+ if (args.name?.trim()) {
302
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${suffix}`, 0, 0);
303
+ }
304
+ const preview = compactInlinePreview(args.script);
305
+ const previewSuffix = preview ? ` ${theme.fg("dim", preview)}` : "";
306
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${suffix}${previewSuffix}`, 0, 0);
307
+ },
308
+ renderResult(result, { expanded, isPartial }, theme) {
309
+ if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
310
+ const details = result.details;
311
+ if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
312
+ const first = result.content[0];
313
+ const text = first?.type === "text" ? first.text : "Workflow finished.";
314
+ return new Text(theme.fg("muted", text), 0, 0);
315
+ },
316
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
317
+ const request = normalizeWorkflowToolRequest(params);
318
+ if (request.kind === "error") return invalidWorkflowInvocationResult();
319
+
320
+ const runOptions: WorkflowRunOptions = {
321
+ concurrency: params.concurrency,
322
+ parallelSubmissionLimit: params.parallelSubmissionLimit,
323
+ perf: params.perf,
324
+ signal,
325
+ };
326
+ const perfRecorder = await createInvocationPerf(runOptions);
327
+ let mod: WorkflowModule;
328
+ let resultName: string;
329
+
330
+ if (request.kind === "named") {
331
+ const { discoverWorkflows } = await loadDiscovery();
332
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
333
+ const named = workflows.get(request.name);
334
+ if (!named) {
335
+ const available = [...workflows.keys()].join(", ") || "(none)";
336
+ return {
337
+ content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }],
338
+ details: { error: "unknown_workflow", available },
339
+ };
340
+ }
341
+ mod = named;
342
+ resultName = request.name;
343
+ } else {
344
+ const inline = await loadInlineWorkflow();
345
+ try {
346
+ mod = inline.compileInlineWorkflow(request.script);
347
+ } catch (error) {
348
+ if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
349
+ throw error;
350
+ }
351
+ resultName = mod.meta.name;
352
+ }
353
+
354
+ const { runWorkflow } = await loadEngine();
355
+ let perfSnapshot: PerfSnapshot | undefined;
356
+ const result = await runWorkflow(ctx, mod, params.args ?? "", {
357
+ ...runOptions,
358
+ perf: runOptions.perf ?? perfRecorder !== undefined,
359
+ perfRecorder,
360
+ onPerfSnapshot: (snapshot) => {
361
+ perfSnapshot = snapshot;
362
+ },
363
+ });
364
+ const perf = compactPerfSnapshot(perfSnapshot);
365
+ const perfLine = formatPerfLine(perf);
366
+ return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(resultName, result, perf) };
367
+ },
368
+ });
369
+ }
@@ -0,0 +1,238 @@
1
+ import { createAgentSession, defineTool, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import type { CreateAgentSessionOptions, ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import type { Api, Model } from "@earendil-works/pi-ai";
4
+ import type { AgentOptions } from "./types.ts";
5
+ import type { Semaphore } from "./concurrency.ts";
6
+ import type { PerfSink } from "./perf.ts";
7
+ import { throwIfAborted } from "./cancellation.ts";
8
+
9
+ /** Name of the synthetic terminating tool that carries structured output. */
10
+ const FINAL_TOOL = "final_answer";
11
+
12
+ export interface AgentRunnerEvent {
13
+ readonly type: string;
14
+ readonly toolName?: string;
15
+ }
16
+
17
+ export interface AgentRunnerSession {
18
+ readonly state: { readonly messages: readonly unknown[] };
19
+ prompt(text: string): Promise<void>;
20
+ subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
21
+ dispose(): void;
22
+ abort(): Promise<void>;
23
+ }
24
+
25
+ export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
26
+
27
+ export interface AgentProgress {
28
+ agentQueued(phase: string | undefined, label: string): number;
29
+ agentStart(phase: string | undefined, label: string, id?: number): void;
30
+ agentTool(label: string, tool: string, id?: number): void;
31
+ agentDone(label: string, id?: number): void;
32
+ agentFailed(label: string, error: unknown, id?: number): void;
33
+ log(message: string): void;
34
+ }
35
+
36
+ /** Shared per-run context threaded into every agent() call. */
37
+ export interface RunContext {
38
+ cwd: string;
39
+ hostModel: Model<Api> | undefined;
40
+ modelRegistry: Pick<ModelRegistry, "find">;
41
+ semaphore: Semaphore;
42
+ progress: AgentProgress;
43
+ signal: AbortSignal | undefined;
44
+ perf: PerfSink;
45
+ createSession?: CreateAgentSession;
46
+ }
47
+
48
+ /** Pull the last assistant message's plain text out of a finished session. */
49
+ function lastAssistantText(state: { readonly messages: readonly unknown[] }): string {
50
+ const messages = state.messages;
51
+ for (let i = messages.length - 1; i >= 0; i--) {
52
+ const message = messages[i];
53
+ if (hasRole(message, "assistant")) {
54
+ return messageTextContent(message).trim();
55
+ }
56
+ }
57
+ return "";
58
+ }
59
+
60
+ function hasRole(value: unknown, role: string): value is { role: string } {
61
+ return typeof value === "object" && value !== null && "role" in value && value.role === role;
62
+ }
63
+
64
+ function messageTextContent(message: unknown): string {
65
+ if (!hasContentArray(message)) return "";
66
+ return message.content
67
+ .filter(isTextPart)
68
+ .map((part) => part.text)
69
+ .join("");
70
+ }
71
+
72
+ function hasContentArray(value: unknown): value is { content: unknown[] } {
73
+ return typeof value === "object" && value !== null && "content" in value && Array.isArray(value.content);
74
+ }
75
+
76
+ function isTextPart(value: unknown): value is { type: "text"; text: string } {
77
+ return typeof value === "object" && value !== null && "type" in value && value.type === "text" && "text" in value && typeof value.text === "string";
78
+ }
79
+
80
+ async function defaultCreateSession(options: CreateAgentSessionOptions): Promise<{ session: AgentRunnerSession }> {
81
+ const { session } = await createAgentSession(options);
82
+ return { session };
83
+ }
84
+
85
+ function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerSession): () => void {
86
+ if (!signal) return () => {};
87
+ if (signal.aborted) {
88
+ void session.abort();
89
+ return () => {};
90
+ }
91
+ const onAbort = () => {
92
+ void session.abort();
93
+ };
94
+ signal.addEventListener("abort", onAbort, { once: true });
95
+ return () => signal.removeEventListener("abort", onAbort);
96
+ }
97
+
98
+ /**
99
+ * Run one subagent to completion in an isolated in-memory session.
100
+ *
101
+ * Structured output trick: when `opts.schema` is set we register a single
102
+ * terminating tool whose `parameters` IS the schema. The agent's last action is to
103
+ * call it; pi validates the args against the schema, hands them to `execute`, and
104
+ * `terminate: true` ends the turn with no extra LLM round-trip. We capture those
105
+ * validated args in a closure — that captured object is the structured result, so
106
+ * no event-stream parsing is needed.
107
+ */
108
+ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
109
+ const label = opts.label ?? "agent";
110
+ const phase = opts.phase ?? "Workflow";
111
+ const tags = { label, phase };
112
+
113
+ return await rc.perf.time("agent.total_ms", async () => {
114
+ throwIfAborted(rc.signal);
115
+ // Track queued agents before acquiring a global concurrency slot.
116
+ const rowId = rc.progress.agentQueued(opts.phase, label);
117
+ let failureHandled = false;
118
+ try {
119
+ return await rc.semaphore.run(
120
+ async () => {
121
+ throwIfAborted(rc.signal);
122
+ rc.progress.agentStart(opts.phase, label, rowId);
123
+
124
+ let captured: unknown = null;
125
+ let failed = false;
126
+ let session: AgentRunnerSession | undefined;
127
+ let unsubscribe: (() => void) | undefined;
128
+ const customTools: ToolDefinition[] = opts.schema
129
+ ? [
130
+ defineTool({
131
+ name: FINAL_TOOL,
132
+ label: "Final Answer",
133
+ description:
134
+ "Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.",
135
+ parameters: opts.schema,
136
+ async execute(_toolCallId, params) {
137
+ captured = params;
138
+ return { content: [{ type: "text", text: "Recorded." }], details: params, terminate: true };
139
+ },
140
+ }),
141
+ ]
142
+ : [];
143
+
144
+ try {
145
+ // When the workflow restricts built-in tools, keep the terminating tool visible.
146
+ const allow = opts.tools
147
+ ? opts.schema
148
+ ? [...opts.tools, FINAL_TOOL]
149
+ : opts.tools
150
+ : undefined;
151
+
152
+ // Resolve the model by runtime id lookup (no compile-time model-id union); fall back to the host's model.
153
+ const model = opts.model ? rc.modelRegistry.find("anthropic", opts.model) ?? rc.hostModel : rc.hostModel;
154
+ const createSessionForRun = rc.createSession ?? defaultCreateSession;
155
+
156
+ throwIfAborted(rc.signal);
157
+ const created = await rc.perf.time(
158
+ "agent.create_session_ms",
159
+ () =>
160
+ createSessionForRun({
161
+ cwd: rc.cwd,
162
+ model,
163
+ modelRegistry: rc.modelRegistry as ModelRegistry,
164
+ thinkingLevel: opts.thinkingLevel,
165
+ tools: allow,
166
+ customTools,
167
+ sessionManager: SessionManager.inMemory(rc.cwd),
168
+ }),
169
+ tags,
170
+ );
171
+ session = created.session;
172
+ const activeSession = session;
173
+ throwIfAborted(rc.signal);
174
+
175
+ unsubscribe = activeSession.subscribe((event) => {
176
+ if (event.type === "tool_execution_start" && event.toolName !== undefined && event.toolName !== FINAL_TOOL) {
177
+ rc.progress.agentTool(label, event.toolName, rowId);
178
+ }
179
+ });
180
+
181
+ const finalPrompt = opts.schema
182
+ ? `${prompt}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
183
+ : prompt;
184
+ throwIfAborted(rc.signal);
185
+ const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
186
+ try {
187
+ await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(finalPrompt), tags);
188
+ } finally {
189
+ unlinkPromptAbort();
190
+ }
191
+ throwIfAborted(rc.signal);
192
+
193
+ return rc.perf.timeSync(
194
+ "agent.extract_result_ms",
195
+ () => {
196
+ if (opts.schema) {
197
+ if (captured === null) {
198
+ rc.progress.log(`${label}: no structured answer returned`);
199
+ rc.perf.counter("agent.structured_missing", 1, tags);
200
+ }
201
+ return captured;
202
+ }
203
+ return lastAssistantText(activeSession.state);
204
+ },
205
+ tags,
206
+ );
207
+ } catch (error) {
208
+ failed = true;
209
+ failureHandled = true;
210
+ rc.progress.agentFailed(label, error, rowId);
211
+ rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
212
+ throw error;
213
+ } finally {
214
+ const disposable = session;
215
+ if (disposable) {
216
+ rc.perf.timeSync(
217
+ "agent.dispose_ms",
218
+ () => {
219
+ unsubscribe?.();
220
+ disposable.dispose();
221
+ },
222
+ tags,
223
+ );
224
+ }
225
+ if (!failed) rc.progress.agentDone(label, rowId);
226
+ }
227
+ },
228
+ { onQueueWaitMs: (durationMs) => rc.perf.observe("agent.queue_wait_ms", durationMs, tags), signal: rc.signal },
229
+ );
230
+ } catch (error) {
231
+ if (!failureHandled) {
232
+ rc.progress.agentFailed(label, error, rowId);
233
+ rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
234
+ }
235
+ throw error;
236
+ }
237
+ }, tags);
238
+ }
@@ -0,0 +1,28 @@
1
+ export class WorkflowAbortError extends Error {
2
+ constructor(message = "Workflow aborted") {
3
+ super(message);
4
+ this.name = "WorkflowAbortError";
5
+ }
6
+ }
7
+
8
+ export function abortReason(signal: AbortSignal | undefined): Error {
9
+ const reason = signal?.reason;
10
+ if (reason instanceof Error) return reason;
11
+ if (typeof reason === "string" && reason.length > 0) return new WorkflowAbortError(reason);
12
+ return new WorkflowAbortError();
13
+ }
14
+
15
+ export function throwIfAborted(signal: AbortSignal | undefined): void {
16
+ if (signal?.aborted) throw abortReason(signal);
17
+ }
18
+
19
+ export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
20
+ if (!parent) return () => {};
21
+ if (parent.aborted) {
22
+ controller.abort(abortReason(parent));
23
+ return () => {};
24
+ }
25
+ const onAbort = () => controller.abort(abortReason(parent));
26
+ parent.addEventListener("abort", onAbort, { once: true });
27
+ return () => parent.removeEventListener("abort", onAbort);
28
+ }