@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.
Files changed (47) hide show
  1. package/README.md +2 -2
  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 +472 -0
  16. package/src/expanded.ts +202 -82
  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 +566 -0
  25. package/src/index.ts +169 -16
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +244 -0
  28. package/src/row-block.ts +209 -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.test.ts +154 -0
  40. package/src/tool-schema.ts +106 -4
  41. package/src/tool-validation.test.ts +321 -0
  42. package/src/tool-validation.ts +262 -0
  43. package/src/types.ts +86 -0
  44. package/src/usage.test.ts +95 -0
  45. package/src/usage.ts +87 -0
  46. package/src/cost.test.ts +0 -62
  47. package/src/cost.ts +0 -8
package/src/index.ts CHANGED
@@ -1,15 +1,24 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, type TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
4
+ import { SUBAGENT_PARAMS_SCHEMA, SUBAGENT_OUTPUT_SCHEMA, subagentStructuredContent, type SubagentParams } from "./tool-schema.js";
5
5
  import { executeSubagent, executeParallel } from "./execute.js";
6
- import { formatParallelResults } from "./format.js";
6
+ import { formatParallelResults, formatSingleResult } from "./format.js";
7
7
  // agent-manager.js lazy-loaded below to keep subagent tool registration fast
8
8
  import { renderSubagentResult } from "./render.js";
9
+ import { RowBlock } from "./row-block.js";
9
10
  import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
10
11
  import { renderToolHeader } from "@pi-archimedes/core/tool-render";
12
+ import { reportPiSupport } from "@pi-archimedes/core/pi-support";
13
+ import { diag, describeError } from "@pi-archimedes/core/log";
11
14
  import { discoverAgents, discoverAgentsAll, findAgent, formatAgentList } from "./agents.js";
12
15
  import { validateModel, firstError } from "./model-validation.js";
