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.
This commit is contained in:
Armin Ronacher 2026-10-06 13:50:41 +02:00
parent 23cf2b948d
commit ddaa0a0341
9 changed files with 183 additions and 34 deletions

View file

@ -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))

View file

@ -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 <list>`<br>
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 <list>`<br>
Disables comma-separated tool names or patterns after all other selection options, MCP tools included.
- `-nbt`, `--no-builtin-tools`<br>
@ -125,7 +125,7 @@ See [Settings](settings.md#tools) for configuring the default tool selection.
- `-nt`, `--no-tools`<br>
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.
<a id="mcp-tools"></a>
@ -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)).

View file

@ -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

View file

@ -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 <tools> 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 <tools> Comma-separated denylist of tool names or patterns (*) to disable
Applies to all tools, MCP tools included
--thinking <level> 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__*'

View file

@ -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<string>();
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({

View file

@ -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,

View file

@ -214,10 +214,41 @@ function deepMergeObjects(base: Record<string, unknown>, overrides: Record<strin
/** Tools enabled at startup when `defaultTools` does not change them. */
export const DEFAULT_TOOL_NAMES: readonly string[] = ["read", "bash", "edit", "write"];
function isToolModifier(entry: unknown): boolean {
/** Whether a tool selection entry is a `+name` or `-name` modifier. */
export function isToolModifier(entry: unknown): boolean {
return typeof entry === "string" && (entry.startsWith("+") || entry.startsWith("-"));
}
/**
* Validate a tool list from `--tools` or the SDK `tools` option. It is either an allowlist of plain
* names and patterns or a list of only `+name`/`-name` entries with exact names. Returns the
* problem, or undefined when the list is valid.
*/
export function getToolListError(entries: readonly string[]): string | undefined {
const modifiers = entries.filter(isToolModifier);
if (modifiers.length === 0) return undefined;
if (modifiers.length < entries.length) return "tool names cannot be mixed with +name or -name entries";
const pattern = modifiers.find((entry) => 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 */

View file

@ -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"]);

View file

@ -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"] });