Skip to content

Commit 6f98aa5

Browse files
committed
pi-agenticoding/04: inherit active registered spawn tools
1 parent b25f51f commit 6f98aa5

4 files changed

Lines changed: 141 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Changed
11+
12+
- Spawned child agents now inherit active registered parent tools executable in the child session, including MCP/extension tools such as ChunkHound when active and registered, while still excluding spawn and handoff and preserving child-local notebook tools.
13+
814
## [0.3.0] - 2026-05-23
915

1016
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ The agent decided to spawn research children, save reusable findings to the note
104104

105105
### Spawn — Isolate Noise
106106

107-
Delegate messy work to an isolated child agent with clean context. The child inherits the parent's model and tools, works independently, and returns only the condensed result. Siblings run in parallel; the parent stays focused on orchestration. Children cannot spawn grandchildren (explosive branch prevention).
107+
Delegate messy work to an isolated child agent with clean context. The child inherits the parent's model, thinking level, cwd, and active registered tools executable in the child session, including MCP/extension tools such as ChunkHound when they are active and registered. Child-local notebook tools remain available, but children cannot spawn grandchildren or handoff. Siblings run in parallel; the parent stays focused on orchestration.
108108

109109
### Notebook — Continuity Across Cuts
110110

agenticoding.test.ts

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import test, { after } from "node:test";
22
import assert from "node:assert/strict";
3+
import { readFile } from "node:fs/promises";
34
import type { Theme } from "@earendil-works/pi-coding-agent";
45
import { Text } from "@earendil-works/pi-tui";
56
import { registerHandoffCommand } from "./handoff/command.js";
@@ -102,6 +103,7 @@ class MockPi {
102103
tools = new Map<string, any>();
103104
handlers = new Map<string, Handler[]>();
104105
activeTools: string[] = [];
106+
allToolNames: string[] | undefined;
105107
toolSources = new Map<string, string>();
106108
sentUserMessages: Array<{ content: string; options: any }> = [];
107109
appendedEntries: Array<{ customType: string; data: any }> = [];
@@ -137,8 +139,17 @@ class MockPi {
137139
this.toolSources.set(name, source);
138140
}
139141

142+
setAllTools(tools: string[]) {
143+
this.allToolNames = [...tools];
144+
for (const tool of tools) {
145+
if (!this.toolSources.has(tool)) {
146+
this.toolSources.set(tool, "builtin");
147+
}
148+
}
149+
}
150+
140151
getAllTools() {
141-
return this.activeTools.map((name) => ({
152+
return (this.allToolNames ?? this.activeTools).map((name) => ({
142153
name,
143154
description: "",
144155
parameters: {},
@@ -883,10 +894,48 @@ test("nested spawn rerenders when stats become unavailable", () => {
883894
assert.equal(after.some((l: string) => l.includes("initializing")), false);
884895
});
885896

886-
test("spawn execute propagates only executable parent tools to child session", async () => {
897+
test("agentic e2e spawn child can use active registered non-builtin tool", async () => {
898+
const pi = new MockPi();
899+
pi.setToolSource("agentic_e2e_probe", "project");
900+
pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]);
901+
const state = createState();
902+
const sentinel = "AGENTIC_E2E_PROBE_OK";
903+
const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`;
904+
905+
const mockFactory = async (config: any) => {
906+
const session = {
907+
messages: [] as any[],
908+
prompt: async (prompt: string) => {
909+
assert.match(prompt, /agentic_e2e_probe/);
910+
if (!config.tools.includes("agentic_e2e_probe")) {
911+
throw new Error("Child could not find tool agentic_e2e_probe");
912+
}
913+
session.messages = [{ role: "assistant", content: [{ type: "text", text: sentinel }] }];
914+
},
915+
abort: async () => {},
916+
getSessionStats: () => undefined,
917+
};
918+
return { session: session as any };
919+
};
920+
921+
registerSpawnTool(pi as any, state, mockFactory as any);
922+
const result = await pi.tools.get("spawn").execute(
923+
"spawn-e2e",
924+
{ prompt: childPrompt, thinking: "medium" },
925+
undefined,
926+
undefined,
927+
{ model: { id: "mock-model" }, cwd: "/tmp" },
928+
);
929+
930+
assert.equal(result.content[0].text, sentinel);
931+
});
932+
933+
test("spawn execute passes broad active registered tool formula to child session", async () => {
887934
const pi = new MockPi();
888-
pi.setActiveTools(["read", "bash", "spawn", "handoff", "future_tool"]);
889-
pi.setToolSource("future_tool", "project");
935+
pi.setToolSource("project_search", "project");
936+
pi.setToolSource("inactive_registered", "extension");
937+
pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]);
938+
pi.setAllTools(["read", "bash", "spawn", "handoff", "project_search", "inactive_registered"]);
890939
const state = createState();
891940

892941
let seenConfig: any;
@@ -916,11 +965,11 @@ test("spawn execute propagates only executable parent tools to child session", a
916965
assert.equal(seenConfig.model.id, "mock-model");
917966
assert.equal(seenConfig.thinkingLevel, "high");
918967
assert.equal(seenConfig.cwd, "/tmp");
919-
assert.equal(seenConfig.tools.includes("read"), true);
920-
assert.equal(seenConfig.tools.includes("bash"), true);
921-
assert.equal(seenConfig.tools.includes("future_tool"), false);
922-
assert.equal(seenConfig.tools.includes("handoff"), false);
923-
assert.equal(seenConfig.tools.includes("spawn"), false);
968+
assert.deepEqual(
969+
new Set(seenConfig.tools),
970+
new Set(["read", "bash", "project_search", "notebook_write", "notebook_read", "notebook_index"]),
971+
);
972+
assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]);
924973
});
925974

926975
test("spawn execute builds prompt with notebook pages and task", async () => {
@@ -1193,7 +1242,7 @@ test("spawn execute fails explicitly without a configured model", async () => {
11931242
);
11941243
});
11951244

1196-
test("child tool set omits spawn", () => {
1245+
test("child tool names inherit active registered builtins and exclude recursive controls", () => {
11971246
const state = createState();
11981247
const childTools = createChildTools(new MockPi() as any, state);
11991248
assert.equal(childTools.some(t => t.name === "spawn"), false);
@@ -1208,9 +1257,10 @@ test("child tool set omits spawn", () => {
12081257
{ name: "future_tool", sourceInfo: { source: "project" } },
12091258
] as any,
12101259
);
1260+
assert.equal(childToolNames.includes("read"), true);
1261+
assert.equal(childToolNames.includes("bash"), true);
12111262
assert.equal(childToolNames.includes("spawn"), false);
12121263
assert.equal(childToolNames.includes("handoff"), false);
1213-
assert.equal(childToolNames.includes("future_tool"), false);
12141264
});
12151265

12161266
test("spawn renderResult transfers session ownership out of shared state", () => {
@@ -1359,24 +1409,59 @@ test("executeSpawn suppresses stale child sessions after resetState during async
13591409
assert.equal(state.liveChildSessions.get("spawn-1"), freshSession);
13601410
});
13611411

1362-
test("child tool names inherit builtin parent tools, exclude handoff and spawn", () => {
1412+
test("child tool names inherit active registered MCP extension tools", () => {
13631413
const state = createState();
13641414
const childTools = createChildTools(new MockPi() as any, state);
13651415

13661416
const toolNames = buildChildToolNames(
1367-
["read", "bash", "handoff", "future_tool"],
1417+
["read", "chunkhound_code_research", "mcp_status"],
13681418
childTools,
13691419
[
13701420
{ name: "read", sourceInfo: { source: "builtin" } },
1371-
{ name: "bash", sourceInfo: { source: "builtin" } },
1372-
{ name: "handoff", sourceInfo: { source: "builtin" } },
1373-
{ name: "future_tool", sourceInfo: { source: "project" } },
1421+
{ name: "chunkhound_code_research", sourceInfo: { source: "extension" } },
1422+
{ name: "mcp_status", sourceInfo: { source: "extension" } },
1423+
] as any,
1424+
);
1425+
1426+
assert.equal(toolNames.includes("chunkhound_code_research"), true);
1427+
assert.equal(toolNames.includes("mcp_status"), true);
1428+
});
1429+
1430+
test("child tool names inherit active registered project package and local extension tools", () => {
1431+
const state = createState();
1432+
const childTools = createChildTools(new MockPi() as any, state);
1433+
1434+
const toolNames = buildChildToolNames(
1435+
["project_search", "package_lint", "local_helper"],
1436+
childTools,
1437+
[
1438+
{ name: "project_search", sourceInfo: { source: "project" } },
1439+
{ name: "package_lint", sourceInfo: { source: "package" } },
1440+
{ name: "local_helper", sourceInfo: { source: "local" } },
13741441
] as any,
13751442
);
13761443

1377-
assert.ok(toolNames.includes("read"));
1378-
assert.ok(toolNames.includes("bash"));
1379-
assert.equal(toolNames.includes("future_tool"), false);
1444+
assert.equal(toolNames.includes("project_search"), true);
1445+
assert.equal(toolNames.includes("package_lint"), true);
1446+
assert.equal(toolNames.includes("local_helper"), true);
1447+
});
1448+
1449+
test("child tool names exclude inactive registered and active phantom tools", () => {
1450+
const state = createState();
1451+
const childTools = createChildTools(new MockPi() as any, state);
1452+
1453+
const toolNames = buildChildToolNames(
1454+
["read", "active_phantom"],
1455+
childTools,
1456+
[
1457+
{ name: "read", sourceInfo: { source: "builtin" } },
1458+
{ name: "inactive_registered", sourceInfo: { source: "extension" } },
1459+
] as any,
1460+
);
1461+
1462+
assert.equal(toolNames.includes("read"), true);
1463+
assert.equal(toolNames.includes("inactive_registered"), false);
1464+
assert.equal(toolNames.includes("active_phantom"), false);
13801465
assert.ok(toolNames.includes("notebook_write"));
13811466
assert.ok(toolNames.includes("notebook_read"));
13821467
assert.ok(toolNames.includes("notebook_index"));
@@ -3608,6 +3693,10 @@ test("registerSpawnTool registers a tool with correct name and metadata", () =>
36083693
assert.equal(tool.name, "spawn");
36093694
assert.equal(tool.label, "Spawn");
36103695
assert.equal(typeof tool.description, "string");
3696+
assert.match(tool.description, /active registered tools executable in the child session/);
3697+
assert.match(tool.description, /shared notebook tools/);
3698+
assert.match(tool.description, /cannot spawn or handoff/);
3699+
assert.doesNotMatch(tool.description, /supported built-in tools/);
36113700
assert.equal(typeof tool.execute, "function");
36123701
assert.equal(typeof tool.renderCall, "function");
36133702
assert.equal(typeof tool.renderResult, "function");
@@ -3616,3 +3705,19 @@ test("registerSpawnTool registers a tool with correct name and metadata", () =>
36163705
assert.ok(tool.parameters, "should have parameters");
36173706
assert.equal(tool.executionMode, undefined, "spawn should not be sequential");
36183707
});
3708+
3709+
test("spawn docs document active registered inheritance", async () => {
3710+
const readme = await readFile("README.md", "utf8");
3711+
const changelog = await readFile("CHANGELOG.md", "utf8");
3712+
const spawnSection = /### Spawn Isolate Noise[\s\S]*?### Notebook/.exec(readme)?.[0] ?? "";
3713+
const unreleased = /## \[Unreleased\][\s\S]*?## \[0\.3\.0\]/.exec(changelog)?.[0] ?? "";
3714+
3715+
assert.match(spawnSection, /active registered tools executable in the child session/);
3716+
assert.match(spawnSection, /MCP\/extension tools such as ChunkHound/);
3717+
assert.match(spawnSection, /[Cc]hild-local notebook tools/);
3718+
assert.match(spawnSection, /cannot spawn grandchildren or handoff/);
3719+
assert.doesNotMatch(spawnSection, /built-in tools only/);
3720+
assert.match(unreleased, /active registered parent tools/);
3721+
assert.match(unreleased, /spawn and handoff/);
3722+
assert.match(unreleased, /notebook tools/);
3723+
});

spawn/index.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* Spawn tool for the agenticoding extension.
33
*
44
* Creates an isolated in-memory child AgentSession for focused subtask execution.
5-
* Children inherit the parent's model, thinking level, cwd, and notebook access.
6-
* Children do not inherit the spawn tool (recursion prevention).
5+
* Children inherit the parent's model, thinking level, cwd, active registered
6+
* executable tools, and notebook access.
7+
* Children do not inherit the spawn or handoff tools (recursion prevention).
78
*
89
* Spawn is context isolation, not a security boundary. Child agents are trusted
910
* extensions of the parent and inherit parent authority by design.
@@ -108,16 +109,17 @@ function truncateResult(text: string): { text: string; truncated: boolean } {
108109
/**
109110
* Build the final list of tool names for a child session.
110111
*
111-
* Child sessions inherit the parent's active built-in tools plus the local
112-
* child custom tools defined here. Parent-only custom tools are intentionally
113-
* excluded so the child never advertises a tool it cannot execute.
112+
* Child sessions inherit parent tool names that are both active in the parent
113+
* and present in Pi's registered tool registry, regardless of source label.
114+
* Local child custom tools are added separately. Parent-only custom tools are
115+
* intentionally excluded so the child never advertises a tool it cannot execute.
114116
*
115117
* handoff and spawn never carry into children.
116118
*/
117119
function getInheritableParentToolNames(parentToolNames: string[], availableTools: Pick<ToolInfo, "name" | "sourceInfo">[]): string[] {
118120
const activeToolNames = new Set(parentToolNames);
119121
return availableTools
120-
.filter((tool) => activeToolNames.has(tool.name) && tool.sourceInfo?.source === "builtin")
122+
.filter((tool) => activeToolNames.has(tool.name))
121123
.map((tool) => tool.name);
122124
}
123125

@@ -137,7 +139,7 @@ export function buildChildToolNames(
137139

138140
const SPAWN_DESCRIPTION =
139141
"Spawn an isolated child agent for a focused subtask. " +
140-
"Child inherits parent model, thinking level, cwd, supported built-in tools, and shared notebook tools; children cannot spawn further children. " +
142+
"Child inherits parent model, thinking level, cwd, active registered tools executable in the child session, and shared notebook tools; children cannot spawn or handoff. " +
141143
"Reference notebook pages by name — child will notebook_read them on demand.";
142144

143145
const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent";
@@ -386,7 +388,7 @@ export async function executeSpawn(
386388
*
387389
* Creates a ToolDefinition that spawns an isolated child AgentSession
388390
* for focused subtasks. Children inherit the parent model, thinking
389-
* level, cwd, and notebook access.
391+
* level, cwd, active registered executable tools, and notebook access.
390392
*
391393
* @param pi - Extension API instance for tool registration
392394
* @param state - Shared session state (child sessions, epoch, notebook)

0 commit comments

Comments
 (0)