pi-workflow-engine 0.8.0 → 0.10.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.
@@ -5,22 +5,39 @@ import type { AgentOptions } from "./types.ts";
5
5
  import type { Semaphore } from "./concurrency.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
7
  import type { WorkflowUsageSink } from "./usage.ts";
8
+ import { WorkflowBudgetExceededError, type WorkflowBudget } from "./budget.ts";
8
9
  import { throwIfAborted } from "./cancellation.ts";
10
+ import { appendSkillReminder, createAgentSkillResourceLoader, extractSkillSelectorsFromText } from "./agent-skills.ts";
11
+ import { agentJournalKey, type WorkflowJournal } from "./journal.ts";
12
+ import type { WorktreeRegistry } from "./worktree.ts";
9
13
 
10
14
  /** Name of the synthetic terminating tool that carries structured output. */
11
15
  const FINAL_TOOL = "final_answer";
16
+ const SCHEMA_REPROMPT_ATTEMPTS = 1;
17
+ const SCHEMA_REPROMPT =
18
+ `You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` +
19
+ `Call ${FINAL_TOOL} now with your final answer as its arguments. Do not reply with plain text.`;
20
+ const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
21
+ const DEFAULT_SEARCH_BASE_TOOLS = ["read", "bash", "grep", "find", "ls"];
12
22
 
13
23
  export interface AgentRunnerEvent {
14
24
  readonly type: string;
15
25
  readonly toolName?: string;
16
26
  }
17
27
 
28
+ export interface AgentRunnerToolInfo {
29
+ readonly name: string;
30
+ readonly description?: string;
31
+ }
32
+
18
33
  export interface AgentRunnerSession {
19
34
  readonly state: { readonly messages: readonly unknown[] };
20
35
  prompt(text: string): Promise<void>;
21
36
  subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
22
37
  dispose(): void;
23
38
  abort(): Promise<void>;
39
+ getAllTools?(): readonly AgentRunnerToolInfo[];
40
+ setActiveToolsByName?(toolNames: readonly string[]): void;
24
41
  }
25
42
 
26
43
  export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
@@ -44,6 +61,9 @@ export interface RunContext {
44
61
  signal: AbortSignal | undefined;
45
62
  perf: PerfSink;
46
63
  usage: WorkflowUsageSink;
64
+ budget: WorkflowBudget;
65
+ journal: WorkflowJournal;
66
+ worktrees: WorktreeRegistry;
47
67
  createSession?: CreateAgentSession;
48
68
  }
49
69
 
@@ -156,6 +176,173 @@ function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerS
156
176
  return () => signal.removeEventListener("abort", onAbort);
157
177
  }
158
178
 
