feat(coding-agent): added loadMode and summary to AgentTool discovery

- Added optional `loadMode` and `summary` fields to `AgentTool` and related type declarations.
- Added `loadMode` and `summary` metadata to built-in tool classes for discoverable/essential behavior.
- Replaced `BUILTIN_TOOL_METADATA` with per-tool fields in discovery code paths.
- Updated `search_tool_bm25` and discovery indexing to use each tool's `summary` text.
- Updated discovery tests to validate tool `loadMode` and summary completeness.
This commit is contained in:
can1357
2026-05-06 19:13:48 +02:00
parent a2d3821ee6
commit 2f871b6d23
40 changed files with 175 additions and 153 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Added
- Added `loadMode` option to `AgentTool` to mark built-in tools as `essential` for initial loading or `discoverable` for search activation
- Added optional `summary` field to `AgentTool` definitions for one-line text used in tool discovery indexes
## [14.7.0] - 2026-05-04
### Breaking Changes
+4
View File
@@ -248,6 +248,10 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
hidden?: boolean;
/** If true, tool can stage a pending action that requires explicit resolution via the resolve tool. */
deferrable?: boolean;
/** Built-in tool loading behavior. "essential" loads initially; "discoverable" can be activated by tool search. */
loadMode?: "essential" | "discoverable";
/** Short one-line summary used for tool discovery indexes. */
summary?: string;
/** If true, tool execution ignores abort signals (runs to completion) */
nonAbortable?: boolean;
/**
+8 -1
View File
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed the exported `BUILTIN_TOOL_METADATA` API, including `BuiltinEntry`-style metadata exports and discoverable-built-in helper exports, which will break consumers relying on those symbols
### Changed
- Updated discoverable tool search (`search_tool_bm25` and related discovery metadata) to read each tool’s own `summary` field when present, improving discoverability descriptions for built-in tools
### Fixed
@@ -7822,4 +7829,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
+1
View File
@@ -246,6 +246,7 @@ async function executeSinglePathEntries(
export class EditTool implements AgentTool<TInput> {
readonly name = "edit";
readonly label = "Edit";
readonly loadMode = "essential";
readonly nonAbortable = true;
readonly concurrency = "exclusive";
readonly strict = true;
+2
View File
@@ -1169,6 +1169,8 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Theme> {
readonly name = "lsp";
readonly label = "LSP";
readonly loadMode = "discoverable";
readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
readonly description: string;
readonly parameters = lspSchema;
readonly renderCall = renderCall;
+7 -15
View File
@@ -116,7 +116,6 @@ import {
} from "./tool-discovery/tool-index";
import {
BashTool,
BUILTIN_TOOL_METADATA,
BUILTIN_TOOLS,
computeEssentialBuiltinNames,
createTools,
@@ -284,7 +283,6 @@ export {
// Individual tool classes (for custom usage)
BashTool,
// Tool classes and factories
BUILTIN_TOOL_METADATA,
BUILTIN_TOOLS,
createTools,
EditTool,
@@ -1358,19 +1356,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
toolContextStore.setToolNames(toolNames);
const discoverableMCPTools = mcpDiscoveryEnabled ? collectDiscoverableMCPTools(tools.values()) : [];
const activeToolNames = new Set(toolNames);
const builtinSummaryMap = new Map(
Object.entries(BUILTIN_TOOL_METADATA)
.filter(([, meta]) => typeof meta.summary === "string")
.map(([name, meta]) => [name, meta.summary!] as const),
);
const discoverableBuiltinTools: DiscoverableTool[] =
effectiveDiscoveryMode === "all"
? collectDiscoverableTools(
Array.from(tools.values()).filter(tool => {
const meta = BUILTIN_TOOL_METADATA[tool.name];
return meta?.loadMode === "discoverable" && !activeToolNames.has(tool.name);
}),
{ source: "builtin", summaryMap: builtinSummaryMap },
Array.from(tools.values()).filter(
tool => tool.loadMode === "discoverable" && !activeToolNames.has(tool.name),
),
{ source: "builtin" },
)
: [];
const discoverableToolsForDesc: DiscoverableTool[] = [
@@ -1527,9 +1519,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// activation persistence is a follow-up). MCP names won't collide with built-in names.
const restoredDiscoveredNames = new Set(existingSession.selectedMCPToolNames);
initialToolNames = initialToolNames.filter(name => {
const meta = BUILTIN_TOOL_METADATA[name];
if (!meta) return true; // not a built-in — leave MCP/custom/extension to existing logic
if (meta.loadMode === "essential") return true;
const tool = toolRegistry.get(name);
if (!tool?.loadMode) return true; // not a built-in — leave MCP/custom/extension to existing logic
if (tool.loadMode === "essential") return true;
if (essentialBuiltinNames.has(name)) return true;
if (explicitlyRequestedToolNames.has(name)) return true;
if (restoredDiscoveredNames.has(name)) return true;
@@ -137,7 +137,6 @@ import {
} from "../tool-discovery/tool-index";
import { assertEditableFile } from "../tools/auto-generated-guard";
import type { CheckpointState } from "../tools/checkpoint";
import { BUILTIN_TOOL_METADATA } from "../tools/index";
import { outputMeta } from "../tools/output-meta";
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
import { isAutoQaEnabled } from "../tools/report-tool-issue";
@@ -2307,20 +2306,17 @@ export class AgentSession {
return filter?.source ? allTools.filter(t => t.source === filter.source) : allTools;
}
/** Collect built-in tools the model can discover via search_tool_bm25. Restricted to entries
* whose `BUILTIN_TOOL_METADATA[name].loadMode === "discoverable"`. This keeps hidden/internal
* tools (resolve, yield, exit_plan_mode, report_finding, report_tool_issue) out of the index
/** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool
* definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools
* (resolve, yield, exit_plan_mode, report_finding, report_tool_issue) out of the index
* and avoids mislabeling extension/custom default-inactive tools as built-ins. */
#collectDiscoverableBuiltinTools(): DiscoverableTool[] {
const activeNames = new Set(this.getActiveToolNames());
const result: DiscoverableTool[] = [];
for (const [name, meta] of Object.entries(BUILTIN_TOOL_METADATA)) {
if (meta.loadMode !== "discoverable") continue;
if (activeNames.has(name)) continue;
const tool = this.#toolRegistry.get(name);
if (!tool) continue;
const summaryMap = meta.summary ? new Map([[name, meta.summary]]) : undefined;
const collected = collectDiscoverableTools([tool], { source: "builtin", summaryMap });
for (const tool of this.#toolRegistry.values()) {
if (tool.loadMode !== "discoverable") continue;
if (activeNames.has(tool.name)) continue;
const collected = collectDiscoverableTools([tool], { source: "builtin" });
result.push(...collected);
}
return result;
+2
View File
@@ -195,7 +195,9 @@ function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams):
export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
readonly name = "task";
readonly label = "Task";
readonly summary = "Spawn a subagent to complete a parallel task";
readonly strict = true;
readonly loadMode = "discoverable";
readonly renderResult = renderResult;
readonly #discoveredAgents: AgentDefinition[];
readonly #blockedAgent: string | undefined;
@@ -156,12 +156,19 @@ export function getDiscoverableTool(
label?: string;
description?: string;
mcpServerName?: string;
summary?: string;
mcpToolName?: string;
parameters?: unknown;
};
const source: DiscoverableToolSource = overrides?.source ?? (isMCPToolName(tool.name) ? "mcp" : "builtin");
const rawSummary =
typeof overrides?.summary === "string"
? overrides.summary
: typeof toolRecord.summary === "string"
? toolRecord.summary
: undefined;
const rawDescription = typeof toolRecord.description === "string" ? toolRecord.description : "";
const summary = overrides?.summary ?? rawDescription.slice(0, 200);
const summary = rawSummary ?? rawDescription.slice(0, 200);
return {
name: tool.name,
label: typeof toolRecord.label === "string" ? toolRecord.label : tool.name,
+2
View File
@@ -379,9 +379,11 @@ type AskParams = AskToolInput;
export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
readonly name = "ask";
readonly label = "Ask";
readonly summary = "Ask the user a clarifying question";
readonly description: string;
readonly parameters = askSchema;
readonly strict = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(askDescription);
@@ -167,10 +167,12 @@ export interface AstEditToolDetails {
export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
readonly name = "ast_edit";
readonly label = "AST Edit";
readonly summary = "Perform AST-aware code edits (structural refactoring)";
readonly description: string;
readonly parameters = astEditSchema;
readonly strict = true;
readonly deferrable = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(astEditDescription);
}
@@ -122,9 +122,11 @@ export interface AstGrepToolDetails {
export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
readonly name = "ast_grep";
readonly label = "AST Grep";
readonly summary = "Search code with AST patterns (structural grep)";
readonly description: string;
readonly parameters = astGrepSchema;
readonly strict = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(astGrepDescription);
+1
View File
@@ -236,6 +236,7 @@ function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutS
export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
readonly name = "bash";
readonly label = "Bash";
readonly loadMode = "essential";
readonly description: string;
readonly parameters: BashToolSchema;
readonly concurrency = "exclusive";
@@ -110,6 +110,8 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
readonly name = "browser";
readonly label = "Browser";
readonly loadMode = "discoverable";
readonly summary = "Control a headless browser to navigate and interact with web pages";
readonly parameters = browserSchema;
readonly strict = true;
@@ -396,6 +396,8 @@ type CalculatorParams = Static<typeof calculatorSchema>;
export class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
readonly name = "calc";
readonly label = "Calc";
readonly summary = "Evaluate a mathematical expression";
readonly loadMode = "discoverable";
readonly description: string;
readonly parameters = calculatorSchema;
readonly strict = true;
@@ -49,9 +49,11 @@ function isTopLevelSession(session: ToolSession): boolean {
export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
readonly name = "checkpoint";
readonly label = "Checkpoint";
readonly summary = "Create a git-based checkpoint to save and restore session state";
readonly description: string;
readonly parameters = checkpointSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly intent = (args: Partial<CheckpointParams>) => (args.goal ? `checkpointing: ${args.goal}` : "checkpointing");
constructor(private readonly session: ToolSession) {
@@ -92,9 +94,11 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDetails> {
readonly name = "rewind";
readonly label = "Rewind";
readonly summary = "Rewind to a previously created checkpoint";
readonly description: string;
readonly parameters = rewindSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly intent = (): string => "rewinding";
constructor(private readonly session: ToolSession) {
+2
View File
@@ -597,10 +597,12 @@ export const debugToolRenderer = {
export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails> {
readonly name = "debug";
readonly label = "Debug";
readonly summary = "Debug a running process with DAP (debugger adapter protocol)";
readonly description: string;
readonly parameters = debugSchema;
readonly strict = true;
readonly concurrency = "exclusive";
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(debugDescription);
+2
View File
@@ -207,6 +207,8 @@ async function resolveBackend(
export class EvalTool implements AgentTool<typeof evalSchema> {
readonly name = "eval";
readonly summary = "Execute Python or JavaScript code in an in-process eval backend";
readonly loadMode = "discoverable";
readonly label = "Eval";
get description(): string {
if (!this.session) return getEvalToolDescription();
+2
View File
@@ -88,6 +88,8 @@ export interface FindToolOptions {
export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
readonly name = "find";
readonly summary = "Find files and directories matching a glob pattern";
readonly loadMode = "discoverable";
readonly label = "Find";
readonly description: string;
readonly parameters = findSchema;
+2
View File
@@ -2087,6 +2087,8 @@ function buildTextResult(
export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails> {
readonly name = "github";
readonly summary = "Interact with GitHub issues, pull requests, and repositories";
readonly loadMode = "discoverable";
readonly label = "GitHub";
readonly description = prompt.render(githubDescription);
readonly parameters = githubSchema;
@@ -19,6 +19,8 @@ export class HindsightRecallTool implements AgentTool<typeof hindsightRecallSche
readonly description = recallDescription;
readonly parameters = hindsightRecallSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly summary = "Search hindsight memory for relevant prior context";
constructor(private readonly session: ToolSession) {}
@@ -18,6 +18,8 @@ export class HindsightReflectTool implements AgentTool<typeof hindsightReflectSc
readonly description = reflectDescription;
readonly parameters = hindsightReflectSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly summary = "Reflect on recent work and write hindsight memory";
constructor(private readonly session: ToolSession) {}
@@ -28,6 +28,8 @@ export class HindsightRetainTool implements AgentTool<typeof hindsightRetainSche
readonly description = retainDescription;
readonly parameters = hindsightRetainSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly summary = "Store important facts in hindsight memory";
constructor(private readonly session: ToolSession) {}
+1 -94
View File
@@ -234,22 +234,6 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
export type BuiltinToolLoadMode = "essential" | "discoverable";
/** Per-tool discovery metadata for built-ins. Kept separate from BUILTIN_TOOLS so the public
* factory map remains directly callable (`BUILTIN_TOOLS.read(session)`). */
export interface BuiltinToolMetadata {
/** "essential" tools are always loaded regardless of discovery mode.
* "discoverable" tools are hidden behind search_tool_bm25 when tools.discoveryMode === "all". */
loadMode: BuiltinToolLoadMode;
/** Short one-line summary for the BM25 corpus (optional; falls back to tool description). */
summary?: string;
}
/** @deprecated Internal shape used by older callers. Prefer BUILTIN_TOOLS (factory) +
* BUILTIN_TOOL_METADATA (loadMode/summary). */
export interface BuiltinEntry extends BuiltinToolMetadata {
factory: ToolFactory;
}
/** Default essential tool names when tools.essentialOverride is empty. */
export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = ["read", "bash", "edit"] as const;
@@ -262,7 +246,7 @@ export function computeEssentialBuiltinNames(settings: Settings): string[] {
const override = settings.get("tools.essentialOverride") ?? [];
const cleaned = override.map(name => name.trim()).filter(Boolean);
if (cleaned.length > 0) {
return cleaned.filter(name => name in BUILTIN_TOOL_METADATA);
return cleaned.filter(name => name in BUILTIN_TOOLS);
}
return [...DEFAULT_ESSENTIAL_TOOL_NAMES];
}
@@ -305,57 +289,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
reflect: HindsightReflectTool.createIf,
};
/**
* Per-tool discovery metadata. Keys must align with BUILTIN_TOOLS. Used by initial-tool filtering
* (sdk.ts) and discovery-corpus collection (search-tool-bm25). Kept separate so the public
* BUILTIN_TOOLS map remains a directly callable factory record.
*/
export const BUILTIN_TOOL_METADATA: Record<string, BuiltinToolMetadata> = {
read: { loadMode: "essential" },
bash: { loadMode: "essential" },
edit: { loadMode: "essential" },
ast_grep: { loadMode: "discoverable", summary: "Search code with AST patterns (structural grep)" },
ast_edit: { loadMode: "discoverable", summary: "Perform AST-aware code edits (structural refactoring)" },
render_mermaid: { loadMode: "discoverable", summary: "Render a Mermaid diagram to an image" },
ask: { loadMode: "discoverable", summary: "Ask the user a clarifying question" },
debug: { loadMode: "discoverable", summary: "Debug a running process with DAP (debugger adapter protocol)" },
eval: { loadMode: "discoverable", summary: "Execute Python or JavaScript code in an in-process eval backend" },
calc: { loadMode: "discoverable", summary: "Evaluate a mathematical expression" },
ssh: { loadMode: "discoverable", summary: "Execute a command on a remote host over SSH" },
github: { loadMode: "discoverable", summary: "Interact with GitHub issues, pull requests, and repositories" },
find: { loadMode: "discoverable", summary: "Find files and directories matching a glob pattern" },
search: { loadMode: "discoverable", summary: "Search file contents using ripgrep (fast text search)" },
lsp: {
loadMode: "discoverable",
summary: "Query LSP (language server) for diagnostics, hover info, and references",
},
notebook: { loadMode: "discoverable", summary: "Read and execute Jupyter notebooks" },
inspect_image: { loadMode: "discoverable", summary: "Describe or analyze an image file" },
browser: {
loadMode: "discoverable",
summary: "Control a headless browser to navigate and interact with web pages",
},
checkpoint: {
loadMode: "discoverable",
summary: "Create a git-based checkpoint to save and restore session state",
},
rewind: { loadMode: "discoverable", summary: "Rewind to a previously created checkpoint" },
task: { loadMode: "discoverable", summary: "Spawn a subagent to complete a parallel task" },
job: { loadMode: "discoverable", summary: "Manage long-running background jobs (async bash/python)" },
recipe: { loadMode: "discoverable", summary: "Execute a saved bash recipe (multi-step shell command preset)" },
irc: { loadMode: "discoverable", summary: "Send and receive messages between agents over IRC-like channels" },
todo_write: {
loadMode: "discoverable",
summary: "Write a structured todo list to track progress within a session",
},
web_search: { loadMode: "discoverable", summary: "Search the web for up-to-date information" },
search_tool_bm25: { loadMode: "essential" },
write: { loadMode: "discoverable", summary: "Write content to a file (creates or overwrites)" },
retain: { loadMode: "discoverable", summary: "Store important facts in hindsight memory" },
recall: { loadMode: "discoverable", summary: "Search hindsight memory for relevant prior context" },
reflect: { loadMode: "discoverable", summary: "Reflect on recent work and write hindsight memory" },
};
export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
yield: s => new YieldTool(s),
report_finding: () => reportFindingTool,
@@ -371,32 +304,6 @@ export interface EvalBackendsAllowance {
js: boolean;
}
/**
* Return all built-in tools whose metadata `loadMode` is "discoverable".
* Used by search_tool_bm25 to build the BM25 corpus when tools.discoveryMode === "all".
* Pass an optional exclusion set to filter out names that are already active.
*/
export function getBuiltinDiscoverableEntries(
excludeNames?: ReadonlySet<string>,
): Array<{ name: string; entry: BuiltinEntry }> {
return Object.entries(BUILTIN_TOOL_METADATA)
.filter(([name, meta]) => meta.loadMode === "discoverable" && !excludeNames?.has(name))
.map(([name, meta]) => {
const factory = BUILTIN_TOOLS[name];
if (!factory) {
throw new Error(`BUILTIN_TOOL_METADATA["${name}"] has no matching factory in BUILTIN_TOOLS`);
}
return { name, entry: { factory, ...meta } satisfies BuiltinEntry };
});
}
/** Names of all built-ins whose metadata `loadMode` is "discoverable". */
export function getBuiltinDiscoverableNames(): string[] {
return Object.entries(BUILTIN_TOOL_METADATA)
.filter(([, meta]) => meta.loadMode === "discoverable")
.map(([name]) => name);
}
/**
* Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
* means "not specified, defer to settings". Returns null when neither is set
@@ -41,6 +41,8 @@ function extractResponseText(message: AssistantMessage): string {
export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
readonly name = "inspect_image";
readonly label = "InspectImage";
readonly loadMode = "discoverable";
readonly summary = "Describe or analyze an image file";
readonly description: string;
readonly parameters = inspectImageSchema;
readonly strict = false;
+2 -1
View File
@@ -74,10 +74,11 @@ export interface IrcDetails {
export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
readonly name = "irc";
readonly label = "IRC";
readonly summary = "Send and receive messages between agents over IRC-like channels";
readonly description: string;
readonly parameters = ircSchema;
readonly strict = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(ircDescription);
}
+2 -1
View File
@@ -76,10 +76,11 @@ export interface JobToolDetails {
export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
readonly name = "job";
readonly label = "Job";
readonly summary = "Manage long-running background jobs (async bash/python)";
readonly description: string;
readonly parameters = jobSchema;
readonly strict = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(jobDescription);
}
@@ -64,6 +64,8 @@ type NotebookParams = Static<typeof notebookSchema>;
export class NotebookTool implements AgentTool<typeof notebookSchema, NotebookToolDetails> {
readonly name = "notebook";
readonly label = "Notebook";
readonly loadMode = "discoverable";
readonly summary = "Read and execute Jupyter notebooks";
readonly description = "Edit, insert, or delete cells in Jupyter notebooks (.ipynb). cell_index is 0-based.";
readonly parameters = notebookSchema;
readonly strict = true;
+1
View File
@@ -512,6 +512,7 @@ interface ResolvedSqliteReadPath {
export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
readonly name = "read";
readonly label = "Read";
readonly loadMode = "essential";
readonly description: string;
readonly parameters = readSchema;
readonly nonAbortable = true;
@@ -33,6 +33,8 @@ export class RecipeTool implements AgentTool<typeof recipeSchema, BashToolDetail
readonly parameters = recipeSchema;
readonly strict = true;
readonly concurrency = "exclusive";
readonly loadMode = "discoverable";
readonly summary = "Execute a saved bash recipe (multi-step shell command preset)";
readonly mergeCallAndResult = true;
readonly inline = true;
readonly renderCall: (args: RecipeRenderArgs, options: RenderResultOptions, uiTheme: Theme) => Component;
@@ -35,9 +35,11 @@ export interface RenderMermaidToolDetails {
export class RenderMermaidTool implements AgentTool<typeof renderMermaidSchema, RenderMermaidToolDetails> {
readonly name = "render_mermaid";
readonly label = "RenderMermaid";
readonly summary = "Render a Mermaid diagram to an image";
readonly description: string;
readonly parameters = renderMermaidSchema;
readonly strict = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(renderMermaidDescription);
@@ -210,6 +210,7 @@ function renderFallbackResult(text: string, theme: Theme): Component {
export class SearchToolBm25Tool implements AgentTool<typeof searchToolBm25Schema, SearchToolBm25Details> {
readonly name = "search_tool_bm25";
readonly label = "SearchTools";
readonly loadMode = "essential";
get description(): string {
return renderSearchToolBm25Description(getDiscoverableToolsForDescription(this.session));
}
@@ -80,6 +80,8 @@ type SearchParams = Static<typeof searchSchema>;
export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDetails> {
readonly name = "search";
readonly label = "Search";
readonly loadMode = "discoverable";
readonly summary = "Search file contents using ripgrep (fast text search)";
readonly description: string;
readonly parameters = searchSchema;
readonly strict = true;
+2
View File
@@ -120,6 +120,8 @@ type SshToolParams = Static<typeof sshSchema>;
export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
readonly name = "ssh";
readonly summary = "Execute a command on a remote host over SSH";
readonly loadMode = "discoverable";
readonly label = "SSH";
readonly parameters = sshSchema;
readonly concurrency = "exclusive";
@@ -503,11 +503,12 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWriteToolDetails> {
readonly name = "todo_write";
readonly label = "Todo Write";
readonly summary = "Write a structured todo list to track progress within a session";
readonly description: string;
readonly parameters = todoWriteSchema;
readonly concurrency = "exclusive";
readonly strict = true;
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(todoWriteDescription);
}
+2
View File
@@ -166,6 +166,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
readonly nonAbortable = true;
readonly strict = true;
readonly concurrency = "exclusive";
readonly loadMode = "discoverable";
readonly summary = "Write content to a file (creates or overwrites)";
readonly #writethrough: WritethroughCallback;
@@ -211,6 +211,8 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
readonly description: string;
readonly parameters = webSearchSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly summary = "Search the web for up-to-date information";
constructor(_session: ToolSession) {
this.description = prompt.render(webSearchDescription);
@@ -52,6 +52,7 @@ describe("AgentSession manual retry", () => {
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-manual-retry-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
});
afterEach(async () => {
@@ -915,7 +915,10 @@ describe("AgentSession MCP discovery", () => {
// ── Findings #4: built-in discovery is restricted to declared discoverable ─
it("getDiscoverableTools({source:'builtin'}) excludes hidden and non-declared registry tools", () => {
const readTool = createBasicTool("read", "Read");
readTool.loadMode = "essential";
const findTool = createBasicTool("find", "Find");
findTool.loadMode = "discoverable";
findTool.summary = "Find files and directories matching a glob pattern";
const resolveTool = createBasicTool("resolve", "Resolve"); // hidden — must be excluded
const customTool = createBasicTool("custom_inactive", "Custom"); // not in metadata — must be excluded
const toolRegistry = new Map([
@@ -942,7 +945,7 @@ describe("AgentSession MCP discovery", () => {
const names = builtin.map(t => t.name);
expect(names).toContain("find"); // declared discoverable AND present in registry
expect(names).not.toContain("read"); // already active
expect(names).not.toContain("resolve"); // hidden — not in BUILTIN_TOOL_METADATA
expect(names).not.toContain("custom_inactive"); // unknown — not in BUILTIN_TOOL_METADATA
expect(names).not.toContain("resolve"); // hidden — no discoverable loadMode
expect(names).not.toContain("custom_inactive"); // unknown — no discoverable loadMode
});
});
@@ -1,12 +1,65 @@
import { describe, expect, it } from "bun:test";
import { Settings } from "../../src/config/settings";
import type { ToolSession } from "../../src/tools/index";
import {
BUILTIN_TOOL_METADATA,
AskTool,
BUILTIN_TOOLS,
computeEssentialBuiltinNames,
createTools,
DEFAULT_ESSENTIAL_TOOL_NAMES,
IrcTool,
JobTool,
RecipeTool,
SshTool,
} from "../../src/tools/index";
const allToolsSettings = Settings.isolated({
"astGrep.enabled": true,
"astEdit.enabled": true,
"renderMermaid.enabled": true,
"debug.enabled": true,
"find.enabled": true,
"search.enabled": true,
"github.enabled": true,
"lsp.enabled": true,
"notebook.enabled": true,
"inspect_image.enabled": true,
"web_search.enabled": true,
"calc.enabled": true,
"browser.enabled": true,
"checkpoint.enabled": true,
"irc.enabled": true,
"recipe.enabled": true,
"todo.enabled": true,
"memory.backend": "hindsight",
"tools.discoveryMode": "all",
});
const toolSession: ToolSession = {
cwd: "/tmp/test",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: allToolsSettings,
isToolDiscoveryEnabled: () => true,
getSelectedDiscoveredToolNames: () => [],
activateDiscoveredTools: async names => names,
};
async function getToolMetadata(): Promise<Map<string, { loadMode?: string; summary?: string }>> {
const tools = await createTools(toolSession, Object.keys(BUILTIN_TOOLS));
const metadata = new Map(tools.map(tool => [tool.name, { loadMode: tool.loadMode, summary: tool.summary }]));
for (const tool of [
new AskTool({ ...toolSession, hasUI: true }),
new SshTool(toolSession, [], new Map(), ""),
new JobTool(toolSession),
new RecipeTool(toolSession, []),
new IrcTool(toolSession),
]) {
metadata.set(tool.name, { loadMode: tool.loadMode, summary: tool.summary });
}
return metadata;
}
describe("BUILTIN_TOOLS public factory map", () => {
it("exposes callable tool factories (back-compat for external SDK callers)", () => {
// External callers may invoke BUILTIN_TOOLS.read(session) directly. Verify the value
@@ -16,31 +69,23 @@ describe("BUILTIN_TOOLS public factory map", () => {
expect(typeof BUILTIN_TOOLS.edit).toBe("function");
});
it("has a callable factory for every metadata entry", () => {
for (const name of Object.keys(BUILTIN_TOOL_METADATA)) {
expect(typeof BUILTIN_TOOLS[name]).toBe("function");
}
});
it("has metadata for every factory entry", () => {
for (const name of Object.keys(BUILTIN_TOOLS)) {
expect(BUILTIN_TOOL_METADATA[name]).toBeDefined();
}
it("sets loading fields on tool definitions without wrapping factories", async () => {
const metadata = await getToolMetadata();
const missing = Object.keys(BUILTIN_TOOLS).filter(name => metadata.get(name)?.loadMode === undefined);
expect(missing).toEqual([]);
});
});
describe("BUILTIN_TOOL_METADATA loadMode annotations", () => {
it("marks read, bash, edit as essential", () => {
expect(BUILTIN_TOOL_METADATA.read?.loadMode).toBe("essential");
expect(BUILTIN_TOOL_METADATA.bash?.loadMode).toBe("essential");
expect(BUILTIN_TOOL_METADATA.edit?.loadMode).toBe("essential");
describe("built-in tool loadMode annotations", () => {
it("marks read, bash, edit, and search_tool_bm25 as essential", async () => {
const metadata = await getToolMetadata();
expect(metadata.get("read")?.loadMode).toBe("essential");
expect(metadata.get("bash")?.loadMode).toBe("essential");
expect(metadata.get("edit")?.loadMode).toBe("essential");
expect(metadata.get("search_tool_bm25")?.loadMode).toBe("essential");
});
it("marks search_tool_bm25 as essential (discovery tool itself)", () => {
expect(BUILTIN_TOOL_METADATA.search_tool_bm25?.loadMode).toBe("essential");
});
it("marks non-essential tools as discoverable", () => {
it("marks non-essential tools as discoverable", async () => {
const discoverableExpected = [
"ast_grep",
"ast_edit",
@@ -70,14 +115,15 @@ describe("BUILTIN_TOOL_METADATA loadMode annotations", () => {
"recall",
"reflect",
];
for (const name of discoverableExpected) {
expect(BUILTIN_TOOL_METADATA[name]?.loadMode).toBe("discoverable");
}
const metadata = await getToolMetadata();
const missing = discoverableExpected.filter(name => metadata.get(name)?.loadMode !== "discoverable");
expect(missing).toEqual([]);
});
it("provides a summary for every discoverable tool", () => {
it("provides a summary for every discoverable tool", async () => {
const missing: string[] = [];
for (const [name, meta] of Object.entries(BUILTIN_TOOL_METADATA)) {
const metadata = await getToolMetadata();
for (const [name, meta] of metadata) {
if (meta.loadMode === "discoverable" && !meta.summary) {
missing.push(name);
}