@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
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 11 item 2 — `subagent` as a programmatic tool.
|
|
3
|
+
*
|
|
4
|
+
* `details` is the renderer's payload: per-run reports plus the live-progress
|
|
5
|
+
* blob (`progress`, with the child's whole streamed output and its tool-call
|
|
6
|
+
* history). A script wants the run reports, not the scrollback, so the
|
|
7
|
+
* structured content is the report set with `progress` left out — see the
|
|
8
|
+
* comment at the declaration in `index.ts`.
|
|
9
|
+
*
|
|
10
|
+
* `subagent` already reports failure as `isError` rather than throwing (ADR
|
|
11
|
+
* 0028), and that shape is unchanged here; what is new is that the failure
|
|
12
|
+
* results carry no structured content, so a script keeps rejecting with the
|
|
13
|
+
* text it rejects with today.
|
|
14
|
+
*
|
|
15
|
+
* `./agents.js` and `./execute.js` are mocked (as `index.test.ts` does) so the
|
|
16
|
+
* assertions are about the result contract rather than a real child process.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
20
|
+
import { Value } from "typebox/value";
|
|
21
|
+
|
|
22
|
+
const discoverAgents = vi.hoisted(() => vi.fn());
|
|
23
|
+
const executeSubagent = vi.hoisted(() => vi.fn());
|
|
24
|
+
const executeParallel = vi.hoisted(() => vi.fn());
|
|
25
|
+
|
|
26
|
+
vi.mock("./agents.js", async (importOriginal) => ({
|
|
27
|
+
...((await importOriginal()) as Record<string, unknown>),
|
|
28
|
+
discoverAgents,
|
|
29
|
+
}));
|
|
30
|
+
vi.mock("./execute.js", () => ({ executeSubagent, executeParallel }));
|
|
31
|
+
|
|
32
|
+
const { registerSubagent } = await import("./index.js");
|
|
33
|
+
const { formatSingleResult, formatParallelResults } = await import("./format.js");
|
|
34
|
+
|
|
35
|
+
type RegisteredTool = {
|
|
36
|
+
name: string;
|
|
37
|
+
outputSchema?: unknown;
|
|
38
|
+
execute: (
|
|
39
|
+
id: string,
|
|
40
|
+
params: unknown,
|
|
41
|
+
signal: AbortSignal | undefined,
|
|
42
|
+
onUpdate: unknown,
|
|
43
|
+
ctx: unknown,
|
|
44
|
+
) => Promise<{
|
|
45
|
+
content: Array<{ type: "text"; text: string }>;
|
|
46
|
+
details: unknown;
|
|
47
|
+
structuredContent?: unknown;
|
|
48
|
+
isError?: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function subagent(): RegisteredTool {
|
|
53
|
+
const tools: RegisteredTool[] = [];
|
|
54
|
+
registerSubagent({
|
|
55
|
+
registerTool: (t: unknown) => tools.push(t as RegisteredTool),
|
|
56
|
+
on: () => {},
|
|
57
|
+
getAllTools: () => [{ name: "read" }, { name: "bash" }],
|
|
58
|
+
events: { emit: () => {}, on: () => () => {} },
|
|
59
|
+
} as never);
|
|
60
|
+
const tool = tools.find((t) => t.name === "subagent");
|
|
61
|
+
if (!tool) throw new Error("subagent tool was not registered");
|
|
62
|
+
return tool;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeCtx(overrides: Record<string, unknown> = {}) {
|
|
66
|
+
return {
|
|
67
|
+
cwd: "/tmp",
|
|
68
|
+
model: undefined,
|
|
69
|
+
modelRegistry: { getAll: () => [], getAvailable: () => [] },
|
|
70
|
+
hasUI: true,
|
|
71
|
+
ui: { notify: vi.fn() },
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A completed run, with the live-progress blob a real stream would attach. */
|
|
77
|
+
const okResult = {
|
|
78
|
+
agent: "reviewer",
|
|
79
|
+
task: "review it",
|
|
80
|
+
childSessionId: "child-uuid",
|
|
81
|
+
exitCode: 0,
|
|
82
|
+
outcome: "completed" as const,
|
|
83
|
+
usage: { input: 10, output: 5, cacheRead: 1, cacheWrite: 2, cost: 0.01, turns: 1 },
|
|
84
|
+
model: "anthropic/claude-opus-4.5",
|
|
85
|
+
finalOutput: "all good",
|
|
86
|
+
error: undefined,
|
|
87
|
+
progress: {
|
|
88
|
+
agent: "reviewer",
|
|
89
|
+
status: "completed" as const,
|
|
90
|
+
outcome: "completed" as const,
|
|
91
|
+
task: "review it",
|
|
92
|
+
currentTool: undefined,
|
|
93
|
+
currentToolArgs: undefined,
|
|
94
|
+
currentToolStartedAt: undefined,
|
|
95
|
+
toolCount: 1,
|
|
96
|
+
inputTokens: 10,
|
|
97
|
+
outputTokens: 5,
|
|
98
|
+
tokens: 15,
|
|
99
|
+
cost: 0.01,
|
|
100
|
+
durationMs: 10,
|
|
101
|
+
error: undefined,
|
|
102
|
+
model: "anthropic/claude-opus-4.5",
|
|
103
|
+
output: "the child's entire scrollback, which a script does not need twice",
|
|
104
|
+
recentOutput: ["x"],
|
|
105
|
+
toolCalls: [{ name: "read", argsPreview: "file", error: false }],
|
|
106
|
+
},
|
|
107
|
+
progressSummary: { toolCount: 1, tokens: 15, durationMs: 10 },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function agentConfig(overrides: Record<string, unknown> = {}) {
|
|
111
|
+
return {
|
|
112
|
+
name: "reviewer",
|
|
113
|
+
description: "d",
|
|
114
|
+
systemPrompt: "p",
|
|
115
|
+
source: "user",
|
|
116
|
+
filePath: "/tmp/agents/reviewer.md",
|
|
117
|
+
...overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function check(tool: RegisteredTool, value: unknown): boolean {
|
|
122
|
+
expect(tool.outputSchema, "subagent declares no outputSchema").toBeDefined();
|
|
123
|
+
return Value.Check(tool.outputSchema as never, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
discoverAgents.mockReset();
|
|
128
|
+
executeSubagent.mockReset();
|
|
129
|
+
executeParallel.mockReset();
|
|
130
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
131
|
+
executeSubagent.mockResolvedValue(okResult);
|
|
132
|
+
executeParallel.mockResolvedValue([okResult]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("subagent outputSchema", () => {
|
|
136
|
+
it("declares one, describing mode + results", () => {
|
|
137
|
+
const tool = subagent();
|
|
138
|
+
expect(tool.outputSchema).toBeDefined();
|
|
139
|
+
const props = (tool.outputSchema as { properties?: Record<string, unknown> }).properties ?? {};
|
|
140
|
+
expect(Object.keys(props).sort()).toEqual(["mode", "results"]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("subagent structuredContent — single mode", () => {
|
|
145
|
+
it("a completed run reports itself structurally, without the live-progress blob", async () => {
|
|
146
|
+
const tool = subagent();
|
|
147
|
+
const result = await tool.execute("c1", { agent: "reviewer", task: "review it" }, undefined, undefined, makeCtx());
|
|
148
|
+
|
|
149
|
+
expect(result.content[0]!.text).toBe(formatSingleResult(okResult as never));
|
|
150
|
+
expect(result.isError).toBe(false);
|
|
151
|
+
expect(result.structuredContent).toEqual({
|
|
152
|
+
mode: "single",
|
|
153
|
+
results: [
|
|
154
|
+
{
|
|
155
|
+
agent: "reviewer",
|
|
156
|
+
task: "review it",
|
|
157
|
+
childSessionId: "child-uuid",
|
|
158
|
+
exitCode: 0,
|
|
159
|
+
outcome: "completed",
|
|
160
|
+
usage: { input: 10, output: 5, cacheRead: 1, cacheWrite: 2, cost: 0.01, turns: 1 },
|
|
161
|
+
model: "anthropic/claude-opus-4.5",
|
|
162
|
+
finalOutput: "all good",
|
|
163
|
+
progressSummary: { toolCount: 1, tokens: 15, durationMs: 10 },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
168
|
+
// The scrollback stays in `details` for the renderer, out of the structured payload.
|
|
169
|
+
expect(JSON.stringify(result.structuredContent)).not.toContain("scrollback");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a failed run is isError with no structured content, content text unchanged", async () => {
|
|
173
|
+
const failed = { ...okResult, outcome: undefined, error: "child crashed", finalOutput: undefined };
|
|
174
|
+
executeSubagent.mockResolvedValueOnce(failed);
|
|
175
|
+
|
|
176
|
+
const tool = subagent();
|
|
177
|
+
const result = await tool.execute("c1", { agent: "reviewer", task: "review it" }, undefined, undefined, makeCtx());
|
|
178
|
+
|
|
179
|
+
expect(result.isError).toBe(true);
|
|
180
|
+
expect(result.content[0]!.text).toBe(formatSingleResult(failed as never));
|
|
181
|
+
expect("structuredContent" in result).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("an unknown agent stays isError with the same text, and carries no structured content", async () => {
|
|
185
|
+
const tool = subagent();
|
|
186
|
+
const result = await tool.execute("c1", { agent: "ghost", task: "t" }, undefined, undefined, makeCtx());
|
|
187
|
+
|
|
188
|
+
expect(result.isError).toBe(true);
|
|
189
|
+
expect(result.content[0]!.text).toContain('Unknown agent: "ghost"');
|
|
190
|
+
expect("structuredContent" in result).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("a call with no task stays isError with the same text", async () => {
|
|
194
|
+
const tool = subagent();
|
|
195
|
+
const result = await tool.execute("c1", {}, undefined, undefined, makeCtx());
|
|
196
|
+
|
|
197
|
+
expect(result.isError).toBe(true);
|
|
198
|
+
expect(result.content[0]!.text).toBe("Missing task parameter");
|
|
199
|
+
expect("structuredContent" in result).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("subagent structuredContent — parallel mode", () => {
|
|
204
|
+
it("reports every run, in task order", async () => {
|
|
205
|
+
// Both agents must exist, or the batch is rejected before anything runs.
|
|
206
|
+
discoverAgents.mockReturnValue([agentConfig(), agentConfig({ name: "explorer" })]);
|
|
207
|
+
const second = { ...okResult, agent: "explorer", task: "look around", finalOutput: "noted" };
|
|
208
|
+
executeParallel.mockResolvedValueOnce([okResult, second]);
|
|
209
|
+
|
|
210
|
+
const tool = subagent();
|
|
211
|
+
const result = await tool.execute(
|
|
212
|
+
"c2",
|
|
213
|
+
{ tasks: [{ agent: "reviewer", task: "review it" }, { agent: "explorer", task: "look around" }] },
|
|
214
|
+
undefined,
|
|
215
|
+
undefined,
|
|
216
|
+
makeCtx(),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result.content[0]!.text).toBe(formatParallelResults([okResult, second] as never));
|
|
220
|
+
const structured = result.structuredContent as { mode: string; results: Array<{ agent: string }> };
|
|
221
|
+
expect(structured.mode).toBe("parallel");
|
|
222
|
+
expect(structured.results.map((r) => r.agent)).toEqual(["reviewer", "explorer"]);
|
|
223
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("a rejected batch (unknown agent in one task) is isError, no structured content", async () => {
|
|
227
|
+
const tool = subagent();
|
|
228
|
+
const result = await tool.execute(
|
|
229
|
+
"c2",
|
|
230
|
+
{ tasks: [{ agent: "ghost", task: "t" }] },
|
|
231
|
+
undefined,
|
|
232
|
+
undefined,
|
|
233
|
+
makeCtx(),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(result.isError).toBe(true);
|
|
237
|
+
expect(result.content[0]!.text).toContain("Unknown agent(s)");
|
|
238
|
+
expect("structuredContent" in result).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The outcomes above tests never produced. `truncated` and `aborted` are declared
|
|
243
|
+
// in `SUBAGENT_OUTPUT_SCHEMA`'s outcome union, and ADR 0028 makes them ordinary
|
|
244
|
+
// successes (something was produced, so the run is not `isError`) — so their
|
|
245
|
+
// reports reach the structured payload, and the declaration has to hold for them
|
|
246
|
+
// too. Nothing here was previously exercised: the suite only ever handed back a
|
|
247
|
+
// `completed` run.
|
|
248
|
+
describe("subagent structuredContent — the other run outcomes", () => {
|
|
249
|
+
it.each(["truncated", "aborted"] as const)("%s run: not an error, carries a report, validates", async (outcome) => {
|
|
250
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
251
|
+
executeSubagent.mockResolvedValueOnce({ ...okResult, outcome, finalOutput: "a partial answer" });
|
|
252
|
+
|
|
253
|
+
const tool = subagent();
|
|
254
|
+
const result = await tool.execute("c3", { agent: "reviewer", task: "review it" }, undefined, undefined, makeCtx());
|
|
255
|
+
|
|
256
|
+
expect(result.isError).toBe(false);
|
|
257
|
+
const report = (result.structuredContent as { results: Array<Record<string, unknown>> }).results[0]!;
|
|
258
|
+
expect(report["outcome"]).toBe(outcome);
|
|
259
|
+
expect(report["error"]).toBeUndefined();
|
|
260
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a run reporting almost nothing (no session id, model, output or summary) validates", async () => {
|
|
264
|
+
discoverAgents.mockReturnValue([]);
|
|
265
|
+
// The bare shape `executeSubagent` produces for a config-less run that had
|
|
266
|
+
// nothing else to report: every optional field absent, not present-and-null.
|
|
267
|
+
executeSubagent.mockResolvedValueOnce({
|
|
268
|
+
agent: "subagent",
|
|
269
|
+
task: "t",
|
|
270
|
+
exitCode: 0,
|
|
271
|
+
usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 1 },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const tool = subagent();
|
|
275
|
+
const result = await tool.execute("c4", { task: "t" }, undefined, undefined, makeCtx());
|
|
276
|
+
|
|
277
|
+
expect(result.structuredContent).toEqual({
|
|
278
|
+
mode: "single",
|
|
279
|
+
results: [
|
|
280
|
+
{
|
|
281
|
+
agent: "subagent",
|
|
282
|
+
task: "t",
|
|
283
|
+
exitCode: 0,
|
|
284
|
+
usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 1 },
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a parallel batch with one failed run reports it per-run, inside a valid payload", async () => {
|
|
292
|
+
discoverAgents.mockReturnValue([agentConfig(), agentConfig({ name: "explorer" })]);
|
|
293
|
+
executeParallel.mockResolvedValueOnce([
|
|
294
|
+
okResult,
|
|
295
|
+
{ ...okResult, agent: "explorer", task: "look around", outcome: undefined, finalOutput: undefined, error: "child died" },
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
const tool = subagent();
|
|
299
|
+
const result = await tool.execute(
|
|
300
|
+
"c5",
|
|
301
|
+
{ tasks: [{ agent: "reviewer", task: "review it" }, { agent: "explorer", task: "look around" }] },
|
|
302
|
+
undefined,
|
|
303
|
+
undefined,
|
|
304
|
+
makeCtx(),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// The batch is not `isError` — it is, and was before these schemas existed:
|
|
308
|
+
// one run failing inside a batch is reported per run, in the text (`✗ explorer`)
|
|
309
|
+
// and in the report (`error`). `error` is declared optional for exactly this.
|
|
310
|
+
expect(result.isError).toBeUndefined();
|
|
311
|
+
expect(result.content[0]!.text).toContain("✗ explorer");
|
|
312
|
+
const reports = (result.structuredContent as { results: Array<Record<string, unknown>> }).results;
|
|
313
|
+
expect(reports[1]!["error"]).toBe("child died");
|
|
314
|
+
expect(reports[0]!["error"]).toBeUndefined();
|
|
315
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|