@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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+
1
3
  export interface SubagentUsage {
2
4
  input: number;
3
5
  output: number;
@@ -13,9 +15,27 @@ export interface SubagentToolCall {
13
15
  error: boolean;
14
16
  }
15
17
 
18
+ /**
19
+ * The terminal state of a finished run — the suite's fixed vocabulary for it
20
+ * (`CONTEXT.md`: *Run outcome*).
21
+ *
22
+ * `completed` finished within limits; `truncated` finished but hit the output
23
+ * token limit, so the answer is partial; `aborted` was cancelled. None of them
24
+ * is a *failed* run — a pre/during-run error carries `error` and **no** outcome.
25
+ */
26
+ export type RunOutcome = "completed" | "truncated" | "aborted";
27
+
16
28
  export interface SubagentProgress {
17
29
  agent: string;
30
+ /**
31
+ * The coarse view axis (still running / finished / broke). The three-valued
32
+ * *Run outcome* lives in `outcome`, which is what the renderers show when a
33
+ * run has finished — a truncated run is not `failed`, and an aborted one is
34
+ * not `completed`, whatever the row's status has to say for layout.
35
+ */
18
36
  status: "running" | "completed" | "failed";
37
+ /** Set once the run reaches a terminal state; absent for a failed run. */
38
+ outcome?: RunOutcome;
19
39
  task: string;
20
40
  currentTool: string | undefined;
21
41
  currentToolArgs: string | undefined;
@@ -43,7 +63,29 @@ export interface SubagentResult {
43
63
  /** Logical Pi session UUID for this spawned subagent process. */
44
64
  childSessionId?: string;
45
65
  exitCode: number;
66
+ /**
67
+ * How the run ended, when it ended. Absent when the run **failed** (an error
68
+ * before or during the run), which is the case `error` and `isError` describe.
69
+ */
70
+ outcome?: RunOutcome;
46
71
  usage: SubagentUsage;
72
+ /**
73
+ * The run happened somewhere that already books its usage, so **this** session
74
+ * must not also report it on the tool result.
75
+ *
76
+ * Set for a bridge-dispatched run: the desktop runs it as a session of its own
77
+ * on its worker runtime, and that session's total is the number its Client
78
+ * shows. `usage` still carries the metrics for display in `details`, but
79
+ * `toNativeUsage`/`sumNativeUsage` leave it out, because pi folds a tool
80
+ * result's `usage` into *this* session's totals — publishing it here would add
81
+ * the same money to the parent's view of a run the parent did not perform
82
+ * (ADR 0028: cost is counted once, which is why the `COST_UPDATE` bus route was
83
+ * deleted rather than left alone).
84
+ *
85
+ * Absent on the fork path, where the child is a bare `pi --mode json` process
86
+ * that reports nothing to anyone: the tool result is the only cost report.
87
+ */
88
+ usageAccountedElsewhere?: boolean;
47
89
  model: string | undefined;
48
90
  finalOutput: string | undefined;
49
91
  error: string | undefined;
@@ -54,9 +96,49 @@ export interface SubagentResult {
54
96
  export interface SubagentToolResult {
55
97
  content: Array<{ type: "text"; text: string }>;
56
98
  details: SubagentDetails;
99
+ /**
100
+ * The machine-readable result for programmatic callers, matching
101
+ * `SUBAGENT_OUTPUT_SCHEMA`. Success results only: a failed run reports
102
+ * `isError` and carries none, so a codemode script keeps rejecting with the
103
+ * text it rejects with today.
104
+ *
105
+ * A type alias, not an interface: `AgentToolResult.structuredContent` is
106
+ * `JsonValue`, and TypeScript will not match an interface against its index
107
+ * signature.
108
+ */
109
+ structuredContent?: {
110
+ mode: "single" | "parallel";
111
+ results: SubagentRunReport[];
112
+ };
113
+ /**
114
+ * The runs' summed usage, in pi's own shape (see `usage.ts`).
115
+ *
116
+ * This is the cost report: pi persists it onto the toolResult message and
117
+ * folds it into `/session`, which is why the `COST_UPDATE` bus route to the
118
+ * footer was deleted instead of merely abandoned — the footer reads the same
119
+ * entries and would have counted the money twice.
120
+ */
121
+ usage?: Usage;
57
122
  isError?: boolean;
58
123
  }
59
124
 
125
+ /**
126
+ * The machine-readable report for one run — `SubagentResult` without the live
127
+ * progress blob (see `subagentStructuredContent()` in `tool-schema.ts`).
128
+ */
129
+ export type SubagentRunReport = {
130
+ agent: string;
131
+ task: string;
132
+ exitCode: number;
133
+ childSessionId?: string;
134
+ outcome?: RunOutcome;
135
+ usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number };
136
+ model?: string;
137
+ finalOutput?: string;
138
+ error?: string;
139
+ progressSummary?: { toolCount: number; tokens: number; durationMs: number };
140
+ };
141
+
60
142
  export interface SubagentDetails {
61
143
  mode: "single" | "parallel";
62
144
  results: SubagentResult[];
@@ -73,6 +155,10 @@ export interface StreamState {
73
155
  totalCacheRead: number;
74
156
  totalCacheWrite: number;
75
157
  totalCost: number;
158
+ /** `stopReason` of the last assistant message seen (`length` ⇒ truncated). */
159
+ lastStopReason?: string;
160
+ /** `agent_settled.aborted` — pi 1.1.0's own account of a cancelled run. */
161
+ settledAborted?: boolean;
76
162
  currentTool: string | undefined;
77
163
  currentToolArgs: string | undefined;
78
164
  currentToolStartedAt: number | undefined;
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { toNativeUsage, sumNativeUsage } from "./usage.js";
3
+ import type { SubagentResult, SubagentUsage } from "./types.js";
4
+
5
+ // pi folds a tool result's `usage` into the session totals, and it reads exactly
6
+ // these fields (`addUsageToTotals`: input/output/cacheRead/cacheWrite and
7
+ // `cost.total`). A field it has to dereference and finds missing is a NaN in the
8
+ // session's cost, so the shape is the contract these tests pin.
9
+
10
+ function usage(overrides: Partial<SubagentUsage> = {}): SubagentUsage {
11
+ return {
12
+ input: 4000,
13
+ output: 1500,
14
+ cacheRead: 512,
15
+ cacheWrite: 8,
16
+ cost: 0.2,
17
+ turns: 3,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function result(withUsage: SubagentUsage): SubagentResult {
23
+ return {
24
+ agent: "reviewer",
25
+ task: "t",
26
+ exitCode: 0,
27
+ usage: withUsage,
28
+ model: undefined,
29
+ finalOutput: "x",
30
+ error: undefined,
31
+ progress: undefined,
32
+ progressSummary: undefined,
33
+ };
34
+ }
35
+
36
+ describe("toNativeUsage", () => {
37
+ it("carries every field pi dereferences, with the cost under cost.total", () => {
38
+ const native = toNativeUsage(usage());
39
+ expect(native.input).toBe(4000);
40
+ expect(native.output).toBe(1500);
41
+ expect(native.cacheRead).toBe(512);
42
+ expect(native.cacheWrite).toBe(8);
43
+ expect(native.cost.total).toBeCloseTo(0.2);
44
+ expect(typeof native.cost.input).toBe("number");
45
+ expect(typeof native.cost.output).toBe("number");
46
+ expect(typeof native.cost.cacheRead).toBe("number");
47
+ expect(typeof native.cost.cacheWrite).toBe("number");
48
+ });
49
+
50
+ it("totalTokens is the sum of the four token kinds, as pi defines it", () => {
51
+ expect(toNativeUsage(usage()).totalTokens).toBe(4000 + 1500 + 512 + 8);
52
+ });
53
+
54
+ it("a zero run produces zeros, not undefined", () => {
55
+ const native = toNativeUsage(usage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }));
56
+ expect(native.totalTokens).toBe(0);
57
+ expect(native.cost.total).toBe(0);
58
+ });
59
+ });
60
+
61
+ describe("sumNativeUsage", () => {
62
+ it("adds a parallel batch into one usage (one tool call, one toolResult message)", () => {
63
+ const total = sumNativeUsage([result(usage()), result(usage())])!;
64
+ expect(total.input).toBe(8000);
65
+ expect(total.output).toBe(3000);
66
+ expect(total.cost.total).toBeCloseTo(0.4);
67
+ expect(total.totalTokens).toBe(2 * (4000 + 1500 + 512 + 8));
68
+ });
69
+
70
+ it("returns undefined when there is nothing to report", () => {
71
+ expect(sumNativeUsage([])).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe("sumNativeUsage: which runs this session is allowed to report", () => {
76
+ // A bridge-dispatched run is a session of the desktop's own, and that session
77
+ // reports its usage to its own Client. Folding the same metrics into THIS
78
+ // session's totals (which is what a toolResult `usage` does) is the
79
+ // double-count ADR 0028 deleted the `COST_UPDATE` route to avoid — one tool
80
+ // call performed elsewhere must not cost twice.
81
+ const elsewhere = (over: Partial<SubagentUsage> = {}): SubagentResult => ({
82
+ ...result(usage(over)),
83
+ usageAccountedElsewhere: true,
84
+ });
85
+
86
+ it("leaves out a run whose usage the runtime that performed it already books", () => {
87
+ const total = sumNativeUsage([result(usage()), elsewhere()])!;
88
+ expect(total.input).toBe(4000);
89
+ expect(total.cost.total).toBeCloseTo(0.2);
90
+ });
91
+
92
+ it("reports nothing at all when every run in the batch was performed elsewhere", () => {
93
+ expect(sumNativeUsage([elsewhere(), elsewhere()])).toBeUndefined();
94
+ });
95
+ });
package/src/usage.ts ADDED
@@ -0,0 +1,87 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { SubagentResult, SubagentUsage } from "./types.js";
3
+
4
+ // ── Native cost reporting (ADR 0028) ────────────────────────────────────────
5
+ //
6
+ // A tool result may carry `usage`, and pi does the accounting from there: the
7
+ // field is copied onto the toolResult message it persists
8
+ // (`pi-agent-core/dist/agent-loop.js`, `createToolResultMessage` — `usage:
9
+ // finalized.result.usage`) and `AgentSession.getSessionStats()` folds every
10
+ // toolResult message's usage into the session totals
11
+ // (`pi-coding-agent/dist/core/agent-session.js`, the `message.role ===
12
+ // "toolResult"` branch). So a subagent that returns `usage` is counted by
13
+ // `/session`, by the footer's own session scan, and by anything else that reads
14
+ // session stats — with no side channel involved.
15
+ //
16
+ // That is why the old bus route (`subagent/src/cost.ts` → `COST_UPDATE` →
17
+ // `footer/CostAccumulator`) had to go rather than just stop being used: the
18
+ // footer already counts toolResult usage, so leaving the accumulator in place
19
+ // would add the same money twice.
20
+
21
+ /**
22
+ * The child's summed usage in pi's own `Usage` shape.
23
+ *
24
+ * The child's JSON stream reports one cost **total** per message, which is what
25
+ * pi adds up (`addUsageToTotals` reads `usage.cost.total`); the per-kind split is
26
+ * not observable from out here, so it is reported as zero rather than guessed at.
27
+ * `totalTokens` follows pi's own definition — input + output + both cache kinds.
28
+ */
29
+ export function toNativeUsage(usage: SubagentUsage): Usage {
30
+ return {
31
+ input: usage.input,
32
+ output: usage.output,
33
+ cacheRead: usage.cacheRead,
34
+ cacheWrite: usage.cacheWrite,
35
+ totalTokens: usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
36
+ cost: {
37
+ input: 0,
38
+ output: 0,
39
+ cacheRead: 0,
40
+ cacheWrite: 0,
41
+ total: usage.cost,
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Sum the usage of several runs into the one `usage` a single tool result can
48
+ * carry — a parallel batch is one tool call, so it has one toolResult message.
49
+ *
50
+ * Runs performed by another runtime (`usageAccountedElsewhere`, i.e. everything
51
+ * the bridge dispatched) are left out: their session reports its own usage, and
52
+ * this sum lands in *this* session's totals. A batch of nothing but such runs
53
+ * reports nothing at all.
54
+ */
55
+ export function sumNativeUsage(results: SubagentResult[]): Usage | undefined {
56
+ const counted = results.filter(
57
+ (r) => r.usage !== undefined && r.usageAccountedElsewhere !== true,
58
+ );
59
+ if (counted.length === 0) return undefined;
60
+ const zero: Usage = {
61
+ input: 0,
62
+ output: 0,
63
+ cacheRead: 0,
64
+ cacheWrite: 0,
65
+ totalTokens: 0,
66
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
67
+ };
68
+ return counted.reduce<Usage>((total, result) => {
69
+ const next = toNativeUsage(result.usage);
70
+ return {
71
+ input: total.input + next.input,
72
+ output: total.output + next.output,
73
+ cacheRead: total.cacheRead + next.cacheRead,
74
+ cacheWrite: total.cacheWrite + next.cacheWrite,
75
+ totalTokens: total.totalTokens + next.totalTokens,
76
+ cost: {
77
+ input: total.cost.input + next.cost.input,
78
+ output: total.cost.output + next.cost.output,
79
+ cacheRead: total.cost.cacheRead + next.cost.cacheRead,
80
+ cacheWrite: total.cost.cacheWrite + next.cost.cacheWrite,
81
+ total: total.cost.total + next.cost.total,
82
+ },
83
+ };
84
+ },
85
+ zero,
86
+ );
87
+ }
package/src/cost.test.ts DELETED
@@ -1,62 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { Events } from "@pi-archimedes/core/bus";
3
-
4
- // ── mock bus ────────────────────────────────────────────────────────────────
5
-
6
- let mockEmit: ReturnType<typeof vi.fn>;
7
-
8
- vi.mock("@pi-archimedes/core/bus", async (importOriginal) => {
9
- const actual = await importOriginal() as typeof import("@pi-archimedes/core/bus");
10
- return {
11
- ...actual,
12
- getBus: () => ({
13
- emit: mockEmit,
14
- }),
15
- };
16
- });
17
-
18
- const { emitCostUpdate } = await import("./cost.js");
19
-
20
- describe("emitCostUpdate", () => {
21
- beforeEach(() => {
22
- mockEmit = vi.fn();
23
- });
24
-
25
- it("emits COST_UPDATE event with correct source prefix", () => {
26
- emitCostUpdate("my-agent", { inputTokens: 100, outputTokens: 50 });
27
-
28
- expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
29
- source: "subagent:my-agent",
30
- inputTokens: 100,
31
- outputTokens: 50,
32
- });
33
- });
34
-
35
- it("passes through all usage fields", () => {
36
- emitCostUpdate("test-agent", {
37
- inputTokens: 1000,
38
- outputTokens: 500,
39
- cacheReadTokens: 200,
40
- cacheWriteTokens: 100,
41
- cost: 0.05,
42
- });
43
-
44
- expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
45
- source: "subagent:test-agent",
46
- inputTokens: 1000,
47
- outputTokens: 500,
48
- cacheReadTokens: 200,
49
- cacheWriteTokens: 100,
50
- cost: 0.05,
51
- });
52
- });
53
-
54
- it("works with partial usage fields", () => {
55
- emitCostUpdate("minimal", { cost: 0.01 });
56
-
57
- expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
58
- source: "subagent:minimal",
59
- cost: 0.01,
60
- });
61
- });
62
- });
package/src/cost.ts DELETED
@@ -1,8 +0,0 @@
1
- import { getBus, Events } from "@pi-archimedes/core/bus";
2
-
3
- export function emitCostUpdate(agent: string, usage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; cost?: number }): void {
4
- getBus().emit(Events.COST_UPDATE, {
5
- source: `subagent:${agent}`,
6
- ...usage,
7
- });
8
- }