1use std::sync::Arc;
16
17use async_trait::async_trait;
18use futures_util::StreamExt;
19
20use lellm_core::{ChatResponse, ContentBlock, Message, TextBlock, ThinkingBlock, ToolCall};
21use lellm_graph::{
22 FlowNode, Graph, GraphBuilder, GraphError, NodeContext, NodeKind, TaskNode, TerminalError,
23};
24use lellm_provider::ProviderEvent;
25
26use super::config::{ToolUseConfig, ToolUseDeps, build_request_inner_with_round, empty_response};
27use super::context::{ContextBudget, ContextCompactor, estimate_reasoning_block, estimate_text};
28use super::event::StopReason;
29use super::runtime::ResolvedRound;
30use super::tools::{ToolExecutor, execute_batch_with};
31use super::typed_state::{AgentEffect, AgentState, AgentStateMerge};
32use lellm_provider::ResolvedModel;
33
34fn split_output_tokens(content: &[lellm_core::ContentBlock]) -> (usize, usize) {
36 let mut output_tokens: usize = 0;
37 let mut reasoning_tokens: usize = 0;
38 for b in content {
39 match b {
40 lellm_core::ContentBlock::Text(t) => output_tokens += estimate_text(&t.text),
41 lellm_core::ContentBlock::Thinking(th) => {
42 reasoning_tokens += estimate_reasoning_block(th)
43 }
44 lellm_core::ContentBlock::Image { .. } | lellm_core::ContentBlock::ToolCall(_) => {}
45 }
46 }
47 (output_tokens, reasoning_tokens)
48}
49
50fn estimate_round_reasoning_tokens(content: &[ContentBlock]) -> usize {
52 content
53 .iter()
54 .filter_map(|b| match b {
55 ContentBlock::Thinking(th) => Some(estimate_reasoning_block(th)),
56 _ => None,
57 })
58 .sum()
59}
60
61#[derive(Clone)]
72pub struct LLMNode {
73 pub name: String,
74 pub model: ResolvedModel,
75 pub executor: ToolExecutor,
76 pub config: ToolUseConfig,
77 pub deps: ToolUseDeps,
78}
79
80impl LLMNode {
81 pub fn new(
82 name: impl Into<String>,
83 model: ResolvedModel,
84 executor: ToolExecutor,
85 config: ToolUseConfig,
86 deps: ToolUseDeps,
87 ) -> Self {
88 Self {
89 name: name.into(),
90 model,
91 executor,
92 config,
93 deps,
94 }
95 }
96}
97
98#[async_trait]
99impl FlowNode<AgentState> for LLMNode {
100 async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
101 let state = ctx.state().clone();
103
104 ctx.emit_effect(AgentEffect::IncrementIteration);
106
107 let round = ResolvedRound::new(self.executor.snapshot().await);
109
110 let req = build_request_inner_with_round(
111 &self.model,
112 &state.messages,
113 self.config.max_output_tokens,
114 &self.config.request_options,
115 state.iterations + 1,
116 &round.definitions,
117 );
118
119 let mut stream = self.model.provider.stream(&req).await.map_err(|e| {
121 GraphError::Terminal(TerminalError::NodeExecutionFailed {
122 node: self.name.clone(),
123 source: e.into(),
124 })
125 })?;
126
127 let mut content_blocks: Vec<ContentBlock> = Vec::new();
129 let mut current_text = String::new();
130 let mut current_thinking = String::new();
131 let mut tool_calls_count: usize = 0;
132 let mut usage: Option<lellm_core::TokenUsage> = None;
133
134 while let Some(event) = stream.next().await {
135 match event {
136 Ok(ProviderEvent::Token { token }) => {
137 ctx.emit(lellm_graph::StreamChunk::Text(token.clone()));
138 current_text.push_str(&token);
139 }
140 Ok(ProviderEvent::ThinkingDelta { thinking, .. }) => {
141 ctx.emit(lellm_graph::StreamChunk::Thinking(thinking.clone()));
142 current_thinking.push_str(&thinking);
143 }
144 Ok(ProviderEvent::ResponseComplete {
145 tool_calls,
146 usage: u,
147 }) => {
148 for tc in tool_calls {
149 content_blocks.push(ContentBlock::ToolCall(tc));
150 }
151 tool_calls_count = content_blocks
152 .iter()
153 .filter(|b| matches!(b, ContentBlock::ToolCall(_)))
154 .count();
155 usage = u;
156 }
157 Ok(_) => {}
158 Err(e) => {
159 return Err(GraphError::Terminal(TerminalError::NodeExecutionFailed {
160 node: self.name.clone(),
161 source: e.into(),
162 }));
163 }
164 }
165 }
166
167 if !current_thinking.is_empty() {
169 content_blocks.push(ContentBlock::Thinking(ThinkingBlock {
170 thinking: current_thinking,
171 redacted: None,
172 }));
173 }
174 if !current_text.is_empty() {
175 content_blocks.push(ContentBlock::Text(TextBlock {
176 text: current_text,
177 cache_control: None,
178 }));
179 }
180
181 let response = ChatResponse {
182 content: content_blocks,
183 usage: usage.unwrap_or_default(),
184 raw: serde_json::json!(null),
185 };
186
187 let (output_tokens, reasoning_tokens) = split_output_tokens(&response.content);
189 ctx.emit_effect(AgentEffect::AddOutputTokens(output_tokens));
190 ctx.emit_effect(AgentEffect::AddReasoningTokens(reasoning_tokens));
191
192 let content = response.content.clone();
194 let msg = Message::Assistant { content };
195 ctx.emit_effect(AgentEffect::AppendMessage(msg));
196
197 let has_tools = response.has_tool_calls();
199 if has_tools {
200 ctx.emit_effect(AgentEffect::AddToolCalls(tool_calls_count));
201 }
202
203 ctx.emit_effect(AgentEffect::SetLastResponse(response));
205
206 tracing::debug!(
208 iteration = state.iterations + 1,
209 has_tool_calls = has_tools,
210 "LLM call completed"
211 );
212
213 Ok(())
214 }
215}
216
217#[derive(Clone)]
225pub struct ToolNode {
226 pub name: String,
227 pub executor: ToolExecutor,
228 pub config: ToolUseConfig,
229}
230
231impl ToolNode {
232 pub fn new(name: impl Into<String>, executor: ToolExecutor, config: ToolUseConfig) -> Self {
233 Self {
234 name: name.into(),
235 executor,
236 config,
237 }
238 }
239}
240
241#[async_trait]
242impl FlowNode<AgentState> for ToolNode {
243 async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
244 let round = ResolvedRound::new(self.executor.snapshot().await);
246 let state = ctx.state().clone();
247 let last_response = state.last_response.unwrap_or_else(empty_response);
248 let tool_calls: Vec<ToolCall> = last_response.tool_calls().cloned().collect();
249
250 if tool_calls.is_empty() {
251 return Ok(());
252 }
253
254 let batch =
256 execute_batch_with(&tool_calls, &round.snapshot, &self.executor.retry_policy()).await;
257
258 if batch.panicked {
259 tracing::warn!("tool batch task panicked — error results filled in by executor");
260 }
261
262 let results: Vec<Message> = batch
264 .results
265 .into_iter()
266 .map(|m| {
267 if let Message::ToolResult {
268 ref tool_call_id,
269 is_error: false,
270 ref content,
271 } = m
272 {
273 let truncated = self
274 .config
275 .context_budget
276 .truncate_tool_result_blocks(content);
277 if truncated != *content {
278 return Message::ToolResult {
279 tool_call_id: tool_call_id.clone(),
280 is_error: false,
281 content: truncated,
282 };
283 }
284 }
285 m
286 })
287 .collect();
288
289 for result in &results {
291 if let Message::ToolResult {
292 tool_call_id,
293 content,
294 is_error,
295 } = result
296 {
297 let content_str: String = content
298 .iter()
299 .filter_map(|b| match b {
300 lellm_core::ContentBlock::Text(t) => Some(t.text.clone()),
301 lellm_core::ContentBlock::Image { .. }
302 | lellm_core::ContentBlock::Thinking(_)
303 | lellm_core::ContentBlock::ToolCall(_) => None,
304 })
305 .collect::<Vec<_>>()
306 .join("");
307 ctx.emit(lellm_graph::StreamChunk::ToolResult {
308 id: tool_call_id.clone(),
309 content: content_str,
310 is_error: *is_error,
311 });
312 }
313 }
314
315 ctx.emit_effect(AgentEffect::AppendMessages(results));
318
319 tracing::debug!(tool_calls = tool_calls.len(), "tool execution completed");
320
321 Ok(())
322 }
323}
324
325#[derive(Debug, Clone)]
331pub struct StopConfig {
332 pub max_iterations: usize,
334 pub max_reasoning_tokens: Option<u32>,
336 pub max_total_output_tokens: Option<u32>,
338 pub max_total_reasoning_tokens: Option<u32>,
340}
341
342impl StopConfig {
343 pub fn from_tool_use_config(config: &ToolUseConfig) -> Self {
344 Self {
345 max_iterations: config.max_iterations,
346 max_reasoning_tokens: config.request_options.max_reasoning_tokens,
347 max_total_output_tokens: config.max_total_output_tokens,
348 max_total_reasoning_tokens: config.max_total_reasoning_tokens,
349 }
350 }
351}
352
353pub struct PostLLMGuard {
371 pub name: String,
372 pub stop_config: StopConfig,
373}
374
375impl PostLLMGuard {
376 pub fn new(name: impl Into<String>, stop_config: StopConfig) -> Self {
377 Self {
378 name: name.into(),
379 stop_config,
380 }
381 }
382}
383
384#[async_trait]
385impl FlowNode<AgentState> for PostLLMGuard {
386 async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
387 let state = ctx.state().clone();
388
389 if state.stop_reason.is_some() {
391 ctx.end();
392 return Ok(());
393 }
394
395 if state.reached_max(self.stop_config.max_iterations) {
397 ctx.emit_effect(AgentEffect::SetStopReason(StopReason::MaxIterationsReached));
398 ctx.end();
399 return Ok(());
400 }
401
402 let last_response = state.last_response.clone().unwrap_or_else(empty_response);
404 let mut stopped = false;
405
406 if let Some(limit) = self.stop_config.max_reasoning_tokens {
408 let round_reasoning = estimate_round_reasoning_tokens(&last_response.content);
409 if round_reasoning > limit as usize {
410 tracing::warn!(
411 round_reasoning,
412 max_reasoning_tokens = limit,
413 "single-round reasoning budget exceeded"
414 );
415 ctx.emit_effect(AgentEffect::SetStopReason(
416 StopReason::ReasoningBudgetExceeded,
417 ));
418 stopped = true;
419 }
420 }
421
422 if !stopped && state.exceeded_output(self.stop_config.max_total_output_tokens) {
424 ctx.emit_effect(AgentEffect::SetStopReason(StopReason::OutputBudgetExceeded));
425 stopped = true;
426 }
427
428 if !stopped && state.exceeded_reasoning(self.stop_config.max_total_reasoning_tokens) {
430 ctx.emit_effect(AgentEffect::SetStopReason(
431 StopReason::ReasoningBudgetExceeded,
432 ));
433 }
434
435 if stopped {
436 ctx.end();
437 return Ok(());
438 }
439
440 if last_response.has_tool_calls() {
442 ctx.goto("tool");
443 return Ok(());
444 }
445
446 ctx.emit_effect(AgentEffect::SetStopReason(StopReason::Complete));
448 ctx.end();
449
450 Ok(())
451 }
452}
453
454#[derive(Clone)]
462pub struct CompactorNode {
463 pub name: String,
464 pub compactor: Arc<dyn ContextCompactor>,
465 pub budget: ContextBudget,
466}
467
468impl CompactorNode {
469 pub fn new(
470 name: impl Into<String>,
471 compactor: Arc<dyn ContextCompactor>,
472 budget: ContextBudget,
473 ) -> Self {
474 Self {
475 name: name.into(),
476 compactor,
477 budget,
478 }
479 }
480}
481
482#[async_trait]
483impl FlowNode<AgentState> for CompactorNode {
484 async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
485 let state = ctx.state();
486
487 if !self.budget.should_compact(state.estimated_context_tokens()) {
488 return Ok(());
489 }
490
491 let result = self.compactor.compact(&state.messages, &self.budget);
492
493 if result.removed_messages > 0 {
495 ctx.emit_effect(AgentEffect::ReplaceMessages(result.messages));
496 ctx.emit_effect(AgentEffect::IncrementCompactCount);
497
498 tracing::debug!(
499 agent = %self.name,
500 before_tokens = result.before_tokens,
501 after_tokens = result.after_tokens,
502 removed = result.removed_messages,
503 "context compacted"
504 );
505 }
506
507 Ok(())
508 }
509}
510
511pub struct BudgetCondition {
518 pub name: String,
519 pub budget: ContextBudget,
520}
521
522impl BudgetCondition {
523 pub fn new(name: impl Into<String>, budget: ContextBudget) -> Self {
524 Self {
525 name: name.into(),
526 budget,
527 }
528 }
529}
530
531#[async_trait]
532impl FlowNode<AgentState> for BudgetCondition {
533 async fn execute(&self, ctx: &mut NodeContext<'_, AgentState>) -> Result<(), GraphError> {
534 let state = ctx.state();
535
536 if self.budget.should_compact(state.estimated_context_tokens()) {
537 ctx.goto("compactor");
538 } else {
539 ctx.goto("llm");
540 }
541
542 Ok(())
543 }
544}
545
546pub fn build_react_graph(
564 llm_node: LLMNode,
565 tool_node: ToolNode,
566 compactor_node: CompactorNode,
567) -> Graph<AgentState, AgentStateMerge> {
568 let llm_name = llm_node.name.clone();
569 let budget = llm_node.config.context_budget.clone();
570 let stop_config = StopConfig::from_tool_use_config(&llm_node.config);
571
572 let mut builder =
573 GraphBuilder::<AgentState, AgentStateMerge>::new(format!("react_{}", llm_name));
574 builder.start("budget_check");
575 builder.end("end");
576
577 builder.node("llm", NodeKind::External(Arc::new(llm_node)));
579 builder.node("tool", NodeKind::External(Arc::new(tool_node)));
580 builder.node(
581 "post_llm_check",
582 NodeKind::External(Arc::new(PostLLMGuard::new(
583 format!("{}_post_llm", llm_name),
584 stop_config,
585 ))),
586 );
587 builder.node(
588 "budget_check",
589 NodeKind::External(Arc::new(BudgetCondition::new(
590 format!("{}_budget", llm_name),
591 budget,
592 ))),
593 );
594 builder.node("compactor", NodeKind::External(Arc::new(compactor_node)));
595 builder.node(
597 "end",
598 NodeKind::Task(TaskNode::<AgentState>::new("end", |_| Ok(()))),
599 );
600
601 builder.edge("budget_check", "llm");
613 builder.edge_fallback("budget_check", "compactor");
614 builder.edge("compactor", "llm");
615 builder.edge("llm", "post_llm_check");
616 builder.edge("post_llm_check", "tool");
617 builder.edge_fallback("post_llm_check", "end");
618 builder.edge("tool", "budget_check");
619
620 builder.build().expect("ReAct graph should be valid")
621}