feat(coding-agent): add configurable default tools

This commit is contained in:
Armin Ronacher 2026-08-12 15:26:42 +02:00
parent 47b5119d09
commit 4d9aa837c2
6 changed files with 136 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<CreateAgentSessionOptions, "tools" | "excludeTools" | "noTools">;
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();
});
});

View file

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