feat(coding-agent): add exclude tools option closes #5109
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -551,6 +551,7 @@ cat README.md | pi -p "Summarize this text"
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--tools <list>`, `-t <list>` | Allowlist specific tool names across built-in, extension, and custom tools |
|
||||
| `--exclude-tools <list>`, `-xt <list>` | 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"
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -184,6 +184,7 @@ cat README.md | pi -p "Summarize this text"
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--tools <list>`, `-t <list>` | Allowlist specific built-in, extension, and custom tools |
|
||||
| `--exclude-tools <list>`, `-xt <list>` | 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
|
||||
|
||||
@@ -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 <tools> Comma-separated allowlist of tool names to enable
|
||||
Applies to built-in, extension, and custom tools
|
||||
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
|
||||
Applies to built-in, extension, and custom tools
|
||||
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
||||
--extension, -e <path> 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
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface CreateAgentSessionFromServicesOptions {
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
scopedModels?: Array<{ model: Model<any>; 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,
|
||||
|
||||
@@ -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<string>;
|
||||
private _excludedToolNames?: Set<string>;
|
||||
private _baseToolsOverride?: Record<string, AgentTool>;
|
||||
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 = [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface HarnessOptions {
|
||||
settings?: Partial<Settings>;
|
||||
systemPrompt?: string;
|
||||
tools?: AgentTool[];
|
||||
initialActiveToolNames?: string[];
|
||||
allowedToolNames?: string[];
|
||||
excludedToolNames?: string[];
|
||||
resourceLoader?: ResourceLoader;
|
||||
extensionFactories?: Array<ExtensionFactory | CreateTestExtensionsResultInput>;
|
||||
withConfiguredAuth?: boolean;
|
||||
@@ -173,6 +176,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
baseToolsOverride: toolMap,
|
||||
initialActiveToolNames: options.initialActiveToolNames,
|
||||
allowedToolNames: options.allowedToolNames,
|
||||
excludedToolNames: options.excludedToolNames,
|
||||
extensionRunnerRef,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtensionFactory } from "../../../src/index.ts";
|
||||
import { createHarness } from "../harness.ts";
|
||||
|
||||
function toolNames(tools: Array<{ name: string }>): 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user