Skip to main content

lellm_agent/runtime/react/
guards.rs

1//! Guard 节点 — 终止条件检查、上下文压缩、预算判断。
2//!
3//! 包含 PostLLMGuard(从 LLMNode 提取的运行时策略)、
4//! CompactorNode(上下文压缩)、BudgetCondition(预算判断)。
5
6use std::sync::Arc;
7
8use async_trait::async_trait;
9
10use lellm_core::ContentBlock;
11use lellm_graph::{GraphError, LeafContext, LeafNode};
12
13use super::super::config::{ToolUseConfig, empty_response};
14use super::super::context::{ContextBudget, ContextCompactor, estimate_reasoning_block};
15use super::super::event::StopReason;
16use super::super::typed_state::{AgentMutation, AgentState};
17
18/// 估算单轮响应中的推理 Token 数。
19fn estimate_round_reasoning_tokens(content: &[ContentBlock]) -> usize {
20    content
21        .iter()
22        .filter_map(|b| match b {
23            ContentBlock::Thinking(th) => Some(estimate_reasoning_block(th)),
24            _ => None,
25        })
26        .sum()
27}
28
29// ─── StopConfig ───────────────────────────────────────────────
30
31/// 终止条件配置 — 从 LLMNode 提取的运行时策略。
32///
33/// 由 PostLLMGuard 持有,LLMNode 完全不知道这些概念。
34#[derive(Debug, Clone)]
35pub struct StopConfig {
36    /// 最大迭代轮次
37    pub max_iterations: usize,
38    /// 单轮推理 Token 上限
39    pub max_reasoning_tokens: Option<u32>,
40    /// 总输出 Token 上限
41    pub max_total_output_tokens: Option<u32>,
42    /// 总推理 Token 上限
43    pub max_total_reasoning_tokens: Option<u32>,
44}
45
46impl StopConfig {
47    pub fn from_tool_use_config(config: &ToolUseConfig) -> Self {
48        Self {
49            max_iterations: config.max_iterations,
50            max_reasoning_tokens: config.request_options.max_reasoning_tokens,
51            max_total_output_tokens: config.max_total_output_tokens,
52            max_total_reasoning_tokens: config.max_total_reasoning_tokens,
53        }
54    }
55}
56
57// ─── PostLLMGuard ─────────────────────────────────────────────
58
59/// LLM 调用后的后置检查节点 — 统一处理所有终止条件与路由。
60///
61/// 检查顺序:
62/// 1. 已终止(stop_reason 已设置)→ End
63/// 2. 超过最大迭代 → End
64/// 3. 单轮推理超限 → End
65/// 4. 总输出超限 → End
66/// 5. 总推理超限 → End
67/// 6. 有 tool_calls → Goto("tool")
68/// 7. 无 tool_calls → End(正常完成)
69pub struct PostLLMGuard {
70    pub name: String,
71    pub stop_config: StopConfig,
72}
73
74impl PostLLMGuard {
75    pub fn new(name: impl Into<String>, stop_config: StopConfig) -> Self {
76        Self {
77            name: name.into(),
78            stop_config,
79        }
80    }
81}
82
83#[async_trait]
84impl LeafNode<AgentState> for PostLLMGuard {
85    async fn execute(&self, ctx: &mut LeafContext<'_, AgentState>) -> Result<(), GraphError> {
86        let state = ctx.state().clone();
87
88        // 1. 已终止(前置节点已设置 stop_reason)→ End
89        if state.stop_reason.is_some() {
90            ctx.end();
91            return Ok(());
92        }
93
94        // 2. 超过最大迭代 → End
95        if state.reached_max(self.stop_config.max_iterations) {
96            ctx.record(AgentMutation::SetStopReason(
97                StopReason::MaxIterationsReached,
98            ));
99            ctx.end();
100            return Ok(());
101        }
102
103        // 3-5. Budget 检查(优先级:单轮推理 > 总输出 > 总推理)
104        let last_response = state.last_response.clone().unwrap_or_else(empty_response);
105        let mut stopped = false;
106
107        // 3. 单轮推理 Token 超限
108        if let Some(limit) = self.stop_config.max_reasoning_tokens {
109            let round_reasoning = estimate_round_reasoning_tokens(&last_response.content);
110            if round_reasoning > limit as usize {
111                tracing::warn!(
112                    round_reasoning,
113                    max_reasoning_tokens = limit,
114                    "single-round reasoning budget exceeded"
115                );
116                ctx.record(AgentMutation::SetStopReason(
117                    StopReason::ReasoningBudgetExceeded,
118                ));
119                stopped = true;
120            }
121        }
122
123        // 4. 总输出 Token 超限
124        if !stopped && state.exceeded_output(self.stop_config.max_total_output_tokens) {
125            ctx.record(AgentMutation::SetStopReason(
126                StopReason::OutputBudgetExceeded,
127            ));
128            stopped = true;
129        }
130
131        // 5. 总推理 Token 超限
132        if !stopped && state.exceeded_reasoning(self.stop_config.max_total_reasoning_tokens) {
133            ctx.record(AgentMutation::SetStopReason(
134                StopReason::ReasoningBudgetExceeded,
135            ));
136        }
137
138        if stopped {
139            ctx.end();
140            return Ok(());
141        }
142
143        // 6. 有 tool_calls → 去执行工具
144        if last_response.has_tool_calls() {
145            ctx.goto("tool");
146            return Ok(());
147        }
148
149        // 7. 无 tool_calls → 正常完成
150        ctx.record(AgentMutation::SetStopReason(StopReason::Complete));
151        ctx.end();
152
153        Ok(())
154    }
155}
156
157// ─── CompactorNode ────────────────────────────────────────────
158
159/// 上下文压缩节点 — 独立 FlowNode,职责单一。
160#[derive(Clone)]
161pub struct CompactorNode {
162    pub name: String,
163    pub compactor: Arc<dyn ContextCompactor>,
164    pub budget: ContextBudget,
165}
166
167impl CompactorNode {
168    pub fn new(
169        name: impl Into<String>,
170        compactor: Arc<dyn ContextCompactor>,
171        budget: ContextBudget,
172    ) -> Self {
173        Self {
174            name: name.into(),
175            compactor,
176            budget,
177        }
178    }
179}
180
181#[async_trait]
182impl LeafNode<AgentState> for CompactorNode {
183    async fn execute(&self, ctx: &mut LeafContext<'_, AgentState>) -> Result<(), GraphError> {
184        let state = ctx.state();
185
186        if !self.budget.should_compact(state.estimated_context_tokens()) {
187            return Ok(());
188        }
189
190        let result = self.compactor.compact(&state.messages, &self.budget);
191
192        // 只有实际压缩了才 emit Effects
193        if result.removed_messages > 0 {
194            ctx.record(AgentMutation::ReplaceMessages(result.messages));
195            ctx.record(AgentMutation::IncrementCompactCount);
196
197            tracing::debug!(
198                agent = %self.name,
199                before_tokens = result.before_tokens,
200                after_tokens = result.after_tokens,
201                removed = result.removed_messages,
202                "context compacted"
203            );
204        }
205
206        Ok(())
207    }
208}
209
210// ─── BudgetCondition ──────────────────────────────────────────
211
212/// 预算条件节点 — 检查 Token 预算,决定是否进入 Compactor。
213///
214/// 预算充足 → Goto("llm")
215/// 需要压缩 → Goto("compactor")
216pub struct BudgetCondition {
217    pub name: String,
218    pub budget: ContextBudget,
219}
220
221impl BudgetCondition {
222    pub fn new(name: impl Into<String>, budget: ContextBudget) -> Self {
223        Self {
224            name: name.into(),
225            budget,
226        }
227    }
228}
229
230#[async_trait]
231impl LeafNode<AgentState> for BudgetCondition {
232    async fn execute(&self, ctx: &mut LeafContext<'_, AgentState>) -> Result<(), GraphError> {
233        let state = ctx.state();
234
235        if self.budget.should_compact(state.estimated_context_tokens()) {
236            ctx.goto("compactor");
237        } else {
238            ctx.goto("llm");
239        }
240
241        Ok(())
242    }
243}