pi-extensible-workflows 3.0.0 → 3.2.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 +15 -17
- package/dist/src/agent-execution.d.ts +4 -80
- package/dist/src/agent-execution.js +20 -10
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +48 -2
- package/dist/src/doctor-cleanup.d.ts +41 -0
- package/dist/src/doctor-cleanup.js +597 -0
- package/dist/src/doctor.d.ts +7 -2
- package/dist/src/doctor.js +127 -22
- package/dist/src/execution.js +13 -4
- package/dist/src/host.d.ts +83 -4
- package/dist/src/host.js +1246 -410
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.js +3 -1
- package/dist/src/persistence.d.ts +3 -0
- package/dist/src/persistence.js +49 -1
- package/dist/src/registry.d.ts +21 -9
- package/dist/src/registry.js +131 -21
- package/dist/src/session-inspector.js +4 -2
- package/dist/src/types.d.ts +135 -7
- package/dist/src/utils.d.ts +6 -0
- package/dist/src/utils.js +45 -5
- package/dist/src/validation.d.ts +6 -2
- package/dist/src/validation.js +157 -31
- package/dist/src/workflow-artifacts.d.ts +13 -0
- package/dist/src/workflow-artifacts.js +39 -0
- package/examples/workflow-extension-template/README.md +37 -0
- package/examples/workflow-extension-template/extension.test.mjs +59 -0
- package/examples/workflow-extension-template/index.js +51 -0
- package/examples/workflow-extension-template/roles/reviewer.md +4 -0
- package/package.json +5 -3
- package/skills/pi-extensible-workflows/SKILL.md +45 -18
- package/src/agent-execution.ts +23 -37
- package/src/cli.ts +33 -2
- package/src/doctor-cleanup.ts +337 -0
- package/src/doctor.ts +117 -24
- package/src/execution.ts +13 -4
- package/src/host.ts +1039 -366
- package/src/index.ts +5 -2
- package/src/persistence.ts +35 -1
- package/src/registry.ts +108 -25
- package/src/session-inspector.ts +4 -2
- package/src/types.ts +53 -8
- package/src/utils.ts +39 -5
- package/src/validation.ts +130 -31
- package/src/workflow-artifacts.ts +34 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export function workflowScriptArtifact(script) { return { extension: ".js", content: script }; }
|
|
6
|
+
export function workflowResultArtifact(value) { return typeof value === "string" ? { extension: ".md", content: value } : { extension: ".json", content: `${JSON.stringify(value, null, 2)}\n` }; }
|
|
7
|
+
async function spawnWorkflowEditor(command, path) {
|
|
8
|
+
const [editor, ...editorArgs] = command.split(" ");
|
|
9
|
+
if (!editor)
|
|
10
|
+
return null;
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
try {
|
|
13
|
+
const child = spawn(editor, [...editorArgs, path], { stdio: "inherit", shell: process.platform === "win32" });
|
|
14
|
+
child.once("error", () => { resolve(null); });
|
|
15
|
+
child.once("close", (code) => { resolve(code); });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
resolve(null);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function openWorkflowArtifact(tui, command, artifact) {
|
|
23
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-workflow-editor-"));
|
|
24
|
+
const path = join(directory, `artifact${artifact.extension}`);
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(path, artifact.content, { encoding: "utf8", mode: 0o600 });
|
|
27
|
+
tui.stop();
|
|
28
|
+
try {
|
|
29
|
+
return await spawnWorkflowEditor(command, path);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
tui.start();
|
|
33
|
+
tui.requestRender(true);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await rm(directory, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Workflow extension template
|
|
2
|
+
|
|
3
|
+
This is a small, copyable extension rather than a generator. It shows the usual
|
|
4
|
+
registration shape with one function and one packaged role. Copy the directory
|
|
5
|
+
into a project, rename the metadata and function, then edit the role body.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
From the repository root after installing dependencies and building the package:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
node --test examples/workflow-extension-template/extension.test.mjs
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For a published package, run the same test from this directory after installing
|
|
17
|
+
`pi-extensible-workflows` in the surrounding project. Copy the directory into a
|
|
18
|
+
trusted Pi extension location; Pi auto-discovers its `index.js` entry point.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Files
|
|
22
|
+
|
|
23
|
+
- `index.js` registers `greet` and resolves `roles/` from `import.meta.url`.
|
|
24
|
+
Relative role-directory strings are not accepted by the extension API.
|
|
25
|
+
- `roles/reviewer.md` is a portable packaged role with no provider or tool
|
|
26
|
+
assumptions.
|
|
27
|
+
- `extension.test.mjs` checks registration, function behavior, role packaging,
|
|
28
|
+
and the advanced examples.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Optional advanced pieces
|
|
32
|
+
|
|
33
|
+
The dynamic `template-model` alias and `templateAdvisor` setup hook are
|
|
34
|
+
optional examples. The hook only changes an agent after the call includes
|
|
35
|
+
`{ templateAdvisor: true }`; remove either section if the extension does not
|
|
36
|
+
need it. Both features are trusted host code and should be kept under explicit
|
|
37
|
+
project policy.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { cp, mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { beginWorkflowExtensionLoading, loadingRegistry, registeredWorkflowFunctions, registeredWorkflowRoleDirectoryRegistrations, resetWorkflowRegistry, workflowCatalog } from "pi-extensible-workflows";
|
|
10
|
+
|
|
11
|
+
test("discovers the copied directory as a trusted Pi extension", async () => {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), "workflow-extension-template-"));
|
|
13
|
+
try {
|
|
14
|
+
const destination = join(root, ".pi", "extensions", "workflow-extension-template");
|
|
15
|
+
await mkdir(join(root, "node_modules"), { recursive: true });
|
|
16
|
+
const packageEntry = fileURLToPath(import.meta.resolve("pi-extensible-workflows"));
|
|
17
|
+
const packageRoot = join(dirname(packageEntry), "..", "..");
|
|
18
|
+
await symlink(packageRoot, join(root, "node_modules", "pi-extensible-workflows"), "dir");
|
|
19
|
+
await cp(dirname(fileURLToPath(import.meta.url)), destination, { recursive: true });
|
|
20
|
+
const result = await discoverAndLoadExtensions([], root, join(root, ".pi", "agent"));
|
|
21
|
+
assert.equal(result.errors.length, 0);
|
|
22
|
+
assert.equal(result.extensions.length, 1);
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(root, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
resetWorkflowRegistry();
|
|
28
|
+
const { default: extension } = await import("./index.js");
|
|
29
|
+
beginWorkflowExtensionLoading();
|
|
30
|
+
extension();
|
|
31
|
+
|
|
32
|
+
const catalog = workflowCatalog();
|
|
33
|
+
assert.deepEqual(catalog.functions.map(({ name }) => name), ["greet"]);
|
|
34
|
+
assert.deepEqual(catalog.modelAliasEntries?.filter(({ name }) => name === "template-model").map(({ name, kind }) => ({ name, kind })), [{ name: "template-model", kind: "dynamic" }]);
|
|
35
|
+
assert.equal(await registeredWorkflowFunctions().greet.run({ name: "Ada" }, {}), "Hello, Ada!");
|
|
36
|
+
|
|
37
|
+
const registration = registeredWorkflowRoleDirectoryRegistrations()[0];
|
|
38
|
+
assert.ok(registration);
|
|
39
|
+
assert.match(readFileSync(join(registration.path, "reviewer.md"), "utf8"), /Packaged reviewer role/);
|
|
40
|
+
|
|
41
|
+
const resolved = await loadingRegistry().resolveModelAliases({
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
projectTrusted: true,
|
|
44
|
+
rootModel: { provider: "example", model: "root" },
|
|
45
|
+
knownModels: new Set(["example/root", "example/available"]),
|
|
46
|
+
availableModels: new Set(["example/available"]),
|
|
47
|
+
signal: new AbortController().signal,
|
|
48
|
+
});
|
|
49
|
+
assert.deepEqual(resolved, { "template-model": "example/available" });
|
|
50
|
+
|
|
51
|
+
const hook = loadingRegistry().agentSetupHooks().find(({ name }) => name === "templateAdvisor");
|
|
52
|
+
assert.ok(hook);
|
|
53
|
+
const agent = { prompt: "Review this", options: {}, sessionInput: {} };
|
|
54
|
+
await hook.setup(agent, { signal: new AbortController().signal });
|
|
55
|
+
assert.equal(agent.sessionInput.systemPromptAppend, undefined);
|
|
56
|
+
agent.options.templateAdvisor = true;
|
|
57
|
+
await hook.setup(agent, { signal: new AbortController().signal });
|
|
58
|
+
assert.match(agent.sessionInput.systemPromptAppend, /one concrete risk/);
|
|
59
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { registerWorkflowExtension } from "pi-extensible-workflows";
|
|
2
|
+
|
|
3
|
+
const templateExtension = {
|
|
4
|
+
version: "1.0.0",
|
|
5
|
+
headline: "Workflow extension template",
|
|
6
|
+
description: "A copyable registered function with a packaged role.",
|
|
7
|
+
functions: {
|
|
8
|
+
greet: {
|
|
9
|
+
description: "Return a greeting for one person.",
|
|
10
|
+
input: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: { name: { type: "string" } },
|
|
13
|
+
required: ["name"],
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
},
|
|
16
|
+
output: { type: "string" },
|
|
17
|
+
run(input) {
|
|
18
|
+
return `Hello, ${String(input.name)}!`;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// Packaged resources must be absolute paths or file URLs. This URL stays
|
|
23
|
+
// correct when the extension is copied or installed elsewhere.
|
|
24
|
+
roleDirectories: [new URL("./roles/", import.meta.url)],
|
|
25
|
+
// Optional advanced example: select an available model without naming a
|
|
26
|
+
// provider-specific model in the extension.
|
|
27
|
+
modelAliases: {
|
|
28
|
+
"template-model": {
|
|
29
|
+
resolve({ availableModels, rootModel }) {
|
|
30
|
+
const root = `${rootModel.provider}/${rootModel.model}`;
|
|
31
|
+
return availableModels.has(root) ? root : availableModels.values().next().value ?? root;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
// Optional advanced example: mutate setup only for explicitly opted-in
|
|
36
|
+
// agents. Setup hooks are trusted code and should stay narrowly scoped.
|
|
37
|
+
agentSetupHooks: {
|
|
38
|
+
templateAdvisor: {
|
|
39
|
+
setup(agent, context) {
|
|
40
|
+
if (context.signal.aborted || agent.options.templateAdvisor !== true) return;
|
|
41
|
+
const suffix = "\n\nAdvisor: call out one concrete risk and one next check.";
|
|
42
|
+
const existing = agent.sessionInput.systemPromptAppend;
|
|
43
|
+
agent.sessionInput.systemPromptAppend = existing ? existing + suffix : suffix;
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default function extension() {
|
|
50
|
+
registerWorkflowExtension(templateExtension);
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-extensible-workflows",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Deterministic multi-agent workflow orchestration for Pi",
|
|
5
5
|
"homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
|
|
6
6
|
"repository": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"src",
|
|
26
26
|
"skills",
|
|
27
27
|
"evals",
|
|
28
|
+
"examples",
|
|
28
29
|
"test/fixtures"
|
|
29
30
|
],
|
|
30
31
|
"scripts": {
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"inspect": "node dist/src/cli.js inspect",
|
|
33
34
|
"lint": "eslint .",
|
|
34
35
|
"test": "npm run build && TEST_FILES='dist/test/*.test.js' npm run test:run",
|
|
35
|
-
"test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test $TEST_FILES",
|
|
36
|
+
"test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test --test-concurrency=1 $TEST_FILES",
|
|
36
37
|
"acceptance": "npm run build && TEST_FILES='dist/test/runtime-acceptance.test.js' npm run test:run",
|
|
37
38
|
"docs:check": "node scripts/check-docs.mjs",
|
|
38
39
|
"check": "npm run lint && npm test && npm run docs:check",
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
},
|
|
65
66
|
"license": "MIT",
|
|
66
67
|
"dependencies": {
|
|
67
|
-
"acorn": "^8.17.0"
|
|
68
|
+
"acorn": "^8.17.0",
|
|
69
|
+
"minimatch": "^10.2.5"
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -4,30 +4,38 @@ description: Use when the task is complex enough to require multiple subagents o
|
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-extensible-workflows
|
|
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.
|
|
8
7
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
## Default path
|
|
9
|
+
|
|
10
|
+
Use `workflow` only for genuinely multi-agent orchestration; a single agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
|
|
12
11
|
|
|
12
|
+
For most multi-agent tasks, start with a named inline workflow: provide a non-empty `name` and a `script` that fans out independent work with `parallel(...)`, awaits the keyed results, passes them into one summarizing `agent(...)`, and returns.
|
|
13
13
|
|
|
14
|
+
```js
|
|
14
15
|
const reports = await parallel("research", {
|
|
15
|
-
first: () => agent("Research the first target."
|
|
16
|
-
second: () => agent("Research the second target."
|
|
16
|
+
first: () => agent("Research the first target."),
|
|
17
|
+
second: () => agent("Research the second target."),
|
|
17
18
|
});
|
|
18
19
|
|
|
19
|
-
return agent(
|
|
20
|
-
prompt("
|
|
21
|
-
{ role: "reviewer", outputSchema: reportSchema },
|
|
20
|
+
return await agent(
|
|
21
|
+
prompt("Summarize these reports:\n\n{reports}", { reports }),
|
|
22
22
|
);
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Await `parallel(...)` or `pipeline(...)` results before interpolation. Runs are backgrounded by default; set the tool-call `foreground: true` when the caller must wait for the final value.
|
|
26
|
+
|
|
27
|
+
## Runtime and safety rules
|
|
28
|
+
|
|
29
|
+
|
|
25
30
|
Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
|
|
31
|
+
|
|
26
32
|
```json
|
|
27
33
|
{ "workflow": "workflowName", "args": { "issue": 42 } }
|
|
28
34
|
```
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
Recovery map: `agent(..., { retries })` reruns one agent call in the same run for transient failures; `workflow_retry({ runId })` replays a failed run into a child; `workflow_resume({ runId, budget? })` continues a `budget_exhausted` run; `parentRunId` on a new launch only borrows named worktrees and never replays or resumes. Use each only for its stated case.
|
|
36
|
+
For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. External side effects before failure are not guaranteed exactly once.
|
|
37
|
+
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. Retry versus per-agent `retries` and `workflow_resume` is always explicit.
|
|
38
|
+
Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
|
|
31
39
|
|
|
32
40
|
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`.
|
|
33
41
|
|
|
@@ -37,20 +45,28 @@ Example use of `shell`:
|
|
|
37
45
|
// ... other code
|
|
38
46
|
const testRes = await shell("yarn test", { env: { CI: "1" } });
|
|
39
47
|
if (testRes.exitCode === 0) {
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
// success path
|
|
49
|
+
return { ok: true };
|
|
42
50
|
}
|
|
43
51
|
```
|
|
44
52
|
|
|
45
53
|
Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
|
|
46
54
|
|
|
55
|
+
## Advanced capabilities
|
|
56
|
+
|
|
57
|
+
Registered functions, `outputSchema`, budgets, checkpoints, worktrees, retry/resume, CLI export, and `pipeline(...)` remain available for workflows that need them. Treat these as advanced controls rather than requirements for the default inline path.
|
|
58
|
+
|
|
47
59
|
## `agent()` options
|
|
60
|
+
|
|
48
61
|
```typescript
|
|
49
62
|
export interface AgentOptions {
|
|
50
|
-
label?: string;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
label?: string;
|
|
64
|
+
model?: string;
|
|
65
|
+
role?: string;
|
|
66
|
+
tools?: string[];
|
|
67
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
68
|
+
outputSchema?: JsonSchema;
|
|
69
|
+
retries?: number;
|
|
54
70
|
timeoutMs?: number | null;
|
|
55
71
|
[key: string]: JsonValue;
|
|
56
72
|
}
|
|
@@ -61,24 +77,34 @@ Extensions may add JSON-compatible agent options such as `advisor: true`; core k
|
|
|
61
77
|
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.
|
|
62
78
|
|
|
63
79
|
## Passing agent results
|
|
80
|
+
|
|
64
81
|
Use independent `agent(prompt, options)` calls and pass each completed result explicitly to the next prompt. This keeps workflow execution deterministic and makes replay state local to each call:
|
|
82
|
+
|
|
65
83
|
```js
|
|
66
84
|
const findings = await agent("Inspect the implementation.");
|
|
67
|
-
const fix = await agent(
|
|
85
|
+
const fix = await agent(
|
|
86
|
+
prompt("Propose the smallest fix from these findings:\n\n{findings}", {
|
|
87
|
+
findings,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
68
90
|
return { findings, fix };
|
|
69
91
|
```
|
|
70
92
|
|
|
71
93
|
## Worktrees
|
|
94
|
+
|
|
72
95
|
Use `withWorktree(name, callback)` for top-level agents that collaborate in one explicitly named worktree scope:
|
|
96
|
+
|
|
73
97
|
```js
|
|
74
98
|
const result = await withWorktree("issue", async ({ path, branch }) => {
|
|
75
99
|
const report = await agent("Implement the issue");
|
|
76
100
|
return { path, branch, report };
|
|
77
101
|
});
|
|
78
102
|
```
|
|
103
|
+
|
|
79
104
|
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.
|
|
80
105
|
|
|
81
106
|
Branches may call any workflow function, not only `agent()`. Use separate named scopes when parallel branches need isolated worktrees:
|
|
107
|
+
|
|
82
108
|
```js
|
|
83
109
|
const results = await parallel("implementation", {
|
|
84
110
|
api: () => withWorktree("api", () => agent("Implement the API")),
|
|
@@ -89,6 +115,7 @@ const results = await parallel("implementation", {
|
|
|
89
115
|
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.
|
|
90
116
|
|
|
91
117
|
## Rules
|
|
118
|
+
|
|
92
119
|
- Use `log(messageString)` for brief operator status.
|
|
93
120
|
- 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.
|
|
94
121
|
- 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.
|
package/src/agent-execution.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { realpathSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import { Value } from "typebox/value";
|
|
5
|
-
import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type
|
|
5
|
+
import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
7
7
|
type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
|
|
8
|
-
import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
|
|
9
|
-
import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
|
|
8
|
+
import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetup, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput, WorkflowRunContext } from "./types.js";
|
|
9
|
+
import { jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
|
|
10
10
|
import { WorkflowError } from "./types.js";
|
|
11
11
|
import type { RunStore } from "./persistence.js";
|
|
12
|
+
export type { AgentSetup, AgentSetupContext, AgentSetupHook, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput } from "./types.js";
|
|
12
13
|
export interface AgentBudgetHooks {
|
|
13
14
|
beforeAttempt(): void;
|
|
14
15
|
beforeTurn(): void;
|
|
@@ -66,29 +67,6 @@ export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: stri
|
|
|
66
67
|
export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
|
|
67
68
|
export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
|
|
68
69
|
export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
|
|
69
|
-
export interface AgentSetup { prompt: string; options: Record<string, JsonValue>; sessionInput: SessionInput; createSession: SessionFactory }
|
|
70
|
-
export interface AgentSetupContext { readonly run: Readonly<WorkflowRunContext>; readonly identity: Readonly<AgentIdentity>; readonly attempt: number; readonly signal: AbortSignal }
|
|
71
|
-
export interface AgentSetupHook { priority?: number; setup: (agent: AgentSetup, context: Readonly<AgentSetupContext>) => void | Promise<void> }
|
|
72
|
-
export interface RegisteredAgentSetupHook { name: string; priority: number; setup: AgentSetupHook["setup"] }
|
|
73
|
-
type NativeSessionStats = Pick<SessionStats, "tokens" | "cost">;
|
|
74
|
-
export interface NativeSession {
|
|
75
|
-
readonly sessionId: string;
|
|
76
|
-
readonly sessionFile: string | undefined;
|
|
77
|
-
readonly messages: readonly AgentMessage[];
|
|
78
|
-
getSessionStats(): NativeSessionStats;
|
|
79
|
-
readonly systemPrompt?: string;
|
|
80
|
-
readonly model?: { provider: string; model?: string; id?: string };
|
|
81
|
-
readonly agent?: { state: { tools: readonly { name: string }[] } };
|
|
82
|
-
getLeafId?: () => string | null;
|
|
83
|
-
getToolDefinitions?: () => unknown;
|
|
84
|
-
subscribe?(listener: (event: AgentSessionEvent) => void): () => void;
|
|
85
|
-
prompt(text: string): Promise<void>;
|
|
86
|
-
steer?(text: string): Promise<void>;
|
|
87
|
-
abort?(): Promise<void>;
|
|
88
|
-
dispose(): void;
|
|
89
|
-
}
|
|
90
|
-
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> }
|
|
91
|
-
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
92
70
|
|
|
93
71
|
function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
94
72
|
if (!value) return { ...fallback, ...(thinking ? { thinking } : {}) };
|
|
@@ -129,7 +107,7 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
|
|
|
129
107
|
return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
|
|
130
108
|
}
|
|
131
109
|
|
|
132
|
-
function accounting(stats:
|
|
110
|
+
function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
|
|
133
111
|
return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
|
|
134
112
|
}
|
|
135
113
|
function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
|
|
@@ -151,14 +129,17 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
151
129
|
settingsManager.setProjectTrusted(policy.projectTrusted);
|
|
152
130
|
const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
|
|
153
131
|
const resolved = await packageManager.resolve();
|
|
154
|
-
const
|
|
155
|
-
const
|
|
132
|
+
const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)))];
|
|
133
|
+
const excludedExtensions = new Set(disabledResources(policy.effective.extensions, discoveredExtensions));
|
|
134
|
+
const extensionPaths = discoveredExtensions.filter((path) => !excludedExtensions.has(path));
|
|
135
|
+
Object.assign(policy, { excludedExtensions: [...excludedExtensions], unmatchedExtensions: unmatchedResourcePatterns(policy.effective.extensions, discoveredExtensions) });
|
|
156
136
|
const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
|
|
157
|
-
const updateSkillMatches = (skills: readonly { name: string }[]) => {
|
|
158
|
-
const names = new Set(skills.map(({ name }) => name));
|
|
159
|
-
|
|
137
|
+
const updateSkillMatches = (skills: readonly { name: string }[]): Set<string> => {
|
|
138
|
+
const names = [...new Set(skills.map(({ name }) => name))];
|
|
139
|
+
const excludedSkills = disabledResources(policy.effective.skills, names);
|
|
140
|
+
Object.assign(policy, { excludedSkills, unmatchedSkills: unmatchedResourcePatterns(policy.effective.skills, names) });
|
|
141
|
+
return new Set(excludedSkills);
|
|
160
142
|
};
|
|
161
|
-
const disabledSkills = new Set(policy.effective.skills);
|
|
162
143
|
resourceLoader = new DefaultResourceLoader({
|
|
163
144
|
cwd: input.cwd,
|
|
164
145
|
agentDir,
|
|
@@ -169,14 +150,12 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
169
150
|
additionalSkillPaths: skillPaths,
|
|
170
151
|
...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
|
|
171
152
|
skillsOverride: (base) => {
|
|
172
|
-
updateSkillMatches(base.skills);
|
|
153
|
+
const disabledSkills = updateSkillMatches(base.skills);
|
|
173
154
|
return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
|
|
174
155
|
},
|
|
175
156
|
...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
|
|
176
157
|
});
|
|
177
158
|
await resourceLoader.reload();
|
|
178
|
-
const discoveredExtensions = new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)));
|
|
179
|
-
Object.assign(policy, { unmatchedExtensions: policy.effective.extensions.filter((path) => !discoveredExtensions.has(canonicalSourcePath(path))) });
|
|
180
159
|
} else if (input.systemPromptAppend || input.extensionFactories?.length) {
|
|
181
160
|
resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
|
|
182
161
|
await resourceLoader.reload();
|
|
@@ -218,7 +197,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
|
|
|
218
197
|
return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
|
|
219
198
|
}
|
|
220
199
|
function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
|
|
221
|
-
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
200
|
+
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
222
201
|
}
|
|
223
202
|
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): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
|
|
224
203
|
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
@@ -518,6 +497,13 @@ export class FairAgentScheduler {
|
|
|
518
497
|
this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
|
|
519
498
|
this.#runOrder.push(runId);
|
|
520
499
|
}
|
|
500
|
+
updateRunLimit(runId: string, limit: number): void {
|
|
501
|
+
const run = this.#runs.get(runId);
|
|
502
|
+
if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
|
|
503
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
|
|
504
|
+
run.limit = limit;
|
|
505
|
+
this.#dispatch();
|
|
506
|
+
}
|
|
521
507
|
|
|
522
508
|
spawn(runId: string, prompt: string, options: ScheduledAgentOptions, parentId?: string): { id: string; result: Promise<ScheduledAgentResult> } {
|
|
523
509
|
const run = this.#runs.get(runId);
|
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
7
7
|
import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Value } from "typebox/value";
|
|
9
9
|
import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
|
|
10
|
-
import
|
|
10
|
+
import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport, type DoctorCleanupOptions } from "./doctor-cleanup.js";
|
|
11
|
+
import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
|
|
11
12
|
import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
|
|
12
13
|
import type { PersistedRun } from "./persistence.js";
|
|
13
14
|
import type { WorkflowCatalogFunction } from "./index.js";
|
|
@@ -158,6 +159,26 @@ function launcherHelpLines(): string[] {
|
|
|
158
159
|
}
|
|
159
160
|
function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
160
161
|
function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
162
|
+
export function parseDoctorCleanupArgs(rawArgs: readonly string[]): Required<Pick<DoctorCleanupOptions, "olderThanDays" | "yes">> {
|
|
163
|
+
let olderThanDays = 90;
|
|
164
|
+
let yes = false;
|
|
165
|
+
let seenDays = false;
|
|
166
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
167
|
+
const token = rawArgs[index] as string;
|
|
168
|
+
if (token === "--yes") { yes = true; continue; }
|
|
169
|
+
const inline = token.startsWith("--older-than-days=") ? token.slice("--older-than-days=".length) : undefined;
|
|
170
|
+
if (token === "--older-than-days" || inline !== undefined) {
|
|
171
|
+
if (seenDays) throw new Error("--older-than-days may only be provided once");
|
|
172
|
+
const raw = inline ?? rawArgs[++index];
|
|
173
|
+
if (raw === undefined || !/^[1-9]\d*$/.test(raw)) throw new Error("older-than-days must be a positive integer");
|
|
174
|
+
const parsed = Number(raw);
|
|
175
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error("older-than-days must be a positive integer");
|
|
176
|
+
olderThanDays = parsed; seenDays = true; continue;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Unknown cleanup option: ${token}`);
|
|
179
|
+
}
|
|
180
|
+
return { olderThanDays, yes };
|
|
181
|
+
}
|
|
161
182
|
function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustOverride?: boolean } {
|
|
162
183
|
const args: string[] = [];
|
|
163
184
|
let trustOverride: boolean | undefined;
|
|
@@ -233,7 +254,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
|
|
|
233
254
|
workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
|
|
234
255
|
const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
|
|
235
256
|
if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
|
|
236
|
-
return { catalog: workflowCatalog(), services, workflowTool, shutdownHandlers };
|
|
257
|
+
return { catalog: workflowCatalog({ cwd, projectTrusted: settingsManager.isProjectTrusted(), globalSettingsPath: workflowSettingsPath(agentDir) }), services, workflowTool, shutdownHandlers };
|
|
237
258
|
}
|
|
238
259
|
|
|
239
260
|
function availableModelInfo(services: WorkflowRuntime["services"], available = false): { provider: string; id: string }[] {
|
|
@@ -415,6 +436,16 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
|
|
|
415
436
|
write(formatDoctorReport(report));
|
|
416
437
|
return doctorExitCode(report);
|
|
417
438
|
}
|
|
439
|
+
if (args[0] === "doctor" && args[1] === "cleanup") {
|
|
440
|
+
if (args.slice(2).some((arg) => arg === "--help" || arg === "-h")) { write("Usage: pi-extensible-workflows doctor cleanup [--older-than-days <days>] [--yes]\n"); return 0; }
|
|
441
|
+
try {
|
|
442
|
+
const parsed = parseDoctorCleanupArgs(args.slice(2));
|
|
443
|
+
const cleanupOptions: DoctorCleanupOptions = { ...parsed, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) };
|
|
444
|
+
const report = await doctorCleanup(cleanupOptions);
|
|
445
|
+
write(formatDoctorCleanupReport(report));
|
|
446
|
+
return doctorCleanupExitCode(report);
|
|
447
|
+
} catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
448
|
+
}
|
|
418
449
|
if (args[0] === "inspect" && args.length <= 2) {
|
|
419
450
|
try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
|
|
420
451
|
catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|