16
+ import {
17
+ unknownToolEntries,
18
+ formatUnknownToolsWarning,
19
+ } from "./tool-validation.js";
20
+ import { resolveThinkingLevel } from "./thinking-validation.js";
21
+ import { toNativeUsage, sumNativeUsage } from "./usage.js";
13
22
  import type {
14
23
  SubagentDetails,
15
24
  SubagentProgress,
@@ -27,23 +36,42 @@ interface RenderTheme {
27
36
  // ── Tool registration ───────────────────────────────────────────────────────
28
37
 
29
38
  export function registerSubagent(pi: ExtensionAPI): void {
39
+ // Floor check before anything that can fail (ADR 0026). This package spawns
40
+ // pi with flags and reads event fields that only behave correctly on 1.x
41
+ // (`--tools` semantics, `agent_settled.aborted`, print-mode exit codes), and a
42
+ // peer range cannot say so at install time. Warn and register anyway — the
43
+ // tool must still show up in /plugins. Entry point for both meta and the
44
+ // standalone default export, so `registerAgentsCommand` needs no second call.
45
+ reportPiSupport("subagent", "1.2.0");
46
+
30
47
  pi.registerTool({
31
48
  name: "subagent",
32
49
  label: "Subagent",
33
50
  description:
34
51
  "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Agent is optional — omit for a config-less run with the parent's model and all tools. Model override is rarely needed; the agent config or parent model is used by default.",
35
52
  parameters: SUBAGENT_PARAMS_SCHEMA,
53
+ outputSchema: SUBAGENT_OUTPUT_SCHEMA,
54
+ /**
55
+ * Declared to the model, never callable from a codemode script — pi's own
56
+ * wording for this exposure is "orchestrating" tools
57
+ * (`dist/core/extensions/types.d.ts`, `ToolExposure`), and dispatching a
58
+ * child session is that.
59
+ *
60
+ * Measured cost, so it is not discovered later: before this line a codemode
61
+ * script could dispatch a child and read its report — `tools.subagent({ task:
62
+ * "Reply with exactly the single word: child" })` resolved to
63
+ * `{ mode: "single", results: [ { outcome: "completed", finalOutput: "child",
64
+ * usage: {…} } ] }` on a real headless pi. After it the same call reports
65
+ * `tools.subagent does not exist`, and `subagent` is gone from the script's
66
+ * `ALL_TOOLS`. A script that wants a child's work now has to ask the model to
67
+ * dispatch one; `list_agents` stays `direct`, so scripts can still read the
68
+ * catalogue. The model's own dispatches are unaffected.
69
+ */
70
+ exposure: "model-only",
36
71
 
37
72
  async execute(
38
73
  _id: string,
39
- params: {
40
- agent?: string;
41
- task?: string;
42
- tasks?: Array<{ agent?: string; task: string; count?: number; model?: string; cwd?: string }>;
43
- model?: string;
44
- cwd?: string;
45
- async?: boolean;
46
- },
74
+ params: SubagentParams,
47
75
  signal: AbortSignal | undefined,
48
76
  onUpdate: ((update: SubagentToolResult) => void) | undefined,
49
77
  ctx: ExtensionContext,
@@ -68,6 +96,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
68
96
  // Skip model validation for tasks already caught by unknown-agent check
69
97
  if (t.agent && unknownAgentSet.has(t.agent)) continue;
70
98
  const taskAgentConfig = t.agent ? findAgent(agents, t.agent) : undefined;
99
+ // A `tools:` list the config read rejected is never applied, so the task
100
+ // cannot run as configured — say so before anything spawns.
101
+ if (taskAgentConfig?.toolsError) errors.push(taskAgentConfig.toolsError);
71
102
  const me = firstError(
72
103
  validateModel(t.model, ctx.modelRegistry, { agentName: t.agent }),
73
104
  validateModel(taskAgentConfig?.model, ctx.modelRegistry, {
@@ -88,6 +119,20 @@ export function registerSubagent(pi: ExtensionAPI): void {
88
119
  isError: true,
89
120
  };
90
121
  }
122
+ // Membership only becomes knowable here, with the handle in scope — and
123
+ // it is a warning, so the batch still runs (see warnUnavailableTools).
124
+ const warnedTools = new Set<string>();
125
+ const warnedThinking = new Set<string>();
126
+ for (const t of params.tasks) {
127
+ warnUnavailableTools(
128
+ pi,
129
+ ctx,
130
+ warnedTools,
131
+ t.agent ?? "subagent",
132
+ t.agent ? findAgent(agents, t.agent)?.tools : undefined,
133
+ );
134
+ warnUnknownThinkingLevel(ctx, warnedThinking, t.agent ? findAgent(agents, t.agent)?.thinking : undefined);
135
+ }
91
136
  const results: SubagentResult[] = await executeParallel({
92
137
  tasks: params.tasks.map((t) => ({
93
138
  agent: t.agent ?? undefined,
@@ -101,6 +146,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
101
146
  toolCallId: _id,
102
147
  })),
103
148
  signal: signal ?? undefined,
149
+ events: pi.events,
104
150
  onUpdate: (progress: SubagentProgress[]) => {
105
151
  onUpdate?.({
106
152
  content: [],
@@ -113,8 +159,11 @@ export function registerSubagent(pi: ExtensionAPI): void {
113
159
  },
114
160
  });
115
161
 
162
+ const nativeUsage = sumNativeUsage(results);
116
163
  return {
117
164
  content: [{ type: "text", text: formatParallelResults(results) }],
165
+ ...(nativeUsage ? { usage: nativeUsage } : {}),
166
+ structuredContent: subagentStructuredContent("parallel", results),
118
167
  details: {
119
168
  mode: "parallel",
120
169
  results,
@@ -158,6 +207,18 @@ export function registerSubagent(pi: ExtensionAPI): void {
158
207
  isError: true,
159
208
  };
160
209
  }
210
+ // A rejected `tools:` list is a config error, not a runtime one: the child
211
+ // would run with a different tool set than the config asked for, so it
212
+ // does not run at all.
213
+ if (agentConfig?.toolsError) {
214
+ return {
215
+ content: [{ type: "text", text: agentConfig.toolsError }],
216
+ details: { mode: "single", results: [], progress: undefined },
217
+ isError: true,
218
+ };
219
+ }
220
+ warnUnavailableTools(pi, ctx, new Set(), params.agent ?? "subagent", agentConfig?.tools);
221
+ warnUnknownThinkingLevel(ctx, new Set(), agentConfig?.thinking);
161
222
  const result: SubagentResult = await executeSubagent({
162
223
  agent: params.agent ?? undefined,
163
224
  agentConfig,
@@ -166,6 +227,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
166
227
  activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
167
228
  cwd: params.cwd ?? undefined,
168
229
  signal: signal ?? undefined,
230
+ events: pi.events,
169
231
  toolCallId: _id,
170
232
  onUpdate: (progress: SubagentProgress) => {
171
233
  onUpdate?.({
@@ -180,13 +242,31 @@ export function registerSubagent(pi: ExtensionAPI): void {
180
242
  });
181
243
 
182
244
  return {
183
- content: [{ type: "text", text: result.finalOutput ?? result.error ?? "completed" }],
245
+ content: [{ type: "text", text: formatSingleResult(result) }],
246
+ // The cost report — but only for a run this session performed. A
247
+ // bridge-dispatched run is a session of the desktop's own and books
248
+ // its usage there; pi folds a tool result's `usage` into THIS
249
+ // session's totals, so publishing it here would count the money twice
250
+ // (the same reason the `COST_UPDATE` route was deleted).
251
+ ...(result.usageAccountedElsewhere ? {} : { usage: toNativeUsage(result.usage) }),
252
+ // Successes only. The predicate is the one two lines below, so a run
253
+ // that reports itself as failed carries no structured content and a
254
+ // codemode script keeps rejecting with the text it rejects with today
255
+ // (see `SUBAGENT_OUTPUT_SCHEMA`).
256
+ ...(result.error === undefined ? { structuredContent: subagentStructuredContent("single", [result]) } : {}),
184
257
  details: {
185
258
  mode: "single",
186
259
  results: [result],
187
260
  progress: result.progress ? [result.progress] : undefined,
188
261
  },
189
- isError: result.exitCode !== 0,
262
+ // Only a *failed* run is a tool error. A truncated answer and a
263
+ // cancelled one both produced something, and `isError` is how the model
264
+ // and the Client decide to retry or give up (ADR 0028). `error` is the
265
+ // predicate because the exit code is not one — a json child exits 0 for
266
+ // an errored run — and `stream.ts` guarantees a failed run always
267
+ // carries an explanation while a completed/truncated/aborted one never
268
+ // does.
269
+ isError: result.error !== undefined,
190
270
  };
191
271
  }
192
272
 
@@ -231,18 +311,21 @@ export function registerSubagent(pi: ExtensionAPI): void {
231
311
  false) as boolean;
232
312
 
233
313
  const renderTheme = theme as unknown as RenderTheme;
234
- const text = new Text("", 0, 0);
314
+ // A RowBlock, not a plain Text: the subagent rows are cut at the width pi
315
+ // renders them at, and only the component is ever handed that width.
316
+ const lastComponent = (context as { lastComponent?: unknown })?.lastComponent;
317
+ const rows = lastComponent instanceof RowBlock ? lastComponent : new RowBlock();
235
318
  const renderContext = {
236
319
  expanded,
237
320
  isError: toolResult.isError ?? false,
238
- lastComponent: (context as { lastComponent?: Text })?.lastComponent,
321
+ lastComponent: rows,
239
322
  state: (context as Record<string, unknown>)?.state ?? {},
240
323
  invalidate: () => {},
241
324
  };
242
- (context as Record<string, unknown>).lastComponent = text;
325
+ (context as Record<string, unknown>).lastComponent = rows;
243
326
 
244
327
  try {
245
- const rendered = renderSubagentResult(text, toolResult, { expanded }, renderTheme, renderContext as any);
328
+ const rendered = renderSubagentResult(rows, toolResult, { expanded }, renderTheme, renderContext as any);
246
329
  return rendered;
247
330
  } catch (e) {
248
331
  debugText.setText("render error: " + (e instanceof Error ? e.message : String(e)));
@@ -256,6 +339,76 @@ export function registerSubagent(pi: ExtensionAPI): void {
256
339
 
257
340
  // ── Helpers ─────────────────────────────────────────────────────────────────
258
341
 
342
+ /**
343
+ * Warn (never fail) about allowlist entries no registered tool satisfies right
344
+ * now, naming each one.
345
+ *
346
+ * Membership cannot be checked any earlier: `pi.getAllTools()` needs this
347
+ * handle, and the config read is pure filesystem. And it must not fail the run:
348
+ * a legitimate name is missing whenever its plugin is toggled off or its MCP
349
+ * server is disconnected, so a hard error would make `tools: web_search` unusable
350
+ * for anyone who disabled `web`. One warning per entry per tool call — a parallel
351
+ * batch of eight agents sharing a list warns once, not eight times.
352
+ *
353
+ * Diagnostics go to `diag()` because a `console.*` write is discarded by a
354
+ * subagent child and smears the fullscreen frame (see `core/log.ts`); the
355
+ * on-screen copy only goes out where there is a UI to receive it.
356
+ */
357
+ function warnUnavailableTools(
358
+ pi: ExtensionAPI,
359
+ ctx: ExtensionContext,
360
+ alreadyWarned: Set<string>,
361
+ agentName: string,
362
+ tools: string[] | undefined,
363
+ ): void {
364
+ if (!tools || tools.length === 0) return;
365
+ let unknown: string[];
366
+ try {
367
+ const known = pi.getAllTools().map((t) => t.name);
368
+ unknown = unknownToolEntries(tools, known).filter((entry) => !alreadyWarned.has(entry));
369
+ } catch (e) {
370
+ // A handle that cannot answer is not a reason to lose the dispatch.
371
+ diag("subagent", `tool-membership check skipped for "${agentName}": ${describeError(e)}`);
372
+ return;
373
+ }
374
+ if (unknown.length === 0) return;
375
+ for (const entry of unknown) alreadyWarned.add(entry);
376
+ const message = formatUnknownToolsWarning(agentName, unknown);
377
+ diag("subagent", message);
378
+ if (!ctx.hasUI) return;
379
+ try {
380
+ ctx.ui.notify(message, "warning");
381
+ } catch {
382
+ // A context too stale to notify must not fail the run it is only warning about.
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Warn (never fail) about a `thinking:` level pi's `--thinking` does not take.
388
+ *
389
+ * `spawnSubagent` withholds the flag in this case, which is what pi would have
390
+ * done anyway — the difference is that the child no longer runs at the default
391
+ * level silently: pi's own notice is a stderr warning, and a successful json
392
+ * child's stderr is never read. One warning per distinct value per tool call.
393
+ */
394
+ function warnUnknownThinkingLevel(
395
+ ctx: ExtensionContext,
396
+ alreadyWarned: Set<string>,
397
+ thinking: string | undefined,
398
+ ): void {
399
+ if (thinking === undefined || alreadyWarned.has(thinking)) return;
400
+ const { warning } = resolveThinkingLevel(thinking);
401
+ if (!warning) return;
402
+ alreadyWarned.add(thinking);
403
+ diag("subagent", warning);
404
+ if (!ctx.hasUI) return;
405
+ try {
406
+ ctx.ui.notify(warning, "warning");
407
+ } catch {
408
+ // Same rule as the tool-membership warning: a report must not lose a run.
409
+ }
410
+ }
411
+
259
412
  export function registerListAgentsTool(pi: ExtensionAPI): void {
260
413
  pi.registerTool({
261
414
  name: "list_agents",
package/src/render.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Text } from "@earendil-works/pi-tui";
1
+ import { RowBlock, row, type RowSegment } from "./row-block.js";
2
2
  import type { SubagentDetails, SubagentProgress, SubagentToolResult } from "./types.js";
3
3
  import {
4
4
  renderCompactSingle,
@@ -17,107 +17,117 @@ type Theme = { fg: (token: string, text: string) => string; bold: (text: string)
17
17
  type RenderContext = {
18
18
  expanded: boolean;
19
19
  isError: boolean;
20
- lastComponent: Text | undefined;
20
+ lastComponent: RowBlock | undefined;
21
21
  state: Record<string, unknown>;
22
22
  invalidate: () => void;
23
23
  };
24
24
 
25
+ /**
26
+ * Draw a subagent result (or its live progress) into `rows`.
27
+ *
28
+ * `rows` is a `RowBlock` rather than a plain `Text` because the renderers below
29
+ * hand back whole content: the component is what knows the width pi is drawing
30
+ * at, and it is what cuts each row to that width (`row-block.ts`).
31
+ */
25
32
  export function renderSubagentResult(
26
- text: Text,
33
+ rows: RowBlock,
27
34
  result: SubagentToolResult,
28
35
  options: { expanded: boolean },
29
36
  theme: Theme,
30
37
  context: RenderContext,
31
- ): Text {
38
+ ): RowBlock {
32
39
  const details: SubagentDetails | undefined = result.details;
33
40
  const expanded = context.expanded ?? options.expanded;
34
41
 
35
42
  if (!details) {
36
- text.setText(theme.fg("dim", " no results"));
37
- return text;
43
+ rows.setText(theme.fg("dim", " no results"));
44
+ return rows;
38
45
  }
39
46
 
40
47
  // ── Streaming progress (results empty but progress has data) ──────────
41
48
  if (details.results.length === 0 && details.progress && details.progress.length > 0) {
42
49
  if (details.progress.length === 1) {
43
- return renderProgressUpdate(text, details.progress[0]!, expanded, theme, context);
50
+ return renderProgressUpdate(rows, details.progress[0]!, expanded, theme, context);
44
51
  }
45
- return renderProgressUpdatesParallel(text, details, expanded, theme, context);
52
+ return renderProgressUpdatesParallel(rows, details, expanded, theme, context);
46
53
  }
47
54
 
48
55
  if (details.results.length === 0) {
49
- text.setText(theme.fg("dim", " no results"));
50
- return text;
56
+ rows.setText(theme.fg("dim", " no results"));
57
+ return rows;
51
58
  }
52
59
 
53
60
  // ── Single agent ────────────────────────────────────────────────────────
54
61
  if (details.mode === "single" || details.results.length === 1) {
55
- return renderSingleAgent(text, details.results[0]!, details.progress?.[0], expanded, theme, context);
62
+ return renderSingleAgent(rows, details.results[0]!, details.progress?.[0], expanded, theme, context);
56
63
  }
57
64
 
58
65
  // ── Parallel agents ─────────────────────────────────────────────────────
59
- return renderParallelAgents(text, details, expanded, theme, context);
66
+ return renderParallelAgents(rows, details, expanded, theme, context);
60
67
  }
61
68
 
62
69
  function renderSingleAgent(
63
- text: Text,
70
+ rows: RowBlock,
64
71
  result: SubagentDetails["results"][number],
65
72
  progress: SubagentDetails["progress"] extends (infer U)[] | undefined ? U | undefined : undefined,
66
73
  expanded: boolean,
67
74
  theme: Theme,
68
75
  context: RenderContext,
69
- ): Text {
76
+ ): RowBlock {
70
77
  if (expanded) {
71
- return renderExpanded(text, result, progress, theme);
78
+ return renderExpanded(rows, result, progress, theme);
72
79
  }
73
- return renderCompactSingle(text, result, progress, theme, context);
80
+ renderCompactSingle(rows, result, progress, theme, context);
81
+ return rows;
74
82
  }
75
83
 
76
84
  function renderParallelAgents(
77
- text: Text,
85
+ rows: RowBlock,
78
86
  details: SubagentDetails,
79
87
  expanded: boolean,
80
88
  theme: Theme,
81
89
  context: RenderContext,
82
- ): Text {
90
+ ): RowBlock {
83
91
  if (expanded) {
84
92
  const progressArr: SubagentProgress[] = details.progress ?? [];
85
- const lines = details.results.map((result, i) => {
86
- return buildExpandedText(result, progressArr[i], theme);
87
- });
88
- text.setText(lines.join("\n\n"));
89
- return text;
93
+ rows.setSegments(joinBlocks(details.results.map((result, i) => buildExpandedText(result, progressArr[i], theme))));
94
+ return rows;
90
95
  }
91
- return renderCompactParallel(text, details, theme, context);
96
+ renderCompactParallel(rows, details, theme, context);
97
+ return rows;
92
98
  }
93
99
 
94
100
  function renderProgressUpdate(
95
- text: Text,
101
+ rows: RowBlock,
96
102
  progress: SubagentProgress,
97
103
  expanded: boolean,
98
104
  theme: Theme,
99
105
  context: RenderContext,
100
- ): Text {
106
+ ): RowBlock {
101
107
  if (expanded) {
102
- return renderProgressExpanded(text, progress, theme);
108
+ return renderProgressExpanded(rows, progress, theme);
103
109
  }
104
- return renderCompactProgress(text, progress, theme, context);
110
+ renderCompactProgress(rows, progress, theme, context);
111
+ return rows;
105
112
  }
106
113
 
107
114
  function renderProgressUpdatesParallel(
108
- text: Text,
115
+ rows: RowBlock,
109
116
  details: SubagentDetails,
110
117
  expanded: boolean,
111
118
  theme: Theme,
112
119
  context: RenderContext,
113
- ): Text {
120
+ ): RowBlock {
114
121
  if (expanded) {
115
122
  const progressArr: SubagentProgress[] = details.progress ?? [];
116
- const lines = progressArr.map((progress) => {
117
- return buildProgressExpandedText(progress, theme);
118
- });
119
- text.setText(lines.join("\n\n"));
120
- return text;
123
+ rows.setSegments(joinBlocks(progressArr.map((progress) => buildProgressExpandedText(progress, theme))));
124
+ return rows;
121
125
  }
122
- return renderCompactParallelProgress(text, details, theme, context);
126
+ renderCompactParallelProgress(rows, details, theme, context);
127
+ return rows;
128
+ }
129
+
130
+ /** One blank row between agents — the spacing the old `join("\n\n")` produced. */
131
+ function joinBlocks(blocks: RowSegment[][]): RowSegment[] {
132
+ return blocks.flatMap((blockLines, i) => (i === 0 ? blockLines : [row(""), ...blockLines]));
123
133
  }