lellm_agent/runtime/react/
guards.rs1use 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
18fn 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#[derive(Debug, Clone)]
35pub struct StopConfig {
36 pub max_iterations: usize,
38 pub max_reasoning_tokens: Option<u32>,
40 pub max_total_output_tokens: Option<u32>,
42 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
57pub 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 if state.stop_reason.is_some() {
90 ctx.end();
91 return Ok(());
92 }
93
94 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 let last_response = state.last_response.clone().unwrap_or_else(empty_response);
105 let mut stopped = false;
106
107 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 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 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 if last_response.has_tool_calls() {
145 ctx.goto("tool");
146 return Ok(());
147 }
148
149 ctx.record(AgentMutation::SetStopReason(StopReason::Complete));
151 ctx.end();
152
153 Ok(())
154 }
155}
156
157#[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 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
210pub 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}