pi-workflow-engine 0.10.2 → 0.11.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.
Files changed (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -120
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. package/package.json +9 -9
@@ -1,560 +1,119 @@
1
- import { createAgentSession, defineTool, SessionManager } from "@earendil-works/pi-coding-agent";
2
- import type { CreateAgentSessionOptions, ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
- import type { Api, Model } from "@earendil-works/pi-ai";
4
- import type { AgentOptions } from "./types.ts";
5
- import type { Semaphore } from "./concurrency.ts";
6
- import type { PerfSink } from "./perf.ts";
7
- import type { WorkflowUsageSink } from "./usage.ts";
8
- import { WorkflowBudgetExceededError, type WorkflowBudget } from "./budget.ts";
1
+ import { assertWorkflowBudgetAvailable } from "./budget.ts";
9
2
  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";
13
-
14
- /** Name of the synthetic terminating tool that carries structured output. */
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"];
22
-
23
- export interface AgentRunnerEvent {
24
- readonly type: string;
25
- readonly toolName?: string;
26
- }
27
-
28
- export interface AgentRunnerToolInfo {
29
- readonly name: string;
30
- readonly description?: string;
31
- }
32
-
33
- export interface AgentRunnerSession {
34
- readonly state: { readonly messages: readonly unknown[] };
35
- prompt(text: string): Promise<void>;
36
- subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
37
- dispose(): void;
38
- abort(): Promise<void>;
39
- getAllTools?(): readonly AgentRunnerToolInfo[];
40
- setActiveToolsByName?(toolNames: readonly string[]): void;
41
- }
42
-
43
- export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
44
-
45
- export interface AgentProgress {
46
- agentQueued(phase: string | undefined, label: string): number;
47
- agentStart(phase: string | undefined, label: string, id?: number): void;
48
- agentTool(label: string, tool: string, id?: number): void;
49
- agentDone(label: string, id?: number): void;
50
- agentFailed(label: string, error: unknown, id?: number): void;
51
- log(message: string): void;
52
- }
53
-
54
- /** Shared per-run context threaded into every agent() call. */
55
- export interface RunContext {
56
- cwd: string;
57
- hostModel: Model<Api> | undefined;
58
- modelRegistry: Pick<ModelRegistry, "find">;
59
- semaphore: Semaphore;
60
- progress: AgentProgress;
61
- signal: AbortSignal | undefined;
62
- perf: PerfSink;
63
- usage: WorkflowUsageSink;
64
- budget: WorkflowBudget;
65
- journal: WorkflowJournal;
66
- worktrees: WorktreeRegistry;
67
- createSession?: CreateAgentSession;
68
- }
69
-
70
- export interface ResolvedAgentModelRequest {
71
- readonly ref: string;
72
- readonly provider: string;
73
- readonly id: string;
74
- }
75
-
76
- export interface ResolvedAgentModel {
77
- readonly model: Model<Api> | undefined;
78
- readonly requested: ResolvedAgentModelRequest | undefined;
79
- }
80
-
81
- function parseAgentModelRef(modelRef: string): ResolvedAgentModelRequest {
82
- const normalized = modelRef.trim();
83
- if (normalized.length === 0) {
84
- throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
85
- }
86
- if (normalized !== modelRef) {
87
- throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
88
- }
89
-
90
- const slash = modelRef.indexOf("/");
91
- if (slash === -1) {
92
- return { ref: modelRef, provider: "anthropic", id: modelRef };
93
- }
94
-
95
- const provider = modelRef.slice(0, slash);
96
- const id = modelRef.slice(slash + 1);
97
- if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
98
- throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
99
- }
100
- return { ref: modelRef, provider, id };
101
- }
102
-
103
- /**
104
- * Resolve a workflow agent model reference.
105
- *
106
- * Omitted refs inherit the host/session default model. Explicit refs are strict:
107
- * bare model ids keep the original Anthropic shorthand for compatibility, and
108
- * provider-qualified refs split only on the first slash so provider routers such
109
- * as OpenRouter can use ids that contain additional slashes.
110
- */
111
- export function resolveAgentModel(
112
- modelRef: string | undefined,
113
- modelRegistry: Pick<ModelRegistry, "find">,
114
- hostModel: Model<Api> | undefined,
115
- ): ResolvedAgentModel {
116
- if (modelRef === undefined) {
117
- return { model: hostModel, requested: undefined };
118
- }
119
-
120
- const parsed = parseAgentModelRef(modelRef);
121
- const found = modelRegistry.find(parsed.provider, parsed.id);
122
- if (!found) {
123
- throw new Error(`Agent model "${modelRef}" not found (resolved as ${parsed.provider}/${parsed.id}).`);
124
- }
125
-
126
- return { model: found, requested: parsed };
127
- }
128
-
129
- /** Pull the last assistant message's plain text out of a finished session. */
130
- function lastAssistantText(state: { readonly messages: readonly unknown[] }): string {
131
- const messages = state.messages;
132
- for (let i = messages.length - 1; i >= 0; i--) {
133
- const message = messages[i];
134
- if (hasRole(message, "assistant")) {
135
- return messageTextContent(message).trim();
136
- }
137
- }
138
- return "";
139
- }
140
-
141
- function hasRole(value: unknown, role: string): value is { role: string } {
142
- return typeof value === "object" && value !== null && "role" in value && value.role === role;
143
- }
144
-
145
- function messageTextContent(message: unknown): string {
146
- if (!hasContentArray(message)) return "";
147
- return message.content
148
- .filter(isTextPart)
149
- .map((part) => part.text)
150
- .join("");
151
- }
152
-
153
- function hasContentArray(value: unknown): value is { content: unknown[] } {
154
- return typeof value === "object" && value !== null && "content" in value && Array.isArray(value.content);
155
- }
156
-
157
- function isTextPart(value: unknown): value is { type: "text"; text: string } {
158
- return typeof value === "object" && value !== null && "type" in value && value.type === "text" && "text" in value && typeof value.text === "string";
159
- }
160
-
161
- async function defaultCreateSession(options: CreateAgentSessionOptions): Promise<{ session: AgentRunnerSession }> {
162
- const { session } = await createAgentSession(options);
163
- return { session };
164
- }
165
-
166
- function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerSession): () => void {
167
- if (!signal) return () => {};
168
- if (signal.aborted) {
169
- void session.abort();
170
- return () => {};
171
- }
172
- const onAbort = () => {
173
- void session.abort();
174
- };
175
- signal.addEventListener("abort", onAbort, { once: true });
176
- return () => signal.removeEventListener("abort", onAbort);
177
- }
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
- }
3
+ import { executeAgentAttempt } from "./agent-attempt.ts";
4
+ import {
5
+ createAgentReplayPlan,
6
+ isReplayEnabled,
7
+ settleAgentAttempt,
8
+ type AgentReplayPlan,
9
+ } from "./agent-replay.ts";
10
+ import {
11
+ resolveAgentModel,
12
+ type ResolvedAgentModel,
13
+ } from "./agent-session.ts";
14
+ import type {
15
+ AgentExecutionOptions,
16
+ AgentRunTags,
17
+ RunContext,
18
+ } from "./agent-runner-types.ts";
19
+ import type { AgentResumeBaseContext } from "./resume-context.ts";
20
+ import { unknownErrorMessage } from "./unknown-error.ts";
21
+
22
+ export type {
23
+ AgentExecutionOptions,
24
+ AgentProgress,
25
+ AgentRunnerEvent,
26
+ AgentRunnerSession,
27
+ AgentRunnerToolInfo,
28
+ CreateAgentSession,
29
+ RunContext,
30
+ } from "./agent-runner-types.ts";
31
+ export { resolveAgentModel } from "./agent-session.ts";
32
+ export type { ResolvedAgentModel, ResolvedAgentModelRequest } from "./agent-session.ts";
345
33
 
