Files
agcore/src/llm/cycle.rs
T
徐涛 c36668071e fix(agent): Phase 9 实施审查修复
- cycle.rs: run_tool_loop 实现 PreRequest hook(之前 `let _ = hook_executor.as_ref()` 是空操作,
  导致 hook-based logging/monitoring 在流式工具循环中失效;现在与 submit_with_tools 行为对齐,
  含 should_block 检查,阻断时通过 StreamEvent::Error 事件化)
- session.rs: 删除 submit_turn_stream 末尾的 `let _ = hook_executor;` 死代码(Arc 引用生命周期
  由 Arc 自动管理)
- session.rs: 新增 2 个集成测试覆盖方案 §4 Step 5:
  - submit_turn_stream_end_to_end:mock provider → 消费流 → finalize_turn 后 cost_so_far
    正确更新(10/5 tokens)+ turn_index=1 + default slot 包含 user/assistant 消息
  - submit_turn_stream_triggers_turn_hooks:OnTurnStart 在 submit_turn_stream 返回流前
    触发(计数=1)+ OnTurnEnd 在 finalize_turn 前不触发(计数=0)+ finalize_turn 后触发(计数=1)
- docs/16-phase9-streaming-experience.md: 标注 finalize_turn Phase 10 签名变更(new_messages_from_cycle
  + Result 返回),Step 5 测试实现位置
- 测试 288 passed / 0 failed(基线 286 + 2 新增),clippy 0 警告,doc 0 warning
2026-07-08 23:19:24 +08:00

