lellm_agent/runtime/react/
tool_node.rs1use 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#[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 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 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 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 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 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 ctx.emit(StreamChunk::ToolLifecycle {
111 phase: ToolPhase::Finished,
112 call_id: call.id.clone(),
113 tool_name: call.name.clone(),
114 });
115 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 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
161fn 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
181fn 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
212fn 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}