@ian-pascoe/pi-minimal-subagents 0.14.1 → 0.16.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 CHANGED
@@ -49,7 +49,9 @@ From this package checkout, load the source directly with
49
49
  pass `turn_id` to re-read a turn whose result is still retained, claimed or
50
50
  not.
51
51
  4. `subagent_status` inspects children; `subagent_cancel` stops work but keeps
52
- the session; `subagent_delete` removes it. Deleted IDs cannot be reused.
52
+ the session; `subagent_delete` removes it. Deleted IDs cannot be reused, and
53
+ spawning with an existing `agent_id` fails with that child's state and the
54
+ next step (`agent_message`, `subagent_delete`, or another `agent_id`).
53
55
 
54
56
  ## Configuration
55
57
 
@@ -274,18 +276,21 @@ creation; reloading does not silently broaden them. The default maximum depth
274
276
  is two levels beneath the interactive Root Agent.
275
277
 
276
278
  The extension registers six coordinator tools for the Root Agent and fanout
277
- children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
278
- `subagent_cancel`, and `subagent_delete`. Ordinary non-fanout children receive
279
- only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
280
- and `subagent_status`.
279
+ children below the depth cap: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
280
+ `subagent_cancel`, and `subagent_delete`. Children that cannot spawn (delegation
281
+ `none`, or fanout at the depth cap) have no children to wait on or inspect, so they
282
+ receive only `agent_message`.
281
283
 
282
- Targeted `subagent_status` includes `recent_activity`, a bounded tail of message
284
+ A running child's `latest_activity_at` advances with the timestamps of its latest
285
+ messages and tool results. Targeted `subagent_status` includes `recent_activity`, a bounded tail of message
283
286
  text, reasoning, tool calls, and tool results. It includes the current streaming
284
- assistant message but omits image data. Timeout Wait Events include the same
285
- detailed status snapshot. Children target only direct children; the Root Agent
287
+ assistant message but omits image data. Timeout Wait Events carry only a compact
288
+ progress snapshot. Children target only direct children; the Root Agent
286
289
  may inspect any descendant. Model-facing status reports a `child_count` but no
287
290
  nested `children` summaries. Reported `usage` always includes `cacheWrite1h`
288
- and `reasoning`, as `0` when the provider reports none. `subagent_cancel`
291
+ and `reasoning`, as `0` when the provider reports none. Tool-result text and
292
+ `structuredContent` round every `usage.cost` field to six decimal places of USD;
293
+ exact values stay in session data. `subagent_cancel`
289
294
  lists in `affected_agent_ids` only agents whose active turns it cancelled.
290
295
 
291
296
  `session_context` defaults to `omit`: the child starts with only its system
@@ -351,8 +356,11 @@ not claim the turn, and `turn_id` rereads the full result. A
351
356
  caller may have only one outstanding wait for the same source turn; a
352
357
  concurrent duplicate is rejected instead of competing for one Wait Event.
353
358
  When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
354
- with the requested turn identity and the same detailed Child Agent status used
355
- by targeted `subagent_status`. It removes only the waiter, leaving the child
359
+ with the requested turn identity and a compact, bounded progress snapshot
360
+ (`state`, `elapsed_ms`, `latest_activity_at`, `total_tokens`, and the labels of
361
+ the last three Recent Activity items as `recent_activity_labels`), under about 1 KB. It omits the task and
362
+ Launch Contract; targeted `subagent_status` returns the full detail.
363
+ It removes only the waiter, leaving the child
356
364
  running and all pending delivery unclaimed. Abort signals remain errors.
357
365
 
