pi-task-dispatcher 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -78
- package/extensions/task-dispatcher.ts +240 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,133 +1,124 @@
|
|
|
1
1
|
# pi-task-dispatcher
|
|
2
2
|
|
|
3
|
-
Delegate bounded
|
|
3
|
+
Delegate bounded tasks from a Pi session to dedicated mechanical and engineering worker models while the selected main model remains in control.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Features
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **Two focused worker roles:** route precise execution work to a mechanic model and broader implementation work to an engineer model.
|
|
8
|
+
- **Least-privilege delegation:** every call supplies an exact tool allowlist selected from tools active for the main agent. Workers cannot invoke the delegation tools recursively.
|
|
9
|
+
- **Live progress:** streamed worker text, tool activity, and an elapsed-time heartbeat keep long-running tasks visible.
|
|
10
|
+
- **Recoverable interruption:** approaching time and turn limits request a worker-authored checkpoint. A hard timeout returns a synthetic fallback checkpoint.
|
|
11
|
+
- **Explicit continuation:** a checkpoint never starts another worker automatically. The main agent reviews current state and decides what happens next.
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
## Worker tools
|
|
14
|
+
|
|
15
|
+
The package adds two model-only tools to Pi:
|
|
16
|
+
|
|
17
|
+
| Tool | Intended use |
|
|
10
18
|
| --- | --- |
|
|
11
|
-
| `delegate_mechanical` |
|
|
12
|
-
| `delegate_engineering` |
|
|
19
|
+
| `delegate_mechanical` | Bounded multi-step inspection, exact edits, repetitive transformations, commands, retrieval, or verification with precise acceptance conditions. |
|
|
20
|
+
| `delegate_engineering` | Self-contained investigation, implementation, debugging, refactoring, testing, review, research, or analysis. |
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
The main model remains the **orchestrator**. It defines the delegated task, selects the smallest sufficient tool allowlist, evaluates the report, reviews material changes, performs required verification, and owns the final answer.
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
Workers operate in the current workspace and can modify files only when their selected tools permit it. Role routing to the configured `provider/model` is deterministic; worker output and correctness are not. Treat every worker report as input to review rather than proof of completion.
|
|
17
25
|
|
|
18
26
|
## Requirements
|
|
19
27
|
|
|
20
|
-
- Pi 1.0.
|
|
28
|
+
- Pi 1.0.2 or later
|
|
21
29
|
- Node.js 22.19.0 or later
|
|
22
|
-
- A configured
|
|
23
|
-
|
|
24
|
-
## Install
|
|
25
|
-
|
|
26
|
-
Install the published package for your Pi user configuration:
|
|
30
|
+
- A configured and authenticated Pi model for each worker role you use
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
pi install npm:pi-task-dispatcher@1.0.0
|
|
30
|
-
```
|
|
32
|
+
## Installation
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
Install the package for your Pi user configuration:
|
|
33
35
|
|
|
34
36
|
```sh
|
|
35
|
-
pi install
|
|
37
|
+
pi install npm:pi-task-dispatcher
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
To
|
|
40
|
+
To install it only for the current project, add `--local`:
|
|
39
41
|
|
|
40
42
|
```sh
|
|
41
|
-
pi install npm:pi-task-dispatcher
|
|
43
|
+
pi install npm:pi-task-dispatcher --local
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
Project packages load only after
|
|
46
|
+
Project packages load only after the project is trusted. In an active Pi session, load the extension with:
|
|
45
47
|
|
|
46
48
|
```text
|
|
47
49
|
/reload
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
## Default workers
|
|
53
|
-
|
|
54
|
-
| Role | Model | Thinking | Timeout | Turn limit |
|
|
55
|
-
| --- | --- | --- | ---: | ---: |
|
|
56
|
-
| Mechanic | `openai-codex/gpt-5.6-luna` | `max` | 300 seconds | 20 |
|
|
57
|
-
| Engineer | `openai-codex/gpt-5.6-terra` | `max` | 900 seconds | 30 |
|
|
58
|
-
|
|
59
|
-
By default, both workers receive this explicit tool allowlist:
|
|
60
|
-
|
|
61
|
-
```text
|
|
62
|
-
read,bash,edit,write
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
`pi-task-dispatcher` provides only these four native Pi tools by default; it does not provide `web_search`, `fetch_content`, `get_search_content`, or `source_check`. If you have the separate `pi-web-access` package installed, you may explicitly add those tools to a role's `tools` array:
|
|
66
|
-
|
|
67
|
-
```json
|
|
68
|
-
{
|
|
69
|
-
"mechanic": {
|
|
70
|
-
"tools": ["read", "bash", "edit", "write", "web_search", "fetch_content", "get_search_content", "source_check"]
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
```
|
|
52
|
+
## Configuration
|
|
74
53
|
|
|
75
|
-
|
|
54
|
+
The package works without a configuration file using these defaults:
|
|
76
55
|
|
|
77
|
-
|
|
56
|
+
| Role | Model | Thinking | Hard timeout | Checkpoint grace | Turn limit |
|
|
57
|
+
| --- | --- | --- | ---: | ---: | ---: |
|
|
58
|
+
| Mechanic | `openai-codex/gpt-5.6-luna` | `max` | 300 seconds | 30 seconds | 20 |
|
|
59
|
+
| Engineer | `openai-codex/gpt-5.6-terra` | `max` | 900 seconds | 45 seconds | 30 |
|
|
78
60
|
|
|
79
|
-
|
|
61
|
+
To override them, create `~/.pi/agent/task-dispatcher.json`. If Pi uses a custom agent directory, create `task-dispatcher.json` in that directory instead. Missing roles and fields retain their defaults.
|
|
80
62
|
|
|
81
63
|
```json
|
|
82
64
|
{
|
|
83
|
-
"version": 1,
|
|
84
65
|
"mechanic": {
|
|
85
66
|
"model": "openai-codex/gpt-5.6-luna",
|
|
86
67
|
"thinking": "max",
|
|
87
|
-
"tools": [
|
|
88
|
-
"read",
|
|
89
|
-
"bash",
|
|
90
|
-
"edit",
|
|
91
|
-
"write"
|
|
92
|
-
],
|
|
93
68
|
"timeoutSeconds": 300,
|
|
69
|
+
"checkpointGraceSeconds": 30,
|
|
94
70
|
"maxTurns": 20
|
|
95
71
|
},
|
|
96
72
|
"engineer": {
|
|
97
73
|
"model": "openai-codex/gpt-5.6-terra",
|
|
98
74
|
"thinking": "max",
|
|
99
|
-
"tools": [
|
|
100
|
-
"read",
|
|
101
|
-
"bash",
|
|
102
|
-
"edit",
|
|
103
|
-
"write"
|
|
104
|
-
],
|
|
105
75
|
"timeoutSeconds": 900,
|
|
76
|
+
"checkpointGraceSeconds": 45,
|
|
106
77
|
"maxTurns": 30
|
|
107
78
|
}
|
|
108
79
|
}
|
|
109
80
|
```
|
|
110
81
|
|
|
111
|
-
| Field |
|
|
82
|
+
| Field | Description |
|
|
112
83
|
| --- | --- |
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
|
|
84
|
+
| `mechanic`, `engineer` | Optional role objects. Each supplied field overrides that role's default. |
|
|
85
|
+
| `model` | Exact `provider/model` identity. The dispatcher verifies model availability and authentication before starting a worker. |
|
|
86
|
+
| `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. |
|
|
87
|
+
| `timeoutSeconds` | Positive integer hard wall-clock limit for the worker process. |
|
|
88
|
+
| `checkpointGraceSeconds` | Positive integer period reserved before the hard timeout for a graceful checkpoint. It must be less than `timeoutSeconds`. |
|
|
89
|
+
| `maxTurns` | Positive integer work-turn limit. The dispatcher allows a final checkpoint response after the limit is reached. |
|
|
90
|
+
|
|
91
|
+
Configuration is read for every delegation, so configuration-only changes do not require `/reload`.
|
|
92
|
+
|
|
93
|
+
Tool access is deliberately absent from role configuration. Every delegation selects its own exact allowlist from the main agent's currently active tools; `[]` gives the worker no tools. A continuation must select its tools again and does not inherit the previous allowlist.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
Give the main model a bounded objective and let it choose whether delegation is useful. For example:
|
|
98
|
+
|
|
99
|
+
> Use the engineering worker to diagnose the failing parser test. Do not change public APIs. Run the focused test and report the cause, changed files, and test result.
|
|
100
|
+
|
|
101
|
+
For tighter control, name the role and allowed tools:
|
|
102
|
+
|
|
103
|
+
> Delegate this to the mechanic worker with only `read` and `bash`: inspect the generated manifest, run its validation command, and report exact mismatches. Do not modify files.
|
|
104
|
+
|
|
105
|
+
A direct tool remains preferable for one trivial operation. Delegation is most useful when a self-contained episode would otherwise consume multiple agent turns or produce bulky intermediate output.
|
|
106
|
+
|
|
107
|
+
## Limits and checkpoints
|
|
108
|
+
|
|
109
|
+
Each delegation runs in a separate Pi RPC child process with an in-memory, no-session conversation. The worker receives its role prompt, selected model, thinking level, and only the allowlist chosen for that call.
|
|
120
110
|
|
|
121
|
-
|
|
111
|
+
The dispatcher propagates parent cancellation and reports live text and tool activity. Before the hard timeout, it steers the worker to stop at the next safe turn boundary and produce a checkpoint. Reaching the turn limit uses the same checkpoint flow. If the worker cannot settle before the hard deadline, the dispatcher terminates it and reports observed completed and interrupted activity in a fallback checkpoint.
|
|
122
112
|
|
|
123
|
-
|
|
113
|
+
Non-completed results include the stopping reason, available partial worker text, observed activity, and a warning when filesystem effects may remain. The main agent then chooses whether to:
|
|
124
114
|
|
|
125
|
-
|
|
115
|
+
- inspect and continue directly;
|
|
116
|
+
- delegate a bounded continuation with a newly selected allowlist;
|
|
117
|
+
- request user input; or
|
|
118
|
+
- stop and report a limitation.
|
|
126
119
|
|
|
127
|
-
|
|
120
|
+
Continuation is never automatic.
|
|
128
121
|
|
|
129
|
-
##
|
|
122
|
+
## Security
|
|
130
123
|
|
|
131
|
-
|
|
132
|
-
2. Give it a bounded request, for example: “Use `delegate_engineering` to diagnose the failing parser test. Do not change public APIs; run the focused test; return the cause, files changed, and test result.”
|
|
133
|
-
3. The orchestrator calls the selected worker tool, receives its report, inspects relevant changes and verification, then produces the final response.
|
|
124
|
+
Worker process separation is **not** a security sandbox. A worker uses its selected tools with the Pi host's permissions in the current environment. Tool availability does not establish task scope, authorization, or safety. Use the smallest practical allowlist and delegate only work you are prepared to authorize and review.
|
|
@@ -25,8 +25,8 @@ type WorkerStatus =
|
|
|
25
25
|
interface WorkerConfig {
|
|
26
26
|
model: string;
|
|
27
27
|
thinking: ThinkingLevel;
|
|
28
|
-
tools: string[];
|
|
29
28
|
timeoutSeconds: number;
|
|
29
|
+
checkpointGraceSeconds: number;
|
|
30
30
|
maxTurns: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -52,6 +52,21 @@ interface UsageTotals {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
interface WorkerActivity {
|
|
56
|
+
toolCallId: string;
|
|
57
|
+
label: string;
|
|
58
|
+
status: "running" | "completed" | "failed";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface WorkerCheckpoint {
|
|
62
|
+
reason: Exclude<WorkerStatus, "running" | "completed">;
|
|
63
|
+
task: string;
|
|
64
|
+
lastAssistantText?: string;
|
|
65
|
+
completedActivities: string[];
|
|
66
|
+
activeActivities: string[];
|
|
67
|
+
filesystemEffectsMayRemain: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
interface WorkerDetails {
|
|
56
71
|
role: Role;
|
|
57
72
|
status: WorkerStatus;
|
|
@@ -64,7 +79,8 @@ interface WorkerDetails {
|
|
|
64
79
|
stopReason?: string;
|
|
65
80
|
diagnostic?: string;
|
|
66
81
|
stderr?: string;
|
|
67
|
-
activity:
|
|
82
|
+
activity: WorkerActivity[];
|
|
83
|
+
checkpoint?: WorkerCheckpoint;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
interface WorkerResult {
|
|
@@ -74,45 +90,51 @@ interface WorkerResult {
|
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
interface ParsedEvent {
|
|
93
|
+
id?: string;
|
|
77
94
|
type?: string;
|
|
95
|
+
success?: boolean;
|
|
96
|
+
error?: string;
|
|
78
97
|
message?: {
|
|
79
98
|
role?: string;
|
|
80
99
|
content?: Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>;
|
|
81
|
-
usage?:
|
|
100
|
+
usage?: EventUsage;
|
|
82
101
|
stopReason?: string;
|
|
83
102
|
errorMessage?: string;
|
|
84
103
|
};
|
|
104
|
+
usage?: EventUsage;
|
|
105
|
+
assistantMessageEvent?: {
|
|
106
|
+
type?: string;
|
|
107
|
+
contentIndex?: number;
|
|
108
|
+
delta?: string;
|
|
109
|
+
content?: string;
|
|
110
|
+
};
|
|
111
|
+
toolCallId?: string;
|
|
85
112
|
toolName?: string;
|
|
86
113
|
args?: unknown;
|
|
114
|
+
isError?: boolean;
|
|
87
115
|
}
|
|
88
116
|
|
|
89
|
-
type EventUsage =
|
|
117
|
+
type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
|
|
90
118
|
|
|
91
119
|
const CONFIG_PATH = path.join(getAgentDir(), "task-dispatcher.json");
|
|
92
120
|
const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
|
|
93
121
|
const RESULT_LIMIT = 24_000;
|
|
94
122
|
const STDERR_LIMIT = 8_000;
|
|
95
|
-
const DEFAULT_TOOLS = [
|
|
96
|
-
"read",
|
|
97
|
-
"bash",
|
|
98
|
-
"edit",
|
|
99
|
-
"write",
|
|
100
|
-
];
|
|
101
123
|
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
102
124
|
|
|
103
125
|
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
104
126
|
mechanic: {
|
|
105
127
|
model: "openai-codex/gpt-5.6-luna",
|
|
106
128
|
thinking: "max",
|
|
107
|
-
tools: DEFAULT_TOOLS,
|
|
108
129
|
timeoutSeconds: 300,
|
|
130
|
+
checkpointGraceSeconds: 30,
|
|
109
131
|
maxTurns: 20,
|
|
110
132
|
},
|
|
111
133
|
engineer: {
|
|
112
134
|
model: "openai-codex/gpt-5.6-terra",
|
|
113
135
|
thinking: "max",
|
|
114
|
-
tools: DEFAULT_TOOLS,
|
|
115
136
|
timeoutSeconds: 900,
|
|
137
|
+
checkpointGraceSeconds: 45,
|
|
116
138
|
maxTurns: 30,
|
|
117
139
|
},
|
|
118
140
|
};
|
|
@@ -155,6 +177,10 @@ const DelegateParams = Type.Object({
|
|
|
155
177
|
maxLength: 12_000,
|
|
156
178
|
description: "A self-contained task with its objective, scope, constraints, and completion checks.",
|
|
157
179
|
}),
|
|
180
|
+
tools: Type.Array(Type.String({ minLength: 1 }), {
|
|
181
|
+
maxItems: 32,
|
|
182
|
+
description: "The exact worker tool allowlist selected from the main agent's active tools. Use the smallest sufficient set; use an empty array for no tools.",
|
|
183
|
+
}),
|
|
158
184
|
});
|
|
159
185
|
|
|
160
186
|
/** Identify plain objects before reading user-controlled JSON configuration fields. */
|
|
@@ -164,7 +190,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
164
190
|
|
|
165
191
|
/** Copy defaults so loaded configuration cannot mutate shared role settings. */
|
|
166
192
|
function copyWorkerConfig(config: WorkerConfig): WorkerConfig {
|
|
167
|
-
return { ...config
|
|
193
|
+
return { ...config };
|
|
168
194
|
}
|
|
169
195
|
|
|
170
196
|
/** Validate and merge one role's optional configuration over its package defaults. */
|
|
@@ -189,16 +215,8 @@ function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
|
|
|
189
215
|
result.thinking = value.thinking as ThinkingLevel;
|
|
190
216
|
}
|
|
191
217
|
|
|
192
|
-
// Require an explicit non-empty allowlist when tools are overridden.
|
|
193
|
-
if (value.tools !== undefined) {
|
|
194
|
-
if (!Array.isArray(value.tools) || value.tools.length === 0 || value.tools.some((tool) => typeof tool !== "string" || !tool.trim())) {
|
|
195
|
-
throw new Error(`"${role}.tools" must be a non-empty array of tool names`);
|
|
196
|
-
}
|
|
197
|
-
result.tools = [...new Set(value.tools as string[])];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
218
|
// Bound worker lifetime settings to positive integers.
|
|
201
|
-
for (const field of ["timeoutSeconds", "maxTurns"] as const) {
|
|
219
|
+
for (const field of ["timeoutSeconds", "checkpointGraceSeconds", "maxTurns"] as const) {
|
|
202
220
|
const candidate = value[field];
|
|
203
221
|
if (candidate !== undefined) {
|
|
204
222
|
if (!Number.isInteger(candidate) || (candidate as number) <= 0) {
|
|
@@ -207,6 +225,9 @@ function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
|
|
|
207
225
|
result[field] = candidate as number;
|
|
208
226
|
}
|
|
209
227
|
}
|
|
228
|
+
if (result.checkpointGraceSeconds >= result.timeoutSeconds) {
|
|
229
|
+
throw new Error(`"${role}.checkpointGraceSeconds" must be less than "${role}.timeoutSeconds"`);
|
|
230
|
+
}
|
|
210
231
|
|
|
211
232
|
return result;
|
|
212
233
|
}
|
|
@@ -227,9 +248,6 @@ function loadConfig(): DispatcherConfig {
|
|
|
227
248
|
throw new Error(`Cannot parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
228
249
|
}
|
|
229
250
|
if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain a JSON object`);
|
|
230
|
-
if (parsed.version !== undefined && parsed.version !== 1) {
|
|
231
|
-
throw new Error(`${CONFIG_PATH} has unsupported version ${String(parsed.version)}`);
|
|
232
|
-
}
|
|
233
251
|
|
|
234
252
|
return {
|
|
235
253
|
mechanic: parseWorkerConfig("mechanic", parsed.mechanic),
|
|
@@ -296,24 +314,45 @@ function formatActivity(name: string, args: unknown): string {
|
|
|
296
314
|
return name;
|
|
297
315
|
}
|
|
298
316
|
|
|
317
|
+
/** Format a stopped worker's observed state for the orchestrator's continuation decision. */
|
|
318
|
+
function formatCheckpoint(checkpoint: WorkerCheckpoint): string {
|
|
319
|
+
const completed = checkpoint.completedActivities.length > 0 ? checkpoint.completedActivities.join("; ") : "none observed";
|
|
320
|
+
const active = checkpoint.activeActivities.length > 0 ? checkpoint.activeActivities.join("; ") : "none observed";
|
|
321
|
+
const text = checkpoint.lastAssistantText ? `\nLast worker text:\n${truncate(checkpoint.lastAssistantText, 4_000)}` : "";
|
|
322
|
+
return [
|
|
323
|
+
"Worker checkpoint:",
|
|
324
|
+
`- Stop reason: ${checkpoint.reason}`,
|
|
325
|
+
`- Delegated task: ${truncate(checkpoint.task, 2_000)}`,
|
|
326
|
+
`- Completed tool activity: ${completed}`,
|
|
327
|
+
`- Active or interrupted tool activity: ${active}`,
|
|
328
|
+
`- Filesystem effects may remain: ${checkpoint.filesystemEffectsMayRemain ? "yes" : "no"}`,
|
|
329
|
+
text,
|
|
330
|
+
"Continuation is not automatic. Inspect material state, then decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation.",
|
|
331
|
+
].filter(Boolean).join("\n");
|
|
332
|
+
}
|
|
333
|
+
|
|
299
334
|
/** Run one isolated Pi worker through its full tool-using agent loop. */
|
|
300
335
|
async function runWorker(
|
|
301
336
|
role: Role,
|
|
302
337
|
task: string,
|
|
303
338
|
config: WorkerConfig,
|
|
339
|
+
tools: string[],
|
|
304
340
|
cwd: string,
|
|
305
341
|
signal: AbortSignal | undefined,
|
|
306
342
|
onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: WorkerDetails }) => void) | undefined,
|
|
307
343
|
): Promise<WorkerResult> {
|
|
308
344
|
const startedAt = Date.now();
|
|
309
345
|
const usage = emptyUsage();
|
|
310
|
-
const activity:
|
|
346
|
+
const activity: WorkerActivity[] = [];
|
|
347
|
+
const activeActivity = new Map<string, WorkerActivity>();
|
|
348
|
+
const streamingText = new Map<number, string>();
|
|
311
349
|
let turns = 0;
|
|
312
350
|
let finalOutput = "";
|
|
313
351
|
let stopReason: string | undefined;
|
|
314
352
|
let modelError: string | undefined;
|
|
315
353
|
let stderr = "";
|
|
316
354
|
let forcedStatus: WorkerStatus | undefined;
|
|
355
|
+
let checkpointRequestedReason: "timeout" | "turn_limit" | undefined;
|
|
317
356
|
let processError: string | undefined;
|
|
318
357
|
|
|
319
358
|
// Keep role instructions out of the command line and remove them after the worker exits.
|
|
@@ -325,18 +364,15 @@ async function runWorker(
|
|
|
325
364
|
|
|
326
365
|
const args = [
|
|
327
366
|
"--mode",
|
|
328
|
-
"
|
|
329
|
-
"-p",
|
|
367
|
+
"rpc",
|
|
330
368
|
"--no-session",
|
|
331
369
|
"--model",
|
|
332
370
|
config.model,
|
|
333
371
|
"--thinking",
|
|
334
372
|
config.thinking,
|
|
335
|
-
"--tools",
|
|
336
|
-
config.tools.join(","),
|
|
373
|
+
...(tools.length > 0 ? ["--tools", tools.join(",")] : ["--no-tools"]),
|
|
337
374
|
"--append-system-prompt",
|
|
338
375
|
promptPath,
|
|
339
|
-
`Delegated ${role} task:\n${task}`,
|
|
340
376
|
];
|
|
341
377
|
|
|
342
378
|
const exitCode = await new Promise<number | null>((resolve) => {
|
|
@@ -344,7 +380,7 @@ async function runWorker(
|
|
|
344
380
|
const child = spawn(invocation.command, invocation.args, {
|
|
345
381
|
cwd,
|
|
346
382
|
shell: false,
|
|
347
|
-
stdio: ["
|
|
383
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
348
384
|
env: { ...process.env, [WORKER_ENV]: role },
|
|
349
385
|
});
|
|
350
386
|
let stdoutBuffer = "";
|
|
@@ -364,7 +400,41 @@ async function runWorker(
|
|
|
364
400
|
}, 5_000);
|
|
365
401
|
};
|
|
366
402
|
|
|
367
|
-
//
|
|
403
|
+
// Ask the worker to stop at the next safe turn boundary and author its handoff report.
|
|
404
|
+
const requestCheckpoint = (reason: "timeout" | "turn_limit") => {
|
|
405
|
+
if (checkpointRequestedReason || settled || child.stdin.writableEnded) return;
|
|
406
|
+
checkpointRequestedReason = reason;
|
|
407
|
+
const message =
|
|
408
|
+
reason === "timeout"
|
|
409
|
+
? "The delegation time limit is approaching. Stop starting new work. At the next safe boundary, return a concise checkpoint with completed work, files changed, checks run, current state, interrupted work, and unresolved items. Do not continue implementation."
|
|
410
|
+
: "The delegation turn limit has been reached. After current tool calls finish, stop work and return a concise checkpoint with completed work, files changed, checks run, current state, interrupted work, and unresolved items. Do not continue implementation.";
|
|
411
|
+
child.stdin.write(`${JSON.stringify({ id: `checkpoint-${reason}`, type: "steer", message })}\n`);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
let lastProgressAt = 0;
|
|
415
|
+
const currentStreamingText = () =>
|
|
416
|
+
[...streamingText.entries()].sort(([left], [right]) => left - right).map(([, text]) => text).join("\n").trim();
|
|
417
|
+
const emitProgress = (text: string, force = false) => {
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
if (!force && now - lastProgressAt < 250) return;
|
|
420
|
+
lastProgressAt = now;
|
|
421
|
+
onUpdate?.({
|
|
422
|
+
content: [{ type: "text", text }],
|
|
423
|
+
details: {
|
|
424
|
+
role,
|
|
425
|
+
status: "running",
|
|
426
|
+
model: config.model,
|
|
427
|
+
thinking: config.thinking,
|
|
428
|
+
tools,
|
|
429
|
+
turns,
|
|
430
|
+
durationMs: now - startedAt,
|
|
431
|
+
exitCode: null,
|
|
432
|
+
activity: activity.map((item) => ({ ...item })),
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// Reduce the documented JSON event stream into live progress and a recoverable checkpoint.
|
|
368
438
|
const processLine = (line: string) => {
|
|
369
439
|
if (!line.trim()) return;
|
|
370
440
|
let event: ParsedEvent;
|
|
@@ -373,10 +443,65 @@ async function runWorker(
|
|
|
373
443
|
} catch {
|
|
374
444
|
return;
|
|
375
445
|
}
|
|
446
|
+
|
|
447
|
+
// Fail promptly when RPC rejects the initial prompt instead of waiting for the wall-clock limit.
|
|
448
|
+
if (event.type === "response" && event.id === "delegated-task" && event.success === false) {
|
|
449
|
+
processError = event.error || "Pi rejected the delegated RPC prompt.";
|
|
450
|
+
stopChild("process_error");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (event.type === "agent_settled") {
|
|
455
|
+
if (checkpointRequestedReason && !forcedStatus) forcedStatus = checkpointRequestedReason;
|
|
456
|
+
if (!child.stdin.writableEnded) child.stdin.end();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
376
460
|
if (event.type === "message_update") {
|
|
377
|
-
if (event.
|
|
461
|
+
if (event.usage) unfinishedUsage = event.usage;
|
|
462
|
+
const update = event.assistantMessageEvent;
|
|
463
|
+
if (typeof update?.contentIndex === "number") {
|
|
464
|
+
if (update.type === "text_start") streamingText.set(update.contentIndex, "");
|
|
465
|
+
if (update.type === "text_delta" && update.delta) {
|
|
466
|
+
streamingText.set(update.contentIndex, (streamingText.get(update.contentIndex) ?? "") + update.delta);
|
|
467
|
+
emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`);
|
|
468
|
+
}
|
|
469
|
+
if (update.type === "text_end" && update.content !== undefined) {
|
|
470
|
+
streamingText.set(update.contentIndex, update.content);
|
|
471
|
+
emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`, true);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
|
|
478
|
+
const item: WorkerActivity = {
|
|
479
|
+
toolCallId: event.toolCallId,
|
|
480
|
+
label: formatActivity(event.toolName, event.args),
|
|
481
|
+
status: "running",
|
|
482
|
+
};
|
|
483
|
+
activity.push(item);
|
|
484
|
+
activeActivity.set(item.toolCallId, item);
|
|
485
|
+
emitProgress(`${role}: running ${item.label}`, true);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (event.type === "tool_execution_update" && event.toolCallId) {
|
|
490
|
+
const item = activeActivity.get(event.toolCallId);
|
|
491
|
+
if (item) emitProgress(`${role}: running ${item.label}`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (event.type === "tool_execution_end" && event.toolCallId) {
|
|
496
|
+
const item = activeActivity.get(event.toolCallId);
|
|
497
|
+
if (item) {
|
|
498
|
+
item.status = event.isError ? "failed" : "completed";
|
|
499
|
+
activeActivity.delete(event.toolCallId);
|
|
500
|
+
emitProgress(`${role}: ${item.status} ${item.label}`, true);
|
|
501
|
+
}
|
|
378
502
|
return;
|
|
379
503
|
}
|
|
504
|
+
|
|
380
505
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return;
|
|
381
506
|
|
|
382
507
|
turns++;
|
|
@@ -384,33 +509,23 @@ async function runWorker(
|
|
|
384
509
|
modelError = event.message.errorMessage ?? modelError;
|
|
385
510
|
addUsage(usage, event.message.usage ?? unfinishedUsage);
|
|
386
511
|
unfinishedUsage = undefined;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
content: [{ type: "text", text: `${role}: ${label}` }],
|
|
396
|
-
details: {
|
|
397
|
-
role,
|
|
398
|
-
status: "running",
|
|
399
|
-
model: config.model,
|
|
400
|
-
thinking: config.thinking,
|
|
401
|
-
tools: config.tools,
|
|
402
|
-
turns,
|
|
403
|
-
durationMs: Date.now() - startedAt,
|
|
404
|
-
exitCode: null,
|
|
405
|
-
activity: [...activity],
|
|
406
|
-
},
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
if (turns >= config.maxTurns && stopReason === "toolUse") stopChild("turn_limit");
|
|
512
|
+
const completedText = (event.message.content ?? [])
|
|
513
|
+
.filter((part) => part.type === "text" && part.text)
|
|
514
|
+
.map((part) => part.text as string)
|
|
515
|
+
.join("\n")
|
|
516
|
+
.trim();
|
|
517
|
+
if (completedText) finalOutput = completedText;
|
|
518
|
+
streamingText.clear();
|
|
519
|
+
if (turns >= config.maxTurns && stopReason === "toolUse") requestCheckpoint("turn_limit");
|
|
412
520
|
};
|
|
413
521
|
|
|
522
|
+
// Keep long model and tool waits visibly alive even when the child emits no stream event.
|
|
523
|
+
const heartbeat = setInterval(() => {
|
|
524
|
+
const running = [...activeActivity.values()].map((item) => item.label).join("; ");
|
|
525
|
+
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
|
526
|
+
emitProgress(`${role}: ${running ? `running ${running}` : "working"} (${elapsedSeconds}s)`, true);
|
|
527
|
+
}, 1_000);
|
|
528
|
+
|
|
414
529
|
// Stream-decode UTF-8 before splitting complete JSONL records, retaining a trailing fragment.
|
|
415
530
|
child.stdout.on("data", (chunk) => {
|
|
416
531
|
stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true });
|
|
@@ -425,9 +540,13 @@ async function runWorker(
|
|
|
425
540
|
child.on("error", (error) => {
|
|
426
541
|
processError = error.message;
|
|
427
542
|
});
|
|
543
|
+
child.stdin.on("error", (error) => {
|
|
544
|
+
processError ??= error.message;
|
|
545
|
+
});
|
|
428
546
|
// Flush decoder state and charge at most one unfinished streamed assistant response.
|
|
429
547
|
child.on("close", (code) => {
|
|
430
548
|
settled = true;
|
|
549
|
+
clearInterval(heartbeat);
|
|
431
550
|
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
432
551
|
stdoutBuffer += stdoutDecoder.decode();
|
|
433
552
|
stderr = truncate(stderr + stderrDecoder.decode(), STDERR_LIMIT);
|
|
@@ -436,8 +555,18 @@ async function runWorker(
|
|
|
436
555
|
resolve(code);
|
|
437
556
|
});
|
|
438
557
|
|
|
558
|
+
// Start the RPC run after all stream handlers are ready.
|
|
559
|
+
child.stdin.write(
|
|
560
|
+
`${JSON.stringify({ id: "delegated-task", type: "prompt", message: `Delegated ${role} task:\n${task}` })}\n`,
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const checkpointLeadMs = (config.timeoutSeconds - config.checkpointGraceSeconds) * 1_000;
|
|
564
|
+
const checkpointTimer = setTimeout(() => requestCheckpoint("timeout"), checkpointLeadMs);
|
|
439
565
|
const timeout = setTimeout(() => stopChild("timeout"), config.timeoutSeconds * 1_000);
|
|
440
|
-
child.once("close", () =>
|
|
566
|
+
child.once("close", () => {
|
|
567
|
+
clearTimeout(checkpointTimer);
|
|
568
|
+
clearTimeout(timeout);
|
|
569
|
+
});
|
|
441
570
|
|
|
442
571
|
// Propagate cancellation from the parent Pi turn to the worker process.
|
|
443
572
|
if (signal) {
|
|
@@ -456,14 +585,23 @@ async function runWorker(
|
|
|
456
585
|
else if (stopReason && stopReason !== "stop") status = "incomplete";
|
|
457
586
|
else if (!finalOutput.trim()) status = "incomplete";
|
|
458
587
|
|
|
459
|
-
// Put failure context
|
|
460
|
-
const
|
|
588
|
+
// Put failure context and a synthetic checkpoint ahead of any partial worker report.
|
|
589
|
+
const streamedOutput = [...streamingText.entries()]
|
|
590
|
+
.sort(([left], [right]) => left - right)
|
|
591
|
+
.map(([, text]) => text)
|
|
592
|
+
.join("\n")
|
|
593
|
+
.trim();
|
|
594
|
+
const partialOutput = finalOutput.trim() || streamedOutput;
|
|
461
595
|
const stderrOutput = stderr.trim();
|
|
462
596
|
const statusDiagnostic =
|
|
463
597
|
status === "timeout"
|
|
464
|
-
?
|
|
598
|
+
? checkpointRequestedReason === "timeout" && exitCode === 0
|
|
599
|
+
? `Worker reached the time limit and returned a checkpoint during the ${config.checkpointGraceSeconds}-second grace period.`
|
|
600
|
+
: `Worker timed out after ${config.timeoutSeconds} seconds.`
|
|
465
601
|
: status === "turn_limit"
|
|
466
|
-
?
|
|
602
|
+
? checkpointRequestedReason === "turn_limit" && exitCode === 0
|
|
603
|
+
? `Worker reached the configured ${config.maxTurns}-turn limit and returned a checkpoint.`
|
|
604
|
+
: `Worker reached the configured ${config.maxTurns}-turn limit.`
|
|
467
605
|
: status === "aborted"
|
|
468
606
|
? "Worker was aborted."
|
|
469
607
|
: status === "process_error"
|
|
@@ -473,10 +611,22 @@ async function runWorker(
|
|
|
473
611
|
: `Worker did not complete${stopReason ? ` (stop reason: ${stopReason})` : ""}.`;
|
|
474
612
|
const diagnostic =
|
|
475
613
|
status === "completed" ? undefined : modelError || processError || stderrOutput || statusDiagnostic;
|
|
476
|
-
const
|
|
477
|
-
status
|
|
478
|
-
?
|
|
479
|
-
:
|
|
614
|
+
const checkpoint: WorkerCheckpoint | undefined =
|
|
615
|
+
status === "completed"
|
|
616
|
+
? undefined
|
|
617
|
+
: {
|
|
618
|
+
reason: status,
|
|
619
|
+
task,
|
|
620
|
+
lastAssistantText: partialOutput || undefined,
|
|
621
|
+
completedActivities: activity
|
|
622
|
+
.filter((item) => item.status !== "running")
|
|
623
|
+
.map((item) => `${item.status}: ${item.label}`),
|
|
624
|
+
activeActivities: activity.filter((item) => item.status === "running").map((item) => item.label),
|
|
625
|
+
filesystemEffectsMayRemain: activity.length > 0,
|
|
626
|
+
};
|
|
627
|
+
const output = checkpoint
|
|
628
|
+
? `${diagnostic ?? statusDiagnostic}\n\n${formatCheckpoint(checkpoint)}`
|
|
629
|
+
: partialOutput || stderrOutput || "The worker produced no final report.";
|
|
480
630
|
return {
|
|
481
631
|
output: truncate(output, RESULT_LIMIT),
|
|
482
632
|
details: {
|
|
@@ -484,7 +634,7 @@ async function runWorker(
|
|
|
484
634
|
status,
|
|
485
635
|
model: config.model,
|
|
486
636
|
thinking: config.thinking,
|
|
487
|
-
tools
|
|
637
|
+
tools,
|
|
488
638
|
turns,
|
|
489
639
|
durationMs: Date.now() - startedAt,
|
|
490
640
|
exitCode,
|
|
@@ -492,6 +642,7 @@ async function runWorker(
|
|
|
492
642
|
diagnostic,
|
|
493
643
|
stderr: stderrOutput || undefined,
|
|
494
644
|
activity,
|
|
645
|
+
checkpoint,
|
|
495
646
|
},
|
|
496
647
|
usage,
|
|
497
648
|
};
|
|
@@ -512,6 +663,20 @@ function validateModel(config: WorkerConfig, ctx: ExtensionToolContext): void {
|
|
|
512
663
|
}
|
|
513
664
|
}
|
|
514
665
|
|
|
666
|
+
/** Validate the exact per-delegation allowlist against tools active for the orchestrator. */
|
|
667
|
+
function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
|
|
668
|
+
const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
|
|
669
|
+
const forbidden = new Set(["delegate_mechanical", "delegate_engineering"]);
|
|
670
|
+
const recursive = tools.filter((tool) => forbidden.has(tool));
|
|
671
|
+
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated recursively: ${recursive.join(", ")}`);
|
|
672
|
+
const active = new Set(pi.getActiveTools());
|
|
673
|
+
const unavailable = tools.filter((tool) => !active.has(tool));
|
|
674
|
+
if (unavailable.length > 0) {
|
|
675
|
+
throw new Error(`Requested worker tools are not active for the main agent: ${unavailable.join(", ")}`);
|
|
676
|
+
}
|
|
677
|
+
return tools;
|
|
678
|
+
}
|
|
679
|
+
|
|
515
680
|
/** Register one role-specific public tool over the shared worker executor. */
|
|
516
681
|
function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
517
682
|
const toolName = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
|
|
@@ -524,7 +689,9 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
524
689
|
role === "mechanic"
|
|
525
690
|
? "Use delegate_mechanical for bounded multi-step execution with precise desired results; give it a self-contained contract and integrate its report."
|
|
526
691
|
: "Use delegate_engineering broadly for self-contained investigation or production work; give it the outcome, constraints, and acceptance conditions, then integrate its report.",
|
|
692
|
+
"Select the smallest exact worker tool allowlist from tools active for the main agent; tool availability does not establish scope or authorization.",
|
|
527
693
|
"Delegation results are worker reports, not proof of correctness; inspect material changes and retain responsibility for the final answer.",
|
|
694
|
+
"When a worker returns a checkpoint, inspect current state and decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation. Continuation is never automatic.",
|
|
528
695
|
],
|
|
529
696
|
parameters: DelegateParams,
|
|
530
697
|
exposure: "model-only",
|
|
@@ -538,9 +705,11 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
538
705
|
|
|
539
706
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
540
707
|
let config: WorkerConfig;
|
|
708
|
+
let tools: string[];
|
|
541
709
|
try {
|
|
542
710
|
config = loadConfig()[role];
|
|
543
711
|
validateModel(config, ctx);
|
|
712
|
+
tools = validateDelegatedTools(params.tools, pi);
|
|
544
713
|
} catch (error) {
|
|
545
714
|
const message = error instanceof Error ? error.message : String(error);
|
|
546
715
|
return {
|
|
@@ -550,7 +719,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
550
719
|
status: "configuration_error" as const,
|
|
551
720
|
model: "",
|
|
552
721
|
thinking: "off" as const,
|
|
553
|
-
tools:
|
|
722
|
+
tools: params.tools,
|
|
554
723
|
turns: 0,
|
|
555
724
|
durationMs: 0,
|
|
556
725
|
exitCode: null,
|
|
@@ -561,7 +730,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
561
730
|
};
|
|
562
731
|
}
|
|
563
732
|
|
|
564
|
-
const result = await runWorker(role, params.task, config, ctx.cwd, signal, onUpdate);
|
|
733
|
+
const result = await runWorker(role, params.task, config, tools, ctx.cwd, signal, onUpdate);
|
|
565
734
|
const heading = `${role} worker ${result.details.status} via ${result.details.model} (${result.details.thinking})`;
|
|
566
735
|
return {
|
|
567
736
|
content: [{ type: "text", text: `${heading}\n\n${result.output}` }],
|