Files
agcore/docs/5-tool-system.md
T
徐涛 e598f6d3ee docs(5-tool-system): 更新工具系统方案文档,完善 Phase 2 实现细节
- 在 BaseTool 中添加 ToolContext 执行上下文参数,包含 session_id、trace_id 和取消令牌
- 更新 LlmCycle 工具循环逻辑:修正消息推送顺序,新增 max_tool_turns 独立字段
- 补充 McpClient 子进程运行时状态 ChildProcessState 设计
- 添加消息压缩 maybe_compact() 方法描述
- 明确 submit_stream_with_tools() 推迟至 Phase 3 实现
- 更新实现计划中各步骤的详细变更
2026-06-07 10:23:36 +08:00

997 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2: Tool System — 方案设计
> 定稿日期:2026-06-03
## 背景与目标
AG Core Phase 0Foundation)已完成 LLM 调用周期基础设施,Phase 1Prompt Engineering)已完成提示词组合与模板化。Phase 2 的目标是补齐**工具系统**能力,实现 LLM 驱动的工具定义、注册、调用、权限控制,以及 MCP 协议集成。
**核心目标**:让 LLM 能通过 `FinishReason::ToolCalls` 触发工具自动执行,并将结果回传至对话上下文,形成完整的"思考 → 调用 → 反馈"闭环。
---
## 需求分析
### 功能需求
| 模块 | 需求 | 验收条件 |
|------|------|---------|
| `BaseTool` trait | 工具抽象接口:名称、描述、参数、执行、权限声明 | 实现 trait 后可注册到 Registry |
| `ToolRegistry` | 工具注册、发现、按名称调用 | 注册 3 个工具后能按名称查找到并执行 |
| `McpClient` | MCP 协议客户端(stdio transport | 能启动 MCP 服务器子进程、列出工具、调用工具 |
| `PermissionChecker` | 工具执行前权限校验 | 禁止无权限的工具执行,返回结构化错误 |
| 自动 Tool 循环 | LlmCycle 收到 ToolCalls 后自动执行工具并回传 | 一个包含工具调用的对话能完整执行 2+ 轮 |
| 流式 Tool 事件 | 流式模式下发射 `ToolExecutionCompleted` 事件 | 流式调用中工具执行完成后触发对应事件 |
| 工具调用历史持久化 | 自动工具循环产生的 Tool/Assistant 消息正确追加到 `messages` | 查看 `cycle.messages()` 能获取完整工具交互轨迹 |
### 非功能需求
- 所有公开 API 必须带 `///` 文档注释
- 无新增 `unwrap()` 调用
- `BaseTool``execute()` 必须为 `async`
- 工具执行错误必须结构化为 `ToolError`,不允许 panic
- MCP 客户端超时默认 30 秒,可配置
- 自定义工具与 MCP 工具通过同一 `ToolRegistry` 管理,对 LlmCycle 透明
- 权限检查在工具执行之前,阻断后返回错误而非静默跳过
- `BaseTool::execute()` 签名必须预留扩展点(`ToolContext` 注入),确保未来 Skill/Agent 层可在不修改 trait 签名的情况下注入 session_id、cancellation_token 等上下文信息
- 自动 tool 循环应考虑 token 消耗——工具定义随每轮请求重复发送,工具结果直接追加到对话历史,需提供结果大小限制和截断策略
---
## 方案设计
### 模块结构
```
src/
tools.rs # tools 模块根:声明子模块 + 重导出公共 API
tools/
base.rs # BaseTool trait — 工具抽象接口
registry.rs # ToolRegistry — 工具注册表
permission.rs # PermissionChecker — 权限校验器
mcp.rs # McpClient — MCP 协议客户端
error.rs # ToolError — 工具系统错误类型
```
`tools.rs` 根模块声明:
```rust
// tools.rs
pub mod base;
pub mod error;
pub mod mcp;
pub mod permission;
pub mod registry;
pub use base::{BaseTool, ToolContext};
pub use error::ToolError;
pub use mcp::McpClient;
pub use permission::{Permission, PermissionChecker, PermissionConfig};
pub use registry::{ToolEntry, ToolInvocation, ToolRegistry};
```
`lib.rs` 添加:
```diff
pub mod llm;
pub mod prompt;
+pub mod tools;
```
### 1. BaseTool trait — 工具抽象接口
```rust
// tools/base.rs
use async_trait::async_trait;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::tools::error::ToolError;
use crate::tools::permission::Permission;
/// 工具执行上下文 —— 携带每次执行的运行时信息。
/// 新增字段时提供默认值,不破坏已有工具实现。
pub struct ToolContext<'a> {
/// 当前对话/会话 ID,用于关联性追踪。
pub session_id: &'a str,
/// 链路追踪 ID,用于跨工具调用的耗时分布。
pub trace_id: &'a str,
/// 取消令牌,用于优雅取消正在执行的工具。
pub cancellation_token: CancellationToken,
}
/// 工具抽象接口 —— 所有工具(自定义或 MCP)最终都实现此 trait。
#[async_trait]
pub trait BaseTool: Send + Sync {
/// 工具名称(唯一标识,用于 LLM 的 tool_calls.name 匹配)。
fn name(&self) -> &str;
/// 工具描述(LLM 据此决定是否调用此工具)。
fn description(&self) -> &str;
/// 工具参数定义(JSON Schema 格式,传递给 LLM 的 tool.parameters)。
fn parameters(&self) -> Value;
/// 声明工具所需的权限列表。
fn required_permissions(&self) -> Vec<Permission> {
Vec::new()
}
/// 执行工具调用。
/// `ctx` 携带执行上下文(session_id、trace_id 等),Phase 3/4 可扩展字段而不破坏 trait 签名。
async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<Value, ToolError>;
}
```
**设计说明**
- `name()` 返回 `&str` 而非 `String`,避免每次调用克隆
- `parameters()` 返回 `serde_json::Value`,与现有 `OpenaiToolDefinition.parameters` 类型一致
- `required_permissions()` 提供默认空实现,简化无敏感操作的工具定义
- `execute()` 接收 `Value`JSON 对象)+ `ToolContext` 作为参数,返回 `Value` 作为结果,与 OpenAI API 的 arguments/output 格式一致
- `ToolContext` 从 Phase 2 即注入 `execute()` 签名,防止后续 breaking change;新增字段用 `Option` 包裹或提供默认值
### 2. ToolRegistry — 工具注册表
```rust
// tools/registry.rs
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use crate::llm::types::{OpenaiToolDefinition, ToolDefinition};
use crate::tools::base::BaseTool;
use crate::tools::error::ToolError;
use crate::tools::permission::{Permission, PermissionChecker};
/// 工具调用记录 —— 用于追踪和调试。
pub struct ToolInvocation {
pub tool_name: String,
pub input: Value,
pub output: Result<Value, ToolError>,
}
/// 工具注册表 —— 管理工具注册、发现、调用。
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn BaseTool>>,
permission_checker: Option<Arc<PermissionChecker>>,
}
impl ToolRegistry {
pub fn new() -> Self;
/// 设置权限检查器(可选,不设置则不检查权限)。
pub fn with_permission_checker(mut self, checker: PermissionChecker) -> Self;
/// 注册一个工具。
pub fn register(&mut self, tool: Arc<dyn BaseTool>) -> Result<(), ToolError>;
/// 批量注册工具。
pub fn register_all(&mut self, tools: Vec<Arc<dyn BaseTool>>) -> Result<(), ToolError>;
/// 注销一个工具。
pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn BaseTool>>;
/// 按名称查找工具。
pub fn get(&self, name: &str) -> Option<Arc<dyn BaseTool>>;
/// 获取所有已注册工具的名称列表。
pub fn list_tools(&self) -> Vec<String>;
/// 获取所有工具的 ToolDefinition 列表(用于传递给 LLM)。
pub fn definitions(&self) -> Vec<ToolDefinition>;
/// 调用一个工具(含权限检查)。
pub async fn invoke(&self, name: &str, args: Value) -> Result<ToolInvocation, ToolError>;
/// 批量执行工具调用(并行执行互不依赖的工具)。
pub async fn invoke_all(
&self,
calls: Vec<(String, Value)>,
) -> Vec<ToolInvocation>;
}
```
**核心逻辑**
- `invoke()`:查找工具 → 权限检查 → 执行 → 返回 `ToolInvocation`
- `invoke_all()`:对多个工具调用并行执行(使用 `tokio::join!``futures::join_all`),适用于 LLM 同时发出多个 tool_calls 的场景
- `invoke_all()` 应对每个工具执行添加超时控制(通过 `tokio::time::timeout`),超时时间由 `CycleConfig::tool_timeout_secs` 配置,默认 60 秒,防止单个工具长时间阻塞整个循环
- `definitions()`:将注册的工具批量转换为 `Vec<ToolDefinition>`,供 LlmCycle 传递 LLM
- `ToolRegistry` 不持有 `PermissionChecker` 的生命周期(使用 `Arc`),允许多个 Registry 共享同一个 Checker
**使用示例**
```rust
let mut registry = ToolRegistry::new()
.with_permission_checker(checker);
registry.register(Arc::new(WeatherTool))?;
registry.register(Arc::new(FileReadTool))?;
// 获取 ToolDefinitions 传递给 LLM
let tools = registry.definitions();
// 收到 LLM 的 tool_calls 后执行
let calls = vec![
("get_weather".into(), json!({"city": "Beijing"})),
("read_file".into(), json!({"path": "/tmp/data.txt"})),
];
let results = registry.invoke_all(calls).await;
```
### 3. PermissionChecker — 权限校验器
```rust
// tools/permission.rs
/// 权限级别枚举。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Permission {
/// 只读(读取文件、查询数据库等)。
Read,
/// 写入(创建/修改文件、插入数据等)。
Write,
/// 删除(删除文件、记录等)。
Delete,
/// 网络访问(HTTP 请求等)。
Network,
/// Shell 命令执行。
Shell,
/// 文件系统操作(除读/写/删之外的 FS 操作)。
FileSystem,
/// 自定义权限(可通过 namespaced 字符串扩展)。
Custom(String),
}
/// 权限配置。
pub struct PermissionConfig {
/// 允许的权限列表(空 = 全部允许)。
pub allowed: Vec<Permission>,
/// 拒绝的权限列表(优先级高于 allowed)。
pub denied: Vec<Permission>,
/// 是否允许未声明权限的工具执行(默认为 true)。
pub allow_unspecified: bool,
}
impl Default for PermissionConfig {
fn default() -> Self {
Self {
allowed: vec![Permission::Read, Permission::Network],
denied: vec![Permission::Delete, Permission::Shell],
allow_unspecified: true,
}
}
}
/// 权限检查器。
pub struct PermissionChecker {
config: PermissionConfig,
}
impl PermissionChecker {
pub fn new(config: PermissionConfig) -> Self;
/// 检查指定权限是否允许执行。
pub fn check(&self, tool_name: &str, permissions: &[Permission]) -> Result<(), ToolError>;
}
```
**权限判定规则**
1. 如果权限在 `denied` 中 → 拒绝
2. 如果权限在 `allowed` 中 → 允许
3. 如果 `allowed` 非空且权限不在其中 → 拒绝(白名单模式)
4. 如果 `allowed` 为空 → 按 `allow_unspecified` 判定
### 4. McpClient — MCP 协议客户端
MCPModel Context Protocol)是一种基于 JSON-RPC 的协议,用于 LLM 与外部工具系统通信。Phase 2 实现其 **最小可行子集**,优先实现 stdio transport。
> **传输方式说明**MCP 协议版本 2025-03-26 定义了两种标准传输——`stdio` 和 `Streamable HTTP`。原有的 `HTTP+SSE` 传输(2024-11-05)已被官方废弃,新实现不应采用。`Streamable HTTP` 通过单一 HTTP 端点同时支持 JSON 响应和 SSE 流式升级,是 HTTP 场景的推荐方案。
```rust
// tools/mcp.rs
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{ChildStdin, ChildStdout, Command as TokioCommand};
use tokio::sync::Mutex;
use crate::tools::base::BaseTool;
use crate::tools::error::ToolError;
/// MCP 协议版本。
const MCP_VERSION: &str = "2025-03-26";
/// MCP 传输方式。
pub enum McpTransport {
/// 通过子进程 stdin/stdout 通信。
Stdio {
command: String,
args: Vec<String>,
},
/// Streamable HTTP 传输(MCP 2025-03-26 引入,替代已废弃的 HTTP+SSE)。
/// 客户端通过单一 HTTP 端点与 MCP Server 通信,支持 JSON 和 SSE 流式响应。
StreamableHttp {
url: String,
headers: Option<Vec<(String, String)>>,
},
}
/// MCP 子进程运行时状态(connect() 后创建)。
struct ChildProcessState {
child: tokio::process::Child,
stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
/// 等待响应的请求映射(id → oneshot sender)。
pending: HashMap<u64, tokio::sync::oneshot::Sender<Result<Value, ToolError>>>,
next_id: u64,
}
/// MCP 客户端 —— 与 MCP 服务器通信。
pub struct McpClient {
transport: McpTransport,
server_name: String,
/// 已初始化的工具列表(缓存)。
tools: Vec<McpTool>,
/// 是否已初始化。
initialized: AtomicBool,
/// 超时时间(秒)。
timeout_secs: u64,
/// 子进程运行时状态(connect() 后创建,close() 后取回)。
process: Option<tokio::sync::Mutex<ChildProcessState>>,
}
/// MCP 服务器暴露的工具(缓存结构)。
struct McpTool {
name: String,
description: Option<String>,
input_schema: Value,
}
impl McpClient {
/// 创建一个 MCP 客户端。
pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self;
/// 设置超时时间。
pub fn with_timeout(mut self, secs: u64) -> Self;
/// 连接并初始化(发送 initialize 请求,获取服务器能力声明)。
/// 启动子进程,创建 ChildProcessState(含 reader task)。
pub async fn connect(&mut self) -> Result<(), ToolError>;
/// 列出服务器支持的工具(调用 tools/list)。
pub async fn list_tools(&mut self) -> Result<Vec<ToolDefinition>, ToolError>;
/// 调用一个工具(调用 tools/call)。
/// 通过 Mutex 获取 stdin 写入权限,发送 JSON-RPC 请求,通过 id 匹配响应。
/// reader task 持续读取 stdout,解析 JSON-RPC 响应,通过 oneshot 通知调用方。
pub async fn call_tool(&self, name: &str, args: Value) -> Result<Value, ToolError>;
/// 关闭连接(终止子进程)。
/// 发送 shutdown → 等待 5s 优雅退出 → 超时则 child.kill()。
pub async fn close(&mut self) -> Result<(), ToolError>;
/// 将 MCP 客户端转换为 BaseTool 适配器列表(用于注册到 ToolRegistry)。
pub fn into_tools(self) -> Vec<Arc<dyn BaseTool>>;
}
```
**MCP 协议交互流程**
```
客户端 MCP 服务器
│ │
├── initialize request ──────► │
│ { protocolVersion, capabilities }
│◄──── initialize response ── │
│ { protocolVersion, serverInfo, capabilities }
│ │
├── initialized notification ──► │
│ │
├── tools/list ──────────────► │
│◄── tools/list result ──────── │
│ { tools: [{ name, description, inputSchema }] }
│ │
├── tools/call ─────────────► │
│ { name, arguments }
│◄── tools/call result ──────── │
│ { content: [{ type, text }] }
│ │
├── shutdown ───────────────► │
│◄── shutdown ───────────── │
```
**关于 stdio transport 实现**
- 使用 `tokio::process::Command` 启动子进程
- stdin 写入 JSON-RPC 请求(每行一个 JSON 对象)
- stdout 读取 JSON-RPC 响应(使用 `BufReader` 逐行读取)
- 每个请求关联一个 `id`(递增整数),通过 `id` 匹配请求和响应
- 进程退出时自动关闭
**关于 MCP Server 的管理**
- Phase 2 **不** 实现 MCP Server 框架,只实现 Client
- MCP Server 由外部提供(如 `npx @anthropic/mcp-server-filesystem`
- 用户需要提供 MCP Server 的启动命令和参数
**工具缓存说明**
- `McpClient``list_tools()` 时缓存工具列表,避免每次调用都重新请求
- 缓存假设:MCP Server 的工具列表在运行时不会频繁变更(如插件式加载场景除外)
- 如需刷新,可通过新增 `refresh_tools()` 方法或基于 TTL(如 60 秒)自动失效
### 5. ToolError — 错误类型
```rust
// tools/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ToolError {
#[error("工具 '{0}' 未注册")]
NotFound(String),
#[error("工具 '{0}' 执行失败: {1}")]
ExecutionFailed(String, String),
#[error("工具 '{0}' 参数无效: {1}")]
InvalidArguments(String, String),
#[error("权限被拒绝: 工具 '{0}' 需要 {1:?} 权限")]
PermissionDenied(String, String),
#[error("MCP 协议错误: {0}")]
McpError(String),
#[error("MCP 未初始化: {0}")]
McpNotInitialized(String),
#[error("MCP 超时: {0}")]
McpTimeout(String),
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
#[error("其他错误: {0}")]
Other(String),
}
```
### 6. LlmCycle 扩展 — 自动 Tool 循环
**核心设计**:在 `LlmCycle` 中新增 `submit_with_tools()` 方法,自动处理 tool 执行循环。
```rust
// llm/cycle.rs 新增
use crate::tools::registry::ToolRegistry;
use crate::tools::error::ToolError;
impl LlmCycle {
/// 提交消息并自动处理工具调用循环。
///
/// 流程:
/// 1. 发送请求(含工具定义)
/// 2. 检查响应中的 finish_reason
/// 3. 如果是 ToolCalls → 先 push Assistant 消息 → 执行工具 → 回传结果 → 重复 1
/// 4. 如果是 Stop/Length → push Assistant 消息 → 返回最终响应
///
/// 注意:OpenAI API 要求 tool 消息必须紧跟在对应的 Assistanttool_calls)消息之后。
/// 因此 push 工具结果前必须先 push Assistant 响应,否则 API 拒绝请求。
pub async fn submit_with_tools(
&mut self,
prompt: String,
registry: &ToolRegistry,
) -> Result<ChatResponse, LlmError> {
let tools = registry.definitions();
let max_turns = self.config.max_tool_turns.unwrap_or(10);
let mut turn = 0;
self.messages.push(OpenaiChatMessage::user_text(prompt));
self.maybe_compact();
loop {
turn += 1;
if turn > max_turns {
return Err(LlmError::Other(format!(
"达到最大工具循环轮次 ({})",
max_turns
)));
}
// 发送请求
let response = self.submit_request(&tools).await?;
// 检查是否需要执行工具
let should_execute = matches!(
response.stop_reason,
Some(FinishReason::ToolCalls)
) && has_tool_calls(&response.message);
// 将 Assistant 响应(含 tool_calls 或最终文本)追加到消息历史
self.messages.push(response.message.clone());
if !should_execute {
return Ok(response);
}
// 解析 tool_calls 并执行
let tool_calls = extract_tool_calls(&response.message);
let results = registry.invoke_all(tool_calls).await;
// 回传工具结果
for result in results {
let content = match &result.output {
Ok(value) => serde_json::to_string(value)
.unwrap_or_else(|e| {
tracing::warn!("工具结果序列化失败: {}", e);
"{}".to_string()
}),
Err(e) => format!("错误: {}", e),
};
self.messages.push(
OpenaiChatMessage::tool_result(result.tool_name.clone(), content)
);
}
// 每轮工具执行后触发 compaction,防止 token 快速膨胀
self.maybe_compact();
}
}
/// 在接近上下文窗口时压缩历史消息。
fn maybe_compact(&mut self) {
if let Some(ref config) = self.compact_config
&& should_compact(&self.messages, config, &self.compact_state)
{
let freed = microcompact(&mut self.messages, config.keep_recent);
if freed > 0 {
self.compact_state.record_success();
}
}
}
/// 内部请求方法(与 submit 共享重试逻辑,但不 push user message)。
async fn submit_request(
&mut self,
tools: &[ToolDefinition],
) -> Result<ChatResponse, LlmError> {
// ... 提取 submit() 中的 request → response 逻辑(不含 user prompt push
}
}
```
**关键设计决策**
| 决策 | 选择 | 理由 |
|------|------|------|
| 循环方式 | 同步循环(单线程串行) | 工具执行依赖前一轮结果,串行更安全 |
| 最大轮次 | `CycleConfig.max_tool_turns`,独立于 `max_turns`,默认 `Some(10)` | 防止无限循环(LLM 反复调用工具)。使用独立字段避免影响现有 `submit()`/`submit_messages()``max_turns` 语义 |
| 工具并行 | `invoke_all()` 互不依赖的工具并行 | LLM 可能一次发出多个 tool_callsparallel_tool_calls |
| 工具超时 | `CycleConfig::tool_timeout_secs`,默认 60 | 防止单个工具长时间阻塞循环。`invoke_all()` 使用 `tokio::time::timeout` 包装 |
| 错误处理 | 工具执行错误以文本回传 LLM,而非终止循环 | LLM 可自行从错误中恢复 |
| 消息追踪 | 所有工具交互通过 `self.messages` 持久化 | 调用方能通过 `cycle.messages()` 查看完整轨迹 |
**Token 消耗分析**
自动 tool 循环的 token 消耗主要来自三个来源:
| 来源 | 说明 | 影响程度 |
|------|------|---------|
| 工具定义重复发送 | `definitions()` 在每轮请求中携带全部工具的 JSON Schema | 注册工具数 × 平均定义大小 × 轮数。20 个工具 × 500B × 5 轮 ≈ 50KB 输入 token |
| 工具结果追加历史 | 每次工具执行结果完整追加到 `messages`,后续请求重发全部历史 | 最显著的 token 泄漏源。大结果(如向量搜索 Top-50)单次可能 ~15KB,多轮累加 |
| Value→String 序列化 | 工具结果 `serde_json::to_string()` 后 JSON 字符串膨胀 ~20-30% | 线性的常量损耗 |
**影响估算**
| 场景 | 工具相关 token 占比 | 说明 |
|------|-------------------|------|
| 单次简单查询 | <5% | 可忽略 |
| 文件读取+分析(3-4 轮) | ~30% | 工具结果逐步累积 |
| 网页搜索+总结(3-5 轮) | ~40% | 工具结果包含页面内容 |
| 多工具数据 pipeline5-10 轮) | ~60%+ | 需关注压缩和限制策略 |
**缓解方向**(Phase 2 不强制实现,但设计需可扩展):
- **结果大小限制**:工具执行结果超过阈值时自动截断(如 `CycleConfig::max_tool_result_bytes`
- **自动压缩**:现有的 Auto-compaction 需感知工具消息,避免压缩掉 LLM 后续依赖的数据
- **工具定义缓存**:基础工具定义变化极少,未来可考虑客户端侧缓存(需等 provider 支持)
**错误分类与处理策略**
工具执行错误需要区分"可恢复"和"不可恢复"两类,不可恢复的错误应终止循环而非回传 LLM:
| 错误类型 | 处理策略 | 理由 |
|---------|---------|------|
| `ToolError::ExecutionFailed` | 回传 LLM(文本) | LLM 可能下次换参数或换方式重试 |
| `ToolError::InvalidArguments` | 回传 LLM(文本) | LLM 可自动修正参数 |
| `ToolError::NotFound` | 终止循环,返回 `LlmError` | LLM 无法注册工具,重试无意义 |
| `ToolError::PermissionDenied` | 终止循环,返回 `LlmError` | 安全敏感,不应允许重试 |
| `ToolError::McpError` | 终止循环,返回 `LlmError` | MCP 链路故障,重试大概率失败 |
| `ToolError::McpTimeout` | 终止循环,返回 `LlmError` | 或可考虑重试 1 次后终止 |
| `ToolError::Io` | 终止循环,返回 `LlmError` | IO 错误通常是环境问题 |
| `ToolError::Other` | 回传 LLM(文本) | 兜底,保守回传 |
实现上可在 `ToolError` 上添加 `is_recoverable()` 方法,或在 `submit_with_tools()` 中通过 `match` 分支判断。
**submit_request() 重构说明**
提取 `submit_request()` 作为 `submit_with_tools()` 的内部方法时,需确保不影响现有方法的行为。重构后的方法职责矩阵:
| 方法 | Push user msg | Compaction | Retry | Call provider | Handle response |
|------|:---:|:---:|:---:|:---:|:---:|
| `submit()` | ✅ | ✅ | ✅ | → `submit_request()` | ✅ |
| `submit_messages()` | ❌ | ✅ | ✅ | → `submit_request()` | ✅ |
| `submit_with_tools()` | ✅ | ✅ | ✅ | → `submit_request()` | ✅* |
| `submit_request()` | ❌ | ❌ | ✅ | ✅ | ✅ |
*`submit_with_tools()``submit_request()` 返回后额外检查 `ToolCalls`,执行工具后递归调用自身。
**流式模式支持**
`submit_stream()` 的增强方案:新增 `submit_stream_with_tools()`,在流式事件层面支持自动 tool 循环。
> **实现复杂度提示**:流式 tool 循环需要自定义 `Stream` 实现 + 内部状态机(`Streaming` → `ExecutingTools` → `Finished`)。每一轮需要:消费当前流 → 收集事件 → 检测 `TurnComplete(ToolCalls)` → 执行工具 → 发射 `ToolExecutionCompleted` → 发起新流 → 继续 yield。不能用简单的 `stream!` 宏实现。
>
> 建议 Phaes 3 再实现 `submit_stream_with_tools()`Phase 2 只实现非流式的 `submit_with_tools()`。如果 Phase 2 需要可先返回 "not yet implemented" 错误。
```rust
impl LlmCycle {
pub async fn submit_stream_with_tools(
&mut self,
prompt: String,
registry: &ToolRegistry,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
// 1. 使用 submit_stream() 获取初始事件流
// 2. 监听 TurnComplete { reason: ToolCalls }
// 3. 触发时:通过 ToolRegistry 执行工具
// 4. 发射 ToolExecutionCompleted 事件(由 submit_stream_with_tools 负责,非底层 stream parser
// 5. 将工具结果注入 messages
// 6. 自动发起下一轮请求(递归)
// 7. 直到 finish_reason 为 Stop
}
}
```
**事件发射时序**
```
submit_stream_with_tools("查天气")
├─ AssistantTextDelta "我来查一下北京的天气..." ← 底层 stream parser 发射
├─ ToolExecutionStarted { tool_name, input, id } ← submit_stream_with_tools 发射
├─ TurnComplete { reason: ToolCalls } ← 底层 stream parser 发射
├── [自动] 执行工具 get_weather({city:"北京"})
├─ ToolExecutionCompleted { tool_name, output, ... } ← submit_stream_with_tools 发射
├─ AssistantTextDelta "北京今天 22°C" ← 底层 stream parser 发射
├─ TurnComplete { reason: Stop } ← 底层 stream parser 发射
└─ (流结束)
**事件发射职责划分**:底层 `parse_chunk_stream()` 负责 LLM 原生事件(`AssistantTextDelta`、`TurnComplete`);`submit_stream_with_tools()` 负责工具层事件(`ToolExecutionStarted`、`ToolExecutionCompleted`),在工具执行前/后手动 `yield` 事件。
```
### 7. 自定义工具示例
```rust
use agcore::tools::{BaseTool, ToolError};
use async_trait::async_trait;
use serde_json::Value;
struct WeatherTool;
#[async_trait]
impl BaseTool for WeatherTool {
fn name(&self) -> &str { "get_weather" }
fn description(&self) -> &str { "获取指定城市的当前天气" }
fn parameters(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名称" }
},
"required": ["city"]
})
}
async fn execute(&self, args: Value) -> Result<Value, ToolError> {
let city = args["city"].as_str()
.ok_or_else(|| ToolError::InvalidArguments(
"get_weather".into(), "缺少 city 参数".into()
))?;
// 模拟天气查询
Ok(serde_json::json!({ "city": city, "temperature": 22, "unit": "°C" }))
}
}
```
### 8. 模块依赖关系
```
tools/ 模块内部依赖:
base.rs → 无内部依赖(Permission 枚举 + ToolError
permission → 无内部依赖
registry → base, error, permission
mcp → base, error(需通过 registry 注册)
error → 无内部依赖
跨模块依赖:
tools/ → llm/types (ToolDefinition 类型)
llm/cycle → tools/registry (自动 tool 循环)
llm/cycle → tools/error (ToolError 转换)
```
---
### 9. 未来工具化路线扩展性分析
> 本节回答"当前设计是否足以支撑未来常规工具、MCP、Skill、记忆等统一走工具调用路线"。
#### 设计目标
未来所有 Agent 可调用的能力(常规工具、MCP 工具、Skill、记忆操作)都应通过 `BaseTool` trait 统一暴露给 LLM`ToolRegistry` 作为唯一的工具发现和调用入口,对 `LlmCycle` 透明。
#### 各场景支持度评估
| 场景 | 当前支持度 | 关键瓶颈 |
|------|-----------|---------|
| 常规工具(天气/计算器) | ✅ 直接可行 | 无 |
| MCP 工具(McpClient→BaseTool 适配器) | ✅ 可行 | 适配器模式优雅,MCP 流式/进度能力被 `Value→Value` 约束 |
| Memory CRUDstore/recall/forget/update | ⚠️ 基本可行 | 检索分页、大量结果返回需额外处理 |
| 长时运行工具(数据集查询、文件上传) | ❌ 不可行 | 无进度汇报、无 cancellation 机制 |
| 多轮确认工具("是否冻结账户?"审批流程) | ❌ 不可行 | 单次调用→单次返回,无法表达"反问→确认"模式 |
| Skill 编排(多步骤组合、嵌套执行) | ❌ 不可行 | 无上下文传播(跨步骤传递中间结果)、无工具组合原语 |
| Agent 按场景筛选工具子集 | ⚠️ 部分可行 | 无 tag/category 筛选机制 |
#### 关键扩展点
**A. `BaseTool::execute()` 签名——预留 `ToolContext` 注入**
`BaseTool` 是公开 trait,一旦用户实现并发布 crate,后续 breaking change 成本极高。当前签名:
```rust
async fn execute(&self, args: Value) -> Result<Value, ToolError>;
```
未来扩展路径——新增 `ToolContext` 参数,携带执行上下文:
```rust
async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<Value, ToolError>;
```
`ToolContext` 初始应包含的字段(Phase 2 实现时不必全部实现,但签名需预留参数位置):
| 字段 | 用途 | 引入阶段 |
|------|------|---------|
| `session_id: &str` | 追踪一次对话中所有工具调用的关联性 | Phase 2 |
| `trace_id: &str` | 链路追踪,跨工具调用的耗时分布 | Phase 2 |
| `cancellation_token: CancellationToken` | 优雅取消正在执行的工具 | Phase 2 |
| `progress: Option<UnboundedSender<ProgressEvent>>` | 进度汇报(数据处理到 50% | Phase 3 |
| `shared_state: Option<&HashMap<String, Value>>` | Skill 跨步骤传递中间结果 | Phase 4 |
这样 Skill/Agent 层在 Phase 4 引入时,`execute` 签名不必改,只需在 `ToolContext` 中增加字段。
**B. `ToolRegistry` 内部结构——引入 `ToolEntry` 元数据**
当前内部是 `HashMap<String, Arc<dyn BaseTool>>`,未来扩展为:
```rust
pub struct ToolEntry {
pub tool: Arc<dyn BaseTool>,
pub tags: Vec<String>,
pub category: String, // "memory", "data", "communication" 等
pub version: Option<String>,
pub stats: ToolStats, // 调用次数、平均耗时
}
```
对应的筛选 API
```rust
pub fn find_by_tag(&self, tag: &str) -> Vec<&ToolEntry>;
pub fn find_by_category(&self, category: &str) -> Vec<&ToolEntry>;
pub fn groups(&self) -> HashMap<&str, Vec<&ToolEntry>>;
```
**C. 工具返回模式——从单一 `Value` 到 `ToolOutput` 枚举**
当前返回类型 `Result<Value, ToolError>` 只能表达"一次性完整返回"。未来根据需要引入多模式输出:
```rust
pub enum ToolOutput {
/// 一次性返回完整结果
Final(Value),
/// 通过 channel 逐步流式输出结果
Streamed { initial: Value, rx: Receiver<Value> },
/// 需要 LLM 进一步确认后再继续
AwaitingInput { context: Value, prompt: String },
}
```
| 返回模式 | 场景示例 |
|---------|---------|
| `Final(Value)` | 天气查询、文件读取 |
| `Streamed { initial, rx }` | 向量搜索 Top-100 逐批返回 |
| `AwaitingInput { context, prompt }` | "检测到可疑交易,是否冻结?" |
#### 各能力的引入时序
```
Phase 2(当前实现)
├─ BaseTool trait (Value→Value, 但签名预留 Context 参数位)
├─ ToolRegistry (HashMap<String, ToolEntry> + tag/category 筛选)
├─ PermissionChecker / McpClient / ToolError
├─ submit_with_tools() / submit_stream_with_tools()
└─ ToolContext { session_id, trace_id, cancellation_token }
Phase 3Memory 工具化)
├─ MemoryStore trait(扩展 BaseTool
├─ memory_store / memory_recall / memory_search 等作为工具注册
└─ ToolContext.progress 支持(分批返回检索结果)
Phase 4Agent + Skill + 编排)
├─ ToolContext.shared_state 支持(跨步骤传递中间结果)
├─ ToolOutput 枚举支持(如需要流式/确认模式)
├─ ToolChain / ToolSelector 工具组合原语
└─ Skill 机制(多步骤编排 + 内部状态)
```
#### 已识别但推迟的设计决策
| 决策 | 推迟原因 | 何时需要 |
|------|---------|---------|
| `ToolOutput` 枚举 | Phase 2 的所有场景(常规工具/MCP)用 `Value` 足够 | Phase 4 Agent 编排或长时工具 |
| 工具 DAG 调度 | Agent 场景后才需要复杂编排 | Phase 4 |
| Skill 机制 | 需要先有 Agent 使用工具的实践经验 | Phase 4 |
| 工具调用审计持久化 | 可先通过 Hook 点实现简单日志 | Phase 4 |
| 用户授权(运行时弹窗确认) | `PermissionChecker` 只做静态策略判定,不处理运行时交互。用户授权属于交互流程,应作为 `ToolOutput::AwaitingInput` 由上层 UI/Agent 层实现 | Phase 4 |
---
## 实现计划
### Step 1: 创建方案文档
创建 `docs/5-tool-system.md`(即本文档)。
### Step 2: ToolError
- 创建 `src/tools/error.rs`
- 定义 `ToolError` 枚举(NotFound / ExecutionFailed / InvalidArguments / PermissionDenied / McpError / McpTimeout / Io / Other
- 运行 `cargo check` 验证
### Step 3: Permission
- 创建 `src/tools/permission.rs`
- 定义 `Permission` 枚举 + `PermissionConfig` + `PermissionChecker`
- 编写权限判定逻辑(白名单/黑名单/未指定策略)
- 编写 5+ 边界测试覆盖:白名单模式、黑名单模式、空列表、自定义权限冲突
- 运行 `cargo test` 验证
### Step 4: BaseTool trait
- 创建 `src/tools/base.rs`
- 定义 `BaseTool` traitname / description / parameters / required_permissions / execute
- 定义 `ToolContext` 结构体(session_id / trace_id / cancellation_token),注入 `execute()` 作为第二个参数
- 创建 `src/tools.rs` 模块根,声明子模块,重导出公共 API
- `lib.rs` 添加 `pub mod tools;`
- 编写 1 个 MockTool 测试工具并验证 trait 实现
- 运行 `cargo check` 验证
### Step 5: ToolRegistry
- 创建 `src/tools/registry.rs`
- 定义 `ToolInvocation` 结构体 + `ToolEntry` 元数据包装(tool + tags + category + stats+ `ToolRegistry`
- 实现核心方法:register / get / list / definitions / invoke / invoke_all / find_by_tag / find_by_category
- `invoke_all()` 使用 `futures::future::join_all` + `tokio::time::timeout` 并行执行互不依赖的工具(每工具独立超时)
- `definitions()``HashMap` 中的工具转换为 `Vec<ToolDefinition>`
- `ToolRegistry` 不支持运行时并发注册(setup 阶段一次性构建),如需热注册由调用方通过 `Arc<RwLock<ToolRegistry>>` 包装
- 编写 8+ 测试覆盖:注册冲突、空注册表查找、单次调用、批量并行调用、工具执行失败
- 运行 `cargo test` 验证
### Step 6: LlmCycle 扩展(自动 Tool 循环)
- 新增 `cycle_submit.rs` 子模块(或直接在 `cycle.rs` 中扩增,取决于代码量)
- 提取 `submit_request()` 内部方法(将 submit() 中的 request→response 逻辑独立),同时重构 `submit_messages()` 以复用同一路径
- 实现 `submit_with_tools()` 方法:
- 循环:submit_request → push Assistant 消息 → 检查 finish_reason → 调用 registry.invoke_all → push tool_results → 重复
- 在 push tool_results **之前**先 push Assistanttool_calls)消息(OpenAI API 要求)
- `max_tool_turns` 控制(独立于 `max_turns`),达到上限返回错误
- 不可恢复的错误(NotFound、PermissionDenied、McpError)终止循环
- 可恢复的错误(ExecutionFailed、InvalidArguments)以文本回传 LLM
- 每轮执行后触发 `maybe_compact()` 防止 token 膨胀
- `submit_stream_with_tools()` 方法:
- Phase 2 标记为未实现(返回 `LlmError::Other("流式 tool 循环将在后续版本中支持")`
- 实际实现推迟到 Phase 3(需要自定义 `ToolStream` 状态机)
- 更新 `CycleConfig`
- 新增 `max_tool_turns: Option<u32>`,默认 `Some(10)`(不影响 `max_turns` 语义)
- 新增 `tool_timeout_secs: u64`,默认值 60
- 新增 `max_tool_result_bytes: Option<usize>`,默认 `Some(65536)`(限制单次工具结果大小)
- 编写 3+ 集成测试:单轮 tool 调用、多轮 tool 调用、达到 max_tool_turns 终止
- 运行 `cargo test` 验证
### Step 7: McpClientMCP 协议客户端)
- 创建 `src/tools/mcp.rs`
- 实现 JSON-RPC 消息结构(Request / Response / Error / Notification
- 定义 `ChildProcessState` 结构体,包含运行时字段:`child`/`stdin`/`pending: HashMap<u64, oneshot::Sender>`/`next_id: u64`
- reader task 使用 `tokio::select!` 同时监听 stdout 和 cancellation token
- `call_tool()` 通过 Mutex 获取 stdin 写入权限,通过 id 匹配响应
- 子进程意外退出时通知所有 pending 请求
- 实现 stdio transport
- `connect()`:启动子进程,创建 ChildProcessState,发送 initialize 请求
- `list_tools()`:调用 tools/list,缓存结果
- `call_tool()`:调用 tools/call,解析响应
- `close()`:发送 shutdown → 等待 5s 优雅退出 → 超时则 child.kill()
- `StreamableHttp` transport 预留枚举变体,当前返回 "not implemented" 错误,不在 Phase 2 实现
- 实现 `into_tools()`:将 MCP 工具转换为 `Vec<Arc<dyn BaseTool>>` 适配器
- 设置 30 秒默认超时
- 编写 MCP 协议消息序列化/反序列化测试 + 模拟子进程集成测试
- 运行 `cargo test` 验证
### Step 8: 收尾
- 更新 `docs/roadmap.md` 标记 Phase 2 完成
- `cargo clippy` — 无警告
- `cargo build` — 完整构建
- 检查所有新公开 API 有 `///` 文档注释
- `cargo test` — 所有测试通过
---
## 术语表
| 术语 | 说明 |
|------|------|
| `BaseTool` | 工具抽象接口,所有工具需实现此 trait |
| `ToolRegistry` | 工具注册表,管理工具注册、发现、调用 |
| `ToolInvocation` | 工具调用记录,包含输入、输出和执行结果 |
| `Permission` | 权限级别枚举(Read/Write/Delete/Network/Shell 等) |
| `PermissionChecker` | 权限校验器,执行前判定是否允许 |
| `McpClient` | MCP 协议客户端,通过 stdio 与 MCP Server 通信 |
| `ToolDefinition` | 传递给 LLM 的工具定义(同 `OpenaiToolDefinition` |
| 自动 Tool 循环 | LlmCycle 自动执行 LLM 请求的工具调用并回传结果 |
---
## 风险评估
| 风险 | 概率 | 缓解措施 |
|------|------|---------|
| MCP 协议规范变化 | 中 | 只实现最小子集(initialize/list_tools/call_tool),封装在 `mcp.rs` 中便于适配 |
| MCP 子进程异常退出 | 中 | 实现超时机制 + 错误恢复;进程退出时自动标记为不可用 |
| 工具执行死循环(LLM 反复调用工具) | 中 | `max_turns` 硬限制,达到上限后终止循环 |
| JSON-RPC 消息竞争(stdio 双工) | 中 | 请求和响应通过 `id` 字段匹配,使用 `Mutex` 保护写操作 + `HashMap<u64, OneshotSender>` 等待响应,实现复杂度高于接口示意 |
| 权限配置过于复杂 | 低 | PermissionConfig 提供合理默认值(允许 Read/Network,拒绝 Delete/Shell),简单场景无需自定义 |
| 工具调用参数类型不匹配 | 低 | `execute()` 接收 `Value`,由实现方自行校验;通过 `ToolError::InvalidArguments` 返回结构化错误 |
---
## 验收标准
1. `cargo check` 编译通过
2. `cargo clippy` 无警告
3. 模块文件路径正确:`src/tools.rs` + `src/tools/{base,registry,permission,mcp,error}.rs`
4. `BaseTool` trait 可被自定义工具实现,`name()` / `description()` / `parameters()` / `execute()` 四个方法正常工作
5. `ToolRegistry` 支持注册、查找、列出、注销操作
6. `ToolRegistry::definitions()` 返回正确的 `Vec<ToolDefinition>`
7. `ToolRegistry::invoke()` 执行工具前进行权限检查
8. `ToolRegistry::invoke_all()` 并行执行多个工具调用
9. `PermissionChecker` 根据配置正确判定权限(白名单/黑名单/默认策略)
10. `LlmCycle::submit_with_tools()` 收到 `FinishReason::ToolCalls` 后自动执行工具并回传结果
11. `LlmCycle::submit_with_tools()` 达到 `max_turns` 上限时终止并返回错误
12. `LlmCycle::submit_stream_with_tools()` 在流式模式下发射 `ToolExecutionCompleted` 事件
13. 自动 tool 循环产生的 Tool 消息正确追加到 `cycle.messages()`
14. `McpClient::connect()` 能完成 MCP 协议握手(initialize
15. `McpClient::list_tools()` 能获取 MCP Server 暴露的工具列表
16. `McpClient::call_tool()` 能调用 MCP Server 的工具
17. `McpClient::into_tools()` 能生成可供 `ToolRegistry` 注册的适配器
18. 所有新公开 API 有文档注释
19. 测试覆盖率:`cargo test` 全部通过
20. `BaseTool::execute()` 签名通过 `ToolContext` 参数预留了扩展点(session_id、cancellation_token),未来 Skill/Agent 层可在不修改 trait 签名的情况下注入上下文