@henryqw/pi-subagent 7.0.1 → 8.0.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/CONTEXT.md +1 -1
- package/README.md +137 -30
- package/dist/ephemeral.d.ts +6 -2
- package/dist/ephemeral.js +37 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/orchestration.md +4 -2
- package/extensions/config.ts +9 -6
- package/extensions/delegate-flow.ts +2 -2
- package/extensions/role-tools.ts +57 -1
- package/extensions/subagent.ts +8 -3
- package/package.json +3 -3
- package/skills/pi-subagent-delegated-development/SKILL.md +2 -2
package/CONTEXT.md
CHANGED
|
@@ -24,7 +24,7 @@ Provide validated built-in and user Roles, shared task-model Pi launch policy, g
|
|
|
24
24
|
|
|
25
25
|
## Invariants
|
|
26
26
|
|
|
27
|
-
- One Delegated Task creates one ephemeral child process and no saved session.
|
|
27
|
+
- One Delegated Task creates one ephemeral child process and no saved session. Execution ends at the first hard budget: attempted turn 51 by default (`maxTurns`, positive safe integer) or `deadline = min(last recognized Pi JSON event + idle timeout, child start + maximum runtime)` (recognized Pi events renew; raw bytes do not; max always terminates). A terminal turn 50 succeeds; attempted continuation rejects with `turn_limit`, accumulated usage, and bounded output. On continuing `turn_end`, the existing child Role tool extension steers the fixed convergence warning once at 80% of completed turns and once at 80% of maximum runtime, combining thresholds first due together and never starting a warning timer or extra turn. After direct Pi exits, inherited stdout/stderr drain until EOF unless an escaped descendant holds them past short inactivity or a one-second hard deadline. Configurable in `~/.pi/agent/config/pi-subagent/pi-subagent.json` (`maxTurns` default 50; `timeout.idleMinutes`/`maxMinutes` defaults 10/30).
|
|
28
28
|
- Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent/pi-subagent.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
|
|
29
29
|
- Ambient child extensions and Skills stay disabled. Every Role requires `tools`, `extensions`, and `skills` YAML arrays, and every launch installs the Role tool policy. `tools: []` activates no base built-ins but does activate all tools from explicitly selected trusted extension bundles and explicit caller tool additions; `skills: []` selects no separately named Role Skills but trusted selected extension Skills still load; `extensions: []` selects no Role extension bundle. A Role/caller explicitly selected extension is a trusted atomic capability bundle: all tools it registers and all Skills supplied through its Pi package metadata or dynamic `resources_discover` load alongside separately named Role Skills. This intentionally includes the extension's executable lifecycle/prompt behavior; pi-subagent does not infer or externally narrow undocumented dependencies, and loading an extension is not sandboxing. Scope children by selecting fewer trusted extensions; finer granularity requires separate entry points/configuration or an upstream split. Explicit Role/caller tool names still verify against the final filtered registry, while parent-only recursive orchestration tools remain excluded.
|
|
30
30
|
- Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation. Explicit Role/caller tool names verify against the final filtered child registry after explicit provider `session_start` handlers, and unavailable names fail before the first turn.
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# `@henryqw/pi-subagent`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Main is the parent Pi session. It can delegate bounded single, parallel, and chained tasks, plus package-owned Git Flow work, to isolated Pi child roles.
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
7
|
-
- **Created for**:
|
|
8
|
-
- **Advantage**: Generic bounded delegation
|
|
7
|
+
- **Created for**: Delegate bounded work to isolated child Pi processes without losing Main's context.
|
|
8
|
+
- **Advantage**: Generic bounded delegation and a deterministic package-owned Git Flow use reusable Role launch policies for package authors.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -19,8 +19,10 @@ pi install npm:@henryqw/pi-subagent
|
|
|
19
19
|
|
|
20
20
|
| Package | Why |
|
|
21
21
|
| --- | --- |
|
|
22
|
-
| `@henryqw/pi-
|
|
23
|
-
| `@henryqw/pi-
|
|
22
|
+
| `@henryqw/pi-multi-codex` | Required. Children can use Main's active Codex slot. |
|
|
23
|
+
| `@henryqw/pi-task-models` | Required. Shared `fast` / `balanced` / `frontier` / `fav` routes. |
|
|
24
|
+
|
|
25
|
+
Model routing is not configured here. Children resolve routes through shared `@henryqw/pi-task-models` config at `~/.pi/agent/config/pi-task-models.json`, which stores only explicit task overrides. The local `pi-subagent/delegateTask` declaration supplies the omitted-class default.
|
|
24
26
|
|
|
25
27
|
## Use
|
|
26
28
|
|
|
@@ -30,7 +32,7 @@ pi install npm:@henryqw/pi-subagent
|
|
|
30
32
|
| `delegate_flow` | tool | Package-owned parallel implementation and declared-order Git integration for 1–8 independent units. |
|
|
31
33
|
| `delegate_flow_continue` | tool | Repair the blocked Flow unit once in its existing worktree. |
|
|
32
34
|
|
|
33
|
-
All three delegation tool blocks use Pi's default boxed shell and background. Their compact custom content is an immutable call label, foreground aggregate partial-result status, and bounded final summaries
|
|
35
|
+
All three delegation tool blocks use Pi's default boxed shell and background. Their compact custom content is an immutable call label, foreground aggregate partial-result status, and bounded final summaries. There is no expanded view.
|
|
34
36
|
|
|
35
37
|
### `delegate_task`
|
|
36
38
|
|
|
@@ -47,37 +49,92 @@ Select exactly one shape:
|
|
|
47
49
|
{ chain: [{ role, task, model?, modelClass?, thinking? }], background? }
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
#### Model precedence
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
1. An explicit `model` is `provider/modelId` and overrides `modelClass`.
|
|
55
|
+
2. Main sets direct `model` and `thinking` only for an explicit user override.
|
|
56
|
+
3. Otherwise, Main selects `modelClass`: `fast` normally or `balanced` upfront for obviously complex work.
|
|
57
|
+
4. `modelClass` is `fast`, `balanced`, `frontier`, or `fav`.
|
|
58
|
+
When omitted, it uses pi-subagent's local `pi-subagent/delegateTask` Model Task declaration.
|
|
59
|
+
That declaration defaults to `fast` and shared config can explicitly override it.
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
This is Main policy only. The runtime records no provenance and does not enforce it. `background` applies to the whole selected mode and is never a per-delegation field.
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
#### Modes, limits, and isolation
|
|
57
64
|
|
|
58
|
-
|
|
65
|
+
- Parallel mode starts entries concurrently, waits for every entry, and reports them in input order.
|
|
66
|
+
- Chain mode is sequential and fail-fast. Each literal `{previous}` receives only the immediately preceding successful assistant output.
|
|
67
|
+
- Foreground failures throw after retaining bounded sibling and recovery evidence.
|
|
68
|
+
- One tool call has one aggregate 50 KiB Main-visible transport cap. It is not 50 KiB per child.
|
|
69
|
+
- Background workflows are session-scoped. Session shutdown or reload aborts them and may deliver only recoverable-work evidence or no follow-up message.
|
|
70
|
+
- Each delegation resolves its own Role, resources, route, and optional worktree request.
|
|
71
|
+
- When available, `isolation: worktree` gives each entry a deterministic separate worktree. Non-Git or unborn-`HEAD` contexts may use Main's cwd.
|
|
72
|
+
- Siblings and chain steps never implicitly share one created worktree.
|
|
59
73
|
|
|
60
74
|
See [Orchestration, isolation, and the public API](./docs/orchestration.md) for generic delegation, Flow behavior, and JavaScript composition examples.
|
|
61
75
|
|
|
62
76
|
### `delegate_flow`
|
|
63
77
|
|
|
64
|
-
Use Flow only for independent, commuting Git changes.
|
|
78
|
+
Use Flow only for independent, commuting Git changes. Commuting changes can integrate in any order.
|
|
79
|
+
|
|
80
|
+
Flow accepts 1–8 uniquely identified units. Each unit has a bounded task, optional `modelClass`, direct command/argument validation gate, and optional non-empty `review` judgment criterion:
|
|
65
81
|
|
|
66
82
|
```text
|
|
67
83
|
delegate_flow({ units: [{ id, task, modelClass?, validation: [{ command, args }], review? }] })
|
|
68
84
|
delegate_flow_continue({ guidance, modelClass? })
|
|
69
85
|
```
|
|
70
86
|
|
|
71
|
-
|
|
87
|
+
```mermaid
|
|
88
|
+
flowchart LR
|
|
89
|
+
setup["Setup: resolve roles and create Unit Worktrees"] --> implement["Implementers in parallel"]
|
|
90
|
+
implement -->|Implementer block| repair["One repair: delegate_flow_continue"]
|
|
91
|
+
implement --> rebase["Rebase, inspect committed state, and validate"]
|
|
92
|
+
rebase -->|Post-rebase no-op| cleanup["Cleanup"]
|
|
93
|
+
rebase --> review{"Review requested?"}
|
|
94
|
+
review -->|No| integrate["Integrate in declared order"]
|
|
95
|
+
review -->|Yes| packet["Exact {base, tip, patchPath}; require PASS"]
|
|
96
|
+
packet -->|PASS| integrate
|
|
97
|
+
rebase -->|Validation block| repair
|
|
98
|
+
packet -->|Review block| repair
|
|
99
|
+
repair --> revalidate["Revalidate repaired work"]
|
|
100
|
+
revalidate -->|No review requested| integrate
|
|
101
|
+
revalidate -->|Review required| rereview["Re-review exact {base, tip, patchPath}; require PASS"]
|
|
102
|
+
rereview -->|PASS| integrate
|
|
103
|
+
revalidate -->|Second block| terminal["Terminal failure: retain worktree"]
|
|
104
|
+
rereview -->|Second block| terminal
|
|
105
|
+
rebase -->|Rebase or infrastructure failure| terminal
|
|
106
|
+
repair -->|Infrastructure failure| terminal
|
|
107
|
+
integrate --> cleanup
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Objective verification is authoritative. Flow always inspects committed Git state and runs declared validation.
|
|
111
|
+
|
|
112
|
+
A unit without `review` skips review evidence and Reviewer launch. It fast-forwards its exact validated tip through the existing guarded `git merge --ff-only` path.
|
|
113
|
+
|
|
114
|
+
Add `review` only for judgment that automation cannot establish. That unit keeps the exact `{base, tip, patchPath}` protocol and requires exact `PASS` before the same integration path.
|
|
115
|
+
|
|
116
|
+
Only one memory-only Flow may be active. At start, it resolves and freezes the effective `implementer` Role, including a same-named user override. It resolves and freezes the effective `reviewer` only when at least one unit requests `review`.
|
|
117
|
+
|
|
118
|
+
An omitted `modelClass` uses the local `pi-subagent/delegateTask` declaration, which defaults to `fast`. A selected class resolves through its shared profile model-and-thinking route for the unit's Implementer and, when applicable, Reviewer.
|
|
72
119
|
|
|
73
|
-
|
|
120
|
+
Flow creates one Unit Worktree per unit before it launches Implementers. It runs Implementers in parallel, then processes settled results in declared order.
|
|
74
121
|
|
|
75
|
-
|
|
122
|
+
After integration, it removes the worktree and branch non-forcibly. A refusal is a completion warning with the retained worktree path and/or branch.
|
|
76
123
|
|
|
77
|
-
|
|
124
|
+
A rebase that drops all unit commits is a no-op. Flow validates it, skips Reviewer and merge, then cleans up ordinarily.
|
|
125
|
+
|
|
126
|
+
Implementer, validation, or review blocks can be repaired once through `delegate_flow_continue` in the same worktree. An omitted continuation `modelClass` retains the blocked unit's current class. A supplied class replaces it for that one repair.
|
|
127
|
+
|
|
128
|
+
Rebase and infrastructure failures are terminal. A reported fast-forward failure completes with its diagnostic as a warning only when Git left Main clean at the exact integrated tip.
|
|
129
|
+
|
|
130
|
+
Otherwise it is terminal and retains the affected worktree. Flow has no graph, saved recovery, automatic retry, aggregate review, or post-merge gate.
|
|
131
|
+
|
|
132
|
+
`delegate_task` keeps its generic isolation behavior. Flow uses the package-shipped Implementer by default and the package-shipped Reviewer only when a unit requests review. Same-named user Roles remain supported overrides.
|
|
78
133
|
|
|
79
134
|
### Delegate UI summary
|
|
80
135
|
|
|
136
|
+
The transient status widget shows status glyph, role, status label, task summary, activity, and metrics for each child. Activity is `thinking…` or the active tool with elapsed time and path basename. Metrics are completed turns, started tools, model, thinking level, tokens, and total duration.
|
|
137
|
+
|
|
81
138
|
| Aspect | Behavior |
|
|
82
139
|
| --- | --- |
|
|
83
140
|
| Call label | `delegate_task · single/parallel/chain · N task(s)`; `delegate_flow · parallel→serial · N unit(s)`; `delegate_flow_continue · repair continuation` |
|
|
@@ -88,21 +145,38 @@ A rebase that drops all unit commits is a no-op: Flow validates it, skips Review
|
|
|
88
145
|
| Terminal retention | Active rows persist; terminal rows clear on next user input |
|
|
89
146
|
| Final result | Bounded summaries with recovery paths; no expanded view |
|
|
90
147
|
|
|
148
|
+
- Rows are active-first: working items first, then stable insertion order.
|
|
149
|
+
- The hard six-physical-line maximum shows all child rows for six or fewer items.
|
|
150
|
+
- Above six, it shows five child rows and one status-aware overflow line: `… N more · X working · Y complete · Z failed · W stopped`.
|
|
151
|
+
- Terminal rows clear on the next real user input. Active rows persist until the child settles.
|
|
152
|
+
- The final `delegate_task` block is deliberately minimal. It has bounded final summaries and role attribution for parallel and chain work. It shows only retained-worktree recovery paths.
|
|
153
|
+
|
|
91
154
|
## Config
|
|
92
155
|
|
|
93
|
-
pi-subagent owns the extension-named config directory `~/.pi/agent/config/pi-subagent
|
|
156
|
+
pi-subagent owns the extension-named config directory `~/.pi/agent/config/pi-subagent/`. It holds two kinds of user-owned configuration.
|
|
157
|
+
|
|
158
|
+
One Markdown file belongs to each Role (see [Roles](#roles)). The other is its own optional JSON file below.
|
|
94
159
|
|
|
95
|
-
`~/.pi/agent/config/pi-subagent/pi-subagent.json` controls the
|
|
160
|
+
`~/.pi/agent/config/pi-subagent/pi-subagent.json` controls the child pool and execution limits. All fields are optional. A missing file uses defaults.
|
|
96
161
|
|
|
97
162
|
| Field | Required | Possible values | Default |
|
|
98
163
|
| --- | --- | --- | --- |
|
|
99
164
|
| `maxSubagents` | No | Safe integer ≥ 1 | `5` |
|
|
165
|
+
| `maxTurns` | No | Safe integer ≥ 1 | `50` |
|
|
100
166
|
| `timeout.idleMinutes` | No | Positive number of minutes where minutes × 60 000 ms ≤ 2,147,483,647 | `10` |
|
|
101
167
|
| `timeout.maxMinutes` | No | Positive number within the same ms cap that must be greater than `timeout.idleMinutes`, otherwise the whole `timeout` object falls back to defaults | `30` |
|
|
102
168
|
|
|
103
|
-
Excess children wait FIFO without consuming child timeout.
|
|
169
|
+
- Excess children wait FIFO without consuming child timeout.
|
|
170
|
+
- A terminal response on turn 50 succeeds.
|
|
171
|
+
- An attempted continuation starts no model work and rejects with `turn_limit`.
|
|
172
|
+
- Before a continuing turn, the child gets one warning when completed turns reach 80%.
|
|
173
|
+
- It gets another warning when elapsed time reaches 80% of the maximum runtime.
|
|
174
|
+
- If both thresholds are first reached together, the child gets one combined warning.
|
|
175
|
+
- `PI_SUBAGENT_MAX_SUBAGENTS` overrides `maxSubagents` for the session. It must be a positive integer. An invalid value prevents the extension from loading and leaves `delegate_task` unavailable.
|
|
104
176
|
|
|
105
|
-
This JSON is read leniently
|
|
177
|
+
This JSON is read leniently. Malformed JSON, a non-object root, unknown keys, or invalid values are collected into one warning.
|
|
178
|
+
|
|
179
|
+
The affected settings fall back to defaults. The file is never rewritten.
|
|
106
180
|
|
|
107
181
|
### Role frontmatter
|
|
108
182
|
|
|
@@ -118,14 +192,14 @@ Each Role `.md` file in the same directory accepts these frontmatter fields:
|
|
|
118
192
|
| `skills` | Yes | YAML array of non-empty Skill names | `[]` selects no separately named Role Skills; trusted extension Skills still load |
|
|
119
193
|
| body | Yes | System-prompt Markdown after the frontmatter | — |
|
|
120
194
|
|
|
121
|
-
An unreadable or invalid Role file fails role loading fast
|
|
195
|
+
An unreadable or invalid Role file fails role loading fast. Duplicate role names are rejected.
|
|
122
196
|
|
|
123
197
|
## Roles
|
|
124
198
|
|
|
125
|
-
The package ships two working built-in Roles
|
|
199
|
+
The package ships two working built-in Roles. They are always available without configuration.
|
|
126
200
|
|
|
127
201
|
- `implementer`: focused edits requesting worktree isolation; commits completed scoped changes locally and never pushes or opens PRs without authorization
|
|
128
|
-
- `reviewer`: read-only correctness review of supplied plans
|
|
202
|
+
- `reviewer`: read-only correctness review of supplied plans or files, or—only when a Flow unit declares `review`—of Flow's exact `{base, tip, patchPath}` packet in its Unit Worktree; never edits or commits
|
|
129
203
|
|
|
130
204
|
A same-named Markdown file in `~/.pi/agent/config/pi-subagent/` explicitly overrides the built-in default.
|
|
131
205
|
|
|
@@ -134,25 +208,58 @@ The repository also includes optional inert samples:
|
|
|
134
208
|
- [`scout`](./examples/roles/scout.md): read-only discovery
|
|
135
209
|
- [`synthesizer`](./examples/roles/synthesizer.md): reconcile supplied reports
|
|
136
210
|
|
|
137
|
-
Copy them manually from your installed `@henryqw/pi-subagent` package
|
|
211
|
+
Copy them manually from your installed `@henryqw/pi-subagent` package if you want a starting point. npm installs ship the `examples/roles/` directory.
|
|
138
212
|
|
|
139
213
|
```bash
|
|
140
214
|
mkdir -p ~/.pi/agent/config/pi-subagent
|
|
141
215
|
cp <package-install-dir>/examples/roles/scout.md ~/.pi/agent/config/pi-subagent/
|
|
142
216
|
```
|
|
143
217
|
|
|
144
|
-
Locate the install directory with `npm root` inside your project, or
|
|
218
|
+
Locate the install directory with `npm root` inside your project, or through Pi's package installation path.
|
|
145
219
|
|
|
146
|
-
The package never installs or writes Role configuration. Sample names are not built-ins
|
|
220
|
+
The package never installs or writes Role configuration. Sample names are not built-ins. After copying, edit or replace them as your own Roles.
|
|
147
221
|
|
|
148
222
|
## Skill
|
|
149
223
|
|
|
150
|
-
The bundled [`pi-subagent-delegated-development`](./skills/pi-subagent-delegated-development/SKILL.md) Skill is Main-side planner
|
|
224
|
+
The bundled [`pi-subagent-delegated-development`](./skills/pi-subagent-delegated-development/SKILL.md) Skill is Main-side planner and orchestrator policy only. `delegate_flow` owns its fixed Git mechanics and validation authority.
|
|
225
|
+
|
|
226
|
+
The Skill adds no runtime code, configuration, or Role installation. Generic orchestration remains outside the executor under [ADR 001](./docs/adr/001-composable-ephemeral-execution.md).
|
|
227
|
+
|
|
228
|
+
## Role, Skill, and resource trust
|
|
229
|
+
|
|
230
|
+
### Role resources
|
|
231
|
+
|
|
232
|
+
A Role explicitly owns base tools, extensions, named Skills, instructions, and optional `isolation: worktree`. Every launch installs its Role tool policy.
|
|
233
|
+
|
|
234
|
+
Named Skills resolve through Main's effective Pi Skill registry. Ambient extension and Skill discovery is disabled in children.
|
|
235
|
+
|
|
236
|
+
### Trusted extensions are not sandboxing
|
|
237
|
+
|
|
238
|
+
Selecting an extension explicitly selects a trusted atomic capability bundle, not just a provider path. Every tool it registers and every Skill supplied through its Pi package metadata or dynamic `resources_discover` loads alongside separately named Role Skills.
|
|
151
239
|
|
|
152
|
-
|
|
240
|
+
This is intentional. An extension may depend on its tools, Skills, lifecycle, and prompt behavior. Loading it permits that executable behavior and is not sandboxing.
|
|
241
|
+
|
|
242
|
+
Scope a child by selecting fewer trusted extensions. Finer-grained selection requires separate extension entry points or configuration, or an upstream split. pi-subagent does not infer or externally narrow undocumented dependencies.
|
|
243
|
+
|
|
244
|
+
### Tool filtering
|
|
245
|
+
|
|
246
|
+
- `tools: []` activates no base built-ins. Trusted selected extension tools and explicit caller tool additions still activate.
|
|
247
|
+
- `skills: []` selects no separately named Role Skills. Trusted selected extension Skills still load.
|
|
248
|
+
- `extensions: []` selects no Role extension bundle.
|
|
249
|
+
- Parent-only recursive orchestration tools and interactive `ask_question` are excluded from children.
|
|
250
|
+
- Explicit Role or caller tool names are verified against the child’s final filtered active registry after provider extensions finish `session_start`.
|
|
251
|
+
- Unavailable tool names fail before the first model turn with provider-extension guidance. Unavailable named Skills warn and skip.
|
|
153
252
|
|
|
154
253
|
## Library API
|
|
155
254
|
|
|
156
|
-
The package root exports Role loading and launch resolution, `createEphemeralSubagentExecutor`, worktree helpers, and generic managed Herdr lifecycle helpers.
|
|
255
|
+
The package root exports Role loading and launch resolution, `createEphemeralSubagentExecutor`, worktree helpers, and generic managed Herdr lifecycle helpers.
|
|
256
|
+
|
|
257
|
+
The executor is for code already running inside active Pi. It does not provide standalone Node.js Pi discovery or launch support.
|
|
258
|
+
|
|
259
|
+
It defaults to a hard limit of 50 turns. An attempted continuation rejects with `turn_limit` while preserving accumulated usage and bounded output.
|
|
260
|
+
|
|
261
|
+
After Pi exits, the executor drains inherited stdout and stderr normally. It destroys streams held by escaped descendants after a short inactivity deadline or one-second hard deadline, so they cannot retain a pool permit.
|
|
262
|
+
|
|
263
|
+
Use [`docs/orchestration.md`](./docs/orchestration.md#public-role-and-executor-api) for exact API behavior.
|
|
157
264
|
|
|
158
|
-
|
|
265
|
+
It includes a post-permit `prepare` example. The example uses `resolveRoleLaunch` with a caller-owned Model Task declaration against the latest Pi context.
|
package/dist/ephemeral.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { PiLaunch } from "./index.ts";
|
|
3
|
+
export declare const DEFAULT_MAX_TURNS = 50;
|
|
4
|
+
export declare const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
|
|
3
5
|
export interface EphemeralSubagentTimeout {
|
|
4
6
|
idleMs: number;
|
|
5
7
|
maxMs: number;
|
|
6
8
|
}
|
|
7
9
|
export interface EphemeralSubagentExecutorOptions {
|
|
8
10
|
maxConcurrency: number;
|
|
11
|
+
maxTurns?: number;
|
|
9
12
|
timeout: EphemeralSubagentTimeout;
|
|
10
13
|
}
|
|
11
14
|
export type EphemeralSubagentActivityEvent = {
|
|
@@ -44,12 +47,13 @@ export type EphemeralSubagentResult = (EphemeralSubagentResultBase & {
|
|
|
44
47
|
}) | (EphemeralSubagentResultBase & {
|
|
45
48
|
outcome: "failure";
|
|
46
49
|
});
|
|
47
|
-
export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "spawn" | "protocol" | "prepare" | "callback";
|
|
50
|
+
export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "turn_limit" | "spawn" | "protocol" | "prepare" | "callback";
|
|
48
51
|
export declare class EphemeralSubagentError extends Error {
|
|
49
52
|
name: string;
|
|
50
53
|
readonly code: EphemeralSubagentErrorCode;
|
|
51
54
|
readonly usage?: Usage;
|
|
52
|
-
|
|
55
|
+
readonly output?: string;
|
|
56
|
+
constructor(code: EphemeralSubagentErrorCode, message: string, cause?: unknown, usage?: Usage, output?: string);
|
|
53
57
|
}
|
|
54
58
|
export interface EphemeralSubagentExecutor {
|
|
55
59
|
run(input: EphemeralSubagentRunInput): Promise<EphemeralSubagentResult>;
|
package/dist/ephemeral.js
CHANGED
|
@@ -4,6 +4,8 @@ import { basename } from "node:path";
|
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
5
|
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
6
6
|
const MAX_JSON_EVENT_BYTES = 1024 * 1024;
|
|
7
|
+
export const DEFAULT_MAX_TURNS = 50;
|
|
8
|
+
export const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
|
|
7
9
|
const MAX_ACTIVITY_TEXT_BYTES = 4 * 1024;
|
|
8
10
|
// A JSON string byte can take six source bytes (for example, \u0000).
|
|
9
11
|
const MAX_ACTIVITY_PREFIX_BYTES = 2 * MAX_ACTIVITY_TEXT_BYTES * 6 + 1024;
|
|
@@ -43,10 +45,12 @@ export class EphemeralSubagentError extends Error {
|
|
|
43
45
|
name = "EphemeralSubagentError";
|
|
44
46
|
code;
|
|
45
47
|
usage;
|
|
46
|
-
|
|
48
|
+
output;
|
|
49
|
+
constructor(code, message, cause, usage, output) {
|
|
47
50
|
super(message, cause === undefined ? undefined : { cause });
|
|
48
51
|
this.code = code;
|
|
49
52
|
this.usage = usage;
|
|
53
|
+
this.output = output;
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
function positiveDelay(value, field) {
|
|
@@ -61,6 +65,9 @@ function validateOptions(options) {
|
|
|
61
65
|
if (!Number.isSafeInteger(options.maxConcurrency) || options.maxConcurrency < 1) {
|
|
62
66
|
throw new RangeError("maxConcurrency must be a positive safe integer.");
|
|
63
67
|
}
|
|
68
|
+
const maxTurns = options.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
69
|
+
if (!Number.isSafeInteger(maxTurns) || maxTurns < 1)
|
|
70
|
+
throw new RangeError("maxTurns must be a positive safe integer.");
|
|
64
71
|
if (!options.timeout || typeof options.timeout !== "object")
|
|
65
72
|
throw new TypeError("timeout is required.");
|
|
66
73
|
const timeout = {
|
|
@@ -69,7 +76,7 @@ function validateOptions(options) {
|
|
|
69
76
|
};
|
|
70
77
|
if (timeout.maxMs <= timeout.idleMs)
|
|
71
78
|
throw new RangeError("timeout.maxMs must be greater than timeout.idleMs.");
|
|
72
|
-
return { maxConcurrency: options.maxConcurrency, timeout };
|
|
79
|
+
return { maxConcurrency: options.maxConcurrency, maxTurns, timeout };
|
|
73
80
|
}
|
|
74
81
|
function abortError(signal, cause = signal?.reason, usage) {
|
|
75
82
|
return new EphemeralSubagentError("aborted", "Subagent was aborted.", cause, usage);
|
|
@@ -202,7 +209,7 @@ export function createEphemeralSubagentExecutor(options) {
|
|
|
202
209
|
}
|
|
203
210
|
if (input.signal?.aborted)
|
|
204
211
|
throw abortError(input.signal);
|
|
205
|
-
return await runPi(prepared, input, validated
|
|
212
|
+
return await runPi(prepared, input, validated, invocation);
|
|
206
213
|
}
|
|
207
214
|
finally {
|
|
208
215
|
release();
|
|
@@ -362,16 +369,23 @@ export function formatDuration(milliseconds) {
|
|
|
362
369
|
const minutes = Math.floor(seconds % 3_600 / 60);
|
|
363
370
|
return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
364
371
|
}
|
|
365
|
-
async function runPi(prepared, input,
|
|
372
|
+
async function runPi(prepared, input, budget, invocation) {
|
|
366
373
|
if (input.signal?.aborted)
|
|
367
374
|
throw abortError(input.signal);
|
|
375
|
+
const timeoutPolicy = budget.timeout;
|
|
368
376
|
return await new Promise((resolve, reject) => {
|
|
369
377
|
const args = [...invocation.args, "--mode", "json", "-p", ...prepared.launch.args, `Task: ${prepared.task}`];
|
|
378
|
+
const startedAt = Date.now();
|
|
379
|
+
const maxDeadline = startedAt + timeoutPolicy.maxMs;
|
|
370
380
|
let child;
|
|
371
381
|
try {
|
|
372
382
|
child = spawn(invocation.command, args, {
|
|
373
383
|
cwd: prepared.cwd,
|
|
374
|
-
env: {
|
|
384
|
+
env: {
|
|
385
|
+
...process.env,
|
|
386
|
+
...prepared.launch.env,
|
|
387
|
+
[EXECUTION_BUDGET_ENV]: JSON.stringify({ maxTurns: budget.maxTurns, maxMs: timeoutPolicy.maxMs, startedAt }),
|
|
388
|
+
},
|
|
375
389
|
shell: false,
|
|
376
390
|
stdio: ["ignore", "pipe", "pipe"],
|
|
377
391
|
detached: process.platform !== "win32",
|
|
@@ -397,8 +411,8 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
397
411
|
let spawnError;
|
|
398
412
|
let protocolError;
|
|
399
413
|
let aborted = false;
|
|
400
|
-
|
|
401
|
-
|
|
414
|
+
let turnLimited = false;
|
|
415
|
+
let startedTurns = 0;
|
|
402
416
|
let lastEventAt = startedAt;
|
|
403
417
|
let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
|
|
404
418
|
let timedOutAfterMs;
|
|
@@ -480,6 +494,11 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
480
494
|
: `Subagent timed out after ${formatDuration(timeoutPolicy.idleMs)} without a recognized Pi event.`;
|
|
481
495
|
reject(new EphemeralSubagentError("timeout", message, new Error(message), accumulatedUsage()));
|
|
482
496
|
}
|
|
497
|
+
else if (turnLimited) {
|
|
498
|
+
const summary = `Subagent reached its maximum turn limit of ${budget.maxTurns}.`;
|
|
499
|
+
const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
|
|
500
|
+
reject(new EphemeralSubagentError("turn_limit", message, new Error(message), accumulatedUsage(), output));
|
|
501
|
+
}
|
|
483
502
|
else if (protocolError) {
|
|
484
503
|
reject(new EphemeralSubagentError("protocol", protocolError.message, protocolError, accumulatedUsage()));
|
|
485
504
|
}
|
|
@@ -551,7 +570,7 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
551
570
|
scheduleDeadline();
|
|
552
571
|
};
|
|
553
572
|
const processLine = (line) => {
|
|
554
|
-
if (!line.trim())
|
|
573
|
+
if (turnLimited || !line.trim())
|
|
555
574
|
return;
|
|
556
575
|
let event;
|
|
557
576
|
try {
|
|
@@ -566,6 +585,12 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
566
585
|
if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type))
|
|
567
586
|
return;
|
|
568
587
|
observeEvent();
|
|
588
|
+
if (record.type === "turn_start" && ++startedTurns > budget.maxTurns) {
|
|
589
|
+
if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
|
|
590
|
+
turnLimited = true;
|
|
591
|
+
stop(true);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
569
594
|
if (record.type === "message_start") {
|
|
570
595
|
partial.prefix = "";
|
|
571
596
|
partial.totalBytes = 0;
|
|
@@ -690,7 +715,7 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
690
715
|
};
|
|
691
716
|
onStdoutData = (data) => {
|
|
692
717
|
armPostExitIdleDeadline();
|
|
693
|
-
if (callbackFailure || protocolError)
|
|
718
|
+
if (callbackFailure || protocolError || turnLimited)
|
|
694
719
|
return;
|
|
695
720
|
let offset = 0;
|
|
696
721
|
while (offset < data.length) {
|
|
@@ -731,7 +756,7 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
731
756
|
invokeCallback("onActivity", input.onActivity, activity);
|
|
732
757
|
}
|
|
733
758
|
}
|
|
734
|
-
if (callbackFailure)
|
|
759
|
+
if (callbackFailure || turnLimited)
|
|
735
760
|
return;
|
|
736
761
|
lineParts = [];
|
|
737
762
|
lineBytes = 0;
|
|
@@ -771,13 +796,13 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
|
771
796
|
killTimer.unref();
|
|
772
797
|
}
|
|
773
798
|
const abort = () => {
|
|
774
|
-
if (timedOutAfterMs !== undefined || childExited)
|
|
799
|
+
if (timedOutAfterMs !== undefined || turnLimited || childExited)
|
|
775
800
|
return;
|
|
776
801
|
aborted = true;
|
|
777
802
|
stop();
|
|
778
803
|
};
|
|
779
804
|
function timeout(afterMs, reason) {
|
|
780
|
-
if (timedOutAfterMs !== undefined || childExited)
|
|
805
|
+
if (timedOutAfterMs !== undefined || turnLimited || childExited)
|
|
781
806
|
return;
|
|
782
807
|
if (reason === "maximum") {
|
|
783
808
|
if (!aborted) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type HerdrExecutor } from "@henryqw/pi-herdr";
|
|
3
3
|
import { type AvailableModel, type ModelTask, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
|
|
4
|
-
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, EphemeralSubagentError, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
|
|
4
|
+
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
|
|
5
5
|
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, type WorktreeDirtyInspection, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
|
|
6
6
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, type PreparedReviewEvidence, type PrepareExactReviewEvidenceInput, } from "./review-evidence.ts";
|
|
7
7
|
export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { createHerdrClient, herdrCommandFailure, hasHerdrErrorCode, startPiAgent } from "@henryqw/pi-herdr";
|
|
7
7
|
import { modelReference, orderedProfileRoutes, readTaskModelsConfig, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
|
|
8
|
-
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, EphemeralSubagentError, formatDuration, } from "./ephemeral.js";
|
|
8
|
+
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
|
|
9
9
|
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, } from "./worktree.js";
|
|
10
10
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
|
|
11
11
|
const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
|
package/docs/orchestration.md
CHANGED
|
@@ -161,11 +161,12 @@ A loaded `Role` contains `name`, `description`, required normalized `tools`, `ex
|
|
|
161
161
|
```js
|
|
162
162
|
const executorOptions = {
|
|
163
163
|
maxConcurrency: 4,
|
|
164
|
+
maxTurns: 50,
|
|
164
165
|
timeout: { idleMs: 10 * 60_000, maxMs: 30 * 60_000 },
|
|
165
166
|
};
|
|
166
167
|
```
|
|
167
168
|
|
|
168
|
-
Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`, `onTokens(number)`, and `onActivity(event)` callbacks plus required `prepare()`. A queued run receives its permit before `prepare` executes, so resource and route resolution can use the latest Pi state. Queued time does not consume child timeout. `maxConcurrency`, `idleMs`, and `maxMs` must be positive; `maxMs` must exceed `idleMs
|
|
169
|
+
Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`, `onTokens(number)`, and `onActivity(event)` callbacks plus required `prepare()`. A queued run receives its permit before `prepare` executes, so resource and route resolution can use the latest Pi state. Queued time does not consume child timeout. `maxConcurrency`, `maxTurns`, `idleMs`, and `maxMs` must be positive; integer limits must be safe integers, `maxMs` must exceed `idleMs`, and omitted `maxTurns` defaults to 50.
|
|
169
170
|
|
|
170
171
|
The executor is **active-Pi-only**. It reuses the currently running Pi invocation and does not locate or support a standalone Node.js Pi installation. Once direct Pi exits, stdout/stderr drain normally until EOF; an escaped descendant retaining either stream is cut off after short output inactivity or a one-second hard deadline so it cannot retain the FIFO permit.
|
|
171
172
|
|
|
@@ -193,6 +194,7 @@ export function createRunRole(pi) {
|
|
|
193
194
|
|
|
194
195
|
const executor = createEphemeralSubagentExecutor({
|
|
195
196
|
maxConcurrency: 4,
|
|
197
|
+
maxTurns: 50,
|
|
196
198
|
timeout: { idleMs: 10 * 60_000, maxMs: 30 * 60_000 },
|
|
197
199
|
});
|
|
198
200
|
|
|
@@ -244,7 +246,7 @@ export function createRunRole(pi) {
|
|
|
244
246
|
}
|
|
245
247
|
```
|
|
246
248
|
|
|
247
|
-
`run` resolves to `EphemeralSubagentResult`. Both outcome variants contain `exitCode`, `output`, `stderr`, and optional `stopReason`, `errorMessage`, and `usage`. A launched child/model failure is a typed `{ outcome: "failure", ... }` result. Abort, timeout, spawn, protocol, preparation, and callback failures reject with `EphemeralSubagentError` and a stable `code`. Assistant `output` and `stderr` are bounded, and `usage` contains aggregate child usage when Pi supplies it.
|
|
249
|
+
`run` resolves to `EphemeralSubagentResult`. Both outcome variants contain `exitCode`, `output`, `stderr`, and optional `stopReason`, `errorMessage`, and `usage`. A launched child/model failure is a typed `{ outcome: "failure", ... }` result. Abort, timeout, turn-limit, spawn, protocol, preparation, and callback failures reject with `EphemeralSubagentError` and a stable `code`. A terminal response at `maxTurns` succeeds; an attempted continuation rejects with `turn_limit`, accumulated `usage`, and bounded `output`. The executor supplies the same turn/maximum-runtime budget to the existing child Role tool extension, which steers the fixed convergence warning before another model turn once at each 80% threshold, combines thresholds first due together, and uses no timer or extra turn. Assistant `output` and `stderr` are bounded, and `usage` contains aggregate child usage when Pi supplies it.
|
|
248
250
|
|
|
249
251
|
### Activity callbacks
|
|
250
252
|
|
package/extensions/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface SubagentTimeoutConfig {
|
|
|
11
11
|
|
|
12
12
|
export interface SubagentConfig {
|
|
13
13
|
maxSubagents?: number;
|
|
14
|
+
maxTurns?: number;
|
|
14
15
|
timeout?: SubagentTimeoutConfig;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -66,16 +67,18 @@ export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConf
|
|
|
66
67
|
const problems: string[] = [];
|
|
67
68
|
const config: SubagentConfig = {};
|
|
68
69
|
for (const key of Object.keys(record)) {
|
|
69
|
-
if (key !== "maxSubagents" && key !== "timeout") {
|
|
70
|
-
problems.push(`unknown config key ${JSON.stringify(key)}; expected maxSubagents, timeout`);
|
|
70
|
+
if (key !== "maxSubagents" && key !== "maxTurns" && key !== "timeout") {
|
|
71
|
+
problems.push(`unknown config key ${JSON.stringify(key)}; expected maxSubagents, maxTurns, timeout`);
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
for (const key of ["maxSubagents", "maxTurns"] as const) {
|
|
76
|
+
const value = record[key];
|
|
77
|
+
if (value === undefined) continue;
|
|
78
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) {
|
|
79
|
+
config[key] = value;
|
|
77
80
|
} else {
|
|
78
|
-
problems.push(
|
|
81
|
+
problems.push(`${key} must be a safe integer >= 1, got ${JSON.stringify(value)}`);
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -795,8 +795,8 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
|
|
|
795
795
|
description: "Run 1–8 independent Implementers in isolated Unit Worktrees, validate and serially fast-forward each tip, with exact review only for units that declare a judgment criterion.",
|
|
796
796
|
promptSnippet: "Run a deterministic parallel-implementation, serial-verification Flow",
|
|
797
797
|
promptGuidelines: [
|
|
798
|
-
"Use delegate_flow only for cohesive units expected to commute
|
|
799
|
-
"Each unit must include explicit bounded requirements and its authoritative direct command/argument validation gate. Add review only for an explicit judgment that validation cannot establish.",
|
|
798
|
+
"Use delegate_flow only for cohesive units expected to commute: split independent outcomes into units, sequence dependent work outside delegate_flow, and never divide one invariant across multiple units. Combine work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.",
|
|
799
|
+
"Each delegate_flow unit must own one concrete outcome with one focused validation story: include explicit bounded requirements and its authoritative direct command/argument validation gate. If the affected flow or scope is not yet known, perform bounded read-only discovery first. Add review only for an explicit judgment that validation cannot establish.",
|
|
800
800
|
"If a Flow blocks, inspect its classification and call delegate_flow_continue once with explicit repair guidance; modelClass may replace that one repair's current class.",
|
|
801
801
|
],
|
|
802
802
|
parameters: DelegateFlowSchema,
|
package/extensions/role-tools.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { CHILD_EXCLUDED_TOOL_NAMES, ROLE_TOOL_POLICY_FLAG } from "@henryqw/pi-subagent";
|
|
2
|
+
import { CHILD_EXCLUDED_TOOL_NAMES, EXECUTION_BUDGET_ENV, ROLE_TOOL_POLICY_FLAG } from "@henryqw/pi-subagent";
|
|
3
3
|
|
|
4
4
|
const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
|
|
5
|
+
const WARNING_RATIO = 0.8;
|
|
6
|
+
const WARNING_MESSAGE_TYPE = "pi-subagent-execution-budget";
|
|
5
7
|
|
|
6
8
|
function configuredTools(value: unknown): string[] {
|
|
7
9
|
if (typeof value !== "string") throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
@@ -17,6 +19,35 @@ function configuredTools(value: unknown): string[] {
|
|
|
17
19
|
return [...new Set(parsed.map((name) => name.trim()))];
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
function executionBudget(value: string | undefined): { maxTurns: number; maxMs: number; startedAt: number } | undefined {
|
|
23
|
+
if (value === undefined) return;
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(value);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
29
|
+
}
|
|
30
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
32
|
+
}
|
|
33
|
+
const budget = parsed as Record<string, unknown>;
|
|
34
|
+
if (Object.keys(budget).length !== 3 || !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
|
|
35
|
+
|| !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < 1
|
|
36
|
+
|| typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0
|
|
37
|
+
|| !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0) {
|
|
38
|
+
throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
39
|
+
}
|
|
40
|
+
return { maxTurns: budget.maxTurns as number, maxMs: budget.maxMs, startedAt: budget.startedAt as number };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expectsAnotherTurn(message: unknown): boolean {
|
|
44
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return false;
|
|
45
|
+
const record = message as Record<string, unknown>;
|
|
46
|
+
return record.role === "assistant" && Array.isArray(record.content)
|
|
47
|
+
&& record.content.some((part) => part && typeof part === "object" && !Array.isArray(part)
|
|
48
|
+
&& (part as Record<string, unknown>).type === "toolCall");
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
export default function roleTools(pi: ExtensionAPI): void {
|
|
21
52
|
pi.registerFlag(ROLE_TOOL_POLICY_FLAG, {
|
|
22
53
|
description: "Internal Pi Subagent Role tool policy",
|
|
@@ -36,4 +67,29 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
36
67
|
throw new Error(`Subagent requested unavailable tools: ${unavailable.join(", ")}. Check spelling and load the provider extension that registers them.`);
|
|
37
68
|
}
|
|
38
69
|
});
|
|
70
|
+
|
|
71
|
+
const budget = executionBudget(process.env[EXECUTION_BUDGET_ENV]);
|
|
72
|
+
if (!budget) return;
|
|
73
|
+
const warningTurn = Math.ceil(budget.maxTurns * WARNING_RATIO);
|
|
74
|
+
let completedTurns = 0;
|
|
75
|
+
let turnWarningSent = false;
|
|
76
|
+
let runtimeWarningSent = false;
|
|
77
|
+
pi.on("turn_end", (event) => {
|
|
78
|
+
completedTurns += 1;
|
|
79
|
+
if (!expectsAnotherTurn(event.message)) return;
|
|
80
|
+
const elapsedMs = Math.max(0, Date.now() - budget.startedAt);
|
|
81
|
+
const turnWarningDue = !turnWarningSent && completedTurns >= warningTurn;
|
|
82
|
+
const runtimeWarningDue = !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
|
|
83
|
+
if (!turnWarningDue && !runtimeWarningDue) return;
|
|
84
|
+
if (turnWarningDue) turnWarningSent = true;
|
|
85
|
+
if (runtimeWarningDue) runtimeWarningSent = true;
|
|
86
|
+
const remainingTurns = Math.max(0, budget.maxTurns - completedTurns);
|
|
87
|
+
const remainingMinutes = Math.max(0, Math.ceil((budget.maxMs - elapsedMs) / 60_000));
|
|
88
|
+
const maxMinutes = budget.maxMs / 60_000;
|
|
89
|
+
pi.sendMessage({
|
|
90
|
+
customType: WARNING_MESSAGE_TYPE,
|
|
91
|
+
content: `**Execution budget warning:** ${remainingTurns} of ${budget.maxTurns} turns and approximately ${remainingMinutes} of ${maxMinutes} minutes remain before forced termination.\nConverge now: stop expanding scope, complete the highest-priority required work, perform only essential validation, and return a concise final result. If completion is impossible, follow your role’s recovery requirements and report the blocker and exact remaining work. This warning does not change your role, scope, or permissions.`,
|
|
92
|
+
display: true,
|
|
93
|
+
}, { deliverAs: "steer", triggerTurn: false });
|
|
94
|
+
});
|
|
39
95
|
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
createEphemeralSubagentExecutor,
|
|
19
19
|
DELEGATE_TASK,
|
|
20
20
|
createRoleLaunch,
|
|
21
|
+
DEFAULT_MAX_TURNS,
|
|
21
22
|
EphemeralSubagentError,
|
|
22
23
|
finalizeChildWorktree,
|
|
23
24
|
formatDuration,
|
|
@@ -331,7 +332,11 @@ export default function subagentExtension(
|
|
|
331
332
|
// Explicit policy argument (tests/embedders) wins; otherwise resolve from
|
|
332
333
|
// config file over defaults.
|
|
333
334
|
const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
|
|
334
|
-
const executor = createEphemeralSubagentExecutor({
|
|
335
|
+
const executor = createEphemeralSubagentExecutor({
|
|
336
|
+
maxConcurrency: maxActiveSubagents,
|
|
337
|
+
maxTurns: loadedConfig.config.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
338
|
+
timeout: timeoutPolicy,
|
|
339
|
+
});
|
|
335
340
|
// Background children outlive the launching tool call, so they get their own
|
|
336
341
|
// abort signal: tied to the session, not to the turn that started them.
|
|
337
342
|
const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
|
|
@@ -602,8 +607,8 @@ export default function subagentExtension(
|
|
|
602
607
|
description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
|
|
603
608
|
promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
|
|
604
609
|
promptGuidelines: [
|
|
605
|
-
"Call delegate_task with exactly one mode: role+task for one task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output.",
|
|
606
|
-
"Every delegate_task entry must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
|
|
610
|
+
"Call delegate_task with exactly one mode: role+task for one task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output; split independent, commuting outcomes into parallel entries, sequence dependent work in chain entries, and never divide one invariant across multiple entries.",
|
|
611
|
+
"Every delegate_task entry must own one concrete outcome with one focused validation story: state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; if the affected flow or scope is not yet known, perform bounded read-only discovery first; never pass the parent request unchanged.",
|
|
607
612
|
"For each delegate_task entry, populate model and thinking only for an explicit user override; otherwise choose only modelClass: fast normally, or balanced upfront for obviously complex work. This is Main policy, not runtime enforcement.",
|
|
608
613
|
"Parallel delegate_task entries must own non-overlapping files. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.",
|
|
609
614
|
"delegate_task background applies to the whole selected workflow and returns before results exist; use it only when the user explicitly asks for non-blocking work.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
},
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"files": [
|
|
17
|
+
"LICENSE",
|
|
17
18
|
"dist",
|
|
18
19
|
"extensions",
|
|
19
20
|
"docs",
|
|
20
21
|
"examples",
|
|
21
22
|
"skills",
|
|
22
23
|
"README.md",
|
|
23
|
-
"CONTEXT.md"
|
|
24
|
-
"LICENSE"
|
|
24
|
+
"CONTEXT.md"
|
|
25
25
|
],
|
|
26
26
|
"types": "./dist/index.d.ts",
|
|
27
27
|
"exports": {
|
|
@@ -11,9 +11,9 @@ You are Main, the planner/orchestrator: slice work and call `delegate_flow`. Do
|
|
|
11
11
|
|
|
12
12
|
Before slicing, identify applicable repository prohibitions. If the request or plan conflicts with them, stop and resolve the conflict before delegation. Copy them into every affected task and into `review` when automated validation cannot establish compliance; never replace repository policy with generic preservation or migration assumptions. When compatibility is disallowed, require deletion of replaced paths and forbid legacy readers, aliases, adapters, dual schemas, deprecation paths, and compatibility fallbacks.
|
|
13
13
|
|
|
14
|
-
Use the fewest cohesive units. `delegate_flow` is for independent units expected to commute
|
|
14
|
+
Use the fewest cohesive units. `delegate_flow` is for independent units expected to commute: split independent outcomes into units, combine or sequence work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants, and never divide one invariant across multiple units. Dependent work remains outside Flow; sequence it in one task or ordinary caller-controlled sequencing.
|
|
15
15
|
|
|
16
|
-
Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array. Do not pass the parent request unchanged. Use `modelClass: "fast"` normally; use `"balanced"` upfront only for obviously complex work. Add non-empty `review` only for an explicit judgment that automated validation cannot establish. Call `delegate_flow` with 1–8 units; the runtime always supplies the effective Implementer and supplies the Reviewer only when a unit needs review.
|
|
16
|
+
Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array; each delegation must own one concrete outcome with one focused validation story. If the affected flow or scope is not yet known, perform bounded read-only discovery first. Do not pass the parent request unchanged. Use `modelClass: "fast"` normally; use `"balanced"` upfront only for obviously complex work. Add non-empty `review` only for an explicit judgment that automated validation cannot establish. Call `delegate_flow` with 1–8 units; the runtime always supplies the effective Implementer and supplies the Reviewer only when a unit needs review.
|
|
17
17
|
|
|
18
18
|
## Runtime Flow
|
|
19
19
|
|