From ddaa0a0341a84b073a087a3d89b9b9e7fbdaf6ba Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 6 Oct 2026 13:50:41 +0200 Subject: [PATCH] feat(coding-agent): accept +name/-name entries in --tools A --tools list of only +name/-name entries changes the default tool selection like the defaultTools setting, e.g. pi -t +codemode. Mixed lists and patterns in modifiers are rejected, and /reload keeps tools removed with -name removed. --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/cli.md | 8 +-- packages/coding-agent/docs/settings.md | 4 +- packages/coding-agent/src/cli/args.ts | 14 ++++- .../coding-agent/src/core/agent-session.ts | 31 ++++++++--- packages/coding-agent/src/core/sdk.ts | 33 ++++++++--- .../coding-agent/src/core/settings-manager.ts | 43 +++++++++++---- packages/coding-agent/test/args.test.ts | 25 +++++++++ .../test/default-tools-setting.test.ts | 55 +++++++++++++++++++ 9 files changed, 183 insertions(+), 34 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fc107bb4c..f057f2832 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `+name` and `-name` entries to `--tools`, which change the default tool selection instead of replacing it, for example `pi -t +codemode` + ### Fixed - Fixed managed installs keeping every old release; `pi update` now keeps only the new release and the one it updated from ([#10392](https://github.com/earendil-works/pi/issues/10392)) diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index 4550a3feb..bbb11111a 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -117,7 +117,7 @@ pi --tools read,grep,find,ls --print "Review this project" See [Settings](settings.md#tools) for configuring the default tool selection. - `-t`, `--tools `
- Replaces the default selection with a comma-separated allowlist of built-in, extension, or custom tools. Entries are tool names or patterns where `*` matches any characters. MCP tools are kept unless an entry starts with `mcp__` (see [MCP tools](#mcp-tools)). + Replaces the default selection with a comma-separated allowlist of built-in, extension, or custom tools. Entries are tool names or patterns where `*` matches any characters. MCP tools are kept unless an entry starts with `mcp__` (see [MCP tools](#mcp-tools)). A list of only `+name` and `-name` entries is not an allowlist; it changes the default selection instead. - `-xt`, `--exclude-tools `
Disables comma-separated tool names or patterns after all other selection options, MCP tools included. - `-nbt`, `--no-builtin-tools`
@@ -125,7 +125,7 @@ See [Settings](settings.md#tools) for configuring the default tool selection. - `-nt`, `--no-tools`
Starts with all built-in, extension, custom, and MCP tools disabled. -Default enabled tools are `read`, `bash`, `edit`, and `write`, unless `defaultTools` changes them. `--tools` replaces the whole selection, so name every tool you want; `defaultTools` also accepts `+name` and `-name` to change the defaults instead. +Default enabled tools are `read`, `bash`, `edit`, and `write`, unless `defaultTools` changes them. `--tools` with plain names replaces the whole selection, so name every tool you want. Like `defaultTools`, it also accepts a list of only `+name` and `-name` entries, which adds tools to or removes them from the default selection: `pi --tools +codemode,-write` keeps the other default tools, enables `codemode`, and disables `write`. These entries take exact tool names, not `*` patterns; use `--exclude-tools` to disable tools by pattern. Plain names and `+name`/`-name` entries cannot be mixed. `/reload` enables tools newly added to `defaultTools`, but a tool removed with `-name` stays removed. @@ -165,10 +165,10 @@ To turn on `codemode` for every session, add it to the default tools in `~/.pi/a } ``` -This keeps `read`, `bash`, `edit`, and `write` and adds `codemode`. For one invocation, list every tool, since `--tools` replaces the selection: +This keeps `read`, `bash`, `edit`, and `write` and adds `codemode`. For one invocation, add it with `--tools`: ```sh -pi --tools read,bash,edit,write,codemode +pi --tools +codemode ``` Codemode is useful without MCP: scripts can run several tool calls in parallel, filter large output before it reaches the model, call classifier models such as TypeSafe's Jev through `models.classify()` (see [Classifier models](models.md#use-classifier-models)), and generate images through `models.generateImages()` (see [Image models](models.md#use-image-models)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 02f46e634..0a2e0d9a0 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -53,9 +53,9 @@ A list of only `+name` and `-name` entries changes the inherited selection inste This replaces `bash` with `powershell` and enables `grep`: `["-bash", "+powershell", "+grep"]`. Project settings apply on top of user settings: a project list with only `+name` and `-name` entries changes the user's selection, and a project list with a plain name replaces it. In one list, plain names form the selection, and `+name` and `-name` then apply in order. -`/reload` enables tools newly added to `defaultTools`. It does not disable tools removed from it or re-enable unchanged tools you turned off. `--tools`, `--no-tools`, and `--no-builtin-tools` override `defaultTools`, also on reload. +`/reload` enables tools newly added to `defaultTools`. It does not disable tools removed from it or re-enable unchanged tools you turned off. `--tools` with plain names, `--no-tools`, and `--no-builtin-tools` override `defaultTools`, also on reload. -CLI tool options override this setting for one invocation; `--tools` does not accept `+name` or `-name`. See [Command Line](cli.md#tools). +CLI tool options override this setting for one invocation. `--tools` with only `+name` and `-name` entries changes the resolved `defaultTools` selection instead, for example `pi --tools +codemode`. On `/reload`, these entries apply to the reloaded setting too, so a tool removed with `-name` stays removed. See [Command Line](cli.md#tools). ## Sessions and context diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index c994db011..38d268a9f 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -6,7 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts"; import type { ExtensionFlag } from "../core/extensions/types.ts"; -import type { TuiMode } from "../core/settings-manager.ts"; +import { getToolListError, type TuiMode } from "../core/settings-manager.ts"; export type Mode = "text" | "json" | "rpc"; @@ -149,10 +149,16 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--no-builtin-tools" || arg === "-nbt") { result.noBuiltinTools = true; } else if ((arg === "--tools" || arg === "-t") && i + 1 < args.length) { - result.tools = args[++i] + const tools = args[++i] .split(",") .map((s) => s.trim()) .filter((name) => name.length > 0); + const error = getToolListError(tools); + if (error) { + result.diagnostics.push({ type: "error", message: `${arg}: ${error}` }); + } else { + result.tools = tools; + } } else if ((arg === "--exclude-tools" || arg === "-xt") && i + 1 < args.length) { result.excludeTools = args[++i] .split(",") @@ -313,6 +319,7 @@ ${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 or patterns (*) to enable Keeps MCP tools unless an entry starts with mcp__ + Only +name/-name entries add to or remove from the defaults --exclude-tools, -xt Comma-separated denylist of tool names or patterns (*) to disable Applies to all tools, MCP tools included --thinking Set thinking level: off, minimal, low, medium, high, xhigh, max @@ -395,6 +402,9 @@ ${chalk.bold("Examples:")} # Read-only mode (no file modifications possible) ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/" + # Add codemode to the default tools + ${APP_NAME} --tools +codemode + # Codemode with only the tools of one MCP server ${APP_NAME} --tools read,bash,codemode,'mcp__radius__*' diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 95b96350d..7388024e0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -130,7 +130,12 @@ import { SessionManager, type SessionProjection, } from "./session-manager.ts"; -import { type CacheWarmingMode, DEFAULT_TOOL_NAMES, type SettingsManager } from "./settings-manager.ts"; +import { + applyToolModifiers, + type CacheWarmingMode, + DEFAULT_TOOL_NAMES, + type SettingsManager, +} from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { BUILTIN_PATH_PREFIX, createSyntheticSourceInfo, isSyntheticPath, type SourceInfo } from "./source-info.ts"; import { @@ -266,6 +271,11 @@ export interface AgentSessionConfig { * tools newly added to the setting. Tools removed from it stay active. */ usesDefaultTools?: boolean; + /** + * `+name`/`-name` entries applied on top of the `defaultTools` setting, from `--tools`. Reload + * applies them to the reloaded setting too, so a removed tool stays removed. + */ + defaultToolModifiers?: string[]; /** * Optional allowlist of tool names or patterns (`*` matches any characters). When provided, only * matching tools are exposed. A non-empty list without `mcp__` entries also keeps MCP tools @@ -436,6 +446,7 @@ export class AgentSession { */ private _pendingToolNames = new Set(); private _usesDefaultTools: boolean; + private _defaultToolModifiers: string[]; /** Matches the `--tools` entries: tool names or patterns. */ private _allowedTools?: (name: string) => boolean; /** @@ -487,6 +498,7 @@ export class AgentSession { this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._usesDefaultTools = config.usesDefaultTools ?? false; + this._defaultToolModifiers = config.defaultToolModifiers ?? []; if (config.allowedToolNames) { this._allowedTools = createToolNameMatcher(config.allowedToolNames); this._allowlistFiltersMcp = @@ -3648,20 +3660,21 @@ export class AgentSession { const previousFlagValues = oldRunner.getFlagValues(); await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" }); oldRunner.invalidate(); - const previousDefaultTools = new Set( - this._usesDefaultTools ? (this.settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES) : [], - ); + const getDefaultTools = () => + this._usesDefaultTools + ? applyToolModifiers( + this.settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES, + this._defaultToolModifiers, + ) + : []; + const previousDefaultTools = new Set(getDefaultTools()); await this.settingsManager.reload(); this.syncQueueModesFromSettings(); resetApiProviders(); await this._resourceLoader.reload(); // Activate tools newly added to defaultTools. Removed ones stay active, and tools disabled // during the session stay disabled unless the setting newly adds them. - const addedDefaultTools = this._usesDefaultTools - ? (this.settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES).filter( - (name) => !previousDefaultTools.has(name), - ) - : []; + const addedDefaultTools = getDefaultTools().filter((name) => !previousDefaultTools.has(name)); // Tools the new extensions register later, such as MCP tools, are pending until then. for (const name of this.getActiveToolNames()) this._pendingToolNames.add(name); this._buildRuntime({ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 09bb92fa0..f678ba43c 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -17,7 +17,13 @@ import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; import type { ResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts"; import { getDefaultSessionDir, SessionManager } from "./session-manager.ts"; -import { DEFAULT_TOOL_NAMES, SettingsManager } from "./settings-manager.ts"; +import { + applyToolModifiers, + DEFAULT_TOOL_NAMES, + getToolListError, + isToolModifier, + SettingsManager, +} from "./settings-manager.ts"; import { time } from "./timings.ts"; import { createBashTool, @@ -73,6 +79,10 @@ export interface CreateAgentSessionOptions { * enabled. MCP tools stay registered for codemode and tool search unless an * entry starts with `mcp__`; then only matching MCP tools are kept. An empty * list, like `noTools: "all"`, disables MCP tools too. + * + * A list of only `+name` and `-name` entries is not an allowlist: it adds tools + * to or removes them from the default selection, like the `defaultTools` setting. + * These entries take exact names. Mixing them with plain entries throws. */ tools?: string[]; /** @@ -267,13 +277,21 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel; } - const configuredDefaultToolNames = settingsManager.getDefaultTools(); - const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); + const toolListError = options.tools ? getToolListError(options.tools) : undefined; + if (toolListError) throw new Error(`Invalid tools option: ${toolListError}`); + const defaultToolNames = options.noTools ? [] : (settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES); + // A `tools` list of only `+name`/`-name` entries changes the default selection instead of + // replacing it, like the `defaultTools` setting. + const toolModifiers = options.tools?.some(isToolModifier) ? options.tools : undefined; + const selectedToolNames = toolModifiers ? applyToolModifiers(defaultToolNames, toolModifiers) : options.tools; + const allowedToolNames = toolModifiers + ? options.noTools === "all" + ? selectedToolNames + : undefined + : (options.tools ?? (options.noTools === "all" ? [] : undefined)); const excludedToolNames = options.excludeTools; const isExcludedTool = excludedToolNames ? createToolNameMatcher(excludedToolNames) : undefined; - const initialActiveToolNames = ( - options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? DEFAULT_TOOL_NAMES)) - ).filter((name) => !isExcludedTool?.(name)); + const initialActiveToolNames = (selectedToolNames ?? defaultToolNames).filter((name) => !isExcludedTool?.(name)); // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { @@ -451,7 +469,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} modelRuntime, cacheWarmer, initialActiveToolNames, - usesDefaultTools: options.tools === undefined && !options.noTools, + usesDefaultTools: (options.tools === undefined || toolModifiers !== undefined) && !options.noTools, + defaultToolModifiers: toolModifiers, allowedToolNames, excludedToolNames, extensionRunnerRef, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 6593b5238..3c615c39b 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -214,10 +214,41 @@ function deepMergeObjects(base: Record, overrides: Record entry.includes("*")); + if (pattern) return `+name and -name entries take exact tool names, not patterns: ${pattern}`; + return undefined; +} + +/** + * Apply the `+name` and `-name` entries of `entries` to `base` in order: `+name` adds a tool and + * `-name` removes one. Other entries are ignored. + */ +export function applyToolModifiers(base: readonly string[], entries: readonly string[]): string[] { + const tools = [...base]; + for (const entry of entries) { + if (!isToolModifier(entry)) continue; + const name = entry.slice(1); + const index = tools.indexOf(name); + if (entry.startsWith("+") && index === -1 && name) tools.push(name); + else if (entry.startsWith("-") && index !== -1) tools.splice(index, 1); + } + return tools; +} + /** * Merge `defaultTools` of two settings layers. A list with plain tool names replaces the inherited * one; a list of only `+name`/`-name` entries is appended, so it modifies the inherited selection. @@ -235,15 +266,7 @@ function mergeDefaultTools(base: string[] | undefined, overrides: string[] | und */ function resolveDefaultTools(entries: string[]): string[] { const plain = entries.filter((entry) => !isToolModifier(entry)); - const tools = plain.length > 0 || entries.length === 0 ? plain : [...DEFAULT_TOOL_NAMES]; - for (const entry of entries) { - if (!isToolModifier(entry)) continue; - const name = entry.slice(1); - const index = tools.indexOf(name); - if (entry.startsWith("+") && index === -1 && name) tools.push(name); - else if (entry.startsWith("-") && index !== -1) tools.splice(index, 1); - } - return tools; + return applyToolModifiers(plain.length > 0 || entries.length === 0 ? plain : DEFAULT_TOOL_NAMES, entries); } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index a09334821..2874bafd4 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -465,6 +465,31 @@ describe("parseArgs", () => { expect(result.tools).toEqual(["read", "bash"]); }); + test("parses +name and -name tool modifiers", () => { + const result = parseArgs(["-t", "+codemode,-write"]); + expect(result.tools).toEqual(["+codemode", "-write"]); + expect(result.diagnostics).toEqual([]); + }); + + test("rejects tool names mixed with modifiers", () => { + const result = parseArgs(["--tools", "read,+codemode"]); + expect(result.tools).toBeUndefined(); + expect(result.diagnostics).toEqual([ + { type: "error", message: "--tools: tool names cannot be mixed with +name or -name entries" }, + ]); + }); + + test("rejects patterns in tool modifiers", () => { + const result = parseArgs(["-t", "+mcp__radius__*"]); + expect(result.tools).toBeUndefined(); + expect(result.diagnostics).toEqual([ + { + type: "error", + message: "-t: +name and -name entries take exact tool names, not patterns: +mcp__radius__*", + }, + ]); + }); + test("parses --exclude-tools flag", () => { const result = parseArgs(["--exclude-tools", "read,bash"]); expect(result.excludeTools).toEqual(["read", "bash"]); diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 9030fb360..91f7361ec 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -156,6 +156,50 @@ describe("defaultTools setting", () => { toolLessSession.dispose(); }); + it("applies +name and -name tool options to the default selection", async () => { + const inactiveTool: InlineExtension = (pi) => { + pi.registerTool({ + name: "inactive_tool", + label: "Inactive Tool", + description: "Extension tool registered inactive", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + defaultActive: false, + }); + pi.registerTool({ + name: "active_tool", + label: "Active Tool", + description: "Extension tool registered active", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + }; + + const session = await createSession(["+grep"], { tools: ["+inactive_tool", "-write"] }, [inactiveTool]); + expect(session.getActiveToolNames().sort()).toEqual([ + "active_tool", + "bash", + "edit", + "grep", + "inactive_tool", + "read", + ]); + session.dispose(); + + const toolLess = await createSession(["read"], { noTools: "all", tools: ["+inactive_tool"] }, [inactiveTool]); + expect(toolLess.getActiveToolNames()).toEqual(["inactive_tool"]); + toolLess.dispose(); + }); + + it("rejects invalid tool modifier options", async () => { + await expect(createSession([], { tools: ["read", "+grep"] })).rejects.toThrow( + "Invalid tools option: tool names cannot be mixed with +name or -name entries", + ); + await expect(createSession([], { tools: ["-gr*"] })).rejects.toThrow( + "Invalid tools option: +name and -name entries take exact tool names, not patterns: -gr*", + ); + }); + describe("reload", () => { const inactiveTool: InlineExtension = (pi) => { pi.registerTool({ @@ -211,6 +255,17 @@ describe("defaultTools setting", () => { session.dispose(); }); + it("keeps tools removed by -name tool options removed on reload", async () => { + writeSettings({ defaultTools: ["read"] }); + const session = await createFileSession({ tools: ["-bash", "+grep"] }); + expect(session.getActiveToolNames()).toEqual(["read", "grep"]); + + writeSettings({ defaultTools: ["read", "bash", "inactive_tool"] }); + await session.reload(); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]); + session.dispose(); + }); + it("keeps explicit tool options on reload", async () => { const allowlisted = await createFileSession({ tools: ["read"] }); writeSettings({ defaultTools: ["+grep"] });