use std::sync::Arc;
use async_trait::async_trait;
use futures_util::StreamExt;
use lellm_core::{ChatResponse, ContentBlock, Message, TextBlock, ThinkingBlock, ToolCall};
use lellm_graph::{
FlowNode, Graph, GraphBuilder, GraphError, NodeContext, NodeKind, TaskNode, TerminalError,
};
use lellm_provider::ProviderEvent;
use super::config::{ToolUseConfig, ToolUseDeps, build_request_inner_with_round, empty_response};
use super::context::{ContextBudget, ContextCompactor, estimate_reasoning_block, estimate_text};
use super::event::StopReason;
use super::runtime::ResolvedRound;
use super::tools::ToolExecutor;
use super::typed_state::{AgentEffect, AgentState, AgentStateMerge};
use lellm_provider::ResolvedModel;
fn split_output_tokens(content: &[lellm_core::ContentBlock]) -> (usize, usize) {
let mut output_tokens: usize = 0;
let mut reasoning_tokens: usize = 0;
for b in content {
match b {
lellm_core::ContentBlock::Text(t) => output_tokens += estimate_text(&t.text),
lellm_core::ContentBlock::Thinking(th) => {
reasoning_tokens += estimate_reasoning_block(th)
}
lellm_core::ContentBlock::Image { .. } | lellm_core::ContentBlock::ToolCall(_) => {}
}
}
(output_tokens, reasoning_tokens)
}
fn estimate_round_reasoning_tokens(content: &[ContentBlock]) -> usize {
content
.iter()
.filter_map(|b| match b {
ContentBlock::Thinking(th) => Some(estimate_reasoning_block(th)),
_ => None,
})
.sum()
}
#[derive(Clone)]
pub struct LLMNode {
pub name: String,
pub model: ResolvedModel,
pub executor: ToolExecutor,
pub config: ToolUseConfig,
pub deps: ToolUseDeps,
}
impl LLMNode {
pub fn new(
name: impl Into<String>,
model: ResolvedModel,
executor: ToolExecutor,
config: ToolUseConfig,
deps: ToolUseDeps,
) -> Self {
Self {
name: name.into(),
model,
executor,
config,
deps,
}
}
}
#[async_trait]
impl FlowNode<AgentState> for LLMNode {
async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
let state = ctx.state().clone();
ctx.emit_effect(AgentEffect::IncrementIteration);
let round = ResolvedRound::new(self.executor.snapshot().await);
let req = build_request_inner_with_round(
&self.model,
&state.messages,
self.config.max_output_tokens,
&self.config.request_options,
state.iterations + 1,
&round.definitions,
);
let mut stream = self.model.provider.stream(&req).await.map_err(|e| {
GraphError::Terminal(TerminalError::NodeExecutionFailed {
node: self.name.clone(),
source: e.into(),
})
})?;
let mut content_blocks: Vec<ContentBlock> = Vec::new();
let mut current_text = String::new();
let mut current_thinking = String::new();
let mut tool_calls_count: usize = 0;
let mut usage: Option<lellm_core::TokenUsage> = None;
while let Some(event) = stream.next().await {
match event {
Ok(ProviderEvent::Token { token }) => {
ctx.emit(lellm_graph::StreamChunk::TextDelta(token.clone()));
current_text.push_str(&token);
}
Ok(ProviderEvent::ThinkingDelta { thinking, .. }) => {
ctx.emit(lellm_graph::StreamChunk::ThinkingDelta(thinking.clone()));
current_thinking.push_str(&thinking);
}
Ok(ProviderEvent::ResponseComplete {
tool_calls,
usage: u,
}) => {
for tc in tool_calls {
content_blocks.push(ContentBlock::ToolCall(tc));
}
tool_calls_count = content_blocks
.iter()
.filter(|b| matches!(b, ContentBlock::ToolCall(_)))
.count();
usage = u;
}
Ok(_) => {}
Err(e) => {
return Err(GraphError::Terminal(TerminalError::NodeExecutionFailed {
node: self.name.clone(),
source: e.into(),
}));
}
}
}
if !current_thinking.is_empty() {
content_blocks.push(ContentBlock::Thinking(ThinkingBlock {
thinking: current_thinking,
redacted: None,
}));
}
if !current_text.is_empty() {
content_blocks.push(ContentBlock::Text(TextBlock {
text: current_text,
cache_control: None,
}));
}
let response = ChatResponse {
content: content_blocks,
usage: usage.unwrap_or_default(),
raw: serde_json::json!(null),
};
let (output_tokens, reasoning_tokens) = split_output_tokens(&response.content);
ctx.emit_effect(AgentEffect::AddOutputTokens(output_tokens));
ctx.emit_effect(AgentEffect::AddReasoningTokens(reasoning_tokens));
let content = response.content.clone();
let msg = Message::Assistant { content };
ctx.emit_effect(AgentEffect::AppendMessage(msg));
let has_tools = response.has_tool_calls();
if has_tools {
ctx.emit_effect(AgentEffect::AddToolCalls(tool_calls_count));
}
ctx.emit_effect(AgentEffect::SetLastResponse(response));
tracing::debug!(
iteration = state.iterations + 1,
has_tool_calls = has_tools,
"LLM call completed"
);
Ok(())
}
}
#[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 FlowNode<AgentState> for ToolNode {
async fn execute(&self, ctx: &mut NodeContext<'_, 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 = Message::tool_result(&call_clone, &result);
let msg = apply_budget_truncate(msg, &budget_clone);
(msg, duration)
}));
}
let mut results: Vec<Option<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 = 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.emit_effect(AgentEffect::AppendMessages(
results.into_iter().flatten().collect(),
));
tracing::debug!(tool_calls = tool_calls.len(), "tool execution completed");
Ok(())
}
}
fn apply_budget_truncate(msg: Message, budget: &ContextBudget) -> Message {
if let Message::ToolResult {
ref tool_call_id,
is_error: false,
ref content,
} = msg
{
let truncated = budget.truncate_tool_result_blocks(content);
if truncated != *content {
return Message::ToolResult {
tool_call_id: tool_call_id.clone(),
is_error: false,
content: truncated,
};
}
}
msg
}
fn tool_output_chunk(
msg: &Message,
call_id: &str,
tool_name: &str,
duration: std::time::Duration,
) -> Option<lellm_graph::StreamChunk> {
if let 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
}
}
#[derive(Debug, Clone)]
pub struct StopConfig {
pub max_iterations: usize,
pub max_reasoning_tokens: Option<u32>,
pub max_total_output_tokens: Option<u32>,
pub max_total_reasoning_tokens: Option<u32>,
}
impl StopConfig {
pub fn from_tool_use_config(config: &ToolUseConfig) -> Self {
Self {
max_iterations: config.max_iterations,
max_reasoning_tokens: config.request_options.max_reasoning_tokens,
max_total_output_tokens: config.max_total_output_tokens,
max_total_reasoning_tokens: config.max_total_reasoning_tokens,
}
}
}
pub struct PostLLMGuard {
pub name: String,
pub stop_config: StopConfig,
}
impl PostLLMGuard {
pub fn new(name: impl Into<String>, stop_config: StopConfig) -> Self {
Self {
name: name.into(),
stop_config,
}
}
}
#[async_trait]
impl FlowNode<AgentState> for PostLLMGuard {
async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
let state = ctx.state().clone();
if state.stop_reason.is_some() {
ctx.end();
return Ok(());
}
if state.reached_max(self.stop_config.max_iterations) {
ctx.emit_effect(AgentEffect::SetStopReason(StopReason::MaxIterationsReached));
ctx.end();
return Ok(());
}
let last_response = state.last_response.clone().unwrap_or_else(empty_response);
let mut stopped = false;
if let Some(limit) = self.stop_config.max_reasoning_tokens {
let round_reasoning = estimate_round_reasoning_tokens(&last_response.content);
if round_reasoning > limit as usize {
tracing::warn!(
round_reasoning,
max_reasoning_tokens = limit,
"single-round reasoning budget exceeded"
);
ctx.emit_effect(AgentEffect::SetStopReason(
StopReason::ReasoningBudgetExceeded,
));
stopped = true;
}
}
if !stopped && state.exceeded_output(self.stop_config.max_total_output_tokens) {
ctx.emit_effect(AgentEffect::SetStopReason(StopReason::OutputBudgetExceeded));
stopped = true;
}
if !stopped && state.exceeded_reasoning(self.stop_config.max_total_reasoning_tokens) {
ctx.emit_effect(AgentEffect::SetStopReason(
StopReason::ReasoningBudgetExceeded,
));
}
if stopped {
ctx.end();
return Ok(());
}
if last_response.has_tool_calls() {
ctx.goto("tool");
return Ok(());
}
ctx.emit_effect(AgentEffect::SetStopReason(StopReason::Complete));
ctx.end();
Ok(())
}
}
#[derive(Clone)]
pub struct CompactorNode {
pub name: String,
pub compactor: Arc<dyn ContextCompactor>,
pub budget: ContextBudget,
}
impl CompactorNode {
pub fn new(
name: impl Into<String>,
compactor: Arc<dyn ContextCompactor>,
budget: ContextBudget,
) -> Self {
Self {
name: name.into(),
compactor,
budget,
}
}
}
#[async_trait]
impl FlowNode<AgentState> for CompactorNode {
async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
let state = ctx.state();
if !self.budget.should_compact(state.estimated_context_tokens()) {
return Ok(());
}
let result = self.compactor.compact(&state.messages, &self.budget);
if result.removed_messages > 0 {
ctx.emit_effect(AgentEffect::ReplaceMessages(result.messages));
ctx.emit_effect(AgentEffect::IncrementCompactCount);
tracing::debug!(
agent = %self.name,
before_tokens = result.before_tokens,
after_tokens = result.after_tokens,
removed = result.removed_messages,
"context compacted"
);
}
Ok(())
}
}
pub struct BudgetCondition {
pub name: String,
pub budget: ContextBudget,
}
impl BudgetCondition {
pub fn new(name: impl Into<String>, budget: ContextBudget) -> Self {
Self {
name: name.into(),
budget,
}
}
}
#[async_trait]
impl FlowNode<AgentState> for BudgetCondition {
async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
let state = ctx.state();
if self.budget.should_compact(state.estimated_context_tokens()) {
ctx.goto("compactor");
} else {
ctx.goto("llm");
}
Ok(())
}
}
pub fn build_react_graph(
llm_node: LLMNode,
tool_node: ToolNode,
compactor_node: CompactorNode,
) -> Graph<AgentState, AgentStateMerge> {
let llm_name = llm_node.name.clone();
let budget = llm_node.config.context_budget.clone();
let stop_config = StopConfig::from_tool_use_config(&llm_node.config);
let mut builder =
GraphBuilder::<AgentState, AgentStateMerge>::new(format!("react_{}", llm_name));
builder.start("budget_check");
builder.end("end");
builder.node("llm", NodeKind::External(Arc::new(llm_node)));
builder.node("tool", NodeKind::External(Arc::new(tool_node)));
builder.node(
"post_llm_check",
NodeKind::External(Arc::new(PostLLMGuard::new(
format!("{}_post_llm", llm_name),
stop_config,
))),
);
builder.node(
"budget_check",
NodeKind::External(Arc::new(BudgetCondition::new(
format!("{}_budget", llm_name),
budget,
))),
);
builder.node("compactor", NodeKind::External(Arc::new(compactor_node)));
builder.node(
"end",
NodeKind::Task(TaskNode::<AgentState>::new("end", |_| Ok(()))),
);
builder.edge("budget_check", "llm");
builder.edge_fallback("budget_check", "compactor");
builder.edge("compactor", "llm");
builder.edge("llm", "post_llm_check");
builder.edge("post_llm_check", "tool");
builder.edge_fallback("post_llm_check", "end");
builder.edge("tool", "budget_check");
builder.build().expect("ReAct graph should be valid")
}