1716 lines
65 KiB
Rust
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.
//! LLM 调用周期控制模块。
mod retry;
pub mod usage;
pub use retry::RetryConfig;
pub use usage::{CostTracker, Usage};
use std::pin::Pin;
use std::sync::Arc;
use async_stream::stream;
use futures_core::stream::Stream;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact};
use crate::llm::cycle::retry::should_retry;
use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookEvent, HookExecutor};
use crate::llm::provider::LlmProvider;
use crate::llm::stream::StreamEvent;
use crate::llm::types::message::{ContentBlock, Message};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, PartialMessageResponse, StopReason};
use crate::llm::types::tool::ToolDef;
use crate::llm::types::ToolChoice;
use crate::tools::ToolRegistry;
/// LLM 调用周期配置。
#[derive(Debug, Clone)]
pub struct CycleConfig {
/// 模型名称。
pub model: String,
/// 最大输出 token 数。
pub max_tokens: Option<u32>,
/// 采样温度。
pub temperature: Option<f32>,
/// 最大对话轮次。
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,
/// 单个工具结果的最大字节数(超过此值将被截断)。
/// 默认 6553664KB),防止大结果导致 token 膨胀。
pub max_tool_result_bytes: usize,
}
impl Default for CycleConfig {
fn default() -> Self {
Self {
model: String::from("gpt-4o"),
max_tokens: None,
temperature: None,
max_turns: None,
retry: RetryConfig::default(),
max_tool_turns: Some(10),
tool_timeout_secs: 60,
max_tool_result_bytes: 65_536,
}
}
}
/// LLM 调用周期 —— 管理一次或多次 LLM 请求的生命周期。
///
/// Phase 2 修订:
/// - `messages` 字段从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`IR 类型)。
/// - 移除 `system_prompt` 字段(FIX-D)—— 调用方通过 `Message::system_text()` +
/// `with_messages()` 自行管理系统提示消息,避免重复维护两条路径。
/// - `with_system_prompt()` 方法标记 `#[deprecated]`,过渡期内仍可用。
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
/// 消息历史 —— 直接存储 IR `Message` 类型,build_request 不再做转换。
messages: Vec<Message>,
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
compact_state: CompactState,
}
#[allow(deprecated)]
impl LlmCycle {
/// 创建一个新的 LlmCycle(持有 `Box<dyn LlmProvider>` 的独占所有权)。
///
/// 内部将 Box 转为 `Arc<dyn LlmProvider>` 以便 `new_with_arc` 复用句柄。
/// 公共签名保持不变,向后兼容。
pub fn new(provider: Box<dyn LlmProvider>, config: CycleConfig) -> Self {
Self::new_with_arc(Arc::from(provider), config)
}
/// 创建一个新的 LlmCycle,共享传入的 `Arc<dyn LlmProvider>` 句柄。
///
/// **新增**Phase 4a 引入):用于 `AgentSession::submit_turn` 在多 session 间共享 provider。
pub fn new_with_arc(provider: Arc<dyn LlmProvider>, config: CycleConfig) -> Self {
Self {
provider,
config,
usage: CostTracker::default(),
messages: Vec::new(),
hook_executor: None,
compact_config: None,
compact_state: CompactState::new(),
}
}
/// 设置系统提示词(**已废弃** —— Phase 2 起使用 `Message::system_text()` + `with_messages()`)。
///
/// 当前实现为过渡期保留:在 messages 头部插入 `Message::System { content: [Text { text }] }`。
#[deprecated(
since = "0.2.0",
note = "请改用 Message::system_text() + with_messages()"
)]
pub fn with_system_prompt(mut self, prompt: String) -> Self {
self.messages.insert(
0,
Message::System {
content: vec![ContentBlock::Text { text: prompt }],
},
);
self
}
/// 设置钩子执行器。
pub fn with_hook_executor(mut self, executor: HookExecutor) -> Self {
self.hook_executor = Some(Arc::new(executor));
self
}
/// 设置上下文压缩配置。
pub fn with_compact_config(mut self, config: CompactConfig) -> Self {
self.compact_config = Some(config);
self
}
/// 获取用量追踪器引用。
pub fn usage(&self) -> &CostTracker {
&self.usage
}
/// 获取消息历史引用(Phase 2:返回 `&[Message]` IR 类型)。
pub fn messages(&self) -> &[Message] {
&self.messages
}
/// 清空消息历史。
pub fn clear_messages(&mut self) {
self.messages.clear();
}
/// 重置用量统计。
pub fn reset_usage(&mut self) {
self.usage.reset();
}
/// 直接设置消息历史(覆盖已有消息),支持 Builder 链式调用。
pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
self.messages = messages;
self
}
/// 追加消息到历史尾部。
pub fn extend_messages(&mut self, messages: Vec<Message>) {
self.messages.extend(messages);
}
/// 追加单条消息到历史尾部。
///
/// 公开给 `submit_stream()` 消费方在收到 `MessageComplete` 事件后调用。
pub fn push_message(&mut self, msg: Message) {
self.messages.push(msg);
}
/// 使用预构建消息提交(跳过自动 push user prompt)。
///
/// 与 `submit()` 不同,不自动添加 `user_text(prompt)`。
/// 调用方完全控制消息序列内容。
pub async fn submit_messages(
&mut self,
messages: Vec<Message>,
tools: Vec<ToolDef>,
) -> Result<MessageResponse, LlmError> {
let request = MessageRequest {
model: self.config.model.clone(),
messages,
tools,
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
..Default::default()
};
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 = MessageRequest::default();
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);
Ok(response)
}
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;
}
Err(e)
}
}
}
/// 提交用户消息并获取 LLM 响应。
pub async fn submit(
&mut self,
prompt: String,
tools: Vec<ToolDef>,
) -> Result<MessageResponse, LlmError> {
self.messages.push(Message::user_text(prompt));
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();
}
}
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;
}
// ponytail: Phase 2 直接存储 IR Message —— 不再转换。
self.messages.push(response.message.clone());
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);
}
}
}
}
/// 提交用户消息并返回语义事件流(Phase 2 / FIX-E 简化方案)。
///
/// 与 `submit` 不同,该方法返回流式事件而非完整响应。
/// 适用于需要实时处理 LLM 输出的场景。
///
/// **Phase 2 设计决策(FIX-E**
/// - `Item = StreamEvent`**不再是 `Result<StreamEvent, LlmError>`**
/// - 错误统一以 `StreamEvent::Error { message }` 形式在流中传出
/// - `self.messages` 不会在流结束后自动 push Assistant 响应 ——
/// **调用方** 在收到 `StreamEvent::MessageComplete` 后手动调用
/// `cycle.push_message(response.message.clone())` 完成消息历史追加
///
/// 调用模式(FIX-E 推荐):
/// ```ignore
/// use futures_util::StreamExt;
/// let mut stream = cycle.submit_stream(prompt, tools).await?;
/// let mut final_response: Option<MessageResponse> = None;
/// while let Some(event) = stream.next().await {
/// if let StreamEvent::MessageComplete { full_response } = &event {
/// final_response = Some(full_response.clone());
/// }
/// }
/// if let Some(resp) = final_response {
/// cycle.push_message(resp.message.clone());
/// }
/// ```
pub async fn submit_stream(
&mut self,
prompt: String,
tools: Vec<ToolDef>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
self.messages.push(Message::user_text(prompt));
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();
}
}
let request = self.build_request(&tools);
// PreRequest hook
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));
}
}
let ir_event_stream = self.provider.chat_stream(request).await?;
let hook_executor = self.hook_executor.clone();
// ponytail: Phase 2 简化方案(FIX-E)。流是延迟求值的,&mut self 无法进入闭包。
// 调用方在收到 MessageComplete 后手动调 push_message()。
Ok(Box::pin(stream! {
use futures_util::StreamExt;
let mut ir_event_stream = ir_event_stream;
while let Some(result) = ir_event_stream.next().await {
match result {
Ok(event) => {
let is_terminal =
matches!(event, StreamEvent::MessageComplete { .. } | StreamEvent::Error { .. });
yield event;
if is_terminal {
break;
}
}
Err(e) => {
// ponytail: 错误不再走 Err 分支,统一以 StreamEvent::Error 传出。
if let Some(ref executor) = hook_executor {
let ctx = crate::llm::hooks::HookContext::new(
crate::llm::hooks::HookEvent::OnError,
)
.with_error(&e);
executor
.execute(crate::llm::hooks::HookEvent::OnError, &ctx)
.await;
}
yield StreamEvent::Error { message: e.to_string() };
break;
}
}
}
// ponytail: post_request hook 收到的消息列表**不包含本次 Assistant 响应**
// (因为流是延迟求值,本轮响应还未到达)。调用方如需完整上下文,
// 应在收到 MessageComplete 后手动触发 hook。
if let Some(ref executor) = hook_executor {
let ctx = crate::llm::hooks::HookContext::new(
crate::llm::hooks::HookEvent::PostRequest,
);
executor
.execute(crate::llm::hooks::HookEvent::PostRequest, &ctx)
.await;
}
}))
}
fn build_request(&self, tools: &[ToolDef]) -> MessageRequest {
// ponytail: Phase 2 简化 —— 直接 clone self.messages,无任何转换 / system prompt 注入。
// 系统消息如需存在,由调用方通过 `with_messages()` 自行管理。
MessageRequest {
model: self.config.model.clone(),
messages: self.messages.clone(),
tools: tools.to_vec(),
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
..Default::default()
}
}
/// 内部请求方法(与 `submit` 共享重试逻辑,但不 push user message 和 Assistant 响应)。
///
/// 用于 `submit_with_tools()` 的多轮 tool 循环。
async fn submit_request(
&mut self,
tools: &[ToolDef],
) -> Result<MessageResponse, 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 消息必须紧跟在对应的 Assistanttool_calls)消息之后。
/// 因此 push 工具结果前必须先 push Assistant 响应,否则 API 拒绝请求。
pub async fn submit_with_tools(
&mut self,
prompt: String,
registry: &crate::tools::ToolRegistry,
) -> Result<MessageResponse, 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(Message::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, StopReason::ToolUse)
&& has_tool_calls_in_response(&response);
// ponytail: Phase 2 直接存储 IR Message —— 不再转换。
self.messages.push(response.message.clone());
if !should_execute {
return Ok(response);
}
// 解析 tool_calls 并执行
let tool_calls = extract_tool_calls_from_response(&response);
let calls: Vec<(String, String, serde_json::Value)> = tool_calls
.into_iter()
.map(|(id, name, args)| {
let value: serde_json::Value =
serde_json::from_str(&args).unwrap_or(serde_json::Value::Null);
(id, name, value)
})
.collect();
let results = registry.invoke_all(calls, tool_timeout).await;
// 回传工具结果
for result in results {
let is_error = result.output.is_err();
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
)));
}
};
// ponytail: 当前 self.messages 仍是 Vec<OpenaiChatMessage> (Phase 2 切换后
// 改为 Message::tool_result 并传入 result.tool_call_id)。当前实现已经使用
// 真实 tool_call_id 而非 tool_name 充当 —— 这条 FIX-A 修复与 Phase 2 消息切换
// 同步生效。
// ponytail: Phase 2 直接存储 Message::ToolResultis_error 由 ToolInvocation.output 推断。
self.messages
.push(Message::tool_result(result.tool_call_id, content, is_error));
}
// 每轮工具执行后触发 compaction
self.maybe_compact();
}
// unreachable: loop returns
#[allow(unreachable_code)]
{
Err(LlmError::Other("unreachable".into()))
}
}
/// 提交消息并自动处理工具调用循环,流式产出所有事件。
///
/// 与 `submit_with_tools` 的区别:
/// - LLM 响应是流式的(全程 `chat_stream` 而非 `chat`
/// - 工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
/// - 错误以 `StreamEvent::Error` 形式出现在流中,而非终止 `Result`
/// - 消费方需手动 `push_message()` 同步消息历史
///
/// **运行时要求**:内部使用 `tokio::spawn`,需要 tokio 多线程运行时。
/// `#[tokio::test]` 单线程运行时不支持 spawn,测试场景需用 `flavor = "multi_thread"` 或
/// 直接调用模块函数 `run_tool_loop`。
///
/// ponytail: 返回的流是 `Item = StreamEvent`(非 `Result`),所有错误事件化为 `StreamEvent::Error`。
pub async fn submit_with_tools_stream(
&mut self,
prompt: String,
tool_registry: Arc<ToolRegistry>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
self.messages.push(Message::user_text(prompt));
self.maybe_compact();
// 提取 self 字段所有 owned 数据 —— spawn 闭包不能捕获 &mut self。
let provider = Arc::clone(&self.provider);
let config = self.config.clone();
let hook_executor = self.hook_executor.clone();
let tools = tool_registry.definitions();
let messages = std::mem::take(&mut self.messages);
let tool_registry = tool_registry;
let (tx, rx) = mpsc::unbounded_channel::<StreamEvent>();
tokio::spawn(async move {
run_tool_loop(
messages,
provider,
config,
tool_registry,
tools,
tx,
hook_executor,
)
.await;
});
Ok(Box::pin(UnboundedReceiverStream::new(rx)))
}
/// 在接近上下文窗口时压缩历史消息。
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_response(response: &MessageResponse) -> bool {
match &response.message {
Message::Assistant { content } => content
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
_ => false,
}
}
/// 提取 Assistant 消息中的 tool_calls。
///
/// 返回 `(tool_call_id, tool_name, arguments_json_string)` 列表。
///
/// ponytail: 当前 Phase 0 实现,`arguments_json_string` 内含 JSON 序列化的 input。
/// 消费方在调用 `registry.invoke_all()` 时反序列化一次。该小段冗余序列化
/// 在 Phase 2 切换为 `Vec<Message>` 后可整体消除。
fn extract_tool_calls_from_response(response: &MessageResponse) -> Vec<(String, String, String)> {
let mut out = Vec::new();
if let Message::Assistant { content } = &response.message {
for block in content {
if let ContentBlock::ToolUse { id, name, input } = block {
let args = serde_json::to_string(input).unwrap_or_else(|_| "null".to_string());
out.push((id.clone(), name.clone(), args));
}
}
}
out
}
/// 截断工具结果到指定字节数。
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()
)
}
/// 运行流式工具循环的核心异步状态机(Phase 9)。
///
/// 由 `submit_with_tools_stream` 在 `tokio::spawn` 中调用。
///
/// 流程:
/// - loop: 构建请求 → `chat_stream` → 消费流(转 mpsc)→ finalize → 检测 tool_use
/// - 如果有 tool_use:插入 `ToolExecutionStarted` → 调 `invoke_all` → 插入 `ToolExecutionCompleted`
/// → push 工具结果 → 下一轮
/// - 否则 break(最终轮)
/// - 最大轮次超限:发 `StreamEvent::Error` + break
///
/// **所有错误事件化**:通过 `tx.send(Error{..})` 表达错误,最终 `return` 结束 task。
/// 不返回 `Result`,因为错误已通过事件流传递。
async fn run_tool_loop(
mut messages: Vec<Message>,
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
tool_registry: Arc<ToolRegistry>,
tools: Vec<ToolDef>,
tx: mpsc::UnboundedSender<StreamEvent>,
hook_executor: Option<Arc<HookExecutor>>,
) {
use futures_util::StreamExt;
let max_turns = config.max_tool_turns.unwrap_or(10);
let tool_timeout = config.tool_timeout_secs;
let max_bytes = config.max_tool_result_bytes;
let mut round = 0u32;
loop {
round += 1;
if round > max_turns {
// §3.6 错误表:最大轮次超限 → Error 事件 + 终止
let _ = tx.send(StreamEvent::Error {
message: "达到最大工具循环轮次".to_string(),
});
break;
}
// ① 构建请求
let request = MessageRequest {
model: config.model.clone(),
messages: messages.clone(),
tools: tools.clone(),
tool_choice: ToolChoice::Auto,
max_tokens: config.max_tokens,
temperature: config.temperature,
..Default::default()
};
// ② PreRequest hook —— 与 `submit_with_tools` / `submit_stream` 行为对齐:
// 触发 hook → 检查 should_block → 阻断则事件化 Error + return 结束 task。
// 阻断原因透传,让消费者看到完整的拒绝原因。
if let Some(ref executor) = hook_executor {
let ctx = HookContext::new(HookEvent::PreRequest).with_request(&request);
let results = executor.execute(HookEvent::PreRequest, &ctx).await;
if let Some(blocking) = results.iter().find(|r| r.should_block) {
let reason = blocking
.reason
.clone()
.unwrap_or_else(|| "Blocked by pre-request hook".to_string());
let _ = tx.send(StreamEvent::Error { message: reason });
return;
}
}
// ③ chat_stream —— 第一层错误
let mut stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
// ponytail: 不做 retry —— retry 重建 mpsc 通道复杂度与收益不匹配
let _ = tx.send(StreamEvent::Error {
message: e.to_string(),
});
return;
}
};
// ④ 消费 LLM 流
let mut partial = PartialMessageResponse::new();
loop {
match stream.next().await {
Some(Ok(event)) => {
partial.apply_to(&event);
let is_terminal = matches!(
event,
StreamEvent::MessageComplete { .. } | StreamEvent::Error { .. }
);
if tx.send(event).is_err() {
return; // 消费者已 drop rxtask 终止
}
if is_terminal {
break;
}
}
Some(Err(e)) => {
// ponytail: 流内 Err 后 partial 处于损坏状态,直接 return 结束 task
let _ = tx.send(StreamEvent::Error {
message: e.to_string(),
});
return;
}
None => break, // 流自然结束
}
}
// ⑤ finalize
// ponytail: 防御性检查 —— 若 Provider 在流中产出过 Ok(StreamEvent::Error)
// partial 已设为 is_errored=truefinalize() 会基于损坏状态生成不可信响应,
// 此时跳过 finalize 直接退出流,让消费者看到 Error 事件后的流自然结束。
if partial.is_errored {
return;
}
let response = match partial.finalize() {
Ok(r) => r,
Err(e) => {
let _ = tx.send(StreamEvent::Error {
message: e.to_string(),
});
return;
}
};
messages.push(response.message.clone());
// ⑥ 检测 tool_use —— 没有则 break(最终轮)
if !has_tool_calls_in_response(&response) {
break;
}
// ⑦ 提取 tool_calls
let tool_calls = extract_tool_calls_from_response(&response);
let calls: Vec<(String, String, Value)> = tool_calls
.into_iter()
.map(|(id, name, args)| {
let value: Value = serde_json::from_str(&args).unwrap_or(Value::Null);
(id, name, value)
})
.collect();
// ⑧ 发送 ToolExecutionStarted
for (tool_call_id, tool_name, args_value) in &calls {
let args_json = serde_json::to_string(args_value).unwrap_or_default();
if tx
.send(StreamEvent::ToolExecutionStarted {
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: args_json,
})
.is_err()
{
return;
}
}
// ⑨ 执行工具
let results = tool_registry.invoke_all(calls, tool_timeout).await;
// ⑩ 发送 ToolExecutionCompleted
for result in &results {
let summary = match &result.output {
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
Err(e) => e.to_string(),
};
let truncated = truncate_tool_result(&summary, max_bytes);
if tx
.send(StreamEvent::ToolExecutionCompleted {
tool_name: result.tool_name.clone(),
tool_call_id: result.tool_call_id.clone(),
result_summary: truncated,
is_error: result.output.is_err(),
})
.is_err()
{
return;
}
}
// ⑪ push 工具结果到 messages(区分可恢复/不可恢复)
for result in results {
let is_error = result.output.is_err();
let content = match &result.output {
Ok(v) => {
// ponytail: 与 submit_with_tools 行为对齐 —— 用 truncate_tool_result
// 截断结果以防止超大工具输出在 tool 循环中膨胀 messages 上下文窗口
let serialized =
serde_json::to_string(v).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) => {
// 不可恢复错误 —— 终止循环
let _ = tx.send(StreamEvent::Error {
message: format!("工具 '{}' 不可恢复错误: {}", result.tool_name, e),
});
return;
}
};
messages.push(Message::tool_result(result.tool_call_id, content, is_error));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};
use crate::tools::{BaseTool, ToolRegistry};
use async_trait::async_trait;
use futures_core::Stream;
use serde_json::{Value, json};
use std::pin::Pin;
/// 模拟 Provider —— 预定义响应序列,按调用顺序返回。
struct MockProvider {
responses: std::sync::Mutex<Vec<MessageResponse>>,
call_count: std::sync::Mutex<u32>,
}
impl MockProvider {
fn new(responses: Vec<MessageResponse>) -> 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: MessageRequest) -> Result<MessageResponse, 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))
}
async fn chat_stream(
&self,
_request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
unimplemented!()
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "mock",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
fn empty_usage() -> crate::llm::types::Usage {
crate::llm::types::Usage::default()
}
fn assistant_text_response(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: empty_usage(),
stop_reason: StopReason::Stop,
extra: std::collections::HashMap::new(),
}
}
fn assistant_tool_call_response(calls: Vec<(&str, &str, &str)>) -> MessageResponse {
let tool_blocks: Vec<ContentBlock> = calls
.into_iter()
.map(|(id, name, args)| {
let input: serde_json::Value =
serde_json::from_str(args).unwrap_or(serde_json::Value::Null);
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input,
}
})
.collect();
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: tool_blocks,
},
usage: empty_usage(),
stop_reason: StopReason::ToolUse,
extra: std::collections::HashMap::new(),
}
}
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() {
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(), &registry)
.await
.unwrap();
// 最终响应是 Assistant 消息
assert!(matches!(response.message, Message::Assistant { .. }));
// 验证消息历史:
// user, assistant(含 tool_use), tool_result, assistant(text)
let messages = cycle.messages();
assert_eq!(messages.len(), 4);
assert!(matches!(messages[0], Message::User { .. }));
assert!(matches!(messages[1], Message::Assistant { content: _ }));
if let Message::Assistant { content } = &messages[1] {
assert!(
content
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }))
);
}
assert!(matches!(
messages[2],
Message::ToolResult {
is_error: false,
..
}
));
assert!(matches!(messages[3], Message::Assistant { .. }));
}
#[tokio::test]
async fn test_submit_with_tools_multi_turn() {
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(), &registry)
.await
.unwrap();
assert!(matches!(response.message, Message::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() {
let config = CycleConfig {
max_tool_turns: Some(2),
..Default::default()
};
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(), &registry).await;
assert!(
matches!(result, Err(LlmError::Other(msg)) if msg.contains("达到最大工具循环轮次"))
);
}
#[tokio::test]
async fn test_submit_with_tools_no_tool_call_response() {
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(), &registry)
.await
.unwrap();
assert!(matches!(response.message, Message::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("中"));
}
// ====== Phase 9: submit_with_tools_stream 单元测试 ======
//
// ponytail: 由于 in-mod MockProvider 的 chat_stream 是 unimplemented!()
// 这里使用公开的 `crate::llm::mock::MockProvider`(已实现完整 chat_stream + 预设队列)。
// 测试需使用 `tokio::test(flavor = "multi_thread")` 满足 submit_with_tools_stream 内部
// 的 `tokio::spawn` 运行时要求。
use crate::llm::mock::MockProvider as Mock;
use futures_util::StreamExt;
/// 收集流中所有事件到 Vec。
async fn drain(stream: Pin<Box<dyn Stream<Item = StreamEvent> + Send>>) -> Vec<StreamEvent> {
let mut out = Vec::new();
let mut s = stream;
while let Some(ev) = s.next().await {
out.push(ev);
}
out
}
/// Phase 9 测试 3.1 — 纯文本流(无 tool_use
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_pure_text() {
let provider = Mock::new(vec![assistant_text_response("你好")]);
let mut cycle =
LlmCycle::new(Box::new(provider), CycleConfig::default());
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(AddTool)).unwrap();
let stream = cycle
.submit_with_tools_stream("问个问题".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 期望序列:MessageStart → ContentBlockStart(Text) → TextDelta → ContentBlockEnd → CostUpdate → MessageComplete
assert!(matches!(events.first(), Some(StreamEvent::MessageStart { .. })));
assert!(events
.iter()
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "你好")));
assert!(events
.iter()
.any(|e| matches!(e, StreamEvent::MessageComplete { .. })));
// 纯文本流不应有 ToolExecutionStarted/Completed 事件
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. })));
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. })));
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::Error { .. })));
}
/// Phase 9 测试 3.2 — 单轮工具调用
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_single_tool() {
let provider = Mock::new(vec![
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
assistant_text_response("答案是 3"),
]);
let mut cycle =
LlmCycle::new(Box::new(provider), CycleConfig::default());
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(AddTool)).unwrap();
let stream = cycle
.submit_with_tools_stream("1+2".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 应有 1 对 ToolExecutionStarted / ToolExecutionCompleted
let started_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. }))
.count();
let completed_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. }))
.count();
assert_eq!(started_count, 1, "应有 1 个 ToolExecutionStarted");
assert_eq!(completed_count, 1, "应有 1 个 ToolExecutionCompleted");
// 验证 ToolExecutionStarted.arguments 携带有效 JSON
let started = events
.iter()
.find_map(|e| match e {
StreamEvent::ToolExecutionStarted {
tool_name,
tool_call_id,
arguments,
} => Some((tool_name, tool_call_id, arguments)),
_ => None,
})
.unwrap();
assert_eq!(started.0, "add");
assert_eq!(started.1, "call_1");
assert!(!started.2.is_empty(), "arguments 应携带实际 JSON 参数");
// 验证 ToolExecutionCompleted 的 result_summary
let completed = events
.iter()
.find_map(|e| match e {
StreamEvent::ToolExecutionCompleted {
tool_name,
tool_call_id,
result_summary,
is_error,
} => Some((tool_name, tool_call_id, result_summary, *is_error)),
_ => None,
})
.unwrap();
assert_eq!(completed.0, "add");
assert_eq!(completed.1, "call_1");
assert!(!completed.2.is_empty());
assert!(!completed.3);
// 应有最终 MessageComplete { stop_reason: Stop }
let final_response = events
.iter()
.rev()
.find_map(|e| match e {
StreamEvent::MessageComplete { full_response } => Some(full_response.clone()),
_ => None,
})
.unwrap();
assert_eq!(final_response.stop_reason, StopReason::Stop);
}
/// Phase 9 测试 3.3 — 多轮工具调用
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_multi_tool() {
let provider = Mock::new(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 mut cycle =
LlmCycle::new(Box::new(provider), CycleConfig::default());
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(AddTool)).unwrap();
let stream = cycle
.submit_with_tools_stream("计算总和".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
let started_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. }))
.count();
let completed_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. }))
.count();
assert_eq!(started_count, 3, "3 轮工具调用");
assert_eq!(completed_count, 3, "3 个 ToolExecutionCompleted");
// 应有 4 个 MessageComplete(每个 LLM 调用独立一个)
let complete_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::MessageComplete { .. }))
.count();
assert_eq!(complete_count, 4, "4 个 LLM 调用 → 4 个 MessageComplete");
}
/// Phase 9 测试 3.4 — 最大轮次超限
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_max_turns_exceeded() {
let config = CycleConfig {
max_tool_turns: Some(2),
..Default::default()
};
let provider = Mock::new(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}"#)]),
]);
let mut cycle = LlmCycle::new(Box::new(provider), config);
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(AddTool)).unwrap();
let stream = cycle
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 流中应有 Error 事件(最大轮次超限)
let error_events: Vec<_> = events
.iter()
.filter_map(|e| match e {
StreamEvent::Error { message } => Some(message.clone()),
_ => None,
})
.collect();
assert!(
error_events.iter().any(|m| m.contains("达到最大工具循环轮次")),
"应包含最大轮次超限 Error,实际: {:?}", error_events
);
// 工具调用次数应 ≤ 2
let started_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. }))
.count();
assert_eq!(started_count, 2, "工具调用应在第 2 轮后停止");
}
/// Phase 9 测试 3.5 — `chat_stream` 返回 Err
///
/// 使用自定义 MockProvider 返回 chat_stream Err。
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_chat_stream_err() {
use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};
struct ErrProvider;
#[async_trait]
impl LlmProvider for ErrProvider {
async fn chat(&self, _r: MessageRequest) -> Result<MessageResponse, LlmError> {
Err(LlmError::Other("网络错误".into()))
}
async fn chat_stream(
&self,
_r: MessageRequest,
) -> Result<
Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>,
LlmError,
> {
Err(LlmError::Other("网络错误".into()))
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "err",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
let mut cycle = LlmCycle::new(Box::new(ErrProvider), CycleConfig::default());
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(AddTool)).unwrap();
let stream = cycle
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 第一个事件应是 Errorchat_stream Err 立即事件化)
assert!(
events.first().map(|e| matches!(e, StreamEvent::Error { .. }))
== Some(true),
"流中首个事件应是 Error,实际: {:?}", events.first()
);
assert!(events
.iter()
.filter_map(|e| match e {
StreamEvent::Error { message } => Some(message.as_str()),
_ => None,
})
.any(|m| m.contains("网络错误")));
}
/// Phase 9 测试 3.6 — 空 tool_registry
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_empty_registry() {
let provider = Mock::new(vec![assistant_text_response("纯文本回答")]);
let mut cycle =
LlmCycle::new(Box::new(provider), CycleConfig::default());
let registry = ToolRegistry::new();
let stream = cycle
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 流退化为纯文本流 —— 无 ToolExecution 事件,无 Error
assert!(events
.iter()
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "纯文本回答")));
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. })));
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. })));
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::Error { .. })));
}
/// Phase 9 测试 3.7 — 不可恢复工具错误
struct UnrecoverableTool;
#[async_trait]
impl BaseTool for UnrecoverableTool {
fn name(&self) -> &str {
"fail_unrecoverable"
}
fn description(&self) -> &str {
"不可恢复地失败"
}
fn parameters(&self) -> Value {
json!({})
}
async fn execute(
&self,
_args: Value,
_ctx: &crate::tools::ToolContext<'_>,
) -> Result<Value, crate::tools::ToolError> {
// 不可恢复错误 —— NotFound 表示工具在执行时失败且不可恢复
Err(crate::tools::ToolError::NotFound("永久失败".into()))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_unrecoverable_tool_error() {
let provider = Mock::new(vec![
assistant_tool_call_response(vec![("call_x", "fail_unrecoverable", "{}")]),
assistant_text_response("忽略"),
]);
let mut cycle =
LlmCycle::new(Box::new(provider), CycleConfig::default());
let mut registry = ToolRegistry::new();
registry
.register(std::sync::Arc::new(UnrecoverableTool))
.unwrap();
let stream = cycle
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 流中应有 Error 事件(不可恢复错误)
let error_events: Vec<_> = events
.iter()
.filter_map(|e| match e {
StreamEvent::Error { message } => Some(message.clone()),
_ => None,
})
.collect();
assert!(
error_events.iter().any(|m| m.contains("不可恢复")),
"应包含不可恢复错误 Error"
);
}
/// Phase 9 测试 3.8 — 可恢复工具错误
struct RecoverableTool;
#[async_trait]
impl BaseTool for RecoverableTool {
fn name(&self) -> &str {
"fail_recoverable"
}
fn description(&self) -> &str {
"可恢复失败"
}
fn parameters(&self) -> Value {
json!({})
}
async fn execute(
&self,
_args: Value,
_ctx: &crate::tools::ToolContext<'_>,
) -> Result<Value, crate::tools::ToolError> {
// ExecutionFailed = 可恢复错误(is_recoverable() == true
Err(crate::tools::ToolError::ExecutionFailed(
"工具暂时失败".into(),
"网络抖动".into(),
))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_recoverable_tool_error() {
let provider = Mock::new(vec![
assistant_tool_call_response(vec![("call_y", "fail_recoverable", "{}")]),
assistant_text_response("已恢复"),
]);
let mut cycle =
LlmCycle::new(Box::new(provider), CycleConfig::default());
let mut registry = ToolRegistry::new();
registry
.register(std::sync::Arc::new(RecoverableTool))
.unwrap();
let stream = cycle
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 可恢复错误:tool_result 回传 LLM,最终流正常结束
assert!(!events
.iter()
.any(|e| matches!(e, StreamEvent::Error { .. })));
// 最终 MessageComplete 应是 Stop(不是 ToolUse
let final_response = events
.iter()
.rev()
.find_map(|e| match e {
StreamEvent::MessageComplete { full_response } => Some(full_response.clone()),
_ => None,
})
.unwrap();
assert_eq!(final_response.stop_reason, StopReason::Stop);
// ToolExecutionCompleted.is_error 应为 true
let completed = events
.iter()
.find_map(|e| match e {
StreamEvent::ToolExecutionCompleted { is_error, .. } => Some(*is_error),
_ => None,
})
.unwrap();
assert!(completed, "可恢复错误的 ToolExecutionCompleted.is_error 应为 true");
}
/// Phase 9 测试 3.9 — 工具超时
///
/// 使用一个永远 sleep 的工具 + tool_timeout_secs: 1,验证 TimeoutError 路径。
///
/// 注:`tokio::time::timeout` 在 `invoke_all` 中将超时转为 `ToolError::McpTimeout("timeout")`。
/// 当前 `McpTimeout.is_recoverable() == false`(见 `tools/error.rs`),因此工具超时视为不可恢复:
/// - 流中应出现 `ToolExecutionCompleted { is_error: true }`
/// - 然后出现 `StreamEvent::Error`(不可恢复错误终止循环,§3.6 错误表)
/// - 第一轮的 `MessageComplete { stop_reason: ToolUse }` 在 Error 事件之前已发出
/// - 不会再有第二轮 LLM 调用(与方案 §3.6 一致)
#[tokio::test(flavor = "multi_thread")]
async fn test_submit_with_tools_stream_tool_timeout() {
struct SlowTool;
#[async_trait]
impl BaseTool for SlowTool {
fn name(&self) -> &str {
"slow_tool"
}
fn description(&self) -> &str {
"慢工具,模拟超时"
}
fn parameters(&self) -> Value {
json!({})
}
async fn execute(
&self,
_args: Value,
_ctx: &crate::tools::ToolContext<'_>,
) -> Result<Value, crate::tools::ToolError> {
// sleep 超过 5stool_timeout = 1s),触发 invoke_all 的 tokio::time::timeout
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Ok(json!({"ok": true}))
}
}
let config = CycleConfig {
tool_timeout_secs: 1,
..Default::default()
};
// ponytail: 预设第二个响应不会消费——超时后流立即终止,不会发起第二轮 LLM 调用
let provider = Mock::new(vec![
assistant_tool_call_response(vec![("call_z", "slow_tool", "{}")]),
assistant_text_response("不会到达"),
]);
let mut cycle = LlmCycle::new(Box::new(provider), config);
let mut registry = ToolRegistry::new();
registry
.register(std::sync::Arc::new(SlowTool))
.unwrap();
let stream = cycle
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
.await
.unwrap();
let events = drain(stream).await;
// 1. ToolExecutionCompleted 应报告 is_error=trueMcpTimeout 视为失败)
let tool_completed: Vec<_> = events
.iter()
.filter_map(|e| match e {
StreamEvent::ToolExecutionCompleted {
is_error,
tool_name,
..
} => Some((*is_error, tool_name.clone())),
_ => None,
})
.collect();
assert!(!tool_completed.is_empty(), "应有 ToolExecutionCompleted 事件");
assert!(tool_completed[0].0, "超时后 ToolExecutionCompleted.is_error 应为 true");
assert_eq!(tool_completed[0].1, "slow_tool");
// 2. 流中应有不可恢复错误终止事件(tool_timeout → McpTimeout → 不可恢复 → Error
let error_events: Vec<_> = events
.iter()
.filter_map(|e| match e {
StreamEvent::Error { message } => Some(message.clone()),
_ => None,
})
.collect();
assert!(
error_events
.iter()
.any(|m| m.contains("不可恢复错误")),
"应有不可恢复错误事件终止流,实际事件: {:?}", error_events
);
// 3. 第一轮的 MessageComplete { stop_reason: ToolUse } 在 Error 之前已发出
let first_complete = events
.iter()
.find_map(|e| match e {
StreamEvent::MessageComplete { full_response } => Some(full_response.clone()),
_ => None,
})
.expect("第一轮 MessageComplete 应存在");
assert_eq!(
first_complete.stop_reason,
StopReason::ToolUse,
"第一轮 LLM 响应 stop_reason 应为 ToolUse"
);
// 4. 不会发起第二轮 LLM —— 流中只有 1 个 MessageComplete
let complete_count = events
.iter()
.filter(|e| matches!(e, StreamEvent::MessageComplete { .. }))
.count();
assert_eq!(
complete_count, 1,
"超时后不应有第二轮 LLM 流,应只有 1 个 MessageComplete"
);
}
}