pi-workflow-engine 0.7.0 → 0.9.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 +103 -14
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +105 -9
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +222 -0
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +14 -1
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +13 -7
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +230 -0
- package/README.md +148 -182
- package/USAGE.md +14 -6
- package/package.json +2 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { SelectList, Text, truncateToWidth, type Component, type SelectItem, type SelectListTheme, type TUI } from "@earendil-works/pi-tui";
|
|
5
5
|
import type { WorkflowProgressSnapshot } from "./src/progress.ts";
|
|
6
6
|
import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunOptions } from "./src/types.ts";
|
|
7
7
|
import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
|
|
8
8
|
import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
|
|
9
|
+
import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./src/usage.ts";
|
|
9
10
|
import { registerDynamax } from "./src/dynamax.ts";
|
|
10
11
|
import { handleReviewViewerAction } from "./src/review/review-actions.ts";
|
|
11
12
|
import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
|
|
@@ -21,13 +22,15 @@ function summarize(result: unknown): string {
|
|
|
21
22
|
return typeof result === "string" ? result : "Workflow finished.";
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function formatMessageContent(name: string, result: unknown, perf?: WorkflowPerfDetails): string {
|
|
25
|
+
function formatMessageContent(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): string {
|
|
26
|
+
const usageLine = formatWorkflowUsageLine(usage);
|
|
25
27
|
const perfLine = formatPerfLine(perf);
|
|
26
|
-
|
|
28
|
+
const details = [usageLine, perfLine].filter((line): line is string => line !== undefined);
|
|
29
|
+
return `## Workflow: ${name}\n\n${summarize(result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
function workflowEnvelope(name: string, result: unknown, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
|
|
30
|
-
return { name, result, completedAt: Date.now(), perf };
|
|
32
|
+
function workflowEnvelope(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
|
|
33
|
+
return { name, result, completedAt: Date.now(), usage, perf };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
|
|
@@ -78,7 +81,81 @@ export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Pro
|
|
|
78
81
|
return mod;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
const
|
|
84
|
+
const AUTHOR_TEMP_WORKFLOW_VALUE = "__author-temporary-workflow__";
|
|
85
|
+
const AUTHOR_TEMP_WORKFLOW_LABEL = "Author temporary one-shot workflow";
|
|
86
|
+
const AUTHOR_TEMP_WORKFLOW_DESCRIPTION = "Ask the host agent to author and run an inline workflow.";
|
|
87
|
+
const WORKFLOW_PICKER_MAX_VISIBLE = 8;
|
|
88
|
+
const WORKFLOW_PICKER_LAYOUT = { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 22 };
|
|
89
|
+
|
|
90
|
+
class WorkflowPickerComponent implements Component {
|
|
91
|
+
private readonly list: SelectList;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
items: SelectItem[],
|
|
95
|
+
private readonly tui: TUI,
|
|
96
|
+
private readonly theme: Theme,
|
|
97
|
+
done: (value: string | undefined) => void,
|
|
98
|
+
) {
|
|
99
|
+
this.list = new SelectList(items, Math.min(WORKFLOW_PICKER_MAX_VISIBLE, items.length), selectListTheme(theme), WORKFLOW_PICKER_LAYOUT);
|
|
100
|
+
this.list.onSelect = (item) => done(item.value);
|
|
101
|
+
this.list.onCancel = () => done(undefined);
|
|
102
|
+
this.list.onSelectionChange = () => this.tui.requestRender();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
render(width: number): string[] {
|
|
106
|
+
const safeWidth = Math.max(20, width);
|
|
107
|
+
const rule = this.theme.fg("borderMuted", "-".repeat(safeWidth));
|
|
108
|
+
const lines = [
|
|
109
|
+
rule,
|
|
110
|
+
this.theme.fg("accent", this.theme.bold("Run workflow")),
|
|
111
|
+
"",
|
|
112
|
+
...this.list.render(safeWidth),
|
|
113
|
+
"",
|
|
114
|
+
this.theme.fg("muted", "up/down navigate enter select escape cancel"),
|
|
115
|
+
rule,
|
|
116
|
+
];
|
|
117
|
+
return lines.map((line) => truncateToWidth(line, safeWidth));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
handleInput(data: string): void {
|
|
121
|
+
this.list.handleInput(data);
|
|
122
|
+
this.tui.requestRender();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
invalidate(): void {
|
|
126
|
+
this.list.invalidate();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function selectListTheme(theme: Theme): SelectListTheme {
|
|
131
|
+
return {
|
|
132
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
133
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
134
|
+
description: (text) => theme.fg("muted", text),
|
|
135
|
+
scrollInfo: (text) => theme.fg("muted", text),
|
|
136
|
+
noMatch: (text) => theme.fg("muted", text),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function workflowPickerItems(workflows: ReadonlyMap<string, WorkflowModule>): SelectItem[] {
|
|
141
|
+
return [
|
|
142
|
+
{ value: AUTHOR_TEMP_WORKFLOW_VALUE, label: AUTHOR_TEMP_WORKFLOW_LABEL, description: AUTHOR_TEMP_WORKFLOW_DESCRIPTION },
|
|
143
|
+
...[...workflows.values()].map((mod) => ({ value: mod.meta.name, label: mod.meta.name, description: mod.meta.description })),
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function selectWorkflowValue(workflows: ReadonlyMap<string, WorkflowModule>, ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
148
|
+
const items = workflowPickerItems(workflows);
|
|
149
|
+
if (typeof ctx.ui.custom === "function") {
|
|
150
|
+
return await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => new WorkflowPickerComponent(items, tui, theme, done));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const choice = await ctx.ui.select(
|
|
154
|
+
"Run workflow",
|
|
155
|
+
items.map((item) => item.label),
|
|
156
|
+
);
|
|
157
|
+
return items.find((item) => item.label === choice)?.value ?? choice;
|
|
158
|
+
}
|
|
82
159
|
|
|
83
160
|
export interface LastWorkflowInspection {
|
|
84
161
|
readonly name: string;
|
|
@@ -225,6 +302,7 @@ Use the workflow tool with a script argument, not a saved workflow name.
|
|
|
225
302
|
The script must start with export const meta = { ... } and default-export an async workflow function.
|
|
226
303
|
Use the injected Type object for schemas. Do not import anything or use dynamic import().
|
|
227
304
|
Set thinkingLevel explicitly on each agent() call.
|
|
305
|
+
Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only.
|
|
228
306
|
Do not edit files unless the user explicitly requested edits.`;
|
|
229
307
|
}
|
|
230
308
|
|
|
@@ -266,11 +344,10 @@ export async function pickWorkflow(
|
|
|
266
344
|
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
267
345
|
ctx: ExtensionCommandContext,
|
|
268
346
|
): Promise<WorkflowInvocation | undefined> {
|
|
269
|
-
const
|
|
270
|
-
const choice = await ctx.ui.select("Run workflow", choices);
|
|
347
|
+
const choice = await selectWorkflowValue(workflows, ctx);
|
|
271
348
|
if (!choice) return undefined;
|
|
272
349
|
|
|
273
|
-
if (choice ===
|
|
350
|
+
if (choice === AUTHOR_TEMP_WORKFLOW_VALUE || choice === AUTHOR_TEMP_WORKFLOW_LABEL) {
|
|
274
351
|
const brief = await ctx.ui.editor(
|
|
275
352
|
"Describe temporary workflow",
|
|
276
353
|
"Goal:\n\nAgents to run:\n- \n\nFinal output should include:\n- summary\n- findings\n- next steps\n",
|
|
@@ -297,6 +374,7 @@ export async function sendWorkflowResult(
|
|
|
297
374
|
): Promise<void> {
|
|
298
375
|
const { runWorkflow } = await loadEngine();
|
|
299
376
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
377
|
+
let usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
300
378
|
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
301
379
|
const result = await runWorkflow(ctx, mod, args, {
|
|
302
380
|
...options,
|
|
@@ -317,6 +395,10 @@ export async function sendWorkflowResult(
|
|
|
317
395
|
perfSnapshot = snapshot;
|
|
318
396
|
options.onPerfSnapshot?.(snapshot);
|
|
319
397
|
},
|
|
398
|
+
onUsageSnapshot(snapshot) {
|
|
399
|
+
usageSnapshot = snapshot;
|
|
400
|
+
options.onUsageSnapshot?.(snapshot);
|
|
401
|
+
},
|
|
320
402
|
onProgressSnapshot(snapshot) {
|
|
321
403
|
lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
322
404
|
options.onProgressSnapshot?.(snapshot);
|
|
@@ -336,7 +418,7 @@ export async function sendWorkflowResult(
|
|
|
336
418
|
await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
|
|
337
419
|
}
|
|
338
420
|
pi.sendMessage(
|
|
339
|
-
{ customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
|
|
421
|
+
{ customType: "workflow-result", content: formatMessageContent(name, result, usageSnapshot, perf), display: true, details: workflowEnvelope(name, result, usageSnapshot, perf) },
|
|
340
422
|
{ triggerTurn: false },
|
|
341
423
|
);
|
|
342
424
|
}
|
|
@@ -346,7 +428,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
346
428
|
|
|
347
429
|
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
348
430
|
const details = message.details;
|
|
349
|
-
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
431
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
|
|
350
432
|
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
351
433
|
});
|
|
352
434
|
|
|
@@ -406,6 +488,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
406
488
|
"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.",
|
|
407
489
|
"Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
|
|
408
490
|
"Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
|
|
491
|
+
"Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
|
|
409
492
|
"Every workflow tool call must provide exactly one of `name` or `script`, never both.",
|
|
410
493
|
],
|
|
411
494
|
parameters: Type.Object({
|
|
@@ -428,7 +511,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
428
511
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
429
512
|
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
430
513
|
const details = result.details;
|
|
431
|
-
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
514
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
|
|
432
515
|
const first = result.content[0];
|
|
433
516
|
const text = first?.type === "text" ? first.text : "Workflow finished.";
|
|
434
517
|
return new Text(theme.fg("muted", text), 0, 0);
|
|
@@ -474,6 +557,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
474
557
|
|
|
475
558
|
const { runWorkflow } = await loadEngine();
|
|
476
559
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
560
|
+
let usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
477
561
|
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
478
562
|
const resultArgs = params.args ?? "";
|
|
479
563
|
const result = await runWorkflow(ctx, mod, resultArgs, {
|
|
@@ -493,6 +577,9 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
493
577
|
onPerfSnapshot: (snapshot) => {
|
|
494
578
|
perfSnapshot = snapshot;
|
|
495
579
|
},
|
|
580
|
+
onUsageSnapshot: (snapshot) => {
|
|
581
|
+
usageSnapshot = snapshot;
|
|
582
|
+
},
|
|
496
583
|
onProgressSnapshot: (snapshot) => {
|
|
497
584
|
// Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
|
|
498
585
|
// previously uninspectable, unlike the /workflow command path.
|
|
@@ -500,8 +587,10 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
500
587
|
},
|
|
501
588
|
});
|
|
502
589
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
590
|
+
const usageLine = formatWorkflowUsageLine(usageSnapshot);
|
|
503
591
|
const perfLine = formatPerfLine(perf);
|
|
504
|
-
|
|
592
|
+
const detailLines = [usageLine, perfLine].filter((line): line is string => line !== undefined);
|
|
593
|
+
return { content: [{ type: "text", text: `${summarize(result)}${detailLines.length > 0 ? `\n\n${detailLines.join("\n")}` : ""}` }], details: workflowEnvelope(resultName, result, usageSnapshot, perf) };
|
|
505
594
|
},
|
|
506
595
|
});
|
|
507
596
|
}
|
|
@@ -4,7 +4,9 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
5
|
import type { Semaphore } from "./concurrency.ts";
|
|
6
6
|
import type { PerfSink } from "./perf.ts";
|
|
7
|
+
import type { WorkflowUsageSink } from "./usage.ts";
|
|
7
8
|
import { throwIfAborted } from "./cancellation.ts";
|
|
9
|
+
import { appendSkillReminder, createAgentSkillResourceLoader, extractSkillSelectorsFromText } from "./agent-skills.ts";
|
|
8
10
|
|
|
9
11
|
/** Name of the synthetic terminating tool that carries structured output. */
|
|
10
12
|
const FINAL_TOOL = "final_answer";
|
|
@@ -42,9 +44,69 @@ export interface RunContext {
|
|
|
42
44
|
progress: AgentProgress;
|
|
43
45
|
signal: AbortSignal | undefined;
|
|
44
46
|
perf: PerfSink;
|
|
47
|
+
usage: WorkflowUsageSink;
|
|
45
48
|
createSession?: CreateAgentSession;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
export interface ResolvedAgentModelRequest {
|
|
52
|
+
readonly ref: string;
|
|
53
|
+
readonly provider: string;
|
|
54
|
+
readonly id: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ResolvedAgentModel {
|
|
58
|
+
readonly model: Model<Api> | undefined;
|
|
59
|
+
readonly requested: ResolvedAgentModelRequest | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseAgentModelRef(modelRef: string): ResolvedAgentModelRequest {
|
|
63
|
+
const normalized = modelRef.trim();
|
|
64
|
+
if (normalized.length === 0) {
|
|
65
|
+
throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
|
|
66
|
+
}
|
|
67
|
+
if (normalized !== modelRef) {
|
|
68
|
+
throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const slash = modelRef.indexOf("/");
|
|
72
|
+
if (slash === -1) {
|
|
73
|
+
return { ref: modelRef, provider: "anthropic", id: modelRef };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const provider = modelRef.slice(0, slash);
|
|
77
|
+
const id = modelRef.slice(slash + 1);
|
|
78
|
+
if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
|
|
79
|
+
throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
|
|
80
|
+
}
|
|
81
|
+
return { ref: modelRef, provider, id };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a workflow agent model reference.
|
|
86
|
+
*
|
|
87
|
+
* Omitted refs inherit the host/session default model. Explicit refs are strict:
|
|
88
|
+
* bare model ids keep the original Anthropic shorthand for compatibility, and
|
|
89
|
+
* provider-qualified refs split only on the first slash so provider routers such
|
|
90
|
+
* as OpenRouter can use ids that contain additional slashes.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveAgentModel(
|
|
93
|
+
modelRef: string | undefined,
|
|
94
|
+
modelRegistry: Pick<ModelRegistry, "find">,
|
|
95
|
+
hostModel: Model<Api> | undefined,
|
|
96
|
+
): ResolvedAgentModel {
|
|
97
|
+
if (modelRef === undefined) {
|
|
98
|
+
return { model: hostModel, requested: undefined };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const parsed = parseAgentModelRef(modelRef);
|
|
102
|
+
const found = modelRegistry.find(parsed.provider, parsed.id);
|
|
103
|
+
if (!found) {
|
|
104
|
+
throw new Error(`Agent model "${modelRef}" not found (resolved as ${parsed.provider}/${parsed.id}).`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { model: found, requested: parsed };
|
|
108
|
+
}
|
|
109
|
+
|
|
48
110
|
/** Pull the last assistant message's plain text out of a finished session. */
|
|
49
111
|
function lastAssistantText(state: { readonly messages: readonly unknown[] }): string {
|
|
50
112
|
const messages = state.messages;
|
|
@@ -95,6 +157,20 @@ function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerS
|
|
|
95
157
|
return () => signal.removeEventListener("abort", onAbort);
|
|
96
158
|
}
|
|
97
159
|
|
|
160
|
+
function buildToolAllowlist(opts: AgentOptions, skillsEnabled: boolean): string[] | undefined {
|
|
161
|
+
if (!opts.tools) return undefined;
|
|
162
|
+
const allow = [...opts.tools];
|
|
163
|
+
if (skillsEnabled && !allow.includes("read")) allow.push("read");
|
|
164
|
+
if (opts.schema && !allow.includes(FINAL_TOOL)) allow.push(FINAL_TOOL);
|
|
165
|
+
return allow;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: AgentOptions): boolean {
|
|
169
|
+
if (!rc.createSession) return true;
|
|
170
|
+
if (opts.skills !== undefined) return true;
|
|
171
|
+
return extractSkillSelectorsFromText(prompt).length > 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
98
174
|
/**
|
|
99
175
|
* Run one subagent to completion in an isolated in-memory session.
|
|
100
176
|
*
|
|
@@ -124,7 +200,13 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
124
200
|
let captured: unknown = null;
|
|
125
201
|
let failed = false;
|
|
126
202
|
let session: AgentRunnerSession | undefined;
|
|
203
|
+
let usageRecorded = false;
|
|
127
204
|
let unsubscribe: (() => void) | undefined;
|
|
205
|
+
const recordUsage = (activeSession: AgentRunnerSession): void => {
|
|
206
|
+
if (usageRecorded) return;
|
|
207
|
+
usageRecorded = true;
|
|
208
|
+
rc.usage.recordAgentSession({ label, phase, messages: activeSession.state.messages });
|
|
209
|
+
};
|
|
128
210
|
const customTools: ToolDefinition[] = opts.schema
|
|
129
211
|
? [
|
|
130
212
|
defineTool({
|
|
@@ -142,15 +224,25 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
142
224
|
: [];
|
|
143
225
|
|
|
144
226
|
try {
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
?
|
|
148
|
-
|
|
149
|
-
|
|
227
|
+
const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
|
|
228
|
+
const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
|
|
229
|
+
? await rc.perf.time(
|
|
230
|
+
"agent.skills_ms",
|
|
231
|
+
() =>
|
|
232
|
+
createAgentSkillResourceLoader({
|
|
233
|
+
cwd: rc.cwd,
|
|
234
|
+
prompt,
|
|
235
|
+
skills: opts.skills,
|
|
236
|
+
log: (message) => rc.progress.log(`${label}: ${message}`),
|
|
237
|
+
}),
|
|
238
|
+
tags,
|
|
239
|
+
)
|
|
150
240
|
: undefined;
|
|
241
|
+
const selectedSkills = skillSetup?.selectedSkills ?? [];
|
|
242
|
+
// When the workflow restricts built-in tools, keep the terminating tool visible.
|
|
243
|
+
// Skill opt-ins also need read so the subagent can load SKILL.md on demand.
|
|
244
|
+
const allow = buildToolAllowlist(opts, selectedSkills.length > 0);
|
|
151
245
|
|
|
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
246
|
const createSessionForRun = rc.createSession ?? defaultCreateSession;
|
|
155
247
|
|
|
156
248
|
throwIfAborted(rc.signal);
|
|
@@ -164,6 +256,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
164
256
|
thinkingLevel: opts.thinkingLevel,
|
|
165
257
|
tools: allow,
|
|
166
258
|
customTools,
|
|
259
|
+
resourceLoader: skillSetup?.resourceLoader,
|
|
167
260
|
sessionManager: SessionManager.inMemory(rc.cwd),
|
|
168
261
|
}),
|
|
169
262
|
tags,
|
|
@@ -178,9 +271,10 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
178
271
|
}
|
|
179
272
|
});
|
|
180
273
|
|
|
274
|
+
const promptedWithSkills = appendSkillReminder(prompt, selectedSkills);
|
|
181
275
|
const finalPrompt = opts.schema
|
|
182
|
-
? `${
|
|
183
|
-
:
|
|
276
|
+
? `${promptedWithSkills}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
|
|
277
|
+
: promptedWithSkills;
|
|
184
278
|
throwIfAborted(rc.signal);
|
|
185
279
|
const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
|
|
186
280
|
try {
|
|
@@ -188,6 +282,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
188
282
|
} finally {
|
|
189
283
|
unlinkPromptAbort();
|
|
190
284
|
}
|
|
285
|
+
recordUsage(activeSession);
|
|
191
286
|
throwIfAborted(rc.signal);
|
|
192
287
|
|
|
193
288
|
return rc.perf.timeSync(
|
|
@@ -213,6 +308,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
213
308
|
} finally {
|
|
214
309
|
const disposable = session;
|
|
215
310
|
if (disposable) {
|
|
311
|
+
recordUsage(disposable);
|
|
216
312
|
rc.perf.timeSync(
|
|
217
313
|
"agent.dispose_ms",
|
|
218
314
|
() => {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { DefaultResourceLoader, getAgentDir, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type AgentSkillOption = readonly string[] | undefined;
|
|
4
|
+
export type AgentSkillRequestSource = "explicit" | "prompt";
|
|
5
|
+
|
|
6
|
+
export interface AgentSkillRequest {
|
|
7
|
+
readonly selectors: readonly string[];
|
|
8
|
+
readonly source: AgentSkillRequestSource;
|
|
9
|
+
readonly strict: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AgentSkillResolution {
|
|
13
|
+
readonly selected: readonly Skill[];
|
|
14
|
+
readonly unmatched: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AgentSkillResourceSetup {
|
|
18
|
+
readonly resourceLoader: DefaultResourceLoader;
|
|
19
|
+
readonly selectedSkills: readonly Skill[];
|
|
20
|
+
readonly unmatchedSelectors: readonly string[];
|
|
21
|
+
readonly diagnostics: readonly ResourceDiagnostic[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SLASH_SKILL_PATTERN = /\/skill:([a-z0-9][a-z0-9-]{0,63})/gi;
|
|
25
|
+
const SKILLS_FIELD_PATTERN = /(?<!\/)\bskills?\s*[:=]\s*([^\n.;]+)/gi;
|
|
26
|
+
const VERB_SKILL_LIST_PATTERN = /\b(?:include|use|enable|load|provide|allow|grant|with)\s+(?:the\s+|this\s+|these\s+)?skills?\s+([^\n.;]+)/gi;
|
|
27
|
+
const VERB_NAMED_SKILL_PATTERN = /\b(?:include|use|enable|load|provide|allow|grant|with)\s+(?:the\s+|this\s+|these\s+)?(["'`]?)([a-z0-9][a-z0-9 _-]{0,80}?)\1\s+skills?\b/gi;
|
|
28
|
+
const PURPOSE_BOUNDARY_PATTERN = /\s+\b(?:for|to|when|while|so|because|in\s+order\s+to)\b.*$/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve which subagent skills were requested.
|
|
32
|
+
*
|
|
33
|
+
* Subagents are skillless by default. Explicit `opts.skills` wins and suppresses prompt inference;
|
|
34
|
+
* when it is omitted we only infer from clear natural-language opt-ins such as `/skill:name`,
|
|
35
|
+
* `include skill diagnose`, `include this Skill diagnose`, or `use the diagnose skill`.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveAgentSkillRequest(prompt: string, explicitSkills: unknown): AgentSkillRequest {
|
|
38
|
+
if (explicitSkills !== undefined) {
|
|
39
|
+
return { selectors: normalizeExplicitSkillSelectors(explicitSkills), source: "explicit", strict: true };
|
|
40
|
+
}
|
|
41
|
+
return { selectors: extractSkillSelectorsFromText(prompt), source: "prompt", strict: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function extractSkillSelectorsFromText(text: string): string[] {
|
|
45
|
+
const selectors: string[] = [];
|
|
46
|
+
collectPatternMatches(text, SLASH_SKILL_PATTERN, selectors, 1);
|
|
47
|
+
collectListPatternMatches(text, SKILLS_FIELD_PATTERN, selectors);
|
|
48
|
+
collectListPatternMatches(text, VERB_SKILL_LIST_PATTERN, selectors);
|
|
49
|
+
collectPatternMatches(text, VERB_NAMED_SKILL_PATTERN, selectors, 2);
|
|
50
|
+
return uniqueSelectors(selectors);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeSkillSelector(value: string): string {
|
|
54
|
+
return value
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/^\/skill:/i, "")
|
|
57
|
+
.replace(/^skills?\s*[:=]?\s*/i, "")
|
|
58
|
+
.replace(/^(?:named|called)\s+/i, "")
|
|
59
|
+
.replace(/^(?:the|this|these)\s+/i, "")
|
|
60
|
+
.replace(/\s+skills?$/i, "")
|
|
61
|
+
.replace(/^[`"']+|[`"']+$/g, "")
|
|
62
|
+
.trim()
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[\s_]+/g, "-")
|
|
65
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
66
|
+
.replace(/-+/g, "-")
|
|
67
|
+
.replace(/^-|-$/g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function selectAgentSkills(skills: readonly Skill[], selectors: readonly string[]): AgentSkillResolution {
|
|
71
|
+
const selected = new Map<string, Skill>();
|
|
72
|
+
const unmatched: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const selector of selectors) {
|
|
75
|
+
const match = matchSkillSelector(skills, selector);
|
|
76
|
+
if (!match) {
|
|
77
|
+
unmatched.push(selector);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
selected.set(match.name, makeSkillModelInvocable(match));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { selected: [...selected.values()], unmatched };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function createAgentSkillResourceLoader(options: {
|
|
87
|
+
readonly cwd: string;
|
|
88
|
+
readonly prompt: string;
|
|
89
|
+
readonly skills: unknown;
|
|
90
|
+
readonly log?: (message: string) => void;
|
|
91
|
+
}): Promise<AgentSkillResourceSetup> {
|
|
92
|
+
const request = resolveAgentSkillRequest(options.prompt, options.skills);
|
|
93
|
+
let resolution: AgentSkillResolution = { selected: [], unmatched: [] };
|
|
94
|
+
let availableSkillNames: readonly string[] = [];
|
|
95
|
+
|
|
96
|
+
const loader = new DefaultResourceLoader({
|
|
97
|
+
cwd: options.cwd,
|
|
98
|
+
agentDir: getAgentDir(),
|
|
99
|
+
noSkills: request.selectors.length === 0,
|
|
100
|
+
skillsOverride:
|
|
101
|
+
request.selectors.length === 0
|
|
102
|
+
? undefined
|
|
103
|
+
: (current) => {
|
|
104
|
+
availableSkillNames = current.skills.map((skill) => skill.name).sort();
|
|
105
|
+
resolution = selectAgentSkills(current.skills, request.selectors);
|
|
106
|
+
return { skills: [...resolution.selected], diagnostics: current.diagnostics };
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
await loader.reload();
|
|
110
|
+
const { diagnostics } = loader.getSkills();
|
|
111
|
+
logSkillDiagnostics(options.log, diagnostics, request.selectors.length > 0);
|
|
112
|
+
|
|
113
|
+
if (request.strict && resolution.unmatched.length > 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Unknown subagent skill${resolution.unmatched.length === 1 ? "" : "s"}: ${resolution.unmatched.join(", ")}. Available: ${
|
|
116
|
+
availableSkillNames.join(", ") || "(none)"
|
|
117
|
+
}.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!request.strict && resolution.unmatched.length > 0) {
|
|
122
|
+
options.log?.(`Ignoring unrecognized subagent skill mention${resolution.unmatched.length === 1 ? "" : "s"}: ${resolution.unmatched.join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { resourceLoader: loader, selectedSkills: resolution.selected, unmatchedSelectors: resolution.unmatched, diagnostics };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function appendSkillReminder(prompt: string, skills: readonly Pick<Skill, "name" | "filePath">[]): string {
|
|
129
|
+
if (skills.length === 0) return prompt;
|
|
130
|
+
const skillList = skills.map((skill) => `${skill.name} (${skill.filePath})`).join(", ");
|
|
131
|
+
return `${prompt}\n\nWorkflow subagent skills enabled: ${skillList}. If you rely on an enabled skill, first read its SKILL.md with the read tool and follow its instructions. No other skills are available in this subagent.`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function collectPatternMatches(text: string, pattern: RegExp, selectors: string[], captureIndex: number): void {
|
|
135
|
+
pattern.lastIndex = 0;
|
|
136
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
137
|
+
const value = match[captureIndex];
|
|
138
|
+
if (value !== undefined) selectors.push(value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectListPatternMatches(text: string, pattern: RegExp, selectors: string[]): void {
|
|
143
|
+
pattern.lastIndex = 0;
|
|
144
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
145
|
+
const value = match[1];
|
|
146
|
+
if (value === undefined) continue;
|
|
147
|
+
selectors.push(...parseSkillList(value));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseSkillList(fragment: string): string[] {
|
|
152
|
+
const scoped = fragment.replace(PURPOSE_BOUNDARY_PATTERN, "");
|
|
153
|
+
return scoped
|
|
154
|
+
.split(/\s*(?:,|\s+\band\b\s+|&|\+)\s*/i)
|
|
155
|
+
.map((part) => part.trim())
|
|
156
|
+
.filter((part) => part.length > 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeExplicitSkillSelectors(value: unknown): string[] {
|
|
160
|
+
if (!Array.isArray(value)) {
|
|
161
|
+
throw new Error("Invalid subagent skills option: expected an array of skill names.");
|
|
162
|
+
}
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
const result: string[] = [];
|
|
165
|
+
for (const item of value) {
|
|
166
|
+
if (typeof item !== "string") {
|
|
167
|
+
throw new Error("Invalid subagent skills option: every skill name must be a string.");
|
|
168
|
+
}
|
|
169
|
+
const normalized = normalizeSkillSelector(item);
|
|
170
|
+
if (!normalized || isGenericSkillWord(normalized)) {
|
|
171
|
+
throw new Error(`Invalid subagent skills option: \"${item}\" is not a valid skill name.`);
|
|
172
|
+
}
|
|
173
|
+
if (seen.has(normalized)) continue;
|
|
174
|
+
seen.add(normalized);
|
|
175
|
+
result.push(normalized);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function logSkillDiagnostics(log: ((message: string) => void) | undefined, diagnostics: readonly ResourceDiagnostic[], requested: boolean): void {
|
|
181
|
+
if (!requested || !log) return;
|
|
182
|
+
for (const diagnostic of diagnostics) {
|
|
183
|
+
const where = diagnostic.path ? ` (${diagnostic.path})` : "";
|
|
184
|
+
log(`skill ${diagnostic.type}: ${diagnostic.message}${where}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function uniqueSelectors(values: Iterable<string>): string[] {
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
const result: string[] = [];
|
|
191
|
+
for (const value of values) {
|
|
192
|
+
const normalized = normalizeSkillSelector(value);
|
|
193
|
+
if (!normalized || isGenericSkillWord(normalized) || seen.has(normalized)) continue;
|
|
194
|
+
seen.add(normalized);
|
|
195
|
+
result.push(normalized);
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isGenericSkillWord(value: string): boolean {
|
|
201
|
+
return value === "skill" || value === "skills" || value === "the" || value === "this" || value === "these" || value === "that";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matchSkillSelector(skills: readonly Skill[], selector: string): Skill | undefined {
|
|
205
|
+
const normalized = normalizeSkillSelector(selector);
|
|
206
|
+
if (!normalized) return undefined;
|
|
207
|
+
|
|
208
|
+
const exact = skills.find((skill) => skill.name.toLowerCase() === normalized);
|
|
209
|
+
if (exact) return exact;
|
|
210
|
+
|
|
211
|
+
const selectorWords = normalized.replace(/-/g, " ");
|
|
212
|
+
const fuzzy = skills.filter((skill) => {
|
|
213
|
+
const name = skill.name.toLowerCase();
|
|
214
|
+
const words = name.replace(/-/g, " ");
|
|
215
|
+
return name.includes(normalized) || words.includes(selectorWords);
|
|
216
|
+
});
|
|
217
|
+
return fuzzy.length === 1 ? fuzzy[0] : undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function makeSkillModelInvocable(skill: Skill): Skill {
|
|
221
|
+
return skill.disableModelInvocation ? { ...skill, disableModelInvocation: false } : skill;
|
|
222
|
+
}
|
|
@@ -32,6 +32,7 @@ Inline workflow rules:
|
|
|
32
32
|
- Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
|
|
33
33
|
- Do not use import statements or dynamic import() in inline workflow scripts.
|
|
34
34
|
- Set thinkingLevel explicitly on each agent() call so fan-out remains bounded.
|
|
35
|
+
- Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set.
|
|
35
36
|
- Provide exactly one of workflow.name or workflow.script.
|
|
36
37
|
`;
|
|
37
38
|
|
|
@@ -4,6 +4,7 @@ import { linkAbortSignal } from "./cancellation.ts";
|
|
|
4
4
|
import { runAgent, type RunContext } from "./agent-runner.ts";
|
|
5
5
|
import { ProgressTracker } from "./progress.ts";
|
|
6
6
|
import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
|
|
7
|
+
import { createWorkflowUsageRecorder } from "./usage.ts";
|
|
7
8
|
import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
|
|
8
9
|
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
9
10
|
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
@@ -58,6 +59,7 @@ export async function runWorkflow(
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
|
|
62
|
+
const usage = createWorkflowUsageRecorder();
|
|
61
63
|
const runAbortController = new AbortController();
|
|
62
64
|
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
63
65
|
const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
|
|
@@ -69,6 +71,7 @@ export async function runWorkflow(
|
|
|
69
71
|
progress,
|
|
70
72
|
signal: runAbortController.signal,
|
|
71
73
|
perf,
|
|
74
|
+
usage,
|
|
72
75
|
};
|
|
73
76
|
|
|
74
77
|
try {
|
|
@@ -84,6 +87,7 @@ export async function runWorkflow(
|
|
|
84
87
|
);
|
|
85
88
|
} finally {
|
|
86
89
|
try {
|
|
90
|
+
resolvedOptions.onUsageSnapshot?.(usage.snapshot());
|
|
87
91
|
const snapshot = perf.snapshot();
|
|
88
92
|
if (resolvedOptions.perf) {
|
|
89
93
|
resolvedOptions.onPerfSnapshot?.(snapshot);
|