358
366
  The persisted Delivery Ledger records Coordination Messages, terminal results,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -353,7 +353,7 @@ export function buildSubagentSystemPrompt(
353
353
  ): string {
354
354
  const coordinatorBoundary = options.canSpawn
355
355
  ? "Coordinator tools support subagent, agent_message, subagent_wait, subagent_status, subagent_cancel, and subagent_delete. Wait, status, cancel, and delete target direct children only; recursive cancel and delete may affect a child's subtree."
356
- : "Coordinator tools support agent_message, subagent_wait, and subagent_status; wait and status target direct children only.";
356
+ : "Coordinator tools support only agent_message; you have no children to wait on or inspect.";
357
357
  const delegationBoundary = options.canSpawn
358
358
  ? [
359
359
  "You have explicit fanout responsibility for this assigned task.",
@@ -69,12 +69,17 @@ import type {
69
69
  StatusResult,
70
70
  TurnResult,
71
71
  WaitMessageResult,
72
+ WaitTimeoutResult,
72
73
  WaitDeliveredTurnResult,
73
74
  WaitResult,
74
75
  } from "./minimal-subagents-types.js";
75
76
 
76
77
  const FRIENDLY_AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
77
78
  const RESERVED_AGENT_IDS = new Set(["root", "parent"]);
79
+ /** Recent Activity labels a timed-out wait reports. */
80
+ const WAIT_TIMEOUT_ACTIVITY_LIMIT = 3;
81
+ /** Characters kept of each label so a timed-out wait stays bounded whatever a tool is named. */
82
+ const WAIT_TIMEOUT_LABEL_MAX_LENGTH = 80;
78
83
  const RECENT_MESSAGE_LIMIT = 20;
79
84
  const DEFAULT_AUTOMATIC_DELIVERY_GRACE_MS = 1_000;
80
85
 
@@ -260,7 +265,7 @@ export class MinimalSubagentsCoordinator {
260
265
  );
261
266
  }
262
267
  if (this.agents.has(agentId) || this.pendingAgentIds.has(agentId)) {
263
- throw new Error(`Minimal subagents duplicate agent ID: ${agentId}`);
268
+ throw new Error(this.duplicateAgentIdMessage(agentId));
264
269
  }
265
270
 
266
271
  const sessionContext = parameters.session_context ?? "omit";
@@ -481,7 +486,7 @@ export class MinimalSubagentsCoordinator {
481
486
  }
482
487
  }
483
488
 
484
- /** Wait for one exact turn, returning detailed child status if the waiter times out. */
489
+ /** Wait for one exact turn, returning compact child progress if the waiter times out. */
485
490
  wait(
486
491
  callerId: string,
487
492
  agentId: string,
@@ -546,7 +551,7 @@ export class MinimalSubagentsCoordinator {
546
551
  agent_id: agentId,
547
552
  turn_id: turnId,
548
553
  timeout_ms: timeoutMs,
549
- agent: this.buildAgentDetail(agent, false),
554
+ ...this.buildWaitTimeoutProgress(agent),
550
555
  });
551
556
  }, timeoutMs);
552
557
  }
@@ -1838,6 +1843,20 @@ export class MinimalSubagentsCoordinator {
1838
1843
  this.removeSettledEmptyTurnClaim(delivery.source_agent_id, delivery.source_turn_id);
1839
1844
  }
1840
1845
 
