Skip to content

Commit 31f0b94

Browse files
authored
Merge pull request #10 from grzegorznowak/pi-agenticoding/story-04-inherit-trusted-spawn-tools-origin-main
Inherit active registered tools in spawn children
2 parents b25f51f + 463b3d3 commit 31f0b94

4 files changed

Lines changed: 259 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 6 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
@@ -105,9 +111,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
105111
[0.3.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.2.0...v0.3.0
106112
[0.2.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.1.0...v0.2.0
107113
[0.1.0]: https://github.com/agenticoding/pi-agenticoding/releases/tag/v0.1.0
108-
109-
## [Unreleased]
110-
111-
### Added
112-
113-
- No changes yet.

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: 242 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import test, { after } from "node:test";
22
import assert from "node:assert/strict";
3-
import type { Theme } from "@earendil-works/pi-coding-agent";
3+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { AuthStorage, ModelRegistry, type Theme } from "@earendil-works/pi-coding-agent";
47
import { Text } from "@earendil-works/pi-tui";
58
import { registerHandoffCommand } from "./handoff/command.js";
69
import { registerHandoffTool } from "./handoff/tool.js";
@@ -102,6 +105,7 @@ class MockPi {
102105
tools = new Map<string, any>();
103106
handlers = new Map<string, Handler[]>();
104107
activeTools: string[] = [];
108+
allToolNames: string[] | undefined;
105109
toolSources = new Map<string, string>();
106110
sentUserMessages: Array<{ content: string; options: any }> = [];
107111
appendedEntries: Array<{ customType: string; data: any }> = [];
@@ -137,8 +141,17 @@ class MockPi {
137141
this.toolSources.set(name, source);
138142
}
139143

144+
setAllTools(tools: string[]) {
145+
this.allToolNames = [...tools];
146+
for (const tool of tools) {
147+
if (!this.toolSources.has(tool)) {
148+
this.toolSources.set(tool, "builtin");
149+
}
150+
}
151+
}
152+
140153
getAllTools() {
141-
return this.activeTools.map((name) => ({
154+
return (this.allToolNames ?? this.activeTools).map((name) => ({
142155
name,
143156
description: "",
144157
parameters: {},
@@ -164,6 +177,43 @@ class MockPi {
164177
}
165178
}
166179

180+
const EMPTY_USAGE = {
181+
input: 0,
182+
output: 0,
183+
cacheRead: 0,
184+
cacheWrite: 0,
185+
totalTokens: 0,
186+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
187+
};
188+
189+
function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") {
190+
return {
191+
role: "assistant",
192+
content,
193+
api: model.api,
194+
provider: model.provider,
195+
model: model.id,
196+
usage: EMPTY_USAGE,
197+
stopReason,
198+
timestamp: Date.now(),
199+
};
200+
}
201+
202+
function createTestAssistantStream(message: any): any {
203+
return {
204+
async *[Symbol.asyncIterator]() {
205+
yield { type: "done", reason: message.stopReason, message };
206+
},
207+
result: async () => message,
208+
};
209+
}
210+
211+
function messageText(message: any): string {
212+
return (message.content ?? [])
213+
.map((block: any) => block.type === "text" ? block.text : JSON.stringify(block))
214+
.join("\n");
215+
}
216+
167217
// ── TUI indicator tests ───────────────────────────────────────────────
168218

169219
function makeTUICtx(
@@ -883,10 +933,126 @@ test("nested spawn rerenders when stats become unavailable", () => {
883933
assert.equal(after.some((l: string) => l.includes("initializing")), false);
884934
});
885935

886-
test("spawn execute propagates only executable parent tools to child session", async () => {
936+
test("agentic e2e spawn child can use active registered non-builtin tool", async () => {
937+
const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-"));
938+
const tempCwd = join(tempRoot, "project");
939+
const tempAgentDir = join(tempRoot, "agent");
940+
const extensionDir = join(tempCwd, ".pi", "extensions");
941+
const sentinel = "AGENTIC_E2E_PROBE_OK";
942+
const oldAgentDir = process.env.PI_CODING_AGENT_DIR;
943+
const oldOpenAiApiKey = process.env.OPENAI_API_KEY;
944+
const parentRegistry = ModelRegistry.inMemory(AuthStorage.inMemory());
945+
let streamCallCount = 0;
946+
947+
try {
948+
await mkdir(extensionDir, { recursive: true });
949+
await mkdir(tempAgentDir, { recursive: true });
950+
await writeFile(join(tempCwd, "package.json"), JSON.stringify({ type: "module" }));
951+
await writeFile(
952+
join(extensionDir, "agentic-e2e-probe.js"),
953+
`
954+
export default function(pi) {
955+
pi.registerTool({
956+
name: "agentic_e2e_probe",
957+
label: "Agentic E2E Probe",
958+
description: "Return the deterministic Story 04 A10 sentinel.",
959+
promptSnippet: "Call agentic_e2e_probe to return the Story 04 A10 sentinel.",
960+
parameters: { type: "object", properties: {}, additionalProperties: false },
961+
async execute() {
962+
globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1;
963+
return {
964+
content: [{ type: "text", text: "${sentinel}" }],
965+
details: { sentinel: "${sentinel}" },
966+
};
967+
},
968+
});
969+
}
970+
`,
971+
);
972+
973+
process.env.PI_CODING_AGENT_DIR = tempAgentDir;
974+
process.env.OPENAI_API_KEY = "test-openai-key";
975+
(globalThis as any).__agenticE2eProbeCalls = 0;
976+
977+
parentRegistry.registerProvider("openai", {
978+
name: "Agentic E2E OpenAI-compatible provider",
979+
api: "agentic-e2e-api",
980+
apiKey: "test-openai-key",
981+
baseUrl: "http://localhost:0",
982+
streamSimple: (model: any, context: any) => {
983+
streamCallCount += 1;
984+
if (streamCallCount === 1) {
985+
const promptText = context.messages.map(messageText).join("\n");
986+
assert.match(promptText, /agentic_e2e_probe/);
987+
assert.match(promptText, new RegExp(sentinel));
988+
return createTestAssistantStream(createTestAssistantMessage(model, [
989+
{ type: "toolCall", id: "probe-call-1", name: "agentic_e2e_probe", arguments: {} },
990+
], "tool_calls"));
991+
}
992+
993+
const probeResult = context.messages.find((message: any) =>
994+
message.role === "toolResult" &&
995+
message.toolName === "agentic_e2e_probe" &&
996+
messageText(message).includes(sentinel)
997+
);
998+
const text = probeResult ? sentinel : "AGENTIC_E2E_PROBE_MISSING";
999+
return createTestAssistantStream(createTestAssistantMessage(model, [{ type: "text", text }]));
1000+
},
1001+
models: [{
1002+
id: "agentic-e2e-model",
1003+
name: "Agentic E2E Model",
1004+
reasoning: false,
1005+
input: ["text"],
1006+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1007+
contextWindow: 128000,
1008+
maxTokens: 1024,
1009+
}],
1010+
});
1011+
const model = parentRegistry.find("openai", "agentic-e2e-model");
1012+
assert.ok(model);
1013+
1014+
const pi = new MockPi();
1015+
pi.setToolSource("agentic_e2e_probe", "project");
1016+
pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]);
1017+
pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]);
1018+
const state = createState();
1019+
const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`;
1020+
1021+
registerSpawnTool(pi as any, state);
1022+
const result = await pi.tools.get("spawn").execute(
1023+
"spawn-e2e",
1024+
{ prompt: childPrompt, thinking: "medium" },
1025+
undefined,
1026+
undefined,
1027+
{ model, cwd: tempCwd },
1028+
);
1029+
1030+
assert.equal(result.content[0].text, sentinel);
1031+
assert.equal((globalThis as any).__agenticE2eProbeCalls, 1);
1032+
assert.equal(streamCallCount, 2);
1033+
} finally {
1034+
parentRegistry.unregisterProvider("openai");
1035+
if (oldAgentDir === undefined) {
1036+
delete process.env.PI_CODING_AGENT_DIR;
1037+
} else {
1038+
process.env.PI_CODING_AGENT_DIR = oldAgentDir;
1039+
}
1040+
if (oldOpenAiApiKey === undefined) {
1041+
delete process.env.OPENAI_API_KEY;
1042+
} else {
1043+
process.env.OPENAI_API_KEY = oldOpenAiApiKey;
1044+
}
1045+
delete (globalThis as any).__agenticE2eProbeCalls;
1046+
await rm(tempRoot, { recursive: true, force: true });
1047+
}
1048+
});
1049+
1050+
test("spawn execute passes broad active registered tool formula to child session", async () => {
8871051
const pi = new MockPi();
888-
pi.setActiveTools(["read", "bash", "spawn", "handoff", "future_tool"]);
889-
pi.setToolSource("future_tool", "project");
1052+
pi.setToolSource("project_search", "project");
1053+
pi.setToolSource("inactive_registered", "extension");
1054+
pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]);
1055+
pi.setAllTools(["read", "bash", "spawn", "handoff", "project_search", "inactive_registered"]);
8901056
const state = createState();
8911057

8921058
let seenConfig: any;
@@ -916,11 +1082,11 @@ test("spawn execute propagates only executable parent tools to child session", a
9161082
assert.equal(seenConfig.model.id, "mock-model");
9171083
assert.equal(seenConfig.thinkingLevel, "high");
9181084
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);
1085+
assert.deepEqual(
1086+
new Set(seenConfig.tools),
1087+
new Set(["read", "bash", "project_search", "notebook_write", "notebook_read", "notebook_index"]),
1088+
);
1089+
assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]);
9241090
});
9251091

9261092
test("spawn execute builds prompt with notebook pages and task", async () => {
@@ -1193,7 +1359,7 @@ test("spawn execute fails explicitly without a configured model", async () => {
11931359
);
11941360
});
11951361

1196-
test("child tool set omits spawn", () => {
1362+
test("child tool names inherit active registered builtins and exclude recursive controls", () => {
11971363
const state = createState();
11981364
const childTools = createChildTools(new MockPi() as any, state);
11991365
assert.equal(childTools.some(t => t.name === "spawn"), false);
@@ -1208,9 +1374,10 @@ test("child tool set omits spawn", () => {
12081374
{ name: "future_tool", sourceInfo: { source: "project" } },
12091375
] as any,
12101376
);
1377+
assert.equal(childToolNames.includes("read"), true);
1378+
assert.equal(childToolNames.includes("bash"), true);
12111379
assert.equal(childToolNames.includes("spawn"), false);
12121380
assert.equal(childToolNames.includes("handoff"), false);
1213-
assert.equal(childToolNames.includes("future_tool"), false);
12141381
});
12151382

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

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

13661533
const toolNames = buildChildToolNames(
1367-
["read", "bash", "handoff", "future_tool"],
1534+
["read", "chunkhound_code_research", "mcp_status"],
13681535
childTools,
13691536
[
13701537
{ name: "read", sourceInfo: { source: "builtin" } },
1371-
{ name: "bash", sourceInfo: { source: "builtin" } },
1372-
{ name: "handoff", sourceInfo: { source: "builtin" } },
1373-
{ name: "future_tool", sourceInfo: { source: "project" } },
1538+
{ name: "chunkhound_code_research", sourceInfo: { source: "extension" } },
1539+
{ name: "mcp_status", sourceInfo: { source: "extension" } },
1540+
] as any,
1541+
);
1542+
1543+
assert.equal(toolNames.includes("chunkhound_code_research"), true);
1544+
assert.equal(toolNames.includes("mcp_status"), true);
1545+
});
1546+
1547+
test("child tool names inherit active registered project package and local extension tools", () => {
1548+
const state = createState();
1549+
const childTools = createChildTools(new MockPi() as any, state);
1550+
1551+
const toolNames = buildChildToolNames(
1552+
["project_search", "package_lint", "local_helper"],
1553+
childTools,
1554+
[
1555+
{ name: "project_search", sourceInfo: { source: "project" } },
1556+
{ name: "package_lint", sourceInfo: { source: "package" } },
1557+
{ name: "local_helper", sourceInfo: { source: "local" } },
1558+
] as any,
1559+
);
1560+
1561+
assert.equal(toolNames.includes("project_search"), true);
1562+
assert.equal(toolNames.includes("package_lint"), true);
1563+
assert.equal(toolNames.includes("local_helper"), true);
1564+
});
1565+
1566+
test("child tool names exclude inactive registered and active phantom tools", () => {
1567+
const state = createState();
1568+
const childTools = createChildTools(new MockPi() as any, state);
1569+
1570+
const toolNames = buildChildToolNames(
1571+
["read", "active_phantom"],
1572+
childTools,
1573+
[
1574+
{ name: "read", sourceInfo: { source: "builtin" } },
1575+
{ name: "inactive_registered", sourceInfo: { source: "extension" } },
13741576
] as any,
13751577
);
13761578

1377-
assert.ok(toolNames.includes("read"));
1378-
assert.ok(toolNames.includes("bash"));
1379-
assert.equal(toolNames.includes("future_tool"), false);
1579+
assert.equal(toolNames.includes("read"), true);
1580+
assert.equal(toolNames.includes("inactive_registered"), false);
1581+
assert.equal(toolNames.includes("active_phantom"), false);
13801582
assert.ok(toolNames.includes("notebook_write"));
13811583
assert.ok(toolNames.includes("notebook_read"));
13821584
assert.ok(toolNames.includes("notebook_index"));
@@ -3608,6 +3810,10 @@ test("registerSpawnTool registers a tool with correct name and metadata", () =>
36083810
assert.equal(tool.name, "spawn");
36093811
assert.equal(tool.label, "Spawn");
36103812
assert.equal(typeof tool.description, "string");
3813+
assert.match(tool.description, /active registered tools executable in the child session/);
3814+
assert.match(tool.description, /shared notebook tools/);
3815+
assert.match(tool.description, /cannot spawn or handoff/);
3816+
assert.doesNotMatch(tool.description, /supported built-in tools/);
36113817
assert.equal(typeof tool.execute, "function");
36123818
assert.equal(typeof tool.renderCall, "function");
36133819
assert.equal(typeof tool.renderResult, "function");
@@ -3616,3 +3822,19 @@ test("registerSpawnTool registers a tool with correct name and metadata", () =>
36163822
assert.ok(tool.parameters, "should have parameters");
36173823
assert.equal(tool.executionMode, undefined, "spawn should not be sequential");
36183824
});
3825+
3826+
test("spawn docs document active registered inheritance", async () => {
3827+
const readme = await readFile("README.md", "utf8");
3828+
const changelog = await readFile("CHANGELOG.md", "utf8");
3829+
const spawnSection = /### Spawn Isolate Noise[\s\S]*?### Notebook/.exec(readme)?.[0] ?? "";
3830+
const unreleased = /## \[Unreleased\][\s\S]*?## \[0\.3\.0\]/.exec(changelog)?.[0] ?? "";
3831+
3832+
assert.match(spawnSection, /active registered tools executable in the child session/);
3833+
assert.match(spawnSection, /MCP\/extension tools such as ChunkHound/);
3834+
assert.match(spawnSection, /[Cc]hild-local notebook tools/);
3835+
assert.match(spawnSection, /cannot spawn grandchildren or handoff/);
3836+
assert.doesNotMatch(spawnSection, /built-in tools only/);
3837+
assert.match(unreleased, /active registered parent tools/);
3838+
assert.match(unreleased, /spawn and handoff/);
3839+
assert.match(unreleased, /notebook tools/);
3840+
});

0 commit comments

Comments
 (0)