@pi-archimedes/subagent 2.10.4 → 2.11.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 +1 -1
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +61 -0
- package/src/expanded.ts +96 -80
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +486 -0
- package/src/index.ts +168 -8
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +195 -0
- package/src/row-block.ts +186 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.ts +85 -0
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
package/src/spawn.ts
CHANGED
|
@@ -4,7 +4,9 @@ import * as net from "node:net";
|
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Events, emitOn } from "@pi-archimedes/core/events";
|
|
9
|
+
import { resolveThinkingLevel } from "./thinking-validation.js";
|
|
8
10
|
import type { AgentConfig } from "./agents.js";
|
|
9
11
|
|
|
10
12
|
export interface SpawnOptions {
|
|
@@ -14,6 +16,8 @@ export interface SpawnOptions {
|
|
|
14
16
|
cwd: string | undefined;
|
|
15
17
|
signal: AbortSignal | undefined;
|
|
16
18
|
agent: AgentConfig | undefined;
|
|
19
|
+
/** The parent's host handle (`pi.events`) — see `startAskSocketServer`. */
|
|
20
|
+
events: EventBus;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -73,25 +77,32 @@ function resolvePiBinary(): string {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/**
|
|
76
|
-
* Start a Unix socket server that bridges the child's ask tool to the parent
|
|
80
|
+
* Start a Unix socket server that bridges the child's ask tool to the parent's
|
|
81
|
+
* event handle.
|
|
77
82
|
*
|
|
78
83
|
* The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
|
|
79
84
|
* { type: "ask_request", requestId, questions, toolCallId } as a JSON line
|
|
80
85
|
* and waits for:
|
|
81
86
|
* { type: "ask_response", requestId, cancelled, results } as a JSON line
|
|
82
87
|
*
|
|
83
|
-
* We forward the request onto the
|
|
88
|
+
* We forward the request onto the handle (ASK_REQUEST, carrying toolCallId), the
|
|
84
89
|
* ask package (or the bridge, in bridge mode) answers it, then emits
|
|
85
|
-
* ASK_RESPONSE on the
|
|
90
|
+
* ASK_RESPONSE on the same handle, and we write it back to the socket connection.
|
|
86
91
|
*
|
|
87
92
|
* When the socket closes (child exit, or the child's own 5-min timeout →
|
|
88
93
|
* socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
|
|
89
94
|
* We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
|
|
90
95
|
* (no double-decrement of its refcount).
|
|
91
96
|
*
|
|
97
|
+
* @param events the parent's `pi.events`, injected: this is a plain function, not
|
|
98
|
+
* an extension, so the handle has to come from the caller that has
|
|
99
|
+
* one. The subscription it opens is closed by the returned
|
|
100
|
+
* `cleanup()` — a per-spawn subscription with a per-spawn
|
|
101
|
+
* teardown, which is why it cannot accumulate across runs.
|
|
102
|
+
*
|
|
92
103
|
* Returns the socket path and a cleanup function.
|
|
93
104
|
*/
|
|
94
|
-
export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
105
|
+
export function startAskSocketServer(agentName: string, events: EventBus): { socketPath: string; cleanup: () => void } {
|
|
95
106
|
// Use named pipes on Windows, Unix domain sockets elsewhere.
|
|
96
107
|
// Linux socket path limit is 108 chars — keep it short.
|
|
97
108
|
const id = randomUUID().slice(0, 8);
|
|
@@ -103,8 +114,8 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
|
|
|
103
114
|
// Map of pending ask requests: requestId → write-back callback
|
|
104
115
|
const pending = new Map<string, (response: unknown) => void>();
|
|
105
116
|
|
|
106
|
-
// Listen for ASK_RESPONSE
|
|
107
|
-
const unsubResponse =
|
|
117
|
+
// Listen for ASK_RESPONSE on the handle and route back to the waiting socket conn
|
|
118
|
+
const unsubResponse = events.on(Events.ASK_RESPONSE, (payload: unknown) => {
|
|
108
119
|
const data = payload as {
|
|
109
120
|
requestId: string;
|
|
110
121
|
cancelled: boolean;
|
|
@@ -152,9 +163,9 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
|
|
|
152
163
|
}
|
|
153
164
|
});
|
|
154
165
|
socketRequestIds.add(msg.requestId);
|
|
155
|
-
// Forward
|
|
166
|
+
// Forward onto the handle — the ask package (or the bridge, in bridge mode)
|
|
156
167
|
// answers it; toolCallId rides along for Client correlation
|
|
157
|
-
|
|
168
|
+
emitOn(events, Events.ASK_REQUEST, {
|
|
158
169
|
source: `subagent:${agentName}`,
|
|
159
170
|
requestId: msg.requestId,
|
|
160
171
|
questions: msg.questions,
|
|
@@ -174,7 +185,7 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
|
|
|
174
185
|
socket.on("close", () => {
|
|
175
186
|
for (const rid of socketRequestIds) {
|
|
176
187
|
if (pending.has(rid)) {
|
|
177
|
-
|
|
188
|
+
emitOn(events, Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
|
|
178
189
|
pending.delete(rid);
|
|
179
190
|
}
|
|
180
191
|
}
|
|
@@ -208,7 +219,7 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
208
219
|
const agentName = options.agent?.name ?? "general";
|
|
209
220
|
|
|
210
221
|
// Start ask bridge socket before spawning so the env var is ready
|
|
211
|
-
const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName);
|
|
222
|
+
const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName, options.events);
|
|
212
223
|
|
|
213
224
|
// Build CLI args
|
|
214
225
|
const args: string[] = ["--mode", "json", "--no-session", "-p"];
|
|
@@ -219,14 +230,35 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
219
230
|
args.push("--model", model);
|
|
220
231
|
}
|
|
221
232
|
|
|
222
|
-
// Thinking level from agent config
|
|
223
|
-
|
|
224
|
-
|
|
233
|
+
// Thinking level from agent config. An unknown level is NOT passed on: pi
|
|
234
|
+
// warns and drops the flag itself, which leaves the child on the default
|
|
235
|
+
// level with the explanation in a stderr nobody reads for a successful run.
|
|
236
|
+
// `index.ts` reports the same drop where the user can see it.
|
|
237
|
+
const thinking = resolveThinkingLevel(options.agent?.thinking);
|
|
238
|
+
if (thinking.level !== undefined) {
|
|
239
|
+
args.push("--thinking", thinking.level);
|
|
225
240
|
}
|
|
226
241
|
|
|
227
|
-
// Tool allowlist from agent config
|
|
228
|
-
|
|
229
|
-
|
|
242
|
+
// Tool allowlist from agent config.
|
|
243
|
+
//
|
|
244
|
+
// pi 1.0.4 changed what a non-empty `--tools` does to MCP tools. It no longer
|
|
245
|
+
// filters them: unless the list is empty or names an `mcp__` entry, they stay
|
|
246
|
+
// REGISTERED — reachable through codemode and `tool_search` — even though they
|
|
247
|
+
// are not *declared* to the model (`agent-session.js`: `_allowlistFiltersMcp`
|
|
248
|
+
// false → `_isAllowedTool` keeps them, `_isActivatable` withholds the
|
|
249
|
+
// declaration unless `tool_search` can load them). So the child's tool box is
|
|
250
|
+
// wider than the three names in the config, which is the hole ADR 0028 closed:
|
|
251
|
+
// `--no-mcp` stops the servers connecting at all, so there is nothing to reach.
|
|
252
|
+
// A config that wants the servers asks with `mcp: true` — verified against a
|
|
253
|
+
// live 1.2.0 child: with `--tools read` it sees exactly `read` either way, so
|
|
254
|
+
// the opt-in's observable effect today is registration, not a declared list
|
|
255
|
+
// (see ADR 0028's verification note for the open follow-up).
|
|
256
|
+
const tools = options.agent?.tools ?? [];
|
|
257
|
+
if (tools.length > 0) {
|
|
258
|
+
args.push("--tools", tools.join(","));
|
|
259
|
+
if (options.agent?.mcp !== true) {
|
|
260
|
+
args.push("--no-mcp");
|
|
261
|
+
}
|
|
230
262
|
}
|
|
231
263
|
|
|
232
264
|
// Always exclude the subagent tool itself to prevent infinite recursion
|
package/src/stream.test.ts
CHANGED
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { PassThrough } from "node:stream";
|
|
3
3
|
import type { ChildProcess } from "node:child_process";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
4
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
5
|
+
import { Events } from "@pi-archimedes/core/events";
|
|
6
6
|
import { streamEvents, type StreamCallbacks } from "./stream.js";
|
|
7
7
|
|
|
8
8
|
type FakeChild = ChildProcess & { stdout: PassThrough; stderr: PassThrough };
|
|
9
9
|
|
|
10
|
+
/** Stands in for the host's `createEventBus()` — the emit/on/unsubscribe contract `pi.events` implements. */
|
|
11
|
+
function fakeBus() {
|
|
12
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
13
|
+
return {
|
|
14
|
+
emit(channel: string, data: unknown): void {
|
|
15
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
16
|
+
},
|
|
17
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
18
|
+
const subs = listeners.get(channel) ?? [];
|
|
19
|
+
subs.push(handler);
|
|
20
|
+
listeners.set(channel, subs);
|
|
21
|
+
return () => {
|
|
22
|
+
const current = listeners.get(channel) ?? [];
|
|
23
|
+
const idx = current.indexOf(handler);
|
|
24
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fresh handle per test: the emitter is handed this, the assertions listen on it. */
|
|
31
|
+
let bus: ReturnType<typeof fakeBus>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
bus = fakeBus();
|
|
35
|
+
});
|
|
36
|
+
|
|
10
37
|
function fakeChild(): FakeChild {
|
|
11
38
|
const child = new EventEmitter() as FakeChild;
|
|
12
39
|
Object.assign(child, {
|
|
@@ -19,10 +46,10 @@ function fakeChild(): FakeChild {
|
|
|
19
46
|
|
|
20
47
|
async function finishWith(
|
|
21
48
|
events: Array<Record<string, unknown>>,
|
|
22
|
-
callbacks: StreamCallbacks = {},
|
|
49
|
+
callbacks: Partial<StreamCallbacks> = {},
|
|
23
50
|
) {
|
|
24
51
|
const child = fakeChild();
|
|
25
|
-
const result = streamEvents(child, callbacks);
|
|
52
|
+
const result = streamEvents(child, { events: bus, ...callbacks } as StreamCallbacks);
|
|
26
53
|
for (const event of events) {
|
|
27
54
|
child.stdout.write(`${JSON.stringify(event)}\n`);
|
|
28
55
|
}
|
|
@@ -65,10 +92,10 @@ describe("streamEvents todo mirroring", () => {
|
|
|
65
92
|
it("mirrors the child-accepted state from result.details.todos", async () => {
|
|
66
93
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
67
94
|
const clears: Array<{ source: string }> = [];
|
|
68
|
-
const off =
|
|
95
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
69
96
|
updates.push(p as (typeof updates)[number]),
|
|
70
97
|
);
|
|
71
|
-
const offClear =
|
|
98
|
+
const offClear = bus.on(Events.TODOS_CLEAR, (p: unknown) =>
|
|
72
99
|
clears.push(p as { source: string }),
|
|
73
100
|
);
|
|
74
101
|
|
|
@@ -99,15 +126,16 @@ describe("streamEvents todo mirroring", () => {
|
|
|
99
126
|
expect(updates[0]!.todos).toEqual([
|
|
100
127
|
{ content: "Fix auth", status: "pending" },
|
|
101
128
|
]);
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
129
|
+
// Each test owns its handle now, so `clears` can only hold this test's
|
|
130
|
+
// emit — but the assertion stays source-scoped, because that is the
|
|
131
|
+
// contract a reader needs to see: the clear names the child it belongs to.
|
|
132
|
+
expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
|
|
105
133
|
expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
|
|
106
134
|
});
|
|
107
135
|
|
|
108
136
|
it("mirrors nothing when the tool returned a validation rejection (result.isError)", async () => {
|
|
109
137
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
110
|
-
const off =
|
|
138
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
111
139
|
updates.push(p as (typeof updates)[number]),
|
|
112
140
|
);
|
|
113
141
|
|
|
@@ -139,7 +167,7 @@ describe("streamEvents todo mirroring", () => {
|
|
|
139
167
|
|
|
140
168
|
it("mirrors nothing when the harness reported an error (event.isError)", async () => {
|
|
141
169
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
142
|
-
const off =
|
|
170
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
143
171
|
updates.push(p as (typeof updates)[number]),
|
|
144
172
|
);
|
|
145
173
|
|
|
@@ -166,7 +194,7 @@ describe("streamEvents todo mirroring", () => {
|
|
|
166
194
|
|
|
167
195
|
it("falls back to normalized raw args when the accepted result has no details", async () => {
|
|
168
196
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
169
|
-
const off =
|
|
197
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
170
198
|
updates.push(p as (typeof updates)[number]),
|
|
171
199
|
);
|
|
172
200
|
|
|
@@ -194,7 +222,7 @@ describe("streamEvents todo mirroring", () => {
|
|
|
194
222
|
|
|
195
223
|
it("stows raw args under the alias key todo_list (result has no details)", async () => {
|
|
196
224
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
197
|
-
const off =
|
|
225
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
198
226
|
updates.push(p as (typeof updates)[number]),
|
|
199
227
|
);
|
|
200
228
|
|
|
@@ -230,7 +258,7 @@ describe("streamEvents todo mirroring", () => {
|
|
|
230
258
|
|
|
231
259
|
it("does not crash or emit when the end event has no matching start", async () => {
|
|
232
260
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
233
|
-
const off =
|
|
261
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
234
262
|
updates.push(p as (typeof updates)[number]),
|
|
235
263
|
);
|
|
236
264
|
|
|
@@ -254,7 +282,7 @@ describe("streamEvents todo mirroring", () => {
|
|
|
254
282
|
|
|
255
283
|
it("does not emit for non-todo tools with todoList-shaped args", async () => {
|
|
256
284
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
257
|
-
const off =
|
|
285
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
258
286
|
updates.push(p as (typeof updates)[number]),
|
|
259
287
|
);
|
|
260
288
|
|
|
@@ -293,10 +321,10 @@ describe("streamEvents todo mirroring", () => {
|
|
|
293
321
|
it("uses a unique per-child source when the child provides a session id", async () => {
|
|
294
322
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
295
323
|
const clears: Array<{ source: string }> = [];
|
|
296
|
-
const off =
|
|
324
|
+
const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
297
325
|
updates.push(p as (typeof updates)[number]),
|
|
298
326
|
);
|
|
299
|
-
const offClear =
|
|
327
|
+
const offClear = bus.on(Events.TODOS_CLEAR, (p: unknown) =>
|
|
300
328
|
clears.push(p as { source: string }),
|
|
301
329
|
);
|
|
302
330
|
|
|
@@ -345,3 +373,173 @@ describe("streamEvents todo mirroring", () => {
|
|
|
345
373
|
});
|
|
346
374
|
});
|
|
347
375
|
|
|
376
|
+
|
|
377
|
+
// ── Run outcomes (CONTEXT.md: *Run outcome*; ADR 0028) ──────────────────────
|
|
378
|
+
//
|
|
379
|
+
// pi 1.2.0 made print mode exit 1 when the final response hit the output token
|
|
380
|
+
// limit, and text-mode truncation is reported that way — so an exit code alone
|
|
381
|
+
// can no longer tell a useful-but-cut-off answer from a crash. The child's own
|
|
382
|
+
// `stopReason` and 1.1.0's `agent_settled.aborted` are what carry the
|
|
383
|
+
// distinction; `agent_end` fires for a cancelled run too and says nothing about
|
|
384
|
+
// either. Cost has to survive all three outcomes.
|
|
385
|
+
|
|
386
|
+
const USAGE = {
|
|
387
|
+
input: 100,
|
|
388
|
+
output: 40,
|
|
389
|
+
cacheRead: 7,
|
|
390
|
+
cacheWrite: 2,
|
|
391
|
+
totalTokens: 149,
|
|
392
|
+
cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
function assistantMessage(stopReason: string): Record<string, unknown> {
|
|
396
|
+
return {
|
|
397
|
+
type: "message_end",
|
|
398
|
+
message: {
|
|
399
|
+
role: "assistant",
|
|
400
|
+
content: [{ type: "text", text: "partial answer" }],
|
|
401
|
+
model: "anthropic/claude-opus-4.5",
|
|
402
|
+
stopReason,
|
|
403
|
+
usage: USAGE,
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function finishChild(
|
|
409
|
+
events: Array<Record<string, unknown>>,
|
|
410
|
+
close: [code: number | null, signal?: NodeJS.Signals | null],
|
|
411
|
+
stderr = "",
|
|
412
|
+
) {
|
|
413
|
+
const child = fakeChild();
|
|
414
|
+
const result = streamEvents(child, { agent: "reviewer", task: "t", events: bus });
|
|
415
|
+
if (stderr) child.stderr.write(stderr);
|
|
416
|
+
for (const event of events) {
|
|
417
|
+
child.stdout.write(`${JSON.stringify(event)}\n`);
|
|
418
|
+
}
|
|
419
|
+
child.emit("close", close[0], close[1] ?? null);
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
describe("streamEvents run outcomes", () => {
|
|
424
|
+
it("a clean finish is `completed`", async () => {
|
|
425
|
+
const result = await finishChild(
|
|
426
|
+
[
|
|
427
|
+
assistantMessage("stop"),
|
|
428
|
+
{ type: "agent_end", messages: [] },
|
|
429
|
+
{ type: "agent_settled", aborted: false },
|
|
430
|
+
],
|
|
431
|
+
[0],
|
|
432
|
+
);
|
|
433
|
+
expect(result.outcome).toBe("completed");
|
|
434
|
+
expect(result.error).toBeUndefined();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("exit 1 with stopReason `length` is `truncated`, not a failure, and keeps the cost", async () => {
|
|
438
|
+
const result = await finishChild(
|
|
439
|
+
[
|
|
440
|
+
assistantMessage("length"),
|
|
441
|
+
{ type: "agent_end", messages: [] },
|
|
442
|
+
{ type: "agent_settled", aborted: false },
|
|
443
|
+
],
|
|
444
|
+
[1],
|
|
445
|
+
"Response was truncated: the model reached its output token limit.\n",
|
|
446
|
+
);
|
|
447
|
+
expect(result.outcome).toBe("truncated");
|
|
448
|
+
expect(result.error).toBeUndefined();
|
|
449
|
+
expect(result.usage.cost).toBeCloseTo(0.003);
|
|
450
|
+
expect(result.usage.input).toBe(100);
|
|
451
|
+
expect(result.usage.output).toBe(40);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("`--mode json` exits 0 for the same truncation, and it is still `truncated`", async () => {
|
|
455
|
+
// Verified on 1.2.0: the exit-1 mapping lives in print mode's *text* branch,
|
|
456
|
+
// so a json child reports truncation only through stopReason.
|
|
457
|
+
const result = await finishChild(
|
|
458
|
+
[assistantMessage("length"), { type: "agent_settled", aborted: false }],
|
|
459
|
+
[0],
|
|
460
|
+
);
|
|
461
|
+
expect(result.outcome).toBe("truncated");
|
|
462
|
+
expect(result.error).toBeUndefined();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("`agent_settled.aborted: true` is `aborted`, whatever the exit code says", async () => {
|
|
466
|
+
// The child's print mode exits 143 when it is SIGTERM'd on the way out.
|
|
467
|
+
const result = await finishChild(
|
|
468
|
+
[assistantMessage("stop"), { type: "agent_settled", aborted: true }],
|
|
469
|
+
[143],
|
|
470
|
+
);
|
|
471
|
+
expect(result.outcome).toBe("aborted");
|
|
472
|
+
expect(result.error).toBeUndefined();
|
|
473
|
+
expect(result.usage.cost).toBeCloseTo(0.003);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("a child killed by the abort signal before it settled is `aborted`", async () => {
|
|
477
|
+
const result = await finishChild([assistantMessage("stop")], [null, "SIGTERM"]);
|
|
478
|
+
expect(result.outcome).toBe("aborted");
|
|
479
|
+
expect(result.error).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("a real SIGTERM'd child exits 143 with no signal and no settle event, and is still `aborted`", async () => {
|
|
483
|
+
// The shape a live 1.2.0 child actually produces when the parent's abort
|
|
484
|
+
// handler SIGTERMs it: print mode catches the signal and `process.exit(143)`,
|
|
485
|
+
// so there is no `signal`, and the process dies before it can emit
|
|
486
|
+
// `agent_settled`. Whatever it had printed to stderr (here, an unrelated
|
|
487
|
+
// provider warning) must not be promoted to an error.
|
|
488
|
+
const result = await finishChild([assistantMessage("stop")], [143], "LiteLLM discovery: warning");
|
|
489
|
+
expect(result.outcome).toBe("aborted");
|
|
490
|
+
expect(result.error).toBeUndefined();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("an assistant message aborted at the provider is `aborted` even with no settle event", async () => {
|
|
494
|
+
const result = await finishChild([assistantMessage("aborted")], [0]);
|
|
495
|
+
expect(result.outcome).toBe("aborted");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("a pre/during-run error stays a failure: no outcome, error from stderr", async () => {
|
|
499
|
+
const result = await finishChild(
|
|
500
|
+
[assistantMessage("error"), { type: "agent_settled", aborted: false }],
|
|
501
|
+
[1],
|
|
502
|
+
"401 unauthorized",
|
|
503
|
+
);
|
|
504
|
+
expect(result.outcome).toBeUndefined();
|
|
505
|
+
expect(result.error).toContain("401");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("a nonzero exit with no run behind it stays a failure", async () => {
|
|
509
|
+
const result = await finishChild([], [2], "unknown flag --nope");
|
|
510
|
+
expect(result.outcome).toBeUndefined();
|
|
511
|
+
expect(result.error).toContain("unknown flag");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("a failed run always says why, even on a clean exit with empty stderr", async () => {
|
|
515
|
+
// The shape a json child produces when the provider errors mid-run: the last
|
|
516
|
+
// assistant message ends `stopReason: "error"`, the exit code stays 0 (the
|
|
517
|
+
// code mapping is print mode's text branch) and stderr is empty. Without a
|
|
518
|
+
// synthesised explanation this is indistinguishable from a successful run.
|
|
519
|
+
const result = await finishChild(
|
|
520
|
+
[assistantMessage("error"), { type: "agent_settled", aborted: false }],
|
|
521
|
+
[0],
|
|
522
|
+
);
|
|
523
|
+
expect(result.outcome).toBeUndefined();
|
|
524
|
+
expect(result.error).toMatch(/stopReason|failed/i);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("a crash on an unexpected signal is a failure, not a cancellation", async () => {
|
|
528
|
+
const result = await finishChild([assistantMessage("stop")], [null, "SIGSEGV"]);
|
|
529
|
+
expect(result.outcome).toBeUndefined();
|
|
530
|
+
expect(result.error).toBeTruthy();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("the final progress carries the outcome, so the live view can show it", async () => {
|
|
534
|
+
const seen: Array<string | undefined> = [];
|
|
535
|
+
const result = await finishChild(
|
|
536
|
+
[assistantMessage("length"), { type: "agent_settled", aborted: false }],
|
|
537
|
+
[0],
|
|
538
|
+
).then((r) => {
|
|
539
|
+
seen.push(r.progress?.outcome);
|
|
540
|
+
return r;
|
|
541
|
+
});
|
|
542
|
+
expect(seen).toEqual(["truncated"]);
|
|
543
|
+
expect(result.progress?.status).toBe("completed");
|
|
544
|
+
});
|
|
545
|
+
});
|
package/src/stream.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import type { ChildProcess } from "node:child_process";
|
|
3
|
+
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
|
|
4
|
-
import {
|
|
5
|
+
import { Events, emitOn } from "@pi-archimedes/core/events";
|
|
5
6
|
import { normalizeTodoItems, pickTodoListRaw } from "@pi-archimedes/todo/prepare-args";
|
|
6
7
|
import {
|
|
7
8
|
type JsonEvent,
|
|
@@ -11,11 +12,19 @@ import {
|
|
|
11
12
|
handleMessageEnd,
|
|
12
13
|
handleAgentEnd,
|
|
13
14
|
} from "./handlers.js";
|
|
15
|
+
import { decideRunOutcome } from "./run-outcome.js";
|
|
14
16
|
|
|
15
17
|
export interface StreamCallbacks {
|
|
16
18
|
agent?: string;
|
|
17
19
|
task?: string;
|
|
18
20
|
onProgress?: (progress: SubagentProgress) => void;
|
|
21
|
+
/**
|
|
22
|
+
* The parent's host handle (`pi.events`), injected. `streamEvents` is a plain
|
|
23
|
+
* function over a child process, so it has no `pi` of its own; the child's
|
|
24
|
+
* todo state is announced on the handle the caller is holding, which is the
|
|
25
|
+
* same one the todo package subscribed on in this process.
|
|
26
|
+
*/
|
|
27
|
+
events: EventBus;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
|
@@ -27,7 +36,7 @@ export interface StreamCallbacks {
|
|
|
27
36
|
*/
|
|
28
37
|
export function streamEvents(
|
|
29
38
|
child: ChildProcess,
|
|
30
|
-
callbacks: StreamCallbacks
|
|
39
|
+
callbacks: StreamCallbacks,
|
|
31
40
|
): Promise<SubagentResult> {
|
|
32
41
|
return new Promise((resolve, reject) => {
|
|
33
42
|
const startTime = Date.now();
|
|
@@ -175,7 +184,7 @@ export function streamEvents(
|
|
|
175
184
|
const detailsTodos = (result?.details as Record<string, unknown> | undefined)?.todos;
|
|
176
185
|
const todos = normalizeTodoItems(Array.isArray(detailsTodos) ? detailsTodos : stowed);
|
|
177
186
|
if (todos) {
|
|
178
|
-
|
|
187
|
+
emitOn(callbacks.events, Events.TODOS_UPDATE, { source: subagentSource, todos });
|
|
179
188
|
}
|
|
180
189
|
}
|
|
181
190
|
}
|
|
@@ -198,21 +207,48 @@ export function streamEvents(
|
|
|
198
207
|
handleAgentEnd(state, event);
|
|
199
208
|
break;
|
|
200
209
|
}
|
|
210
|
+
case "agent_settled": {
|
|
211
|
+
// pi 1.1.0: the child's own statement that its run is over, and
|
|
212
|
+
// whether it was cancelled. `agent_end` fires for a cancelled run too
|
|
213
|
+
// (and can fire while a continuation is still pending), so this — not
|
|
214
|
+
// `agent_end` — is what an outcome is read from.
|
|
215
|
+
if (event.aborted === true) state.settledAborted = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
201
218
|
// Ignore: agent_start, message_start, message_update, turn_end, tool_execution_update
|
|
202
219
|
}
|
|
203
220
|
});
|
|
204
221
|
|
|
205
222
|
// Handle process exit
|
|
206
|
-
child.on("close", (code) => {
|
|
223
|
+
child.on("close", (code, signal) => {
|
|
207
224
|
clearStartupTimer();
|
|
208
225
|
clearInterval(heartbeat);
|
|
209
226
|
const durationMs = Date.now() - startTime;
|
|
210
227
|
const exitCode = code ?? 1;
|
|
211
228
|
|
|
212
|
-
|
|
213
|
-
|
|
229
|
+
const outcome = decideRunOutcome({
|
|
230
|
+
settledAborted: state.settledAborted === true,
|
|
231
|
+
stopReason: state.lastStopReason,
|
|
232
|
+
// The raw code, not `exitCode`: `null` is the difference between "it was
|
|
233
|
+
// killed" and "it failed", and the outcome table reads that difference.
|
|
234
|
+
exitCode: code,
|
|
235
|
+
signal: signal ?? undefined,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// stderr explains a failure and nothing else. A truncated run's stderr is
|
|
239
|
+
// pi's own "Response was truncated" notice and an aborted one's is noise —
|
|
240
|
+
// promoting either to `error` would report a real answer as a broken one.
|
|
241
|
+
if (outcome === "failed" && !error) {
|
|
214
242
|
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
215
243
|
if (stderr) error = stderr;
|
|
244
|
+
else if (code === null) error = `subagent terminated by ${signal ?? "a signal"}`;
|
|
245
|
+
// A json child that errored leaves nothing behind to read: the exit code
|
|
246
|
+
// stays 0 (the stopReason→code mapping is print mode's text branch) and
|
|
247
|
+
// stderr is clean, which used to make a failed run byte-identical to a
|
|
248
|
+
// successful one at this boundary. A failed run always says why.
|
|
249
|
+
else if (state.lastStopReason === "error")
|
|
250
|
+
error = 'subagent failed: the child\u2019s last response ended with stopReason "error"';
|
|
251
|
+
else error = `subagent failed with exit code ${code ?? "null"}`;
|
|
216
252
|
}
|
|
217
253
|
|
|
218
254
|
const result: SubagentResult = {
|
|
@@ -220,6 +256,7 @@ export function streamEvents(
|
|
|
220
256
|
task: callbacks.task ?? "",
|
|
221
257
|
...(state.childSessionId ? { childSessionId: state.childSessionId } : {}),
|
|
222
258
|
exitCode,
|
|
259
|
+
...(outcome === "failed" ? {} : { outcome }),
|
|
223
260
|
model: state.model,
|
|
224
261
|
usage: {
|
|
225
262
|
input: state.totalInput,
|
|
@@ -233,7 +270,8 @@ export function streamEvents(
|
|
|
233
270
|
error,
|
|
234
271
|
progress: {
|
|
235
272
|
...buildProgress(),
|
|
236
|
-
status:
|
|
273
|
+
status: outcome === "failed" ? "failed" : "completed",
|
|
274
|
+
...(outcome === "failed" ? {} : { outcome }),
|
|
237
275
|
durationMs,
|
|
238
276
|
},
|
|
239
277
|
progressSummary: {
|
|
@@ -245,8 +283,8 @@ export function streamEvents(
|
|
|
245
283
|
|
|
246
284
|
callbacks.onProgress?.(result.progress!);
|
|
247
285
|
|
|
248
|
-
// Clear subagent todos from the
|
|
249
|
-
|
|
286
|
+
// Clear subagent todos from the handle on exit
|
|
287
|
+
emitOn(callbacks.events, Events.TODOS_CLEAR, {
|
|
250
288
|
source: subagentSource,
|
|
251
289
|
});
|
|
252
290
|
|