1846
+ /** Name an existing agent ID's state and the next steps that are valid for that state. */
1847
+ private duplicateAgentIdMessage(agentId: string): string {
1848
+ const prefix = `Minimal subagents duplicate agent ID: ${agentId}`;
1849
+ const existing = this.agents.get(agentId);
1850
+ if (!existing) {
1851
+ return `${prefix} is still being created; retry shortly or choose another agent_id`;
1852
+ }
1853
+ if (existing.availability === "unavailable") {
1854
+ return `${prefix} already exists (unavailable). Use subagent_delete to remove it (a deleted ID cannot be reused), or choose another agent_id.`;
1855
+ }
1856
+ const state = existing.active_turn_id ? "running" : "idle";
1857
+ return `${prefix} already exists (${state}). Use agent_message to continue it, subagent_delete to remove it (a deleted ID cannot be reused), or choose another agent_id.`;
1858
+ }
1859
+
1841
1860
  private buildAgentSummary(agent: PersistedAgent, includeDescendants = true): AgentSummary {
1842
1861
  const directChildren = this.childrenOf(agent.agent_id);
1843
1862
  const elapsed = agent.active_turn_started_at
@@ -1860,7 +1879,7 @@ export class MinimalSubagentsCoordinator {
1860
1879
  ...runtimeProfile,
1861
1880
  tools: runtime?.getActiveToolNames?.() ?? [...agent.launch_contract.ordinary_tools],
1862
1881
  elapsed_ms: elapsed ?? agent.latest_result?.elapsed_ms,
1863
- latest_activity_at: agent.latest_activity_at ?? agent.created_at,
1882
+ latest_activity_at: this.latestActivityAt(agent, runtime),
1864
1883
  task: agent.task,
1865
1884
  child_count: directChildren.length,
1866
1885
  };
@@ -1870,6 +1889,41 @@ export class MinimalSubagentsCoordinator {
1870
1889
  return summary;
1871
1890
  }
1872
1891
 
1892
+ /**
1893
+ * The persisted activity time, advanced by the timestamps of the messages and tool results a
1894
+ * running child has produced since. Only running children can have newer unpersisted activity.
1895
+ */
1896
+ private latestActivityAt(agent: PersistedAgent, runtime: ChildAgentRuntime | undefined): string {
1897
+ const persisted = agent.latest_activity_at ?? agent.created_at;
1898
+ if (!agent.active_turn_id || !runtime) return persisted;
1899
+ const persistedMs = Date.parse(persisted);
1900
+ let latestMs = persistedMs;
1901
+ for (const { timestamp } of runtime.snapshotActivityMessages()) {
1902
+ if (Number.isFinite(timestamp) && timestamp > latestMs) latestMs = timestamp;
1903
+ }
1904
+ return latestMs > persistedMs ? new Date(latestMs).toISOString() : persisted;
1905
+ }
1906
+
1907
+ /** The compact progress a timed-out wait reports; `subagent_status` has the full detail. */
1908
+ private buildWaitTimeoutProgress(
1909
+ agent: PersistedAgent,
1910
+ ): Omit<WaitTimeoutResult, "event" | "agent_id" | "turn_id" | "timeout_ms"> {
1911
+ const summary = this.buildAgentSummary(agent, false);
1912
+ const runtime = this.runtimes.get(agent.agent_id);
1913
+ const messages =
1914
+ runtime?.snapshotActivityMessages() ?? this.inspectTranscript(agent.agent_id).messages;
1915
+ const usage = runtime?.getUsage() ?? agent.latest_result?.usage;
1916
+ return {
1917
+ state: summary.state,
1918
+ elapsed_ms: summary.elapsed_ms,
1919
+ latest_activity_at: summary.latest_activity_at,
1920
+ total_tokens: usage?.totalTokens,
1921
+ recent_activity_labels: buildRecentAgentActivity(messages)
1922
+ .slice(-WAIT_TIMEOUT_ACTIVITY_LIMIT)
1923
+ .map((activity) => activity.label.slice(0, WAIT_TIMEOUT_LABEL_MAX_LENGTH)),
1924
+ };
1925
+ }
1926
+
1873
1927
  private buildAgentDetail(agent: PersistedAgent, includeDescendants = true): AgentDetail {
1874
1928
  const summary = this.buildAgentSummary(agent, includeDescendants);
1875
1929
  const runtime = this.runtimes.get(agent.agent_id);
@@ -154,7 +154,13 @@ const WaitTimeoutDetailsSchema = Type.Object({
154
154
  agent_id: Type.String(),
155
155
  turn_id: Type.String(),
156
156
  timeout_ms: Type.Number(),
157
- agent: RenderStatusAgentSchema,
157
+ state: Type.Optional(Type.String()),
158
+ elapsed_ms: Type.Optional(Type.Number()),
159
+ latest_activity_at: Type.Optional(Type.String()),
160
+ total_tokens: Type.Optional(Type.Number()),
161
+ recent_activity_labels: Type.Optional(Type.Array(Type.String())),
162
+ /** Pre-compact timeout results carried the full child status. */
163
+ agent: Type.Optional(RenderStatusAgentSchema),
158
164
  });
159
165
  const StatusDetailsSchema = Type.Union([
160
166
  Type.Object({
@@ -266,7 +272,10 @@ const CoordinatorWaitSourceOutputSchema = {
266
272
  source_turn_id: Type.String(),
267
273
  };
268
274
 
269
- /** Final details schemas exposed to CodeMode for the six coordinator tools. */
275
+ /**
276
+ * Final details schemas exposed to CodeMode for the six coordinator tools. `structuredContent`
277
+ * matches them with every `usage.cost` field rounded for presentation.
278
+ */
270
279
  export const CoordinatorToolOutputSchemas = {
271
280
  subagent: Type.Object({
272
281
  agent_id: Type.String(),
@@ -313,7 +322,11 @@ export const CoordinatorToolOutputSchemas = {
313
322
  agent_id: Type.String(),
314
323
  turn_id: Type.String(),
315
324
  timeout_ms: Type.Number(),
316
- agent: CoordinatorAgentDetailOutputSchema,
325
+ state: Type.Union([Type.Literal("running"), Type.Literal("idle")]),
326
+ elapsed_ms: Type.Optional(Type.Number()),
327
+ latest_activity_at: Type.Optional(Type.String()),
328
+ total_tokens: Type.Optional(Type.Number()),
329
+ recent_activity_labels: Type.Array(Type.String()),
317
330
  ...CoordinatorWaitSourceOutputSchema,
318
331
  }),
319
332
  ]),
@@ -585,6 +585,16 @@ function renderWaitProgress(
585
585
  /** Shown instead of the output of a result already delivered to the parent automatically. */
586
586
  const ALREADY_DELIVERED_TEXT = "Already delivered automatically; wait with turn_id to reread it.";
587
587
 
588
+ /** The activity labels of a timeout result, including pre-compact results that carried the full status. */
589
+ function timeoutActivityLabels(
590
+ details: Extract<WaitRenderDetails, { event: "timeout" }>,
591
+ ): readonly string[] {
592
+ return (
593
+ details.recent_activity_labels ??
594
+ latestProgressActivity(details.agent?.recent_activity ?? []).map((entry) => entry.label)
595
+ );
596
+ }
597
+
588
598
  /** The collapsed body of a settled wait: what the child said, or why it stopped. */
589
599
  function collapsedWaitBody(
590
600
  details: WaitRenderDetails,
@@ -593,8 +603,10 @@ function collapsedWaitBody(
593
603
  ): Component | undefined {
594
604
  if (details.event === "message") return collapsedTextPreview(details.message, theme);
595
605
  if (details.event === "timeout") {
596
- const activity = latestProgressActivity(details.agent.recent_activity ?? []);
597
- return activity.length > 0 ? new ActivityLines(activity, theme) : undefined;
606
+ const labels = timeoutActivityLabels(details);
607
+ return labels.length > 0
608
+ ? collapsedTextPreview(theme.fg("dim", labels.join(" \u00b7 ")), theme)
609
+ : undefined;
598
610
  }
599
611
  if (details.already_delivered) {
600
612
  return collapsedTextPreview(theme.fg("muted", ALREADY_DELIVERED_TEXT), theme);
@@ -627,7 +639,9 @@ function renderWaitResult(
627
639
  details.event === "timeout" ? details.timeout_ms : details.elapsed_ms,
628
640
  );
629
641
  const usage = details.event === "timeout" ? undefined : details.usage;
630
- const tokens = formatSubagentTokenCount(usage?.totalTokens);
642
+ const tokens = formatSubagentTokenCount(
643
+ details.event === "timeout" ? details.total_tokens : usage?.totalTokens,
644
+ );
631
645
  const drainedMessageCount =
632
646
  details.event === "message" || details.event === "timeout"
633
647
  ? 0
@@ -658,12 +672,15 @@ function renderWaitResult(
658
672
  return container;
659
673
  }
660
674
  if (details.event === "timeout") {
661
- appendComponentSection(
662
- container,
663
- theme,
664
- "Child status",
665
- renderDetailedStatusAgent(details.agent, options, theme),
675
+ container.addChild(
676
+ renderLabelValue(theme, "State", details.state ?? details.agent?.state ?? "unknown"),
666
677
  );
678
+ if (details.latest_activity_at) {
679
+ container.addChild(renderLabelValue(theme, "Latest activity", details.latest_activity_at));
680
+ }
681
+ const labels = timeoutActivityLabels(details);
682
+ if (labels.length > 0)
683
+ appendTextSection(container, theme, "Recent activity", labels.join("\n"));
667
684
  return container;
668
685
  }
669
686
  if (details.messages && details.messages.length > 0) {
@@ -13,6 +13,7 @@ import type { TUI } from "@earendil-works/pi-tui";
13
13
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
14
14
  import { withTroubleshootingHint } from "./troubleshooting-skill.js";
15
15
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
16
+ import { roundMinimalSubagentsUsageCosts } from "./minimal-subagents-usage.js";
16
17
  import {
17
18
  renderCoordinatorToolCall,
18
19
  renderCoordinatorToolResult,
@@ -42,11 +43,8 @@ import type {
42
43
  WaitResult,
43
44
  } from "./minimal-subagents-types.js";
44
45
 
45
- const ORDINARY_CHILD_COORDINATOR_TOOL_NAMES = new Set([
46
- "agent_message",
47
- "subagent_wait",
48
- "subagent_status",
49
- ]);
46
+ /** A child that cannot spawn can never have children, so only adjacent messaging is useful. */
47
+ const NON_SPAWNING_CHILD_COORDINATOR_TOOL_NAMES = new Set(["agent_message"]);
50
48
 
51
49
  /** Coordinator operations consumed by the six public coordinator tool definitions. */
52
50
  export type CoordinatorToolOperations = Pick<
@@ -183,10 +181,28 @@ function createCoordinatorToolRendering(
183
181
  };
184
182
  }
185
183
 
184
+ /**
185
+ * Serialize a tool result with every nested `usage` cost rounded for presentation. Exact values
186
+ * stay in `details`, the Registry, and session data, which never pass through here.
187
+ */
188
+ function stringifyWithRoundedUsageCosts(
189
+ result: CoordinatorToolResultDetails,
190
+ space?: number,
191
+ ): string {
192
+ // `JSON.stringify` types replacer values as `any`; a usage is recognized by key and `cost` object.
193
+ return JSON.stringify(
194
+ result,
195
+ (key, value) =>
196
+ key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
197
+ space,
198
+ );
199
+ }
200
+
186
201
  function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
187
202
  result: TDetails,
188
203
  ): AgentToolResult<TDetails> {
189
- const json = JSON.stringify(result, null, 2);
204
+ // Presentation only: `details` keeps the exact values the session persists.
205
+ const json = stringifyWithRoundedUsageCosts(result, 2);
190
206
  const truncated = truncateHead(json, {
191
207
  maxBytes: DEFAULT_MAX_BYTES,
192
208
  maxLines: DEFAULT_MAX_LINES,
@@ -330,7 +346,7 @@ export function createCoordinatorToolDefinitions(
330
346
  name: "subagent_wait",
331
347
  label: "Subagent Wait",
332
348
  description:
333
- "Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. Timeout returns event=timeout with detailed child status and never cancels the child.",
349
+ "Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. Timeout returns a compact event=timeout (state, elapsed_ms, latest_activity_at, total_tokens, last few activity labels; use subagent_status for detail) and never cancels the child.",
334
350
  promptSnippet: "Wait for one direct child's exact turn",
335
351
  parameters: options.schemas.subagent_wait,
336
352
  annotations: {
@@ -380,7 +396,7 @@ export function createCoordinatorToolDefinitions(
380
396
  ? alreadyDeliveredContent(result)
381
397
  : structuredToolResult(result).content,
382
398
  details,
383
- structuredContent: JSON.parse(JSON.stringify(details)),
399
+ structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(details)),
384
400
  };
385
401
  });
386
402
  } finally {
@@ -483,5 +499,5 @@ export function createCoordinatorToolDefinitions(
483
499
  const allowFanoutTools = options.allowFanoutTools ?? options.callerId === "root";
484
500
  return allowFanoutTools
485
501
  ? coordinatorTools
486
- : coordinatorTools.filter((tool) => ORDINARY_CHILD_COORDINATOR_TOOL_NAMES.has(tool.name));
502
+ : coordinatorTools.filter((tool) => NON_SPAWNING_CHILD_COORDINATOR_TOOL_NAMES.has(tool.name));
487
503
  }
@@ -111,15 +111,20 @@ export interface WaitDeliveredTurnResult extends Pick<
111
111
  messages?: WaitMessageResult[];
112
112
  }
113
113
 
114
- /** Reports an observational wait timeout with the current detailed child status. */
114
+ /** Reports an observational wait timeout with a compact, bounded child progress snapshot. */
115
115
  export interface WaitTimeoutResult {
116
116
  event: "timeout";
117
117
  agent_id: string;
118
118
  turn_id: string;
119
119
  /** Requested timeout duration that expired. */
120
120
  timeout_ms: number;
121
- /** Detailed child status captured when the timeout callback won. */
122
- agent: AgentDetail;
121
+ state: AgentState;
122
+ elapsed_ms?: number;
123
+ latest_activity_at?: string;
124
+ /** Total tokens the child has used, when usage is known. */
125
+ total_tokens?: number;
126
+ /** Labels of the child's last few Recent Activity items, oldest first. */
127
+ recent_activity_labels: string[];
123
128
  }
124
129
 
125
130
  /** Reports one message, terminal turn, or timeout returned by subagent_wait. */
@@ -33,3 +33,33 @@ export function addMinimalSubagentsUsage(
33
33
  },
34
34
  };
35
35
  }
36
+
37
+ /**
38
+ * USD decimal places kept in model-visible and structured usage costs. Six places is one
39
+ * millionth of a dollar: finer than any single turn's cost is worth reading, yet coarse enough to
40
+ * drop the binary-float tails that summing many per-message costs produces.
41
+ */
42
+ export const MINIMAL_SUBAGENTS_COST_DECIMALS = 6;
43
+
44
+ const COST_SCALE = 10 ** MINIMAL_SUBAGENTS_COST_DECIMALS;
45
+
46
+ function roundCost(value: number): number {
47
+ return Number.isFinite(value) ? Math.round(value * COST_SCALE) / COST_SCALE : value;
48
+ }
49
+
50
+ /**
51
+ * Return a clone whose cost fields are rounded for presentation. Each field rounds independently
52
+ * from its exact value, so `total` stays within half a unit per summed field of the rounded sum.
53
+ */
54
+ export function roundMinimalSubagentsUsageCosts(usage: Usage): Usage {
55
+ return {
56
+ ...usage,
57
+ cost: {
58
+ input: roundCost(usage.cost.input),
59
+ output: roundCost(usage.cost.output),
60
+ cacheRead: roundCost(usage.cost.cacheRead),
61
+ cacheWrite: roundCost(usage.cost.cacheWrite),
62
+ total: roundCost(usage.cost.total),
63
+ },
64
+ };
65
+ }