198 lines
7.5 KiB
Markdown
198 lines
7.5 KiB
Markdown
|
|
# 工具可见性分类机制设计
|
|||
|
|
|
|||
|
|
> 日期: 2026-04-14
|
|||
|
|
> 状态: 待实施
|
|||
|
|
|
|||
|
|
## 问题背景
|
|||
|
|
|
|||
|
|
当前 Agent 的 todo(任务规划)工具在执行时,每次更新任务状态都会触发 `tool_call` + `tool_result` 事件,前端以"工具调用成功"卡片的形式展示。而 `todo-card` 组件本身已经实时展示了任务的完成状态(划掉已完成项)。这导致:
|
|||
|
|
|
|||
|
|
1. **重复展示**: 4 个任务完成 → 4 次 todo-card 划线 + 4 个"工具完成"卡片
|
|||
|
|
2. **前端 hack**: 当前通过 `sp.name !== 'todo'` 过滤隐藏,治标不治本
|
|||
|
|
3. **skill 与工具混用同一组件**: skill 执行和普通工具调用都用 `skill-card.vue` 展示,缺乏区分
|
|||
|
|
|
|||
|
|
## 设计目标
|
|||
|
|
|
|||
|
|
1. 从架构层面区分"内部工具"和"用户可见工具",内部工具不产生对话流 UI 反馈
|
|||
|
|
2. skill 和普通工具调用使用不同的展示组件
|
|||
|
|
3. 机制通用,未来新增内部工具只需设置一个属性
|
|||
|
|
|
|||
|
|
## 方案:工具可见性分类(visibility 属性)
|
|||
|
|
|
|||
|
|
### 核心概念
|
|||
|
|
|
|||
|
|
在工具定义层引入 `visibility` 属性,从源头控制工具的 UI 可见性:
|
|||
|
|
|
|||
|
|
| visibility | 含义 | 前端行为 | 示例 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `internal` | 内部状态管理工具 | 不推送 `tool_call`/`tool_result` 事件,只走专属事件通道 | todo |
|
|||
|
|
| `tool` | 普通工具(默认值) | 推送 `tool_call`/`tool_result`,用 `tool-card` 展示 | 搜索、代码执行 |
|
|||
|
|
| `skill` | Skill 技能(预留值) | 当前 Skill 走独立通道(`skill_start`/`skill_end`),不使用此字段 | 预留 |
|
|||
|
|
|
|||
|
|
### 参考:Hermes Agent 的做法
|
|||
|
|
|
|||
|
|
Hermes 项目中 todo 工具是纯内部状态管理,不向外部推送事件。返回值仅作为 LLM 工具响应返回给模型,状态变化只存储在内存中的 TodoStore 实例。子代理的中间工具调用对父代理完全隐藏。
|
|||
|
|
|
|||
|
|
## 详细设计
|
|||
|
|
|
|||
|
|
### 1. 后端:工具类型定义扩展
|
|||
|
|
|
|||
|
|
**文件**: `packages/backend/src/modules/netaclaw/tools/common.ts`
|
|||
|
|
|
|||
|
|
在 `AgentToolWithMeta` 类型上新增 `visibility` 字段:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
export type ToolVisibility = 'internal' | 'tool' | 'skill';
|
|||
|
|
|
|||
|
|
export type AgentToolWithMeta<TParams extends TSchema, TResult> =
|
|||
|
|
AgentTool<TParams, TResult> & {
|
|||
|
|
ownerOnly?: boolean;
|
|||
|
|
displaySummary?: string;
|
|||
|
|
visibility?: ToolVisibility; // 新增:默认 'tool'
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 后端:执行层根据 visibility 过滤回调
|
|||
|
|
|
|||
|
|
**文件**: `packages/backend/src/modules/netaclaw/runtime/attempt.ts`
|
|||
|
|
|
|||
|
|
在工具执行循环中,根据工具的 `visibility` 决定是否触发 `onToolCall`/`onToolResult`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
for (const tc of response.toolCalls) {
|
|||
|
|
toolCallCount++;
|
|||
|
|
const tool = tools.find(t => t.name === tc.name);
|
|||
|
|
const visibility = tool?.visibility ?? 'tool';
|
|||
|
|
|
|||
|
|
// 仅非 internal 工具触发 UI 回调
|
|||
|
|
if (visibility !== 'internal') {
|
|||
|
|
onToolCall?.(tc.name, JSON.parse(tc.arguments));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ... 执行工具逻辑不变 ...
|
|||
|
|
|
|||
|
|
if (visibility !== 'internal') {
|
|||
|
|
onToolResult?.(tc.name, resultText);
|
|||
|
|
}
|
|||
|
|
conversation.push({ role: 'tool', content: resultText, toolCallId: tc.id });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
关键点:
|
|||
|
|
- `internal` 工具的执行结果仍然写入 `conversation`(LLM 需要看到工具返回值)
|
|||
|
|
- 只是不触发 `onToolCall`/`onToolResult` 回调(不推送到前端 WebSocket)
|
|||
|
|
- todo 的 `onTodoUpdate` 专属回调不受影响,todo-card 仍然实时更新
|
|||
|
|
|
|||
|
|
### 3. 后端:todo 工具标记为 internal
|
|||
|
|
|
|||
|
|
**文件**: `packages/backend/src/modules/netaclaw/runtime/agent.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
const todoTool: AnyAgentTool = {
|
|||
|
|
...todoToolSchema,
|
|||
|
|
label: '任务规划',
|
|||
|
|
visibility: 'internal', // 标记为内部工具
|
|||
|
|
execute: async (_id: string, args: any) => {
|
|||
|
|
const result = executeTodo(todoStore, args);
|
|||
|
|
params.onTodoUpdate?.(result);
|
|||
|
|
return JSON.stringify(result);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4. 前端:Store 层区分 skill 和 tool 来源
|
|||
|
|
|
|||
|
|
**文件**: `packages/frontend/src/modules/agent/store/chat.ts`
|
|||
|
|
|
|||
|
|
在 `SkillProgress` 类型中新增 `source` 字段:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
interface SkillProgress {
|
|||
|
|
name: string;
|
|||
|
|
label: string;
|
|||
|
|
status: 'running' | 'done' | 'error';
|
|||
|
|
/** 来源类型:skill=Skill技能, tool=普通工具调用。历史数据可能无此字段 */
|
|||
|
|
source?: 'skill' | 'tool'; // 新增:区分来源(可选,兼容历史数据)
|
|||
|
|
result?: any;
|
|||
|
|
tokens?: number;
|
|||
|
|
percent?: number;
|
|||
|
|
step?: string;
|
|||
|
|
detail?: string;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
事件处理调整:
|
|||
|
|
- `skill_start` 事件 → `source: 'skill'`
|
|||
|
|
- `tool_call` 事件 → `source: 'tool'`
|
|||
|
|
|
|||
|
|
移除 `visibleSkillProgress` 中的 `sp.name !== 'todo'` 过滤(后端已不再推送 todo 的 tool_call/tool_result)。
|
|||
|
|
|
|||
|
|
### 5. 前端:新建 tool-card.vue 组件
|
|||
|
|
|
|||
|
|
**文件**: `packages/frontend/src/modules/agent/components/tool-card.vue`(新建)
|
|||
|
|
|
|||
|
|
工具调用卡片,比 skill-card 更简洁:
|
|||
|
|
- 只显示工具名 + 状态图标(running 旋转 / done 勾 / error 叉)
|
|||
|
|
- 无进度条、无步骤描述
|
|||
|
|
- 可展开查看工具返回结果
|
|||
|
|
|
|||
|
|
### 6. 前端:chat.vue 根据 source 选择组件
|
|||
|
|
|
|||
|
|
**文件**: `packages/frontend/src/modules/agent/views/chat.vue`
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<div class="skill-card-row" v-for="sp in skillProgress" :key="sp.name">
|
|||
|
|
<skill-card v-if="sp.source === 'skill'" v-bind="sp" />
|
|||
|
|
<tool-card v-else v-bind="sp" />
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
移除 `visibleSkillProgress` 计算属性,直接使用 `skillProgress`。
|
|||
|
|
|
|||
|
|
## 改动文件清单
|
|||
|
|
|
|||
|
|
| 文件 | 改动类型 | 说明 |
|
|||
|
|
|------|---------|------|
|
|||
|
|
| `packages/backend/src/modules/netaclaw/tools/common.ts` | 修改 | 新增 `ToolVisibility` 类型和 `visibility` 字段 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/runtime/attempt.ts` | 修改 | 根据 visibility 过滤 onToolCall/onToolResult 回调 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/runtime/agent.ts` | 修改 | todo 工具设置 `visibility: 'internal'` |
|
|||
|
|
| `packages/frontend/src/modules/agent/store/chat.ts` | 修改 | SkillProgress 新增 source 字段,移除 todo 过滤 |
|
|||
|
|
| `packages/frontend/src/modules/agent/views/chat.vue` | 修改 | 移除 visibleSkillProgress hack,根据 source 选组件 |
|
|||
|
|
| `packages/frontend/src/modules/agent/components/tool-card.vue` | 新建 | 工具调用卡片组件 |
|
|||
|
|
|
|||
|
|
## 数据流对比
|
|||
|
|
|
|||
|
|
### 改动前(todo 工具)
|
|||
|
|
```
|
|||
|
|
Agent 调用 todo → executeTodo()
|
|||
|
|
├→ onToolCall('todo', args) → WS: tool_call → 前端 skillProgress (被 filter 隐藏)
|
|||
|
|
├→ onTodoUpdate(data) → WS: todo_update → 前端 todo-card ✓
|
|||
|
|
└→ onToolResult('todo', res) → WS: tool_result → 前端 skillProgress (被 filter 隐藏)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 改动后(todo 工具, visibility: 'internal')
|
|||
|
|
```
|
|||
|
|
Agent 调用 todo → executeTodo()
|
|||
|
|
├→ onToolCall 不触发 → 无 WS 事件
|
|||
|
|
├→ onTodoUpdate(data) → WS: todo_update → 前端 todo-card ✓
|
|||
|
|
└→ onToolResult 不触发 → 无 WS 事件
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 改动后(普通工具, visibility: 'tool')
|
|||
|
|
```
|
|||
|
|
Agent 调用搜索 → execute()
|
|||
|
|
├→ onToolCall('search', args) → WS: tool_call → 前端 tool-card (source: 'tool')
|
|||
|
|
└→ onToolResult('search', res) → WS: tool_result → 前端 tool-card ✓
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 改动后(Skill, visibility: 'skill')
|
|||
|
|
```
|
|||
|
|
Skill 执行
|
|||
|
|
├→ onSkillStart(name, label) → WS: skill_start → 前端 skill-card (source: 'skill')
|
|||
|
|
├→ onProgress(...) → WS: progress → 前端 skill-card 进度更新
|
|||
|
|
└→ onSkillEnd(name, status) → WS: skill_end → 前端 skill-card ✓
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 扩展性
|
|||
|
|
|
|||
|
|
未来如需新增内部工具(如 memory 记忆管理、context 上下文管理),只需在工具定义时设置 `visibility: 'internal'`,并为其建立专属事件通道和 UI 组件即可。无需修改执行引擎或前端过滤逻辑。
|