Skip to main content

lellm_agent/runtime/react/
tool_node.rs

1//! 工具执行节点 — 读取 tool_calls,执行工具,写入 results。
2//!
3//! # Typed State
4//!
5//! 从 ctx 获取 `AgentState`,执行工具,追加结果到消息历史。
6
7use async_trait::async_trait;
8
9use lellm_core::{CacheControl, TextBlock, ToolCall};
10use lellm_graph::{GraphError, LeafContext, LeafNode};
11
12use super::super::config::{ToolUseConfig, empty_response};
13use super::super::context::ContextBudget;
14use super::super::runtime::ResolvedRound;
15use super::super::tools::{ToolExecutor, ToolFn};
16use super::super::typed_state::{AgentMutation, AgentState};
17
18/// 工具执行节点。
19#[derive(Clone)]
20pub struct ToolNode {
21    pub name: String,
22    pub executor: ToolExecutor,
23    pub config: ToolUseConfig,
24}
25
26impl ToolNode {
27    pub fn new(name: impl Into<String>, executor: ToolExecutor, config: ToolUseConfig) -> Self {
28        Self {
29            name: name.into(),
30            executor,
31            config,
32        }
33    }
34}
35
36#[async_trait]
37impl LeafNode<AgentState> for ToolNode {
38    async fn execute(&self, ctx: &mut LeafContext<'_, AgentState>) -> Result<(), GraphError> {
39        use lellm_graph::{StreamChunk, ToolPhase};
40
41        // 1. 获取工具调用
42        let round = ResolvedRound::new(self.executor.snapshot().await);
43        let state = ctx.state().clone();
44        let last_response = state.last_response.unwrap_or_else(empty_response);
45        let tool_calls: Vec<ToolCall> = last_response.tool_calls().cloned().collect();
46
47        if tool_calls.is_empty() {
48            return Ok(());
49        }
50
51        // 2. Emit Queued + Started for all tools (严格按 ToolCall 顺序)
52        for call in &tool_calls {
53            ctx.emit(StreamChunk::ToolLifecycle {
54                phase: ToolPhase::Queued,
55                call_id: call.id.clone(),
56                tool_name: call.name.clone(),
57            });
58            ctx.emit(StreamChunk::ToolLifecycle {
59                phase: ToolPhase::Started,
60                call_id: call.id.clone(),
61                tool_name: call.name.clone(),
62            });
63        }
64
65        // 3. 并发执行每个工具,完成后立即 emit Finished + ToolOutput
66        let retry_policy = self.executor.retry_policy().clone();
67        let snapshot = round.snapshot.clone();
68        let budget = self.config.context_budget.clone();
69
70        let mut handles = Vec::with_capacity(tool_calls.len());
71        for call in &tool_calls {
72            let entry = snapshot.get(&call.name).cloned();
73            let rp = retry_policy.clone();
74            let call_clone = call.clone();
75            let budget_clone = budget.clone();
76
77            handles.push(tokio::spawn(async move {
78                let start = std::time::Instant::now();
79                let result: lellm_core::ToolResult = match entry {
80                    Some(reg) => {
81                        let tool_fn: ToolFn =
82                            std::sync::Arc::new(move |args: &serde_json::Value| reg.execute(args));
83                        rp.execute_with_retry(&tool_fn, &call_clone.arguments).await
84                    }
85                    None => Err(lellm_core::ToolError::not_found(format!(
86                        "unknown tool: {}",
87                        call_clone.name
88                    ))),
89                };
90                let duration = start.elapsed();
91
92                // 应用预算截断 + 前缀缓存 Breakpoint
93                let msg = lellm_core::Message::tool_result(&call_clone, &result);
94                let msg = apply_budget_truncate(msg, &budget_clone);
95                let msg = set_tool_result_cache(msg);
96
97                (msg, duration)
98            }));
99        }
100
101        // 4. 收集结果,join_all 保持顺序,i-th result 对应 i-th tool_call
102        let mut results: Vec<Option<lellm_core::Message>> = vec![None; tool_calls.len()];
103        let mut panicked = false;
104
105        let collect = futures_util::future::join_all(handles).await;
106        for (i, (call, join_result)) in tool_calls.iter().zip(collect).enumerate() {
107            match join_result {
108                Ok((msg, duration)) => {
109                    // Emit Finished
110                    ctx.emit(StreamChunk::ToolLifecycle {
111                        phase: ToolPhase::Finished,
112                        call_id: call.id.clone(),
113                        tool_name: call.name.clone(),
114                    });
115                    // Emit ToolOutput
116                    if let Some(chunk) = tool_output_chunk(&msg, &call.id, &call.name, duration) {
117                        ctx.emit(chunk);
118                    }
119                    results[i] = Some(msg);
120                }
121                Err(join_err) => {
122                    panicked = true;
123                    let err_msg = lellm_core::Message::tool_result(
124                        call,
125                        &Err(lellm_core::ToolError {
126                            kind: lellm_core::ToolErrorKind::Internal,
127                            message: format!("tool task panicked: {join_err}"),
128                        }),
129                    );
130                    let err_msg = set_tool_result_cache(err_msg);
131                    ctx.emit(StreamChunk::ToolLifecycle {
132                        phase: ToolPhase::Finished,
133                        call_id: call.id.clone(),
134                        tool_name: call.name.clone(),
135                    });
136                    if let Some(chunk) =
137                        tool_output_chunk(&err_msg, &call.id, &call.name, std::time::Duration::ZERO)
138                    {
139                        ctx.emit(chunk);
140                    }
141                    results[i] = Some(err_msg);
142                }
143            }
144        }
145
146        if panicked {
147            tracing::warn!("tool batch task panicked — error results filled");
148        }
149
150        // 5. Emit 消息追加 Mutation
151        ctx.record(AgentMutation::AppendMessages(
152            results.into_iter().flatten().collect(),
153        ));
154
155        tracing::debug!(tool_calls = tool_calls.len(), "tool execution completed");
156
157        Ok(())
158    }
159}
160
161/// 对单个 ToolResult 应用预算截断。
162fn apply_budget_truncate(msg: lellm_core::Message, budget: &ContextBudget) -> lellm_core::Message {
163    if let lellm_core::Message::ToolResult {
164        ref tool_call_id,
165        is_error: false,
166        ref content,
167    } = msg
168    {
169        let truncated = budget.truncate_tool_result_blocks(content);
170        if truncated != *content {
171            return lellm_core::Message::ToolResult {
172                tool_call_id: tool_call_id.clone(),
173                is_error: false,
174                content: truncated,
175            };
176        }
177    }
178    msg
179}
180
181/// 为 ToolResult 的 TextBlock 添加 cache_control Breakpoint。
182///
183/// ToolResult 在 Anthropic 协议中等价于 role="user" 消息,成为对话历史的一部分。
184/// 添加 Breakpoint 后,后续轮次可命中前缀缓存(包含之前的工具执行结果)。
185fn set_tool_result_cache(msg: lellm_core::Message) -> lellm_core::Message {
186    if let lellm_core::Message::ToolResult {
187        tool_call_id,
188        is_error,
189        content,
190    } = msg
191    {
192        let new_content = content
193            .into_iter()
194            .map(|block| match block {
195                lellm_core::ContentBlock::Text(tb) => lellm_core::ContentBlock::Text(TextBlock {
196                    text: tb.text,
197                    cache_control: Some(CacheControl::Breakpoint),
198                }),
199                other => other,
200            })
201            .collect();
202        lellm_core::Message::ToolResult {
203            tool_call_id,
204            is_error,
205            content: new_content,
206        }
207    } else {
208        msg
209    }
210}
211
212/// 从 Message::ToolResult 提取内容,构建 ToolOutput chunk。
213fn tool_output_chunk(
214    msg: &lellm_core::Message,
215    call_id: &str,
216    tool_name: &str,
217    duration: std::time::Duration,
218) -> Option<lellm_graph::StreamChunk> {
219    if let lellm_core::Message::ToolResult {
220        content, is_error, ..
221    } = msg
222    {
223        let content_str: String = content
224            .iter()
225            .filter_map(|b| match b {
226                lellm_core::ContentBlock::Text(t) => Some(t.text.clone()),
227                lellm_core::ContentBlock::Image { .. }
228                | lellm_core::ContentBlock::Thinking(_)
229                | lellm_core::ContentBlock::ToolCall(_) => None,
230            })
231            .collect::<Vec<_>>()
232            .join("");
233        Some(lellm_graph::StreamChunk::ToolOutput {
234            call_id: call_id.to_string(),
235            tool_name: tool_name.to_string(),
236            content: content_str,
237            is_error: *is_error,
238            duration,
239        })
240    } else {
241        None
242    }
243}