pi-workflow-engine 0.10.3 → 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.
- package/.pi/extensions/pi-workflow-engine/index.ts +168 -121
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -1,560 +1,119 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
350
|
-
*
|
|
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(
|
|
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
|
|
368
|
-
const
|
|
369
|
-
if (
|
|
370
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
}
|