@henryqw/pi-subagent 15.1.0 → 15.1.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 +2 -1
- package/README.md +15 -6
- package/dist/ephemeral.d.ts +8 -1
- package/dist/ephemeral.js +63 -13
- package/dist/index.d.ts +1 -1
- package/docs/orchestration.md +15 -5
- package/extensions/config.ts +10 -16
- package/extensions/role-tools.ts +53 -9
- package/extensions/subagent.ts +1 -0
- package/package.json +1 -1
- package/skills/pi-subagent-delegated-development/SKILL.md +2 -2
package/CONTEXT.md
CHANGED
|
@@ -23,7 +23,8 @@ Provide validated built-in and user Roles, shared task-model Pi launch policy, g
|
|
|
23
23
|
|
|
24
24
|
## Invariants
|
|
25
25
|
|
|
26
|
-
- 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` is a safe integer >= 1; default 50) 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.
|
|
26
|
+
- 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` is a safe integer >= 1; default 50), attempted continuation after the optional token handoff, 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. Optional `maxTokens` is a safe integer >= 1 with an unlimited default. It is one global executor default applied independently to every `delegate_task` child and every Flow Implementer, Reviewer, and repair child; it is neither a shared pool nor a per-call or environment option. Token accounting sums each completed assistant response's `Usage.totalTokens` once, matching the executor's aggregate `Usage`. A terminal response crossing the limit succeeds. A continuing crossing turn and its tools finish, then exactly one extra response turn is permitted. Further continuation rejects with typed `token_limit`, aggregate usage, and bounded last assistant output. This permits a crossing turn plus the final response to overshoot; it is not an exact hard cap. Raw executor launches enforce the extra-turn window but do not guarantee tools are disabled.
|
|
27
|
+
- Every Role launch installs the shared tool policy. On a continuing token crossing, or the continuing penultimate `maxTurns` turn, the policy waits for `turn_end`, disables every tool, and steers one structured final report. This covers `delegate_task` and each Flow launch. With `maxTurns` set to 1, tools are disabled during `session_start`, and the sole provider turn is the response-only handoff. The fixed decision packet is the default, but exact task or Role output takes precedence and is returned alone. Terminal boundary responses get no handoff. Before the final boundary, the policy steers the fixed convergence warning once at each 80% threshold for completed turns, aggregate tokens when configured, and maximum runtime. It combines thresholds first due together and starts no timer or extra warning turn. Timeout, provider, or child-process failures can prevent a handoff. 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/config.json` (`maxTurns` defaults to 50; `maxTokens` defaults to unlimited; `timeout.idleMinutes`/`maxMinutes` default to 10/30).
|
|
27
28
|
- Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent/config.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout.
|
|
28
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.
|
|
29
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
|
@@ -94,6 +94,8 @@ Flow is separate. It owns exact review evidence, exact `PASS` approval, validati
|
|
|
94
94
|
|
|
95
95
|
Flow requires a clean Main worktree on an attached branch with a committed `HEAD`. Use it only for independent Git changes that can merge in any order. Do not split units that overlap files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.
|
|
96
96
|
|
|
97
|
+
One Implementer launch must plausibly finish before the configured maximum runtime. Cohesion is not enough when work has several preservable, separately verifiable milestones. Split oversized dependent work into serial one-unit Flows after each milestone integrates. Units in one Flow stay independent and commuting.
|
|
98
|
+
|
|
97
99
|
```text
|
|
98
100
|
delegate_flow({ units: [{ id, name, task, modelClass?, validation: [{ command, args }], review? }] })
|
|
99
101
|
delegate_flow_continue({ guidance, modelClass? })
|
|
@@ -128,22 +130,29 @@ pi-subagent owns `~/.pi/agent/config/pi-subagent/config.json`. It is optional. A
|
|
|
128
130
|
| --- | --- | --- | --- |
|
|
129
131
|
| `maxSubagents` | Sets the maximum number of active child processes. | Safe integer of at least 1. | `5` |
|
|
130
132
|
| `maxTurns` | Sets the hard provider-turn limit for each child. | Safe integer of at least 1. | `50` |
|
|
131
|
-
| `
|
|
132
|
-
| `timeout.
|
|
133
|
+
| `maxTokens` | Sets the token limit for each child. | Safe integer of at least 1. | Unlimited |
|
|
134
|
+
| `timeout.idleMinutes` | Sets the idle timeout for a child. | Positive minutes; minutes × 60,000 ≤ 2,147,483,647 ms | `10` |
|
|
135
|
+
| `timeout.maxMinutes` | Sets the maximum runtime for a child. | Positive minutes greater than `idleMinutes`; minutes × 60,000 ≤ 2,147,483,647 ms | `30` |
|
|
136
|
+
|
|
137
|
+
`maxTokens` applies separately to every child. This includes `delegate_task` and each Flow Implementer, Reviewer, and repair launch. It is not a shared pool or per-call option. Set it only in this file.
|
|
138
|
+
|
|
139
|
+
Pi adds each completed assistant response's `Usage.totalTokens` once. This matches the executor's aggregate `Usage`. At 80%, a Role receives one convergence warning.
|
|
140
|
+
|
|
141
|
+
A terminal response that crosses `maxTokens` succeeds. A continuing crossing turn completes its tools. Pi then disables tools and allows one response-only handoff. That handoff can overshoot the limit, so `maxTokens` is not an exact cap. Further continuation rejects with `token_limit`, aggregate `Usage`, and bounded last output.
|
|
133
142
|
|
|
134
143
|
Excess children wait FIFO without using a child timeout. A terminal response on turn 50 succeeds; an attempted continuation rejects with `turn_limit`.
|
|
135
144
|
|
|
136
145
|
### Final response handoff
|
|
137
146
|
|
|
138
|
-
|
|
147
|
+
Role launches reserve a response-only handoff at a continuing `maxTokens` crossing or the penultimate `maxTurns` turn. This includes `delegate_task` and every Implementer or Reviewer launch within `delegate_flow`.
|
|
139
148
|
|
|
140
149
|
With `maxTurns` set to 1, Pi disables tools at startup. The sole provider turn is the response-only handoff.
|
|
141
150
|
|
|
142
|
-
Pi waits for the
|
|
151
|
+
Pi waits for the current turn's tools. It then disables all tools and requests a final report. A terminal boundary response gets no handoff.
|
|
143
152
|
|
|
144
|
-
The fixed decision packet asks for Status (completed, blocked, or incomplete), one-sentence Outcome, up to three concrete Evidence facts, Blocker, one material Risk, and one Suggested next action. It is the default. Exact output required by the assigned task or Role takes precedence. The child returns only that output, such as a Flow Reviewer's exact `PASS` or caller-required structured output.
|
|
153
|
+
The fixed decision packet asks for Status (completed, blocked, or incomplete), one-sentence Outcome, up to three concrete Evidence facts, Blocker, one material Risk, and one Suggested next action. It is the default. Exact output required by the assigned task or Role takes precedence. The child returns only that output, such as a Flow Reviewer's exact `PASS` or caller-required structured output. The handoff stays within `maxTurns`, but it is the one allowed turn after a token crossing. Commits, validation, and retained-worktree facts from executor/Flow structured evidence remain authoritative; the model handoff supplies semantic context and a suggested next action.
|
|
145
154
|
|
|
146
|
-
A raw `createEphemeralSubagentExecutor` launch
|
|
155
|
+
A raw `createEphemeralSubagentExecutor` launch can enforce the extra-turn window. It cannot guarantee disabled tools or the final handoff. A timeout, provider failure, or child-process failure can also end a Role launch before handoff.
|
|
147
156
|
|
|
148
157
|
Malformed or unreadable JSON, a non-object root, unknown keys, and invalid values produce one warning. Invalid settings use defaults while valid settings still apply. If the effective maximum is not greater than the idle timeout, both timeout settings use defaults. The file is never rewritten.
|
|
149
158
|
|
package/dist/ephemeral.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { Usage } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { PiLaunch } from "./index.ts";
|
|
3
3
|
export declare const DEFAULT_MAX_TURNS = 50;
|
|
4
4
|
export declare const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
|
|
5
|
+
export interface EphemeralSubagentExecutionBudget {
|
|
6
|
+
maxTurns: number;
|
|
7
|
+
maxMs: number;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
}
|
|
5
11
|
export interface EphemeralSubagentTimeout {
|
|
6
12
|
idleMs: number;
|
|
7
13
|
maxMs: number;
|
|
@@ -9,6 +15,7 @@ export interface EphemeralSubagentTimeout {
|
|
|
9
15
|
export interface EphemeralSubagentExecutorOptions {
|
|
10
16
|
maxConcurrency: number;
|
|
11
17
|
maxTurns?: number;
|
|
18
|
+
maxTokens?: number;
|
|
12
19
|
timeout: EphemeralSubagentTimeout;
|
|
13
20
|
}
|
|
14
21
|
export type EphemeralSubagentActivityEvent = {
|
|
@@ -47,7 +54,7 @@ export type EphemeralSubagentResult = (EphemeralSubagentResultBase & {
|
|
|
47
54
|
}) | (EphemeralSubagentResultBase & {
|
|
48
55
|
outcome: "failure";
|
|
49
56
|
});
|
|
50
|
-
export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "turn_limit" | "spawn" | "protocol" | "prepare" | "callback";
|
|
57
|
+
export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "turn_limit" | "token_limit" | "spawn" | "protocol" | "prepare" | "callback";
|
|
51
58
|
export declare class EphemeralSubagentError extends Error {
|
|
52
59
|
name: string;
|
|
53
60
|
readonly code: EphemeralSubagentErrorCode;
|
package/dist/ephemeral.js
CHANGED
|
@@ -70,6 +70,9 @@ function validateOptions(options) {
|
|
|
70
70
|
if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
|
|
71
71
|
throw new RangeError("maxTurns must be a safe integer >= 1.");
|
|
72
72
|
}
|
|
73
|
+
if (options.maxTokens !== undefined && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens < 1)) {
|
|
74
|
+
throw new RangeError("maxTokens must be a safe integer >= 1.");
|
|
75
|
+
}
|
|
73
76
|
if (!options.timeout || typeof options.timeout !== "object")
|
|
74
77
|
throw new TypeError("timeout is required.");
|
|
75
78
|
const timeout = {
|
|
@@ -78,7 +81,12 @@ function validateOptions(options) {
|
|
|
78
81
|
};
|
|
79
82
|
if (timeout.maxMs <= timeout.idleMs)
|
|
80
83
|
throw new RangeError("timeout.maxMs must be greater than timeout.idleMs.");
|
|
81
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
maxConcurrency: options.maxConcurrency,
|
|
86
|
+
maxTurns,
|
|
87
|
+
...(options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }),
|
|
88
|
+
timeout,
|
|
89
|
+
};
|
|
82
90
|
}
|
|
83
91
|
function abortError(signal, cause = signal?.reason, usage) {
|
|
84
92
|
return new EphemeralSubagentError("aborted", "Subagent was aborted.", cause, usage);
|
|
@@ -378,12 +386,18 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
378
386
|
const maxDeadline = startedAt + timeoutPolicy.maxMs;
|
|
379
387
|
let child;
|
|
380
388
|
try {
|
|
389
|
+
const executionBudget = {
|
|
390
|
+
maxTurns: budget.maxTurns,
|
|
391
|
+
maxMs: timeoutPolicy.maxMs,
|
|
392
|
+
startedAt,
|
|
393
|
+
...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens }),
|
|
394
|
+
};
|
|
381
395
|
child = spawn(invocation.command, args, {
|
|
382
396
|
cwd: prepared.cwd,
|
|
383
397
|
env: {
|
|
384
398
|
...process.env,
|
|
385
399
|
...prepared.launch.env,
|
|
386
|
-
[EXECUTION_BUDGET_ENV]: JSON.stringify(
|
|
400
|
+
[EXECUTION_BUDGET_ENV]: JSON.stringify(executionBudget),
|
|
387
401
|
},
|
|
388
402
|
shell: false,
|
|
389
403
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -411,6 +425,8 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
411
425
|
let protocolError;
|
|
412
426
|
let aborted = false;
|
|
413
427
|
let turnLimited = false;
|
|
428
|
+
let tokenBudget = "within";
|
|
429
|
+
const tokenLimited = () => tokenBudget === "limited";
|
|
414
430
|
let startedTurns = 0;
|
|
415
431
|
let lastEventAt = startedAt;
|
|
416
432
|
let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
|
|
@@ -498,6 +514,11 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
498
514
|
const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
|
|
499
515
|
reject(new EphemeralSubagentError("turn_limit", message, new Error(message), accumulatedUsage(), output));
|
|
500
516
|
}
|
|
517
|
+
else if (tokenLimited()) {
|
|
518
|
+
const summary = `Subagent reached its maximum token limit of ${budget.maxTokens}.`;
|
|
519
|
+
const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
|
|
520
|
+
reject(new EphemeralSubagentError("token_limit", message, new Error(message), accumulatedUsage(), output));
|
|
521
|
+
}
|
|
501
522
|
else if (protocolError) {
|
|
502
523
|
reject(new EphemeralSubagentError("protocol", protocolError.message, protocolError, accumulatedUsage()));
|
|
503
524
|
}
|
|
@@ -558,7 +579,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
558
579
|
deadlineTimer.unref();
|
|
559
580
|
};
|
|
560
581
|
const observeEvent = () => {
|
|
561
|
-
if (callbackFailure || aborted || timedOutAfterMs !== undefined || childExited)
|
|
582
|
+
if (callbackFailure || aborted || timedOutAfterMs !== undefined || turnLimited || tokenLimited() || childExited)
|
|
562
583
|
return;
|
|
563
584
|
const now = Date.now();
|
|
564
585
|
if (now >= deadline) {
|
|
@@ -568,8 +589,31 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
568
589
|
lastEventAt = now;
|
|
569
590
|
scheduleDeadline();
|
|
570
591
|
};
|
|
592
|
+
const advanceTokenBudget = (event) => {
|
|
593
|
+
switch (tokenBudget) {
|
|
594
|
+
case "within":
|
|
595
|
+
if (event === "turn_end" && budget.maxTokens !== undefined && completedTokens >= budget.maxTokens) {
|
|
596
|
+
tokenBudget = "crossed";
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
case "crossed":
|
|
600
|
+
if (event === "turn_start")
|
|
601
|
+
tokenBudget = "final_turn";
|
|
602
|
+
return false;
|
|
603
|
+
case "final_turn":
|
|
604
|
+
if (event === "turn_start") {
|
|
605
|
+
if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
|
|
606
|
+
tokenBudget = "limited";
|
|
607
|
+
stop(true);
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
case "limited":
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
571
615
|
const processLine = (line) => {
|
|
572
|
-
if (turnLimited || !line.trim())
|
|
616
|
+
if (turnLimited || tokenLimited() || !line.trim())
|
|
573
617
|
return;
|
|
574
618
|
let event;
|
|
575
619
|
try {
|
|
@@ -584,12 +628,18 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
584
628
|
if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type))
|
|
585
629
|
return;
|
|
586
630
|
observeEvent();
|
|
587
|
-
if (record.type === "turn_start"
|
|
588
|
-
if (
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
631
|
+
if (record.type === "turn_start") {
|
|
632
|
+
if (++startedTurns > budget.maxTurns) {
|
|
633
|
+
if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
|
|
634
|
+
turnLimited = true;
|
|
635
|
+
stop(true);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (advanceTokenBudget("turn_start"))
|
|
639
|
+
return;
|
|
592
640
|
}
|
|
641
|
+
if (record.type === "turn_end")
|
|
642
|
+
advanceTokenBudget("turn_end");
|
|
593
643
|
if (record.type === "message_start") {
|
|
594
644
|
partial.prefix = "";
|
|
595
645
|
partial.totalBytes = 0;
|
|
@@ -714,7 +764,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
714
764
|
};
|
|
715
765
|
onStdoutData = (data) => {
|
|
716
766
|
armPostExitIdleDeadline();
|
|
717
|
-
if (callbackFailure || protocolError || turnLimited)
|
|
767
|
+
if (callbackFailure || protocolError || turnLimited || tokenLimited())
|
|
718
768
|
return;
|
|
719
769
|
let offset = 0;
|
|
720
770
|
while (offset < data.length) {
|
|
@@ -755,7 +805,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
755
805
|
invokeCallback("onActivity", input.onActivity, activity);
|
|
756
806
|
}
|
|
757
807
|
}
|
|
758
|
-
if (callbackFailure || turnLimited)
|
|
808
|
+
if (callbackFailure || turnLimited || tokenLimited())
|
|
759
809
|
return;
|
|
760
810
|
lineParts = [];
|
|
761
811
|
lineBytes = 0;
|
|
@@ -795,13 +845,13 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
795
845
|
killTimer.unref();
|
|
796
846
|
}
|
|
797
847
|
const abort = () => {
|
|
798
|
-
if (timedOutAfterMs !== undefined || turnLimited || childExited)
|
|
848
|
+
if (timedOutAfterMs !== undefined || turnLimited || tokenLimited() || childExited)
|
|
799
849
|
return;
|
|
800
850
|
aborted = true;
|
|
801
851
|
stop();
|
|
802
852
|
};
|
|
803
853
|
function timeout(afterMs, reason) {
|
|
804
|
-
if (timedOutAfterMs !== undefined || turnLimited || childExited)
|
|
854
|
+
if (timedOutAfterMs !== undefined || turnLimited || tokenLimited() || childExited)
|
|
805
855
|
return;
|
|
806
856
|
if (reason === "maximum") {
|
|
807
857
|
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 AvailableModel, type ModelTask, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
|
|
3
3
|
export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.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";
|
|
4
|
+
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutionBudget, 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/docs/orchestration.md
CHANGED
|
@@ -144,13 +144,15 @@ Role Skill names resolve through Main's effective Pi Skill registry at launch. M
|
|
|
144
144
|
|
|
145
145
|
### Role final-turn handoff
|
|
146
146
|
|
|
147
|
-
Role launches made by `createRoleLaunch` reserve the
|
|
147
|
+
Role launches made by `createRoleLaunch` reserve a response-only handoff at either boundary. They use the penultimate `maxTurns` turn or a continuing `maxTokens` crossing. This includes `delegate_task` and every Flow Implementer, Reviewer, and repair launch.
|
|
148
148
|
|
|
149
149
|
With `maxTurns` set to 1, Pi disables tools during `session_start`. The sole provider turn is the response-only handoff.
|
|
150
150
|
|
|
151
|
-
After a continuing
|
|
151
|
+
After a continuing boundary turn, Pi completes its tools. It then disables every active tool and queues one structured final handoff. A terminal boundary response succeeds without a handoff. If the child continues after the token handoff, the executor rejects with `token_limit`.
|
|
152
152
|
|
|
153
|
-
The handoff requests a fixed Markdown decision packet with Status (`completed`, `blocked`, or `incomplete`), one-sentence Outcome, up to three concrete Evidence facts, Blocker, one material Risk, and one Suggested next action. It is the default. Exact output required by the assigned task or Role takes precedence, and the child replies only with it. This preserves a Flow Reviewer's exact `PASS` and caller-required structured output.
|
|
153
|
+
The handoff requests a fixed Markdown decision packet with Status (`completed`, `blocked`, or `incomplete`), one-sentence Outcome, up to three concrete Evidence facts, Blocker, one material Risk, and one Suggested next action. It is the default. Exact output required by the assigned task or Role takes precedence, and the child replies only with it. This preserves a Flow Reviewer's exact `PASS` and caller-required structured output. The handoff remains inside `maxTurns`, but it is the one permitted response after crossing `maxTokens`. A timeout, provider failure, or child-process failure can end a Role launch before handoff. Commits, validation, and retained-worktree facts from executor/Flow structured evidence remain authoritative; the model handoff supplies semantic context and a suggested next action.
|
|
154
|
+
|
|
155
|
+
A raw `createEphemeralSubagentExecutor` launch enforces the token extra-turn window. It does not guarantee disabled tools or the handoff message. Only Role launches install that policy.
|
|
154
156
|
|
|
155
157
|
## Public Role and executor API
|
|
156
158
|
|
|
@@ -181,11 +183,14 @@ A loaded `Role` contains `name`, `description`, required normalized `tools`, `ex
|
|
|
181
183
|
const executorOptions = {
|
|
182
184
|
maxConcurrency: 4,
|
|
183
185
|
maxTurns: 50,
|
|
186
|
+
maxTokens: 100_000, // optional; omitted means unlimited
|
|
184
187
|
timeout: { idleMs: 10 * 60_000, maxMs: 30 * 60_000 },
|
|
185
188
|
};
|
|
186
189
|
```
|
|
187
190
|
|
|
188
|
-
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` and `
|
|
191
|
+
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`, and configured `maxTokens` must be safe integers >= 1. `idleMs` and `maxMs` must be positive. `maxMs` must exceed `idleMs`. Omitted `maxTurns` defaults to 50. Omitted `maxTokens` is unlimited.
|
|
192
|
+
|
|
193
|
+
One executor applies `maxTokens` independently to every `run`; it is not a shared pool. `run` has no token override. The built-in extension reads its global value only from `config/pi-subagent/config.json`.
|
|
189
194
|
|
|
190
195
|
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.
|
|
191
196
|
|
|
@@ -214,6 +219,7 @@ export function createRunRole(pi) {
|
|
|
214
219
|
const executor = createEphemeralSubagentExecutor({
|
|
215
220
|
maxConcurrency: 4,
|
|
216
221
|
maxTurns: 50,
|
|
222
|
+
maxTokens: 100_000,
|
|
217
223
|
timeout: { idleMs: 10 * 60_000, maxMs: 30 * 60_000 },
|
|
218
224
|
});
|
|
219
225
|
|
|
@@ -267,7 +273,11 @@ export function createRunRole(pi) {
|
|
|
267
273
|
}
|
|
268
274
|
```
|
|
269
275
|
|
|
270
|
-
`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`.
|
|
276
|
+
`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, token-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`.
|
|
277
|
+
|
|
278
|
+
Token accounting adds each completed assistant response's `Usage.totalTokens` once. It uses the same aggregate `usage` returned by the executor. A terminal response crossing `maxTokens` succeeds. A continuing crossing response and its tools finish before one final response turn. That turn may overshoot the configured value. Further continuation rejects with `token_limit`, aggregate `usage`, and bounded last assistant `output`.
|
|
279
|
+
|
|
280
|
+
The executor passes its turn, token, and runtime budget through its internal child protocol. On a Role launch, its tool policy sends one convergence warning at each 80% threshold. It combines thresholds first due together and uses no timer or extra warning turn. Assistant `output` and `stderr` are bounded, and `usage` contains aggregate child usage when Pi supplies it.
|
|
271
281
|
|
|
272
282
|
### Activity callbacks
|
|
273
283
|
|
package/extensions/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface SubagentTimeoutConfig {
|
|
|
11
11
|
export interface SubagentConfig {
|
|
12
12
|
maxSubagents?: number;
|
|
13
13
|
maxTurns?: number;
|
|
14
|
+
maxTokens?: number;
|
|
14
15
|
timeout?: SubagentTimeoutConfig;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -31,6 +32,7 @@ const positive = (value: unknown): value is number =>
|
|
|
31
32
|
// every child immediately instead of applying the configured deadline.
|
|
32
33
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
33
34
|
export const DEFAULT_TIMEOUT_CONFIG = { idleMinutes: 10, maxMinutes: 30 } as const;
|
|
35
|
+
const INTEGER_FIELDS = ["maxSubagents", "maxTurns", "maxTokens"] as const;
|
|
34
36
|
const TIMEOUT_FIELDS = ["idleMinutes", "maxMinutes"] as const;
|
|
35
37
|
|
|
36
38
|
/** Return the canonical default JSON path for pi-subagent's config home. */
|
|
@@ -45,26 +47,18 @@ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfi
|
|
|
45
47
|
const problems: string[] = [];
|
|
46
48
|
const config: SubagentConfig = {};
|
|
47
49
|
for (const key of Object.keys(record)) {
|
|
48
|
-
if (
|
|
49
|
-
problems.push(`unknown config key ${JSON.stringify(key)}; expected
|
|
50
|
+
if (![...INTEGER_FIELDS, "timeout"].includes(key as typeof INTEGER_FIELDS[number] | "timeout")) {
|
|
51
|
+
problems.push(`unknown config key ${JSON.stringify(key)}; expected ${INTEGER_FIELDS.join(", ")}, timeout`);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
55
|
+
for (const key of INTEGER_FIELDS) {
|
|
56
|
+
const value = record[key];
|
|
57
|
+
if (value === undefined) continue;
|
|
58
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) {
|
|
59
|
+
config[key] = value;
|
|
57
60
|
} else {
|
|
58
|
-
problems.push(
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const maxTurns = record.maxTurns;
|
|
63
|
-
if (maxTurns !== undefined) {
|
|
64
|
-
if (typeof maxTurns === "number" && Number.isSafeInteger(maxTurns) && maxTurns >= 1) {
|
|
65
|
-
config.maxTurns = maxTurns;
|
|
66
|
-
} else {
|
|
67
|
-
problems.push(`maxTurns must be a safe integer >= 1, got ${JSON.stringify(maxTurns)}`);
|
|
61
|
+
problems.push(`${key} must be a safe integer >= 1, got ${JSON.stringify(value)}`);
|
|
68
62
|
}
|
|
69
63
|
}
|
|
70
64
|
|
package/extensions/role-tools.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CHILD_EXCLUDED_TOOL_NAMES,
|
|
4
|
+
EXECUTION_BUDGET_ENV,
|
|
5
|
+
ROLE_TOOL_POLICY_FLAG,
|
|
6
|
+
type EphemeralSubagentExecutionBudget,
|
|
7
|
+
} from "@henryqw/pi-subagent";
|
|
3
8
|
|
|
4
9
|
const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
|
|
5
10
|
const WARNING_RATIO = 0.8;
|
|
@@ -24,7 +29,7 @@ function configuredTools(value: unknown): string[] {
|
|
|
24
29
|
return [...new Set(parsed.map((name) => name.trim()))];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
function executionBudget(value: string | undefined):
|
|
32
|
+
function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
28
33
|
if (value === undefined) return;
|
|
29
34
|
let parsed: unknown;
|
|
30
35
|
try {
|
|
@@ -36,13 +41,22 @@ function executionBudget(value: string | undefined): { maxTurns: number; maxMs:
|
|
|
36
41
|
throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
37
42
|
}
|
|
38
43
|
const budget = parsed as Record<string, unknown>;
|
|
39
|
-
|
|
44
|
+
const maxTokensValid = budget.maxTokens === undefined
|
|
45
|
+
|| Number.isSafeInteger(budget.maxTokens) && (budget.maxTokens as number) >= 1;
|
|
46
|
+
if (Object.keys(budget).some((key) => !["maxTurns", "maxMs", "startedAt", "maxTokens"].includes(key))
|
|
47
|
+
|| !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
|
|
40
48
|
|| !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < 1
|
|
41
49
|
|| typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0
|
|
42
|
-
|| !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0
|
|
50
|
+
|| !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0
|
|
51
|
+
|| !maxTokensValid) {
|
|
43
52
|
throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
44
53
|
}
|
|
45
|
-
return {
|
|
54
|
+
return {
|
|
55
|
+
maxTurns: budget.maxTurns as number,
|
|
56
|
+
maxMs: budget.maxMs,
|
|
57
|
+
startedAt: budget.startedAt as number,
|
|
58
|
+
...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens as number }),
|
|
59
|
+
};
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
function expectsAnotherTurn(message: unknown): boolean {
|
|
@@ -53,6 +67,18 @@ function expectsAnotherTurn(message: unknown): boolean {
|
|
|
53
67
|
&& (part as Record<string, unknown>).type === "toolCall");
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
function messageTokens(message: unknown): number | undefined {
|
|
71
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return;
|
|
72
|
+
const usage = (message as Record<string, unknown>).usage;
|
|
73
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return;
|
|
74
|
+
const totalTokens = (usage as Record<string, unknown>).totalTokens;
|
|
75
|
+
return typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens >= 0 ? totalTokens : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function joinBudgetParts(parts: string[]): string {
|
|
79
|
+
return `${parts.slice(0, -1).join(", ")}${parts.length > 2 ? "," : ""} and ${parts.at(-1)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
56
82
|
export default function roleTools(pi: ExtensionAPI): void {
|
|
57
83
|
pi.registerFlag(ROLE_TOOL_POLICY_FLAG, {
|
|
58
84
|
description: "Internal Pi Subagent Role tool policy",
|
|
@@ -82,30 +108,48 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
82
108
|
|
|
83
109
|
if (!budget) return;
|
|
84
110
|
const warningTurn = Math.ceil(budget.maxTurns * WARNING_RATIO);
|
|
111
|
+
const warningTokens = budget.maxTokens === undefined ? undefined : Math.ceil(budget.maxTokens * WARNING_RATIO);
|
|
85
112
|
let completedTurns = 0;
|
|
113
|
+
let completedTokens = 0;
|
|
114
|
+
let currentTokens = 0;
|
|
86
115
|
let turnWarningSent = false;
|
|
116
|
+
let tokenWarningSent = false;
|
|
87
117
|
let runtimeWarningSent = false;
|
|
118
|
+
pi.on("message_update", (event) => {
|
|
119
|
+
currentTokens = messageTokens(event.message) ?? currentTokens;
|
|
120
|
+
});
|
|
88
121
|
pi.on("turn_end", (event) => {
|
|
89
122
|
completedTurns += 1;
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
completedTokens += messageTokens(event.message) ?? currentTokens;
|
|
124
|
+
currentTokens = 0;
|
|
125
|
+
const continuing = expectsAnotherTurn(event.message);
|
|
126
|
+
const tokenBudgetCrossed = budget.maxTokens !== undefined && completedTokens >= budget.maxTokens;
|
|
127
|
+
if (!handoffSent && (continuing && completedTurns === budget.maxTurns - 1 || tokenBudgetCrossed)) {
|
|
92
128
|
pi.setActiveTools([]);
|
|
93
129
|
pi.sendMessage(FINAL_HANDOFF_MESSAGE, { deliverAs: "steer", triggerTurn: false });
|
|
94
130
|
handoffSent = true;
|
|
95
131
|
return;
|
|
96
132
|
}
|
|
133
|
+
if (!continuing || handoffSent) return;
|
|
97
134
|
const elapsedMs = Math.max(0, Date.now() - budget.startedAt);
|
|
98
135
|
const turnWarningDue = !turnWarningSent && completedTurns >= warningTurn;
|
|
136
|
+
const tokenWarningDue = warningTokens !== undefined && !tokenWarningSent && completedTokens >= warningTokens;
|
|
99
137
|
const runtimeWarningDue = !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
|
|
100
|
-
if (!turnWarningDue && !runtimeWarningDue) return;
|
|
138
|
+
if (!turnWarningDue && !tokenWarningDue && !runtimeWarningDue) return;
|
|
101
139
|
if (turnWarningDue) turnWarningSent = true;
|
|
140
|
+
if (tokenWarningDue) tokenWarningSent = true;
|
|
102
141
|
if (runtimeWarningDue) runtimeWarningSent = true;
|
|
103
142
|
const remainingTurns = Math.max(0, budget.maxTurns - completedTurns);
|
|
104
143
|
const remainingMinutes = Math.max(0, Math.ceil((budget.maxMs - elapsedMs) / 60_000));
|
|
105
144
|
const maxMinutes = budget.maxMs / 60_000;
|
|
145
|
+
const parts = [
|
|
146
|
+
`${remainingTurns} of ${budget.maxTurns} turns`,
|
|
147
|
+
...(budget.maxTokens === undefined ? [] : [`${Math.max(0, budget.maxTokens - completedTokens)} of ${budget.maxTokens} tokens`]),
|
|
148
|
+
`approximately ${remainingMinutes} of ${maxMinutes} minutes`,
|
|
149
|
+
];
|
|
106
150
|
pi.sendMessage({
|
|
107
151
|
customType: WARNING_MESSAGE_TYPE,
|
|
108
|
-
content: `**Execution budget warning:** ${
|
|
152
|
+
content: `**Execution budget warning:** ${joinBudgetParts(parts)} 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.`,
|
|
109
153
|
display: true,
|
|
110
154
|
}, { deliverAs: "steer", triggerTurn: false });
|
|
111
155
|
});
|
package/extensions/subagent.ts
CHANGED
|
@@ -301,6 +301,7 @@ export default function subagentExtension(
|
|
|
301
301
|
const executor = createEphemeralSubagentExecutor({
|
|
302
302
|
maxConcurrency: maxActiveSubagents,
|
|
303
303
|
maxTurns: loadedConfig.config.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
304
|
+
maxTokens: loadedConfig.config.maxTokens,
|
|
304
305
|
timeout: timeoutPolicy,
|
|
305
306
|
});
|
|
306
307
|
// Background children outlive the launching tool call, so they get their own
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ You are Main, the planner/orchestrator: slice work and choose `delegate_flow` or
|
|
|
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: 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.
|
|
14
|
+
Use the fewest cohesive units. Before selecting a Flow unit, require that one Implementer launch can plausibly finish before the configured maximum runtime. Cohesion alone is not enough when work has multiple preservable, separately verifiable milestones. `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. Units inside one Flow remain independent and commuting. Dependent work remains outside Flow. Split oversized dependent work into serial one-unit Flows after each milestone integrates; otherwise sequence it in one task or ordinary caller-controlled sequencing.
|
|
15
15
|
|
|
16
16
|
Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array. Each task packet must name the neighboring behavior that must stay unchanged. Include the exact test name or error when known CI evidence exists. Never claim a validation command matches unknown CI.
|
|
17
17
|
|
|
@@ -29,7 +29,7 @@ A successful Flow owns integration and cleanup. A blocked outcome is repairable
|
|
|
29
29
|
delegate_flow_continue({ guidance: "Address the reported block and complete the bounded unit.", modelClass: "balanced" })
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain an explicit blocked-unit class or otherwise use each frozen Role's default; supply it only to replace both defaults for that one repair. Do not call continuation unless Flow reports a repairable block. If continuation or Flow returns a terminal failure, inspect
|
|
32
|
+
Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain an explicit blocked-unit class or otherwise use each frozen Role's default; supply it only to replace both defaults for that one repair. Do not call continuation unless Flow reports a repairable block. If continuation or Flow returns a terminal failure, inspect each exact retained path reported by the runtime directly, then reslice or manually recover from Main; do not retry the Flow or guess a rebase resolution. Do not run `git worktree list` merely to rediscover a retained path.
|
|
33
33
|
|
|
34
34
|
A cleanup warning does not undo successful integration. Report a cleanup warning from a successful Flow as-is. Do not investigate it unless the user asks or cleanup is part of acceptance.
|
|
35
35
|
|