@pi-archimedes/subagent 2.10.4 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- 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 +472 -0
- package/src/expanded.ts +202 -82
- 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 +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -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.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- 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/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
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
THINKING_LEVELS,
|
|
4
|
+
THINKING_LEVELS_PI_VERSION,
|
|
5
|
+
isThinkingLevel,
|
|
6
|
+
resolveThinkingLevel,
|
|
7
|
+
formatUnknownThinkingWarning,
|
|
8
|
+
} from "./thinking-validation.js";
|
|
9
|
+
|
|
10
|
+
// pi's CLI keeps its own list of accepted `--thinking` levels and, for anything
|
|
11
|
+
// outside it, warns and drops the flag rather than failing
|
|
12
|
+
// (`pi/dist/cli/args.js`: `VALID_THINKING_LEVELS`). A child configured with a
|
|
13
|
+
// level pi does not take therefore runs at the default while its config claims
|
|
14
|
+
// otherwise, and in `--mode json` the warning lands in stderr — which the stream
|
|
15
|
+
// reader only ever shows for a failed run.
|
|
16
|
+
//
|
|
17
|
+
// This file pins the copy of that list the suite checks against. The host's own
|
|
18
|
+
// validator cannot be consulted: pi's `exports` map publishes only `"."` and
|
|
19
|
+
// `"./rpc-entry"`, so `dist/cli/args.js` is not importable, and the root entry
|
|
20
|
+
// re-exports neither `isValidThinkingLevel` nor `VALID_THINKING_LEVELS` nor the
|
|
21
|
+
// `ThinkingLevel` type that could pin it at compile time. What the test can do is
|
|
22
|
+
// pin the list and the pi version it claims to describe, so drift is a
|
|
23
|
+
// deliberately-edited constant rather than an invisible difference — and the
|
|
24
|
+
// consequence of not editing it stays a warning plus pi's default, never a
|
|
25
|
+
// dispatch refused over a word the host has since learned.
|
|
26
|
+
|
|
27
|
+
describe("isThinkingLevel", () => {
|
|
28
|
+
it("accepts exactly pi's seven levels", () => {
|
|
29
|
+
expect([...THINKING_LEVELS]).toEqual([
|
|
30
|
+
"off",
|
|
31
|
+
"minimal",
|
|
32
|
+
"low",
|
|
33
|
+
"medium",
|
|
34
|
+
"high",
|
|
35
|
+
"xhigh",
|
|
36
|
+
"max",
|
|
37
|
+
]);
|
|
38
|
+
for (const level of THINKING_LEVELS) expect(isThinkingLevel(level)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("refines the value it accepts", () => {
|
|
42
|
+
const value: unknown = "high";
|
|
43
|
+
if (isThinkingLevel(value)) {
|
|
44
|
+
const asString: string = value;
|
|
45
|
+
expect(asString).toBe("high");
|
|
46
|
+
} else {
|
|
47
|
+
throw new Error("high should be a thinking level");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses a level pi warns about and drops", () => {
|
|
52
|
+
for (const bad of ["ultra", "Medium", "medium ", "highest", "auto", "0"]) {
|
|
53
|
+
expect(isThinkingLevel(bad), bad).toBe(false);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("refuses anything that is not a string at all", () => {
|
|
58
|
+
for (const bad of [undefined, null, 2, true, ["low"]]) {
|
|
59
|
+
expect(isThinkingLevel(bad)).toBe(false);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("resolveThinkingLevel", () => {
|
|
65
|
+
it("passes a valid level through", () => {
|
|
66
|
+
expect(resolveThinkingLevel("xhigh")).toEqual({ level: "xhigh", warning: undefined });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("says nothing when the config set no level", () => {
|
|
70
|
+
expect(resolveThinkingLevel(undefined)).toEqual({ level: undefined, warning: undefined });
|
|
71
|
+
expect(resolveThinkingLevel("")).toEqual({ level: undefined, warning: undefined });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("withholds a level pi would drop, and says so", () => {
|
|
75
|
+
const resolved = resolveThinkingLevel("ultra");
|
|
76
|
+
expect(resolved.level).toBeUndefined();
|
|
77
|
+
expect(resolved.warning).toContain('"ultra"');
|
|
78
|
+
expect(resolved.warning).toMatch(/default thinking level/i);
|
|
79
|
+
for (const level of THINKING_LEVELS) {
|
|
80
|
+
expect(resolved.warning).toContain(level);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("formatUnknownThinkingWarning", () => {
|
|
86
|
+
it("names the value rather than describing the problem in the abstract", () => {
|
|
87
|
+
expect(formatUnknownThinkingWarning("turbo")).toContain("turbo");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("says which pi the level list came from", () => {
|
|
91
|
+
// The list is a copy of the host's, so the sentence that quotes it has to
|
|
92
|
+
// say whose list it is — a reader on a newer pi should question it rather
|
|
93
|
+
// than trust it.
|
|
94
|
+
expect(formatUnknownThinkingWarning("turbo")).toContain(THINKING_LEVELS_PI_VERSION);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ── The `thinking:` field: what the child will actually accept ─────────────
|
|
2
|
+
//
|
|
3
|
+
// A child is spawned with `--thinking <level>`, and pi's CLI parser keeps its
|
|
4
|
+
// own list (`pi/dist/cli/args.js`: `VALID_THINKING_LEVELS`, `isValidThinkingLevel`).
|
|
5
|
+
// A level outside that list is **not** an error there: the parser pushes a
|
|
6
|
+
// `warning` diagnostic and drops the flag, so the child runs at the default
|
|
7
|
+
// thinking level while the config says something else. In `--mode json` that
|
|
8
|
+
// warning goes to stderr, and `stream.ts` only reads stderr for a *failed* run —
|
|
9
|
+
// so a successful dispatch reported "thinking: medium" having run nothing of the
|
|
10
|
+
// sort. Greptile found it; a live 1.2.0 child confirmed the mechanism.
|
|
11
|
+
//
|
|
12
|
+
// So the suite drops the same value first and says so out loud, at dispatch,
|
|
13
|
+
// where the user is watching. Not a refusal: the run still happens, at exactly
|
|
14
|
+
// the level pi would have used anyway — the difference is that it is now named
|
|
15
|
+
// instead of swallowed. That is also what keeps this safe across a pi upgrade
|
|
16
|
+
// that *adds* a level: the worst case is a warning and the default, never a
|
|
17
|
+
// dispatch refused over a word the host has since learned.
|
|
18
|
+
//
|
|
19
|
+
// The list is duplicated here because there is no other way to read it. pi
|
|
20
|
+
// exports only `"."` and `"./rpc-entry"` (its package `exports` map), so
|
|
21
|
+
// `@earendil-works/pi-coding-agent/dist/cli/args.js` — where `isValidThinkingLevel`
|
|
22
|
+
// and `VALID_THINKING_LEVELS` live — is not importable; the root entry does not
|
|
23
|
+
// re-export either, nor the `ThinkingLevel` type that would pin the set at
|
|
24
|
+
// compile time (that lives in `@earendil-works/pi-agent-core`, which this package
|
|
25
|
+
// cannot resolve). So the copy below is checked by test, not by the host, and the
|
|
26
|
+
// warning names the pi version it was read from — `thinking-validation.test.ts`
|
|
27
|
+
// pins both. `model-validation.ts` (ADR 0008) already keeps a host-owned set the
|
|
28
|
+
// same way for model ids, obtaining those through the `modelRegistry` handle
|
|
29
|
+
// instead, which for levels does not exist.
|
|
30
|
+
|
|
31
|
+
/** pi's own `VALID_THINKING_LEVELS` were read from, and the version they were
|
|
32
|
+
* read from. The warning quotes the version so a reader on a newer host knows
|
|
33
|
+
* which list they are being handed. */
|
|
34
|
+
export const THINKING_LEVELS_PI_VERSION = "1.2.0";
|
|
35
|
+
|
|
36
|
+
/** pi 1.2.0's `VALID_THINKING_LEVELS`, in pi's order. */
|
|
37
|
+
export const THINKING_LEVELS = [
|
|
38
|
+
"off",
|
|
39
|
+
"minimal",
|
|
40
|
+
"low",
|
|
41
|
+
"medium",
|
|
42
|
+
"high",
|
|
43
|
+
"xhigh",
|
|
44
|
+
"max",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/** Whether `level` is a thinking level pi's own `--thinking` accepts. */
|
|
48
|
+
export function isThinkingLevel(level: unknown): level is string {
|
|
49
|
+
return typeof level === "string" && (THINKING_LEVELS as readonly string[]).includes(level);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The thinking level to put on the child's command line, and what to say about
|
|
54
|
+
* the one we are not putting there.
|
|
55
|
+
*
|
|
56
|
+
* `level` is what to pass (undefined = no `--thinking` flag at all); `warning`
|
|
57
|
+
* is set when the config asked for something pi would have dropped anyway.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveThinkingLevel(
|
|
60
|
+
configured: string | undefined,
|
|
61
|
+
): { level: string | undefined; warning: string | undefined } {
|
|
62
|
+
if (configured === undefined || configured === "") return { level: undefined, warning: undefined };
|
|
63
|
+
if (isThinkingLevel(configured)) return { level: configured, warning: undefined };
|
|
64
|
+
return { level: undefined, warning: formatUnknownThinkingWarning(configured) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The warning for a level pi's `--thinking` does not take.
|
|
69
|
+
*
|
|
70
|
+
* It names the value, the accepted set, and what the child is running at
|
|
71
|
+
* instead — "the flag was dropped" is only useful if you learn the consequence.
|
|
72
|
+
*/
|
|
73
|
+
export function formatUnknownThinkingWarning(configured: string): string {
|
|
74
|
+
return [
|
|
75
|
+
`Subagent asked for thinking level "${configured}", which pi's --thinking does not take.`,
|
|
76
|
+
`The run proceeds at pi's default thinking level. Valid levels (pi ${THINKING_LEVELS_PI_VERSION}): ${THINKING_LEVELS.join(", ")}.`,
|
|
77
|
+
].join(" ");
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 11 item 3 — `subagent` is for the model, not for scripts, and
|
|
3
|
+
* `list_agents` stays reachable both ways.
|
|
4
|
+
*
|
|
5
|
+
* `exposure: "model-only"` is measured, not assumed (see the same file in
|
|
6
|
+
* `ask` for the method and the pi source references). For `subagent` the cost is
|
|
7
|
+
* concrete and it was observed before the change: a codemode script called
|
|
8
|
+
* `tools.subagent({ task: "Reply with exactly the single word: child" })` and
|
|
9
|
+
* got the whole run back as a structured value —
|
|
10
|
+
* `{"mode":"single","results":[{... "outcome":"completed","finalOutput":"child" ...}]}`.
|
|
11
|
+
* After the change that same script line reports
|
|
12
|
+
* `tools.subagent does not exist` and `subagent` is absent from `ALL_TOOLS`.
|
|
13
|
+
*
|
|
14
|
+
* The plan takes the exposure anyway (`docs/roadmap/pi-1-x-alignment.md`,
|
|
15
|
+
* Task 11 item 3): a subagent dispatch is an orchestration step that pi's own
|
|
16
|
+
* docs file under "Use it for orchestrating or interactive tools", the model
|
|
17
|
+
* still drives it, and the nested-call path it does remove is one where the
|
|
18
|
+
* child's row renders inside a script row the user cannot expand. Say so to the
|
|
19
|
+
* maintainer rather than discover it later.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, vi } from "vitest";
|
|
23
|
+
import { registerSubagent } from "./index.js";
|
|
24
|
+
|
|
25
|
+
type RegisteredTool = { name: string; exposure?: string; annotations?: Record<string, boolean> };
|
|
26
|
+
|
|
27
|
+
function register(): Record<string, RegisteredTool> {
|
|
28
|
+
const tools: RegisteredTool[] = [];
|
|
29
|
+
registerSubagent({
|
|
30
|
+
registerTool: (t: unknown) => tools.push(t as RegisteredTool),
|
|
31
|
+
on: () => {},
|
|
32
|
+
getAllTools: () => [],
|
|
33
|
+
events: { emit: () => {}, on: () => () => {} },
|
|
34
|
+
} as never);
|
|
35
|
+
return Object.fromEntries(tools.map((t) => [t.name, t]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("subagent exposure", () => {
|
|
39
|
+
it("subagent is model-only: declared to the model, never callable from a script", () => {
|
|
40
|
+
expect(register().subagent!.exposure).toBe("model-only");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("list_agents stays direct — a script that dispatches nothing still reads the catalogue", () => {
|
|
44
|
+
expect(register().list_agents!.exposure ?? "direct").toBe("direct");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("neither tool carries annotations", () => {
|
|
48
|
+
const tools = register();
|
|
49
|
+
expect(tools.subagent!.annotations).toBeUndefined();
|
|
50
|
+
expect(tools.list_agents!.annotations).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("nothing in this package is codemode or deferred exposure (ADR 0029)", () => {
|
|
54
|
+
for (const tool of Object.values(register())) {
|
|
55
|
+
expect(["codemode", "deferred", "hidden"]).not.toContain(tool.exposure);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|