@ian-pascoe/pi-minimal-subagents 0.14.0 → 0.14.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.
package/README.md CHANGED
@@ -40,7 +40,9 @@ From this package checkout, load the source directly with
40
40
  `agent_message` in the same tool batch can target the new child.
41
41
  2. `subagent_wait` claims the turn's result, or leave it unclaimed and the final
42
42
  response arrives automatically as a steer message. Without `turn_id`, a wait
43
- skips turns whose result you already claimed or received automatically.
43
+ skips turns whose result you already claimed or received automatically, and
44
+ never repeats an automatically delivered result: it reports
45
+ `already_delivered: true` instead of the output.
44
46
  3. `agent_message` continues an idle child: the result reports
45
47
  `disposition: "started-turn"` with the new `turn_id`. A plain
46
48
  `subagent_wait({ agent_id })` targets that new turn, not the first result;
@@ -336,12 +338,16 @@ intermediate Wait Event containing a Coordination Message before the child turn
336
338
  settles. That event claims only its message, so later unconsumed messages and the
337
339
  terminal result retain automatic fallback. If the turn has already settled, a
338
340
  wait returns its terminal result with queued messages in `messages`; waiting
339
- again for the same settled turn returns the same result while it is retained. Pass
340
- optional `turn_id` to address a retained turn exactly, including one whose
341
- result you already claimed (a claimed result stays retained until Delivery
342
- Evidence settles it). Without it, waits skip claimed turns and turns whose
343
- result was already delivered to you automatically, and select the oldest
344
- remaining observable turn, falling back to the active, then latest, turn. A
341
+ again for a settled turn you claimed returns the same result while it is
342
+ retained. Pass optional `turn_id` to address a retained turn exactly, including
343
+ one whose result you already claimed (a claimed result stays retained until
344
+ Delivery Evidence settles it). Without it, waits skip claimed turns and turns
345
+ whose result was already delivered to you automatically, and select the oldest
346
+ remaining observable turn, falling back to the active, then latest, turn. When
347
+ that fallback reaches a result already delivered to you automatically, whether
348
+ still queued or settled, the wait returns `event: "turn"` with
349
+ `already_delivered: true`, the turn identity, and status, but no output; it does
350
+ not claim the turn, and `turn_id` rereads the full result. A
345
351
  caller may have only one outstanding wait for the same source turn; a
346
352
  concurrent duplicate is rejected instead of competing for one Wait Event.
347
353
  When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -69,6 +69,7 @@ import type {
69
69
  StatusResult,
70
70
  TurnResult,
71
71
  WaitMessageResult,
72
+ WaitDeliveredTurnResult,
72
73
  WaitResult,
73
74
  } from "./minimal-subagents-types.js";
74
75
 
@@ -145,6 +146,27 @@ function terminalWaitResult(result: TurnResult, messages: WaitMessageResult[] =
145
146
  return messages.length === 0 ? terminal : { ...terminal, messages: structuredClone(messages) };
146
147
  }
147
148
 
