- 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
1716 lines
65 KiB
Rust
1716 lines
65 KiB
Rust
//! 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,
|
||
/// 单个工具结果的最大字节数(超过此值将被截断)。
|
||
/// 默认 65536(64KB),防止大结果导致 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 消息必须紧跟在对应的 Assistant(tool_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::ToolResult,is_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 rx,task 终止
|
||
}
|
||
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=true,finalize() 会基于损坏状态生成不可信响应,
|
||
// 此时跳过 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(), ®istry)
|
||
.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(), ®istry)
|
||
.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(), ®istry).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(), ®istry)
|
||
.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;
|
||
|
||
// 第一个事件应是 Error(chat_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 超过 5s(tool_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=true(McpTimeout 视为失败)
|
||
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"
|
||
);
|
||
}
|
||
}
|