346
34
  /**
347
35
  * Run one subagent to completion in an isolated in-memory session.
348
36
  *
349
- * Structured output trick: when `opts.schema` is set we register a single
350
- * terminating tool whose `parameters` IS the schema. The agent's last action is to
351
- * call it; pi validates the args against the schema, hands them to `execute`, and
352
- * `terminate: true` ends the turn with no extra LLM round-trip. We capture those
353
- * validated args in a closure — that captured object is the structured result, so
354
- * no event-stream parsing is needed.
37
+ * Session construction, replay validation, and workspace cleanup live in focused
38
+ * modules; this boundary owns only admission, progress, and the one cache-to-live retry.
355
39
  */
356
- export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
40
+ export async function runAgent(
41
+ rc: RunContext,
42
+ prompt: string,
43
+ opts: AgentExecutionOptions,
44
+ resumeBaseContext: AgentResumeBaseContext,
45
+ ): Promise<unknown> {
357
46
  if (typeof prompt !== "string") {
358
47
  throw new Error(`agent() prompt must be a string; received ${describeAgentPrompt(prompt)}`);
359
48
  }
360
49
 
361
50
  const label = opts.label ?? "agent";
362
51
  const phase = opts.phase ?? "Workflow";
363
- const tags = { label, phase };
52
+ const tags: AgentRunTags = { label, phase };
364
53
 
365
54
  return await rc.perf.time("agent.total_ms", async () => {
366
55
  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);
377
- // Track queued agents before acquiring a global concurrency slot.
56
+ const model = resolveModel(rc, opts, label);
57
+ const replay = createAgentReplayPlan(prompt, opts);
58
+ if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(rc.budget);
59
+
378
60
  const rowId = rc.progress.agentQueued(opts.phase, label);
379
- let failureHandled = false;
380
61
  try {
381
62
  return await rc.semaphore.run(
382
63
  async () => {
383
- throwIfAborted(rc.signal);
384
- // Re-check after waiting for a slot; earlier agents may have exhausted the budget.
385
- ensureWithinBudget(rc.budget);
386
- rc.progress.agentStart(opts.phase, label, rowId);
387
-
388
- let captured: unknown = null;
389
- let failed = false;
390
- let session: AgentRunnerSession | undefined;
391
- let usageRecorded = false;
392
- let unsubscribe: (() => void) | undefined;
393
- let workspace: AgentWorkspace | undefined;
394
- const recordUsage = (activeSession: AgentRunnerSession): void => {
395
- if (usageRecorded) return;
396
- usageRecorded = true;
397
- rc.usage.recordAgentSession({ label, phase, messages: activeSession.state.messages });
398
- };
399
- const customTools: ToolDefinition[] = opts.schema
400
- ? [
401
- defineTool({
402
- name: FINAL_TOOL,
403
- label: "Final Answer",
404
- description:
405
- "Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.",
406
- parameters: opts.schema,
407
- async execute(_toolCallId, params) {
408
- captured = params;
409
- return { content: [{ type: "text", text: "Recorded." }], details: params, terminate: true };
410
- },
411
- }),
412
- ]
413
- : [];
414
-
415
- try {
416
- workspace = await createAgentWorkspace(rc, opts, label);
417
- const agentCwd = workspace.cwd;
418
-
419
- const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
420
- const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
421
- ? await rc.perf.time(
422
- "agent.skills_ms",
423
- () =>
424
- createAgentSkillResourceLoader({
425
- cwd: agentCwd,
426
- prompt,
427
- skills: opts.skills,
428
- log: (message) => rc.progress.log(`${label}: ${message}`),
429
- }),
430
- tags,
431
- )
432
- : undefined;
433
- const selectedSkills = skillSetup?.selectedSkills ?? [];
434
- // When the workflow restricts tools, keep the terminating tool visible.
435
- // Skill opt-ins also need read so the subagent can load SKILL.md on demand.
436
- const toolSelection = buildToolSelection(opts, selectedSkills.length > 0);
437
-
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
- );
456
-
457
64
  throwIfAborted(rc.signal);
458
- let created = await createSubagentSession(toolSelection.sessionOptions);
459
- session = created.session;
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;
65
+ if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(rc.budget);
66
+ rc.progress.agentStart(opts.phase, label, rowId);
67
+ if (replay.kind === "disabled") {
68
+ rc.progress.log(`${label}: resume disabled for this call (${replay.reason})`);
471
69
  }
472
- throwIfAborted(rc.signal);
473
-
474
- unsubscribe = activeSession.subscribe((event) => {
475
- if (event.type === "tool_execution_start" && event.toolName !== undefined && event.toolName !== FINAL_TOOL) {
476
- rc.progress.agentTool(label, event.toolName, rowId);
477
- }
478
- });
479
-
480
- const promptedWithSkills = appendSkillReminder(prompt, selectedSkills);
481
- const finalPrompt = opts.schema
482
- ? `${promptedWithSkills}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
483
- : promptedWithSkills;
484
- throwIfAborted(rc.signal);
485
- const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
486
- try {
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
- }
496
- } finally {
497
- unlinkPromptAbort();
498
- }
499
- recordUsage(activeSession);
500
- throwIfAborted(rc.signal);
501
70
 
502
- const rawResult = rc.perf.timeSync(
503
- "agent.extract_result_ms",
504
- () => {
505
- if (opts.schema) {
506
- if (captured === null) {
507
- rc.progress.log(`${label}: no structured answer returned`);
508
- rc.perf.counter("agent.structured_missing", 1, tags);
509
- }
510
- return captured;
511
- }
512
- return lastAssistantText(activeSession.state);
513
- },
514
- tags,
515
- );
516
- const result = await workspace.wrapResult(rawResult);
517
- await recordJournalResult(rc, label, journalKey, result);
518
- return result;
519
- } catch (error) {
520
- failed = true;
521
- failureHandled = true;
522
- rc.progress.agentFailed(label, error, rowId);
523
- rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
524
- throw error;
525
- } finally {
526
- const disposable = session;
527
- if (disposable) {
528
- recordUsage(disposable);
529
- rc.perf.timeSync(
530
- "agent.dispose_ms",
531
- () => {
532
- unsubscribe?.();
533
- disposable.dispose();
534
- },
71
+ let attemptPlan: AgentReplayPlan = replay;
72
+ while (true) {
73
+ const outcome = await executeAgentAttempt({
74
+ rc,
75
+ prompt,
76
+ opts,
77
+ resumeBaseContext,
78
+ model,
79
+ replay: attemptPlan,
80
+ label,
81
+ rowId,
535
82
  tags,
536
- );
83
+ });
84
+ const settlement = await settleAgentAttempt({ rc, label, tags, replay: attemptPlan, outcome });
85
+ if (settlement.kind === "retry-live") {
86
+ attemptPlan = { kind: "off" };
87
+ continue;
88
+ }
89
+ rc.progress.agentDone(label, rowId);
90
+ return settlement.result;
537
91
  }
538
- await disposeAgentWorkspace(rc, label, workspace);
539
- if (!failed) rc.progress.agentDone(label, rowId);
540
- }
541
92
  },
542
- { onQueueWaitMs: (durationMs) => rc.perf.observe("agent.queue_wait_ms", durationMs, tags), signal: rc.signal },
93
+ {
94
+ onQueueWaitMs: (durationMs) => rc.perf.observe("agent.queue_wait_ms", durationMs, tags),
95
+ signal: rc.signal,
96
+ },
543
97
  );
544
98
  } catch (error) {
545
- if (!failureHandled) {
546
- rc.progress.agentFailed(label, error, rowId);
547
- rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
548
- }
99
+ rc.progress.agentFailed(label, error, rowId);
100
+ rc.progress.log(`${label} failed: ${unknownErrorMessage(error)}`);
549
101
  throw error;
550
102
  }
551
103
  }, tags);
552
104
  }
553
105
 
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 };
106
+ function resolveModel(
107
+ rc: RunContext,
108
+ opts: AgentExecutionOptions,
109
+ label: string,
110
+ ): ResolvedAgentModel["model"] {
111
+ try {
112
+ return resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel).model;
113
+ } catch (error) {
114
+ rc.progress.agentFailed(label, error);
115
+ throw error;
116
+ }
558
117
  }
559
118
 
560
119
  function describeAgentPrompt(value: unknown): string {
@@ -562,7 +121,3 @@ function describeAgentPrompt(value: unknown): string {
562
121
  if (Array.isArray(value)) return "array";
563
122
  return typeof value;
564
123
  }
565
-
566
- function errorMessage(error: unknown): string {
567
- return error instanceof Error ? error.message : String(error);
568
- }