@d3ara1n/pi-subagent 0.9.1 → 0.10.1
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 +20 -6
- package/package.json +4 -3
- package/src/config.test.ts +123 -0
- package/src/config.ts +6 -5
- package/src/history.ts +56 -0
- package/src/index.ts +29 -444
- package/src/output.ts +116 -0
- package/src/render.ts +277 -0
- package/src/roles.ts +6 -6
- package/src/spawn.ts +47 -24
- package/src/types.ts +9 -9
- package/src/utils.test.ts +36 -2
- package/src/utils.ts +27 -9
package/README.md
CHANGED
|
@@ -32,9 +32,9 @@ This means:
|
|
|
32
32
|
|
|
33
33
|
| Role | Model Role | Tools | Can Delegate To | Description |
|
|
34
34
|
|------|-----------|-------|-----------------|-------------|
|
|
35
|
-
| `explorer` | fast | read, find, grep
|
|
36
|
-
| `reviewer` | heavy | read, bash, grep,
|
|
37
|
-
| `worker` | default | read, bash, edit, write, grep,
|
|
35
|
+
| `explorer` | fast | read, find, grep | — | Fast code search (read-only, no bash) |
|
|
36
|
+
| `reviewer` | heavy | read, bash, grep, find | — | Deep code review (read-only, bash for git/log) |
|
|
37
|
+
| `worker` | default | read, bash, edit, write, grep, find, delegate | explorer, researcher | Implementation — the only role that can modify files |
|
|
38
38
|
| `researcher` | fast | web_search, fetch_content, read, bash, delegate | explorer | Web research + GitHub repo analysis |
|
|
39
39
|
|
|
40
40
|
**Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
|
|
@@ -54,7 +54,19 @@ This means:
|
|
|
54
54
|
## Installation
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
pi install
|
|
57
|
+
pi install npm:@d3ara1n/pi-model-roles
|
|
58
|
+
pi install npm:@d3ara1n/pi-subagent
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or add to `~/.pi/agent/settings.json`:
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"extensions": [
|
|
66
|
+
"/absolute/path/to/pi-extensions/packages/pi-model-roles",
|
|
67
|
+
"/absolute/path/to/pi-extensions/packages/pi-subagent"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
58
70
|
```
|
|
59
71
|
|
|
60
72
|
## Configuration
|
|
@@ -82,6 +94,8 @@ Edit `~/.pi/agent/settings.json`:
|
|
|
82
94
|
|
|
83
95
|
All fields are optional. Defaults: `timeout: 1500` (seconds; 25 min; active time — the clock pauses while the child is inside a nested `delegate` call, so delegate-capable roles need no extra headroom), `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
|
|
84
96
|
|
|
97
|
+
All numeric limits accept `0` for unlimited: `timeout`, `maxConcurrency`, `maxDepth`, `maxTurns`, and `maxCost`. Negative values are normalized to `0`; non-numeric or non-finite values fall back to their defaults. `maxConcurrency: 0` runs delegates without queuing, and `maxDepth: 0` permits unrestricted nesting.
|
|
98
|
+
|
|
85
99
|
### Agent Overrides
|
|
86
100
|
|
|
87
101
|
Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
|
|
@@ -117,7 +131,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
|
|
|
117
131
|
|
|
118
132
|
**Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `tools`, `systemPrompt`.
|
|
119
133
|
|
|
120
|
-
**Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role timeout
|
|
134
|
+
**Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset uses the global setting, `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the global setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role on provider errors).
|
|
121
135
|
|
|
122
136
|
Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
|
|
123
137
|
|
|
@@ -182,7 +196,7 @@ Each path is injected as an independent `@file` attachment the subagent reads di
|
|
|
182
196
|
|
|
183
197
|
### Budget enforcement
|
|
184
198
|
|
|
185
|
-
`maxTurns` / `maxCost` cap a run. When exceeded, the child is killed and the last completed output is returned with `stopReason: "budget_exceeded"` (shown in the expanded TUI). Defaults are unlimited (0); set global defaults in config or per-role
|
|
199
|
+
`maxTurns` / `maxCost` cap a run. When exceeded, the child is killed and the last completed output is returned with `stopReason: "budget_exceeded"` (shown in the expanded TUI). Defaults are unlimited (`0`); set global defaults in config or per-role overrides in `agentOverrides`. Negative values are normalized to `0`.
|
|
186
200
|
|
|
187
201
|
### Oversized outputs
|
|
188
202
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
9
|
-
"pi"
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-extension"
|
|
10
11
|
],
|
|
11
12
|
"peerDependencies": {
|
|
12
13
|
"@earendil-works/pi-ai": "*",
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
}
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@d3ara1n/pi-model-roles": "^0.
|
|
30
|
+
"@d3ara1n/pi-model-roles": "^0.7.0"
|
|
30
31
|
},
|
|
31
32
|
"pi": {
|
|
32
33
|
"extensions": [
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loading regression tests.
|
|
3
|
+
*
|
|
4
|
+
* Uses isolated global/project settings roots via PI_AGENT_DIR.
|
|
5
|
+
* node --test packages/pi-subagent/src/config.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { loadSubagentConfig } from "./config.ts";
|
|
14
|
+
import { DEFAULT_CONFIG } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const originalAgentDir = process.env.PI_AGENT_DIR;
|
|
17
|
+
const tempRoots: string[] = [];
|
|
18
|
+
|
|
19
|
+
function makeRoot(): { agentDir: string; projectDir: string } {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-config-test-"));
|
|
21
|
+
tempRoots.push(root);
|
|
22
|
+
const agentDir = path.join(root, "agent");
|
|
23
|
+
const projectDir = path.join(root, "project");
|
|
24
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
25
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
26
|
+
process.env.PI_AGENT_DIR = agentDir;
|
|
27
|
+
return { agentDir, projectDir };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeSettings(dir: string, settings: unknown): void {
|
|
31
|
+
writeSettingsText(dir, JSON.stringify(settings));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeSettingsText(dir: string, content: string): void {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, "settings.json"), content);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (originalAgentDir === undefined) delete process.env.PI_AGENT_DIR;
|
|
41
|
+
else process.env.PI_AGENT_DIR = originalAgentDir;
|
|
42
|
+
for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("loadSubagentConfig", () => {
|
|
46
|
+
test("preserves zero limits and clamps negative numeric limits to unlimited", () => {
|
|
47
|
+
const { agentDir } = makeRoot();
|
|
48
|
+
writeSettings(agentDir, {
|
|
49
|
+
subagent: {
|
|
50
|
+
timeout: 0,
|
|
51
|
+
maxConcurrency: -1,
|
|
52
|
+
maxDepth: -2,
|
|
53
|
+
maxTurns: 0,
|
|
54
|
+
maxCost: -0.5,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const config = loadSubagentConfig();
|
|
59
|
+
assert.equal(config.timeout, 0);
|
|
60
|
+
assert.equal(config.maxConcurrency, 0);
|
|
61
|
+
assert.equal(config.maxDepth, 0);
|
|
62
|
+
assert.equal(config.maxTurns, 0);
|
|
63
|
+
assert.equal(config.maxCost, 0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("uses defaults for non-finite or non-numeric numeric limits", () => {
|
|
67
|
+
const { agentDir } = makeRoot();
|
|
68
|
+
// JSON.parse accepts numeric overflow as Infinity, even though JSON.stringify
|
|
69
|
+
// would serialize it as null. Exercise both non-finite and wrong-type inputs.
|
|
70
|
+
writeSettingsText(
|
|
71
|
+
agentDir,
|
|
72
|
+
'{"subagent":{"timeout":1e999,"maxConcurrency":-1e999,"maxDepth":{},"maxTurns":false,"maxCost":"NaN"}}',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const config = loadSubagentConfig();
|
|
76
|
+
assert.equal(config.timeout, DEFAULT_CONFIG.timeout);
|
|
77
|
+
assert.equal(config.maxConcurrency, DEFAULT_CONFIG.maxConcurrency);
|
|
78
|
+
assert.equal(config.maxDepth, DEFAULT_CONFIG.maxDepth);
|
|
79
|
+
assert.equal(config.maxTurns, DEFAULT_CONFIG.maxTurns);
|
|
80
|
+
assert.equal(config.maxCost, DEFAULT_CONFIG.maxCost);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("floors finite fractional count limits", () => {
|
|
84
|
+
const { agentDir } = makeRoot();
|
|
85
|
+
writeSettings(agentDir, {
|
|
86
|
+
subagent: { maxConcurrency: 2.9, maxDepth: 3.1, maxTurns: 4.8 },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const config = loadSubagentConfig();
|
|
90
|
+
assert.equal(config.maxConcurrency, 2);
|
|
91
|
+
assert.equal(config.maxDepth, 3);
|
|
92
|
+
assert.equal(config.maxTurns, 4);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("project subagent block replaces global wholesale and defaults omitted fields", () => {
|
|
96
|
+
const { agentDir, projectDir } = makeRoot();
|
|
97
|
+
writeSettings(agentDir, {
|
|
98
|
+
subagent: {
|
|
99
|
+
timeout: 999,
|
|
100
|
+
maxConcurrency: 8,
|
|
101
|
+
maxDepth: 7,
|
|
102
|
+
maxTurns: 6,
|
|
103
|
+
maxCost: 5,
|
|
104
|
+
history: { enabled: false },
|
|
105
|
+
summary: { enabled: false, role: "global-summary" },
|
|
106
|
+
agentOverrides: { global: { disabled: true } },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
writeSettings(path.join(projectDir, ".pi"), {
|
|
110
|
+
subagent: { timeout: 12, summary: { role: "project-summary" } },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const config = loadSubagentConfig(projectDir);
|
|
114
|
+
assert.equal(config.timeout, 12);
|
|
115
|
+
assert.equal(config.maxConcurrency, DEFAULT_CONFIG.maxConcurrency);
|
|
116
|
+
assert.equal(config.maxDepth, DEFAULT_CONFIG.maxDepth);
|
|
117
|
+
assert.equal(config.maxTurns, DEFAULT_CONFIG.maxTurns);
|
|
118
|
+
assert.equal(config.maxCost, DEFAULT_CONFIG.maxCost);
|
|
119
|
+
assert.deepEqual(config.history, DEFAULT_CONFIG.history);
|
|
120
|
+
assert.deepEqual(config.summary, { enabled: DEFAULT_CONFIG.summary.enabled, role: "project-summary" });
|
|
121
|
+
assert.deepEqual(config.agentOverrides, {});
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as os from "node:os";
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import type { SubagentConfig } from "./types.ts";
|
|
12
12
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
13
|
+
import { normalizeNonNegativeInteger, normalizeNonNegativeNumber } from "./utils.ts";
|
|
13
14
|
|
|
14
15
|
function getAgentDir(): string {
|
|
15
16
|
const envDir = process.env.PI_AGENT_DIR;
|
|
@@ -45,11 +46,11 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
|
|
|
45
46
|
const rawSummary = raw?.summary;
|
|
46
47
|
const rawHistory = raw?.history;
|
|
47
48
|
return {
|
|
48
|
-
timeout: raw.timeout
|
|
49
|
-
maxConcurrency: raw.maxConcurrency
|
|
50
|
-
maxDepth: raw.maxDepth
|
|
51
|
-
maxTurns: raw.maxTurns
|
|
52
|
-
maxCost: raw.maxCost
|
|
49
|
+
timeout: normalizeNonNegativeNumber(raw.timeout, DEFAULT_CONFIG.timeout),
|
|
50
|
+
maxConcurrency: normalizeNonNegativeInteger(raw.maxConcurrency, DEFAULT_CONFIG.maxConcurrency),
|
|
51
|
+
maxDepth: normalizeNonNegativeInteger(raw.maxDepth, DEFAULT_CONFIG.maxDepth),
|
|
52
|
+
maxTurns: normalizeNonNegativeInteger(raw.maxTurns, DEFAULT_CONFIG.maxTurns),
|
|
53
|
+
maxCost: normalizeNonNegativeNumber(raw.maxCost, DEFAULT_CONFIG.maxCost),
|
|
53
54
|
history: {
|
|
54
55
|
enabled: rawHistory?.enabled ?? DEFAULT_CONFIG.history.enabled,
|
|
55
56
|
},
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History persistence for pi-subagent delegate runs.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort audit log: writes one JSON record per delegate run under
|
|
5
|
+
* ~/.pi/subagent/history/{sessionId}/{toolCallId}.json. Never throws — persistence
|
|
6
|
+
* must not fail the delegation. Privacy parity with pi's own session files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import type { SubagentResult } from "./types.ts";
|
|
13
|
+
import { sanitizeFilename } from "./utils.ts";
|
|
14
|
+
|
|
15
|
+
export function persistSubagentHistory(
|
|
16
|
+
sessionId: string | undefined,
|
|
17
|
+
toolCallId: string,
|
|
18
|
+
role: string,
|
|
19
|
+
task: string,
|
|
20
|
+
r: SubagentResult,
|
|
21
|
+
rawOutput?: string,
|
|
22
|
+
): void {
|
|
23
|
+
try {
|
|
24
|
+
const dir = path.join(
|
|
25
|
+
os.homedir(),
|
|
26
|
+
".pi",
|
|
27
|
+
"subagent",
|
|
28
|
+
"history",
|
|
29
|
+
sanitizeFilename(sessionId ?? "unknown"),
|
|
30
|
+
);
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
const payload = {
|
|
33
|
+
id: toolCallId,
|
|
34
|
+
role,
|
|
35
|
+
task,
|
|
36
|
+
timestamp: Date.now(),
|
|
37
|
+
exitCode: r.exitCode,
|
|
38
|
+
stopReason: r.stopReason,
|
|
39
|
+
model: r.model,
|
|
40
|
+
summary: r.summary,
|
|
41
|
+
// Keep the full original output for auditing even if LLM/TUI saw a compressed/truncated version.
|
|
42
|
+
output: rawOutput ?? r.output,
|
|
43
|
+
outputMethod: r.outputMethod,
|
|
44
|
+
errorMessage: r.errorMessage,
|
|
45
|
+
usage: r.usage,
|
|
46
|
+
activityLog: r.activityLog,
|
|
47
|
+
};
|
|
48
|
+
fs.writeFileSync(
|
|
49
|
+
path.join(dir, `${sanitizeFilename(toolCallId)}.json`),
|
|
50
|
+
JSON.stringify(payload, null, 2),
|
|
51
|
+
{ mode: 0o600 },
|
|
52
|
+
);
|
|
53
|
+
} catch {
|
|
54
|
+
/* best-effort — never fail the delegation */
|
|
55
|
+
}
|
|
56
|
+
}
|