use async_trait::async_trait;
use lellm_core::ToolCall;
use lellm_graph::{GraphError, LeafContext, LeafNode};
use super::super::config::{ToolUseConfig, empty_response};
use super::super::context::ContextBudget;
use super::super::runtime::ResolvedRound;
use super::super::tools::ToolExecutor;
use super::super::typed_state::{AgentMutation, AgentState};
#[derive(Clone)]
pub struct ToolNode {
pub name: String,
pub executor: ToolExecutor,
pub config: ToolUseConfig,
}
impl ToolNode {
pub fn new(name: impl Into<String>, executor: ToolExecutor, config: ToolUseConfig) -> Self {
Self {
name: name.into(),
executor,
config,
}
}
}
#[async_trait]
impl LeafNode<AgentState> for ToolNode {
async fn execute(&self, ctx: &mut LeafContext<'_, AgentState>) -> Result<(), GraphError> {
use lellm_graph::{StreamChunk, ToolPhase};
let round = ResolvedRound::new(self.executor.snapshot().await);
let state = ctx.state().clone();
let last_response = state.last_response.unwrap_or_else(empty_response);
let tool_calls: Vec<ToolCall> = last_response.tool_calls().cloned().collect();
if tool_calls.is_empty() {
return Ok(());
}
for call in &tool_calls {
ctx.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Queued,
call_id: call.id.clone(),
tool_name: call.name.clone(),
});
ctx.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Started,
call_id: call.id.clone(),
tool_name: call.name.clone(),
});
}
let retry_policy = self.executor.retry_policy().clone();
let snapshot = round.snapshot.clone();
let budget = self.config.context_budget.clone();
let mut handles = Vec::with_capacity(tool_calls.len());
for call in &tool_calls {
let entry = snapshot.get(&call.name).cloned();
let rp = retry_policy.clone();
let call_clone = call.clone();
let budget_clone = budget.clone();
handles.push(tokio::spawn(async move {
let start = std::time::Instant::now();
let result: lellm_core::ToolResult = match entry {
Some(reg) => {
rp.execute_with_retry(®.func, &call_clone.arguments)
.await
}
None => Err(lellm_core::ToolError::not_found(format!(
"unknown tool: {}",
call_clone.name
))),
};
let duration = start.elapsed();
let msg = lellm_core::Message::tool_result(&call_clone, &result);
let msg = apply_budget_truncate(msg, &budget_clone);
(msg, duration)
}));
}
let mut results: Vec<Option<lellm_core::Message>> = vec![None; tool_calls.len()];
let mut panicked = false;
let collect = futures_util::future::join_all(handles).await;
for (i, (call, join_result)) in tool_calls.iter().zip(collect).enumerate() {
match join_result {
Ok((msg, duration)) => {
ctx.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Finished,
call_id: call.id.clone(),
tool_name: call.name.clone(),
});
if let Some(chunk) = tool_output_chunk(&msg, &call.id, &call.name, duration) {
ctx.emit(chunk);
}
results[i] = Some(msg);
}
Err(join_err) => {
panicked = true;
let err_msg = lellm_core::Message::tool_result(
call,
&Err(lellm_core::ToolError {
kind: lellm_core::ToolErrorKind::Internal,
message: format!("tool task panicked: {join_err}"),
}),
);
ctx.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Finished,
call_id: call.id.clone(),
tool_name: call.name.clone(),
});
if let Some(chunk) =
tool_output_chunk(&err_msg, &call.id, &call.name, std::time::Duration::ZERO)
{
ctx.emit(chunk);
}
results[i] = Some(err_msg);
}
}
}
if panicked {
tracing::warn!("tool batch task panicked — error results filled");
}
ctx.record(AgentMutation::AppendMessages(
results.into_iter().flatten().collect(),
));
tracing::debug!(tool_calls = tool_calls.len(), "tool execution completed");
Ok(())
}
}
fn apply_budget_truncate(msg: lellm_core::Message, budget: &ContextBudget) -> lellm_core::Message {
if let lellm_core::Message::ToolResult {
ref tool_call_id,
is_error: false,
ref content,
} = msg
{
let truncated = budget.truncate_tool_result_blocks(content);
if truncated != *content {
return lellm_core::Message::ToolResult {
tool_call_id: tool_call_id.clone(),
is_error: false,
content: truncated,
};
}
}
msg
}
fn tool_output_chunk(
msg: &lellm_core::Message,
call_id: &str,
tool_name: &str,
duration: std::time::Duration,
) -> Option<lellm_graph::StreamChunk> {
if let lellm_core::Message::ToolResult {
content, is_error, ..
} = msg
{
let content_str: String = content
.iter()
.filter_map(|b| match b {
lellm_core::ContentBlock::Text(t) => Some(t.text.clone()),
lellm_core::ContentBlock::Image { .. }
| lellm_core::ContentBlock::Thinking(_)
| lellm_core::ContentBlock::ToolCall(_) => None,
})
.collect::<Vec<_>>()
.join("");
Some(lellm_graph::StreamChunk::ToolOutput {
call_id: call_id.to_string(),
tool_name: tool_name.to_string(),
content: content_str,
is_error: *is_error,
duration,
})
} else {
None
}
}