@ian-pascoe/pi-minimal-subagents 0.12.0 → 0.14.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
@@ -31,6 +31,24 @@ filter in `~/.pi/agent/settings.json` using the repository-relative path:
31
31
  From this package checkout, load the source directly with
32
32
  `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=0.99.0`.
33
33
 
34
+ ## Typical workflow
35
+
36
+ 1. `subagent` launches a Child Agent from a self-contained task: by default the
37
+ child starts without the parent's conversation. It returns at once with its `agent_id`,
38
+ `turn_id`, and resolved model, thinking level, tools, delegation, and any
39
+ tool-resolution `warnings`. Spawns run sequentially, so a `subagent_wait` or
40
+ `agent_message` in the same tool batch can target the new child.
41
+ 2. `subagent_wait` claims the turn's result, or leave it unclaimed and the final
42
+ response arrives automatically as a steer message. Without `turn_id`, a wait
43
+ skips turns whose result you already claimed or received automatically.
44
+ 3. `agent_message` continues an idle child: the result reports
45
+ `disposition: "started-turn"` with the new `turn_id`. A plain
46
+ `subagent_wait({ agent_id })` targets that new turn, not the first result;
47
+ pass `turn_id` to re-read a turn whose result is still retained, claimed or
48
+ not.
49
+ 4. `subagent_status` inspects children; `subagent_cancel` stops work but keeps
50
+ the session; `subagent_delete` removes it. Deleted IDs cannot be reused.
51
+
34
52
  ## Configuration
35
53
 
36
54
  Configure the extension in Pi's standard settings files:
@@ -79,10 +97,10 @@ source branch, and a new branch without an override follows settings.
79
97
 
80
98
  ## Model roles
81
99
 
82
- `minimalSubagents.modelRoles` gives the parent agent advisory names for
100
+ `minimalSubagents.modelRoles` gives the parent agent named, advisory choices of
83
101
  eligible models. The extension defines no roles itself, performs no task
84
- classification, and does not route launches. The parent still passes the
85
- ordinary `model` and `thinking_level` arguments separately.
102
+ classification, and does not route launches: the parent names the role it wants
103
+ with the optional `role` argument of `subagent`.
86
104
 
87
105
  ```json
88
106
  {
@@ -99,8 +117,9 @@ ordinary `model` and `thinking_level` arguments separately.
99
117
  ```
100
118
 
