@ian-pascoe/pi-minimal-subagents 0.14.1 → 0.15.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
 
@@ -279,10 +281,11 @@ children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
279
281
  only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
280
282
  and `subagent_status`.
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
291
  and `reasoning`, as `0` when the provider reports none. `subagent_cancel`
@@ -351,8 +354,11 @@ not claim the turn, and `turn_id` rereads the full result. A
351
354
  caller may have only one outstanding wait for the same source turn; a
352
355
  concurrent duplicate is rejected instead of competing for one Wait Event.
353
356
  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
357
+ with the requested turn identity and a compact, bounded progress snapshot
358
+ (`state`, `elapsed_ms`, `latest_activity_at`, `total_tokens`, and the labels of
359
+ the last three Recent Activity items as `recent_activity_labels`), under about 1 KB. It omits the task and
360
+ Launch Contract; targeted `subagent_status` returns the full detail.
361
+ It removes only the waiter, leaving the child
356
362
  running and all pending delivery unclaimed. Abort signals remain errors.
357
363
 
358
364
  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.15.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -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({
@@ -313,7 +319,11 @@ export const CoordinatorToolOutputSchemas = {
313
319
  agent_id: Type.String(),
314
320
  turn_id: Type.String(),
315
321
  timeout_ms: Type.Number(),
316
- agent: CoordinatorAgentDetailOutputSchema,
322
+ state: Type.Union([Type.Literal("running"), Type.Literal("idle")]),
323
+ elapsed_ms: Type.Optional(Type.Number()),
324
+ latest_activity_at: Type.Optional(Type.String()),
325
+ total_tokens: Type.Optional(Type.Number()),
326
+ recent_activity_labels: Type.Array(Type.String()),
317
327
  ...CoordinatorWaitSourceOutputSchema,
318
328
  }),
319
329
  ]),
@@ -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) {
@@ -330,7 +330,7 @@ export function createCoordinatorToolDefinitions(
330
330
  name: "subagent_wait",
331
331
  label: "Subagent Wait",
332
332
  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.",
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 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
334
  promptSnippet: "Wait for one direct child's exact turn",
335
335
  parameters: options.schemas.subagent_wait,
336
336
  annotations: {
@@ -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. */