feat(coding-agent): support +name/-name in defaultTools

Entries of only +name/-name modify the inherited tool selection, so
"defaultTools": ["+codemode"] enables codemode without repeating the
defaults. Project entries layer on top of user settings. Document how to
enable codemode without MCP and how to use classifier models from it.
This commit is contained in:
Armin Ronacher
2026-09-29 14:02:57 +02:00
parent 4df1574339
commit 30a1d1849d
14 changed files with 185 additions and 18 deletions
+1
View File
@@ -13,6 +13,7 @@
- Added a show/hide toggle (`H`) in HTML exports for custom messages marked `display: false`. Messages remain hidden by default and can also be revealed from the sidebar ([#8896](https://github.com/earendil-works/pi/issues/8896)).
- Added inherited Claude Sonnet 5.5 support for Anthropic with adaptive thinking and a 1M context window.
- Added a Built-in section in `pi config` to disable the built-in `mcp`, `llama.cpp`, `codemode`, and `tool-search` extensions globally or per project, stored as `-builtin:<name>` in the `extensions` setting. SDK inline extensions opt in with `builtin: true`.
- Added `+name` and `-name` entries to the `defaultTools` setting to add or remove tools without repeating the defaults, for example `"defaultTools": ["+codemode"]`. Project entries of this form apply on top of the user setting. Documented how to enable `codemode` without MCP and how to use classifier models such as Jev from codemode scripts.
### Changed
+26 -4
View File
@@ -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, and custom tools disabled.
Default enabled tools are `read`, `bash`, `edit`, and `write`, unless `defaultTools` changes them.
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.
| Built-in | Purpose |
|---|---|
@@ -138,13 +138,33 @@ Default enabled tools are `read`, `bash`, `edit`, and `write`, unless `defaultTo
| `find` | Find paths using glob patterns |
| `ls` | List directory contents |
Built-in extensions add two more tools. They are off by default; name them in `--tools` or `defaultTools` to enable them.
Built-in extensions add two more tools. They are off by default; the MCP extension turns them on when an MCP server needs them (see [MCP](mcp.md#exposure)). To enable them yourself, name them in `--tools` or `defaultTools`.
| Built-in extension | Purpose |
|---|---|
| `codemode` | Run JavaScript that calls the other tools, for example in parallel with `Promise.allSettled`; only the script's output reaches the model |
| `tool_search` | Search tools that are not declared to the model (`codemode` and `deferred` exposure, such as MCP tools) and declare the matches for the next call |
### Enable codemode
To turn on `codemode` for every session, add it to the default tools in `~/.pi/agent/settings.json` or a project's `.pi/settings.json`:
```json
{
"defaultTools": ["+codemode"]
}
```
This keeps `read`, `bash`, `edit`, and `write` and adds `codemode`. For one invocation, list every tool, since `--tools` replaces the selection:
```sh
pi --tools read,bash,edit,write,codemode
```
Codemode is useful without MCP: scripts can run several tool calls in parallel, filter large output before it reaches the model, and call classifier models such as TypeSafe's Jev through `models.classify()` (see [Classifier models](models.md#use-classifier-models)).
### How codemode works
Codemode scripts run in a QuickJS sandbox that can only reach the other tools, through `tools.<name>(args)`; `ALL_TOOLS` lists them. Output comes from `text(value)`, `image(dataUrlOrImageContent)`, `console.*`, and a top-level `return value`; `exit()` ends the script early. The result starts with `Script completed` or `Script failed`, the wall time, and the output; a failed script keeps its partial output, followed by `Script error:` and the error.
A script may start with an options line such as `// @options: {"max_output_tokens": 2000, "timeout_ms": 60000}`. `max_output_tokens` (default 10000) limits the output: longer output keeps its start and end, and the full text is written to a temp file whose path is included in the result. `timeout_ms` is a hard deadline, unset by default.
@@ -153,12 +173,14 @@ While `codemode` is active, `codemode.mode` in [settings](settings.md#tools) dec
The `codemode` description lists the callable tools with their TypeScript declarations, grouped by namespace (for example one MCP server). Declarations share a budget of 3000 estimated tokens (`codemode.inlineBudget` in [settings](settings.md#tools)); every namespace is still listed with its tool count, and the description says whether the list is complete. Scripts find the rest with `await searchTools(query, { limit, namespace })`, which ranks tools with BM25, and `await describeTool(name)`, or by filtering `ALL_TOOLS`.
`tool_search` is off by default; enable it with `--tools` or `defaultTools`. It uses the same ranking over tools that are not declared yet and declares the matches for the next model call. Loaded tools are recorded in the session like other tool changes, so they stay declared on that branch.
Tools with an output schema resolve to structured values: `bash` to `{ output, exit_code, wall_time_seconds }`, also for non-zero exit codes, and MCP tools to their `CallToolResult`. Other tools resolve to their text output.
`store(key, value)` and `load(key)` keep JSON values across `codemode` calls: each successful script that stores values appends a `codemode-store` custom entry to the session, so resumed sessions keep the values and each branch sees only the values written on its path. Scripts can also use `models`: `getModelsOfType`, `getAvailableOfType`, and `getModelOfType` list the model catalog, and `classify(model, context)` runs a classifier model with the session's credentials, at most four at a time per script.
### Tool search
`tool_search` is off by default; enable it with `"defaultTools": ["+tool_search"]` or `--tools`. It uses the same ranking as `searchTools()` over tools that are not declared yet and declares the matches for the next model call. Loaded tools are recorded in the session like other tool changes, so they stay declared on that branch.
<a id="resource-options"></a>
## Resources
+1 -1
View File
@@ -88,7 +88,7 @@ If the router disconnects, `/llama` shows **Retry** and **Close**. Retry reconne
## Classification
Every model listed for chat is also listed as a classifier model with the same ID and the `llama-cpp-classify` API. Classifier models answer typed `choice`, `bool`, and `score` questions about JSON state, like TypeSafe's Jev models.
Every model listed for chat is also listed as a classifier model with the same ID and the `llama-cpp-classify` API. Classifier models answer typed `choice`, `bool`, and `score` questions about JSON state, like TypeSafe's Jev models. The model reaches them from [`codemode`](cli.md#enable-codemode) scripts, and extensions through `ctx.modelRegistry.classify()`; see [Classifier models](models.md#use-classifier-models).
The model does not generate an answer. Each question becomes one chat prompt: the state, every question of the request, the state again, and then the question with its answers under single-token labels. Labels are letters for a choice (up to 62 options), `Yes`/`No` for a bool, and digits for a score (up to 10 levels). The second copy of the state is read with the questions in view, which improved accuracy on JevBench with small models. Pi reads the probabilities of the labels as the next token and normalizes them. A choice returns every option's probability and a confidence of `(n * peak - 1) / (n - 1)`; a score returns the expected level.
+1 -1
View File
@@ -151,7 +151,7 @@ Each server's tools are registered as `mcp__<server>__<tool>`. The `exposure` se
Tools that are not declared (`codemode`, `codemode-deferred`, and `deferred` exposure) are reachable through either tool: codemode scripts can call all of them, and `tool_search` can load any of them. For example, with `codemode` active, scripts can call the tools of a `deferred` server, and with `tool_search` active, the model can load the tools of a `codemode` server.
Tools called from codemode scripts do not depend on the active tool set, so they stay callable after `/tree`, resume, and fork. Tools loaded by `tool_search` are recorded in the transcript like any other tool change and stay declared on that branch. To keep pi from activating the `codemode` tool, set `"autoEnableCodemode": false` at the top level of `mcp.json`, next to `mcpServers`. A project `mcp.json` value overrides the global one. Pi warns once when neither `codemode` nor `tool_search` is active, since the tools then cannot be called.
Tools called from codemode scripts do not depend on the active tool set, so they stay callable after `/tree`, resume, and fork. Tools loaded by `tool_search` are recorded in the transcript like any other tool change and stay declared on that branch. To keep `codemode` active without MCP servers too, add `"defaultTools": ["+codemode"]` to [settings](settings.md#tools). To keep pi from activating the `codemode` tool, set `"autoEnableCodemode": false` at the top level of `mcp.json`, next to `mcpServers`. A project `mcp.json` value overrides the global one. Pi warns once when neither `codemode` nor `tool_search` is active, since the tools then cannot be called.
Text results over 20KB reach the model with the middle cut out, in the format Codex uses: the start and end of the text around a `…N chars truncated…` marker. The full text is saved to a temp file whose path the result names. Codemode scripts always receive the whole result, so a script can filter a large result down to what the model needs.
+31
View File
@@ -100,6 +100,37 @@ Choose the conservative end of any published range. A model without a lifetime f
Compatibility settings should describe verified differences in the endpoint's request or response behavior. Do not enable them based only on an endpoint advertising OpenAI or Anthropic compatibility.
## Use classifier models
Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from three providers:
| Provider | Model IDs | Authentication |
|---|---|---|
| `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` |
| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or `/login` |
| `cloudflare-workers-ai` | `typesafe/jev` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` |
Chat models on a [llama.cpp router](llama-cpp.md#classification) are also listed as classifier models.
Classifier models do not appear in `/model`. The model reaches them through the [`codemode`](cli.md#enable-codemode) tool, which is off unless an MCP server turned it on. Enable it with `"defaultTools": ["+codemode"]` in [settings](settings.md#tools). Scripts then list classifier models with `models.getAvailableOfType("classifier")` and call `models.classify(model, { state, questions })`:
```js
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const result = await models.classify(jev, {
state: { message: "The change works, thanks." },
questions: {
approved: {
type: "bool",
instructions: "Does the user approve of the result?",
criteria: { true: "Approval", false: "No approval" },
},
},
});
return result.answers;
```
Extensions call classifiers through `ctx.modelRegistry.classify()`, without codemode. [Virtual models](virtual-models.md#route-requests) can use them to route requests; see the `jev-router.ts` example.
## Add a custom provider
Use an extension when the provider needs custom streaming, model discovery, or authentication behavior. See [Custom Providers](custom-provider.md) for the extension workflow.
+1
View File
@@ -49,6 +49,7 @@ This table covers providers with a single primary API-key variable. Providers th
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` |
| OpenCode Zen and Go | `OPENCODE_API_KEY` |
| Radius | `RADIUS_API_KEY` |
| TypeSafe ([classifier models](models.md#use-classifier-models)) | `TYPESAFE_API_KEY` |
| Hugging Face | `HF_TOKEN` |
| Fireworks | `FIREWORKS_API_KEY` |
| Together AI | `TOGETHER_API_KEY` |
+1 -1
View File
@@ -113,7 +113,7 @@ Inline extension factories can be supplied through `DefaultResourceLoader`. Give
<a id="codemode-mcp"></a>
The CLI loads `codemode`, `tool_search`, and MCP as built-in extensions. SDK sessions do not; add `createCodemodeExtension()`, `createToolSearchExtension()`, and `createMcpExtension()` to the `extensionFactories` of `DefaultResourceLoader`. `codemode` and `tool_search` are registered inactive: enable them through the `defaultTools` setting, or let the MCP extension activate them: `codemode` for servers with `codemode` or `codemode-deferred` exposure, `tool_search` for servers with `deferred` exposure. The MCP extension connects its servers on `session_start`, so call `session.bindExtensions()`. See [Codemode and MCP](../examples/sdk/14-codemode-mcp.ts).
The CLI loads `codemode`, `tool_search`, and MCP as built-in extensions. SDK sessions do not; add `createCodemodeExtension()`, `createToolSearchExtension()`, and `createMcpExtension()` to the `extensionFactories` of `DefaultResourceLoader`. `codemode` and `tool_search` are registered inactive: enable them through the `defaultTools` setting (`["+codemode", "+tool_search"]` keeps the other default tools), or let the MCP extension activate them: `codemode` for servers with `codemode` or `codemode-deferred` exposure, `tool_search` for servers with `deferred` exposure. The MCP extension connects its servers on `session_start`, so call `session.bindExtensions()`. See [Codemode and MCP](../examples/sdk/14-codemode-mcp.ts).
See the focused examples for [models](../examples/sdk/02-custom-model.ts), [tools](../examples/sdk/05-tools.ts), [extensions](../examples/sdk/06-extensions.ts), and [full control](../examples/sdk/12-full-control.ts).
+14 -2
View File
@@ -37,11 +37,23 @@ See [Choose a Model](models.md) for model selection and thinking controls.
| Setting | Type | Default | Description |
|---|---|---|---|
| `defaultTools` | `string[]` | `read`, `bash`, `edit`, `write` | Built-in tools enabled at startup. An empty array disables all built-in tools but not extension or SDK tools. |
| `defaultTools` | `string[]` | `read`, `bash`, `edit`, `write` | Tools enabled at startup. Plain names replace the defaults; `+name` adds a tool and `-name` removes one. An empty array disables all built-in tools but not extension or SDK tools. |
| `codemode.mode` | `"on"` \| `"only"` | `"on"` | How the `codemode` tool presents tools while it is active. `on`: declared tools get their `codemode` declaration appended to their description, and `codemode` lists only tools that are not declared (MCP `codemode` exposure). `only`: `codemode` lists every tool scripts can call, and active built-in and extension tools are hidden from the model, so it reaches them through `codemode`. |
| `codemode.inlineBudget` | number | `3000` | Estimated tokens (characters / 4) the `codemode` tool's description may spend on tool declarations. Tools that do not fit are left out and found with `searchTools()`. `0` lists only namespaces. |
Available built-in tools are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`. `defaultTools` can also name `codemode` and `tool_search`, which built-in extensions register inactive. CLI tool options override this setting for one invocation. See [Command Line](cli.md#tools).
Available built-in tools are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`. `defaultTools` can also name `codemode` and `tool_search`, which built-in extensions register inactive, and other extension tools registered inactive.
A list of only `+name` and `-name` entries changes the inherited selection instead of replacing it. For example, this enables `codemode` next to the default tools:
```json
{
"defaultTools": ["+codemode"]
}
```
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.
CLI tool options override this setting for one invocation; `--tools` does not accept `+name` or `-name`. See [Command Line](cli.md#tools).
## Sessions and context
+2
View File
@@ -42,6 +42,8 @@ To replace the model-facing `bash` tool with `powershell`, add this to `~/.pi/ag
}
```
`["-bash", "+powershell"]` does the same while keeping any other default tools you configured.
Restart Pi, then ask it to run a harmless PowerShell command. The `!` and `!!` editor commands continue to use Bash. The `powershell` tool is available only when Pi runs as a native Windows process.
See [Settings](settings.md#tools) for other tool combinations.
@@ -36,7 +36,8 @@ const resourceLoader = new DefaultResourceLoader({
await resourceLoader.reload();
const settingsManager = SettingsManager.create(cwd);
settingsManager.applyOverrides({ defaultTools: ["read", "bash", "edit", "write", "codemode", "tool_search"] });
// `+name` adds to the configured default tools instead of replacing them.
settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });
const { session } = await createAgentSession({
resourceLoader,
+3 -5
View File
@@ -16,7 +16,7 @@ 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 { SettingsManager } from "./settings-manager.ts";
import { DEFAULT_TOOL_NAMES, SettingsManager } from "./settings-manager.ts";
import { time } from "./timings.ts";
import {
createBashTool,
@@ -29,7 +29,6 @@ import {
createReadOnlyTools,
createReadTool,
createWriteTool,
type ToolName,
withFileMutationQueue,
} from "./tools/index.ts";
import { getBranchSelection } from "./virtual-models.ts";
@@ -66,7 +65,7 @@ export interface CreateAgentSessionOptions {
/**
* Optional allowlist of tool names.
*
* When omitted, pi uses the `defaultTools` setting for the initial built-in
* When omitted, pi uses the resolved `defaultTools` setting for the initial
* selection when configured. Otherwise it enables the default built-in tools
* (read, bash, edit, write). Extension/custom tools remain enabled unless
* `noTools` changes that default. When provided, only the listed tool names are
@@ -262,13 +261,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel;
}
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
const configuredDefaultToolNames = settingsManager.getDefaultTools();
const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);
const excludedToolNames = options.excludeTools;
const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;
const initialActiveToolNames = (
options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? defaultActiveToolNames))
options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? DEFAULT_TOOL_NAMES))
).filter((name) => !excludedToolNameSet?.has(name));
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
@@ -162,7 +162,7 @@ export interface Settings {
terminal?: TerminalSettings;
images?: ImageSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
defaultTools?: string[]; // Initial built-in tool selection
defaultTools?: string[]; // Initial tool selection; `+name`/`-name` entries add to or remove from the inherited selection
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
@@ -208,9 +208,46 @@ function deepMergeObjects(base: Record<string, unknown>, overrides: Record<strin
return result;
}
/** 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 {
return typeof entry === "string" && (entry.startsWith("+") || entry.startsWith("-"));
}
/**
* 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.
*/
function mergeDefaultTools(base: string[] | undefined, overrides: string[] | undefined): string[] | undefined {
if (overrides === undefined) return base;
// Settings files are not validated; a malformed value replaces instead of throwing here.
if (!Array.isArray(base) || !Array.isArray(overrides) || !overrides.every(isToolModifier)) return overrides;
return [...base, ...overrides];
}
/**
* Resolve a merged `defaultTools` list: plain names replace `DEFAULT_TOOL_NAMES`, then `+name` adds
* and `-name` removes a tool, in list order.
*/
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;
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
function deepMergeSettings(base: Settings, overrides: Settings): Settings {
return deepMergeObjects(base as Record<string, unknown>, overrides as Record<string, unknown>) as Settings;
const merged = deepMergeObjects(base as Record<string, unknown>, overrides as Record<string, unknown>) as Settings;
const defaultTools = mergeDefaultTools(base.defaultTools, overrides.defaultTools);
return defaultTools === undefined ? merged : { ...merged, defaultTools };
}
function parseTimeoutSetting(value: unknown, settingName: string): number | undefined {
@@ -1375,9 +1412,11 @@ export class SettingsManager {
return this.settings.enabledModels;
}
/** The resolved `defaultTools` selection, or undefined when no settings layer sets it. */
getDefaultTools(): string[] | undefined {
const tools = this.settings.defaultTools;
return tools ? [...tools] : undefined;
if (tools === undefined) return undefined;
return resolveDefaultTools(Array.isArray(tools) ? tools.filter((tool) => typeof tool === "string") : []);
}
setEnabledModels(patterns: string[] | undefined): void {
@@ -79,6 +79,24 @@ describe("defaultTools setting", () => {
session.dispose();
});
it("activates an inactive extension tool with +name", async () => {
const session = await createSession(["+inactive_tool", "-write"], {}, [
(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,
});
},
]);
expect(session.getActiveToolNames().sort()).toEqual(["bash", "edit", "inactive_tool", "read"]);
session.dispose();
});
it("keeps extension and SDK custom tools enabled", async () => {
const session = await createSession(
["grep"],
@@ -629,6 +629,48 @@ describe("SettingsManager", () => {
expect(SettingsManager.inMemory({ defaultTools: [] }).getDefaultTools()).toEqual([]);
expect(SettingsManager.inMemory().getDefaultTools()).toBeUndefined();
});
it("applies +name and -name to the default selection", () => {
expect(SettingsManager.inMemory({ defaultTools: ["+codemode", "-write"] }).getDefaultTools()).toEqual([
"read",
"bash",
"edit",
"codemode",
]);
expect(SettingsManager.inMemory({ defaultTools: ["read", "+grep", "+read"] }).getDefaultTools()).toEqual([
"read",
"grep",
]);
});
it("layers project modifiers on top of the global selection", () => {
writeFileSync(
join(agentDir, "settings.json"),
JSON.stringify({ defaultTools: ["read", "bash", "+codemode"] }),
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ defaultTools: ["-codemode", "+tool_search"] }),
);
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getDefaultTools()).toEqual(["read", "bash", "tool_search"]);
manager.applyOverrides({ defaultTools: ["+codemode"] });
expect(manager.getDefaultTools()).toEqual(["read", "bash", "tool_search", "codemode"]);
});
it("applies project modifiers to the built-in defaults without a global setting", () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultTools: ["+codemode"] }));
expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual([
"read",
"bash",
"edit",
"write",
"codemode",
]);
});
});
describe("getSessionDir", () => {