101
119
  A recognized final suffix (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`,
102
- or `max`) is a preferred `thinking_level`, not part of the canonical model
103
- passed to `subagent`. Unsuffixed roles leave thinking selection independent.
120
+ or `max`) is the role's `thinking_level`, not part of the canonical model.
121
+ Unsuffixed roles leave thinking selection independent: the child uses the
122
+ caller's thinking level unless `thinking_level` is passed.
104
123
  Role names and hints are trimmed, single-line text. Names may be up to 64
105
124
  characters and hints up to 500 characters. Models use canonical
106
125
  `provider/model` IDs and must be available under the effective `enabledModels`
@@ -111,6 +130,27 @@ preference. A thinking level pinned in `enabledModels` neither supplies nor
111
130
  constrains a role preference, and normal spawn-time model-capability clamping
112
131
  still applies.
113
132
 
133
+ ### Launching with a role
134
+
135
+ `subagent({ task, role: "design" })` launches the child with the `design` role's
136
+ model and, when the role has a suffix, its thinking level. An explicit `model` or
137
+ `thinking_level` overrides the role's value independently, so
138
+ `{ role: "design", thinking_level: "low" }` keeps the role's model with a
139
+ different thinking level. The role is recorded even when explicit `model` and
140
+ `thinking_level` override all of its values. An unknown `role` fails before any
141
+ child is created and lists the configured role names. The Launch Contract
142
+ records the resolved `model` and `thinking_level` together with the `role` used
143
+ (`role` is absent when none was named), and `subagent_status` and the expanded
144
+ `subagent` result show it. Recording the name does not tie the child to the role: later settings
145
+ changes never alter an existing Launch Contract.
146
+
147
+ Roles are read when the session starts, so editing `modelRoles` mid-session
148
+ changes nothing until a reload. The `role` parameter is a plain string rather
149
+ than an enum of the configured names, so the tool definition stays
150
+ byte-identical when `modelRoles` changes. Only the role list in the system
151
+ prompt (the `subagent` prompt guidelines) changes, and only at reload. The
152
+ `model` enum is separate: it already follows the eligible models.
153
+
114
154
  Global and project roles merge by name in settings order. Expanded role
115
155
  objects merge by field; a project string replaces the whole global entry. A
116
156
  project can remove one inherited role with `null`, or clear all inherited
@@ -240,7 +280,32 @@ and `subagent_status`.
240
280
  Targeted `subagent_status` includes `recent_activity`, a bounded tail of message
241
281
  text, reasoning, tool calls, and tool results. It includes the current streaming
242
282
  assistant message but omits image data. Timeout Wait Events include the same
243
- detailed status snapshot.
283
+ detailed status snapshot. Children target only direct children; the Root Agent
284
+ may inspect any descendant. Model-facing status reports a `child_count` but no
285
+ nested `children` summaries. Reported `usage` always includes `cacheWrite1h`
286
+ and `reasoning`, as `0` when the provider reports none. `subagent_cancel`
287
+ lists in `affected_agent_ids` only agents whose active turns it cancelled.
288
+
289
+ `session_context` defaults to `omit`: the child starts with only its system
290
+ prompt and task, so the task should be a self-contained brief covering the goal,
291
+ target files and non-goals, the change or question, and acceptance criteria.
292
+ Opt in to `inherit` to copy the parent's conversation, or `compact` to summarize
293
+ it first, only when the child needs that discussion. Inheriting is costly: the
294
+ child's system prompt differs from its parent's, so the copied conversation
295
+ cannot reuse the parent's prompt cache, and `compact` adds a summarization call.
296
+ With either opt-in, the parent's conversation is quoted rather than replayed as
297
+ the child's own turns: each parent message becomes one
298
+ `minimal-subagents.parent-context` custom message, sent to the model as
299
+ user-role text wrapped in `<parent_message from="…" role="…">`. Tool calls and
300
+ results become text, a custom message's label names its `customType`, and an
301
+ errored, aborted, or length-limited parent turn is marked as incomplete. Images
302
+ are kept; parent reasoning and the parent's system prompt and tool declarations
303
+ are omitted, since the child declares its own. Each quoted message stays a
304
+ separate session entry, so `compact` can still summarize older entries. The task
305
+ is then framed as a handoff: the quoted conversation is background, its requests
306
+ belonged to the parent, and the child's own assignment follows. Quoting keeps
307
+ the parent's turns distinct from the child's own; it does not guarantee that a
308
+ child, particularly a small model, ignores the quoted requests. Existing Child Agents keep the mode recorded in their Launch Contracts.
244
309
 
245
310
  The `subagent` `tools` argument distinguishes configurable Tool Presets from
246
311
  exact lists; every selection also receives the permitted Base Toolset described
@@ -260,18 +325,24 @@ tools while the Launch Contract continues to record the originally granted
260
325
  capability names.
261
326
 
262
327
  `agent_message` reports whether a message was delivered through an active
263
- parent wait, queued for the recipient, or failed. A failed delivery and a
328
+ parent wait (`delivered-via-wait`), queued into the recipient's active turn
329
+ (`queued`), started a new turn on an idle child (`started-turn`, with its
330
+ `turn_id`), or failed. A failed delivery and a
264
331
  partially failed `subagent_delete` return an error result (`isError: true`) that
265
332
  still carries the declared structured output, so the model sees an error while
266
333
  codemode scripts receive the `failures` or `error` data instead of a data-less
267
334
  rejection. `subagent_wait` can return an
268
335
  intermediate Wait Event containing a Coordination Message before the child turn
269
336
  settles. That event claims only its message, so later unconsumed messages and the
270
- terminal result retain automatic fallback. If the turn has already settled, one
271
- wait returns its terminal result with queued messages in `messages`. Pass
272
- optional `turn_id` to address an older retained turn exactly. Without it, waits
273
- select the oldest observable claimed or pending turn before the active/latest
274
- turn. A caller may have only one outstanding wait for the same source turn; a
337
+ terminal result retain automatic fallback. If the turn has already settled, a
338
+ 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
345
+ caller may have only one outstanding wait for the same source turn; a
275
346
  concurrent duplicate is rejected instead of competing for one Wait Event.
276
347
  When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
277
348
  with the requested turn identity and the same detailed Child Agent status used
@@ -279,20 +350,26 @@ by targeted `subagent_status`. It removes only the waiter, leaving the child
279
350
  running and all pending delivery unclaimed. Abort signals remain errors.
280
351
 
281
352
  The persisted Delivery Ledger records Coordination Messages, terminal results,
282
- globally increasing sequence, and wait ownership before delivery. Existing
283
- items retain their sequence; gaps from skipped malformed records are valid.
284
- Claims can name only active, latest, or retained turns. Wait-returned messages
285
- retain individual delivery evidence; terminal wait ownership remains durable
286
- across reloads, forks, and newer turns. Automatic fallback retains its ordered
287
- queue reservation while batching queued messages from one source turn into one
288
- Pi steer. Root-bound messages remain batchable while the root turn is active; a
289
- pending terminal result absorbs them. Child sessions drain all available steers
290
- before the next model call. Destination-session Delivery Evidence still settles
291
- each batched ledger item independently, preventing duplicate delivery and
292
- unbounded checkpoint growth. The pure Delivery Ledger state machine retains at
293
- most 20 pending wait-only terminal results per source agent; Coordination Messages
294
- are not removed by that terminal-retention limit. Delivered messages include
295
- stable delivery, source-agent, and source-turn identities in persisted details.
353
+ globally increasing sequence, and wait ownership before delivery. Existing items
354
+ retain their sequence; gaps from skipped malformed records are valid. Claims can
355
+ name only active, latest, or retained turns. Wait-returned messages retain
356
+ individual delivery evidence; terminal wait ownership remains durable across
357
+ reloads, forks, and newer turns. Automatic fallback retains its ordered queue
358
+ reservation while batching queued messages from one source turn into one Pi
359
+ steer. Root-bound messages remain batchable while the root turn is active; a
360
+ pending terminal result absorbs them. If Pi discards a root-bound steer before
361
+ consuming it, as Esc does, the next root turn boundary reporting an empty Pi
362
+ queue releases the result: a default wait selects it again at once, and
363
+ automatic fallback re-sends it, ahead of newer results, into the root run if it
364
+ is still going, or, after an abort such as Esc, when the root's next run starts.
365
+ Recovery never starts a root turn by itself. Child sessions drain all available
366
+ steers before the next model call. Destination-session Delivery Evidence still
367
+ settles each batched ledger item independently, preventing duplicate delivery
368
+ and unbounded checkpoint growth. The pure Delivery Ledger state machine retains
369
+ at most 20 pending wait-only terminal results per source agent; Coordination
370
+ Messages are not removed by that terminal-retention limit. Delivered messages
371
+ include stable delivery, source-agent, and source-turn identities in persisted
372
+ details.
296
373
 
297
374
  Deleting a child first verifies its session header and persistent identity,
298
375
  then uses the optional `trash` command when available and falls back to
@@ -333,6 +410,18 @@ The coordinator tools declare MCP-style `annotations`, which Pi reports through
333
410
 
334
411
  ## Status and TUI
335
412
 
413
+ Coordinator tool calls and results stay readable without expanding. A
414
+ `subagent` call shows the launch settings the caller chose and its task; an
415
+ `agent_message` call shows its message. While `subagent_wait` blocks, it shows
416
+ the child's tool-call count and, on a tree rail, the latest work of the turn
417
+ being waited on, rendered with Pi's own message and tool components as in the
418
+ `/subagents` viewer and refreshed each second; collapsed, the rail keeps the
419
+ newest items within 20 lines. A settled wait shows the child's output as
420
+ Markdown, or its error, with duration, tokens, and cost. Long text previews its
421
+ first 10 lines, like Pi's `read`. Every collapsed result ends its first line
422
+ with Pi's tool-expansion hint (normally Ctrl+O), which reveals the rest and the
423
+ full detail. Automatic agent results and messages preview the same way.
424
+
336
425
  In TUI mode, `/subagents` or `/subagents status` opens a large, centered,
337
426
  framed overlay. Up/Down selects a Child Agent; Enter opens its Child Session
338
427
  Transcript. Escape returns to the tree, then Escape closes the overlay. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -53,7 +53,7 @@
53
53
  ]
54
54
  },
55
55
  "scripts": {
56
- "test": "vitest run --config ../../vitest.config.ts --root .",
56
+ "test": "vitest run",
57
57
  "typecheck": "tsc --noEmit -p tsconfig.json"
58
58
  }
59
59
  }
@@ -75,7 +75,9 @@ export function resolveOrdinaryToolSelection(
75
75
  const ceiling = new Set(context.capabilityCeiling);
76
76
  const exceeded = uniqueRequested.filter((name) => !ceiling.has(name));
77
77
  if (exceeded.length > 0) {
78
- throw new Error(`Minimal subagents capability ceiling exceeded: ${exceeded.join(", ")}`);
78
+ throw new Error(
79
+ `Minimal subagents tool selection: ${exceeded.join(", ")} ${exceeded.length === 1 ? "is" : "are"} unknown or outside the caller's capability ceiling. ${describePermittedTools(excludeCoordinatorTools(context.capabilityCeiling))}`,
80
+ );
79
81
  }
