988 lines
36 KiB
Markdown
988 lines
36 KiB
Markdown
# Patch/Clarify 工具 + 工具目录 + 提示词优化 实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
> **Post-task review:** After each task commit, run the `simplify` skill to review changed code for reuse, quality, and efficiency.
|
||
|
||
**Goal:** 为 Neta Agent 引擎新增 patch(模糊补丁)和 clarify(阻塞式提问)工具,引入轻量工具目录,重写提示词提升工具触发率。
|
||
|
||
**Architecture:** 新建 `tools/catalog.ts` 管理工具 schema 元数据(不管理实例)。patch 工具依赖 `tools/fuzzy_match.ts` 九级匹配引擎。clarify 工具通过 `attempt.ts` 的 `onClarifyRequest` 回调实现阻塞,gateway 层用 Promise+Map 协调 WebSocket 消息,REST 入口降级为文本输出。
|
||
|
||
**Tech Stack:** TypeScript, @sinclair/typebox, Midway.js Socket.IO, Node.js fs/promises
|
||
|
||
**Base path:** `packages/backend/src/modules/netaclaw`
|
||
|
||
---
|
||
|
||
### Task 1: 工具目录 catalog.ts
|
||
|
||
**Files:**
|
||
- Create: `tools/catalog.ts`
|
||
- Modify: `runtime/prompt_builder.ts`
|
||
|
||
- [ ] **Step 1: 创建 tools/catalog.ts**
|
||
|
||
```typescript
|
||
// tools/catalog.ts
|
||
/**
|
||
* 轻量工具目录 — 只注册 schema 元数据,不管理工具实例。
|
||
* 用于 prompt_builder 查询可用工具名列表,替代硬编码 BASE_TOOLS。
|
||
*/
|
||
|
||
export interface ToolSchema {
|
||
name: string;
|
||
toolset: string;
|
||
description: string;
|
||
}
|
||
|
||
const catalog = new Map<string, ToolSchema>();
|
||
|
||
export function registerSchema(schema: ToolSchema): void {
|
||
catalog.set(schema.name, schema);
|
||
}
|
||
|
||
export function getToolNamesByToolsets(toolsets: string[]): string[] {
|
||
const names: string[] = [];
|
||
for (const entry of catalog.values()) {
|
||
if (toolsets.includes(entry.toolset)) names.push(entry.name);
|
||
}
|
||
return names;
|
||
}
|
||
|
||
/** 默认启用的工具集(所有 Agent 都有) */
|
||
export const TOOLSET_DEFAULTS = ['base', 'planning', 'interaction'] as const;
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 prompt_builder.ts — 从 catalog 查询工具名**
|
||
|
||
将 `runtime/prompt_builder.ts` 中硬编码的 `BASE_TOOLS` 和 `collectAvailableToolNames` 改为从 catalog 查询:
|
||
|
||
```typescript
|
||
// runtime/prompt_builder.ts — 替换原有的 BASE_TOOLS 和 collectAvailableToolNames
|
||
|
||
import { getToolNamesByToolsets, TOOLSET_DEFAULTS } from '../tools/catalog.js';
|
||
|
||
// 删除: const BASE_TOOLS = ['bash', 'read_file', 'write_file', 'list_dir', 'todo'];
|
||
|
||
export function collectAvailableToolNames(opts: CollectToolNamesOpts): string[] {
|
||
const toolsets = [...TOOLSET_DEFAULTS];
|
||
if (opts.memoryEnabled) toolsets.push('memory');
|
||
if (opts.hasSkills) toolsets.push('skill');
|
||
if (opts.crewRole === 'master') toolsets.push('crew');
|
||
return getToolNamesByToolsets(toolsets);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 现有工具注册到 catalog — bash.ts**
|
||
|
||
在 `tools/builtin/bash.ts` 文件末尾追加:
|
||
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'bash', toolset: 'base', description: bashTool.description });
|
||
```
|
||
|
||
- [ ] **Step 4: 现有工具注册到 catalog — file.ts**
|
||
|
||
在 `tools/builtin/file.ts` 文件末尾追加:
|
||
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'read_file', toolset: 'base', description: readFileTool.description });
|
||
registerSchema({ name: 'write_file', toolset: 'base', description: writeFileTool.description });
|
||
registerSchema({ name: 'list_dir', toolset: 'base', description: listDirTool.description });
|
||
```
|
||
|
||
- [ ] **Step 5: 现有工具注册到 catalog — memory.ts**
|
||
|
||
在 `tools/builtin/memory.ts` 文件末尾追加:
|
||
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'memory_save', toolset: 'memory', description: '存储、更新或删除长期记忆。' });
|
||
registerSchema({ name: 'memory_recall', toolset: 'memory', description: '搜索长期记忆中的相关信息。' });
|
||
```
|
||
|
||
- [ ] **Step 6: 现有工具注册到 catalog — todo_tool.ts**
|
||
|
||
在 `tools/todo_tool.ts` 文件末尾追加:
|
||
|
||
```typescript
|
||
import { registerSchema } from './catalog.js';
|
||
registerSchema({ name: 'todo', toolset: 'planning', description: todoToolSchema.description });
|
||
```
|
||
|
||
- [ ] **Step 7: 工厂工具注册到 catalog — skill 工具**
|
||
|
||
在以下文件末尾各追加 `registerSchema` 调用(schema 是静态的,只有 execute 需要运行时依赖):
|
||
|
||
`tools/builtin/read_skill.ts` 末尾:
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'read_skill', toolset: 'skill', description: '读取指定技能的 SKILL.md 内容' });
|
||
```
|
||
|
||
`tools/builtin/read_skill_file.ts` 末尾:
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'read_skill_file', toolset: 'skill', description: '读取技能的附属文件内容' });
|
||
```
|
||
|
||
`tools/builtin/skill_manage.ts` 末尾:
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'skill_manage', toolset: 'skill', description: '创建、更新或删除技能' });
|
||
```
|
||
|
||
- [ ] **Step 8: 工厂工具注册到 catalog — crew 工具**
|
||
|
||
`tools/builtin/delegate_task.ts` 末尾:
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'delegate_task', toolset: 'crew', description: '委派任务给指定成员 Agent' });
|
||
```
|
||
|
||
`tools/builtin/delegate_parallel.ts` 末尾:
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'delegate_parallel', toolset: 'crew', description: '并行委派多个任务给不同成员' });
|
||
```
|
||
|
||
`tools/builtin/escalate.ts` 末尾:
|
||
```typescript
|
||
import { registerSchema } from '../catalog.js';
|
||
registerSchema({ name: 'escalate', toolset: 'crew', description: '将问题升级给用户或上级 Agent' });
|
||
```
|
||
|
||
- [ ] **Step 9: catalog.ts 自注册所有工具文件**
|
||
|
||
> 消除 side-effect import 维护负担:catalog.ts 自身 import 所有工具文件触发注册。
|
||
|
||
在 `tools/catalog.ts` 底部追加:
|
||
|
||
```typescript
|
||
// --- 集中注册入口:import 触发各工具文件的 registerSchema ---
|
||
import './builtin/bash.js';
|
||
import './builtin/file.js';
|
||
import './builtin/patch.js';
|
||
import './builtin/clarify.js';
|
||
import './builtin/memory.js';
|
||
import './builtin/read_skill.js';
|
||
import './builtin/read_skill_file.js';
|
||
import './builtin/skill_manage.js';
|
||
import './builtin/delegate_task.js';
|
||
import './builtin/delegate_parallel.js';
|
||
import './builtin/escalate.js';
|
||
import './todo_tool.js';
|
||
```
|
||
|
||
> 注意:这些 import 放在 catalog.ts 底部(registerSchema 函数定义之后),确保函数已可用。消费方只需 `import { getToolNamesByToolsets } from '../tools/catalog.js'`。
|
||
|
||
- [ ] **Step 10: 验证构建**
|
||
|
||
Run: `cd packages/backend && npm run build`
|
||
Expected: 编译成功,无类型错误
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/modules/netaclaw/tools/catalog.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/bash.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/file.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/memory.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/read_skill_file.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts \
|
||
packages/backend/src/modules/netaclaw/tools/builtin/escalate.ts \
|
||
packages/backend/src/modules/netaclaw/tools/todo_tool.ts \
|
||
packages/backend/src/modules/netaclaw/runtime/prompt_builder.ts \
|
||
packages/backend/src/modules/netaclaw/gateway/server.ts \
|
||
packages/backend/src/modules/netaclaw/service/agent_executor.ts
|
||
git commit -m "feat(netaclaw): add tool catalog for schema-based tool name resolution"
|
||
```
|
||
|
||
- [ ] **Step 12: 运行 simplify skill 审查本次变更**
|
||
|
||
---
|
||
|
||
### Task 2: 九级模糊匹配引擎 fuzzy_match.ts
|
||
|
||
**Files:**
|
||
- Create: `tools/fuzzy_match.ts`
|
||
|
||
- [ ] **Step 1: 创建 tools/fuzzy_match.ts — 类型定义和策略框架**
|
||
|
||
```typescript
|
||
// tools/fuzzy_match.ts
|
||
/**
|
||
* 九级模糊匹配引擎
|
||
* 按优先级依次尝试 9 种策略,首个成功即返回。
|
||
*/
|
||
|
||
export interface FuzzyMatchResult {
|
||
strategy: string;
|
||
startIndex: number;
|
||
endIndex: number;
|
||
matchedText: string;
|
||
}
|
||
|
||
type Strategy = {
|
||
name: string;
|
||
find: (content: string, search: string) => FuzzyMatchResult[];
|
||
};
|
||
|
||
/** 在 content 中查找所有匹配 search 的位置,按策略优先级 */
|
||
export function fuzzyFindAll(content: string, search: string): FuzzyMatchResult[] {
|
||
for (const strategy of strategies) {
|
||
const results = strategy.find(content, search);
|
||
if (results.length > 0) return results;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// --- 辅助函数 ---
|
||
|
||
/** Levenshtein 距离 */
|
||
function levenshtein(a: string, b: string): number {
|
||
const m = a.length, n = b.length;
|
||
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
|
||
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
|
||
);
|
||
for (let i = 1; i <= m; i++) {
|
||
for (let j = 1; j <= n; j++) {
|
||
dp[i][j] = a[i - 1] === b[j - 1]
|
||
? dp[i - 1][j - 1]
|
||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
||
}
|
||
}
|
||
return dp[m][n];
|
||
}
|
||
|
||
/** 两字符串相似度 0-1 */
|
||
function similarity(a: string, b: string): number {
|
||
if (a === b) return 1;
|
||
const maxLen = Math.max(a.length, b.length);
|
||
if (maxLen === 0) return 1;
|
||
return 1 - levenshtein(a, b) / maxLen;
|
||
}
|
||
|
||
/** 将 content 按 normalizer 转换后查找 search,映射回原始索引 */
|
||
function findByNormalization(
|
||
content: string,
|
||
search: string,
|
||
normalizer: (s: string) => string,
|
||
strategyName: string,
|
||
): FuzzyMatchResult[] {
|
||
const normContent = normalizer(content);
|
||
const normSearch = normalizer(search);
|
||
if (normSearch.length === 0) return [];
|
||
|
||
// 构建字符映射:normIndex → origIndex
|
||
// 逐字符对比原始和标准化后的内容,建立位置映射
|
||
const charMap = buildCharMap(content, normContent);
|
||
|
||
const results: FuzzyMatchResult[] = [];
|
||
let pos = 0;
|
||
while (true) {
|
||
const idx = normContent.indexOf(normSearch, pos);
|
||
if (idx === -1) break;
|
||
const endIdx = idx + normSearch.length;
|
||
const origStart = charMap[idx] ?? 0;
|
||
const origEnd = (endIdx < charMap.length ? charMap[endIdx] : content.length);
|
||
results.push({
|
||
strategy: strategyName,
|
||
startIndex: origStart,
|
||
endIndex: origEnd,
|
||
matchedText: content.slice(origStart, origEnd),
|
||
});
|
||
pos = idx + 1;
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 构建标准化索引 → 原始索引的映射表。
|
||
* 对于行级 normalizer(不改变行数),按行对齐映射。
|
||
* 对于可能改变行数的 normalizer,逐字符扫描。
|
||
*/
|
||
function buildCharMap(original: string, normalized: string): number[] {
|
||
// 简单实现:如果行数相同,按行内偏移映射
|
||
const origLines = original.split('\n');
|
||
const normLines = normalized.split('\n');
|
||
|
||
if (origLines.length === normLines.length) {
|
||
// 行级映射:每行内按比例映射
|
||
const map: number[] = [];
|
||
let origOffset = 0;
|
||
let normOffset = 0;
|
||
for (let i = 0; i < normLines.length; i++) {
|
||
const origLen = origLines[i].length;
|
||
const normLen = normLines[i].length;
|
||
for (let j = 0; j < normLen; j++) {
|
||
map[normOffset + j] = origOffset + Math.round((j / Math.max(normLen, 1)) * origLen);
|
||
}
|
||
// 换行符映射
|
||
if (i < normLines.length - 1) {
|
||
map[normOffset + normLen] = origOffset + origLen;
|
||
}
|
||
origOffset += origLen + 1;
|
||
normOffset += normLen + 1;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// 行数不同(如 whitespace_normalized):按字符比例映射
|
||
const map: number[] = [];
|
||
const ratio = original.length / Math.max(normalized.length, 1);
|
||
for (let i = 0; i < normalized.length; i++) {
|
||
map[i] = Math.round(i * ratio);
|
||
}
|
||
return map;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 实现 9 个策略**
|
||
|
||
在 `tools/fuzzy_match.ts` 底部追加策略数组:
|
||
|
||
```typescript
|
||
// --- 9 级策略 ---
|
||
|
||
const strategies: Strategy[] = [
|
||
// 1. exact
|
||
{
|
||
name: 'exact',
|
||
find(content, search) {
|
||
const results: FuzzyMatchResult[] = [];
|
||
let pos = 0;
|
||
while (true) {
|
||
const idx = content.indexOf(search, pos);
|
||
if (idx === -1) break;
|
||
results.push({ strategy: 'exact', startIndex: idx, endIndex: idx + search.length, matchedText: search });
|
||
pos = idx + 1;
|
||
}
|
||
return results;
|
||
},
|
||
},
|
||
|
||
// 2. line_trimmed — 每行 trim 后匹配
|
||
{
|
||
name: 'line_trimmed',
|
||
find(content, search) {
|
||
return findByNormalization(content, search, s => s.split('\n').map(l => l.trim()).join('\n'), 'line_trimmed');
|
||
},
|
||
},
|
||
|
||
// 3. whitespace_normalized — 连续空白合并为单空格
|
||
{
|
||
name: 'whitespace_normalized',
|
||
find(content, search) {
|
||
return findByNormalization(content, search, s => s.replace(/\s+/g, ' '), 'whitespace_normalized');
|
||
},
|
||
},
|
||
|
||
// 4. indent_flexible — 去除行首所有空白
|
||
{
|
||
name: 'indent_flexible',
|
||
find(content, search) {
|
||
return findByNormalization(content, search, s => s.split('\n').map(l => l.trimStart()).join('\n'), 'indent_flexible');
|
||
},
|
||
},
|
||
|
||
// 5. escape_normalized — \\n → \n, \\t → \t
|
||
{
|
||
name: 'escape_normalized',
|
||
find(content, search) {
|
||
const norm = (s: string) => s.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||
return findByNormalization(content, search, norm, 'escape_normalized');
|
||
},
|
||
},
|
||
|
||
// 6. trimmed_boundary — 仅首尾行 trim
|
||
{
|
||
name: 'trimmed_boundary',
|
||
find(content, search) {
|
||
const norm = (s: string) => {
|
||
const lines = s.split('\n');
|
||
if (lines.length > 0) lines[0] = lines[0].trim();
|
||
if (lines.length > 1) lines[lines.length - 1] = lines[lines.length - 1].trim();
|
||
return lines.join('\n');
|
||
};
|
||
return findByNormalization(content, search, norm, 'trimmed_boundary');
|
||
},
|
||
},
|
||
|
||
// 7. unicode_normalized — 智能引号/em-dash/省略号 → ASCII
|
||
{
|
||
name: 'unicode_normalized',
|
||
find(content, search) {
|
||
const norm = (s: string) => s
|
||
.replace(/[\u2018\u2019\u201A]/g, "'")
|
||
.replace(/[\u201C\u201D\u201E]/g, '"')
|
||
.replace(/[\u2013\u2014]/g, '--')
|
||
.replace(/\u2026/g, '...');
|
||
return findByNormalization(content, search, norm, 'unicode_normalized');
|
||
},
|
||
},
|
||
|
||
// 8. block_anchor — 首尾行锚定 + 中间 Levenshtein ≥60%
|
||
{
|
||
name: 'block_anchor',
|
||
find(content, search) {
|
||
const searchLines = search.split('\n');
|
||
if (searchLines.length < 3) return [];
|
||
const firstLine = searchLines[0].trim();
|
||
const lastLine = searchLines[searchLines.length - 1].trim();
|
||
const contentLines = content.split('\n');
|
||
const results: FuzzyMatchResult[] = [];
|
||
|
||
for (let i = 0; i < contentLines.length; i++) {
|
||
if (contentLines[i].trim() !== firstLine) continue;
|
||
for (let j = i + searchLines.length - 1; j < contentLines.length && j < i + searchLines.length + 5; j++) {
|
||
if (contentLines[j].trim() !== lastLine) continue;
|
||
const candidateMiddle = contentLines.slice(i + 1, j).join('\n');
|
||
const searchMiddle = searchLines.slice(1, -1).join('\n');
|
||
if (similarity(candidateMiddle, searchMiddle) >= 0.6) {
|
||
const startIndex = content.split('\n').slice(0, i).join('\n').length + (i > 0 ? 1 : 0);
|
||
const matchedLines = contentLines.slice(i, j + 1);
|
||
const matchedText = matchedLines.join('\n');
|
||
results.push({ strategy: 'block_anchor', startIndex, endIndex: startIndex + matchedText.length, matchedText });
|
||
}
|
||
}
|
||
}
|
||
return results;
|
||
},
|
||
},
|
||
|
||
// 9. context_aware — 逐行相似度 ≥80%,整体 ≥50%
|
||
{
|
||
name: 'context_aware',
|
||
find(content, search) {
|
||
const searchLines = search.split('\n');
|
||
const contentLines = content.split('\n');
|
||
if (searchLines.length === 0) return [];
|
||
const results: FuzzyMatchResult[] = [];
|
||
|
||
for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
|
||
let matchCount = 0;
|
||
for (let j = 0; j < searchLines.length; j++) {
|
||
if (similarity(contentLines[i + j].trim(), searchLines[j].trim()) >= 0.8) matchCount++;
|
||
}
|
||
if (matchCount / searchLines.length >= 0.5) {
|
||
const startIndex = contentLines.slice(0, i).join('\n').length + (i > 0 ? 1 : 0);
|
||
const matchedText = contentLines.slice(i, i + searchLines.length).join('\n');
|
||
results.push({ strategy: 'context_aware', startIndex, endIndex: startIndex + matchedText.length, matchedText });
|
||
}
|
||
}
|
||
return results;
|
||
},
|
||
},
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 3: 验证构建**
|
||
|
||
Run: `cd packages/backend && npm run build`
|
||
Expected: 编译成功
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/modules/netaclaw/tools/fuzzy_match.ts
|
||
git commit -m "feat(netaclaw): add 9-level fuzzy match engine for patch tool"
|
||
```
|
||
|
||
- [ ] **Step 5: 运行 simplify skill 审查本次变更**
|
||
|
||
---
|
||
|
||
### Task 3: Patch 工具
|
||
|
||
**Files:**
|
||
- Create: `tools/builtin/patch.ts`
|
||
- Modify: `gateway/server.ts`
|
||
- Modify: `service/agent_executor.ts`
|
||
|
||
- [ ] **Step 1: 创建 tools/builtin/patch.ts**
|
||
|
||
```typescript
|
||
// tools/builtin/patch.ts
|
||
import { Type } from '@sinclair/typebox';
|
||
import * as fs from 'fs/promises';
|
||
import { AgentToolWithMeta, textResult } from '../common.js';
|
||
import { fuzzyFindAll } from '../fuzzy_match.js';
|
||
import { registerSchema } from '../catalog.js';
|
||
|
||
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' })),
|
||
});
|
||
|
||
export const patchTool: AgentToolWithMeta<typeof PatchParams, unknown> = {
|
||
name: 'patch',
|
||
label: '模糊补丁',
|
||
description: '对文件进行局部查找替换,支持模糊匹配。比 write_file 更安全、更省 token。',
|
||
parameters: PatchParams,
|
||
async execute(_id, params) {
|
||
try {
|
||
const content = await fs.readFile(params.path, 'utf-8');
|
||
const matches = fuzzyFindAll(content, params.old_string);
|
||
|
||
if (matches.length === 0) {
|
||
return textResult(`未找到匹配。请检查 old_string 是否正确,或提供更多上下文。`);
|
||
}
|
||
if (matches.length > 1 && !params.replace_all) {
|
||
return textResult(`找到 ${matches.length} 处匹配(策略: ${matches[0].strategy}),请提供更多上下文确保唯一匹配,或设置 replace_all=true。`);
|
||
}
|
||
|
||
// 从后往前替换,避免索引偏移
|
||
let result = content;
|
||
const sorted = [...matches].sort((a, b) => b.startIndex - a.startIndex);
|
||
for (const m of sorted) {
|
||
result = result.slice(0, m.startIndex) + params.new_string + result.slice(m.endIndex);
|
||
}
|
||
|
||
await fs.writeFile(params.path, result, 'utf-8');
|
||
|
||
const count = params.replace_all ? matches.length : 1;
|
||
return textResult(`已替换 ${count} 处(策略: ${matches[0].strategy}): ${params.path}`);
|
||
} catch (err: any) {
|
||
return textResult(`patch 失败: ${err.message}`);
|
||
}
|
||
},
|
||
};
|
||
|
||
registerSchema({ name: 'patch', toolset: 'base', description: patchTool.description });
|
||
```
|
||
|
||
- [ ] **Step 2: gateway/server.ts — defaultTools 加入 patchTool**
|
||
|
||
```typescript
|
||
// gateway/server.ts 顶部 import 追加
|
||
import { patchTool } from '../tools/builtin/patch.js';
|
||
|
||
// 修改 defaultTools(第 50 行)
|
||
private defaultTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool];
|
||
```
|
||
|
||
- [ ] **Step 3: agent_executor.ts — defaultTools 加入 patchTool**
|
||
|
||
```typescript
|
||
// service/agent_executor.ts 顶部 import 追加
|
||
import { patchTool } from '../tools/builtin/patch.js';
|
||
|
||
// 修改 defaultTools(第 40 行)
|
||
private readonly defaultTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool];
|
||
```
|
||
|
||
- [ ] **Step 3.5: crew_orchestrator.ts + crew_delegate.ts — builtinTools 加入 patchTool**
|
||
|
||
> Crew 子 Agent 也需要 patch 进行文件编辑(但不加 clarify,Crew 无用户交互通道)。
|
||
|
||
```typescript
|
||
// service/crew_orchestrator.ts 顶部 import 追加
|
||
import { patchTool } from '../tools/builtin/patch.js';
|
||
|
||
// 修改第 24 行 BUILTIN_TOOL_NAMES 追加 'patch'
|
||
const BUILTIN_TOOL_NAMES = ['bash', 'read_file', 'write_file', 'list_dir', 'patch', 'delegate_task', 'delegate_parallel', 'escalate'];
|
||
|
||
// 修改第 175 行 builtinTools 追加 patchTool
|
||
const builtinTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool];
|
||
```
|
||
|
||
```typescript
|
||
// service/crew_delegate.ts 顶部 import 追加
|
||
import { patchTool } from '../tools/builtin/patch.js';
|
||
|
||
// 修改第 17 行 BUILTIN_TOOL_NAMES 追加 'patch'
|
||
const BUILTIN_TOOL_NAMES = ['bash', 'read_file', 'write_file', 'list_dir', 'patch'];
|
||
|
||
// 修改第 40 行 builtinTools 追加 patchTool
|
||
const builtinTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool];
|
||
```
|
||
|
||
- [ ] **Step 4: 验证构建**
|
||
|
||
Run: `cd packages/backend && npm run build`
|
||
Expected: 编译成功
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/modules/netaclaw/tools/builtin/patch.ts \
|
||
packages/backend/src/modules/netaclaw/gateway/server.ts \
|
||
packages/backend/src/modules/netaclaw/service/agent_executor.ts \
|
||
packages/backend/src/modules/netaclaw/service/crew_orchestrator.ts \
|
||
packages/backend/src/modules/netaclaw/service/crew_delegate.ts
|
||
git commit -m "feat(netaclaw): add patch tool with fuzzy matching"
|
||
```
|
||
|
||
- [ ] **Step 6: 运行 simplify skill 审查本次变更**
|
||
|
||
---
|
||
|
||
### Task 4: Clarify 工具 + WebSocket 协议 + 阻塞机制
|
||
|
||
**Files:**
|
||
- Create: `tools/builtin/clarify.ts`
|
||
- Modify: `gateway/protocol.ts`
|
||
- Modify: `runtime/attempt.ts`
|
||
- Modify: `runtime/agent.ts`
|
||
- Modify: `gateway/server.ts`
|
||
|
||
- [ ] **Step 1: 创建 tools/builtin/clarify.ts**
|
||
|
||
```typescript
|
||
// tools/builtin/clarify.ts
|
||
import { Type } from '@sinclair/typebox';
|
||
import { AgentToolWithMeta, textResult } from '../common.js';
|
||
import { registerSchema } from '../catalog.js';
|
||
|
||
const ClarifyParams = Type.Object({
|
||
question: Type.String({ description: '要问用户的问题' }),
|
||
choices: Type.Optional(Type.Array(Type.String(), { maxItems: 4, description: '预设选项(最多4个)' })),
|
||
});
|
||
|
||
/** 降级 execute:当无 WebSocket 回调时,返回问题文本让 Agent 作为回复输出 */
|
||
export const clarifyTool: AgentToolWithMeta<typeof ClarifyParams, unknown> = {
|
||
name: 'clarify',
|
||
label: '向用户提问',
|
||
description: '当任务需求不明确时,向用户提出澄清问题。支持选择题和开放式问题。',
|
||
parameters: ClarifyParams,
|
||
async execute(_id, params) {
|
||
const choicesText = params.choices?.length
|
||
? `\n选项: ${params.choices.map((c, i) => `${i + 1}. ${c}`).join(', ')}`
|
||
: '';
|
||
return textResult(`[需要用户确认] ${params.question}${choicesText}`);
|
||
},
|
||
};
|
||
|
||
registerSchema({ name: 'clarify', toolset: 'interaction', description: clarifyTool.description });
|
||
```
|
||
|
||
- [ ] **Step 2: 扩展 gateway/protocol.ts**
|
||
|
||
在 `protocol.ts` 的 `ClientMessage` 和 `ServerEvent` 联合类型中追加:
|
||
|
||
```typescript
|
||
// --- 客户端 → 服务端 追加 ---
|
||
|
||
export interface ClientClarifyResponseMessage {
|
||
type: 'clarify_response';
|
||
sessionId: string;
|
||
requestId: string;
|
||
answer: string;
|
||
}
|
||
|
||
export type ClientMessage = ClientChatMessage | ClientPingMessage | ClientSetThinkingLevelMessage | ClientClarifyResponseMessage;
|
||
|
||
// --- 服务端 → 客户端 追加 ---
|
||
|
||
export interface ServerClarifyRequestEvent {
|
||
type: 'clarify_request';
|
||
sessionId: string;
|
||
data: { requestId: string; question: string; choices?: string[] };
|
||
}
|
||
|
||
// ServerEvent 联合类型追加 ServerClarifyRequestEvent
|
||
```
|
||
|
||
- [ ] **Step 3: 修改 runtime/attempt.ts — 新增 onClarifyRequest 回调**
|
||
|
||
```typescript
|
||
// attempt.ts — AttemptParams 接口追加字段
|
||
export interface AttemptParams {
|
||
// ... 现有字段不变 ...
|
||
onClarifyRequest?: (question: string, choices?: string[]) => Promise<string>;
|
||
}
|
||
|
||
// attempt.ts — 工具执行循环中(第 69-79 行区域),在 tool.execute 之前插入 clarify 分支
|
||
if (tc.name === 'clarify' && params.onClarifyRequest) {
|
||
const args = JSON.parse(tc.arguments);
|
||
try {
|
||
resultText = await params.onClarifyRequest(args.question, args.choices);
|
||
} catch {
|
||
resultText = '用户未回答,请自行判断并继续。';
|
||
}
|
||
} else if (!tool) {
|
||
resultText = `错误: 工具 "${tc.name}" 不存在`;
|
||
} else {
|
||
try {
|
||
const result = await tool.execute(tc.id, JSON.parse(tc.arguments));
|
||
resultText = typeof result === 'string' ? result : JSON.stringify(result);
|
||
} catch (err: any) {
|
||
resultText = `工具执行错误: ${err.message}`;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 修改 runtime/agent.ts — 透传 onClarifyRequest**
|
||
|
||
```typescript
|
||
// agent.ts — AgentRunParams 接口追加
|
||
export interface AgentRunParams {
|
||
// ... 现有字段不变 ...
|
||
onClarifyRequest?: (question: string, choices?: string[]) => Promise<string>;
|
||
}
|
||
|
||
// agent.ts — runAttempt 调用处(第 86-96 行)追加透传
|
||
return runAttempt({
|
||
// ... 现有参数不变 ...
|
||
onClarifyRequest: params.onClarifyRequest,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 修改 gateway/server.ts — 注入 clarify 阻塞回调**
|
||
|
||
> **Critical**: Midway.js `@WSController` 是 request-scope(每连接一个实例),`clarifyResolvers` 必须是**模块级** Map,否则 clarify_response 可能路由到不同实例导致 resolve 永远不被调用。超时设为 20s(前端适配前快速降级)。
|
||
|
||
```typescript
|
||
// gateway/server.ts — 顶部 import 追加
|
||
import { randomUUID } from 'crypto';
|
||
import { clarifyTool } from '../tools/builtin/clarify.js';
|
||
|
||
// 模块级 Map(不是实例属性!@WSController 每连接一个实例)
|
||
const clarifyResolvers = new Map<string, { resolve: (answer: string) => void; timer: NodeJS.Timeout }>();
|
||
|
||
// defaultTools 追加 clarifyTool
|
||
private defaultTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool, clarifyTool];
|
||
|
||
// runAgent 调用处(第 219 行区域)追加 onClarifyRequest
|
||
onClarifyRequest: async (question, choices) => {
|
||
const requestId = randomUUID();
|
||
this.send({ type: 'clarify_request', sessionId: sid, data: { requestId, question, choices } });
|
||
return new Promise<string>((resolve) => {
|
||
const timer = setTimeout(() => {
|
||
clarifyResolvers.delete(requestId);
|
||
resolve('用户未在规定时间内回答。请根据已有信息自行判断并继续执行。');
|
||
}, 20_000); // 前端适配前用短超时快速降级
|
||
clarifyResolvers.set(requestId, { resolve, timer });
|
||
});
|
||
},
|
||
|
||
// onMessage 方法中(第 80 行区域)追加 clarify_response 处理
|
||
if (msg.type === 'clarify_response') {
|
||
const entry = clarifyResolvers.get(msg.requestId);
|
||
if (entry) {
|
||
clearTimeout(entry.timer);
|
||
clarifyResolvers.delete(msg.requestId);
|
||
entry.resolve(msg.answer);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 验证构建**
|
||
|
||
Run: `cd packages/backend && npm run build`
|
||
Expected: 编译成功
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/modules/netaclaw/tools/builtin/clarify.ts \
|
||
packages/backend/src/modules/netaclaw/gateway/protocol.ts \
|
||
packages/backend/src/modules/netaclaw/runtime/attempt.ts \
|
||
packages/backend/src/modules/netaclaw/runtime/agent.ts \
|
||
packages/backend/src/modules/netaclaw/gateway/server.ts
|
||
git commit -m "feat(netaclaw): add clarify tool with blocking WebSocket interaction"
|
||
```
|
||
|
||
- [ ] **Step 8: 运行 simplify skill 审查本次变更**
|
||
|
||
---
|
||
|
||
### Task 5: 提示词重写 + todo TypeBox 迁移
|
||
|
||
**Files:**
|
||
- Modify: `runtime/prompt_guidance.ts`
|
||
- Modify: `tools/todo_tool.ts`
|
||
|
||
- [ ] **Step 1: 重写 prompt_guidance.ts — TOOL_USE_ENFORCEMENT 改为动态函数**
|
||
|
||
> **Critical**: 原设计硬编码了 clarify/patch 工具名,Crew 子 Agent 没有这些工具会被误导调用不存在的工具。改为根据实际可用工具列表动态生成。
|
||
|
||
删除 `prompt_guidance.ts` 第 29-34 行的 `export const TOOL_USE_ENFORCEMENT = ...`,替换为:
|
||
|
||
```typescript
|
||
/**
|
||
* 根据实际可用工具列表动态生成工具使用规范。
|
||
* Crew 子 Agent 没有 clarify/patch,不应在提示词中出现这些工具。
|
||
*/
|
||
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;
|
||
}
|
||
```
|
||
|
||
同时修改 `runtime/prompt_builder.ts` 中 Layer 2 的调用:
|
||
|
||
```typescript
|
||
// prompt_builder.ts — 将 TOOL_USE_ENFORCEMENT 常量引用改为函数调用
|
||
// 旧: import { TOOL_USE_ENFORCEMENT, ... } from './prompt_guidance.js';
|
||
// 新: import { getToolUseEnforcement, ... } from './prompt_guidance.js';
|
||
|
||
// Layer 2 中:
|
||
// 旧: parts.push(TOOL_USE_ENFORCEMENT);
|
||
// 新: parts.push(getToolUseEnforcement(params.availableToolNames));
|
||
```
|
||
|
||
- [ ] **Step 2: 重写 prompt_guidance.ts — TOOL_BEHAVIOR**
|
||
|
||
替换 `prompt_guidance.ts` 第 104-117 行的 `TOOL_BEHAVIOR` 对象:
|
||
|
||
```typescript
|
||
const TOOL_BEHAVIOR: Record<string, string> = {
|
||
memory_save: `## 记忆使用策略
|
||
使用 memory_save 存储对未来对话有价值的持久信息:用户偏好、环境细节、项目约束。
|
||
保持记忆紧凑,聚焦于减少用户未来重复纠正的事实。
|
||
不要存储任务进度、会话结果、临时 TODO 状态。更新已有记忆而非创建重复条目。`,
|
||
|
||
todo: `## 任务规划策略
|
||
收到用户请求后,先评估任务复杂度:
|
||
**必须使用 todo:** 涉及 2 个以上步骤、修改多个文件、需要先调研再实施、用户一次提出多个需求。
|
||
**无需使用 todo:** 单步操作(查看文件、回答问题、一条命令)、简单单文件小修改。
|
||
列表顺序=优先级。同一时间只标记一个 in_progress。完成立即标记 completed。
|
||
任务列表是你的工作契约 - 它让用户看到你的计划并跟踪进度。`,
|
||
|
||
skill_manage: `## 技能管理策略
|
||
完成复杂任务(5步以上工具调用)后,考虑用 skill_manage 将方法保存为技能以便复用。
|
||
使用技能时如发现过时或错误,立即修补,不要等用户要求。`,
|
||
|
||
patch: `## 文件编辑策略
|
||
修改已有文件时,优先使用 patch 进行局部替换,而非 write_file 全量重写。
|
||
**用 patch:** 修改函数、修复 bug、调整配置、添加/删除代码片段。
|
||
**用 write_file:** 创建新文件、文件需要完全重写。
|
||
old_string 提供足够上下文确保唯一匹配。不需要精确匹配缩进和空白。`,
|
||
|
||
clarify: `## 主动提问策略
|
||
任务需求不明确、存在多种合理解读、或缺少关键信息时,使用 clarify 向用户提问。
|
||
**应该提问:** 指令模糊有多种解读、缺少关键参数、操作有风险需确认。
|
||
**不应提问:** 任务明确可直接执行、可从上下文推断、琐碎实现细节。
|
||
提供 choices 选项让用户快速选择。一次只问一个问题。`,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: todo_tool.ts — TypeBox 迁移**
|
||
|
||
替换 `tools/todo_tool.ts` 的 schema 定义(第 3-41 行):
|
||
|
||
```typescript
|
||
import { Type, Static } from '@sinclair/typebox';
|
||
import { TodoStore } from '../runtime/todo_store.js';
|
||
import { registerSchema } from './catalog.js';
|
||
|
||
const TodoItemSchema = Type.Object({
|
||
id: Type.String({ description: '唯一标识' }),
|
||
content: Type.String({ description: '任务描述' }),
|
||
status: Type.Union([
|
||
Type.Literal('pending'),
|
||
Type.Literal('in_progress'),
|
||
Type.Literal('completed'),
|
||
Type.Literal('cancelled'),
|
||
], { description: '当前状态' }),
|
||
});
|
||
|
||
const TodoParams = Type.Object({
|
||
todos: Type.Optional(Type.Array(TodoItemSchema, { description: '任务项数组。省略则读取当前列表。' })),
|
||
merge: Type.Optional(Type.Boolean({ description: 'true=按 id 增量更新。false(默认)=全量替换。', default: false })),
|
||
});
|
||
|
||
export const todoToolSchema = {
|
||
name: 'todo' as const,
|
||
description:
|
||
'管理当前会话的任务列表。用于复杂任务或用户提供多个任务时。\n'
|
||
+ '不传参数=读取当前列表。传 todos 数组=写入。\n'
|
||
+ 'merge=false(默认)全量替换,merge=true 按 id 增量更新。\n'
|
||
+ '列表顺序=优先级。同一时间只能有一个 in_progress。',
|
||
parameters: TodoParams,
|
||
};
|
||
|
||
type TodoExecArgs = Static<typeof TodoParams>;
|
||
|
||
/** 执行 todo 工具 */
|
||
export function executeTodo(
|
||
store: TodoStore,
|
||
args: TodoExecArgs,
|
||
): { todos: any[]; summary: any } {
|
||
if (args.todos && args.todos.length > 0) {
|
||
store.write(args.todos, args.merge ?? false);
|
||
}
|
||
return {
|
||
todos: store.read(),
|
||
summary: store.getSummary(),
|
||
};
|
||
}
|
||
|
||
registerSchema({ name: 'todo', toolset: 'planning', description: todoToolSchema.description });
|
||
```
|
||
|
||
- [ ] **Step 4: 验证构建**
|
||
|
||
Run: `cd packages/backend && npm run build`
|
||
Expected: 编译成功
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/modules/netaclaw/runtime/prompt_guidance.ts \
|
||
packages/backend/src/modules/netaclaw/tools/todo_tool.ts
|
||
git commit -m "feat(netaclaw): rewrite tool prompts and migrate todo schema to TypeBox"
|
||
```
|
||
|
||
- [ ] **Step 6: 运行 simplify skill 审查本次变更**
|
||
|
||
---
|
||
|
||
### Task 6: 集成验证
|
||
|
||
**Files:** 无新文件,验证现有改动的集成正确性
|
||
|
||
- [ ] **Step 1: 完整构建验证**
|
||
|
||
Run: `cd packages/backend && npm run build`
|
||
Expected: 零错误,零警告
|
||
|
||
- [ ] **Step 2: 验证 catalog 工具名解析**
|
||
|
||
在构建产物中检查 `collectAvailableToolNames` 是否正确返回所有工具名。手动验证方式:
|
||
|
||
Run: `cd packages/backend && node -e "import('./dist/modules/netaclaw/tools/builtin/bash.js').then(() => import('./dist/modules/netaclaw/tools/builtin/file.js')).then(() => import('./dist/modules/netaclaw/tools/builtin/patch.js')).then(() => import('./dist/modules/netaclaw/tools/builtin/clarify.js')).then(() => import('./dist/modules/netaclaw/tools/builtin/memory.js')).then(() => import('./dist/modules/netaclaw/tools/todo_tool.js')).then(() => import('./dist/modules/netaclaw/runtime/prompt_builder.js')).then(m => console.log(m.collectAvailableToolNames({ memoryEnabled: true, hasSkills: true, crewRole: 'master' })))"`
|
||
|
||
Expected: `['bash', 'read_file', 'write_file', 'list_dir', 'patch', 'todo', 'clarify', 'memory_save', 'memory_recall', 'read_skill', 'read_skill_file', 'skill_manage', 'delegate_task', 'delegate_parallel', 'escalate']`
|
||
|
||
- [ ] **Step 3: 验证 prompt_guidance 输出**
|
||
|
||
Run: `cd packages/backend && node -e "import('./dist/modules/netaclaw/runtime/prompt_guidance.js').then(m => { console.log('=== ENFORCEMENT ==='); console.log(m.getToolUseEnforcement(['bash','read_file','write_file','list_dir','patch','todo','clarify']).slice(0, 300)); console.log('=== BEHAVIOR ==='); console.log(m.getToolBehaviorGuidance(['todo', 'patch', 'clarify']).slice(0, 300)); })"`
|
||
|
||
Expected: ENFORCEMENT 输出包含所有 7 个工具的场景描述;BEHAVIOR 输出包含 todo/patch/clarify 策略
|
||
|
||
- [ ] **Step 4: 最终 Commit(如有修复)**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix(netaclaw): integration fixes for tool catalog and prompts"
|
||
```
|