From 541045ae0e30ac0375fde429f8a8baa057c009db Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 12 Aug 2026 15:42:55 +0200 Subject: [PATCH] fix(coding-agent): preserve extension tools with defaults --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/settings.md | 6 +- packages/coding-agent/src/core/sdk.ts | 13 ++-- .../coding-agent/src/core/settings-manager.ts | 2 +- .../test/default-tools-setting.test.ts | 78 +++++++++++++++++-- 5 files changed, 82 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a0ac089b..27ec5cbd2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,7 +7,7 @@ - Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. - Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. -- Added the `defaultTools` setting for configuring the initial tool allowlist globally or per project. +- Added the `defaultTools` setting for configuring the initial built-in tool selection globally or per project. ### Changed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index aa777f81f..cb48caee9 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -204,9 +204,9 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `defaultTools` | string[] | - | Initial tool allowlist. When omitted, Pi uses its standard defaults | +| `defaultTools` | string[] | - | Built-in tools enabled initially. When omitted, Pi uses its standard defaults | -`defaultTools` applies to built-in, extension, and custom tools, like the `--tools` CLI option: +`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled: ```json { @@ -214,7 +214,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic } ``` -An empty array starts with no tools. `--tools`, `--no-tools`, and `--no-builtin-tools` override this setting; `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. +An empty array starts with no built-in tools while preserving extension and SDK custom tools. `--tools` replaces this behavior with a strict allowlist for all tools, `--no-tools` disables all tools, and `--no-builtin-tools` disables the built-in defaults. `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. ### Sessions diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5d962e00a..a9a266418 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -62,10 +62,11 @@ export interface CreateAgentSessionOptions { /** * Optional allowlist of tool names. * - * When omitted, pi uses the `defaultTools` setting when configured. Otherwise it - * enables the default built-in tools (read, bash, edit, write) and leaves - * extension/custom tools enabled unless `noTools` changes that default. When - * provided, only the listed tool names are enabled. + * When omitted, pi uses the `defaultTools` setting for the initial built-in + * selection when configured. Otherwise it enables the default built-in tools + * (read, bash, edit, write). Extension/custom tools remain enabled unless + * `noTools` changes that default. When provided, only the listed tool names are + * enabled. */ tools?: string[]; /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ @@ -245,9 +246,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const configuredDefaultToolNames = settingsManager.getDefaultTools(); - const allowedToolNames = - options.tools ?? - (options.noTools === "all" ? [] : options.noTools === undefined ? configuredDefaultToolNames : undefined); + const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); const excludedToolNames = options.excludeTools; const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; const initialActiveToolNames = ( diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index b1fa11304..9a744f027 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -120,7 +120,7 @@ export interface Settings { terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) - defaultTools?: string[]; // Initial tool allowlist (same format as --tools CLI flag) + defaultTools?: string[]; // Initial built-in tool selection doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index f3130ec21..69adbceb5 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -2,14 +2,15 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.ts"; import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; -import { type CreateAgentSessionOptions, createAgentSession } from "../src/core/sdk.ts"; +import { type CreateAgentSessionOptions, createAgentSession, type InlineExtension } from "../src/core/sdk.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -type ToolOptions = Pick; +type ToolOptions = Pick; describe("defaultTools setting", () => { let tempDir: string; @@ -27,9 +28,18 @@ describe("defaultTools setting", () => { } }); - async function createSession(defaultTools: string[], options: ToolOptions = {}) { + async function createSession( + defaultTools: string[], + options: ToolOptions = {}, + extensionFactories: InlineExtension[] = [], + ) { const settingsManager = SettingsManager.inMemory({ defaultTools }); - const resourceLoader = new DefaultResourceLoader({ cwd: tempDir, agentDir, settingsManager }); + const resourceLoader = new DefaultResourceLoader({ + cwd: tempDir, + agentDir, + settingsManager, + extensionFactories, + }); await resourceLoader.reload(); return ( @@ -45,16 +55,65 @@ describe("defaultTools setting", () => { ).session; } - it("uses the configured list as the initial tool allowlist", async () => { + it("uses the configured list as the initial built-in selection", async () => { const session = await createSession(["grep", "find"]); - expect(session.getAllTools().map((tool) => tool.name)).toEqual(["grep", "find"]); + expect( + session + .getAllTools() + .map((tool) => tool.name) + .sort(), + ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["grep", "find"]); expect(session.systemPrompt).toContain("- grep:"); expect(session.systemPrompt).not.toContain("- read:"); session.dispose(); }); + it("keeps extension and SDK custom tools enabled", async () => { + const session = await createSession( + ["grep"], + { + customTools: [ + { + name: "sdk_tool", + label: "SDK Tool", + description: "SDK custom tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + ], + }, + [ + (pi) => { + pi.registerTool({ + name: "static_tool", + label: "Static Tool", + description: "Statically registered extension tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + pi.on("session_start", () => { + pi.registerTool({ + name: "dynamic_tool", + label: "Dynamic Tool", + description: "Dynamically registered extension tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + }); + }, + ], + ); + await session.bindExtensions({}); + + expect(session.getActiveToolNames().sort()).toEqual(["dynamic_tool", "grep", "sdk_tool", "static_tool"]); + expect(session.getAllTools().map((tool) => tool.name)).toEqual( + expect.arrayContaining(["read", "dynamic_tool", "sdk_tool", "static_tool"]), + ); + session.dispose(); + }); + it("preserves explicit tool option precedence", async () => { const allowlistedSession = await createSession(["grep"], { tools: ["read"] }); expect(allowlistedSession.getActiveToolNames()).toEqual(["read"]); @@ -79,7 +138,12 @@ describe("defaultTools setting", () => { model: getModel("anthropic", "claude-sonnet-4-5")!, }); - expect(session.getAllTools().map((tool) => tool.name)).toEqual(["ls"]); + expect( + session + .getAllTools() + .map((tool) => tool.name) + .sort(), + ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["ls"]); session.dispose(); });