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, execute_batch_with};
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::Text(token.clone()));
current_text.push_str(&token);
}
Ok(ProviderEvent::ThinkingDelta { thinking, .. }) => {
ctx.emit(lellm_graph::StreamChunk::Thinking(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> {
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(());
}
let batch =
execute_batch_with(&tool_calls, &round.snapshot, &self.executor.retry_policy()).await;
if batch.panicked {
tracing::warn!("tool batch task panicked — error results filled in by executor");
}
let results: Vec<Message> = batch
.results
.into_iter()
.map(|m| {
if let Message::ToolResult {
ref tool_call_id,
is_error: false,
ref content,
} = m
{
let truncated = self
.config
.context_budget
.truncate_tool_result_blocks(content);
if truncated != *content {
return Message::ToolResult {
tool_call_id: tool_call_id.clone(),
is_error: false,
content: truncated,
};
}
}
m
})
.collect();
for result in &results {
if let Message::ToolResult {
tool_call_id,
content,
is_error,
} = result
{
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("");
ctx.emit(lellm_graph::StreamChunk::ToolResult {
id: tool_call_id.clone(),
content: content_str,
is_error: *is_error,
});
}
}
ctx.emit_effect(AgentEffect::AppendMessages(results));
tracing::debug!(tool_calls = tool_calls.len(), "tool execution completed");
Ok(())
}
}
#[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")
}