pi-extensible-workflows 1.0.0 → 2.0.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/README.md +7 -2
- package/dist/src/agent-execution.d.ts +13 -0
- package/dist/src/agent-execution.js +177 -40
- package/dist/src/ambient-workflow-evals.js +14 -33
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +530 -6
- package/dist/src/doctor.d.ts +4 -4
- package/dist/src/doctor.js +9 -31
- package/dist/src/herdr.d.ts +12 -0
- package/dist/src/herdr.js +74 -0
- package/dist/src/index.d.ts +107 -27
- package/dist/src/index.js +838 -322
- package/dist/src/persistence.d.ts +28 -0
- package/dist/src/persistence.js +213 -16
- package/dist/src/session-inspector.d.ts +1 -0
- package/dist/src/session-inspector.js +3 -0
- package/dist/src/workflow-evals.d.ts +1 -0
- package/dist/src/workflow-evals.js +25 -26
- package/package.json +8 -4
- package/skills/pi-extensible-workflows/SKILL.md +48 -60
- package/src/agent-execution.ts +140 -27
- package/src/ambient-workflow-evals.ts +18 -28
- package/src/cli.ts +418 -6
- package/src/doctor.ts +13 -35
- package/src/herdr.ts +73 -0
- package/src/index.ts +734 -266
- package/src/persistence.ts +192 -16
- package/src/session-inspector.ts +4 -0
- package/src/workflow-evals.ts +15 -17
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ For source installs and local development, see the [installation guide](https://
|
|
|
20
20
|
|
|
21
21
|
## Capabilities
|
|
22
22
|
|
|
23
|
-
The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts
|
|
23
|
+
The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts; every registered function is also directly runnable as a top-level workflow.
|
|
24
24
|
|
|
25
25
|
A workflow can fan out across specialized agents, combine their results, and resume without rerunning completed work.
|
|
26
26
|
|
|
@@ -61,9 +61,14 @@ Global workflow settings live at `~/.pi/agent/pi-extensible-workflows/settings.j
|
|
|
61
61
|
```sh
|
|
62
62
|
npx pi-extensible-workflows doctor
|
|
63
63
|
npx pi-extensible-workflows inspect [session-id]
|
|
64
|
+
npx pi-extensible-workflows transcript <session-file>
|
|
65
|
+
npx pi-extensible-workflows run <workflow-name> [workflow arguments]
|
|
66
|
+
npx pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]
|
|
64
67
|
```
|
|
65
68
|
|
|
66
|
-
`doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs.
|
|
69
|
+
`doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs. `transcript` renders a session transcript to stdout. `run` derives flat CLI arguments and help from a registered function's input schema. Use `--input '<json>'` for nested or otherwise complex inputs. It executes in the current working directory, writes the final JSON result to stdout, and writes progress and errors to stderr. `export` creates an executable POSIX launcher in `~/.local/bin` by default.
|
|
70
|
+
`run` and `export` accept the trust overrides `--approve` and `--no-approve`; the generated launcher forwards its arguments to `run`. `--` ends launcher option parsing, and later tokens are passed to workflow input instead of being interpreted as launcher options.
|
|
71
|
+
Launch snapshots use identity version 4. A cold resume intentionally rejects persisted v3 runs with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
|
|
67
72
|
|
|
68
73
|
## Development
|
|
69
74
|
|
|
@@ -31,6 +31,15 @@ export interface AgentDefinition {
|
|
|
31
31
|
tools?: readonly string[];
|
|
32
32
|
disabledAgentResources?: AgentResourceExclusions;
|
|
33
33
|
}
|
|
34
|
+
export interface AgentProviderFailure {
|
|
35
|
+
label: string;
|
|
36
|
+
provider: string;
|
|
37
|
+
model: string;
|
|
38
|
+
error: string;
|
|
39
|
+
}
|
|
40
|
+
export type AgentProviderRecovery = "retry" | "abort" | {
|
|
41
|
+
model: string;
|
|
42
|
+
};
|
|
34
43
|
export interface AgentExecutionOptions {
|
|
35
44
|
label: string;
|
|
36
45
|
workflowName: string;
|
|
@@ -40,6 +49,8 @@ export interface AgentExecutionOptions {
|
|
|
40
49
|
thinking?: ThinkingLevel;
|
|
41
50
|
onProgress?: (progress: AgentProgress) => void | Promise<void>;
|
|
42
51
|
onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
|
|
52
|
+
providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
|
|
53
|
+
modelOverride?: ModelSpec;
|
|
43
54
|
tools?: readonly string[];
|
|
44
55
|
effectiveTools?: readonly string[];
|
|
45
56
|
role?: string;
|
|
@@ -179,6 +190,7 @@ export interface SessionInput {
|
|
|
179
190
|
sessionFile: string;
|
|
180
191
|
leafId: string;
|
|
181
192
|
};
|
|
193
|
+
allowModelChange?: boolean;
|
|
182
194
|
}
|
|
183
195
|
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
184
196
|
export declare function createNativeAgentSession(input: SessionInput): Promise<NativeSession>;
|
|
@@ -278,6 +290,7 @@ export declare class FairAgentScheduler {
|
|
|
278
290
|
retry(id: string): void;
|
|
279
291
|
attemptStarted(id: string): void;
|
|
280
292
|
cancelRun(runId: string): Promise<void>;
|
|
293
|
+
removeRun(runId: string): void;
|
|
281
294
|
toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[];
|
|
282
295
|
snapshot(): readonly OwnershipRecord[];
|
|
283
296
|
restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void;
|
|
@@ -25,10 +25,23 @@ function latestAssistantHasToolCall(messages) {
|
|
|
25
25
|
const message = [...messages].reverse().find((item) => item.role === "assistant");
|
|
26
26
|
return hasToolCall(message);
|
|
27
27
|
}
|
|
28
|
-
function throwIfTerminalAssistantError(session) {
|
|
28
|
+
function throwIfTerminalAssistantError(session, fallbackModel) {
|
|
29
29
|
const message = [...session.messages].reverse().find((item) => item.role === "assistant");
|
|
30
|
-
if (message?.stopReason
|
|
31
|
-
|
|
30
|
+
if (message?.stopReason !== "error")
|
|
31
|
+
return;
|
|
32
|
+
const provider = session.model?.provider ?? fallbackModel.provider;
|
|
33
|
+
const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
|
|
34
|
+
const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
|
|
35
|
+
const failure = new WorkflowError("AGENT_FAILED", error);
|
|
36
|
+
Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
|
|
37
|
+
throw failure;
|
|
38
|
+
}
|
|
39
|
+
function terminalProviderError(error) {
|
|
40
|
+
const value = error.terminalProviderError;
|
|
41
|
+
if (!value || typeof value !== "object")
|
|
42
|
+
return undefined;
|
|
43
|
+
const candidate = value;
|
|
44
|
+
return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
|
|
32
45
|
}
|
|
33
46
|
function accounting(stats) {
|
|
34
47
|
return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
|
|
@@ -50,8 +63,11 @@ export async function createNativeAgentSession(input) {
|
|
|
50
63
|
throw new Error("Persisted transcript identity does not match the conversation head");
|
|
51
64
|
manager.branch(input.continuation.leafId);
|
|
52
65
|
const context = manager.buildSessionContext();
|
|
53
|
-
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model))
|
|
54
|
-
|
|
66
|
+
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
|
|
67
|
+
if (!input.allowModelChange)
|
|
68
|
+
throw new Error("Persisted transcript model does not match the conversation execution policy");
|
|
69
|
+
manager.appendModelChange(input.model.provider, input.model.model);
|
|
70
|
+
}
|
|
55
71
|
if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
|
|
56
72
|
throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
|
|
57
73
|
}
|
|
@@ -120,6 +136,43 @@ export async function createNativeAgentSession(input) {
|
|
|
120
136
|
}
|
|
121
137
|
function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
|
|
122
138
|
function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
|
|
139
|
+
function jsonValue(value, seen = new Set()) {
|
|
140
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
141
|
+
return true;
|
|
142
|
+
if (typeof value === "number")
|
|
143
|
+
return Number.isFinite(value);
|
|
144
|
+
if (typeof value !== "object" || seen.has(value))
|
|
145
|
+
return false;
|
|
146
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
147
|
+
return false;
|
|
148
|
+
const keys = Reflect.ownKeys(value);
|
|
149
|
+
if (keys.some((key) => typeof key !== "string"))
|
|
150
|
+
return false;
|
|
151
|
+
seen.add(value);
|
|
152
|
+
const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
|
|
153
|
+
seen.delete(value);
|
|
154
|
+
return valid;
|
|
155
|
+
}
|
|
156
|
+
function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
157
|
+
function isChildAgentToolParams(value) {
|
|
158
|
+
if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
|
|
159
|
+
return false;
|
|
160
|
+
if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string")))
|
|
161
|
+
return false;
|
|
162
|
+
if (value.model !== undefined && typeof value.model !== "string")
|
|
163
|
+
return false;
|
|
164
|
+
if (value.thinking !== undefined && !validThinking(value.thinking))
|
|
165
|
+
return false;
|
|
166
|
+
if (value.role !== undefined && typeof value.role !== "string")
|
|
167
|
+
return false;
|
|
168
|
+
if (value.outputSchema !== undefined && !jsonObject(value.outputSchema))
|
|
169
|
+
return false;
|
|
170
|
+
if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0))
|
|
171
|
+
return false;
|
|
172
|
+
if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1)))
|
|
173
|
+
return false;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
123
176
|
function fallbackSetupContext(root, options, signal) {
|
|
124
177
|
const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
|
|
125
178
|
const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
|
|
@@ -156,6 +209,26 @@ function conversationExecutionPolicy(options, setup) {
|
|
|
156
209
|
resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
|
|
157
210
|
});
|
|
158
211
|
}
|
|
212
|
+
function conversationPolicyMatches(expected, current, allowModelChange) {
|
|
213
|
+
if (fingerprint(expected) === fingerprint(current))
|
|
214
|
+
return true;
|
|
215
|
+
if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current))
|
|
216
|
+
return false;
|
|
217
|
+
const expectedModel = conversationPolicyModel(expected);
|
|
218
|
+
const currentModel = conversationPolicyModel(current);
|
|
219
|
+
if (!expectedModel || !currentModel)
|
|
220
|
+
return false;
|
|
221
|
+
return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
|
|
222
|
+
}
|
|
223
|
+
function conversationPolicyModel(policy) {
|
|
224
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy))
|
|
225
|
+
return undefined;
|
|
226
|
+
const model = policy.model;
|
|
227
|
+
if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string")
|
|
228
|
+
return undefined;
|
|
229
|
+
const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking : undefined;
|
|
230
|
+
return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
|
|
231
|
+
}
|
|
159
232
|
function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
|
|
160
233
|
async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
|
|
161
234
|
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
@@ -224,7 +297,7 @@ export class WorkflowAgentExecutor {
|
|
|
224
297
|
throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
|
|
225
298
|
}
|
|
226
299
|
const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
|
|
227
|
-
const model = parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
|
|
300
|
+
const model = options.modelOverride ?? parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
|
|
228
301
|
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
229
302
|
if (!availableModels.has(modelCapability(model)))
|
|
230
303
|
throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
|
|
@@ -236,7 +309,8 @@ export class WorkflowAgentExecutor {
|
|
|
236
309
|
throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
|
|
237
310
|
if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0))
|
|
238
311
|
throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
|
|
239
|
-
|
|
312
|
+
let resolved = this.resolve(options);
|
|
313
|
+
let recoveryModel;
|
|
240
314
|
let cwd;
|
|
241
315
|
if (options.parent) {
|
|
242
316
|
if (!options.cwd)
|
|
@@ -276,6 +350,11 @@ export class WorkflowAgentExecutor {
|
|
|
276
350
|
catch (error) {
|
|
277
351
|
throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
|
|
278
352
|
}
|
|
353
|
+
if (conversationRecord) {
|
|
354
|
+
const model = conversationPolicyModel(conversationRecord.policy);
|
|
355
|
+
if (model)
|
|
356
|
+
resolved = this.resolve({ ...options, modelOverride: model });
|
|
357
|
+
}
|
|
279
358
|
if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
|
|
280
359
|
throw conversationFailure("Conversation turn must be a positive integer");
|
|
281
360
|
if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
|
|
@@ -283,10 +362,11 @@ export class WorkflowAgentExecutor {
|
|
|
283
362
|
}
|
|
284
363
|
const attempts = [];
|
|
285
364
|
let conversationBaseline;
|
|
286
|
-
|
|
365
|
+
let maxAttempts = (options.retries ?? 0) + 1;
|
|
287
366
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
367
|
+
if (recoveryModel)
|
|
368
|
+
resolved = this.resolve({ ...options, modelOverride: recoveryModel });
|
|
288
369
|
options.budget?.beforeAttempt();
|
|
289
|
-
let accepted = false;
|
|
290
370
|
let schemaResult;
|
|
291
371
|
let session;
|
|
292
372
|
let setup;
|
|
@@ -302,10 +382,10 @@ export class WorkflowAgentExecutor {
|
|
|
302
382
|
const resultTool = options.schema ? {
|
|
303
383
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
304
384
|
async execute(_id, value) {
|
|
305
|
-
if (!accepted)
|
|
306
|
-
return { content: [{ type: "text", text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
|
|
307
385
|
if (!Value.Check(options.schema, value))
|
|
308
386
|
return { content: [{ type: "text", text: "Result does not match the required schema." }], details: {}, isError: true };
|
|
387
|
+
if (schemaResult !== undefined)
|
|
388
|
+
return { content: [{ type: "text", text: "Result has already been accepted." }], details: {}, isError: true };
|
|
309
389
|
schemaResult = structuredClone(value);
|
|
310
390
|
void session?.abort?.();
|
|
311
391
|
return { content: [{ type: "text", text: "Result accepted." }], details: {} };
|
|
@@ -337,6 +417,8 @@ export class WorkflowAgentExecutor {
|
|
|
337
417
|
setupFailed = false;
|
|
338
418
|
if (executionSignal?.aborted)
|
|
339
419
|
throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
420
|
+
if (recoveryModel && conversationRecord)
|
|
421
|
+
setup.sessionInput.allowModelChange = true;
|
|
340
422
|
const started = Date.now();
|
|
341
423
|
session = await setup.createSession(setup.sessionInput);
|
|
342
424
|
if (setup.sessionInput.resourcePolicy)
|
|
@@ -348,9 +430,9 @@ export class WorkflowAgentExecutor {
|
|
|
348
430
|
if (conversationRecord) {
|
|
349
431
|
if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
|
|
350
432
|
throw conversationFailure("Conversation transcript identity changed");
|
|
351
|
-
if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId)
|
|
433
|
+
if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId))
|
|
352
434
|
throw conversationFailure("Conversation transcript leaf identity changed");
|
|
353
|
-
if (
|
|
435
|
+
if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel)))
|
|
354
436
|
throw conversationFailure("Conversation execution policy changed");
|
|
355
437
|
if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
|
|
356
438
|
throw conversationFailure("Conversation system prompt changed");
|
|
@@ -358,7 +440,7 @@ export class WorkflowAgentExecutor {
|
|
|
358
440
|
throw conversationFailure("Conversation tool definitions changed");
|
|
359
441
|
}
|
|
360
442
|
else if (conversationBaseline) {
|
|
361
|
-
if (
|
|
443
|
+
if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel)))
|
|
362
444
|
throw conversationFailure("Conversation execution policy changed");
|
|
363
445
|
if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
|
|
364
446
|
throw conversationFailure("Conversation tool definitions changed");
|
|
@@ -417,7 +499,7 @@ export class WorkflowAgentExecutor {
|
|
|
417
499
|
activity = undefined;
|
|
418
500
|
if (event.message.role === "assistant") {
|
|
419
501
|
const needsMoreWork = hasToolCall(event.message);
|
|
420
|
-
const final = !needsMoreWork || (options.schema !== undefined &&
|
|
502
|
+
const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
|
|
421
503
|
if (!budgetError) {
|
|
422
504
|
try {
|
|
423
505
|
options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final);
|
|
@@ -459,34 +541,41 @@ export class WorkflowAgentExecutor {
|
|
|
459
541
|
const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
|
|
460
542
|
options.budget?.beforeTurn();
|
|
461
543
|
turnStarted = true;
|
|
462
|
-
|
|
544
|
+
try {
|
|
545
|
+
await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (!hasSchemaResult())
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
463
551
|
if (conversationMismatch)
|
|
464
552
|
throw conversationMismatch;
|
|
465
|
-
throwIfTerminalAssistantError(session);
|
|
553
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
466
554
|
{
|
|
467
555
|
const completedAccounting = accounting(session.getSessionStats());
|
|
468
|
-
options.budget?.afterTurn(completedAccounting, options.schema !== undefined ?
|
|
556
|
+
options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
|
|
469
557
|
turnStarted = false;
|
|
470
558
|
}
|
|
471
559
|
if (budgetError)
|
|
472
560
|
throw budgetError;
|
|
473
561
|
if (options.schema) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
562
|
+
if (!hasSchemaResult()) {
|
|
563
|
+
try {
|
|
564
|
+
options.budget?.beforeTurn();
|
|
565
|
+
turnStarted = true;
|
|
566
|
+
await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
|
|
567
|
+
{
|
|
568
|
+
const completedAccounting = accounting(session.getSessionStats());
|
|
569
|
+
options.budget?.afterTurn(completedAccounting, true);
|
|
570
|
+
turnStarted = false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
if (!hasSchemaResult())
|
|
575
|
+
throw error;
|
|
483
576
|
}
|
|
484
577
|
}
|
|
485
|
-
|
|
486
|
-
if (!hasSchemaResult())
|
|
487
|
-
throw error;
|
|
488
|
-
}
|
|
489
|
-
throwIfTerminalAssistantError(session);
|
|
578
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
490
579
|
if (!hasSchemaResult()) {
|
|
491
580
|
try {
|
|
492
581
|
options.budget?.beforeTurn();
|
|
@@ -502,7 +591,7 @@ export class WorkflowAgentExecutor {
|
|
|
502
591
|
if (!hasSchemaResult())
|
|
503
592
|
throw error;
|
|
504
593
|
}
|
|
505
|
-
throwIfTerminalAssistantError(session);
|
|
594
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
506
595
|
}
|
|
507
596
|
if (schemaResult === undefined)
|
|
508
597
|
throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
@@ -557,6 +646,30 @@ export class WorkflowAgentExecutor {
|
|
|
557
646
|
}
|
|
558
647
|
if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
|
|
559
648
|
await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
|
|
649
|
+
const terminal = terminalProviderError(typed);
|
|
650
|
+
if (terminal && options.providerErrorRecovery) {
|
|
651
|
+
let recovery;
|
|
652
|
+
try {
|
|
653
|
+
recovery = await options.providerErrorRecovery({ label: options.label, ...terminal });
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
throw Object.assign(typed, { attempts });
|
|
657
|
+
}
|
|
658
|
+
if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
|
|
659
|
+
if (typeof recovery === "object") {
|
|
660
|
+
try {
|
|
661
|
+
const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
|
|
662
|
+
recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
throw Object.assign(typed, { attempts });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
maxAttempts += 1;
|
|
669
|
+
beforeRetry?.();
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
560
673
|
if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
|
|
561
674
|
throw Object.assign(typed, { attempts });
|
|
562
675
|
beforeRetry?.();
|
|
@@ -686,7 +799,26 @@ export class FairAgentScheduler {
|
|
|
686
799
|
if (nodes.every(({ restored }) => restored))
|
|
687
800
|
run.logical = 0;
|
|
688
801
|
}
|
|
689
|
-
|
|
802
|
+
removeRun(runId) {
|
|
803
|
+
const run = this.#runs.get(runId);
|
|
804
|
+
if (!run)
|
|
805
|
+
return;
|
|
806
|
+
const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
|
|
807
|
+
if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state)))
|
|
808
|
+
throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
|
|
809
|
+
for (const { id } of nodes)
|
|
810
|
+
this.#nodes.delete(id);
|
|
811
|
+
this.#runs.delete(runId);
|
|
812
|
+
const index = this.#runOrder.indexOf(runId);
|
|
813
|
+
if (index >= 0) {
|
|
814
|
+
this.#runOrder.splice(index, 1);
|
|
815
|
+
if (index < this.#cursor)
|
|
816
|
+
this.#cursor -= 1;
|
|
817
|
+
if (this.#cursor >= this.#runOrder.length)
|
|
818
|
+
this.#cursor = 0;
|
|
819
|
+
}
|
|
820
|
+
this.#dispatch();
|
|
821
|
+
}
|
|
690
822
|
toolsFor(parentId, resolveTools) {
|
|
691
823
|
const parent = this.#node(parentId);
|
|
692
824
|
if (!parent.options.tools.includes("agent"))
|
|
@@ -694,11 +826,15 @@ export class FairAgentScheduler {
|
|
|
694
826
|
const agentTool = {
|
|
695
827
|
name: "agent", label: "Child Agent", description: "Start a direct child agent",
|
|
696
828
|
parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
|
|
697
|
-
execute: async (_id,
|
|
829
|
+
execute: async (_id, rawParams) => {
|
|
830
|
+
if (!isChildAgentToolParams(rawParams))
|
|
831
|
+
throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
|
|
832
|
+
const params = rawParams;
|
|
698
833
|
if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined))
|
|
699
834
|
throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
700
835
|
const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
|
|
701
|
-
const agentOptions =
|
|
836
|
+
const agentOptions = { ...params };
|
|
837
|
+
Reflect.deleteProperty(agentOptions, "prompt");
|
|
702
838
|
const options = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
|
|
703
839
|
const child = this.spawn(parent.runId, params.prompt, options, parentId);
|
|
704
840
|
return { content: [{ type: "text", text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
|
|
@@ -745,17 +881,17 @@ export class FairAgentScheduler {
|
|
|
745
881
|
#inherit(parent, options) {
|
|
746
882
|
if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools))
|
|
747
883
|
throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
|
|
884
|
+
const inheritedTools = options.tools;
|
|
748
885
|
if (!parent)
|
|
749
|
-
return Object.freeze({ ...options, tools: Object.freeze([...
|
|
886
|
+
return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
|
|
750
887
|
if (options.cwd !== parent.options.cwd)
|
|
751
888
|
throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
|
|
752
|
-
const forbidden =
|
|
889
|
+
const forbidden = inheritedTools.find((tool) => !parent.options.tools.includes(tool));
|
|
753
890
|
if (forbidden)
|
|
754
891
|
throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
|
|
755
892
|
const identity = options.agentIdentity ?? parent.options.agentIdentity;
|
|
756
|
-
return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...
|
|
893
|
+
return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
|
|
757
894
|
}
|
|
758
|
-
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
|
|
759
895
|
#enqueue(runId, node, start) { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
|
|
760
896
|
#dispatch() {
|
|
761
897
|
while (this.#active < this.sessionLimit && this.#runOrder.length) {
|
|
@@ -802,6 +938,7 @@ export class FairAgentScheduler {
|
|
|
802
938
|
return;
|
|
803
939
|
const heldPermit = node.state === "running" || node.state === "retrying";
|
|
804
940
|
node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
|
|
941
|
+
Reflect.deleteProperty(node, "steer");
|
|
805
942
|
this.#persist(node.runId);
|
|
806
943
|
if (heldPermit)
|
|
807
944
|
this.#release(node.runId);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdirSync, mkdtempSync,
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { join, relative } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
|
|
9
|
-
import {
|
|
9
|
+
import { isObject } from "./index.js";
|
|
10
|
+
import { extractCapturedWorkflows, extractParentOracleFile, findSessionFile } from "./workflow-evals.js";
|
|
10
11
|
export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
|
|
11
12
|
export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
|
|
12
13
|
export const AMBIENT_INVOCATION_MODE = "ambient-capture-only";
|
|
@@ -234,34 +235,12 @@ function emptyAccounting() {
|
|
|
234
235
|
function ambientAgentDir(environment) {
|
|
235
236
|
return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
236
237
|
}
|
|
237
|
-
function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
238
|
-
function findSessionFile(directory, sessionId) {
|
|
239
|
-
if (!existsSync(directory))
|
|
240
|
-
return undefined;
|
|
241
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
242
|
-
const path = join(directory, entry.name);
|
|
243
|
-
if (entry.isDirectory()) {
|
|
244
|
-
const found = findSessionFile(path, sessionId);
|
|
245
|
-
if (found)
|
|
246
|
-
return found;
|
|
247
|
-
}
|
|
248
|
-
else if (entry.name.endsWith(".jsonl")) {
|
|
249
|
-
try {
|
|
250
|
-
const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
|
|
251
|
-
if (isObject(header) && header.id === sessionId)
|
|
252
|
-
return path;
|
|
253
|
-
}
|
|
254
|
-
catch { /* Ignore incomplete sessions. */ }
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return undefined;
|
|
258
|
-
}
|
|
259
238
|
function emptyResult(candidate, repository, worktree, environment, error) {
|
|
260
239
|
const accounting = emptyAccounting();
|
|
261
240
|
return {
|
|
262
241
|
id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
|
|
263
242
|
manifest: {
|
|
264
|
-
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore,
|
|
243
|
+
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
|
|
265
244
|
cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
|
|
266
245
|
},
|
|
267
246
|
};
|
|
@@ -271,12 +250,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
|
|
|
271
250
|
const sessionId = randomUUID();
|
|
272
251
|
const sessionDir = join(repository.root, "sessions", candidate.id);
|
|
273
252
|
let result;
|
|
274
|
-
let
|
|
253
|
+
let failure;
|
|
254
|
+
let gitStatusAfter;
|
|
275
255
|
try {
|
|
276
256
|
const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
|
|
277
257
|
const sessionFile = findSessionFile(sessionDir, sessionId);
|
|
278
258
|
if (!sessionFile)
|
|
279
|
-
|
|
259
|
+
failure = "Ambient parent session was not written.";
|
|
280
260
|
else {
|
|
281
261
|
const oracle = extractParentOracleFile(sessionFile);
|
|
282
262
|
const workflows = extractCapturedWorkflows(oracle);
|
|
@@ -286,14 +266,14 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
|
|
|
286
266
|
result = {
|
|
287
267
|
id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
|
|
288
268
|
manifest: {
|
|
289
|
-
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore,
|
|
269
|
+
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
|
|
290
270
|
cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
|
|
291
271
|
},
|
|
292
272
|
};
|
|
293
273
|
}
|
|
294
274
|
}
|
|
295
275
|
catch (error) {
|
|
296
|
-
|
|
276
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
297
277
|
}
|
|
298
278
|
finally {
|
|
299
279
|
try {
|
|
@@ -304,12 +284,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
|
|
|
304
284
|
}
|
|
305
285
|
const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
|
|
306
286
|
if (!result)
|
|
307
|
-
result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
|
|
287
|
+
result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
|
|
308
288
|
const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
|
|
309
|
-
result = { ...result, manifest: { ...result.manifest,
|
|
310
|
-
writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
|
289
|
+
result = { ...result, manifest: { ...result.manifest, cleanup } };
|
|
311
290
|
}
|
|
312
|
-
|
|
291
|
+
const finalized = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
|
|
292
|
+
writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
|
|
293
|
+
return finalized;
|
|
313
294
|
}
|
|
314
295
|
export function assertAmbientOptIn(environment = process.env) {
|
|
315
296
|
if (environment[AMBIENT_OPT_IN] !== "1")
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type DoctorOptions } from "./doctor.js";
|
|
3
|
+
import { type JsonSchema, type JsonValue } from "./index.js";
|
|
4
|
+
import type { WorkflowCatalogFunction } from "./index.js";
|
|
3
5
|
export interface CliOptions extends DoctorOptions {
|
|
4
6
|
inspect?: (sessionId?: string) => Promise<void>;
|
|
7
|
+
transcript?: (sessionFile: string) => Promise<void>;
|
|
8
|
+
stderr?: (text: string) => void;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
trustOverride?: boolean;
|
|
11
|
+
isTTY?: boolean;
|
|
5
12
|
}
|
|
13
|
+
export declare function formatWorkflowCliHelp(fn: WorkflowCatalogFunction, command?: string): string;
|
|
14
|
+
export declare function parseWorkflowCliArgs(schema: JsonSchema, rawArgs: readonly string[]): Record<string, JsonValue>;
|
|
6
15
|
export declare function runCli(args: readonly string[], options?: CliOptions, write?: (text: string) => void): Promise<number>;
|