pi-workflow-engine 0.9.0 → 0.10.1
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 +136 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +257 -23
- 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/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 +59 -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 +10 -1
- package/USAGE.md +50 -8
- package/package.json +8 -8
|
@@ -5,23 +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";
|
|
9
10
|
import { appendSkillReminder, createAgentSkillResourceLoader, extractSkillSelectorsFromText } from "./agent-skills.ts";
|
|
11
|
+
import { agentJournalKey, type WorkflowJournal } from "./journal.ts";
|
|
12
|
+
import type { WorktreeRegistry } from "./worktree.ts";
|
|
10
13
|
|
|
11
14
|
/** Name of the synthetic terminating tool that carries structured output. */
|
|
12
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"];
|
|
13
22
|
|
|
14
23
|
export interface AgentRunnerEvent {
|
|
15
24
|
readonly type: string;
|
|
16
25
|
readonly toolName?: string;
|
|
17
26
|
}
|
|
18
27
|
|
|
28
|
+
export interface AgentRunnerToolInfo {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export interface AgentRunnerSession {
|
|
20
34
|
readonly state: { readonly messages: readonly unknown[] };
|
|
21
35
|
prompt(text: string): Promise<void>;
|
|
22
36
|
subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
|
|
23
37
|
dispose(): void;
|
|
24
38
|
abort(): Promise<void>;
|
|
39
|
+
getAllTools?(): readonly AgentRunnerToolInfo[];
|
|
40
|
+
setActiveToolsByName?(toolNames: readonly string[]): void;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
|
|
@@ -45,6 +61,9 @@ export interface RunContext {
|
|
|
45
61
|
signal: AbortSignal | undefined;
|
|
46
62
|
perf: PerfSink;
|
|
47
63
|
usage: WorkflowUsageSink;
|
|
64
|
+
budget: WorkflowBudget;
|
|
65
|
+
journal: WorkflowJournal;
|
|
66
|
+
worktrees: WorktreeRegistry;
|
|
48
67
|
createSession?: CreateAgentSession;
|
|
49
68
|
}
|
|
50
69
|
|
|
@@ -157,20 +176,173 @@ function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerS
|
|
|
157
176
|
return () => signal.removeEventListener("abort", onAbort);
|
|
158
177
|
}
|
|
159
178
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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;
|
|
163
196
|
if (skillsEnabled && !allow.includes("read")) allow.push("read");
|
|
164
197
|
if (opts.schema && !allow.includes(FINAL_TOOL)) allow.push(FINAL_TOOL);
|
|
165
198
|
return allow;
|
|
166
199
|
}
|
|
167
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
|
+
|
|
168
254
|
function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: AgentOptions): boolean {
|
|
169
255
|
if (!rc.createSession) return true;
|
|
170
256
|
if (opts.skills !== undefined) return true;
|
|
171
257
|
return extractSkillSelectorsFromText(prompt).length > 0;
|
|
172
258
|
}
|
|
173
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
|
+
|
|
174
346
|
/**
|
|
175
347
|
* Run one subagent to completion in an isolated in-memory session.
|
|
176
348
|
*
|
|
@@ -182,12 +354,26 @@ function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: A
|
|
|
182
354
|
* no event-stream parsing is needed.
|
|
183
355
|
*/
|
|
184
356
|
export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
|
|
357
|
+
if (typeof prompt !== "string") {
|
|
358
|
+
throw new Error(`agent() prompt must be a string; received ${describeAgentPrompt(prompt)}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
185
361
|
const label = opts.label ?? "agent";
|
|
186
362
|
const phase = opts.phase ?? "Workflow";
|
|
187
363
|
const tags = { label, phase };
|
|
188
364
|
|
|
189
365
|
return await rc.perf.time("agent.total_ms", async () => {
|
|
190
366
|
throwIfAborted(rc.signal);
|
|
367
|
+
const journalKey = agentJournalKey(prompt, opts);
|
|
368
|
+
const cached = rc.journal.lookup(journalKey);
|
|
369
|
+
if (cached.hit) {
|
|
370
|
+
rc.progress.log(`${label}: using cached result from workflow journal`);
|
|
371
|
+
rc.perf.counter("agent.cache_hit", 1, tags);
|
|
372
|
+
await recordJournalResult(rc, label, journalKey, cached.value);
|
|
373
|
+
return cached.value;
|
|
374
|
+
}
|
|
375
|
+
// Stop spending the moment the run is over budget — before queueing.
|
|
376
|
+
ensureWithinBudget(rc.budget);
|
|
191
377
|
// Track queued agents before acquiring a global concurrency slot.
|
|
192
378
|
const rowId = rc.progress.agentQueued(opts.phase, label);
|
|
193
379
|
let failureHandled = false;
|
|
@@ -195,6 +381,8 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
195
381
|
return await rc.semaphore.run(
|
|
196
382
|
async () => {
|
|
197
383
|
throwIfAborted(rc.signal);
|
|
384
|
+
// Re-check after waiting for a slot; earlier agents may have exhausted the budget.
|
|
385
|
+
ensureWithinBudget(rc.budget);
|
|
198
386
|
rc.progress.agentStart(opts.phase, label, rowId);
|
|
199
387
|
|
|
200
388
|
let captured: unknown = null;
|
|
@@ -202,6 +390,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
202
390
|
let session: AgentRunnerSession | undefined;
|
|
203
391
|
let usageRecorded = false;
|
|
204
392
|
let unsubscribe: (() => void) | undefined;
|
|
393
|
+
let workspace: AgentWorkspace | undefined;
|
|
205
394
|
const recordUsage = (activeSession: AgentRunnerSession): void => {
|
|
206
395
|
if (usageRecorded) return;
|
|
207
396
|
usageRecorded = true;
|
|
@@ -224,13 +413,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
224
413
|
: [];
|
|
225
414
|
|
|
226
415
|
try {
|
|
416
|
+
workspace = await createAgentWorkspace(rc, opts, label);
|
|
417
|
+
const agentCwd = workspace.cwd;
|
|
418
|
+
|
|
227
419
|
const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
|
|
228
420
|
const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
|
|
229
421
|
? await rc.perf.time(
|
|
230
422
|
"agent.skills_ms",
|
|
231
423
|
() =>
|
|
232
424
|
createAgentSkillResourceLoader({
|
|
233
|
-
cwd:
|
|
425
|
+
cwd: agentCwd,
|
|
234
426
|
prompt,
|
|
235
427
|
skills: opts.skills,
|
|
236
428
|
log: (message) => rc.progress.log(`${label}: ${message}`),
|
|
@@ -239,30 +431,44 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
239
431
|
)
|
|
240
432
|
: undefined;
|
|
241
433
|
const selectedSkills = skillSetup?.selectedSkills ?? [];
|
|
242
|
-
// When the workflow restricts
|
|
434
|
+
// When the workflow restricts tools, keep the terminating tool visible.
|
|
243
435
|
// Skill opt-ins also need read so the subagent can load SKILL.md on demand.
|
|
244
|
-
const
|
|
436
|
+
const toolSelection = buildToolSelection(opts, selectedSkills.length > 0);
|
|
245
437
|
|
|
246
438
|
const createSessionForRun = rc.createSession ?? defaultCreateSession;
|
|
439
|
+
const createSubagentSession = (sessionOptions: ToolSessionOptions) =>
|
|
440
|
+
rc.perf.time(
|
|
441
|
+
"agent.create_session_ms",
|
|
442
|
+
() =>
|
|
443
|
+
createSessionForRun({
|
|
444
|
+
cwd: agentCwd,
|
|
445
|
+
model,
|
|
446
|
+
modelRegistry: rc.modelRegistry as ModelRegistry,
|
|
447
|
+
thinkingLevel: opts.thinkingLevel,
|
|
448
|
+
tools: sessionOptions.tools,
|
|
449
|
+
excludeTools: sessionOptions.excludeTools,
|
|
450
|
+
customTools,
|
|
451
|
+
resourceLoader: skillSetup?.resourceLoader,
|
|
452
|
+
sessionManager: SessionManager.inMemory(agentCwd),
|
|
453
|
+
}),
|
|
454
|
+
tags,
|
|
455
|
+
);
|
|
247
456
|
|
|
248
457
|
throwIfAborted(rc.signal);
|
|
249
|
-
|
|
250
|
-
"agent.create_session_ms",
|
|
251
|
-
() =>
|
|
252
|
-
createSessionForRun({
|
|
253
|
-
cwd: rc.cwd,
|
|
254
|
-
model,
|
|
255
|
-
modelRegistry: rc.modelRegistry as ModelRegistry,
|
|
256
|
-
thinkingLevel: opts.thinkingLevel,
|
|
257
|
-
tools: allow,
|
|
258
|
-
customTools,
|
|
259
|
-
resourceLoader: skillSetup?.resourceLoader,
|
|
260
|
-
sessionManager: SessionManager.inMemory(rc.cwd),
|
|
261
|
-
}),
|
|
262
|
-
tags,
|
|
263
|
-
);
|
|
458
|
+
let created = await createSubagentSession(toolSelection.sessionOptions);
|
|
264
459
|
session = created.session;
|
|
265
|
-
|
|
460
|
+
let activeSession = session;
|
|
461
|
+
if (!applyDynamicToolHints(activeSession, toolSelection)) {
|
|
462
|
+
rc.progress.log(`${label}: dynamic tool hints unavailable; falling back to concrete tools only`);
|
|
463
|
+
rc.perf.counter("agent.tool_hint_fallback", 1, tags);
|
|
464
|
+
const dynamicSession = activeSession;
|
|
465
|
+
session = undefined;
|
|
466
|
+
rc.perf.timeSync("agent.dispose_ms", () => dynamicSession.dispose(), tags);
|
|
467
|
+
throwIfAborted(rc.signal);
|
|
468
|
+
created = await createSubagentSession(toolSelection.fallbackSessionOptions);
|
|
469
|
+
session = created.session;
|
|
470
|
+
activeSession = session;
|
|
471
|
+
}
|
|
266
472
|
throwIfAborted(rc.signal);
|
|
267
473
|
|
|
268
474
|
unsubscribe = activeSession.subscribe((event) => {
|
|
@@ -279,13 +485,21 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
279
485
|
const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
|
|
280
486
|
try {
|
|
281
487
|
await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(finalPrompt), tags);
|
|
488
|
+
if (opts.schema) {
|
|
489
|
+
for (let attempt = 0; captured === null && attempt < SCHEMA_REPROMPT_ATTEMPTS; attempt++) {
|
|
490
|
+
throwIfAborted(rc.signal);
|
|
491
|
+
rc.progress.log(`${label}: no final answer; re-prompting (${attempt + 1}/${SCHEMA_REPROMPT_ATTEMPTS})`);
|
|
492
|
+
rc.perf.counter("agent.structured_reprompt", 1, tags);
|
|
493
|
+
await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(SCHEMA_REPROMPT), tags);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
282
496
|
} finally {
|
|
283
497
|
unlinkPromptAbort();
|
|
284
498
|
}
|
|
285
499
|
recordUsage(activeSession);
|
|
286
500
|
throwIfAborted(rc.signal);
|
|
287
501
|
|
|
288
|
-
|
|
502
|
+
const rawResult = rc.perf.timeSync(
|
|
289
503
|
"agent.extract_result_ms",
|
|
290
504
|
() => {
|
|
291
505
|
if (opts.schema) {
|
|
@@ -299,6 +513,9 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
299
513
|
},
|
|
300
514
|
tags,
|
|
301
515
|
);
|
|
516
|
+
const result = await workspace.wrapResult(rawResult);
|
|
517
|
+
await recordJournalResult(rc, label, journalKey, result);
|
|
518
|
+
return result;
|
|
302
519
|
} catch (error) {
|
|
303
520
|
failed = true;
|
|
304
521
|
failureHandled = true;
|
|
@@ -318,6 +535,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
318
535
|
tags,
|
|
319
536
|
);
|
|
320
537
|
}
|
|
538
|
+
await disposeAgentWorkspace(rc, label, workspace);
|
|
321
539
|
if (!failed) rc.progress.agentDone(label, rowId);
|
|
322
540
|
}
|
|
323
541
|
},
|
|
@@ -332,3 +550,19 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
332
550
|
}
|
|
333
551
|
}, tags);
|
|
334
552
|
}
|
|
553
|
+
|
|
554
|
+
async function isolatedResult(rc: RunContext, worktreePath: string, result: unknown): Promise<{ readonly result: unknown; readonly patch: string; readonly changed: boolean }> {
|
|
555
|
+
const patch = await rc.worktrees.capturePatch(worktreePath, rc.signal);
|
|
556
|
+
if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
|
|
557
|
+
return { result, patch: patch.patch, changed: patch.changed };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function describeAgentPrompt(value: unknown): string {
|
|
561
|
+
if (value === null) return "null";
|
|
562
|
+
if (Array.isArray(value)) return "array";
|
|
563
|
+
return typeof value;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function errorMessage(error: unknown): string {
|
|
567
|
+
return error instanceof Error ? error.message : String(error);
|
|
568
|
+
}
|
|
@@ -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) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { abortReason, throwIfAborted } from "./cancellation.ts";
|
|
2
|
+
import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A counting semaphore: bounds how many async tasks run at once.
|
|
@@ -68,15 +68,24 @@ export interface ParallelOptions {
|
|
|
68
68
|
readonly limit?: number;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
export interface PipelineOptions {
|
|
72
|
+
readonly signal?: AbortSignal;
|
|
73
|
+
readonly abortController?: AbortController;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
|
|
77
|
+
return values.filter((value): value is T => value != null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Run every thunk concurrently and wait for all results; recoverable failures become null slots. */
|
|
81
|
+
export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<Array<T | null>> {
|
|
73
82
|
throwIfAborted(options.signal);
|
|
74
|
-
const results = new Array<T>(thunks.length);
|
|
83
|
+
const results = new Array<T | null>(thunks.length);
|
|
75
84
|
let remaining = thunks.length;
|
|
76
85
|
if (remaining === 0) return results;
|
|
77
86
|
|
|
78
87
|
const limit = normalizeLimit(options.limit, thunks.length);
|
|
79
|
-
return await new Promise<T
|
|
88
|
+
return await new Promise<Array<T | null>>((resolve, reject) => {
|
|
80
89
|
let rejected = false;
|
|
81
90
|
let active = 0;
|
|
82
91
|
let next = 0;
|
|
@@ -88,6 +97,18 @@ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: Para
|
|
|
88
97
|
cleanup();
|
|
89
98
|
reject(error);
|
|
90
99
|
};
|
|
100
|
+
const settleSlot = (index: number, value: T | null) => {
|
|
101
|
+
if (rejected) return;
|
|
102
|
+
active--;
|
|
103
|
+
results[index] = value;
|
|
104
|
+
remaining--;
|
|
105
|
+
if (remaining === 0) {
|
|
106
|
+
cleanup();
|
|
107
|
+
resolve(results);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
launchMore();
|
|
111
|
+
};
|
|
91
112
|
const onAbort = () => rejectOnce(abortReason(options.signal));
|
|
92
113
|
const launchMore = () => {
|
|
93
114
|
while (!rejected && active < limit && next < thunks.length) {
|
|
@@ -99,19 +120,14 @@ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: Para
|
|
|
99
120
|
return thunks[index]();
|
|
100
121
|
})
|
|
101
122
|
.then(
|
|
102
|
-
(value) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
remaining--;
|
|
107
|
-
if (remaining === 0) {
|
|
108
|
-
cleanup();
|
|
109
|
-
resolve(results);
|
|
123
|
+
(value) => settleSlot(index, value),
|
|
124
|
+
(error: unknown) => {
|
|
125
|
+
if (isFatalWorkflowError(error, options.signal)) {
|
|
126
|
+
rejectOnce(error);
|
|
110
127
|
return;
|
|
111
128
|
}
|
|
112
|
-
|
|
129
|
+
settleSlot(index, null);
|
|
113
130
|
},
|
|
114
|
-
(error: unknown) => rejectOnce(error),
|
|
115
131
|
);
|
|
116
132
|
}
|
|
117
133
|
};
|
|
@@ -137,33 +153,77 @@ function normalizeLimit(limit: number | undefined, itemCount: number): number {
|
|
|
137
153
|
export async function pipeline<Item, A>(
|
|
138
154
|
items: readonly Item[],
|
|
139
155
|
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
140
|
-
): Promise<A
|
|
156
|
+
): Promise<Array<A | null>>;
|
|
141
157
|
export async function pipeline<Item, A, B>(
|
|
142
158
|
items: readonly Item[],
|
|
143
159
|
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
144
160
|
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
145
|
-
): Promise<B
|
|
161
|
+
): Promise<Array<B | null>>;
|
|
146
162
|
export async function pipeline<Item, A, B, C>(
|
|
147
163
|
items: readonly Item[],
|
|
148
164
|
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
149
165
|
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
150
166
|
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
151
|
-
): Promise<C
|
|
167
|
+
): Promise<Array<C | null>>;
|
|
152
168
|
export async function pipeline(
|
|
153
169
|
items: readonly unknown[],
|
|
154
170
|
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
155
|
-
): Promise<unknown
|
|
171
|
+
): Promise<Array<unknown | null>>;
|
|
156
172
|
export async function pipeline(
|
|
157
173
|
items: readonly unknown[],
|
|
158
174
|
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
159
|
-
): Promise<unknown
|
|
175
|
+
): Promise<Array<unknown | null>> {
|
|
176
|
+
return pipelineWithOptions(items, stages);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function bindPipeline(options: PipelineOptions): Pipeline {
|
|
180
|
+
async function boundPipeline<Item, A>(
|
|
181
|
+
items: readonly Item[],
|
|
182
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
183
|
+
): Promise<Array<A | null>>;
|
|
184
|
+
async function boundPipeline<Item, A, B>(
|
|
185
|
+
items: readonly Item[],
|
|
186
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
187
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
188
|
+
): Promise<Array<B | null>>;
|
|
189
|
+
async function boundPipeline<Item, A, B, C>(
|
|
190
|
+
items: readonly Item[],
|
|
191
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
192
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
193
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
194
|
+
): Promise<Array<C | null>>;
|
|
195
|
+
async function boundPipeline(
|
|
196
|
+
items: readonly unknown[],
|
|
197
|
+
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
198
|
+
): Promise<Array<unknown | null>> {
|
|
199
|
+
return pipelineWithOptions(items, stages, options);
|
|
200
|
+
}
|
|
201
|
+
return boundPipeline;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function pipelineWithOptions(
|
|
205
|
+
items: readonly unknown[],
|
|
206
|
+
stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>,
|
|
207
|
+
options: PipelineOptions = {},
|
|
208
|
+
): Promise<Array<unknown | null>> {
|
|
209
|
+
throwIfAborted(options.signal);
|
|
160
210
|
return Promise.all(
|
|
161
211
|
items.map(async (item, index) => {
|
|
212
|
+
throwIfAborted(options.signal);
|
|
162
213
|
let acc: unknown = item;
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
try {
|
|
215
|
+
for (const stage of stages) {
|
|
216
|
+
throwIfAborted(options.signal);
|
|
217
|
+
acc = await stage(acc, item, index);
|
|
218
|
+
}
|
|
219
|
+
return acc;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (isFatalWorkflowError(error, options.signal)) {
|
|
222
|
+
options.abortController?.abort(error);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
165
226
|
}
|
|
166
|
-
return acc;
|
|
167
227
|
}),
|
|
168
228
|
);
|
|
169
229
|
}
|