feat(tools): 添加工具系统框架与 MCP 协议客户端
This commit is contained in:
+465
-1
@@ -19,7 +19,8 @@ use crate::llm::hooks::{HookContext, HookExecutor};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::stream::StreamEvent;
|
||||
use crate::llm::types::{
|
||||
ChatRequest, ChatResponse, OpenaiChatMessage, OpenaiTool, ToolChoice, ToolDefinition,
|
||||
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage, OpenaiTool, OpenaiToolCall,
|
||||
ToolChoice, ToolDefinition,
|
||||
};
|
||||
|
||||
/// LLM 调用周期配置。
|
||||
@@ -34,6 +35,15 @@ pub struct CycleConfig {
|
||||
pub max_turns: Option<u32>,
|
||||
/// 重试策略配置。
|
||||
pub retry: RetryConfig,
|
||||
/// 自动 tool 循环的最大轮次(独立于 `max_turns`,避免影响现有 `submit()` 语义)。
|
||||
/// 默认 `Some(10)`,防止 LLM 反复调用工具导致无限循环。
|
||||
pub max_tool_turns: Option<u32>,
|
||||
/// 单个工具执行的超时秒数(0 表示不超时)。
|
||||
/// 默认 60 秒。
|
||||
pub tool_timeout_secs: u64,
|
||||
/// 单个工具结果的最大字节数(超过此值将被截断)。
|
||||
/// 默认 65536(64KB),防止大结果导致 token 膨胀。
|
||||
pub max_tool_result_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for CycleConfig {
|
||||
@@ -44,6 +54,9 @@ impl Default for CycleConfig {
|
||||
temperature: None,
|
||||
max_turns: None,
|
||||
retry: RetryConfig::default(),
|
||||
max_tool_turns: Some(10),
|
||||
tool_timeout_secs: 60,
|
||||
max_tool_result_bytes: 65_536,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,4 +453,455 @@ impl LlmCycle {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部请求方法(与 `submit` 共享重试逻辑,但不 push user message 和 Assistant 响应)。
|
||||
///
|
||||
/// 用于 `submit_with_tools()` 的多轮 tool 循环。
|
||||
async fn submit_request(
|
||||
&mut self,
|
||||
tools: &[ToolDefinition],
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
let request = self.build_request(tools);
|
||||
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PreRequest)
|
||||
.with_request(&request);
|
||||
let results = executor
|
||||
.execute(crate::llm::hooks::HookEvent::PreRequest, &ctx)
|
||||
.await;
|
||||
if results.iter().any(|r| r.should_block) {
|
||||
let reason = results
|
||||
.iter()
|
||||
.find(|r| r.should_block)
|
||||
.and_then(|r| r.reason.clone())
|
||||
.unwrap_or_else(|| "Blocked by pre-request hook".to_string());
|
||||
return Err(LlmError::Other(reason));
|
||||
}
|
||||
}
|
||||
|
||||
match self.provider.chat(request).await {
|
||||
Ok(response) => {
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let post_request = self.build_request(tools);
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PostRequest)
|
||||
.with_request(&post_request);
|
||||
executor
|
||||
.execute(crate::llm::hooks::HookEvent::PostRequest, &ctx)
|
||||
.await;
|
||||
}
|
||||
self.usage.add(&response.usage);
|
||||
return Ok(response);
|
||||
}
|
||||
Err(e) if should_retry(&e) && attempts < self.config.retry.max_retries => {
|
||||
attempts += 1;
|
||||
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::OnRetry)
|
||||
.with_error(&e)
|
||||
.with_attempt(attempts);
|
||||
executor
|
||||
.execute(crate::llm::hooks::HookEvent::OnRetry, &ctx)
|
||||
.await;
|
||||
}
|
||||
|
||||
let delay = self.config.retry.compute_delay(attempts);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::OnError)
|
||||
.with_error(&e);
|
||||
executor
|
||||
.execute(crate::llm::hooks::HookEvent::OnError, &ctx)
|
||||
.await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交消息并自动处理工具调用循环。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 发送请求(含工具定义)
|
||||
/// 2. 检查响应中的 finish_reason
|
||||
/// 3. 如果是 ToolCalls → push Assistant 消息 → 执行工具 → 回传结果 → 重复 1
|
||||
/// 4. 如果是 Stop/Length → push Assistant 消息 → 返回最终响应
|
||||
///
|
||||
/// 注意:OpenAI API 要求 tool 消息必须紧跟在对应的 Assistant(tool_calls)消息之后。
|
||||
/// 因此 push 工具结果前必须先 push Assistant 响应,否则 API 拒绝请求。
|
||||
pub async fn submit_with_tools(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &crate::tools::ToolRegistry,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let tools = registry.definitions();
|
||||
let max_turns = self.config.max_tool_turns.unwrap_or(10);
|
||||
let tool_timeout = self.config.tool_timeout_secs;
|
||||
let max_bytes = self.config.max_tool_result_bytes;
|
||||
|
||||
self.messages.push(OpenaiChatMessage::user_text(prompt));
|
||||
self.maybe_compact();
|
||||
|
||||
let mut turn = 0;
|
||||
|
||||
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_in_message(&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_from_message(&response.message);
|
||||
let calls: Vec<(String, serde_json::Value)> = tool_calls
|
||||
.into_iter()
|
||||
.map(|(_id, name, args)| {
|
||||
let args: serde_json::Value =
|
||||
serde_json::from_str(&args).unwrap_or(serde_json::Value::Null);
|
||||
(name, args)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = registry.invoke_all(calls, tool_timeout).await;
|
||||
|
||||
// 回传工具结果
|
||||
for result in results {
|
||||
let content = match result.output {
|
||||
Ok(value) => {
|
||||
let serialized = serde_json::to_string(&value).unwrap_or_else(|e| {
|
||||
tracing::warn!("工具结果序列化失败: {}", e);
|
||||
"{}".to_string()
|
||||
});
|
||||
truncate_tool_result(&serialized, max_bytes)
|
||||
}
|
||||
Err(e) if e.is_recoverable() => format!("错误: {}", e),
|
||||
Err(e) => {
|
||||
// 不可恢复错误:终止循环
|
||||
return Err(LlmError::Other(format!(
|
||||
"工具 '{}' 不可恢复错误: {}",
|
||||
result.tool_name, e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
self.messages
|
||||
.push(OpenaiChatMessage::tool_result(result.tool_name, content));
|
||||
}
|
||||
|
||||
// 每轮工具执行后触发 compaction
|
||||
self.maybe_compact();
|
||||
}
|
||||
|
||||
// unreachable: loop returns
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
Err(LlmError::Other("unreachable".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 在接近上下文窗口时压缩历史消息。
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 Assistant 消息是否包含 tool_calls。
|
||||
fn has_tool_calls_in_message(msg: &OpenaiChatMessage) -> bool {
|
||||
matches!(
|
||||
msg,
|
||||
OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} if !calls.is_empty()
|
||||
)
|
||||
}
|
||||
|
||||
/// 提取 Assistant 消息中的 tool_calls。
|
||||
///
|
||||
/// 返回 `(tool_call_id, tool_name, arguments_json_string)` 列表。
|
||||
fn extract_tool_calls_from_message(
|
||||
msg: &OpenaiChatMessage,
|
||||
) -> Vec<(String, String, String)> {
|
||||
if let OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
calls
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
OpenaiToolCall::Function { id, function } => {
|
||||
(id.clone(), function.name.clone(), function.arguments.clone())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 截断工具结果到指定字节数。
|
||||
fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}\n\n[... truncated, original size: {} bytes ...]", &s[..end], s.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::{ContentField, OpenaiContentPart};
|
||||
use crate::tools::{BaseTool, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 模拟 Provider —— 预定义响应序列,按调用顺序返回。
|
||||
struct MockProvider {
|
||||
responses: std::sync::Mutex<Vec<ChatResponse>>,
|
||||
call_count: std::sync::Mutex<u32>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
Self {
|
||||
responses: std::sync::Mutex::new(responses),
|
||||
call_count: std::sync::Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
let mut count = self.call_count.lock().unwrap();
|
||||
*count += 1;
|
||||
let mut responses = self.responses.lock().unwrap();
|
||||
if responses.is_empty() {
|
||||
return Err(LlmError::Other("no more mock responses".into()));
|
||||
}
|
||||
Ok(responses.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_usage() -> crate::llm::types::Usage {
|
||||
crate::llm::types::Usage::default()
|
||||
}
|
||||
|
||||
fn assistant_text_response(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text(text),
|
||||
usage: empty_usage(),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_tool_call_response(
|
||||
calls: Vec<(&str, &str, &str)>,
|
||||
) -> ChatResponse {
|
||||
use crate::llm::types::{OpenaiToolCall, FunctionCall};
|
||||
let tool_calls: Vec<OpenaiToolCall> = calls
|
||||
.into_iter()
|
||||
.map(|(id, name, args)| OpenaiToolCall::Function {
|
||||
id: id.to_string(),
|
||||
function: FunctionCall {
|
||||
name: name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::Assistant {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: String::new(),
|
||||
}]),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
},
|
||||
usage: empty_usage(),
|
||||
stop_reason: Some(FinishReason::ToolCalls),
|
||||
}
|
||||
}
|
||||
|
||||
struct AddTool;
|
||||
|
||||
#[async_trait]
|
||||
impl BaseTool for AddTool {
|
||||
fn name(&self) -> &str {
|
||||
"add"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"加法"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
args: Value,
|
||||
_ctx: &crate::tools::ToolContext<'_>,
|
||||
) -> Result<Value, crate::tools::ToolError> {
|
||||
let a = args["a"].as_i64().unwrap_or(0);
|
||||
let b = args["b"].as_i64().unwrap_or(0);
|
||||
Ok(json!({"result": a + b}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_single_turn() {
|
||||
// 第一轮:返回 tool_call;第二轮:返回最终文本
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_text_response("答案是 3"),
|
||||
];
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut cycle = LlmCycle::new(provider, CycleConfig::default());
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
let response = cycle
|
||||
.submit_with_tools("1+2=?".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
// 验证最终响应是文本响应
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
|
||||
// 验证消息历史:user, assistant(tool_calls), tool, assistant(text)
|
||||
let messages = cycle.messages();
|
||||
assert_eq!(messages.len(), 4);
|
||||
assert!(matches!(messages[0], OpenaiChatMessage::User { .. }));
|
||||
assert!(matches!(messages[1], OpenaiChatMessage::Assistant { .. }));
|
||||
assert!(matches!(messages[2], OpenaiChatMessage::Tool { .. }));
|
||||
assert!(matches!(messages[3], OpenaiChatMessage::Assistant { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_multi_turn() {
|
||||
// 3 轮 tool 调用后给出最终答案
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_tool_call_response(vec![("call_2", "add", r#"{"a":3,"b":4}"#)]),
|
||||
assistant_tool_call_response(vec![("call_3", "add", r#"{"a":5,"b":6}"#)]),
|
||||
assistant_text_response("完成"),
|
||||
];
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut cycle = LlmCycle::new(provider, CycleConfig::default());
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
let response = cycle
|
||||
.submit_with_tools("计算总和".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
|
||||
// user + 3*(assistant + tool) + final assistant = 8
|
||||
let messages = cycle.messages();
|
||||
assert_eq!(messages.len(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_max_turns_exceeded() {
|
||||
// 配置 max_tool_turns = 2
|
||||
let mut config = CycleConfig::default();
|
||||
config.max_tool_turns = Some(2);
|
||||
// 4 轮 tool 调用 + 终止
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]),
|
||||
assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]),
|
||||
assistant_tool_call_response(vec![("c3", "add", r#"{"a":1,"b":1}"#)]),
|
||||
assistant_text_response("完成"),
|
||||
];
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut cycle = LlmCycle::new(provider, config);
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
let result = cycle
|
||||
.submit_with_tools("test".to_string(), ®istry)
|
||||
.await;
|
||||
assert!(matches!(result, Err(LlmError::Other(msg)) if msg.contains("达到最大工具循环轮次")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_no_tool_call_response() {
|
||||
// LLM 直接给出最终响应(不调用工具)
|
||||
let responses = vec![assistant_text_response("直接回答")];
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut cycle = LlmCycle::new(provider, CycleConfig::default());
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
let response = cycle
|
||||
.submit_with_tools("直接回答".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tool_result_short() {
|
||||
let s = "short text";
|
||||
assert_eq!(truncate_tool_result(s, 100), "short text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tool_result_long() {
|
||||
let s = "a".repeat(1000);
|
||||
let truncated = truncate_tool_result(&s, 50);
|
||||
assert!(truncated.len() < s.len());
|
||||
assert!(truncated.contains("[... truncated,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tool_result_chinese_chars() {
|
||||
let s = "中".repeat(100);
|
||||
let truncated = truncate_tool_result(&s, 50);
|
||||
// 不会在字符中间截断
|
||||
assert!(truncated.starts_with("中"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user