feat(core): 新增 Phase 9 流式体验增强 submit_turn_stream

- 新增 StreamEvent::ToolExecutionStarted/Completed 变体(apply_to 元事件)
- 新增 LlmCycle::submit_with_tools_stream + run_tool_loop(spawn + mpsc 状态机)
- 新增 AgentSession::submit_turn_stream + finalize_turn(手动同步状态)
- CycleConfig 加 Clone derive

- 新增 9 个单元测试 + 2 个集成测试(211 passed)
- clippy 0 警告,存量 0 回归

docs: 追加 Phase 9 实施方案(docs/16-phase9-streaming-experience.md)
This commit is contained in:
徐涛
2026-07-05 23:37:37 +08:00
parent 88d00ac927
commit 212cfcc916
4 changed files with 1832 additions and 2 deletions
+207 -1
View File
@@ -7,14 +7,18 @@
//! - **不做业务循环**:多轮策略、错误重试、记忆回写由上层应用或具体 `TaskAgent` 决定
//! - **不持有 ConversationMemory**:上层可独立 new 一个 `ConversationMemory`,在合适的时机调 `add_message`
use std::pin::Pin;
use std::sync::Arc;
use futures_core::Stream;
use crate::agent::agent::Agent;
use crate::agent::error::AgentError;
use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::hooks::{HookContext, HookEvent};
use crate::llm::stream::StreamEvent;
use crate::llm::types::message::Message;
use crate::llm::types::response_v2::MessageResponse;
use crate::memory::store::InMemoryStore;
@@ -169,6 +173,84 @@ impl AgentSession {
Ok(response)
}
/// 提交一轮对话(流式版本,含自动 tool 循环),返回 `StreamEvent` 流。
///
/// 与 `submit_turn` 的区别:
/// - 以流事件序列而非 `MessageResponse` 返回
/// - 工具执行期间插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
/// - 消费方在收到 `MessageComplete` 后需手动调用 `finalize_turn` 同步状态
///
/// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。
///
/// ponytail: 流程结构与 `submit_turn` 对称,但 `OnTurnEnd` hook + cost 累计不在流生成路径上,
/// 因为流是延迟求值且 `&mut self` 无法进入 spawn 闭包。调用方消费流完毕后必须调 `finalize_turn`。
pub async fn submit_turn_stream(
&mut self,
user_input: impl Into<String>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, AgentError> {
let turn_index = self.turn_index;
let hook_executor = Arc::clone(&self.bundle.hook_executor);
// 1. 触发 OnTurnStart hook(同步)
let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index);
hook_executor
.execute(HookEvent::OnTurnStart, &start_ctx)
.await;
// 2. 触发子 trait 覆盖(白名单/过滤)的副作用
let _ = self.agent.tool_definitions(&self.bundle);
// 3. 组装 LlmCycle
let mut cycle =
LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default())
.with_messages(Vec::new());
if let Some(prompt) = self.agent.system_prompt() {
cycle = cycle.with_messages(vec![Message::system(prompt)]);
}
if let Some(cfg) = self.bundle.config.compact_config.clone() {
cycle = cycle.with_compact_config(cfg);
}
// 4. 调用流式工具循环
let stream = cycle
.submit_with_tools_stream(
user_input.into(),
Arc::clone(&self.bundle.tool_registry),
)
.await?;
// 5. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号
self.turn_index += 1;
// 注:hook_executor 不显式 drop,生命周期由 Arc 自动管理
let _ = hook_executor;
Ok(stream)
}
/// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook。
///
/// 由消费者在收到 `MessageComplete.full_response` 后调用。
///
/// **消费者注意**`finalize_turn` 是开发者责任 —— 遗漏调用会导致 cost 不累计、OnTurnEnd 不触发。
/// session 状态仍然可用,后续 `submit_turn` 也能正常执行,但 cost 信息不完整。
///
/// ponytail: 与 `submit_turn` 行为对齐 —— `cost_so_far` 仅计入最终轮的 usage。
pub async fn finalize_turn(&mut self, response: &MessageResponse) {
self.cost_so_far.add(&response.usage);
// ponytail: 防御性 saturating_sub 防止误用 panic。
// 正常路径是 submit_turn_stream 内 turn_index += 1 后再调 finalize_turn
// 所以 saturating 后为 0 是正常的;若调用方忘了先调 submit_turn_stream
// turn_index 仍为 0,传 0 给 OnTurnEnd hook 不会 panic。
let end_ctx = HookContext::new(HookEvent::OnTurnEnd)
.with_turn_index(self.turn_index.saturating_sub(1));
self.bundle
.hook_executor
.execute(HookEvent::OnTurnEnd, &end_ctx)
.await;
}
}
#[cfg(test)]
@@ -331,4 +413,128 @@ mod tests {
assert_eq!(start_count.0.load(Ordering::SeqCst), 2);
assert_eq!(end_count.0.load(Ordering::SeqCst), 2);
}
}
// ====== Phase 9: submit_turn_stream + finalize_turn 集成测试 ======
use futures_util::StreamExt;
/// 集成测试 5.1 — `submit_turn_stream` 端到端:跑通 mock provider → 消费流
/// 验证各事件到达 → `finalize_turn` 后 cost 更新正确
#[tokio::test(flavor = "multi_thread")]
async fn submit_turn_stream_end_to_end() {
use crate::llm::mock::MockProvider as SessionMock;
let provider = Arc::new(SessionMock::new(vec![assistant_text("hello")]));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: Some("you are a test agent".into()),
});
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.unwrap(),
);
let mut session = AgentSession::new(agent, "stream-s1", bundle);
assert_eq!(session.turn_index(), 0);
// 1. 提交 stream
let mut stream = session.submit_turn_stream("hi").await.unwrap();
// 2. 消费流,收集事件直到结束
let mut events: Vec<StreamEvent> = Vec::new();
let mut final_response = None;
while let Some(event) = stream.next().await {
let _ = &event;
if let StreamEvent::MessageComplete { full_response } = &event {
final_response = Some(full_response.clone());
}
events.push(event);
}
// 3. 验证事件序列
assert!(!events.is_empty(), "应有事件");
assert!(
events.iter().any(|e| matches!(e, StreamEvent::MessageStart { .. })),
"应包含 MessageStart"
);
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "hello")),
"应包含 TextDelta hello"
);
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::MessageComplete { .. })),
"应包含 MessageComplete"
);
// 4. 调用 finalize_turn
let response = final_response.expect("流应包含至少一个 MessageComplete");
session.finalize_turn(&response).await;
// 5. 验证 cost 累计
assert_eq!(session.turn_index(), 1);
assert_eq!(session.usage().total().prompt_tokens, 10);
assert_eq!(session.usage().total().completion_tokens, 5);
}
/// 集成测试 5.2 — Hook 触发验证:
/// - `OnTurnStart` 在 `submit_turn_stream` 返回流之前触发
/// - `finalize_turn` 调用后 `OnTurnEnd` 正确触发
#[tokio::test(flavor = "multi_thread")]
async fn submit_turn_stream_triggers_turn_hooks() {
use crate::llm::mock::MockProvider as SessionMock;
let mut hook_executor = HookExecutor::new();
let start_count = Arc::new(CountHook(AtomicU32::new(0)));
let end_count = Arc::new(CountHook(AtomicU32::new(0)));
hook_executor.register(
HookEvent::OnTurnStart,
Box::new(CountHookAdapter(start_count.clone())),
);
hook_executor.register(
HookEvent::OnTurnEnd,
Box::new(CountHookAdapter(end_count.clone())),
);
let provider = Arc::new(SessionMock::new(vec![assistant_text("ok")]));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: None,
});
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(hook_executor))
.build()
.unwrap(),
);
let mut session = AgentSession::new(agent, "stream-s2", bundle);
// 1. submit_turn_stream 触发 OnTurnStart
let mut stream = session.submit_turn_stream("hi").await.unwrap();
assert_eq!(start_count.0.load(Ordering::SeqCst), 1);
assert_eq!(end_count.0.load(Ordering::SeqCst), 0, "OnTurnEnd 未在流返回前触发");
// 2. 消费完流后再调 finalize_turn 触发 OnTurnEnd
let mut final_response = None;
while let Some(event) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &event {
final_response = Some(full_response.clone());
}
}
session
.finalize_turn(&final_response.expect("应有 MessageComplete"))
.await;
assert_eq!(start_count.0.load(Ordering::SeqCst), 1);
assert_eq!(end_count.0.load(Ordering::SeqCst), 1, "OnTurnEnd 在 finalize_turn 后触发");
}
}
+783 -1
View File
@@ -11,6 +11,9 @@ 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;
@@ -20,11 +23,13 @@ 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, StopReason};
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,
@@ -613,6 +618,53 @@ impl LlmCycle {
}
}
/// 提交消息并自动处理工具调用循环,流式产出所有事件。
///
/// 与 `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
@@ -672,6 +724,199 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
)
}
/// 运行流式工具循环的核心异步状态机(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 hookfire-and-forget,仅占位保留以保持接口对称)
let _ = hook_executor.as_ref();
// ③ 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::*;
@@ -917,4 +1162,541 @@ mod tests {
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"
);
}
}
+21
View File
@@ -192,6 +192,24 @@ pub enum StreamEvent {
MessageComplete { full_response: MessageResponse },
/// 错误事件。
Error { message: String },
/// 工具开始执行 —— 在 `ToolCallEnd` 之后、`registry.invoke_all` 之前发出。
/// 让 UI 层可以显示 "正在执行工具:add(1, 2)"。
ToolExecutionStarted {
tool_name: String,
tool_call_id: String,
/// 工具参数(JSON 字符串形式),用于 UI 展示
arguments: String,
},
/// 工具执行完成 —— 在工具返回后、新一轮 LLM 流开始之前发出。
ToolExecutionCompleted {
tool_name: String,
tool_call_id: String,
/// 结果摘要(由 `CycleConfig.max_tool_result_bytes` 截断,默认 65536 字节/字符边界安全),
/// 用于 UI 反馈。完整结果已在内部 `messages` 中作为 `ToolResult` 回传给 LLM。
result_summary: String,
/// 是否执行出错
is_error: bool,
},
}
/// 流式响应累积状态。
@@ -352,6 +370,9 @@ impl PartialMessageResponse {
self.is_errored = true;
false
}
// 元事件:不参与内容块累积,不修改 partial 状态
//(Phase 9 —— 工具执行透明化,由 run_tool_loop 在工具前后插入)
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => true,
}
}