diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 95010b1fa..4a0ac089b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +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. ### Changed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index ec8aa26cc..aa777f81f 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -200,6 +200,22 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic `npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers. +### Tools + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `defaultTools` | string[] | - | Initial tool allowlist. When omitted, Pi uses its standard defaults | + +`defaultTools` applies to built-in, extension, and custom tools, like the `--tools` CLI option: + +```json +{ + "defaultTools": ["bash", "edit", "write"] +} +``` + +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. + ### Sessions | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7f662d330..5d962e00a 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -62,9 +62,10 @@ export interface CreateAgentSessionOptions { /** * Optional allowlist of tool names. * - * When omitted, pi 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 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. */ tools?: string[]; /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ @@ -243,11 +244,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; - const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); + const configuredDefaultToolNames = settingsManager.getDefaultTools(); + const allowedToolNames = + options.tools ?? + (options.noTools === "all" ? [] : options.noTools === undefined ? configuredDefaultToolNames : undefined); const excludedToolNames = options.excludeTools; const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; - const initialActiveToolNames: string[] = ( - options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + const initialActiveToolNames = ( + options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? defaultActiveToolNames)) ).filter((name) => !excludedToolNameSet?.has(name)); let agent: Agent; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index d18916a5c..b1fa11304 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -120,6 +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) 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 @@ -1188,6 +1189,11 @@ export class SettingsManager { return this.settings.enabledModels; } + getDefaultTools(): string[] | undefined { + const tools = this.settings.defaultTools; + return tools ? [...tools] : undefined; + } + setEnabledModels(patterns: string[] | undefined): void { this.globalSettings.enabledModels = patterns; this.markModified("enabledModels"); diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts new file mode 100644 index 000000000..f3130ec21 --- /dev/null +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -0,0 +1,86 @@ +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 { 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 { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +type ToolOptions = Pick; + +describe("defaultTools setting", () => { + let tempDir: string; + let agentDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-default-tools-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + mkdirSync(agentDir, { recursive: true }); + }); + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function createSession(defaultTools: string[], options: ToolOptions = {}) { + const settingsManager = SettingsManager.inMemory({ defaultTools }); + const resourceLoader = new DefaultResourceLoader({ cwd: tempDir, agentDir, settingsManager }); + await resourceLoader.reload(); + + return ( + await createAgentSession({ + cwd: tempDir, + agentDir, + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager, + sessionManager: SessionManager.inMemory(tempDir), + resourceLoader, + ...options, + }) + ).session; + } + + it("uses the configured list as the initial tool allowlist", async () => { + const session = await createSession(["grep", "find"]); + + expect(session.getAllTools().map((tool) => tool.name)).toEqual(["grep", "find"]); + expect(session.getActiveToolNames()).toEqual(["grep", "find"]); + expect(session.systemPrompt).toContain("- grep:"); + expect(session.systemPrompt).not.toContain("- read:"); + session.dispose(); + }); + + it("preserves explicit tool option precedence", async () => { + const allowlistedSession = await createSession(["grep"], { tools: ["read"] }); + expect(allowlistedSession.getActiveToolNames()).toEqual(["read"]); + allowlistedSession.dispose(); + + const excludedSession = await createSession(["read", "grep"], { excludeTools: ["read"] }); + expect(excludedSession.getActiveToolNames()).toEqual(["grep"]); + excludedSession.dispose(); + + const toolLessSession = await createSession(["read"], { noTools: "all" }); + expect(toolLessSession.getAllTools()).toEqual([]); + expect(toolLessSession.getActiveToolNames()).toEqual([]); + toolLessSession.dispose(); + }); + + it("applies through service-based session creation", async () => { + const settingsManager = SettingsManager.inMemory({ defaultTools: ["ls"] }); + const services = await createAgentSessionServices({ cwd: tempDir, agentDir, settingsManager }); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(tempDir), + model: getModel("anthropic", "claude-sonnet-4-5")!, + }); + + expect(session.getAllTools().map((tool) => tool.name)).toEqual(["ls"]); + expect(session.getActiveToolNames()).toEqual(["ls"]); + session.dispose(); + }); +}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 6a223fd00..638a70f23 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -526,6 +526,23 @@ describe("SettingsManager", () => { }); }); + describe("defaultTools", () => { + it("loads global defaults and lets project settings replace them", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultTools: ["read", "bash"] })); + + expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual(["read", "bash"]); + + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultTools: ["grep"] })); + + expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual(["grep"]); + }); + + it("preserves an empty tool list", () => { + expect(SettingsManager.inMemory({ defaultTools: [] }).getDefaultTools()).toEqual([]); + expect(SettingsManager.inMemory().getDefaultTools()).toBeUndefined(); + }); + }); + describe("getSessionDir", () => { it("should return undefined when not set", () => { writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" }));