Skip to content

Commit c63724a

Browse files
committed
spawn: remove nesting depth tracking
1 parent 1727272 commit c63724a

3 files changed

Lines changed: 17 additions & 80 deletions

File tree

spawn/index.ts

Lines changed: 11 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* Creates an isolated in-memory child AgentSession for focused subtask execution.
55
* Children inherit the parent's model, thinking level, cwd, and ledger access.
6-
* Max nesting depth: 1 edge (parent → child only).
6+
* Children do not inherit the spawn tool (recursion prevention).
77
*
88
* Spawn is context isolation, not a security boundary. Child agents are trusted
99
* extensions of the parent and inherit parent authority by design.
@@ -39,7 +39,6 @@ import {
3939

4040
// ── Constants ─────────────────────────────────────────────────────────
4141

42-
const MAX_SPAWN_DEPTH = 1;
4342
const CHILD_MAX_LINES = 2000;
4443
const CHILD_MAX_BYTES = 50 * 1024;
4544

@@ -113,8 +112,7 @@ function truncateResult(text: string): { text: string; truncated: boolean } {
113112
* child custom tools defined here. Parent-only custom tools are intentionally
114113
* excluded so the child never advertises a tool it cannot execute.
115114
*
116-
* handoff never carries into children, and spawn is only re-added from
117-
* childTools when the current depth still allows nesting.
115+
* handoff and spawn never carry into children.
118116
*/
119117
function getInheritableParentToolNames(parentToolNames: string[], availableTools: Pick<ToolInfo, "name" | "sourceInfo">[]): string[] {
120118
const activeToolNames = new Set(parentToolNames);
@@ -135,11 +133,11 @@ export function buildChildToolNames(
135133
return [...new Set([...inheritedTools, ...childTools.map((tool) => tool.name)])];
136134
}
137135

138-
// ── Shared spawn tool metadata (used by both parent and child tool definitions) ──
136+
// ── Spawn tool metadata ──
139137

140138
const SPAWN_DESCRIPTION =
141139
"Spawn an isolated child agent for a focused subtask. " +
142-
"Child inherits parent model, thinking level, cwd, supported built-in tools, and shared ledger tools; spawn is only exposed when depth allows. " +
140+
"Child inherits parent model, thinking level, cwd, supported built-in tools, and shared ledger tools; children cannot spawn further children. " +
143141
"Reference ledger entries by name — child will ledger_get them on demand.";
144142

145143
const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent";
@@ -168,85 +166,35 @@ const SPAWN_PARAMETERS = Type.Object({
168166
/**
169167
* Build the custom tool set for child agent sessions.
170168
*
171-
* Produces ledger tools (add/get/list) and conditionally includes the spawn
172-
* tool when currentDepth is below MAX_SPAWN_DEPTH. The spawn tool is omitted
173-
* at max depth to prevent the LLM from attempting illegal recursion.
169+
* Produces ledger tools (add/get/list). Children do not receive the spawn
170+
* tool to prevent the LLM from attempting recursion.
174171
*
175172
* All tools read/write the shared parent state so ledger entries are visible
176173
* across parent and child contexts.
177-
*
178-
* @param sessionFactory - Test seam for dependency-injecting createAgentSession.
179174
*/
180175
export function createChildTools(
181176
pi: ExtensionAPI,
182177
state: AgenticodingState,
183-
defaultThinking: ThinkingValue,
184-
currentDepth: number,
185-
sessionFactory: typeof createAgentSession = createAgentSession,
186178
options?: { isStale?: () => boolean },
187179
): ToolDefinition[] {
188-
// Child sessions inherit only executable parent tools via
189-
// buildChildToolNames(). Only built-in parent tools are carried through.
190-
// handoff never carries into children, and spawn is only re-added here
191-
// while depth allows it.
192-
193-
const childSpawnTool: ToolDefinition = {
194-
name: "spawn",
195-
label: "Spawn",
196-
description: SPAWN_DESCRIPTION,
197-
promptSnippet: SPAWN_PROMPT_SNIPPET,
198-
promptGuidelines: SPAWN_PROMPT_GUIDELINES,
199-
parameters: SPAWN_PARAMETERS,
200-
async execute(
201-
toolCallId: string,
202-
params: { prompt: string; thinking?: ThinkingValue },
203-
signal: AbortSignal | undefined,
204-
onUpdate:
205-
| ((result: {
206-
content: { type: string; text: string }[];
207-
details?: unknown;
208-
}) => void)
209-
| undefined,
210-
ctx: ExtensionContext,
211-
) {
212-
return executeSpawn(toolCallId, pi, ctx, state, params, signal, onUpdate, defaultThinking, currentDepth, sessionFactory);
213-
},
214-
renderCall: renderSpawnCall,
215-
renderResult(result, { expanded }, theme, context) {
216-
return renderSpawnResult(result, expanded, theme, context, state);
217-
},
218-
};
219-
220-
const childLedgerTools = createLedgerToolDefinitions(pi, state, { isStale: options?.isStale });
221-
222-
return [
223-
...(currentDepth < MAX_SPAWN_DEPTH ? [childSpawnTool] : []),
224-
...childLedgerTools,
225-
];
180+
return createLedgerToolDefinitions(pi, state, { isStale: options?.isStale });
226181
}
227182

228183

229184

230185
// ── Shared spawn execution logic ──────────────────────────────────────
231-
// Used by both the parent-registered spawn tool and child custom spawn tools.
232186

233187
/**
234188
* Creates an isolated child agent session, runs the given prompt, and returns
235189
* the result with usage stats.
236190
*
237-
* Errors (all thrown, not returned):
238-
* - "Max spawn depth reached" → currentDepth >= MAX_SPAWN_DEPTH
239-
* - "No model configured..." → ctx.model is undefined
240-
* - "Child agent produced no output." → no assistant text after prompt
191+
* Error: "No model configured..." → ctx.model is undefined
241192
*
242193
* Side effects on state:
243194
* - state.childSessions.set(toolCallId, session) on creation
244195
* - state.liveChildSessions.set(toolCallId, session) on creation
245196
* - both registries delete(toolCallId) on error and completion paths
246197
*
247-
* @param onUpdate - Callback that fires once after session creation with
248-
* empty content + initial details (depth, model, thinking). Pi uses this
249-
* to render the component before the child produces output.
250198
* @param sessionFactory - Test seam for mocking createAgentSession.
251199
*/
252200
export async function executeSpawn(
@@ -263,20 +211,15 @@ export async function executeSpawn(
263211
}) => void)
264212
| undefined,
265213
defaultThinking: ThinkingValue,
266-
currentDepth: number,
267214
sessionFactory: typeof createAgentSession = createAgentSession,
268215
) {
269-
if (currentDepth >= MAX_SPAWN_DEPTH) {
270-
throw new Error(`Max spawn depth (${MAX_SPAWN_DEPTH}) reached. Cannot spawn further children.`);
271-
}
272216

273217
const childModel = ctx.model;
274218
if (!childModel) {
275219
throw new Error("No model configured. Cannot spawn child agent.");
276220
}
277221

278222
const childThinking: ThinkingValue = params.thinking ?? defaultThinking;
279-
const depth = currentDepth + 1;
280223

281224
const listing = formatEntryList(state);
282225
const ledgerListing = listing
@@ -285,7 +228,7 @@ export async function executeSpawn(
285228
const fullPrompt =
286229
`You are a focused child agent spawned by a parent agent. ` +
287230
`You have the same authority as the parent. ` +
288-
`You inherit the parent's supported built-in tools plus shared ledger tools, and spawn is only exposed when depth allows it. ` +
231+
`Children cannot spawn further children. ` +
289232
`Your result will be read by the parent, so be concise and complete.\n\n` +
290233
`${ledgerListing}\n\n` +
291234
`## Task\n\n${params.prompt}\n\n` +
@@ -296,7 +239,7 @@ export async function executeSpawn(
296239
const modelRegistry = ModelRegistry.create(authStorage);
297240
const childSessionEpoch = state.childSessionEpoch;
298241
const isStale = () => state.childSessionEpoch !== childSessionEpoch;
299-
const childTools = createChildTools(pi, state, childThinking, depth, sessionFactory, { isStale });
242+
const childTools = createChildTools(pi, state, { isStale });
300243
const parentToolNames = pi.getActiveTools();
301244
const childToolNames = buildChildToolNames(parentToolNames, childTools, pi.getAllTools());
302245

@@ -356,7 +299,6 @@ export async function executeSpawn(
356299
onUpdate?.({
357300
content: [],
358301
details: {
359-
depth,
360302
model: childModel.id,
361303
thinking: childThinking,
362304
truncated: false,
@@ -420,7 +362,6 @@ export async function executeSpawn(
420362
}
421363

422364
const details: SpawnResultDetails = {
423-
depth,
424365
model: childModel.id,
425366
thinking: childThinking,
426367
truncated,
@@ -475,7 +416,7 @@ export function registerSpawnTool(
475416
ctx: ExtensionContext,
476417
) {
477418
const parentThinking: ThinkingValue = pi.getThinkingLevel();
478-
return executeSpawn(_toolCallId, pi, ctx, state, params, signal, onUpdate, parentThinking, 0, sessionFactory);
419+
return executeSpawn(_toolCallId, pi, ctx, state, params, signal, onUpdate, parentThinking, sessionFactory);
479420
},
480421

481422
renderCall: renderSpawnCall,

spawn/renderer.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
// ── Render-only constants ────────────────────────────────────────────
3333

3434
const COLLAPSED_PREVIEW_MAX_LINES = 5;
35-
const INDENT_SPACES_PER_DEPTH = 4;
35+
const SPAWN_INDENT = 4;
3636
const PROMPT_PREVIEW_COLLAPSED_LINES = 3;
3737
const TOOL_RESULT_PREVIEW_CHARS = 60;
3838
const LIVE_TEXT_PREVIEW_CHARS = 80;
@@ -151,7 +151,7 @@ function safeKeyHint(action: string, fallback: string): string {
151151
* live "last action" summary (tool name + result preview, or assistant
152152
* text preview), 5-line preview of last assistant output when available,
153153
* token/cost summary.
154-
* 2. Expanded view — full chat history with 4-space indent per depth level.
154+
* 2. Expanded view — full chat history with 4-space indent.
155155
* 3. Session lifecycle — subscribes to child session events, streams tool
156156
* executions and assistant messages in real time, maintains live action
157157
* tracking via lastAction field updated on every event.
@@ -502,9 +502,8 @@ class NestedAgentSessionComponent extends Container {
502502

503503
// Identity line — distinguishes nested spawns in collapsed view
504504
if (details) {
505-
const depthLabel = details.depth > 0 ? `[depth ${details.depth}] ` : "";
506505
lines.push(truncateToWidth(
507-
color("dim", `${getOutcomeMarker(outcome)}${depthLabel}${details.model}${details.thinking}`),
506+
color("dim", `${getOutcomeMarker(outcome)}${details.model}${details.thinking}`),
508507
width,
509508
));
510509
}
@@ -554,14 +553,12 @@ class NestedAgentSessionComponent extends Container {
554553

555554
private renderExpanded(width: number): string[] {
556555
// Renders children directly rather than via super.render() to apply
557-
// depth-based indentation. Container.render() from pi-tui is a simple
556+
// indentation. Container.render() from pi-tui is a simple
558557
// passthrough (no layout/decoration) so this is equivalent. If it ever
559558
// adds padding or inter-child spacing, switch to super.render() and
560559
// post-process lines to add indentation.
561-
const depth = this.details?.depth ?? 0;
562-
const indent = depth * INDENT_SPACES_PER_DEPTH;
563-
const childWidth = Math.max(1, width - indent);
564-
const leftPad = " ".repeat(indent);
560+
const childWidth = Math.max(1, width - SPAWN_INDENT);
561+
const leftPad = " ".repeat(SPAWN_INDENT);
565562
const lines: string[] = [];
566563

567564
// Show identity header when expanded — anchors which nested session this is

spawn/shared.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export type ThinkingValue = "off" | "minimal" | "low" | "medium" | "high" | "xhi
22
export type SpawnOutcome = "running" | "success" | "aborted" | "error";
33

44
export type SpawnResultDetails = {
5-
depth: number;
65
model: string;
76
thinking: ThinkingValue;
87
truncated: boolean;

0 commit comments

Comments
 (0)