声明式工具定义模式
Gemini CLI — 声明式工具定义模式
学习目标
- 理解 AnyDeclarativeTool 接口的三要素设计
- 掌握两阶段(build → execute)工具调用的优势
- 分析 Zod 验证、工具别名和注册表的工作方式
项目实践
声明式工具三要素
Gemini CLI 中每个工具通过 AnyDeclarativeTool 接口定义:
interface AnyDeclarativeTool { name: string; // 工具名称 displayName: string; // 显示名称(UI 友好) description: string; // 工具描述(注入到系统提示) schema: FunctionDeclaration; // JSON Schema 参数定义 build(params): ToolInvocation; // 验证参数,创建调用实例}三要素:
- 声明(schema):描述工具接受什么参数
- 验证(build):将原始参数验证为具体类型,失败时返回错误
- 执行(execute):执行工具操作,返回结果
两阶段调用:build → execute
LLM: functionCall("read_file", { file_path: "main.ts" }) ↓Stage 1: ToolRegistry.getTool("read_file").build(params) ├── Zod schema 验证参数 ├── 构建 ToolInvocation 实例 ├── 如果验证失败 → 返回 ErroredToolCall(不执行) └── 如果验证成功 → 返回 ValidatingToolCall ↓Stage 2: ValidatingToolCall.invocation.execute(options) ├── 执行实际的文件读取 ├── 返回 ToolResult(结果或错误) └── 结果格式化为 LLM 可理解的 functionResponse为什么分离:
- Policy 检查:Scheduler 在 Validating 阶段做策略检查,此时工具尚未执行
- 用户确认:
shouldConfirmExecute()在确认前调用,用户可以拒绝 - 并行优化:验证阶段可以快速完成,执行阶段可能耗时较长,分离后可以在验证完成后并行执行
- 错误隔离:参数验证错误 vs 执行错误,区分处理
ToolInvocation 抽象
interface ToolInvocation<TParams, TResult> { params: TParams; getDescription(): string; // 执行前描述(用于确认对话框) getDisplayTitle?(): string; // 简洁标题(用于 UI 展示) getExplanation?(): string; // 对话解释 toolLocations(): ToolLocation[]; // 影响的文件路径(用于沙箱策略) execute(options: ExecuteOptions): Promise<TResult>;}BaseToolInvocation 提供通用实现:
- 构造函数接收参数、工具名称、MessageBus
getDescription()默认实现为 JSON 字符串化参数execute()需要子类实现
Zod 参数验证
每个工具使用 Zod schema 验证参数:
const ReadFileSchema = z.object({ file_path: z.string().min(1, "file_path is required"), start_line: z.number().optional(), end_line: z.number().optional(),});
class ReadFileTool extends BaseDeclarativeTool { schema = ReadFileSchema;
build(params: unknown): ToolInvocation { const parsed = this.schema.parse(params); return new ReadFileInvocation(parsed); }}验证失败处理:build() 中 Zod 抛出异常,Scheduler 捕获后创建 ErroredToolCall,返回详细错误信息给 LLM。
工具别名
TOOL_LEGACY_ALIASES 支持向后兼容:
const TOOL_LEGACY_ALIASES: Record<string, string> = { "read": "read_file", "write": "write_file", // ...};当 LLM 使用旧名称调用工具时,ToolRegistry 自动映射到新名称,确保向后兼容。
ToolRegistry 统一管理
class ToolRegistry { private tools: Map<string, AnyDeclarativeTool> = new Map();
register(tool: AnyDeclarativeTool): void { this.tools.set(tool.name, tool); }
getTool(name: string): AnyDeclarativeTool | undefined { // 先查原始名称,再查别名 return this.tools.get(name) || this.tools.get(TOOL_LEGACY_ALIASES[name]); }
getAllToolNames(): string[] { return Array.from(this.tools.keys()); }}注册的工具类型:
- 内置工具:read_file, write_file, edit, shell, grep, glob, ls 等
- MCP 工具:通过 MCP 协议动态发现的工具(
mcp_*前缀) - Agent 工具:子 Agent 封装(
agent_*前缀) - 扩展工具:Extension 注册的工具
陷阱与对策
参数验证与执行的环境差异
问题:build() 阶段可用的环境变量在执行阶段可能变化。
对策:execute() 接收 ExecuteOptions,包含当前 AbortSignal、输出更新回调等,确保执行时使用最新环境。
工具名称冲突
问题:内置工具和 MCP 工具可能同名。
对策:MCP 工具使用 mcp_{server}_{tool} 前缀命名空间,内置工具使用简短名称,不会冲突。
设计取舍
声明式 vs 命令式工具
| 方案 | 优势 | 代价 |
|---|---|---|
| 声明式 | 统一接口、自动验证、可序列化 | 每个工具需要编写 schema 和实现类 |
| 命令式 | 灵活、实现快速 | 参数验证不统一、难以做通用策略检查 |
Gemini CLI 的选择:声明式,因为需要统一的工具接口供 LLM 发现、Policy Engine 检查、Scheduler 调度。
Zod vs 运行时类型检查
| 方案 | 优势 | 代价 |
|---|---|---|
| Zod | 类型推导、组合性好、错误信息详细 | 增加依赖、schema 编写有学习曲线 |
| 手动检查 | 无额外依赖、灵活 | 容易遗漏边界、错误信息不一致 |
Gemini CLI 的选择:Zod,因为类型推导使得工具参数类型与 TypeScript 类型系统一致,减少运行时错误。