315 lines
13 KiB
Markdown
315 lines
13 KiB
Markdown
# 工具注册表 + Patch/Clarify 工具 + 提示词优化 设计文档 (v2)
|
||
|
||
> 日期: 2026-04-16
|
||
> 参考: Hermes Agent (Nous Research) 工具系统架构
|
||
> v2: 架构审查后修订,移除过度设计,修正遗漏
|
||
> v3: 架构师自审修复 3 个 Critical + 2 个 Important 问题
|
||
|
||
## 1. 目标
|
||
|
||
1. 引入轻量级**工具目录** (`tools/catalog.ts`),统一工具 schema 注册和名称查询,不管理工具实例
|
||
2. 新增 **patch 工具**:9 级模糊匹配策略链
|
||
3. 新增 **clarify 工具**:阻塞式用户交互(仅 WebSocket 入口可用,REST 入口降级)
|
||
4. **重写提示词**:参考 Hermes,优化工具使用纪律和 todo 触发率
|
||
5. 统一 todo_tool.ts 的 schema 为 TypeBox 格式
|
||
|
||
## 2. 架构决策:为什么不用 Hermes 式 Registry
|
||
|
||
Hermes 的 registry 模式要求工具在模块加载时完成注册(schema + handler)。
|
||
Neta 的工具有两类,生命周期完全不同:
|
||
|
||
| 类型 | 工具 | 创建时机 | 依赖 |
|
||
|------|------|----------|------|
|
||
| 静态工具 | bash, read_file, write_file, list_dir, **patch** | 模块加载时 | 无 |
|
||
| 工厂工具 | memory_*, skill_*, delegate_*, todo, **clarify** | 每次会话/请求 | provider, skillLoader, CrewRunContext, TodoStore, WebSocket 回调 |
|
||
|
||
工厂工具的 `execute` 函数通过闭包捕获运行时上下文,无法在模块加载时注册完整实例。
|
||
强行用 registry 只会增加间接层,不解决实际问题。
|
||
|
||
**替代方案:工具目录 (Tool Catalog)**
|
||
- 只注册 schema(name, description, parameters, toolset),不注册 handler
|
||
- 用于 prompt_builder 查询可用工具名列表,替代硬编码的 BASE_TOOLS
|
||
- 工具实例的组装仍由 gateway/server.ts 和 agent_executor.ts 负责
|
||
|
||
## 3. 工具目录 (`tools/catalog.ts`)
|
||
|
||
```typescript
|
||
interface ToolSchema {
|
||
name: string;
|
||
toolset: string;
|
||
description: string;
|
||
parameters: any; // TypeBox schema 或 plain object
|
||
}
|
||
|
||
const catalog = new Map<string, ToolSchema>();
|
||
|
||
export function registerSchema(schema: ToolSchema): void;
|
||
export function getSchemasByToolset(toolset: string): ToolSchema[];
|
||
export function getAllToolNames(toolsets: string[]): string[];
|
||
```
|
||
|
||
工具集定义(内联在 catalog.ts 中,不单独建文件):
|
||
|
||
```typescript
|
||
export const TOOLSET_DEFAULTS = ['base', 'planning', 'interaction'] as const;
|
||
// base: bash, read_file, write_file, list_dir, patch
|
||
// planning: todo
|
||
// interaction: clarify
|
||
// memory: memory_save, memory_recall(条件启用)
|
||
// skill: read_skill, read_skill_file, skill_manage(条件启用)
|
||
// crew: delegate_task, delegate_parallel, escalate(条件启用)
|
||
```
|
||
|
||
**注册入口集中化**:catalog.ts 自身 import 所有工具文件触发注册,消费方只需 `import { getToolNamesByToolsets } from '../tools/catalog.js'`,无需在 server.ts/agent_executor.ts 添加 side-effect import。
|
||
|
||
## 4. Patch 工具
|
||
|
||
### 4.1 Schema (`tools/builtin/patch.ts`)
|
||
|
||
```typescript
|
||
const PatchParams = Type.Object({
|
||
path: Type.String({ description: '文件绝对路径' }),
|
||
old_string: Type.String({ description: '要查找的文本片段' }),
|
||
new_string: Type.String({ description: '替换为的文本' }),
|
||
replace_all: Type.Optional(Type.Boolean({ description: '替换所有匹配,默认 false' })),
|
||
});
|
||
```
|
||
|
||
归入 `base` 工具集,作为静态工具与 bash/read_file/write_file/list_dir 同级。
|
||
|
||
### 4.2 九级模糊匹配 (`utils/fuzzy_match.ts`)
|
||
|
||
| 级别 | 策略 | 算法 |
|
||
|------|------|------|
|
||
| 1 | exact | `content.indexOf(search)` |
|
||
| 2 | line_trimmed | 每行 `.trim()` 后匹配 |
|
||
| 3 | whitespace_normalized | `/\s+/g` → 单空格 |
|
||
| 4 | indent_flexible | 去除行首所有空白 |
|
||
| 5 | escape_normalized | `\\n` → `\n`, `\\t` → `\t` |
|
||
| 6 | trimmed_boundary | 仅首尾行 trim |
|
||
| 7 | unicode_normalized | 智能引号/em-dash/省略号 → ASCII |
|
||
| 8 | block_anchor | 首尾行锚定 + 中间 Levenshtein 相似度 ≥60% |
|
||
| 9 | context_aware | 逐行相似度 ≥80%,整体 ≥50% |
|
||
|
||
接口:
|
||
|
||
```typescript
|
||
interface FuzzyMatchResult {
|
||
strategy: string;
|
||
startIndex: number;
|
||
endIndex: number;
|
||
matchedText: string;
|
||
}
|
||
|
||
// 返回所有匹配(供 replace_all 和唯一性检查使用)
|
||
function fuzzyFindAll(content: string, search: string): FuzzyMatchResult[];
|
||
```
|
||
|
||
### 4.3 执行流程
|
||
|
||
1. `fs.readFile(path)` 读取文件
|
||
2. `fuzzyFindAll(content, old_string)` 查找所有匹配
|
||
3. 匹配数 = 0 → 返回错误 "未找到匹配,请检查 old_string"
|
||
4. 匹配数 > 1 且 `replace_all=false` → 返回错误 "找到 N 处匹配,请提供更多上下文或设置 replace_all=true"
|
||
5. 从后往前替换(避免索引偏移)
|
||
6. `fs.writeFile(path, result)` 写回
|
||
7. 返回简要 diff 摘要
|
||
|
||
## 5. Clarify 工具
|
||
|
||
### 5.1 两个入口点的差异处理
|
||
|
||
| 入口 | 通道 | clarify 行为 |
|
||
|------|------|-------------|
|
||
| `gateway/server.ts` (WebSocket) | 有实时双向通道 | 阻塞式:推送问题,等待回答 |
|
||
| `service/agent_executor.ts` (REST) | 无实时通道 | 降级:返回问题文本,Agent 本轮结束 |
|
||
|
||
### 5.2 Schema (`tools/builtin/clarify.ts`)
|
||
|
||
```typescript
|
||
const ClarifyParams = Type.Object({
|
||
question: Type.String({ description: '要问用户的问题' }),
|
||
choices: Type.Optional(Type.Array(Type.String(), { maxItems: 4, description: '预设选项' })),
|
||
});
|
||
```
|
||
|
||
工具本身只定义 schema,不包含阻塞逻辑。阻塞由外部注入的回调控制。
|
||
|
||
### 5.3 阻塞机制实现
|
||
|
||
**attempt.ts 改动**:新增 `onClarifyRequest` 回调参数
|
||
|
||
```typescript
|
||
interface AttemptParams {
|
||
// ... 现有参数不变 ...
|
||
onClarifyRequest?: (question: string, choices?: string[]) => Promise<string>;
|
||
}
|
||
```
|
||
|
||
工具执行处(attempt.ts 第 59-86 行区域)增加分支:
|
||
|
||
```typescript
|
||
if (tc.name === 'clarify' && params.onClarifyRequest) {
|
||
const args = JSON.parse(tc.arguments);
|
||
resultText = await params.onClarifyRequest(args.question, args.choices);
|
||
} else {
|
||
// 原有的 tool.execute 逻辑
|
||
}
|
||
```
|
||
|
||
**gateway/server.ts 改动**:
|
||
|
||
> **Critical**: Midway.js `@WSController` 是 request-scope(每连接一个实例),`clarifyResolvers` 必须是模块级 Map,否则 clarify_response 可能路由到不同实例导致 resolve 永远不被调用。
|
||
|
||
```typescript
|
||
// 模块级 Map(不是实例属性)
|
||
const clarifyResolvers = new Map<string, {
|
||
resolve: (answer: string) => void;
|
||
timer: NodeJS.Timeout;
|
||
}>();
|
||
|
||
// runAgent 调用时注入回调
|
||
onClarifyRequest: async (question, choices) => {
|
||
const requestId = crypto.randomUUID();
|
||
this.send({ type: 'clarify_request', sessionId: sid, data: { requestId, question, choices } });
|
||
return new Promise<string>((resolve) => {
|
||
const timer = setTimeout(() => {
|
||
this.clarifyResolvers.delete(requestId);
|
||
resolve('用户未在规定时间内回答。请根据已有信息自行判断并继续执行。');
|
||
}, 20_000); // 前端适配前用短超时快速降级
|
||
this.clarifyResolvers.set(requestId, { resolve, timer });
|
||
});
|
||
},
|
||
|
||
// onMessage 新增分支
|
||
if (msg.type === 'clarify_response') {
|
||
const entry = this.clarifyResolvers.get(msg.requestId);
|
||
if (entry) {
|
||
clearTimeout(entry.timer);
|
||
this.clarifyResolvers.delete(msg.requestId);
|
||
entry.resolve(msg.answer);
|
||
}
|
||
}
|
||
```
|
||
|
||
**agent_executor.ts 改动**:不注入 `onClarifyRequest`。
|
||
当 `onClarifyRequest` 未提供时,attempt.ts 走正常的 `tool.execute` 路径。
|
||
clarify 工具的默认 execute 返回:`"[clarify] 问题: {question}\n选项: {choices}"`
|
||
Agent 会将此作为回复输出,用户下次消息自然回答。
|
||
|
||
### 5.4 WebSocket 协议扩展
|
||
|
||
```typescript
|
||
// protocol.ts 新增
|
||
|
||
// 服务端 → 客户端
|
||
interface ServerClarifyRequestEvent {
|
||
type: 'clarify_request';
|
||
sessionId: string;
|
||
data: { requestId: string; question: string; choices?: string[] };
|
||
}
|
||
|
||
// 客户端 → 服务端
|
||
interface ClientClarifyResponseMessage {
|
||
type: 'clarify_response';
|
||
sessionId: string;
|
||
requestId: string;
|
||
answer: string;
|
||
}
|
||
```
|
||
|
||
## 6. 提示词重写
|
||
|
||
### 6.1 TOOL_USE_ENFORCEMENT 改为动态函数
|
||
|
||
> **Critical**: 原设计硬编码了 clarify/patch 工具名,Crew 子 Agent 没有这些工具会被误导调用不存在的工具。改为根据实际可用工具列表动态生成。
|
||
|
||
```typescript
|
||
export function getToolUseEnforcement(toolNames: string[]): string {
|
||
const rules = `# 工具使用规范
|
||
|
||
你必须通过工具采取行动 - 不要只描述你打算做什么。
|
||
|
||
## 强制规则
|
||
- 当你说"我来检查"、"让我执行"时,必须在同一回复中立即发起工具调用
|
||
- 不要以"下一步我会..."结束回复 - 现在就执行
|
||
- 持续工作直到任务真正完成,不要停在计划阶段
|
||
- 每条回复要么包含推进任务的工具调用,要么向用户交付最终结果`;
|
||
|
||
const scenarios: string[] = [];
|
||
if (toolNames.includes('read_file')) scenarios.push('需要读取文件内容时 → read_file(不要凭记忆猜测)');
|
||
if (toolNames.includes('patch')) scenarios.push('需要修改已有文件时 → patch(局部修改)或 write_file(新建/全量重写)');
|
||
else if (toolNames.includes('write_file')) scenarios.push('需要修改文件时 → write_file');
|
||
if (toolNames.includes('list_dir')) scenarios.push('需要了解目录结构时 → list_dir');
|
||
if (toolNames.includes('bash')) scenarios.push('需要执行命令时 → bash');
|
||
if (toolNames.includes('clarify')) scenarios.push('任务需求不明确时 → clarify(主动提问,不要猜测)');
|
||
if (toolNames.includes('todo')) scenarios.push('复杂任务开始前 → todo(创建任务列表)');
|
||
|
||
return scenarios.length > 0
|
||
? `${rules}\n\n## 必须使用工具的场景\n${scenarios.map(s => `- ${s}`).join('\n')}\n\n## 操作前先确认\n- 修改文件前先 read_file 确认当前内容\n- 执行命令前确认工作目录`
|
||
: rules;
|
||
}
|
||
```
|
||
|
||
对应 `prompt_builder.ts` 的调用从 `TOOL_USE_ENFORCEMENT` 常量改为 `getToolUseEnforcement(params.availableToolNames)` 函数调用。
|
||
|
||
### 6.2 TOOL_BEHAVIOR 新增/修改
|
||
|
||
```typescript
|
||
const TOOL_BEHAVIOR: Record<string, string> = {
|
||
|
||
todo: `## 任务规划策略
|
||
收到用户请求后,先评估任务复杂度:
|
||
**必须使用 todo:** 涉及 2 个以上步骤、修改多个文件、需要先调研再实施、用户一次提出多个需求。
|
||
**无需使用 todo:** 单步操作(查看文件、回答问题、一条命令)、简单单文件小修改。
|
||
列表顺序=优先级。同一时间只标记一个 in_progress。完成立即标记 completed。
|
||
任务列表是你的工作契约 - 它让用户看到你的计划并跟踪进度。`,
|
||
|
||
patch: `## 文件编辑策略
|
||
修改已有文件时,优先使用 patch 进行局部替换,而非 write_file 全量重写。
|
||
**用 patch:** 修改函数、修复 bug、调整配置、添加/删除代码片段。
|
||
**用 write_file:** 创建新文件、文件需要完全重写。
|
||
old_string 提供足够上下文确保唯一匹配。不需要精确匹配缩进和空白。`,
|
||
|
||
clarify: `## 主动提问策略
|
||
任务需求不明确、存在多种合理解读、或缺少关键信息时,使用 clarify 向用户提问。
|
||
**应该提问:** 指令模糊有多种解读、缺少关键参数、操作有风险需确认。
|
||
**不应提问:** 任务明确可直接执行、可从上下文推断、琐碎实现细节。
|
||
提供 choices 选项让用户快速选择。一次只问一个问题。`,
|
||
|
||
// memory_save 和 skill_manage 保持不变
|
||
};
|
||
```
|
||
|
||
### 6.3 todo_tool.ts TypeBox 迁移
|
||
|
||
将 `todo_tool.ts` 的 plain object schema 改为 TypeBox 格式,与其他 12 个工具保持一致。
|
||
|
||
## 7. 文件变更清单
|
||
|
||
| 操作 | 文件 | 说明 |
|
||
|------|------|------|
|
||
| 新建 | `tools/catalog.ts` | 轻量工具目录(schema 注册 + 工具集查询) |
|
||
| 新建 | `tools/builtin/patch.ts` | patch 工具(TypeBox schema + execute) |
|
||
| 新建 | `tools/builtin/clarify.ts` | clarify 工具(TypeBox schema + 降级 execute) |
|
||
| 新建 | `utils/fuzzy_match.ts` | 9 级模糊匹配引擎 |
|
||
| 修改 | `tools/todo_tool.ts` | schema 迁移到 TypeBox + 注册到 catalog |
|
||
| 修改 | `tools/builtin/bash.ts` | 底部加 `registerSchema()` |
|
||
| 修改 | `tools/builtin/file.ts` | 底部加 `registerSchema()` |
|
||
| 修改 | `tools/builtin/memory.ts` | 底部加 `registerSchema()` |
|
||
| 修改 | `runtime/attempt.ts` | 新增 `onClarifyRequest` 回调 + clarify 分支 |
|
||
| 修改 | `runtime/prompt_guidance.ts` | TOOL_USE_ENFORCEMENT 改为动态函数 + 重写 todo + 新增 patch/clarify 策略 |
|
||
| 修改 | `runtime/prompt_builder.ts` | `collectAvailableToolNames` 从 catalog 查询;Layer 2 调用 `getToolUseEnforcement(toolNames)` |
|
||
| 修改 | `gateway/protocol.ts` | 新增 clarify_request/clarify_response 事件 |
|
||
| 修改 | `gateway/server.ts` | defaultTools 加 patch/clarify,模块级 clarifyResolvers + 响应处理 |
|
||
| 修改 | `service/agent_executor.ts` | defaultTools 加 patch(不注入 clarify 回调) |
|
||
| 修改 | `service/crew_orchestrator.ts` | builtinTools 加 patch(不加 clarify,Crew 无用户交互通道) |
|
||
|
||
## 8. 不做的事
|
||
|
||
- **不引入 Hermes 式 registry**:Neta 工厂工具多,registry 管不了运行时依赖,是过度设计
|
||
- **不引入 V4A 多文件补丁格式**:Neta 场景不需要原子多文件操作
|
||
- **不修改前端代码**:clarify UI 需前端配合,不在本次后端设计范围
|
||
- **不重构现有工具的导出方式**:bash/file/memory 仍保持现有的 export 模式,只追加 catalog 注册
|
||
- **不新建 toolsets.ts**:工具集定义内联在 catalog.ts 中,不值得单独一个文件
|