From 6f98aa5eb8ea15bc3697da621aa3a0c436fcd0b9 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 29 May 2026 15:37:22 +0000 Subject: [PATCH 1/2] pi-agenticoding/04: inherit active registered spawn tools --- CHANGELOG.md | 6 ++ README.md | 2 +- agenticoding.test.ts | 143 +++++++++++++++++++++++++++++++++++++------ spawn/index.ts | 18 +++--- 4 files changed, 141 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c857049..4bc7d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- 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. + ## [0.3.0] - 2026-05-23 ### Added diff --git a/README.md b/README.md index 63df214..bc91c1c 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ The agent decided to spawn research children, save reusable findings to the note ### Spawn — Isolate Noise -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). +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. ### Notebook — Continuity Across Cuts diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 42d350c..3aeb1d5 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,5 +1,6 @@ import test, { after } from "node:test"; import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { registerHandoffCommand } from "./handoff/command.js"; @@ -102,6 +103,7 @@ class MockPi { tools = new Map(); handlers = new Map(); activeTools: string[] = []; + allToolNames: string[] | undefined; toolSources = new Map(); sentUserMessages: Array<{ content: string; options: any }> = []; appendedEntries: Array<{ customType: string; data: any }> = []; @@ -137,8 +139,17 @@ class MockPi { this.toolSources.set(name, source); } + setAllTools(tools: string[]) { + this.allToolNames = [...tools]; + for (const tool of tools) { + if (!this.toolSources.has(tool)) { + this.toolSources.set(tool, "builtin"); + } + } + } + getAllTools() { - return this.activeTools.map((name) => ({ + return (this.allToolNames ?? this.activeTools).map((name) => ({ name, description: "", parameters: {}, @@ -883,10 +894,48 @@ test("nested spawn rerenders when stats become unavailable", () => { assert.equal(after.some((l: string) => l.includes("initializing")), false); }); -test("spawn execute propagates only executable parent tools to child session", async () => { +test("agentic e2e spawn child can use active registered non-builtin tool", async () => { + const pi = new MockPi(); + pi.setToolSource("agentic_e2e_probe", "project"); + pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); + const state = createState(); + const sentinel = "AGENTIC_E2E_PROBE_OK"; + const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`; + + const mockFactory = async (config: any) => { + const session = { + messages: [] as any[], + prompt: async (prompt: string) => { + assert.match(prompt, /agentic_e2e_probe/); + if (!config.tools.includes("agentic_e2e_probe")) { + throw new Error("Child could not find tool agentic_e2e_probe"); + } + session.messages = [{ role: "assistant", content: [{ type: "text", text: sentinel }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + const result = await pi.tools.get("spawn").execute( + "spawn-e2e", + { prompt: childPrompt, thinking: "medium" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(result.content[0].text, sentinel); +}); + +test("spawn execute passes broad active registered tool formula to child session", async () => { const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn", "handoff", "future_tool"]); - pi.setToolSource("future_tool", "project"); + pi.setToolSource("project_search", "project"); + pi.setToolSource("inactive_registered", "extension"); + pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]); + pi.setAllTools(["read", "bash", "spawn", "handoff", "project_search", "inactive_registered"]); const state = createState(); let seenConfig: any; @@ -916,11 +965,11 @@ test("spawn execute propagates only executable parent tools to child session", a assert.equal(seenConfig.model.id, "mock-model"); assert.equal(seenConfig.thinkingLevel, "high"); assert.equal(seenConfig.cwd, "/tmp"); - assert.equal(seenConfig.tools.includes("read"), true); - assert.equal(seenConfig.tools.includes("bash"), true); - assert.equal(seenConfig.tools.includes("future_tool"), false); - assert.equal(seenConfig.tools.includes("handoff"), false); - assert.equal(seenConfig.tools.includes("spawn"), false); + assert.deepEqual( + new Set(seenConfig.tools), + new Set(["read", "bash", "project_search", "notebook_write", "notebook_read", "notebook_index"]), + ); + assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]); }); 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 () => { ); }); -test("child tool set omits spawn", () => { +test("child tool names inherit active registered builtins and exclude recursive controls", () => { const state = createState(); const childTools = createChildTools(new MockPi() as any, state); assert.equal(childTools.some(t => t.name === "spawn"), false); @@ -1208,9 +1257,10 @@ test("child tool set omits spawn", () => { { name: "future_tool", sourceInfo: { source: "project" } }, ] as any, ); + assert.equal(childToolNames.includes("read"), true); + assert.equal(childToolNames.includes("bash"), true); assert.equal(childToolNames.includes("spawn"), false); assert.equal(childToolNames.includes("handoff"), false); - assert.equal(childToolNames.includes("future_tool"), false); }); test("spawn renderResult transfers session ownership out of shared state", () => { @@ -1359,24 +1409,59 @@ test("executeSpawn suppresses stale child sessions after resetState during async assert.equal(state.liveChildSessions.get("spawn-1"), freshSession); }); -test("child tool names inherit builtin parent tools, exclude handoff and spawn", () => { +test("child tool names inherit active registered MCP extension tools", () => { const state = createState(); const childTools = createChildTools(new MockPi() as any, state); const toolNames = buildChildToolNames( - ["read", "bash", "handoff", "future_tool"], + ["read", "chunkhound_code_research", "mcp_status"], childTools, [ { name: "read", sourceInfo: { source: "builtin" } }, - { name: "bash", sourceInfo: { source: "builtin" } }, - { name: "handoff", sourceInfo: { source: "builtin" } }, - { name: "future_tool", sourceInfo: { source: "project" } }, + { name: "chunkhound_code_research", sourceInfo: { source: "extension" } }, + { name: "mcp_status", sourceInfo: { source: "extension" } }, + ] as any, + ); + + assert.equal(toolNames.includes("chunkhound_code_research"), true); + assert.equal(toolNames.includes("mcp_status"), true); +}); + +test("child tool names inherit active registered project package and local extension tools", () => { + const state = createState(); + const childTools = createChildTools(new MockPi() as any, state); + + const toolNames = buildChildToolNames( + ["project_search", "package_lint", "local_helper"], + childTools, + [ + { name: "project_search", sourceInfo: { source: "project" } }, + { name: "package_lint", sourceInfo: { source: "package" } }, + { name: "local_helper", sourceInfo: { source: "local" } }, ] as any, ); - assert.ok(toolNames.includes("read")); - assert.ok(toolNames.includes("bash")); - assert.equal(toolNames.includes("future_tool"), false); + assert.equal(toolNames.includes("project_search"), true); + assert.equal(toolNames.includes("package_lint"), true); + assert.equal(toolNames.includes("local_helper"), true); +}); + +test("child tool names exclude inactive registered and active phantom tools", () => { + const state = createState(); + const childTools = createChildTools(new MockPi() as any, state); + + const toolNames = buildChildToolNames( + ["read", "active_phantom"], + childTools, + [ + { name: "read", sourceInfo: { source: "builtin" } }, + { name: "inactive_registered", sourceInfo: { source: "extension" } }, + ] as any, + ); + + assert.equal(toolNames.includes("read"), true); + assert.equal(toolNames.includes("inactive_registered"), false); + assert.equal(toolNames.includes("active_phantom"), false); assert.ok(toolNames.includes("notebook_write")); assert.ok(toolNames.includes("notebook_read")); assert.ok(toolNames.includes("notebook_index")); @@ -3608,6 +3693,10 @@ test("registerSpawnTool registers a tool with correct name and metadata", () => assert.equal(tool.name, "spawn"); assert.equal(tool.label, "Spawn"); assert.equal(typeof tool.description, "string"); + assert.match(tool.description, /active registered tools executable in the child session/); + assert.match(tool.description, /shared notebook tools/); + assert.match(tool.description, /cannot spawn or handoff/); + assert.doesNotMatch(tool.description, /supported built-in tools/); assert.equal(typeof tool.execute, "function"); assert.equal(typeof tool.renderCall, "function"); assert.equal(typeof tool.renderResult, "function"); @@ -3616,3 +3705,19 @@ test("registerSpawnTool registers a tool with correct name and metadata", () => assert.ok(tool.parameters, "should have parameters"); assert.equal(tool.executionMode, undefined, "spawn should not be sequential"); }); + +test("spawn docs document active registered inheritance", async () => { + const readme = await readFile("README.md", "utf8"); + const changelog = await readFile("CHANGELOG.md", "utf8"); + const spawnSection = /### Spawn — Isolate Noise[\s\S]*?### Notebook/.exec(readme)?.[0] ?? ""; + const unreleased = /## \[Unreleased\][\s\S]*?## \[0\.3\.0\]/.exec(changelog)?.[0] ?? ""; + + assert.match(spawnSection, /active registered tools executable in the child session/); + assert.match(spawnSection, /MCP\/extension tools such as ChunkHound/); + assert.match(spawnSection, /[Cc]hild-local notebook tools/); + assert.match(spawnSection, /cannot spawn grandchildren or handoff/); + assert.doesNotMatch(spawnSection, /built-in tools only/); + assert.match(unreleased, /active registered parent tools/); + assert.match(unreleased, /spawn and handoff/); + assert.match(unreleased, /notebook tools/); +}); diff --git a/spawn/index.ts b/spawn/index.ts index f01b8ae..d344f8a 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -2,8 +2,9 @@ * Spawn tool for the agenticoding extension. * * Creates an isolated in-memory child AgentSession for focused subtask execution. - * Children inherit the parent's model, thinking level, cwd, and notebook access. - * Children do not inherit the spawn tool (recursion prevention). + * Children inherit the parent's model, thinking level, cwd, active registered + * executable tools, and notebook access. + * Children do not inherit the spawn or handoff tools (recursion prevention). * * Spawn is context isolation, not a security boundary. Child agents are trusted * extensions of the parent and inherit parent authority by design. @@ -108,16 +109,17 @@ function truncateResult(text: string): { text: string; truncated: boolean } { /** * Build the final list of tool names for a child session. * - * Child sessions inherit the parent's active built-in tools plus the local - * child custom tools defined here. Parent-only custom tools are intentionally - * excluded so the child never advertises a tool it cannot execute. + * Child sessions inherit parent tool names that are both active in the parent + * and present in Pi's registered tool registry, regardless of source label. + * Local child custom tools are added separately. Parent-only custom tools are + * intentionally excluded so the child never advertises a tool it cannot execute. * * handoff and spawn never carry into children. */ function getInheritableParentToolNames(parentToolNames: string[], availableTools: Pick[]): string[] { const activeToolNames = new Set(parentToolNames); return availableTools - .filter((tool) => activeToolNames.has(tool.name) && tool.sourceInfo?.source === "builtin") + .filter((tool) => activeToolNames.has(tool.name)) .map((tool) => tool.name); } @@ -137,7 +139,7 @@ export function buildChildToolNames( const SPAWN_DESCRIPTION = "Spawn an isolated child agent for a focused subtask. " + - "Child inherits parent model, thinking level, cwd, supported built-in tools, and shared notebook tools; children cannot spawn further children. " + + "Child inherits parent model, thinking level, cwd, active registered tools executable in the child session, and shared notebook tools; children cannot spawn or handoff. " + "Reference notebook pages by name — child will notebook_read them on demand."; const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent"; @@ -386,7 +388,7 @@ export async function executeSpawn( * * Creates a ToolDefinition that spawns an isolated child AgentSession * for focused subtasks. Children inherit the parent model, thinking - * level, cwd, and notebook access. + * level, cwd, active registered executable tools, and notebook access. * * @param pi - Extension API instance for tool registration * @param state - Shared session state (child sessions, epoch, notebook) From 463b3d3bfc2e3d0cf755f8f13a23f78a00a8f5af Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 3 Jun 2026 15:14:17 +0000 Subject: [PATCH 2/2] test: prove spawned child executes inherited tools --- CHANGELOG.md | 6 -- agenticoding.test.ts | 177 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 147 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc7d85..761e65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,9 +111,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [0.3.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/agenticoding/pi-agenticoding/releases/tag/v0.1.0 - -## [Unreleased] - -### Added - -- No changes yet. diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 3aeb1d5..5468314 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,7 +1,9 @@ import test, { after } from "node:test"; import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import type { Theme } from "@earendil-works/pi-coding-agent"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage, ModelRegistry, type Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffTool } from "./handoff/tool.js"; @@ -175,6 +177,43 @@ class MockPi { } } +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") { + return { + role: "assistant", + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: EMPTY_USAGE, + stopReason, + timestamp: Date.now(), + }; +} + +function createTestAssistantStream(message: any): any { + return { + async *[Symbol.asyncIterator]() { + yield { type: "done", reason: message.stopReason, message }; + }, + result: async () => message, + }; +} + +function messageText(message: any): string { + return (message.content ?? []) + .map((block: any) => block.type === "text" ? block.text : JSON.stringify(block)) + .join("\n"); +} + // ── TUI indicator tests ─────────────────────────────────────────────── function makeTUICtx( @@ -895,39 +934,117 @@ test("nested spawn rerenders when stats become unavailable", () => { }); test("agentic e2e spawn child can use active registered non-builtin tool", async () => { - const pi = new MockPi(); - pi.setToolSource("agentic_e2e_probe", "project"); - pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); - const state = createState(); + const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-")); + const tempCwd = join(tempRoot, "project"); + const tempAgentDir = join(tempRoot, "agent"); + const extensionDir = join(tempCwd, ".pi", "extensions"); const sentinel = "AGENTIC_E2E_PROBE_OK"; - const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`; + const oldAgentDir = process.env.PI_CODING_AGENT_DIR; + const oldOpenAiApiKey = process.env.OPENAI_API_KEY; + const parentRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + let streamCallCount = 0; - const mockFactory = async (config: any) => { - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - assert.match(prompt, /agentic_e2e_probe/); - if (!config.tools.includes("agentic_e2e_probe")) { - throw new Error("Child could not find tool agentic_e2e_probe"); + try { + await mkdir(extensionDir, { recursive: true }); + await mkdir(tempAgentDir, { recursive: true }); + await writeFile(join(tempCwd, "package.json"), JSON.stringify({ type: "module" })); + await writeFile( + join(extensionDir, "agentic-e2e-probe.js"), + ` +export default function(pi) { + pi.registerTool({ + name: "agentic_e2e_probe", + label: "Agentic E2E Probe", + description: "Return the deterministic Story 04 A10 sentinel.", + promptSnippet: "Call agentic_e2e_probe to return the Story 04 A10 sentinel.", + parameters: { type: "object", properties: {}, additionalProperties: false }, + async execute() { + globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1; + return { + content: [{ type: "text", text: "${sentinel}" }], + details: { sentinel: "${sentinel}" }, + }; + }, + }); +} +`, + ); + + process.env.PI_CODING_AGENT_DIR = tempAgentDir; + process.env.OPENAI_API_KEY = "test-openai-key"; + (globalThis as any).__agenticE2eProbeCalls = 0; + + parentRegistry.registerProvider("openai", { + name: "Agentic E2E OpenAI-compatible provider", + api: "agentic-e2e-api", + apiKey: "test-openai-key", + baseUrl: "http://localhost:0", + streamSimple: (model: any, context: any) => { + streamCallCount += 1; + if (streamCallCount === 1) { + const promptText = context.messages.map(messageText).join("\n"); + assert.match(promptText, /agentic_e2e_probe/); + assert.match(promptText, new RegExp(sentinel)); + return createTestAssistantStream(createTestAssistantMessage(model, [ + { type: "toolCall", id: "probe-call-1", name: "agentic_e2e_probe", arguments: {} }, + ], "tool_calls")); } - session.messages = [{ role: "assistant", content: [{ type: "text", text: sentinel }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - registerSpawnTool(pi as any, state, mockFactory as any); - const result = await pi.tools.get("spawn").execute( - "spawn-e2e", - { prompt: childPrompt, thinking: "medium" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); + const probeResult = context.messages.find((message: any) => + message.role === "toolResult" && + message.toolName === "agentic_e2e_probe" && + messageText(message).includes(sentinel) + ); + const text = probeResult ? sentinel : "AGENTIC_E2E_PROBE_MISSING"; + return createTestAssistantStream(createTestAssistantMessage(model, [{ type: "text", text }])); + }, + models: [{ + id: "agentic-e2e-model", + name: "Agentic E2E Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 1024, + }], + }); + const model = parentRegistry.find("openai", "agentic-e2e-model"); + assert.ok(model); + + const pi = new MockPi(); + pi.setToolSource("agentic_e2e_probe", "project"); + pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); + pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]); + const state = createState(); + const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`; + + registerSpawnTool(pi as any, state); + const result = await pi.tools.get("spawn").execute( + "spawn-e2e", + { prompt: childPrompt, thinking: "medium" }, + undefined, + undefined, + { model, cwd: tempCwd }, + ); - assert.equal(result.content[0].text, sentinel); + assert.equal(result.content[0].text, sentinel); + assert.equal((globalThis as any).__agenticE2eProbeCalls, 1); + assert.equal(streamCallCount, 2); + } finally { + parentRegistry.unregisterProvider("openai"); + if (oldAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = oldAgentDir; + } + if (oldOpenAiApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = oldOpenAiApiKey; + } + delete (globalThis as any).__agenticE2eProbeCalls; + await rm(tempRoot, { recursive: true, force: true }); + } }); test("spawn execute passes broad active registered tool formula to child session", async () => {