179
+ type ToolSessionOptions = Pick<CreateAgentSessionOptions, "tools" | "excludeTools">;
180
+
181
+ interface ToolSelection {
182
+ readonly sessionOptions: ToolSessionOptions;
183
+ readonly fallbackSessionOptions: ToolSessionOptions;
184
+ readonly activeTools?: readonly string[];
185
+ readonly toolHints: NonNullable<AgentOptions["toolHints"]>;
186
+ }
187
+
188
+ interface DynamicToolSession extends AgentRunnerSession {
189
+ getAllTools(): readonly AgentRunnerToolInfo[];
190
+ setActiveToolsByName(toolNames: readonly string[]): void;
191
+ }
192
+
193
+ function buildToolList(opts: AgentOptions, skillsEnabled: boolean, fallback?: readonly string[]): string[] | undefined {
194
+ const allow = opts.tools ? [...opts.tools] : fallback ? [...fallback] : undefined;
195
+ if (!allow) return undefined;
196
+ if (skillsEnabled && !allow.includes("read")) allow.push("read");
197
+ if (opts.schema && !allow.includes(FINAL_TOOL)) allow.push(FINAL_TOOL);
198
+ return allow;
199
+ }
200
+
201
+ function buildToolSelection(opts: AgentOptions, skillsEnabled: boolean): ToolSelection {
202
+ const toolHints = opts.toolHints ?? [];
203
+ const fallback = toolHints.includes("search") ? DEFAULT_SEARCH_BASE_TOOLS : undefined;
204
+ const activeTools = buildToolList(opts, skillsEnabled, fallback);
205
+ const strictSessionOptions = { tools: activeTools ? [...activeTools] : undefined };
206
+
207
+ if (toolHints.length === 0) {
208
+ return { sessionOptions: strictSessionOptions, fallbackSessionOptions: strictSessionOptions, activeTools, toolHints };
209
+ }
210
+
211
+ const explicitlyActive = new Set(activeTools ?? []);
212
+ return {
213
+ sessionOptions: { excludeTools: BUILTIN_TOOL_NAMES.filter((name) => !explicitlyActive.has(name)) },
214
+ // If dynamic tool APIs are unavailable, fail closed to the concrete tools we
215
+ // already know instead of keeping every extension/custom tool active.
216
+ fallbackSessionOptions: { tools: activeTools ? [...activeTools] : [] },
217
+ activeTools,
218
+ toolHints,
219
+ };
220
+ }
221
+
222
+ function hasDynamicToolApis(session: AgentRunnerSession): session is DynamicToolSession {
223
+ return typeof session.getAllTools === "function" && typeof session.setActiveToolsByName === "function";
224
+ }
225
+
226
+ function applyDynamicToolHints(session: AgentRunnerSession, selection: ToolSelection): boolean {
227
+ if (selection.toolHints.length === 0) return true;
228
+ if (!hasDynamicToolApis(session)) return false;
229
+
230
+ const active = new Set(selection.activeTools ?? []);
231
+ for (const tool of session.getAllTools()) {
232
+ if (matchesToolHints(tool, selection.toolHints)) active.add(tool.name);
233
+ }
234
+ session.setActiveToolsByName([...active]);
235
+ return true;
236
+ }
237
+
238
+ function matchesToolHints(tool: AgentRunnerToolInfo, toolHints: readonly string[]): boolean {
239
+ return toolHints.some((hint) => hint === "search" && isSearchLikeTool(tool));
240
+ }
241
+
242
+ function isSearchLikeTool(tool: AgentRunnerToolInfo): boolean {
243
+ if (isClearlyMutatingToolName(tool.name)) return false;
244
+ const name = tool.name.toLowerCase();
245
+ if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
246
+ const description = tool.description?.toLowerCase() ?? "";
247
+ return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(description);
248
+ }
249
+
250
+ function isClearlyMutatingToolName(name: string): boolean {
251
+ return /(?:^|[-_])(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)(?:$|[-_])/.test(name.toLowerCase());
252
+ }
253
+
254
+ function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: AgentOptions): boolean {
255
+ if (!rc.createSession) return true;
256
+ if (opts.skills !== undefined) return true;
257
+ return extractSkillSelectorsFromText(prompt).length > 0;
258
+ }
259
+
260
+ /**
261
+ * Refuse to start a new subagent once the run is over budget. Called before queueing and
262
+ * again after a queued agent acquires a concurrency slot, so an exhausted run stops spending
263
+ * immediately rather than starting stale queued work.
264
+ *
265
+ * Policy: accept overshoot (matches the built-in Workflow tool). `budget.spent()` counts
266
+ * only COMPLETED agents (usage is recorded on session dispose), so agents admitted together
267
+ * can each pass this check and then collectively overshoot `budget.total` — we tolerate that
268
+ * rather than reserving per-agent estimates. An uncapped run (`budget.total === null`,
269
+ * `remaining()` is Infinity) never throws.
270
+ *
271
+ * Heads-up: a throw here is a recoverable branch failure inside `parallel`/`pipeline`, so the
272
+ * over-budget slot becomes null while already-started siblings can finish. The intended primary
273
+ * usage is the loop guard `while (budget.total && budget.remaining() > N) { await agent(...) }`,
274
+ * where this throw is only a backstop.
275
+ */
276
+ function ensureWithinBudget(budget: WorkflowBudget): void {
277
+ if (budget.total !== null && budget.remaining() <= 0) {
278
+ throw new WorkflowBudgetExceededError(budget.total, budget.spent());
279
+ }
280
+ }
281
+
282
+ interface AgentWorkspace {
283
+ readonly cwd: string;
284
+ wrapResult(result: unknown): Promise<unknown>;
285
+ dispose(): Promise<void>;
286
+ }
287
+
288
+ async function createAgentWorkspace(rc: RunContext, opts: AgentOptions, label: string): Promise<AgentWorkspace> {
289
+ if (opts.isolation !== "worktree") {
290
+ return {
291
+ cwd: rc.cwd,
292
+ async wrapResult(result) {
293
+ return result;
294
+ },
295
+ async dispose() {},
296
+ };
297
+ }
298
+
299
+ const probe = await rc.worktrees.probe(rc.signal);
300
+ if (!probe.ok) {
301
+ throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
302
+ }
303
+ if (!probe.inside) {
304
+ throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
305
+ }
306
+
307
+ const added = await rc.worktrees.add(rc.signal);
308
+ if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
309
+ const worktreePath = added.path;
310
+ rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
311
+
312
+ return {
313
+ cwd: worktreePath,
314
+ async wrapResult(result) {
315
+ return await isolatedResult(rc, worktreePath, result);
316
+ },
317
+ async dispose() {
318
+ const removed = await rc.worktrees.remove(worktreePath);
319
+ if (!removed.ok) {
320
+ rc.progress.log(`${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`);
321
+ }
322
+ },
323
+ };
324
+ }
325
+
326
+ async function disposeAgentWorkspace(rc: RunContext, label: string, workspace: AgentWorkspace | undefined): Promise<void> {
327
+ if (!workspace) return;
328
+ try {
329
+ await workspace.dispose();
330
+ } catch (error) {
331
+ rc.progress.log(`${label}: failed to dispose isolated workspace (${errorMessage(error)})`);
332
+ }
333
+ }
334
+
335
+ async function recordJournalResult(rc: RunContext, label: string, key: string, value: unknown): Promise<void> {
336
+ try {
337
+ const recorded = await rc.journal.record(key, value);
338
+ if (!recorded.ok) {
339
+ rc.progress.log(`${label}: workflow journal write failed (${recorded.error}); future resume may be incomplete`);
340
+ }
341
+ } catch (error) {
342
+ rc.progress.log(`${label}: workflow journal write failed (${errorMessage(error)}); future resume may be incomplete`);
343
+ }
344
+ }
345
+
159
346
  /**
160
347
  * Run one subagent to completion in an isolated in-memory session.
161
348
  *
@@ -173,6 +360,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
173
360
 
174
361
  return await rc.perf.time("agent.total_ms", async () => {
175
362
  throwIfAborted(rc.signal);
363
+ const journalKey = agentJournalKey(prompt, opts);
364
+ const cached = rc.journal.lookup(journalKey);
365
+ if (cached.hit) {
366
+ rc.progress.log(`${label}: using cached result from workflow journal`);
367
+ rc.perf.counter("agent.cache_hit", 1, tags);
368
+ await recordJournalResult(rc, label, journalKey, cached.value);
369
+ return cached.value;
370
+ }
371
+ // Stop spending the moment the run is over budget — before queueing.
372
+ ensureWithinBudget(rc.budget);
176
373
  // Track queued agents before acquiring a global concurrency slot.
177
374
  const rowId = rc.progress.agentQueued(opts.phase, label);
178
375
  let failureHandled = false;
@@ -180,6 +377,8 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
180
377
  return await rc.semaphore.run(
181
378
  async () => {
182
379
  throwIfAborted(rc.signal);
380
+ // Re-check after waiting for a slot; earlier agents may have exhausted the budget.
381
+ ensureWithinBudget(rc.budget);
183
382
  rc.progress.agentStart(opts.phase, label, rowId);
184
383
 
185
384
  let captured: unknown = null;
@@ -187,6 +386,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
187
386
  let session: AgentRunnerSession | undefined;
188
387
  let usageRecorded = false;
189
388
  let unsubscribe: (() => void) | undefined;
389
+ let workspace: AgentWorkspace | undefined;
190
390
  const recordUsage = (activeSession: AgentRunnerSession): void => {
191
391
  if (usageRecorded) return;
192
392
  usageRecorded = true;
@@ -209,33 +409,62 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
209
409
  : [];
210
410
 
211
411
  try {
212
- // When the workflow restricts built-in tools, keep the terminating tool visible.
213
- const allow = opts.tools
214
- ? opts.schema
215
- ? [...opts.tools, FINAL_TOOL]
216
- : opts.tools
217
- : undefined;
412
+ workspace = await createAgentWorkspace(rc, opts, label);
413
+ const agentCwd = workspace.cwd;
218
414
 
219
415
  const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
416
+ const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
417
+ ? await rc.perf.time(
418
+ "agent.skills_ms",
419
+ () =>
420
+ createAgentSkillResourceLoader({
421
+ cwd: agentCwd,
422
+ prompt,
423
+ skills: opts.skills,
424
+ log: (message) => rc.progress.log(`${label}: ${message}`),
425
+ }),
426
+ tags,
427
+ )
428
+ : undefined;
429
+ const selectedSkills = skillSetup?.selectedSkills ?? [];
430
+ // When the workflow restricts tools, keep the terminating tool visible.
431
+ // Skill opt-ins also need read so the subagent can load SKILL.md on demand.
432
+ const toolSelection = buildToolSelection(opts, selectedSkills.length > 0);
433
+
220
434
  const createSessionForRun = rc.createSession ?? defaultCreateSession;
435
+ const createSubagentSession = (sessionOptions: ToolSessionOptions) =>
436
+ rc.perf.time(
437
+ "agent.create_session_ms",
438
+ () =>
439
+ createSessionForRun({
440
+ cwd: agentCwd,
441
+ model,
442
+ modelRegistry: rc.modelRegistry as ModelRegistry,
443
+ thinkingLevel: opts.thinkingLevel,
444
+ tools: sessionOptions.tools,
445
+ excludeTools: sessionOptions.excludeTools,
446
+ customTools,
447
+ resourceLoader: skillSetup?.resourceLoader,
448
+ sessionManager: SessionManager.inMemory(agentCwd),
449
+ }),
450
+ tags,
451
+ );
221
452
 
222
453
  throwIfAborted(rc.signal);
223
- const created = await rc.perf.time(
224
- "agent.create_session_ms",
225
- () =>
226
- createSessionForRun({
227
- cwd: rc.cwd,
228
- model,
229
- modelRegistry: rc.modelRegistry as ModelRegistry,
230
- thinkingLevel: opts.thinkingLevel,
231
- tools: allow,
232
- customTools,
233
- sessionManager: SessionManager.inMemory(rc.cwd),
234
- }),
235
- tags,
236
- );
454
+ let created = await createSubagentSession(toolSelection.sessionOptions);
237
455
  session = created.session;
238
- const activeSession = session;
456
+ let activeSession = session;
457
+ if (!applyDynamicToolHints(activeSession, toolSelection)) {
458
+ rc.progress.log(`${label}: dynamic tool hints unavailable; falling back to concrete tools only`);
459
+ rc.perf.counter("agent.tool_hint_fallback", 1, tags);
460
+ const dynamicSession = activeSession;
461
+ session = undefined;
462
+ rc.perf.timeSync("agent.dispose_ms", () => dynamicSession.dispose(), tags);
463
+ throwIfAborted(rc.signal);
464
+ created = await createSubagentSession(toolSelection.fallbackSessionOptions);
465
+ session = created.session;
466
+ activeSession = session;
467
+ }
239
468
  throwIfAborted(rc.signal);
240
469
 
241
470
  unsubscribe = activeSession.subscribe((event) => {
@@ -244,20 +473,29 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
244
473
  }
245
474
  });
246
475
 
476
+ const promptedWithSkills = appendSkillReminder(prompt, selectedSkills);
247
477
  const finalPrompt = opts.schema
248
- ? `${prompt}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
249
- : prompt;
478
+ ? `${promptedWithSkills}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
479
+ : promptedWithSkills;
250
480
  throwIfAborted(rc.signal);
251
481
  const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
252
482
  try {
253
483
  await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(finalPrompt), tags);
484
+ if (opts.schema) {
485
+ for (let attempt = 0; captured === null && attempt < SCHEMA_REPROMPT_ATTEMPTS; attempt++) {
486
+ throwIfAborted(rc.signal);
487
+ rc.progress.log(`${label}: no final answer; re-prompting (${attempt + 1}/${SCHEMA_REPROMPT_ATTEMPTS})`);
488
+ rc.perf.counter("agent.structured_reprompt", 1, tags);
489
+ await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(SCHEMA_REPROMPT), tags);
490
+ }
491
+ }
254
492
  } finally {
255
493
  unlinkPromptAbort();
256
494
  }
257
495
  recordUsage(activeSession);
258
496
  throwIfAborted(rc.signal);
259
497
 
260
- return rc.perf.timeSync(
498
+ const rawResult = rc.perf.timeSync(
261
499
  "agent.extract_result_ms",
262
500
  () => {
263
501
  if (opts.schema) {
@@ -271,6 +509,9 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
271
509
  },
272
510
  tags,
273
511
  );
512
+ const result = await workspace.wrapResult(rawResult);
513
+ await recordJournalResult(rc, label, journalKey, result);
514
+ return result;
274
515
  } catch (error) {
275
516
  failed = true;
276
517
  failureHandled = true;
@@ -290,6 +531,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
290
531
  tags,
291
532
  );
292
533
  }
534
+ await disposeAgentWorkspace(rc, label, workspace);
293
535
  if (!failed) rc.progress.agentDone(label, rowId);
294
536
  }
295
537
  },
@@ -304,3 +546,13 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
304
546
  }
305
547
  }, tags);
306
548
  }
549
+
550
+ async function isolatedResult(rc: RunContext, worktreePath: string, result: unknown): Promise<{ readonly result: unknown; readonly patch: string; readonly changed: boolean }> {
551
+ const patch = await rc.worktrees.capturePatch(worktreePath, rc.signal);
552
+ if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
553
+ return { result, patch: patch.patch, changed: patch.changed };
554
+ }
555
+
556
+ function errorMessage(error: unknown): string {
557
+ return error instanceof Error ? error.message : String(error);
558
+ }
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ import type { WorkflowUsageSink } from "./usage.ts";
2
+
3
+ /**
4
+ * Thrown by `agent()` when a run has consumed its token budget. Carries the
5
+ * configured ceiling and the output tokens spent so callers can report both.
6
+ */
7
+ export class WorkflowBudgetExceededError extends Error {
8
+ constructor(
9
+ readonly total: number,
10
+ readonly spent: number,
11
+ ) {
12
+ super(`Workflow token budget exhausted: spent ${spent} output tokens of ${total}.`);
13
+ this.name = "WorkflowBudgetExceededError";
14
+ }
15
+ }
16
+
17
+ /**
18
+ * The budget handle exposed on `WorkflowApi`. Mirrors the built-in Workflow tool:
19
+ * `total` is the output-token ceiling (or `null` when unset), `spent()` is the
20
+ * output tokens consumed by completed agents this run, and `remaining()` is the
21
+ * headroom left (`Infinity` when there is no ceiling).
22
+ *
23
+ * `spent()`/`remaining()` are LIVE — they re-read usage on every call so that
24
+ * `while (budget.total && budget.remaining() > N) { await agent(...) }` loops and
25
+ * `Math.floor(budget.total / N)` fleet-scaling see up-to-date numbers.
26
+ */
27
+ export interface WorkflowBudget {
28
+ readonly total: number | null;
29
+ /** Output tokens consumed by completed agents this run (sub-workflows included). */
30
+ spent(): number;
31
+ /** `total - spent()` clamped at 0, or `Infinity` when `total` is null. */
32
+ remaining(): number;
33
+ }
34
+
35
+ /**
36
+ * Build a budget backed by the run's usage sink. `spent()` reads
37
+ * `usage.snapshot().totals.output` — the same output-token sum surfaced in the
38
+ * usage line — so no separate token accounting is needed.
39
+ */
40
+ export function createBudget(total: number | null, usage: WorkflowUsageSink): WorkflowBudget {
41
+ return {
42
+ total,
43
+ spent: () => usage.snapshot().totals.output,
44
+ remaining: () => (total === null ? Infinity : Math.max(0, total - usage.snapshot().totals.output)),
45
+ };
46
+ }
@@ -16,6 +16,13 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
16
16
  if (signal?.aborted) throw abortReason(signal);
17
17
  }
18
18
 
19
+ export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
20
+ if (signal?.aborted) return true;
21
+ if (error instanceof WorkflowAbortError) return true;
22
+ if (typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") return true;
23
+ return false;
24
+ }
25
+
19
26
  export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
20
27
  if (!parent) return () => {};
21
28
  if (parent.aborted) {