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
|
@@ -4,87 +4,80 @@ description: Use when the task is complex enough to require multiple subagents o
|
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-extensible-workflows
|
|
7
|
-
|
|
8
|
-
Use `workflow` exclusively for genuinely multi-agent orchestration. For one agent, use ordinary tools or `Agent` directly. Do not wrap a single agent in a workflow; define distinct responsibilities and keep the result flow explicit.
|
|
7
|
+
Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
|
|
9
8
|
|
|
10
9
|
## Pattern
|
|
11
|
-
|
|
12
10
|
```js
|
|
13
|
-
const reportSchema = {
|
|
14
|
-
type: "object",
|
|
15
|
-
properties: {
|
|
16
|
-
summary: { type: "string" },
|
|
17
|
-
findings: { type: "array", items: { type: "string" } },
|
|
18
|
-
},
|
|
19
|
-
required: ["summary", "findings"],
|
|
20
|
-
additionalProperties: false,
|
|
21
|
-
};
|
|
11
|
+
const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
|
|
22
12
|
|
|
23
13
|
const reports = await parallel("research", {
|
|
24
14
|
first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
|
|
25
15
|
second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
|
|
26
16
|
});
|
|
27
17
|
|
|
28
|
-
// Add a downstream agent only when synthesis or independent review is a real phase.
|
|
29
18
|
return agent(
|
|
30
19
|
prompt("Review these reports:\n\n{reports}", { reports }),
|
|
31
20
|
{ role: "reviewer", outputSchema: reportSchema },
|
|
32
21
|
);
|
|
33
22
|
```
|
|
34
23
|
|
|
35
|
-
|
|
24
|
+
Pass structured input from the main agent with `args`:
|
|
36
25
|
```json
|
|
37
26
|
{ "workflow": "workflowName", "args": { "issue": 42 } }
|
|
38
27
|
```
|
|
39
|
-
Inside the workflow, read `args.issue
|
|
40
|
-
|
|
41
|
-
If `workflow_catalog` is available, call it once before creating the first workflow for a task. Use the returned global functions, variables, registered workflows, and configured model aliases as needed for the rest of that task. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
|
|
28
|
+
Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes; unnamed or missing names create new worktrees.
|
|
29
|
+
If `workflow_catalog` is available, call it once before creating the first workflow for a task. The name-less result is a compact index with launch-ready input schemas, descriptions, variables, and configured model aliases. Use those inputs to launch registered functions directly with `{ "workflow": "name", "args": { ... } }`; their input and output schemas are enforced. Request full detail with `{ "name": "name" }` only when composing a function programmatically or inspecting its output contract and extension metadata; do not load full definitions unconditionally. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
|
|
42
30
|
|
|
43
|
-
|
|
31
|
+
Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
|
|
44
32
|
|
|
45
|
-
|
|
33
|
+
Use a bounded verification loop when command output controls the gate:
|
|
34
|
+
```js
|
|
35
|
+
return withWorktree("fix-tests", async ({ path, branch }) => {
|
|
36
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
37
|
+
const tests = await shell("yarn test", { env: { CI: "1" } });
|
|
38
|
+
if (tests.exitCode === 0) return { path, branch, tests };
|
|
39
|
+
await agent(prompt("Fix these failures:\n\n{output}", { output: tests.stderr || tests.stdout }));
|
|
40
|
+
}
|
|
41
|
+
return { path, branch, tests: await shell("yarn test", { env: { CI: "1" } }) };
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
Shell results are journaled only after process exit and RPC validation. A host crash after side effects but before journaling can rerun the command on resume; use `shell()` mainly for verification and bounded gates, not exactly-once mutations.
|
|
46
45
|
|
|
46
|
+
## `agent()` options
|
|
47
47
|
```typescript
|
|
48
48
|
interface AgentOptions {
|
|
49
|
-
label?: string;
|
|
50
|
-
|
|
51
|
-
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
52
|
-
role?: string; // one of the available workflow roles
|
|
53
|
-
tools?: string[]; // [] = no tools; omitted uses role or launch tools
|
|
54
|
-
outputSchema?: JsonSchema;
|
|
55
|
-
retries?: number; // non-negative; use for safe, repeatable work
|
|
56
|
-
timeoutMs?: number | null; // positive milliseconds; null means unlimited
|
|
49
|
+
label?: string; model?: string; role?: string; tools?: string[];
|
|
50
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null;
|
|
57
51
|
}
|
|
58
52
|
```
|
|
59
53
|
|
|
60
|
-
Extensions may
|
|
61
|
-
|
|
62
|
-
Agent calls are unnamed. Direct `agent(...)` calls receive hidden source call-site identity; JavaScript aliases for workflow calls are unsupported. Calls from one source call site must not race outside `parallel` or `pipeline`, whose structural keys keep replay deterministic.
|
|
54
|
+
Extensions may add JSON-compatible agent options such as `advisor: true`; core keys retain validation and role constraints. Extension options go to setup hooks/native setup and are not inherited by child agents.
|
|
63
55
|
|
|
64
|
-
|
|
56
|
+
Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
|
|
65
57
|
|
|
66
|
-
|
|
58
|
+
## Persistent conversations
|
|
59
|
+
Use `conversation(name, options)` when several agent turns should share a persisted transcript and build on one another. Unlike independent `agent(prompt, options)` calls, the returned handle continues from the last successful turn:
|
|
67
60
|
|
|
68
61
|
```js
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
62
|
+
const handle = conversation("developer", { role: "developer" });
|
|
63
|
+
const findings = await handle.run("Inspect the implementation.");
|
|
64
|
+
const fix = await handle.run("Now propose the smallest fix.");
|
|
65
|
+
return { findings, fix };
|
|
73
66
|
```
|
|
74
67
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`parallel()` tasks may call any workflow function, not only `agent()`:
|
|
68
|
+
Await each `handle.run(prompt, turnOptions)` call before starting the next one; conversation turns must be sequential and cannot overlap. Conversation creation accepts the same execution-policy options as `agent()`. `timeoutMs` and `retries` passed to `run()` are turn-local, so a failed turn does not advance the persisted conversation head.
|
|
78
69
|
|
|
70
|
+
## Worktrees
|
|
71
|
+
Use `withWorktree(callback)` or `withWorktree(name, callback)` for top-level agents that collaborate in one worktree:
|
|
79
72
|
```js
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
const result = await withWorktree("issue", async ({ path, branch }) => {
|
|
74
|
+
const report = await agent("Implement the issue");
|
|
75
|
+
return { path, branch, report };
|
|
83
76
|
});
|
|
84
77
|
```
|
|
78
|
+
Entering the scope materializes its worktree before the callback. The callback receives a frozen reference containing only the real string `path` and `branch`; callbacks may ignore the argument, and their bare return value is preserved. Concurrent agents share mutable files, so assign non-conflicting work or coordinate explicitly.
|
|
85
79
|
|
|
86
|
-
Use separate named scopes when
|
|
87
|
-
|
|
80
|
+
Branches may call any workflow function, not only `agent()`. Use separate named scopes when parallel branches need isolated worktrees:
|
|
88
81
|
```js
|
|
89
82
|
const results = await parallel("implementation", {
|
|
90
83
|
api: () => withWorktree("api", () => agent("Implement the API")),
|
|
@@ -92,22 +85,17 @@ const results = await parallel("implementation", {
|
|
|
92
85
|
});
|
|
93
86
|
```
|
|
94
87
|
|
|
95
|
-
Registered extension functions receive `withWorktree` in
|
|
88
|
+
Registered extension functions receive `withWorktree` in context and can compose other registered functions with `context.invoke("reviewRepository", { focus: "security" })`. Their public inputs and outputs remain JSON; callbacks cannot cross the extension boundary.
|
|
96
89
|
|
|
97
90
|
## Rules
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
- Use `
|
|
101
|
-
-
|
|
102
|
-
- Use
|
|
103
|
-
- Call shapes are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; object keys are stable task, item, and stage names.
|
|
104
|
-
- Preserve item metadata in workflow code between pipeline stages instead of requiring agents to echo it through `outputSchema`.
|
|
105
|
-
- Repeated work uses a JavaScript loop; each direct `agent(...)` call receives deterministic call-site and occurrence identity.
|
|
91
|
+
- Use `log(messageString)` for brief operator status.
|
|
92
|
+
- A role owns execution policy: with `role`, do not set `model`, `thinking`, or `tools`; only task options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
|
|
93
|
+
- Use `parallel()` for independent tasks with different flows and `pipeline()` when every keyed item follows the same ordered stages; do not duplicate identical chains in `parallel()`. Signatures are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; keys are stable task, item, and stage names.
|
|
94
|
+
- Preserve item metadata in workflow code between pipeline stages instead of making agents echo it through `outputSchema`.
|
|
95
|
+
- Use a JavaScript loop for repeated work; each direct `agent(...)` call gets deterministic call-site and occurrence identity.
|
|
106
96
|
- Runs default to background; set tool-call `foreground: true` when asked to wait.
|
|
107
|
-
- Add `budget` only
|
|
108
|
-
-
|
|
109
|
-
- `parallel()` and `pipeline()` return keyed bare values
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
- With `outputSchema`, agents must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an additional retry is justified and the work is idempotent.
|
|
113
|
-
- Do not add "persona" specs to the prompt for agents. Just define the task.
|
|
97
|
+
- Add `budget` only for aggregate limits. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
|
|
98
|
+
- `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it.
|
|
99
|
+
- `parallel()` and `pipeline()` return keyed bare values; await them before use. Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings remain literal.
|
|
100
|
+
- Use `outputSchema` only when another phase compares, aggregates, or validates a result, never for final prose. Keep only consumer-needed fields and avoid repeated evidence. Agents with it must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an extra retry is justified and work is idempotent.
|
|
101
|
+
- Do not add persona specifications to agent prompts; define the task directly.
|
package/src/agent-execution.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface AgentBudgetHooks {
|
|
|
17
17
|
instruction(): string | undefined;
|
|
18
18
|
}
|
|
19
19
|
export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
|
|
20
|
+
export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
|
|
21
|
+
export type AgentProviderRecovery = "retry" | "abort" | { model: string };
|
|
20
22
|
export interface AgentExecutionOptions {
|
|
21
23
|
label: string;
|
|
22
24
|
workflowName: string;
|
|
@@ -26,6 +28,8 @@ export interface AgentExecutionOptions {
|
|
|
26
28
|
thinking?: ThinkingLevel;
|
|
27
29
|
onProgress?: (progress: AgentProgress) => void | Promise<void>;
|
|
28
30
|
onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
|
|
31
|
+
providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
|
|
32
|
+
modelOverride?: ModelSpec;
|
|
29
33
|
tools?: readonly string[];
|
|
30
34
|
effectiveTools?: readonly string[];
|
|
31
35
|
role?: string;
|
|
@@ -85,7 +89,7 @@ export interface NativeSession {
|
|
|
85
89
|
abort?(): Promise<void>;
|
|
86
90
|
dispose(): void;
|
|
87
91
|
}
|
|
88
|
-
export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string } }
|
|
92
|
+
export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string }; allowModelChange?: boolean }
|
|
89
93
|
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
90
94
|
|
|
91
95
|
function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
@@ -110,9 +114,22 @@ function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean
|
|
|
110
114
|
return hasToolCall(message);
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
|
|
117
|
+
type TerminalProviderError = { provider: string; model: string; error: string };
|
|
118
|
+
function throwIfTerminalAssistantError(session: NativeSession, fallbackModel: ModelSpec): void {
|
|
114
119
|
const message = [...session.messages].reverse().find((item) => item.role === "assistant");
|
|
115
|
-
if (message?.stopReason
|
|
120
|
+
if (message?.stopReason !== "error") return;
|
|
121
|
+
const provider = session.model?.provider ?? fallbackModel.provider;
|
|
122
|
+
const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
|
|
123
|
+
const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
|
|
124
|
+
const failure = new WorkflowError("AGENT_FAILED", error);
|
|
125
|
+
Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
|
|
126
|
+
throw failure;
|
|
127
|
+
}
|
|
128
|
+
function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
|
|
129
|
+
const value = (error as WorkflowError & { terminalProviderError?: unknown }).terminalProviderError;
|
|
130
|
+
if (!value || typeof value !== "object") return undefined;
|
|
131
|
+
const candidate = value as Partial<TerminalProviderError>;
|
|
132
|
+
return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
function accounting(stats: NativeSessionStats): AgentAccounting {
|
|
@@ -130,7 +147,10 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
130
147
|
if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId)) throw new Error("Persisted transcript identity does not match the conversation head");
|
|
131
148
|
manager.branch(input.continuation.leafId);
|
|
132
149
|
const context = manager.buildSessionContext();
|
|
133
|
-
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model))
|
|
150
|
+
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
|
|
151
|
+
if (!input.allowModelChange) throw new Error("Persisted transcript model does not match the conversation execution policy");
|
|
152
|
+
manager.appendModelChange(input.model.provider, input.model.model);
|
|
153
|
+
}
|
|
134
154
|
if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
|
|
135
155
|
} catch (error) {
|
|
136
156
|
if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
|
|
@@ -192,6 +212,42 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
192
212
|
}
|
|
193
213
|
function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
|
|
194
214
|
function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
|
|
215
|
+
function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
|
|
216
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
|
|
217
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
218
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
219
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
|
|
220
|
+
const keys = Reflect.ownKeys(value);
|
|
221
|
+
if (keys.some((key) => typeof key !== "string")) return false;
|
|
222
|
+
seen.add(value);
|
|
223
|
+
const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
|
|
224
|
+
seen.delete(value);
|
|
225
|
+
return valid;
|
|
226
|
+
}
|
|
227
|
+
function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
228
|
+
interface ChildAgentToolParams {
|
|
229
|
+
prompt: string;
|
|
230
|
+
label: string;
|
|
231
|
+
tools?: string[];
|
|
232
|
+
model?: string;
|
|
233
|
+
thinking?: ThinkingLevel;
|
|
234
|
+
role?: string;
|
|
235
|
+
outputSchema?: JsonSchema;
|
|
236
|
+
retries?: number;
|
|
237
|
+
timeoutMs?: number | null;
|
|
238
|
+
[key: string]: unknown;
|
|
239
|
+
}
|
|
240
|
+
function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams & Record<string, JsonValue> {
|
|
241
|
+
if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string") return false;
|
|
242
|
+
if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string"))) return false;
|
|
243
|
+
if (value.model !== undefined && typeof value.model !== "string") return false;
|
|
244
|
+
if (value.thinking !== undefined && !validThinking(value.thinking)) return false;
|
|
245
|
+
if (value.role !== undefined && typeof value.role !== "string") return false;
|
|
246
|
+
if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
|
|
247
|
+
if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
|
|
248
|
+
if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1))) return false;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
195
251
|
function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
|
|
196
252
|
const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
|
|
197
253
|
const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
|
|
@@ -226,6 +282,21 @@ function conversationExecutionPolicy(options: AgentExecutionOptions, setup: Agen
|
|
|
226
282
|
resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
|
|
227
283
|
}) as unknown as JsonValue;
|
|
228
284
|
}
|
|
285
|
+
function conversationPolicyMatches(expected: JsonValue, current: JsonValue, allowModelChange: boolean): boolean {
|
|
286
|
+
if (fingerprint(expected) === fingerprint(current)) return true;
|
|
287
|
+
if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current)) return false;
|
|
288
|
+
const expectedModel = conversationPolicyModel(expected);
|
|
289
|
+
const currentModel = conversationPolicyModel(current);
|
|
290
|
+
if (!expectedModel || !currentModel) return false;
|
|
291
|
+
return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
|
|
292
|
+
}
|
|
293
|
+
function conversationPolicyModel(policy: JsonValue): ModelSpec | undefined {
|
|
294
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) return undefined;
|
|
295
|
+
const model = policy.model;
|
|
296
|
+
if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string") return undefined;
|
|
297
|
+
const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking as ModelSpec["thinking"] : undefined;
|
|
298
|
+
return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
|
|
299
|
+
}
|
|
229
300
|
function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
|
|
230
301
|
async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, continuation?: ConversationHead): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
|
|
231
302
|
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
@@ -271,7 +342,7 @@ export class WorkflowAgentExecutor {
|
|
|
271
342
|
const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
|
|
272
343
|
if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) { const target = this.root.blockedAliasTargets?.[requestedModel]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
|
|
273
344
|
const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
|
|
274
|
-
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);
|
|
345
|
+
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);
|
|
275
346
|
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
276
347
|
if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
|
|
277
348
|
return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
|
|
@@ -281,7 +352,8 @@ export class WorkflowAgentExecutor {
|
|
|
281
352
|
const executionSignal = signal ?? this.root.runContext?.signal;
|
|
282
353
|
if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
|
|
283
354
|
if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
|
|
284
|
-
|
|
355
|
+
let resolved = this.resolve(options);
|
|
356
|
+
let recoveryModel: ModelSpec | undefined;
|
|
285
357
|
let cwd: string;
|
|
286
358
|
if (options.parent) {
|
|
287
359
|
if (!options.cwd) throw new WorkflowError("INVALID_METADATA", "Child agents require their parent cwd");
|
|
@@ -306,15 +378,19 @@ export class WorkflowAgentExecutor {
|
|
|
306
378
|
const store = this.root.runStore;
|
|
307
379
|
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
308
380
|
try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
|
|
381
|
+
if (conversationRecord) {
|
|
382
|
+
const model = conversationPolicyModel(conversationRecord.policy);
|
|
383
|
+
if (model) resolved = this.resolve({ ...options, modelOverride: model });
|
|
384
|
+
}
|
|
309
385
|
if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
|
|
310
386
|
if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1) throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
|
|
311
387
|
}
|
|
312
388
|
const attempts: AgentAttempt[] = [];
|
|
313
389
|
let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
|
|
314
|
-
|
|
390
|
+
let maxAttempts = (options.retries ?? 0) + 1;
|
|
315
391
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
392
|
+
if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
|
|
316
393
|
options.budget?.beforeAttempt();
|
|
317
|
-
let accepted = false;
|
|
318
394
|
let schemaResult: JsonValue | undefined;
|
|
319
395
|
let session: NativeSession | undefined;
|
|
320
396
|
let setup: AgentSetup | undefined;
|
|
@@ -330,8 +406,8 @@ export class WorkflowAgentExecutor {
|
|
|
330
406
|
const resultTool = options.schema ? {
|
|
331
407
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
332
408
|
async execute(_id: string, value: unknown) {
|
|
333
|
-
if (!accepted) return { content: [{ type: "text" as const, text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
|
|
334
409
|
if (!Value.Check(options.schema as object, value)) return { content: [{ type: "text" as const, text: "Result does not match the required schema." }], details: {}, isError: true };
|
|
410
|
+
if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
|
|
335
411
|
schemaResult = structuredClone(value) as JsonValue;
|
|
336
412
|
void session?.abort?.();
|
|
337
413
|
return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
|
|
@@ -360,6 +436,7 @@ export class WorkflowAgentExecutor {
|
|
|
360
436
|
setupSummary = prepared.summary;
|
|
361
437
|
setupFailed = false;
|
|
362
438
|
if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
439
|
+
if (recoveryModel && conversationRecord) setup.sessionInput.allowModelChange = true;
|
|
363
440
|
const started = Date.now();
|
|
364
441
|
session = await setup.createSession(setup.sessionInput);
|
|
365
442
|
if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
|
|
@@ -369,12 +446,12 @@ export class WorkflowAgentExecutor {
|
|
|
369
446
|
const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
|
|
370
447
|
if (conversationRecord) {
|
|
371
448
|
if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
|
|
372
|
-
if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
|
|
373
|
-
if (
|
|
449
|
+
if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId)) throw conversationFailure("Conversation transcript leaf identity changed");
|
|
450
|
+
if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
|
|
374
451
|
if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
375
452
|
if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
376
453
|
} else if (conversationBaseline) {
|
|
377
|
-
if (
|
|
454
|
+
if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
|
|
378
455
|
if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
379
456
|
} else {
|
|
380
457
|
conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
|
|
@@ -413,7 +490,7 @@ export class WorkflowAgentExecutor {
|
|
|
413
490
|
activity = undefined;
|
|
414
491
|
if (event.message.role === "assistant") {
|
|
415
492
|
const needsMoreWork = hasToolCall(event.message);
|
|
416
|
-
const final = !needsMoreWork || (options.schema !== undefined &&
|
|
493
|
+
const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
|
|
417
494
|
if (!budgetError) { try { options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final); if (!final) { const instruction = options.budget?.instruction(); if (instruction) void session?.steer?.(instruction); } } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
|
|
418
495
|
turnStarted = false;
|
|
419
496
|
report(true);
|
|
@@ -432,18 +509,19 @@ export class WorkflowAgentExecutor {
|
|
|
432
509
|
const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
|
|
433
510
|
options.budget?.beforeTurn();
|
|
434
511
|
turnStarted = true;
|
|
435
|
-
await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
|
|
512
|
+
try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
436
513
|
if (conversationMismatch) throw conversationMismatch;
|
|
437
|
-
throwIfTerminalAssistantError(session);
|
|
438
|
-
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ?
|
|
514
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
515
|
+
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
|
|
439
516
|
if (budgetError) throw budgetError;
|
|
440
517
|
if (options.schema) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
518
|
+
if (!hasSchemaResult()) {
|
|
519
|
+
try { options.budget?.beforeTurn(); turnStarted = true; 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); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
520
|
+
}
|
|
521
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
444
522
|
if (!hasSchemaResult()) {
|
|
445
523
|
try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
446
|
-
throwIfTerminalAssistantError(session);
|
|
524
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
447
525
|
}
|
|
448
526
|
if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
449
527
|
}
|
|
@@ -481,6 +559,22 @@ export class WorkflowAgentExecutor {
|
|
|
481
559
|
session.dispose();
|
|
482
560
|
}
|
|
483
561
|
if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
|
|
562
|
+
const terminal = terminalProviderError(typed);
|
|
563
|
+
if (terminal && options.providerErrorRecovery) {
|
|
564
|
+
let recovery: AgentProviderRecovery;
|
|
565
|
+
try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
|
|
566
|
+
if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
|
|
567
|
+
if (typeof recovery === "object") {
|
|
568
|
+
try {
|
|
569
|
+
const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
|
|
570
|
+
recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
|
|
571
|
+
} catch { throw Object.assign(typed, { attempts }); }
|
|
572
|
+
}
|
|
573
|
+
maxAttempts += 1;
|
|
574
|
+
beforeRetry?.();
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
484
578
|
if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
|
|
485
579
|
beforeRetry?.();
|
|
486
580
|
}
|
|
@@ -640,17 +734,35 @@ export class FairAgentScheduler {
|
|
|
640
734
|
if (nodes.every(({ restored }) => restored)) run.logical = 0;
|
|
641
735
|
}
|
|
642
736
|
|
|
643
|
-
|
|
737
|
+
removeRun(runId: string): void {
|
|
738
|
+
const run = this.#runs.get(runId);
|
|
739
|
+
if (!run) return;
|
|
740
|
+
const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
|
|
741
|
+
if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state))) throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
|
|
742
|
+
for (const { id } of nodes) this.#nodes.delete(id);
|
|
743
|
+
this.#runs.delete(runId);
|
|
744
|
+
const index = this.#runOrder.indexOf(runId);
|
|
745
|
+
if (index >= 0) {
|
|
746
|
+
this.#runOrder.splice(index, 1);
|
|
747
|
+
if (index < this.#cursor) this.#cursor -= 1;
|
|
748
|
+
if (this.#cursor >= this.#runOrder.length) this.#cursor = 0;
|
|
749
|
+
}
|
|
750
|
+
this.#dispatch();
|
|
751
|
+
}
|
|
752
|
+
|
|
644
753
|
toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
|
|
645
754
|
const parent = this.#node(parentId);
|
|
646
755
|
if (!parent.options.tools.includes("agent")) return [];
|
|
647
756
|
const agentTool = {
|
|
648
757
|
name: "agent", label: "Child Agent", description: "Start a direct child agent",
|
|
649
758
|
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<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
|
|
650
|
-
execute: async (_id: string,
|
|
759
|
+
execute: async (_id: string, rawParams: unknown) => {
|
|
760
|
+
if (!isChildAgentToolParams(rawParams)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
|
|
761
|
+
const params = rawParams;
|
|
651
762
|
if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined)) throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
652
763
|
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;
|
|
653
|
-
const agentOptions =
|
|
764
|
+
const agentOptions = { ...params };
|
|
765
|
+
Reflect.deleteProperty(agentOptions, "prompt");
|
|
654
766
|
const options: ScheduledAgentOptions = { 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 }) };
|
|
655
767
|
const child = this.spawn(parent.runId, params.prompt, options, parentId);
|
|
656
768
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
|
|
@@ -694,14 +806,14 @@ export class FairAgentScheduler {
|
|
|
694
806
|
|
|
695
807
|
#inherit(parent: ScheduledNode | undefined, options: ScheduledAgentOptions): Readonly<ScheduledAgentOptions> {
|
|
696
808
|
if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
|
|
697
|
-
|
|
809
|
+
const inheritedTools: readonly string[] = options.tools;
|
|
810
|
+
if (!parent) 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]) }) } : {}) });
|
|
698
811
|
if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
|
|
699
|
-
const forbidden =
|
|
812
|
+
const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
|
|
700
813
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
|
|
701
814
|
const identity = options.agentIdentity ?? parent.options.agentIdentity;
|
|
702
|
-
return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...
|
|
815
|
+
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 } : {}) });
|
|
703
816
|
}
|
|
704
|
-
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
|
|
705
817
|
|
|
706
818
|
#enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
|
|
707
819
|
|
|
@@ -734,6 +846,7 @@ export class FairAgentScheduler {
|
|
|
734
846
|
if (["completed", "failed", "cancelled"].includes(node.state)) return;
|
|
735
847
|
const heldPermit = node.state === "running" || node.state === "retrying";
|
|
736
848
|
node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
|
|
849
|
+
Reflect.deleteProperty(node, "steer");
|
|
737
850
|
this.#persist(node.runId);
|
|
738
851
|
if (heldPermit) this.#release(node.runId);
|
|
739
852
|
for (const childId of node.children) { const child = this.#nodes.get(childId); if (child && !child.collected) this.#cancelTree(child); }
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } 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
|
|
|
11
12
|
export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
|
|
12
13
|
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.";
|
|
@@ -327,26 +328,13 @@ function ambientAgentDir(environment: NodeJS.ProcessEnv): string {
|
|
|
327
328
|
return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
328
329
|
}
|
|
329
330
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
function findSessionFile(directory: string, sessionId: string): string | undefined {
|
|
333
|
-
if (!existsSync(directory)) return undefined;
|
|
334
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
335
|
-
const path = join(directory, entry.name);
|
|
336
|
-
if (entry.isDirectory()) { const found = findSessionFile(path, sessionId); if (found) return found; }
|
|
337
|
-
else if (entry.name.endsWith(".jsonl")) {
|
|
338
|
-
try { const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}") as unknown; if (isObject(header) && header.id === sessionId) return path; } catch { /* Ignore incomplete sessions. */ }
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
return undefined;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function emptyResult(candidate: AmbientEvalCase, repository: AmbientFixtureRepository, worktree: AmbientCaseWorktree, environment: NodeJS.ProcessEnv, error: string): AmbientCaseResult {
|
|
331
|
+
type AmbientCaseResultDraft = Omit<AmbientCaseResult, "manifest"> & { manifest: Omit<AmbientManifest, "gitStatusAfter"> };
|
|
332
|
+
function emptyResult(candidate: AmbientEvalCase, repository: AmbientFixtureRepository, worktree: AmbientCaseWorktree, environment: NodeJS.ProcessEnv, error: string): AmbientCaseResultDraft {
|
|
345
333
|
const accounting = emptyAccounting();
|
|
346
334
|
return {
|
|
347
335
|
id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
|
|
348
336
|
manifest: {
|
|
349
|
-
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore,
|
|
337
|
+
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,
|
|
350
338
|
cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
|
|
351
339
|
},
|
|
352
340
|
};
|
|
@@ -356,12 +344,13 @@ async function runAmbientCase(repository: AmbientFixtureRepository, candidate: A
|
|
|
356
344
|
const worktree = createAmbientCaseWorktree(repository, candidate.id);
|
|
357
345
|
const sessionId = randomUUID();
|
|
358
346
|
const sessionDir = join(repository.root, "sessions", candidate.id);
|
|
359
|
-
let result:
|
|
360
|
-
let
|
|
347
|
+
let result: AmbientCaseResultDraft | undefined;
|
|
348
|
+
let failure: string | undefined;
|
|
349
|
+
let gitStatusAfter: readonly string[];
|
|
361
350
|
try {
|
|
362
351
|
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 });
|
|
363
352
|
const sessionFile = findSessionFile(sessionDir, sessionId);
|
|
364
|
-
if (!sessionFile)
|
|
353
|
+
if (!sessionFile) failure = "Ambient parent session was not written.";
|
|
365
354
|
else {
|
|
366
355
|
const oracle = extractParentOracleFile(sessionFile);
|
|
367
356
|
const workflows = extractCapturedWorkflows(oracle);
|
|
@@ -371,24 +360,25 @@ async function runAmbientCase(repository: AmbientFixtureRepository, candidate: A
|
|
|
371
360
|
result = {
|
|
372
361
|
id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
|
|
373
362
|
manifest: {
|
|
374
|
-
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore,
|
|
363
|
+
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,
|
|
375
364
|
cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
|
|
376
365
|
},
|
|
377
366
|
};
|
|
378
367
|
}
|
|
379
368
|
} catch (error) {
|
|
380
|
-
|
|
369
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
381
370
|
} finally {
|
|
382
371
|
try { gitStatusAfter = gitStatus(worktree.path); } catch { gitStatusAfter = ["<unavailable>"]; }
|
|
383
372
|
const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
|
|
384
|
-
if (!result) result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
|
|
373
|
+
if (!result) result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
|
|
385
374
|
const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
|
|
386
|
-
result = { ...result, manifest: { ...result.manifest,
|
|
387
|
-
writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
|
375
|
+
result = { ...result, manifest: { ...result.manifest, cleanup } };
|
|
388
376
|
}
|
|
389
|
-
return result;
|
|
390
|
-
}
|
|
391
377
|
|
|
378
|
+
const finalized: AmbientCaseResult = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
|
|
379
|
+
writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
|
|
380
|
+
return finalized;
|
|
381
|
+
}
|
|
392
382
|
export function assertAmbientOptIn(environment: NodeJS.ProcessEnv = process.env): void {
|
|
393
383
|
if (environment[AMBIENT_OPT_IN] !== "1") throw new Error(`Ambient Tier D evals are opt-in. Set ${AMBIENT_OPT_IN}=1 to run them.`);
|
|
394
384
|
}
|