pi-workflow-engine 0.2.0 → 0.4.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.
- package/.pi/extensions/pi-workflow-engine/index.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +64 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
- package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
- package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +198 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +24 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +89 -145
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +244 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +188 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +181 -0
- package/.pi/settings.json +3 -0
- package/README.md +163 -63
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
- package/assets/preview.png +0 -0
- package/package.json +32 -6
- package/src/agent-runner.ts +0 -122
- package/src/concurrency.ts +0 -55
- package/src/discovery.ts +0 -54
- package/src/index.ts +0 -141
- package/src/ui/workflow-result-renderer.ts +0 -149
- package/src/ui/workflow-widget.ts +0 -143
- package/src/workflows.ts +0 -14
|
@@ -0,0 +1,256 @@
|
|
|
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 { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
|
|
8
|
+
|
|
9
|
+
/** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
|
|
10
|
+
const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
11
|
+
|
|
12
|
+
function summarize(result: unknown): string {
|
|
13
|
+
if (result && typeof result === "object" && typeof (result as { summary?: unknown }).summary === "string") {
|
|
14
|
+
return (result as { summary: string }).summary;
|
|
15
|
+
}
|
|
16
|
+
return typeof result === "string" ? result : "Workflow finished.";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatMessageContent(name: string, result: unknown, perf?: WorkflowPerfDetails): string {
|
|
20
|
+
const perfLine = formatPerfLine(perf);
|
|
21
|
+
return `## Workflow: ${name}\n\n${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function workflowEnvelope(name: string, result: unknown, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
|
|
25
|
+
return { name, result, completedAt: Date.now(), perf };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
|
|
29
|
+
if (!snapshot?.enabled) return undefined;
|
|
30
|
+
return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
|
|
34
|
+
if (!perf) return undefined;
|
|
35
|
+
const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
36
|
+
return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type DiscoveryModule = typeof import("./src/discovery.ts");
|
|
40
|
+
type EngineModule = typeof import("./src/engine.ts");
|
|
41
|
+
|
|
42
|
+
async function loadDiscovery(): Promise<DiscoveryModule> {
|
|
43
|
+
return await import("./src/discovery.ts");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadEngine(): Promise<EngineModule> {
|
|
47
|
+
return await import("./src/engine.ts");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSink | undefined> {
|
|
51
|
+
const enabled = options.perf ?? process.env.PI_WORKFLOW_PERF === "1";
|
|
52
|
+
if (!enabled) return undefined;
|
|
53
|
+
const { createPerfRecorder } = await import("./src/perf.ts");
|
|
54
|
+
return createPerfRecorder(true);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkflowInvocation {
|
|
58
|
+
name: string;
|
|
59
|
+
args: string;
|
|
60
|
+
options: WorkflowRunOptions;
|
|
61
|
+
refreshDiscovery?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseWorkflowInvocation(input: string): WorkflowInvocation {
|
|
65
|
+
const trimmed = input.trim();
|
|
66
|
+
const space = trimmed.indexOf(" ");
|
|
67
|
+
const name = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
68
|
+
const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
69
|
+
const { args, options, refreshDiscovery } = parseWorkflowOptions(rest);
|
|
70
|
+
return { name, args, options, refreshDiscovery };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseWorkflowOptions(input: string): { args: string; options: WorkflowRunOptions; refreshDiscovery?: boolean } {
|
|
74
|
+
const tokens = input.split(/\s+/).filter(Boolean);
|
|
75
|
+
const kept: string[] = [];
|
|
76
|
+
const options: WorkflowRunOptions = {};
|
|
77
|
+
let refreshDiscovery = false;
|
|
78
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
79
|
+
const token = tokens[i];
|
|
80
|
+
if (token === "--inspect") {
|
|
81
|
+
options.inspect = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (token === "--refresh") {
|
|
85
|
+
refreshDiscovery = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (token === "--perf") {
|
|
89
|
+
options.perf = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (token.startsWith("--concurrency=")) {
|
|
93
|
+
options.concurrency = parseNumericOption(token.slice("--concurrency=".length));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (token === "--concurrency") {
|
|
97
|
+
const next = tokens[i + 1];
|
|
98
|
+
options.concurrency = parseNumericOption(next);
|
|
99
|
+
if (next !== undefined) i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (token.startsWith("--parallel-limit=")) {
|
|
103
|
+
options.parallelSubmissionLimit = parseNumericOption(token.slice("--parallel-limit=".length));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (token === "--parallel-limit") {
|
|
107
|
+
const next = tokens[i + 1];
|
|
108
|
+
options.parallelSubmissionLimit = parseNumericOption(next);
|
|
109
|
+
if (next !== undefined) i++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
kept.push(token);
|
|
113
|
+
}
|
|
114
|
+
return { args: kept.join(" ").trim(), options, refreshDiscovery: refreshDiscovery || undefined };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseNumericOption(value: string | undefined): number | undefined {
|
|
118
|
+
if (value === undefined) return undefined;
|
|
119
|
+
const parsed = Number(value);
|
|
120
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function pickWorkflow(
|
|
124
|
+
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
125
|
+
ctx: ExtensionCommandContext,
|
|
126
|
+
): Promise<WorkflowInvocation | undefined> {
|
|
127
|
+
const choices = [...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`);
|
|
128
|
+
const choice = await ctx.ui.select("Run workflow", choices);
|
|
129
|
+
if (!choice) return undefined;
|
|
130
|
+
|
|
131
|
+
const separator = choice.indexOf(" — ");
|
|
132
|
+
const name = separator === -1 ? choice : choice.slice(0, separator);
|
|
133
|
+
const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
|
|
134
|
+
const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
|
|
135
|
+
return { name, args, options: { inspect } };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function sendWorkflowResult(
|
|
139
|
+
pi: ExtensionAPI,
|
|
140
|
+
ctx: ExtensionCommandContext,
|
|
141
|
+
name: string,
|
|
142
|
+
mod: WorkflowModule,
|
|
143
|
+
args: string,
|
|
144
|
+
options: WorkflowRunOptions,
|
|
145
|
+
perfRecorder?: PerfSink,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const { runWorkflow } = await loadEngine();
|
|
148
|
+
let perfSnapshot: PerfSnapshot | undefined;
|
|
149
|
+
const result = await runWorkflow(ctx, mod, args, {
|
|
150
|
+
...options,
|
|
151
|
+
perf: options.perf ?? perfRecorder !== undefined,
|
|
152
|
+
perfRecorder,
|
|
153
|
+
onPerfSnapshot(snapshot) {
|
|
154
|
+
perfSnapshot = snapshot;
|
|
155
|
+
options.onPerfSnapshot?.(snapshot);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const perf = compactPerfSnapshot(perfSnapshot);
|
|
159
|
+
pi.sendMessage(
|
|
160
|
+
{ customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
|
|
161
|
+
{ triggerTurn: false },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
166
|
+
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
167
|
+
const details = message.details;
|
|
168
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
169
|
+
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// /workflow <name> [args] — user-invoked.
|
|
173
|
+
pi.registerCommand("workflow", {
|
|
174
|
+
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
175
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
176
|
+
const direct = parseWorkflowInvocation(args);
|
|
177
|
+
const perfRecorder = await createInvocationPerf(direct.options);
|
|
178
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
179
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { refresh: direct.refreshDiscovery, perf: perfRecorder });
|
|
180
|
+
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
181
|
+
const invocation = direct.name ? direct : ctx.hasUI ? await pickWorkflow(workflows, ctx) : undefined;
|
|
182
|
+
|
|
183
|
+
if (!invocation) {
|
|
184
|
+
ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const mod = workflows.get(invocation.name);
|
|
189
|
+
if (!mod) {
|
|
190
|
+
ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await sendWorkflowResult(pi, ctx, invocation.name, mod, invocation.args, invocation.options, perfRecorder);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// workflow tool — lets the host agent fan out mid-conversation.
|
|
199
|
+
pi.registerTool({
|
|
200
|
+
name: "workflow",
|
|
201
|
+
label: "Workflow",
|
|
202
|
+
description:
|
|
203
|
+
"Run a named multi-agent workflow (fan-out → verify → synthesize) and return its structured result. Use for thorough reviews or audits.",
|
|
204
|
+
parameters: Type.Object({
|
|
205
|
+
name: Type.String({ description: "Workflow name, e.g. code-review" }),
|
|
206
|
+
args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
|
|
207
|
+
concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
|
|
208
|
+
parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
|
|
209
|
+
perf: Type.Optional(Type.Boolean({ description: "Include workflow performance timing aggregates in the result details" })),
|
|
210
|
+
}),
|
|
211
|
+
renderCall(args, theme) {
|
|
212
|
+
const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
|
|
213
|
+
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name)}${suffix}`, 0, 0);
|
|
214
|
+
},
|
|
215
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
216
|
+
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
217
|
+
const details = result.details;
|
|
218
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
219
|
+
const first = result.content[0];
|
|
220
|
+
const text = first?.type === "text" ? first.text : "Workflow finished.";
|
|
221
|
+
return new Text(theme.fg("muted", text), 0, 0);
|
|
222
|
+
},
|
|
223
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
224
|
+
const runOptions: WorkflowRunOptions = {
|
|
225
|
+
concurrency: params.concurrency,
|
|
226
|
+
parallelSubmissionLimit: params.parallelSubmissionLimit,
|
|
227
|
+
perf: params.perf,
|
|
228
|
+
signal,
|
|
229
|
+
};
|
|
230
|
+
const perfRecorder = await createInvocationPerf(runOptions);
|
|
231
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
232
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
|
|
233
|
+
const mod = workflows.get(params.name);
|
|
234
|
+
if (!mod) {
|
|
235
|
+
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: `Unknown workflow "${params.name}". Available: ${available}` }],
|
|
238
|
+
details: { error: "unknown_workflow", available },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const { runWorkflow } = await loadEngine();
|
|
242
|
+
let perfSnapshot: PerfSnapshot | undefined;
|
|
243
|
+
const result = await runWorkflow(ctx, mod, params.args ?? "", {
|
|
244
|
+
...runOptions,
|
|
245
|
+
perf: runOptions.perf ?? perfRecorder !== undefined,
|
|
246
|
+
perfRecorder,
|
|
247
|
+
onPerfSnapshot: (snapshot) => {
|
|
248
|
+
perfSnapshot = snapshot;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const perf = compactPerfSnapshot(perfSnapshot);
|
|
252
|
+
const perfLine = formatPerfLine(perf);
|
|
253
|
+
return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(params.name, result, perf) };
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const AdvisorySeveritySchema = Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")], {
|
|
4
|
+
description: "Impact level of an advisory finding.",
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const AdvisoryConfidenceSchema = Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")], {
|
|
8
|
+
description: "Confidence that the finding is actionable.",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const AdvisoryVerifierVerdictSchema = Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED")], {
|
|
12
|
+
description: "Verifier judgment for a candidate finding.",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const AdvisoryLocationSchema = Type.Object({
|
|
16
|
+
file: Type.String({ description: "Repository-relative file path." }),
|
|
17
|
+
line: Type.Optional(Type.Number({ description: "Relevant 1-based line number when known." })),
|
|
18
|
+
symbol: Type.Optional(Type.String({ description: "Relevant function, class, type, or other symbol when known." })),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const AdvisoryCandidateSchema = Type.Object({
|
|
22
|
+
summary: Type.String({ description: "One-line candidate finding or hypothesis." }),
|
|
23
|
+
category: Type.String({ description: "Workflow-specific category such as bug, duplication, root-cause, or io." }),
|
|
24
|
+
locations: Type.Array(AdvisoryLocationSchema, { description: "Relevant code or configuration locations." }),
|
|
25
|
+
impact: Type.String({ description: "Concrete consequence if this candidate is real." }),
|
|
26
|
+
recommendation: Type.Optional(Type.String({ description: "Optional first-step recommendation before synthesis." })),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const AdvisoryCandidatesSchema = Type.Object({
|
|
30
|
+
candidates: Type.Array(AdvisoryCandidateSchema, { description: "Candidate advisory findings or hypotheses." }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const AdvisoryVerdictSchema = Type.Object({
|
|
34
|
+
verdict: AdvisoryVerifierVerdictSchema,
|
|
35
|
+
evidence: Type.Array(Type.String({ description: "Quoted or cited evidence supporting the verdict." })),
|
|
36
|
+
confidence: Type.Optional(AdvisoryConfidenceSchema),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const AdvisoryFindingSchema = Type.Object({
|
|
40
|
+
summary: Type.String({ description: "One-line final finding." }),
|
|
41
|
+
category: Type.String({ description: "Workflow-specific category for grouping and display." }),
|
|
42
|
+
severity: AdvisorySeveritySchema,
|
|
43
|
+
confidence: AdvisoryConfidenceSchema,
|
|
44
|
+
locations: Type.Array(AdvisoryLocationSchema, { description: "Relevant code or configuration locations." }),
|
|
45
|
+
evidence: Type.Array(Type.String({ description: "Quoted or cited evidence." })),
|
|
46
|
+
impact: Type.String({ description: "Concrete user, runtime, maintenance, or performance impact." }),
|
|
47
|
+
recommendation: Type.String({ description: "Advisory next action; not an automatic edit." }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const AdvisoryReportSchema = Type.Object({
|
|
51
|
+
summary: Type.String({ description: "Short overall advisory summary." }),
|
|
52
|
+
findings: Type.Array(AdvisoryFindingSchema, { description: "Verified and ranked advisory findings." }),
|
|
53
|
+
nextSteps: Type.Array(Type.String({ description: "Concrete follow-up commands, inspections, or decisions." })),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type AdvisorySeverity = Static<typeof AdvisorySeveritySchema>;
|
|
57
|
+
export type AdvisoryConfidence = Static<typeof AdvisoryConfidenceSchema>;
|
|
58
|
+
export type AdvisoryVerifierVerdict = Static<typeof AdvisoryVerifierVerdictSchema>;
|
|
59
|
+
export type AdvisoryLocation = Static<typeof AdvisoryLocationSchema>;
|
|
60
|
+
export type AdvisoryCandidate = Static<typeof AdvisoryCandidateSchema>;
|
|
61
|
+
export type AdvisoryCandidates = Static<typeof AdvisoryCandidatesSchema>;
|
|
62
|
+
export type AdvisoryVerdict = Static<typeof AdvisoryVerdictSchema>;
|
|
63
|
+
export type AdvisoryFinding = Static<typeof AdvisoryFindingSchema>;
|
|
64
|
+
export type AdvisoryReport = Static<typeof AdvisoryReportSchema>;
|
|
@@ -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
|
+
}
|