From 9380d5f2e4293fa4ebae8468b258f09dd0b22079 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:49:01 +0200 Subject: [PATCH] feat(coding-agent): add exclude tools option closes #5109 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 4 + packages/coding-agent/docs/sdk.md | 6 ++ packages/coding-agent/docs/usage.md | 4 + packages/coding-agent/src/cli/args.ts | 11 +++ .../src/core/agent-session-services.ts | 2 + .../coding-agent/src/core/agent-session.ts | 8 +- packages/coding-agent/src/core/sdk.ts | 13 +-- packages/coding-agent/src/main.ts | 4 + packages/coding-agent/test/args.test.ts | 10 +++ packages/coding-agent/test/suite/harness.ts | 6 ++ .../regressions/5109-exclude-tools.test.ts | 81 +++++++++++++++++++ 12 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5109-exclude-tools.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cffe6369..15a4327c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). + ### Fixed - Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 00fca81e..a326d2f0 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -551,6 +551,7 @@ cat README.md | pi -p "Summarize this text" | Option | Description | |--------|-------------| | `--tools `, `-t ` | Allowlist specific tool names across built-in, extension, and custom tools | +| `--exclude-tools `, `-xt ` | Disable specific tool names across built-in, extension, and custom tools | | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | @@ -619,6 +620,9 @@ pi --models "claude-*,gpt-4o" # Read-only mode pi --tools read,grep,find,ls -p "Review the code" +# Disable one extension or built-in tool while keeping the rest available +pi --exclude-tools ask_question + # High thinking level pi --thinking high "Solve this complex problem" ``` diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 8377185e..2be5f531 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -472,6 +472,7 @@ Specify which built-in tools to enable: - Default built-ins: `read`, `bash`, `edit`, `write` - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled +- `excludeTools` disables specific built-in, extension, or custom tool names after any `tools` allowlist is applied The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers. @@ -487,6 +488,11 @@ const { session } = await createAgentSession({ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); + +// Disable one tool while keeping the rest available +const { session } = await createAgentSession({ + excludeTools: ["ask_question"], +}); ``` #### Tools with Custom cwd diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ae69afaf..6e606912 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -184,6 +184,7 @@ cat README.md | pi -p "Summarize this text" | Option | Description | |--------|-------------| | `--tools `, `-t ` | Allowlist specific built-in, extension, and custom tools | +| `--exclude-tools `, `-xt ` | Disable specific built-in, extension, and custom tools | | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | @@ -255,6 +256,9 @@ pi --models "claude-*,gpt-4o" # Read-only mode pi --tools read,grep,find,ls -p "Review the code" + +# Disable one extension or built-in tool while keeping the rest available +pi --exclude-tools ask_question ``` ### Environment Variables diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 19053db5..72317cfd 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -28,6 +28,7 @@ export interface Args { sessionDir?: string; models?: string[]; tools?: string[]; + excludeTools?: string[]; noTools?: boolean; noBuiltinTools?: boolean; extensions?: string[]; @@ -113,6 +114,11 @@ export function parseArgs(args: string[]): Args { .split(",") .map((s) => s.trim()) .filter((name) => name.length > 0); + } else if ((arg === "--exclude-tools" || arg === "-xt") && i + 1 < args.length) { + result.excludeTools = args[++i] + .split(",") + .map((s) => s.trim()) + .filter((name) => name.length > 0); } else if (arg === "--thinking" && i + 1 < args.length) { const level = args[++i]; if (isValidThinkingLevel(level)) { @@ -237,6 +243,8 @@ ${chalk.bold("Options:")} --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled --tools, -t Comma-separated allowlist of tool names to enable Applies to built-in, extension, and custom tools + --exclude-tools, -xt Comma-separated denylist of tool names to disable + Applies to built-in, extension, and custom tools --thinking Set thinking level: off, minimal, low, medium, high, xhigh --extension, -e Load an extension file (can be used multiple times) --no-extensions, -ne Disable extension discovery (explicit -e paths still work) @@ -299,6 +307,9 @@ ${chalk.bold("Examples:")} # Read-only mode (no file modifications possible) ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/" + # Disable one tool while keeping the rest available + ${APP_NAME} --exclude-tools ask_question + # Export a session file to HTML ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl ${APP_NAME} --export session.jsonl output.html diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index adf6f9e0..d66adc13 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -54,6 +54,7 @@ export interface CreateAgentSessionFromServicesOptions { thinkingLevel?: ThinkingLevel; scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; tools?: string[]; + excludeTools?: CreateAgentSessionOptions["excludeTools"]; noTools?: CreateAgentSessionOptions["noTools"]; customTools?: ToolDefinition[]; } @@ -192,6 +193,7 @@ export async function createAgentSessionFromServices( thinkingLevel: options.thinkingLevel, scopedModels: options.scopedModels, tools: options.tools, + excludeTools: options.excludeTools, noTools: options.noTools, customTools: options.customTools, sessionStartEvent: options.sessionStartEvent, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4ac9d18c..608a6153 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -170,6 +170,8 @@ export interface AgentSessionConfig { initialActiveToolNames?: string[]; /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ allowedToolNames?: string[]; + /** Optional denylist of tool names. When provided, these tool names are not exposed. */ + excludedToolNames?: string[]; /** * Override base tools (useful for custom runtimes). * @@ -294,6 +296,7 @@ export class AgentSession { private _extensionRunnerRef?: { current?: ExtensionRunner }; private _initialActiveToolNames?: string[]; private _allowedToolNames?: Set; + private _excludedToolNames?: Set; private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; @@ -328,6 +331,7 @@ export class AgentSession { this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; + this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; @@ -2272,7 +2276,9 @@ export class AgentSession { const previousRegistryNames = new Set(this._toolRegistry.keys()); const previousActiveToolNames = this.getActiveToolNames(); const allowedToolNames = this._allowedToolNames; - const isAllowedTool = (name: string): boolean => !allowedToolNames || allowedToolNames.has(name); + const excludedToolNames = this._excludedToolNames; + const isAllowedTool = (name: string): boolean => + (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name); const registeredTools = this._extensionRunner.getAllRegisteredTools(); const allCustomTools = [ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 569b1a39..a4401177 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -65,6 +65,8 @@ export interface CreateAgentSessionOptions { * 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. */ + excludeTools?: string[]; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; @@ -279,11 +281,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); - const initialActiveToolNames: string[] = options.tools - ? [...options.tools] - : options.noTools - ? [] - : defaultActiveToolNames; + const excludedToolNames = options.excludeTools; + const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; + const initialActiveToolNames: string[] = ( + options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + ).filter((name) => !excludedToolNameSet?.has(name)); let agent: Agent; @@ -416,6 +418,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} modelRegistry, initialActiveToolNames, allowedToolNames, + excludedToolNames, extensionRunnerRef, sessionStartEvent: options.sessionStartEvent, }); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 04865b9a..71206d9d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -425,6 +425,9 @@ function buildSessionOptions( if (parsed.tools) { options.tools = [...parsed.tools]; } + if (parsed.excludeTools) { + options.excludeTools = [...parsed.excludeTools]; + } return { options, cliThinkingFromModel, diagnostics }; } @@ -646,6 +649,7 @@ export async function main(args: string[], options?: MainOptions) { thinkingLevel: sessionOptions.thinkingLevel, scopedModels: sessionOptions.scopedModels, tools: sessionOptions.tools, + excludeTools: sessionOptions.excludeTools, noTools: sessionOptions.noTools, customTools: sessionOptions.customTools, }); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 2f6d9ea9..8f26a0ee 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -308,6 +308,16 @@ describe("parseArgs", () => { expect(result.tools).toEqual(["read", "bash"]); }); + test("parses --exclude-tools flag", () => { + const result = parseArgs(["--exclude-tools", "read,bash"]); + expect(result.excludeTools).toEqual(["read", "bash"]); + }); + + test("parses -xt shorthand", () => { + const result = parseArgs(["-xt", "read,bash"]); + expect(result.excludeTools).toEqual(["read", "bash"]); + }); + test("parses --no-tools with explicit --tools flags", () => { const result = parseArgs(["--no-tools", "--tools", "read,bash"]); expect(result.noTools).toBe(true); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 090d5683..cec82013 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -60,6 +60,9 @@ export interface HarnessOptions { settings?: Partial; systemPrompt?: string; tools?: AgentTool[]; + initialActiveToolNames?: string[]; + allowedToolNames?: string[]; + excludedToolNames?: string[]; resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; @@ -173,6 +176,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise): string[] { + return tools.map((tool) => tool.name).sort(); +} + +describe("regression #5109: exclude tools", () => { + const extensionFactories: ExtensionFactory[] = [ + (pi) => { + pi.on("session_start", () => { + pi.registerTool({ + name: "ask_question", + label: "Ask Question", + description: "Ask a question", + promptSnippet: "Ask a question", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }); + pi.registerTool({ + name: "dynamic_tool", + label: "Dynamic Tool", + description: "Dynamic test tool", + promptSnippet: "Run dynamic test behavior", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }); + }); + }, + ]; + + it("filters built-in and extension tools from available and active tools", async () => { + const harness = await createHarness({ + excludedToolNames: ["read", "ask_question"], + extensionFactories, + }); + try { + await harness.session.bindExtensions({}); + + const allToolNames = toolNames(harness.session.getAllTools()); + expect(allToolNames).not.toContain("read"); + expect(allToolNames).not.toContain("ask_question"); + expect(allToolNames).toContain("bash"); + expect(allToolNames).toContain("dynamic_tool"); + expect(harness.session.getActiveToolNames().sort()).toEqual(["bash", "dynamic_tool", "edit", "write"]); + expect(harness.session.systemPrompt).not.toContain("- read:"); + expect(harness.session.systemPrompt).not.toContain("ask_question"); + expect(harness.session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); + } finally { + harness.cleanup(); + } + }); + + it("lets excluded tools override the allowlist", async () => { + const harness = await createHarness({ + allowedToolNames: ["read", "bash", "ask_question"], + excludedToolNames: ["read", "ask_question"], + initialActiveToolNames: ["read", "bash", "ask_question"], + extensionFactories, + }); + try { + await harness.session.bindExtensions({}); + + expect(toolNames(harness.session.getAllTools())).toEqual(["bash"]); + expect(harness.session.getActiveToolNames()).toEqual(["bash"]); + expect(harness.session.systemPrompt).toContain("- bash:"); + expect(harness.session.systemPrompt).not.toContain("- read:"); + expect(harness.session.systemPrompt).not.toContain("ask_question"); + } finally { + harness.cleanup(); + } + }); +});