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.
@@ -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
- return `## Workflow: ${name}\n\n${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}`;
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 AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
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 choices = [AUTHOR_TEMP_WORKFLOW_CHOICE, ...[...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`)];
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 === AUTHOR_TEMP_WORKFLOW_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
- return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(resultName, result, perf) };
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
- // 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
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
- ? `${prompt}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
183
- : prompt;
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);