pi-workflow-engine 1.0.0 → 1.2.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 +236 -94
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +12 -4
- package/USAGE.md +167 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -3,7 +3,7 @@ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/p
|
|
|
3
3
|
import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
|
|
4
4
|
import { formatCount } from "../text.ts";
|
|
5
5
|
import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
|
|
6
|
-
import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
|
|
6
|
+
import { countAgents, finishedAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
|
|
7
7
|
|
|
8
8
|
/** pi caps string widgets at this many rows; the component keeps to the same budget. */
|
|
9
9
|
const MAX_WIDGET_LINES = 10;
|
|
@@ -64,7 +64,7 @@ export class WidthAwareWidget implements Component {
|
|
|
64
64
|
/** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
|
|
65
65
|
export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
|
|
66
66
|
const counts = countAgents(snapshot.phases);
|
|
67
|
-
const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts
|
|
67
|
+
const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
|
|
68
68
|
const line = joinParts([
|
|
69
69
|
`${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
|
|
70
70
|
`${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
|
|
@@ -92,7 +92,7 @@ function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
|
|
|
92
92
|
|
|
93
93
|
function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
|
|
94
94
|
const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
|
|
95
|
-
const finished = counts
|
|
95
|
+
const finished = finishedAgents(counts);
|
|
96
96
|
const stats = joinParts([
|
|
97
97
|
counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
|
|
98
98
|
theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
|
|
@@ -156,8 +156,8 @@ function phaseBody(
|
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* Spread the row budget over active phases by importance — a reserved share for failures so they always
|
|
159
|
-
* show, then running, remaining failures, done, queued — while each phase lists its chosen rows
|
|
160
|
-
*
|
|
159
|
+
* show, then running, remaining failures, skipped, done, queued — while each phase lists its chosen rows
|
|
160
|
+
* in that same status order.
|
|
161
161
|
*/
|
|
162
162
|
function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
|
|
163
163
|
const byStatus = (status: AgentRowSnapshot["status"]) =>
|
|
@@ -177,10 +177,11 @@ function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<Ph
|
|
|
177
177
|
take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
|
|
178
178
|
take(byStatus("running"));
|
|
179
179
|
take(failed);
|
|
180
|
+
take(byStatus("skipped"));
|
|
180
181
|
take(byStatus("done"));
|
|
181
182
|
take(byStatus("queued"));
|
|
182
183
|
|
|
183
|
-
const rank = { running: 0, failed: 1,
|
|
184
|
+
const rank = { running: 0, failed: 1, skipped: 2, done: 3, queued: 4 } as const;
|
|
184
185
|
const plans = new Map<PhaseSnapshot, AgentRowPlan>();
|
|
185
186
|
for (const phase of active) {
|
|
186
187
|
const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
|
|
@@ -196,7 +197,7 @@ function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width:
|
|
|
196
197
|
|
|
197
198
|
function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
|
|
198
199
|
const counts = countAgents([phase]);
|
|
199
|
-
const glyph = counts.failed === 0
|
|
200
|
+
const glyph = counts.failed === 0 && counts.skipped === 0
|
|
200
201
|
? theme.fg("success", GLYPH.done)
|
|
201
202
|
: counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
|
|
202
203
|
const span = phaseSpan(phase.agents);
|
|
@@ -204,17 +205,19 @@ function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Them
|
|
|
204
205
|
counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
|
|
205
206
|
span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
|
|
206
207
|
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
208
|
+
counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
|
|
207
209
|
], theme);
|
|
208
210
|
return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
|
|
212
214
|
const counts = countAgents(phases);
|
|
213
|
-
const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
|
|
215
|
+
const glyph = counts.failed > 0 || counts.skipped > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
|
|
214
216
|
const titles = phases.map((phase) => phase.title).join(" · ");
|
|
215
217
|
const detail = joinParts([
|
|
216
218
|
theme.fg("muted", plural(counts.total, "agent")),
|
|
217
219
|
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
220
|
+
counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
|
|
218
221
|
], theme);
|
|
219
222
|
return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
|
|
220
223
|
}
|
|
@@ -226,6 +229,7 @@ function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme):
|
|
|
226
229
|
counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
|
|
227
230
|
counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
|
|
228
231
|
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
232
|
+
counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
|
|
229
233
|
plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
|
|
230
234
|
], theme);
|
|
231
235
|
return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
|
|
@@ -246,6 +250,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
|
|
|
246
250
|
return theme.fg("error", GLYPH.failed);
|
|
247
251
|
case "done":
|
|
248
252
|
return theme.fg("success", GLYPH.done);
|
|
253
|
+
case "skipped":
|
|
254
|
+
return theme.fg("warning", GLYPH.skipped);
|
|
249
255
|
case "queued":
|
|
250
256
|
return theme.fg("dim", GLYPH.queued);
|
|
251
257
|
}
|
|
@@ -264,6 +270,8 @@ function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string
|
|
|
264
270
|
], theme);
|
|
265
271
|
case "failed":
|
|
266
272
|
return theme.fg("error", oneLine(agent.error ?? "failed"));
|
|
273
|
+
case "skipped":
|
|
274
|
+
return theme.fg("warning", "skipped");
|
|
267
275
|
case "queued":
|
|
268
276
|
return theme.fg("dim", "queued");
|
|
269
277
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
1
2
|
import { isFiniteNumber, isRecord } from "./guards.ts";
|
|
2
3
|
import { formatCount } from "./text.ts";
|
|
3
4
|
|
|
@@ -81,9 +82,14 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
|
|
|
81
82
|
const parsed = parseAssistantUsageMessage(message);
|
|
82
83
|
return parsed ? [parsed] : [];
|
|
83
84
|
});
|
|
84
|
-
|
|
85
|
+
// Tools that call models themselves, such as codemode running a classifier, report it on their result.
|
|
86
|
+
const toolUsage = input.messages.flatMap((message) => {
|
|
87
|
+
const parsed = parseToolResultUsage(message);
|
|
88
|
+
return parsed ? [parsed] : [];
|
|
89
|
+
});
|
|
90
|
+
if (assistantMessages.length === 0 && toolUsage.length === 0) return;
|
|
85
91
|
|
|
86
|
-
const usage = sumTotals(assistantMessages.map((message) => message.usage));
|
|
92
|
+
const usage = sumTotals([...assistantMessages.map((message) => message.usage), ...toolUsage]);
|
|
87
93
|
const latestMetadata = assistantMessages.findLast((message) => message.provider !== undefined || message.model !== undefined);
|
|
88
94
|
this.agents.push({
|
|
89
95
|
label: input.label,
|
|
@@ -127,6 +133,13 @@ export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSn
|
|
|
127
133
|
return snapshot.totals.totalTokens > 0 || snapshot.totals.cost.total > 0;
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/** The run's subagent totals as pi's `Usage`, so a tool result adds them to the session's cost. */
|
|
137
|
+
export function workflowUsageForPi(snapshot: unknown): Usage | undefined {
|
|
138
|
+
if (!hasWorkflowUsage(snapshot)) return undefined;
|
|
139
|
+
const { input, output, cacheRead, cacheWrite, totalTokens, cost } = snapshot.totals;
|
|
140
|
+
return { input, output, cacheRead, cacheWrite, totalTokens, cost: { ...cost } };
|
|
141
|
+
}
|
|
142
|
+
|
|
130
143
|
export function formatWorkflowUsageLine(snapshot: unknown): string | undefined {
|
|
131
144
|
if (!hasWorkflowUsage(snapshot)) return undefined;
|
|
132
145
|
const parts = formatWorkflowUsageComponents(snapshot.totals);
|
|
@@ -217,6 +230,11 @@ function parseAssistantUsageMessage(message: unknown): AssistantUsageMessage | u
|
|
|
217
230
|
};
|
|
218
231
|
}
|
|
219
232
|
|
|
233
|
+
function parseToolResultUsage(message: unknown): WorkflowUsageTotals | undefined {
|
|
234
|
+
if (!isRecord(message) || message.role !== "toolResult") return undefined;
|
|
235
|
+
return parseUsageTotals(message.usage);
|
|
236
|
+
}
|
|
237
|
+
|
|
220
238
|
function parseUsageTotals(value: unknown): WorkflowUsageTotals | undefined {
|
|
221
239
|
if (!isRecord(value)) return undefined;
|
|
222
240
|
const input = finiteNumber(value.input);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
|
|
2
|
-
import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
|
|
2
|
+
import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, repoPathCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
|
|
3
|
+
import { isFatalWorkflowError } from "./cancellation.ts";
|
|
4
|
+
import { WorkflowClassifierUnavailableError } from "./classify.ts";
|
|
5
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
3
6
|
import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
|
|
4
7
|
|
|
5
8
|
|
|
@@ -17,7 +20,9 @@ export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
|
|
|
17
20
|
|
|
18
21
|
export interface LensVerificationPipelineResult {
|
|
19
22
|
verified: AdvisoryVerified[];
|
|
23
|
+
/** Every candidate the finders returned, including those over the per-lens cap. */
|
|
20
24
|
rawCandidates: number;
|
|
25
|
+
/** Candidates never verified: over the per-lens cap, duplicates, or out of scope. */
|
|
21
26
|
dropped: number;
|
|
22
27
|
coverage: AdvisoryStageCoverage[];
|
|
23
28
|
}
|
|
@@ -49,7 +54,7 @@ export function publishVerifiedKeptProgress(
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export interface LensVerificationPipelineOptions {
|
|
52
|
-
api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
|
|
57
|
+
api: Pick<WorkflowApi, "agent" | "classify" | "parallel" | "phase" | "progress" | "log">;
|
|
53
58
|
lenses: readonly AdvisoryLens[];
|
|
54
59
|
perLens: number;
|
|
55
60
|
finderPhase?: "Find" | "Hypothesize";
|
|
@@ -70,6 +75,7 @@ export async function runLensVerificationPipeline(
|
|
|
70
75
|
const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
|
|
71
76
|
const coverage: AdvisoryStageCoverage[] = [];
|
|
72
77
|
let rawCandidates = 0;
|
|
78
|
+
let overCap = 0;
|
|
73
79
|
api.phase(finderPhase);
|
|
74
80
|
const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
|
|
75
81
|
const result = await api.agent(finderPrompt(lens), {
|
|
@@ -77,9 +83,16 @@ export async function runLensVerificationPipeline(
|
|
|
77
83
|
tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
|
|
78
84
|
profile: "small", schema: AdvisoryCandidatesSchema,
|
|
79
85
|
});
|
|
86
|
+
rawCandidates += result.candidates.length;
|
|
87
|
+
api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: result.candidates.length });
|
|
88
|
+
// A finder can return more than the cap its prompt states; keep the first perLens and report the rest.
|
|
89
|
+
const cut = Math.max(0, result.candidates.length - perLens);
|
|
90
|
+
if (cut > 0) {
|
|
91
|
+
overCap += cut;
|
|
92
|
+
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: cut });
|
|
93
|
+
api.log(`Dropped ${cut} ${lens.label} candidate(s) over the per-lens cap of ${perLens}`);
|
|
94
|
+
}
|
|
80
95
|
const raw = identifyCandidates(result.candidates.slice(0, perLens), lens.label);
|
|
81
|
-
rawCandidates += raw.length;
|
|
82
|
-
api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
|
|
83
96
|
const candidates = boundCandidate ? raw.flatMap((candidate) => {
|
|
84
97
|
const bounded = boundCandidate(candidate, lens);
|
|
85
98
|
return bounded ? [bounded] : [];
|
|
@@ -90,12 +103,13 @@ export async function runLensVerificationPipeline(
|
|
|
90
103
|
}
|
|
91
104
|
return candidates;
|
|
92
105
|
} })), coverage);
|
|
93
|
-
const candidates = dedupeCandidates(found.flat());
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta:
|
|
97
|
-
api.log(`Dropped ${
|
|
106
|
+
const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat()));
|
|
107
|
+
const duplicateOrOutOfScope = rawCandidates - overCap - candidates.length;
|
|
108
|
+
if (duplicateOrOutOfScope > 0) {
|
|
109
|
+
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: duplicateOrOutOfScope });
|
|
110
|
+
api.log(`Dropped ${duplicateOrOutOfScope} duplicate or out-of-scope candidate(s)`);
|
|
98
111
|
}
|
|
112
|
+
const dropped = overCap + duplicateOrOutOfScope;
|
|
99
113
|
api.phase("Verify");
|
|
100
114
|
const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
|
|
101
115
|
id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
|
|
@@ -112,6 +126,106 @@ export async function runLensVerificationPipeline(
|
|
|
112
126
|
return { verified, rawCandidates, dropped, coverage };
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
/** TypeSafe Jev routes in preference order. Jev is the classifier the duplicate merge was measured with. */
|
|
130
|
+
export const DUPLICATE_CANDIDATE_CLASSIFIERS = [
|
|
131
|
+
"typesafe/jev-latest",
|
|
132
|
+
"openrouter/~typesafe/jev-latest",
|
|
133
|
+
"openrouter/typesafe/jev-1.13",
|
|
134
|
+
"cloudflare-workers-ai/typesafe/jev",
|
|
135
|
+
"vercel-ai-gateway/typesafe-ai/jev",
|
|
136
|
+
"opencode/jev-1.13",
|
|
137
|
+
"opencode/jev-1.13-free",
|
|
138
|
+
] as const;
|
|
139
|
+
|
|
140
|
+
/** Probability above which two candidates count as one defect. */
|
|
141
|
+
const SAME_DEFECT_PROBABILITY = 0.8;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Merge same-file candidates that a classifier judges to be one defect, so each defect gets one verifier.
|
|
145
|
+
* Lenses often report the same bug in different words; on GPT-6.1 Sol code reviews this cut verifier
|
|
146
|
+
* agents and cost by about 40% without losing a finding. Runs without a reachable Jev classifier skip it.
|
|
147
|
+
*/
|
|
148
|
+
export async function mergeEquivalentCandidates<T extends IdentifiedAdvisoryCandidate>(
|
|
149
|
+
api: Pick<WorkflowApi, "classify" | "parallel" | "log">,
|
|
150
|
+
candidates: readonly T[],
|
|
151
|
+
): Promise<T[]> {
|
|
152
|
+
const files = candidates.map(candidateFile);
|
|
153
|
+
const pairs: Array<readonly [number, number]> = [];
|
|
154
|
+
for (let first = 0; first < candidates.length; first++) {
|
|
155
|
+
for (let second = first + 1; second < candidates.length; second++) {
|
|
156
|
+
if (files[first] === files[second]) pairs.push([first, second]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (pairs.length === 0) return [...candidates];
|
|
160
|
+
const sameDefect = async ([first, second]: readonly [number, number]): Promise<number> => {
|
|
161
|
+
const answers = await api.classify({
|
|
162
|
+
state: { first: candidateForClassifier(candidates[first]!), second: candidateForClassifier(candidates[second]!) },
|
|
163
|
+
questions: {
|
|
164
|
+
same: {
|
|
165
|
+
type: "bool",
|
|
166
|
+
instructions: "Two code-review candidates were raised against the same file. Do they describe the same underlying defect (same root cause and same fix), even if worded differently?",
|
|
167
|
+
criteria: { true: "Same underlying defect", false: "Different defects" },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}, { model: DUPLICATE_CANDIDATE_CLASSIFIERS, label: "dedup" });
|
|
171
|
+
return answers.same?.type === "bool" ? answers.same.probability : 0;
|
|
172
|
+
};
|
|
173
|
+
// The first call probes the classifier: if it fails for any reason (no Jev route, bad credentials, a rate
|
|
174
|
+
// limit), skip the merge rather than send every other pair into the same failure.
|
|
175
|
+
let firstProbability: number;
|
|
176
|
+
try {
|
|
177
|
+
firstProbability = await sameDefect(pairs[0]!);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (isFatalWorkflowError(error, undefined)) throw error;
|
|
180
|
+
if (!(error instanceof WorkflowClassifierUnavailableError)) api.log(`Skipped merging duplicate candidates: ${unknownErrorMessage(error)}`);
|
|
181
|
+
return [...candidates];
|
|
182
|
+
}
|
|
183
|
+
if (pairs.length > 1) api.log(`Comparing ${pairs.length} same-file candidate pairs for duplicates`);
|
|
184
|
+
const rest = await api.parallel(pairs.slice(1).map((pair) => () => sameDefect(pair)));
|
|
185
|
+
const probability = new Map<string, number>();
|
|
186
|
+
pairs.forEach(([first, second], index) => probability.set(`${first}:${second}`, index === 0 ? firstProbability : rest[index - 1] ?? 0));
|
|
187
|
+
|
|
188
|
+
// Complete linkage: a candidate joins a group only when it matches every member, so one vague candidate
|
|
189
|
+
// that resembles two different defects cannot chain them together.
|
|
190
|
+
const groups: number[][] = [];
|
|
191
|
+
candidates.forEach((_, index) => {
|
|
192
|
+
const group = groups.find((members) =>
|
|
193
|
+
members.every((member) => (probability.get(`${member}:${index}`) ?? 0) >= SAME_DEFECT_PROBABILITY));
|
|
194
|
+
if (group) group.push(index);
|
|
195
|
+
else groups.push([index]);
|
|
196
|
+
});
|
|
197
|
+
const merged = groups.map(([keep, ...folded]) => {
|
|
198
|
+
const kept = { ...candidates[keep!]! };
|
|
199
|
+
for (const index of folded) {
|
|
200
|
+
const duplicate = candidates[index]!;
|
|
201
|
+
mergeCandidateInto(kept, duplicate);
|
|
202
|
+
// The kept summary leads; keep folded wording visible to the verifier in case the classifier was wrong.
|
|
203
|
+
if (duplicate.summary !== kept.summary) {
|
|
204
|
+
kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), `Also reported as: ${duplicate.summary}`])];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return kept;
|
|
208
|
+
});
|
|
209
|
+
const folded = candidates.length - merged.length;
|
|
210
|
+
if (folded > 0) api.log(`Merged ${folded} candidate(s) describing the same defect before verification`);
|
|
211
|
+
return merged;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Pair by the changed line when there is one, with the repository-relative path exact dedup uses. */
|
|
215
|
+
function candidateFile(candidate: IdentifiedAdvisoryCandidate): string {
|
|
216
|
+
return repoPathCandidates((candidate.reviewAnchor ?? primaryLocation(candidate)).file)[1];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function candidateForClassifier(candidate: IdentifiedAdvisoryCandidate) {
|
|
220
|
+
return {
|
|
221
|
+
category: candidate.category,
|
|
222
|
+
summary: candidate.summary,
|
|
223
|
+
impact: candidate.impact,
|
|
224
|
+
location: formatLocation(candidate),
|
|
225
|
+
evidence: (candidate.discoveryEvidence ?? []).slice(0, 4),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
115
229
|
/** Stats for a lens review that stopped before discovery. */
|
|
116
230
|
export const EMPTY_LENS_REVIEW_STATS: Readonly<WorkflowRunStats> = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 });
|
|
117
231
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sessionKey } from "./session-identity.ts";
|
|
3
|
+
import type { WorkflowMeta } from "./types.ts";
|
|
4
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
5
|
+
import { DEFAULT_WORKFLOW_SIZE_GUIDELINE, workflowSizeGuidelineLine, type WorkflowSizeGuideline } from "./workflow-settings.ts";
|
|
6
|
+
|
|
7
|
+
/** System prompt section pi wraps as `<workflows>`: the registered workflows the host may run by name. */
|
|
8
|
+
export const WORKFLOWS_SECTION = "workflows";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One line per workflow, sorted by name, then the size guideline, so the section text depends only on
|
|
12
|
+
* what is registered and configured.
|
|
13
|
+
*/
|
|
14
|
+
export function formatWorkflowCatalog(workflows: Iterable<WorkflowMeta>, sizeLine?: string): string | undefined {
|
|
15
|
+
const lines = [...workflows]
|
|
16
|
+
// Code-unit order rather than localeCompare, whose result depends on the host locale.
|
|
17
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
|
18
|
+
.map((meta) => {
|
|
19
|
+
const description = meta.description.trim();
|
|
20
|
+
const whenToUse = meta.whenToUse?.trim();
|
|
21
|
+
return `- ${meta.name}${description ? `: ${description}` : ""}${whenToUse ? ` ${whenToUse}` : ""}`;
|
|
22
|
+
});
|
|
23
|
+
const blocks: string[] = [];
|
|
24
|
+
if (lines.length > 0) {
|
|
25
|
+
blocks.push(["Registered workflows the workflow tool runs by `name`, passing the target or instructions as `args` (only once the user opted into workflow orchestration):", ...lines].join("\n"));
|
|
26
|
+
}
|
|
27
|
+
if (sizeLine) blocks.push(sizeLine);
|
|
28
|
+
return blocks.length > 0 ? blocks.join("\n\n") : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WorkflowCatalogSources {
|
|
32
|
+
readonly discover: (ctx: ExtensionContext) => Promise<Iterable<WorkflowMeta>>;
|
|
33
|
+
readonly size: (ctx: ExtensionContext) => WorkflowSizeGuideline;
|
|
34
|
+
readonly log?: (message: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The catalog section for each session, built once from discovery and settings and then reused, so every
|
|
39
|
+
* run in the session sends identical text and keeps the provider's prompt cache. A new session start or a
|
|
40
|
+
* size change rebuilds it.
|
|
41
|
+
*/
|
|
42
|
+
export class WorkflowCatalogPrompt {
|
|
43
|
+
private readonly sessions = new Map<string, Promise<string | undefined>>();
|
|
44
|
+
private readonly log: (message: string) => void;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly sources: WorkflowCatalogSources) {
|
|
47
|
+
this.log = sources.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
section(ctx: ExtensionContext): Promise<string | undefined> {
|
|
51
|
+
const key = sessionKey(ctx);
|
|
52
|
+
const cached = this.sessions.get(key);
|
|
53
|
+
if (cached) return cached;
|
|
54
|
+
const built = this.build(ctx);
|
|
55
|
+
this.sessions.set(key, built);
|
|
56
|
+
return built;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
forget(ctx: Pick<ExtensionContext, "sessionManager">): void {
|
|
60
|
+
this.sessions.delete(sessionKey(ctx));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The catalog only advertises workflows and a guideline; neither failure may block the prompt.
|
|
64
|
+
private async build(ctx: ExtensionContext): Promise<string | undefined> {
|
|
65
|
+
let size = DEFAULT_WORKFLOW_SIZE_GUIDELINE;
|
|
66
|
+
try {
|
|
67
|
+
size = this.sources.size(ctx);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.log(`[workflow] workflow size guideline unavailable, using ${size}: ${unknownErrorMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
let workflows: Iterable<WorkflowMeta> = [];
|
|
72
|
+
try {
|
|
73
|
+
workflows = await this.sources.discover(ctx);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.log(`[workflow] workflow catalog unavailable: ${unknownErrorMessage(error)}`);
|
|
76
|
+
}
|
|
77
|
+
return formatWorkflowCatalog(workflows, workflowSizeGuidelineLine(size));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -25,6 +25,8 @@ export interface WorkflowResultEnvelope {
|
|
|
25
25
|
readonly perf?: WorkflowPerfDetails;
|
|
26
26
|
readonly runId?: string;
|
|
27
27
|
readonly resumedFromRunId?: string;
|
|
28
|
+
/** Whether an inline run's script was persisted beside its journal. */
|
|
29
|
+
readonly scriptPersisted?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/** A workflow result's own summary: the result itself when it is a string, else its string `summary` field. */
|
|
@@ -36,7 +38,7 @@ export function workflowResultSummary(result: unknown): string | undefined {
|
|
|
36
38
|
export type ResolvedWorkflowRunner = (
|
|
37
39
|
ctx: ExtensionContext,
|
|
38
40
|
mod: LoadedWorkflow,
|
|
39
|
-
args:
|
|
41
|
+
args: unknown,
|
|
40
42
|
options: ResolvedWorkflowRunOptions,
|
|
41
43
|
) => Promise<unknown>;
|
|
42
44
|
|
|
@@ -44,7 +46,8 @@ export interface WorkflowExecutionInput {
|
|
|
44
46
|
readonly ctx: ExtensionContext;
|
|
45
47
|
readonly name: string;
|
|
46
48
|
readonly mod: LoadedWorkflow;
|
|
47
|
-
|
|
49
|
+
/** A slash command's argument string, or a tool call's string or JSON value. */
|
|
50
|
+
readonly args: unknown;
|
|
48
51
|
readonly options: ResolvedWorkflowRunOptions;
|
|
49
52
|
readonly perfRecorder?: PerfSink;
|
|
50
53
|
readonly runResolvedWorkflow: ResolvedWorkflowRunner;
|
|
@@ -99,6 +102,7 @@ export async function executeWorkflowInvocation(input: WorkflowExecutionInput):
|
|
|
99
102
|
perf: perfSnapshot?.enabled ? { aggregates: perfSnapshot.aggregates } : undefined,
|
|
100
103
|
runId: runMetadata?.runId,
|
|
101
104
|
resumedFromRunId: runMetadata?.resumedFromRunId,
|
|
105
|
+
scriptPersisted: runMetadata?.scriptPath !== undefined,
|
|
102
106
|
};
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A workflow's input is the string a slash command passes, or any JSON value from a tool call or
|
|
2
|
+
// `api.workflow()`. Workflows read it verbatim as `api.input` and as text as `api.args`.
|
|
3
|
+
|
|
4
|
+
/** `api.args`: a string verbatim, any other JSON value as JSON text, and "" when nothing was passed. */
|
|
5
|
+
export function workflowArgsText(input: unknown): string {
|
|
6
|
+
if (typeof input === "string") return input;
|
|
7
|
+
return JSON.stringify(input) ?? "";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Whether a run had arguments. Run records never keep them, so such a run cannot be relaunched. */
|
|
11
|
+
export function workflowInputPresent(input: unknown): boolean {
|
|
12
|
+
return input !== undefined && input !== "";
|
|
13
|
+
}
|
|
@@ -2,10 +2,14 @@ import type {
|
|
|
2
2
|
LoadedWorkflow,
|
|
3
3
|
WorkflowMeta,
|
|
4
4
|
WorkflowModule,
|
|
5
|
+
WorkflowPhaseMeta,
|
|
5
6
|
WorkflowSourceIdentity,
|
|
6
7
|
} from "./types.ts";
|
|
7
8
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
8
9
|
import { isRecord } from "./guards.ts";
|
|
10
|
+
import { isWorkflowModelProfileName } from "./model-profiles.ts";
|
|
11
|
+
|
|
12
|
+
const PHASES_SHAPE = 'meta.phases must be an array of { title: string, profile?: "small" | "medium" | "big" }';
|
|
9
13
|
|
|
10
14
|
/** Validate and normalize workflow metadata without executing workflow code. */
|
|
11
15
|
export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { reason: string } {
|
|
@@ -15,10 +19,14 @@ export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { rea
|
|
|
15
19
|
const description = meta.description;
|
|
16
20
|
if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
|
|
17
21
|
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
22
|
+
const whenToUse = meta.whenToUse;
|
|
23
|
+
if (whenToUse !== undefined && typeof whenToUse !== "string") return { reason: "meta.whenToUse must be a string when provided" };
|
|
24
|
+
|
|
25
|
+
const phases = meta.phases === undefined ? undefined : parseWorkflowPhases(meta.phases);
|
|
26
|
+
if (phases === null) return { reason: PHASES_SHAPE };
|
|
20
27
|
|
|
21
28
|
const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
|
|
29
|
+
if (whenToUse !== undefined) workflowMeta.whenToUse = whenToUse;
|
|
22
30
|
if (phases !== undefined) workflowMeta.phases = phases;
|
|
23
31
|
return { meta: workflowMeta };
|
|
24
32
|
}
|
|
@@ -50,6 +58,15 @@ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
|
|
|
50
58
|
return typeof value === "function";
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
/** The declared phases keeping only known fields, or null when any entry is malformed. */
|
|
62
|
+
function parseWorkflowPhases(value: unknown): WorkflowPhaseMeta[] | null {
|
|
63
|
+
if (!Array.isArray(value)) return null;
|
|
64
|
+
const phases: WorkflowPhaseMeta[] = [];
|
|
65
|
+
for (const phase of value) {
|
|
66
|
+
if (!isRecord(phase) || typeof phase.title !== "string") return null;
|
|
67
|
+
const { profile } = phase;
|
|
68
|
+
if (profile !== undefined && (typeof profile !== "string" || !isWorkflowModelProfileName(profile))) return null;
|
|
69
|
+
phases.push({ title: phase.title, ...(profile === undefined ? {} : { profile }) });
|
|
70
|
+
}
|
|
71
|
+
return phases;
|
|
55
72
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
|
|
2
3
|
import { isRecord } from "./guards.ts";
|
|
4
|
+
import { WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
5
|
+
import { workflowRunScriptFile } from "./workflow-run-record.ts";
|
|
3
6
|
import { toReviewIssues } from "./review/review-issues.ts";
|
|
4
7
|
import { formatPerfSummary } from "./perf.ts";
|
|
5
8
|
import { prettyJson } from "./text.ts";
|
|
@@ -15,19 +18,36 @@ export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope)
|
|
|
15
18
|
return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
/** Run id, usage and perf lines appended for the host model. */
|
|
21
|
+
/** Run id, run files, usage and perf lines appended for the host model. */
|
|
19
22
|
function formatWorkflowDetailLines(envelope: WorkflowResultEnvelope): string[] {
|
|
20
|
-
const run = envelope.runId
|
|
21
|
-
? envelope.resumedFromRunId ? `Run: ${envelope.runId} (resumed from ${envelope.resumedFromRunId})` : `Run: ${envelope.runId}`
|
|
22
|
-
: undefined;
|
|
23
23
|
return [
|
|
24
|
-
|
|
24
|
+
...(envelope.runId
|
|
25
|
+
? [formatWorkflowRunLine({ runId: envelope.runId, resumedFromRunId: envelope.resumedFromRunId }), ...formatWorkflowRunFileLines(envelope.runId, envelope.scriptPersisted === true)]
|
|
26
|
+
: []),
|
|
25
27
|
formatWorkflowUsageLine(envelope.usage),
|
|
26
28
|
envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
|
|
27
29
|
].filter((line): line is string => line !== undefined);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
/** The run id, naming the run a resume continues. */
|
|
33
|
+
export function formatWorkflowRunLine(run: { readonly runId: string; readonly resumedFromRunId?: string }): string {
|
|
34
|
+
return run.resumedFromRunId ? `Run: ${run.runId} (resumed from ${run.resumedFromRunId})` : `Run: ${run.runId}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the host model can read what the run did, relative to the workflow cwd: the persisted script it
|
|
39
|
+
* can edit and rerun with `scriptPath`, and the replay journal of completed agent results.
|
|
40
|
+
*/
|
|
41
|
+
export function formatWorkflowRunFileLines(runId: string, scriptPersisted: boolean): string[] {
|
|
42
|
+
return [
|
|
43
|
+
...(scriptPersisted ? [`Script: ${join(WORKFLOW_RUNS_DIR, workflowRunScriptFile(runId))}`] : []),
|
|
44
|
+
`Journal: ${join(WORKFLOW_RUNS_DIR, `${runId}.jsonl`)}`,
|
|
45
|
+
"If the result is empty or unexpected, read the journal before diagnosing.",
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A workflow result as the host model reads it: advisory reports as findings, other values as bounded JSON. */
|
|
50
|
+
export function formatResultForContext(result: unknown): string {
|
|
31
51
|
if (typeof result === "string") return result;
|
|
32
52
|
if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
|
|
33
53
|
const summary = workflowResultSummary(result);
|
|
@@ -23,6 +23,7 @@ export function createPersistedWorkflowBackground(
|
|
|
23
23
|
origin: {
|
|
24
24
|
sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
|
|
25
25
|
requestedAt: origin.requestedAt,
|
|
26
|
+
...(origin.wakeOnComplete ? { wakeOnComplete: true } : {}),
|
|
26
27
|
},
|
|
27
28
|
delivery: { state: "pending" },
|
|
28
29
|
};
|
|
@@ -49,6 +50,7 @@ export function updateWorkflowRunDelivery(
|
|
|
49
50
|
export function isPersistedWorkflowBackground(value: unknown): value is PersistedWorkflowBackground {
|
|
50
51
|
if (!isRecord(value) || !isRecord(value.origin) || !isRecord(value.delivery)) return false;
|
|
51
52
|
if (typeof value.origin.sessionId !== "string" || !isFiniteNumber(value.origin.requestedAt)) return false;
|
|
53
|
+
if (value.origin.wakeOnComplete !== undefined && typeof value.origin.wakeOnComplete !== "boolean") return false;
|
|
52
54
|
switch (value.delivery.state) {
|
|
53
55
|
case "pending":
|
|
54
56
|
return true;
|