Skip to main content

lellm_agent/runtime/
react.rs

1//! ReAct Graph — ToolUseLoop 内部构建的有环图。
2//!
3//! v04 设计:ToolUseLoop 内部不再手写 while 循环,
4//! 构建内部 Graph(LLM Node → Condition → Tool Node → 自环),
5//! 调用 `Graph::run_inline()` 驱动循环。
6//!
7//! v0.4+ Typed State: 节点使用 `AgentState` 替代 `HashMap<String, Value>`,
8//! 通过 `AgentEffect` 描述状态转换。
9//!
10//! ```text
11//! [LLM] --有tool_calls--> [Tool] --(自环)--> [LLM]
12//!      --无tool_calls--> [End]
13//! ```
14
15use 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
34/// 分离 output / reasoning token
35fn 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
50/// 估算单轮响应中的推理 Token 数。
51fn 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// ─── LLMNode ──────────────────────────────────────────────────
62
63/// LLM 调用节点 — 执行单次 LLM 调用。
64///
65/// **职责单一:** 只负责"调用 LLM + 收集流式响应 + emit Effects"。
66/// 不感知 Budget、Compaction、Iteration Limit 等运行时策略。
67///
68/// # Typed State
69///
70/// 从 ctx 获取 `AgentState`,直接操作 typed 字段,写回 ctx。
71#[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        // 1. 获取 AgentState
102        let state = ctx.state().clone();
103
104        // 2. Emit 迭代递增 Effect
105        ctx.emit_effect(AgentEffect::IncrementIteration);
106
107        // 3. 获取工具定义 & 构建 LLM 请求
108        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        // 4. 执行 LLM 流式调用
120        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        // 收集流式事件,构建完整的 ChatResponse
128        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        // 构建完整的 ChatResponse
168        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        // 5. 分离 output / reasoning token,Emit Token Effects
188        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        // 6. Emit 消息追加 Effect
193        let content = response.content.clone();
194        let msg = Message::Assistant { content };
195        ctx.emit_effect(AgentEffect::AppendMessage(msg));
196
197        // 7. 记录 tool_calls
198        let has_tools = response.has_tool_calls();
199        if has_tools {
200            ctx.emit_effect(AgentEffect::AddToolCalls(tool_calls_count));
201        }
202
203        // 8. Emit LastResponse(供 PostLLMGuard 检查)
204        ctx.emit_effect(AgentEffect::SetLastResponse(response));
205
206        // 路由决策全部交给 PostLLMGuard,此处不调用 ctx.goto()/ctx.end()
207        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// ─── ToolNode ─────────────────────────────────────────────────
218
219/// 工具执行节点 — 读取 tool_calls,执行工具,写入 results。
220///
221/// # Typed State
222///
223/// 从 ctx 获取 `AgentState`,执行工具,追加结果到消息历史。
224#[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        // 1. 获取工具调用
245        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        // 2. 执行工具
255        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        // 3. 应用预算截断
263        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        // 4. StreamChunk emit (v04 #1) — 工具执行结果
290        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        // 5. Emit 消息追加 Effect(不直接改 state)
316        // context_tokens 由 estimated_context_tokens() 实时派生,无需手动累加
317        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// ─── StopConfig ───────────────────────────────────────────────
326
327/// 终止条件配置 — 从 LLMNode 提取的运行时策略。
328///
329/// 由 PostLLMGuard 持有,LLMNode 完全不知道这些概念。
330#[derive(Debug, Clone)]
331pub struct StopConfig {
332    /// 最大迭代轮次
333    pub max_iterations: usize,
334    /// 单轮推理 Token 上限
335    pub max_reasoning_tokens: Option<u32>,
336    /// 总输出 Token 上限
337    pub max_total_output_tokens: Option<u32>,
338    /// 总推理 Token 上限
339    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
353// ─── PostLLMGuard ─────────────────────────────────────────────
354
355/// LLM 调用后的后置检查节点 — 统一处理所有终止条件与路由。
356///
357/// 从 `LLMNode` 提取的全部运行时策略:
358/// - 最大迭代检查
359/// - Budget 检查(单轮推理 / 总输出 / 总推理)
360/// - 路由决策(Tool / End)
361///
362/// 检查顺序:
363/// 1. 已终止(stop_reason 已设置)→ End
364/// 2. 超过最大迭代 → End
365/// 3. 单轮推理超限 → End
366/// 4. 总输出超限 → End
367/// 5. 总推理超限 → End
368/// 6. 有 tool_calls → Goto("tool")
369/// 7. 无 tool_calls → End(正常完成)
370pub 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        // 1. 已终止(前置节点已设置 stop_reason)→ End
390        if state.stop_reason.is_some() {
391            ctx.end();
392            return Ok(());
393        }
394
395        // 2. 超过最大迭代 → End
396        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        // 3-5. Budget 检查(优先级:单轮推理 > 总输出 > 总推理)
403        let last_response = state.last_response.clone().unwrap_or_else(empty_response);
404        let mut stopped = false;
405
406        // 3. 单轮推理 Token 超限
407        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        // 4. 总输出 Token 超限
423        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        // 5. 总推理 Token 超限
429        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        // 6. 有 tool_calls → 去执行工具
441        if last_response.has_tool_calls() {
442            ctx.goto("tool");
443            return Ok(());
444        }
445
446        // 7. 无 tool_calls → 正常完成
447        ctx.emit_effect(AgentEffect::SetStopReason(StopReason::Complete));
448        ctx.end();
449
450        Ok(())
451    }
452}
453
454// ─── CompactorNode ────────────────────────────────────────────
455
456/// 上下文压缩节点 — 独立 FlowNode,职责单一。
457///
458/// # Typed State
459///
460/// 从 AgentState 获取消息历史,压缩后替换。
461#[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        // 只有实际压缩了才 emit Effects
494        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
511// ─── BudgetCondition ──────────────────────────────────────────
512
513/// 预算条件节点 — 检查 Token 预算,决定是否进入 Compactor。
514///
515/// 预算充足 → Goto("llm")
516/// 需要压缩 → Goto("compactor")
517pub 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
546// ─── build_react_graph ────────────────────────────────────────
547
548/// 构建 ReAct 内部图。
549///
550/// ```text
551/// START → budget_check
552///
553/// budget_check --budget_ok--> [llm]
554///          --need_compact--> [compactor] → [llm]
555///
556/// [llm] → [post_llm_check]
557///    --budget_exceeded--> [end]
558///    --has_tool_calls--> [tool] → [budget_check] (循环)
559///    --no_tool_calls--> [end]
560/// ```
561///
562/// 使用 `Graph<AgentState>` — 节点直接读写强类型 AgentState,零序列化。
563pub 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    // 节点注册
578    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    // End 节点 — no-op 终端节点
596    builder.node(
597        "end",
598        NodeKind::Task(TaskNode::<AgentState>::new("end", |_| Ok(()))),
599    );
600
601    // 注意:以下 edges 仅用于静态分析(analyze/diagnostics),运行时不使用。
602    // 条件节点通过 ctx.goto()/ctx.end() 控制路由,NextAction::Goto 优先于 edge 解析。
603    //
604    // 静态边与运行时路由的对应关系:
605    //   budget_check → llm          (BudgetCondition: 预算充足时 goto("llm"))
606    //   budget_check → compactor    (BudgetCondition: 需要压缩时 goto("compactor"))
607    //   compactor → llm             (CompactorNode: 压缩后走下一步,无显式 goto)
608    //   llm → post_llm_check        (LLMNode: 调用完走下一步,无显式 goto)
609    //   post_llm_check → tool       (PostLLMGuard: 有 tool_calls 时 goto("tool"))
610    //   post_llm_check → end        (PostLLMGuard: 无 tool_calls 或 budget 超限时 end())
611    //   tool → budget_check         (ToolNode: 执行完走下一步,无显式 goto)
612    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}