gemini-cli/packages/core/src/tools/tool-registry.ts

208 lines
6.3 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { FunctionDeclaration } from '@google/genai';
import { Tool, ToolResult, BaseTool } from './tools.js';
import { Config } from '../config/config.js';
import { spawn, execSync } from 'node:child_process';
import { discoverMcpTools } from './mcp-client.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
type ToolParams = Record<string, unknown>;
export class DiscoveredTool extends BaseTool<ToolParams, ToolResult> {
constructor(
private readonly config: Config,
readonly name: string,
readonly description: string,
readonly parameterSchema: Record<string, unknown>,
) {
const discoveryCmd = config.getToolDiscoveryCommand()!;
const callCommand = config.getToolCallCommand()!;
description += `
This tool was discovered from the project by executing the command \`${discoveryCmd}\` on project root.
When called, this tool will execute the command \`${callCommand} ${name}\` on project root.
Tool discovery and call commands can be configured in project or user settings.
When called, the tool call command is executed as a subprocess.
On success, tool output is returned as a json string.
Otherwise, the following information is returned:
Stdout: Output on stdout stream. Can be \`(empty)\` or partial.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
`;
super(
name,
name,
description,
parameterSchema,
false, // isOutputMarkdown
false, // canUpdateOutput
);
}
async execute(params: ToolParams): Promise<ToolResult> {
const callCommand = this.config.getToolCallCommand()!;
const child = spawn(callCommand, [this.name]);
child.stdin.write(JSON.stringify(params));
child.stdin.end();
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
let error: Error | null = null;
child.on('error', (err: Error) => {
error = err;
});
let code: number | null = null;
let signal: NodeJS.Signals | null = null;
child.on(
'close',
(_code: number | null, _signal: NodeJS.Signals | null) => {
code = _code;
signal = _signal;
},
);
await new Promise((resolve) => child.on('close', resolve));
// if there is any error, non-zero exit code, signal, or stderr, return error details instead of stdout
if (error || code !== 0 || signal || stderr) {
const llmContent = [
`Stdout: ${stdout || '(empty)'}`,
`Stderr: ${stderr || '(empty)'}`,
`Error: ${error ?? '(none)'}`,
`Exit Code: ${code ?? '(none)'}`,
`Signal: ${signal ?? '(none)'}`,
].join('\n');
return {
llmContent,
returnDisplay: llmContent,
};
}
return {
llmContent: stdout,
returnDisplay: stdout,
};
}
}
export class ToolRegistry {
private tools: Map<string, Tool> = new Map();
private discovery: Promise<void> | null = null;
private config: Config;
constructor(config: Config) {
this.config = config;
}
/**
* Registers a tool definition.
* @param tool - The tool object containing schema and execution logic.
*/
registerTool(tool: Tool): void {
if (this.tools.has(tool.name)) {
// Decide on behavior: throw error, log warning, or allow overwrite
console.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
this.tools.set(tool.name, tool);
}
/**
* Discovers tools from project (if available and configured).
* Can be called multiple times to update discovered tools.
*/
async discoverTools(): Promise<void> {
// remove any previously discovered tools
for (const tool of this.tools.values()) {
if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
this.tools.delete(tool.name);
} else {
// Keep manually registered tools
}
}
// discover tools using discovery command, if configured
const discoveryCmd = this.config.getToolDiscoveryCommand();
if (discoveryCmd) {
// execute discovery command and extract function declarations (w/ or w/o "tool" wrappers)
const functions: FunctionDeclaration[] = [];
for (const tool of JSON.parse(execSync(discoveryCmd).toString().trim())) {
if (tool['function_declarations']) {
functions.push(...tool['function_declarations']);
} else if (tool['functionDeclarations']) {
functions.push(...tool['functionDeclarations']);
} else if (tool['name']) {
functions.push(tool);
}
}
// register each function as a tool
for (const func of functions) {
this.registerTool(
new DiscoveredTool(
this.config,
func.name!,
func.description!,
func.parameters! as Record<string, unknown>,
),
);
}
}
// discover tools using MCP servers, if configured
await discoverMcpTools(this.config);
}
/**
* Retrieves the list of tool schemas (FunctionDeclaration array).
* Extracts the declarations from the ToolListUnion structure.
* Includes discovered (vs registered) tools if configured.
* @returns An array of FunctionDeclarations.
*/
getFunctionDeclarations(): FunctionDeclaration[] {
const declarations: FunctionDeclaration[] = [];
this.tools.forEach((tool) => {
declarations.push(tool.schema);
});
return declarations;
}
/**
* Returns an array of all registered and discovered tool instances.
*/
getAllTools(): Tool[] {
return Array.from(this.tools.values());
}
/**
* Returns an array of tools registered from a specific MCP server.
*/
getToolsByServer(serverName: string): Tool[] {
const serverTools: Tool[] = [];
for (const tool of this.tools.values()) {
if ((tool as DiscoveredMCPTool)?.serverName === serverName) {
serverTools.push(tool);
}
}
return serverTools;
}
/**
* Get the definition of a specific tool.
*/
getTool(name: string): Tool | undefined {
return this.tools.get(name);
}
}