lellm_agent/runtime/react/
tool_node.rs1use async_trait::async_trait;
8
9use lellm_core::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;
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 rp.execute_with_retry(®.func, &call_clone.arguments)
82 .await
83 }
84 None => Err(lellm_core::ToolError::not_found(format!(
85 "unknown tool: {}",
86 call_clone.name
87 ))),
88 };
89 let duration = start.elapsed();
90
91 let msg = lellm_core::Message::tool_result(&call_clone, &result);
93 let msg = apply_budget_truncate(msg, &budget_clone);
94
95 (msg, duration)
96 }));
97 }
98
99 let mut results: Vec<Option<lellm_core::Message>> = vec![None; tool_calls.len()];
101 let mut panicked = false;
102
103 let collect = futures_util::future::join_all(handles).await;
104 for (i, (call, join_result)) in tool_calls.iter().zip(collect).enumerate() {
105 match join_result {
106 Ok((msg, duration)) => {
107 ctx.emit(StreamChunk::ToolLifecycle {
109 phase: ToolPhase::Finished,
110 call_id: call.id.clone(),
111 tool_name: call.name.clone(),
112 });
113 if let Some(chunk) = tool_output_chunk(&msg, &call.id, &call.name, duration) {
115 ctx.emit(chunk);
116 }
117 results[i] = Some(msg);
118 }
119 Err(join_err) => {
120 panicked = true;
121 let err_msg = lellm_core::Message::tool_result(
122 call,
123 &Err(lellm_core::ToolError {
124 kind: lellm_core::ToolErrorKind::Internal,
125 message: format!("tool task panicked: {join_err}"),
126 }),
127 );
128 ctx.emit(StreamChunk::ToolLifecycle {
129 phase: ToolPhase::Finished,
130 call_id: call.id.clone(),
131 tool_name: call.name.clone(),
132 });
133 if let Some(chunk) =
134 tool_output_chunk(&err_msg, &call.id, &call.name, std::time::Duration::ZERO)
135 {
136 ctx.emit(chunk);
137 }
138 results[i] = Some(err_msg);
139 }
140 }
141 }
142
143 if panicked {
144 tracing::warn!("tool batch task panicked — error results filled");
145 }
146
147 ctx.record(AgentMutation::AppendMessages(
149 results.into_iter().flatten().collect(),
150 ));
151
152 tracing::debug!(tool_calls = tool_calls.len(), "tool execution completed");
153
154 Ok(())
155 }
156}
157
158fn apply_budget_truncate(msg: lellm_core::Message, budget: &ContextBudget) -> lellm_core::Message {
160 if let lellm_core::Message::ToolResult {
161 ref tool_call_id,
162 is_error: false,
163 ref content,
164 } = msg
165 {
166 let truncated = budget.truncate_tool_result_blocks(content);
167 if truncated != *content {
168 return lellm_core::Message::ToolResult {
169 tool_call_id: tool_call_id.clone(),
170 is_error: false,
171 content: truncated,
172 };
173 }
174 }
175 msg
176}
177
178fn tool_output_chunk(
180 msg: &lellm_core::Message,
181 call_id: &str,
182 tool_name: &str,
183 duration: std::time::Duration,
184) -> Option<lellm_graph::StreamChunk> {
185 if let lellm_core::Message::ToolResult {
186 content, is_error, ..
187 } = msg
188 {
189 let content_str: String = content
190 .iter()
191 .filter_map(|b| match b {
192 lellm_core::ContentBlock::Text(t) => Some(t.text.clone()),
193 lellm_core::ContentBlock::Image { .. }
194 | lellm_core::ContentBlock::Thinking(_)
195 | lellm_core::ContentBlock::ToolCall(_) => None,
196 })
197 .collect::<Vec<_>>()
198 .join("");
199 Some(lellm_graph::StreamChunk::ToolOutput {
200 call_id: call_id.to_string(),
201 tool_name: tool_name.to_string(),
202 content: content_str,
203 is_error: *is_error,
204 duration,
205 })
206 } else {
207 None
208 }
209}