149
+ /** A terminal Wait Event naming an already delivered result without repeating its output. */
150
+ function alreadyDeliveredWaitResult(
151
+ result: TurnResult,
152
+ messages: WaitMessageResult[],
153
+ ): WaitDeliveredTurnResult {
154
+ const delivered: WaitDeliveredTurnResult = {
155
+ event: "turn",
156
+ agent_id: result.agent_id,
157
+ turn_id: result.turn_id,
158
+ status: result.status,
159
+ already_delivered: true,
160
+ };
161
+ if (messages.length > 0) delivered.messages = structuredClone(messages);
162
+ return delivered;
163
+ }
164
+
165
+ /** The message ID of an automatic result message, which is also its Delivery Evidence key. */
166
+ function automaticResultMessageId(sourceAgentId: string, sourceTurnId: string): string {
167
+ return `result:${sourceAgentId}:${sourceTurnId}`;
168
+ }
169
+
148
170
  function combineCoordinatorMessages(messages: readonly CoordinatorMessage[]): CoordinatorMessage {
149
171
  const latest = messages.at(-1);
150
172
  if (!latest) throw new Error("Minimal subagents message batch must not be empty");
@@ -486,6 +508,11 @@ export class MinimalSubagentsCoordinator {
486
508
  (agent.latest_result?.turn_id === turnId ? agent.latest_result : undefined);
487
509
  if (retainedResult) {
488
510
  const messages = this.drainPendingParentMessages(callerId, agentId, turnId);
511
+ // A default wait falls back to the latest turn, whose result may already have been delivered
512
+ // automatically; repeating its output would deliver it twice. An explicit turn_id rereads it.
513
+ if (requestedTurnId === undefined && this.wasAlreadyDelivered(callerId, retainedResult)) {
514
+ return Promise.resolve(alreadyDeliveredWaitResult(retainedResult, messages));
515
+ }
489
516
  this.claimTerminalDelivery(callerId, retainedResult);
490
517
  return Promise.resolve(terminalWaitResult(retainedResult, messages));
491
518
  }
@@ -1335,7 +1362,7 @@ export class MinimalSubagentsCoordinator {
1335
1362
  source_agent_id: delivery.source_agent_id,
1336
1363
  destination_agent_id: delivery.destination_agent_id,
1337
1364
  source_turn_id: result.turn_id,
1338
- message_id: `result:${delivery.source_agent_id}:${result.turn_id}`,
1365
+ message_id: automaticResultMessageId(delivery.source_agent_id, result.turn_id),
1339
1366
  status: result.status,
1340
1367
  elapsed_ms: result.elapsed_ms,
1341
1368
  usage: result.usage,
@@ -1737,6 +1764,22 @@ export class MinimalSubagentsCoordinator {
1737
1764
  );
1738
1765
  }
1739
1766
 
1767
+ /**
1768
+ * Whether this result was already delivered to the caller automatically: it is handed, or the
1769
+ * caller's branch holds its automatic result message. A wait's own tool result does not count.
1770
+ */
1771
+ private wasAlreadyDelivered(callerId: string, result: TurnResult): boolean {
1772
+ return (
1773
+ this.handedTerminalKeys.has(deliveryTurnKey(result.agent_id, result.turn_id)) ||
1774
+ this.hasRecipientDeliveryEvidence(
1775
+ callerId,
1776
+ result.agent_id,
1777
+ result.turn_id,
1778
+ automaticResultMessageId(result.agent_id, result.turn_id),
1779
+ )
1780
+ );
1781
+ }
1782
+
1740
1783
  private hasDeliveryEvidence(delivery: PersistedDelivery): boolean {
1741
1784
  return this.hasRecipientDeliveryEvidence(
1742
1785
  delivery.destination_agent_id,
@@ -146,6 +146,7 @@ const WaitTurnDetailsSchema = Type.Object({
146
146
  error: Type.Optional(Type.String()),
147
147
  elapsed_ms: Type.Optional(Type.Number()),
148
148
  usage: Type.Optional(RenderUsageSchema),
149
+ already_delivered: Type.Optional(Type.Boolean()),
149
150
  messages: Type.Optional(Type.Array(WaitMessageDetailsSchema)),
150
151
  });
151
152
  const WaitTimeoutDetailsSchema = Type.Object({
@@ -300,6 +301,13 @@ export const CoordinatorToolOutputSchemas = {
300
301
  messages: Type.Optional(Type.Array(CoordinatorWaitMessageOutputSchema)),
301
302
  ...CoordinatorWaitSourceOutputSchema,
302
303
  }),
304
+ Type.Object({
305
+ ...Type.Pick(CoordinatorTurnOutputSchema, ["agent_id", "turn_id", "status"]).properties,
306
+ event: Type.Literal("turn"),
307
+ already_delivered: Type.Literal(true),
308
+ messages: Type.Optional(Type.Array(CoordinatorWaitMessageOutputSchema)),
309
+ ...CoordinatorWaitSourceOutputSchema,
310
+ }),
303
311
  Type.Object({
304
312
  event: Type.Literal("timeout"),
305
313
  agent_id: Type.String(),
@@ -582,6 +582,9 @@ function renderWaitProgress(
582
582
  return container;
583
583
  }
584
584
 
585
+ /** Shown instead of the output of a result already delivered to the parent automatically. */
586
+ const ALREADY_DELIVERED_TEXT = "Already delivered automatically; wait with turn_id to reread it.";
587
+
585
588
  /** The collapsed body of a settled wait: what the child said, or why it stopped. */
586
589
  function collapsedWaitBody(
587
590
  details: WaitRenderDetails,
@@ -593,6 +596,9 @@ function collapsedWaitBody(
593
596
  const activity = latestProgressActivity(details.agent.recent_activity ?? []);
594
597
  return activity.length > 0 ? new ActivityLines(activity, theme) : undefined;
595
598
  }
599
+ if (details.already_delivered) {
600
+ return collapsedTextPreview(theme.fg("muted", ALREADY_DELIVERED_TEXT), theme);
601
+ }
596
602
  const output = details.output ?? "";
597
603
  if (status === "completed") {
598
604
  return output ? collapsedMarkdownPreview(output, theme) : undefined;
@@ -669,7 +675,9 @@ function renderWaitResult(
669
675
  );
670
676
  }
671
677
  const output = details.output ?? "";
672
- if (status === "completed") {
678
+ if (details.already_delivered) {
679
+ appendTextSection(container, theme, "Output", ALREADY_DELIVERED_TEXT);
680
+ } else if (status === "completed") {
673
681
  if (output.length > 0) {
674
682
  appendComponentSection(
675
683
  container,
@@ -36,6 +36,7 @@ const DeliveryEvidenceMessageSchema = Type.Object({
36
36
  /** Parses durable custom-message and wait-tool Delivery Evidence details. */
37
37
  export const DeliveryEvidenceDetailsSchema = Type.Object({
38
38
  event: Type.Optional(Type.String()),
39
+ already_delivered: Type.Optional(Type.Boolean()),
39
40
  source_agent_id: Type.String(),
40
41
  source_turn_id: Type.String(),
41
42
  delivery_id: Type.Optional(Type.String()),
@@ -503,8 +503,13 @@ export function findDeliveryEvidence(
503
503
  ))
504
504
  );
505
505
  }
506
- // A message or timeout wait result never carried the turn's terminal result.
507
- if (waitToolResult && (details.event === "message" || details.event === "timeout"))
506
+ // A message, timeout, or already-delivered wait result never carried the turn's terminal result.
507
+ if (
508
+ waitToolResult &&
509
+ (details.event === "message" ||
510
+ details.event === "timeout" ||
511
+ details.already_delivered === true)
512
+ )
508
513
  return false;
509
514
  return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId;
510
515
  });
@@ -38,6 +38,7 @@ import type {
38
38
  DeleteResult,
39
39
  SpawnResult,
40
40
  StatusResult,
41
+ WaitDeliveredTurnResult,
41
42
  WaitResult,
42
43
  } from "./minimal-subagents-types.js";
43
44
 
@@ -197,6 +198,22 @@ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
197
198
  };
198
199
  }
199
200
 
201
+ /**
202
+ * One line saying an already-delivered result is not repeated and how to reread it, followed by
203
+ * any Coordination Messages the wait drained, which the parent has not seen yet.
204
+ */
205
+ function alreadyDeliveredContent(
206
+ result: WaitDeliveredTurnResult,
207
+ ): AgentToolResult<WaitResult>["content"] {
208
+ const notice = `Result of ${result.agent_id} turn ${result.turn_id} (${result.status}) was already delivered automatically; call subagent_wait with turn_id "${result.turn_id}" to reread it.`;
209
+ if (!result.messages) return [{ type: "text", text: notice }];
210
+ const messages = truncateHead(JSON.stringify({ messages: result.messages }, null, 2), {
211
+ maxBytes: DEFAULT_MAX_BYTES,
212
+ maxLines: DEFAULT_MAX_LINES,
213
+ });
214
+ return [{ type: "text", text: `${notice}\n${messages.content}` }];
215
+ }
216
+
200
217
  /**
201
218
  * Report a failed operation that still carries declared output: the model sees an error and
202
219
  * codemode scripts receive `structuredContent` instead of a data-less rejection.
@@ -313,7 +330,7 @@ export function createCoordinatorToolDefinitions(
313
330
  name: "subagent_wait",
314
331
  label: "Subagent Wait",
315
332
  description:
316
- "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 the same result. 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 event=timeout with detailed child status and never cancels the child.",
317
334
  promptSnippet: "Wait for one direct child's exact turn",
318
335
  parameters: options.schemas.subagent_wait,
319
336
  annotations: {
@@ -358,7 +375,10 @@ export function createCoordinatorToolDefinitions(
358
375
  source_turn_id: result.turn_id,
359
376
  };
360
377
  return {
361
- ...structuredToolResult(result),
378
+ content:
379
+ "already_delivered" in result
380
+ ? alreadyDeliveredContent(result)
381
+ : structuredToolResult(result).content,
362
382
  details,
363
383
  structuredContent: JSON.parse(JSON.stringify(details)),
364
384
  };
@@ -98,6 +98,19 @@ export interface WaitTurnResult extends TurnResult {
98
98
  messages?: WaitMessageResult[];
99
99
  }
100
100
 
101
+ /**
102
+ * Reports that a default wait selected a terminal turn whose result was already delivered
103
+ * automatically, handed or settled, so the output is not repeated; an explicit `turn_id` rereads it.
104
+ */
105
+ export interface WaitDeliveredTurnResult extends Pick<
106
+ TurnResult,
107
+ "agent_id" | "turn_id" | "status"
108
+ > {
109
+ event: "turn";
110
+ already_delivered: true;
111
+ messages?: WaitMessageResult[];
112
+ }
113
+
101
114
  /** Reports an observational wait timeout with the current detailed child status. */
102
115
  export interface WaitTimeoutResult {
103
116
  event: "timeout";
@@ -110,7 +123,11 @@ export interface WaitTimeoutResult {
110
123
  }
111
124
 
112
125
  /** Reports one message, terminal turn, or timeout returned by subagent_wait. */
113
- export type WaitResult = WaitMessageResult | WaitTurnResult | WaitTimeoutResult;
126
+ export type WaitResult =
127
+ | WaitMessageResult
128
+ | WaitTurnResult
129
+ | WaitDeliveredTurnResult
130
+ | WaitTimeoutResult;
114
131
 
115
132
  /** Provides bounded hierarchy, usage, and best-known Runtime Profile data for one persistent agent. */
116
133
  export interface AgentSummary extends RuntimeProfile {