80
82
 
81
83
  const toolsets = context.toolsets ?? DEFAULT_TOOLSETS;
@@ -103,6 +105,15 @@ export function resolveOrdinaryToolSelection(
103
105
  };
104
106
  }
105
107
 
108
+ const PERMITTED_TOOL_LISTING_LIMIT = 40;
109
+
110
+ function describePermittedTools(permitted: readonly string[]): string {
111
+ if (permitted.length === 0) return "No ordinary tools are permitted.";
112
+ const listed = permitted.slice(0, PERMITTED_TOOL_LISTING_LIMIT).join(", ");
113
+ const remaining = permitted.length - PERMITTED_TOOL_LISTING_LIMIT;
114
+ return `Permitted ordinary tools: ${listed}${remaining > 0 ? `, and ${remaining} more` : ""}.`;
115
+ }
116
+
106
117
  /** Return an agent's hierarchy depth where the interactive root is depth zero. */
107
118
  export function getSubagentDepth(agentId: string): number {
108
119
  if (agentId === "root") return 0;
@@ -45,6 +45,16 @@ const ModelRolesSettingSchema = Type.Union([ModelRoleEntriesSchema, Type.Null()]
45
45
 
46
46
  type ModelRoleThinkingLevel = (typeof THINKING_LEVELS)[number];
47
47
 
48
+ /** Whether `name` is trimmed single-line text of at most 64 characters, as every role name must be. */
49
+ export function isModelRoleName(name: string): boolean {
50
+ return (
51
+ name.length > 0 &&
52
+ name === name.trim() &&
53
+ !/[\r\n]/.test(name) &&
54
+ name.length <= MODEL_ROLE_NAME_MAX_LENGTH
55
+ );
56
+ }
57
+
48
58
  /** Describes one user-authored advisory model role shown to subagent callers. */
49
59
  export interface MinimalSubagentsModelRole {
50
60
  name: string;
@@ -294,12 +304,7 @@ function parseModelRoles(
294
304
  const roles: MinimalSubagentsModelRole[] = [];
295
305
  for (const [name, entry] of entries) {
296
306
  const path = `${entry.scope} minimalSubagents.modelRoles.${name}`;
297
- if (
298
- name.length === 0 ||
299
- name !== name.trim() ||
300
- /[\r\n]/.test(name) ||
301
- name.length > MODEL_ROLE_NAME_MAX_LENGTH
302
- ) {
307
+ if (!isModelRoleName(name)) {
303
308
  warnings.push(`${path}: role name must be trimmed single-line text up to 64 characters`);
304
309
  continue;
305
310
  }
@@ -1,7 +1,13 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { contentText, type ImageContent, type TextContent } from "@earendil-works/pi-ai";
2
+ import {
3
+ contentText,
4
+ type AssistantMessage,
5
+ type ImageContent,
6
+ type TextContent,
7
+ } from "@earendil-works/pi-ai";
3
8
  import { truncateTail } from "@earendil-works/pi-coding-agent";
4
9
  import type {
10
+ ActiveTurnProgress,
5
11
  ChildAgentTranscriptSnapshot,
6
12
  RecentAgentActivity,
7
13
  SessionContextMode,
@@ -141,19 +147,189 @@ export function buildRecentAgentActivity(messages: readonly AgentMessage[]): Rec
141
147
  return activity.slice(-RECENT_AGENT_ACTIVITY_LIMIT);
142
148
  }
143
149
 
150
+ /** A message the child produced during the turn, as opposed to the turn's prompt or coordination input. */
151
+ function isTurnWork(message: AgentMessage, turnStartedAtMs: number): boolean {
152
+ return (
153
+ message.timestamp >= turnStartedAtMs &&
154
+ (message.role === "assistant" || message.role === "toolResult")
155
+ );
156
+ }
157
+
158
+ /** Count the tool calls a running turn has made so far. */
159
+ export function buildActiveTurnProgress(
160
+ messages: readonly AgentMessage[],
161
+ turnStartedAtMs: number,
162
+ ): Omit<ActiveTurnProgress, "turn_id"> {
163
+ let toolCalls = 0;
164
+ for (const message of messages) {
165
+ if (message.role !== "assistant" || !isTurnWork(message, turnStartedAtMs)) continue;
166
+ toolCalls += message.content.filter((content) => content.type === "toolCall").length;
167
+ }
168
+ return { tool_calls: toolCalls };
169
+ }
170
+
171
+ /**
172
+ * Keep the running turn's last `assistantMessageCount` assistant messages and the tool results after
173
+ * them, so a live view renders recent work without the turn's earlier history.
174
+ */
175
+ export function selectActiveTurnTranscript(
176
+ snapshot: ChildAgentTranscriptSnapshot,
177
+ turnStartedAtMs: number,
178
+ assistantMessageCount: number,
179
+ ): ChildAgentTranscriptSnapshot {
180
+ const work = snapshot.messages.flatMap((message, index) =>
181
+ isTurnWork(message, turnStartedAtMs) ? [{ message, index }] : [],
182
+ );
183
+ const assistantPositions = work.flatMap((entry, position) =>
184
+ entry.message.role === "assistant" ? [position] : [],
185
+ );
186
+ const tail = work.slice(assistantPositions.at(-assistantMessageCount) ?? 0);
187
+ const streamingPosition = tail.findIndex(
188
+ (entry) => entry.index === snapshot.streamingAssistantIndex,
189
+ );
190
+ return {
191
+ messages: tail.map((entry) => entry.message),
192
+ streamingAssistantIndex: streamingPosition >= 0 ? streamingPosition : undefined,
193
+ toolDefinitions: snapshot.toolDefinitions,
194
+ };
195
+ }
196
+
144
197
  /** Carries the selected caller messages and whether child preparation should compact them. */
145
198
  export interface ImportedSubagentContext {
146
199
  messages: AgentMessage[];
147
200
  compact: boolean;
148
201
  }
149
202
 
150
- /** Select the imported message snapshot and defer expensive compact preparation to the child turn. */
203
+ /** Custom message type carrying one quoted message from a parent's conversation. */
204
+ export const PARENT_CONTEXT_MESSAGE_TYPE = "minimal-subagents.parent-context";
205
+
206
+ type QuotedContent = TextContent | ImageContent;
207
+
208
+ function quotedParts(content: string | QuotedContent[]): QuotedContent[] {
209
+ if (Array.isArray(content)) return content.map((part) => structuredClone(part));
210
+ return content ? [{ type: "text", text: content }] : [];
211
+ }
212
+
213
+ function parentMessageBody(
214
+ message: AgentMessage,
215
+ ): { label: string; parts: QuotedContent[] } | undefined {
216
+ switch (message.role) {
217
+ case "user":
218
+ return { label: "user", parts: quotedParts(message.content) };
219
+ case "assistant": {
220
+ // Reasoning is omitted: it is model-private and often signature-bound to the parent's model.
221
+ const parts = message.content.flatMap((content): QuotedContent[] => {
222
+ if (content.type === "text")
223
+ return content.text ? [{ type: "text", text: content.text }] : [];
224
+ if (content.type === "toolCall") {
225
+ return [
226
+ {
227
+ type: "text",
228
+ text: `[tool call ${content.name}] ${JSON.stringify(content.arguments)}`,
229
+ },
230
+ ];
231
+ }
232
+ return [];
233
+ });
234
+ const stopNote = incompleteAssistantNote(message.stopReason, message.errorMessage);
235
+ if (stopNote) parts.push({ type: "text", text: stopNote });
236
+ return { label: "assistant", parts };
237
+ }
238
+ case "toolResult":
239
+ return {
240
+ label: `tool result ${message.toolName}${message.isError ? " (error)" : ""}`,
241
+ parts: quotedParts(message.content),
242
+ };
243
+ case "custom":
244
+ return {
245
+ label: `context message: ${message.customType}`,
246
+ parts: quotedParts(message.content),
247
+ };
248
+ case "bashExecution":
249
+ if (message.excludeFromContext) return undefined;
250
+ return {
251
+ label: "shell command",
252
+ parts: [{ type: "text", text: `$ ${message.command}\n${message.output || "(no output)"}` }],
253
+ };
254
+ case "branchSummary":
255
+ case "compactionSummary":
256
+ return {
257
+ label: message.role === "branchSummary" ? "branch summary" : "compaction summary",
258
+ parts: quotedParts(message.summary),
259
+ };
260
+ case "system":
261
+ // The parent's prompt sections and tool declarations describe the parent, not the child,
262
+ // which declares its own prompt and tools on its first request.
263
+ return undefined;
264
+ default: {
265
+ // Fails typecheck when Pi adds a message role, so new roles are quoted deliberately.
266
+ const unhandled: never = message;
267
+ return unhandled;
268
+ }
269
+ }
270
+ }
271
+
272
+ /** Mark a parent turn that ended early, so quoted partial output does not read as complete. */
273
+ function incompleteAssistantNote(
274
+ stopReason: AssistantMessage["stopReason"],
275
+ errorMessage: string | undefined,
276
+ ): string | undefined {
277
+ if (stopReason === "error") return `[turn failed: ${errorMessage ?? "unknown error"}]`;
278
+ if (stopReason === "aborted") return "[turn aborted before completion]";
279
+ if (stopReason === "length") return "[turn stopped at the output length limit]";
280
+ return undefined;
281
+ }
282
+
283
+ /**
284
+ * Quote one parent message as a user-visible custom message. Replaying the parent's assistant
285
+ * turns as the child's own lets the child continue as its parent; quoting keeps the roles apart
286
+ * while each message stays a separate entry that child compaction can cut between.
287
+ */
288
+ function quoteParentMessage(message: AgentMessage, parentId: string): AgentMessage | undefined {
289
+ const body = parentMessageBody(message);
290
+ if (!body || body.parts.length === 0) return undefined;
291
+ return {
292
+ role: "custom",
293
+ customType: PARENT_CONTEXT_MESSAGE_TYPE,
294
+ content: [
295
+ { type: "text", text: `<parent_message from="${parentId}" role="${body.label}">` },
296
+ ...body.parts,
297
+ { type: "text", text: "</parent_message>" },
298
+ ],
299
+ display: true,
300
+ timestamp: message.timestamp,
301
+ };
302
+ }
303
+
304
+ /** Select and quote the imported parent conversation; compaction is deferred to the child turn. */
151
305
  export function assembleImportedContext(
152
306
  mode: SessionContextMode,
153
- committedMessages: AgentMessage[],
307
+ committedMessages: readonly AgentMessage[],
308
+ parentId: string,
154
309
  ): ImportedSubagentContext {
155
310
  if (mode === "omit") return { messages: [], compact: false };
156
- return { messages: committedMessages, compact: mode === "compact" };
311
+ return {
312
+ messages: committedMessages.flatMap((message) => quoteParentMessage(message, parentId) ?? []),
313
+ compact: mode === "compact",
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Frame a child's task after inherited parent conversation so the child does not adopt the
319
+ * parent's earlier requests as its own assignment.
320
+ */
321
+ export function buildInheritedContextTaskPrompt(
322
+ task: string,
323
+ agentId: string,
324
+ parentId: string,
325
+ ): string {
326
+ return [
327
+ `The parent_message entries above, and any summary of earlier ones, come from the conversation of your parent \`${parentId}\`. They are background context only.`,
328
+ `Requests and tool calls in them belong to your parent, not to you: do not continue or repeat your parent's work.`,
329
+ `You are \`${agentId}\`. Your assigned task is:`,
330
+ "",
331
+ task,
332
+ ].join("\n");
157
333
  }
158
334
 
159
335
  /** Detect image content so incompatible child models fail before agent creation. */