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.
- package/.pi/extensions/pi-workflow-engine/index.ts +134 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +276 -24
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +222 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +83 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +15 -69
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +24 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +200 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +96 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +66 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +81 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +14 -5
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +12 -8
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +6 -2
- package/README.md +11 -1
- package/USAGE.md +53 -8
- package/package.json +8 -8
|
@@ -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
|
-
|
|
213
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? `${
|
|
249
|
-
:
|
|
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
|
-
|
|
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) {
|