1use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29use tracing::warn;
30
31const DEFAULT_MAX_DEPTH: u32 = 10;
33
34const DEFAULT_MAX_COST_USD: f64 = 1.0;
36
37const DEFAULT_STEP_TIMEOUT_SECS: u64 = 60;
39
40const DEFAULT_NO_OP_LIMIT: u32 = 3;
42
43#[non_exhaustive]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum PlanningStrategy {
48 #[serde(alias = "ReAct")]
51 React,
52
53 #[serde(alias = "PlanAndExecute")]
56 PlanAndExecute,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PlanningConfig {
62 #[serde(default = "default_max_depth")]
64 pub max_planning_depth: u32,
65
66 #[serde(default = "default_max_cost")]
68 pub max_planning_cost_usd: f64,
69
70 #[serde(
72 default = "default_step_timeout",
73 serialize_with = "serialize_duration_secs",
74 deserialize_with = "deserialize_duration_secs"
75 )]
76 pub planning_step_timeout: Duration,
77
78 #[serde(default = "default_no_op_limit")]
80 pub circuit_breaker_no_op_limit: u32,
81}
82
83fn default_max_depth() -> u32 {
84 DEFAULT_MAX_DEPTH
85}
86
87fn default_max_cost() -> f64 {
88 DEFAULT_MAX_COST_USD
89}
90
91fn default_step_timeout() -> Duration {
92 Duration::from_secs(DEFAULT_STEP_TIMEOUT_SECS)
93}
94
95fn default_no_op_limit() -> u32 {
96 DEFAULT_NO_OP_LIMIT
97}
98
99impl Default for PlanningConfig {
100 fn default() -> Self {
101 Self {
102 max_planning_depth: default_max_depth(),
103 max_planning_cost_usd: default_max_cost(),
104 planning_step_timeout: default_step_timeout(),
105 circuit_breaker_no_op_limit: default_no_op_limit(),
106 }
107 }
108}
109
110#[non_exhaustive]
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum TerminationReason {
115 Completed,
117 MaxDepthReached,
119 BudgetExceeded,
121 StepTimeout,
123 CircuitBreaker,
125 Cancelled,
127}
128
129#[derive(Debug, Clone)]
131pub struct PlanningStepResult {
132 pub step: u32,
134 pub is_actionable: bool,
136 pub cost_usd: f64,
138 pub duration: Duration,
140 pub output: String,
142}
143
144#[derive(Debug, Clone)]
146pub struct PlanningOutcome {
147 pub strategy: PlanningStrategy,
149 pub termination_reason: TerminationReason,
151 pub steps_executed: u32,
153 pub total_cost_usd: f64,
155 pub total_duration: Duration,
157 pub step_results: Vec<PlanningStepResult>,
159 pub explanation: String,
161}
162
163pub struct PlanningRouter {
165 strategy: PlanningStrategy,
166 config: PlanningConfig,
167}
168
169impl PlanningRouter {
170 pub fn new(strategy: PlanningStrategy, config: PlanningConfig) -> Self {
172 Self { strategy, config }
173 }
174
175 pub fn with_defaults(strategy: PlanningStrategy) -> Self {
177 Self::new(strategy, PlanningConfig::default())
178 }
179
180 pub fn strategy(&self) -> PlanningStrategy {
182 self.strategy
183 }
184
185 pub fn config(&self) -> &PlanningConfig {
187 &self.config
188 }
189
190 pub fn check_guard_rails(
195 &self,
196 current_step: u32,
197 total_cost_usd: f64,
198 consecutive_no_ops: u32,
199 ) -> Option<TerminationReason> {
200 if current_step >= self.config.max_planning_depth {
201 warn!(
202 step = current_step,
203 max = self.config.max_planning_depth,
204 "planning depth limit reached"
205 );
206 return Some(TerminationReason::MaxDepthReached);
207 }
208
209 if total_cost_usd >= self.config.max_planning_cost_usd {
210 warn!(
211 cost = total_cost_usd,
212 max = self.config.max_planning_cost_usd,
213 "planning budget cap exceeded"
214 );
215 return Some(TerminationReason::BudgetExceeded);
216 }
217
218 if consecutive_no_ops >= self.config.circuit_breaker_no_op_limit {
219 warn!(
220 no_ops = consecutive_no_ops,
221 limit = self.config.circuit_breaker_no_op_limit,
222 "circuit breaker triggered: too many no-op steps"
223 );
224 return Some(TerminationReason::CircuitBreaker);
225 }
226
227 None
228 }
229
230 pub fn explain_termination(
232 &self,
233 reason: &TerminationReason,
234 steps: u32,
235 cost: f64,
236 ) -> String {
237 match reason {
238 TerminationReason::Completed => {
239 format!("Planning completed successfully after {steps} steps (${cost:.4} spent).")
240 }
241 TerminationReason::MaxDepthReached => {
242 format!(
243 "Planning terminated: maximum depth of {} steps reached. \
244 Partial results from {steps} steps returned (${cost:.4} spent).",
245 self.config.max_planning_depth
246 )
247 }
248 TerminationReason::BudgetExceeded => {
249 format!(
250 "Planning terminated: budget cap of ${:.2} exceeded (${cost:.4} spent). \
251 Partial results from {steps} steps returned.",
252 self.config.max_planning_cost_usd
253 )
254 }
255 TerminationReason::StepTimeout => {
256 format!(
257 "Planning terminated: step timeout of {:?} exceeded at step {steps}. \
258 Partial results returned (${cost:.4} spent).",
259 self.config.planning_step_timeout
260 )
261 }
262 TerminationReason::CircuitBreaker => {
263 format!(
264 "Planning terminated: {} consecutive no-op steps detected (circuit breaker). \
265 Partial results from {steps} steps returned (${cost:.4} spent).",
266 self.config.circuit_breaker_no_op_limit
267 )
268 }
269 TerminationReason::Cancelled => {
270 format!(
271 "Planning cancelled by user or agent shutdown at step {steps} (${cost:.4} spent)."
272 )
273 }
274 }
275 }
276}
277
278impl std::fmt::Debug for PlanningRouter {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 f.debug_struct("PlanningRouter")
281 .field("strategy", &self.strategy)
282 .field("config", &self.config)
283 .finish()
284 }
285}
286
287fn serialize_duration_secs<S>(d: &Duration, s: S) -> Result<S::Ok, S::Error>
288where
289 S: serde::Serializer,
290{
291 s.serialize_u64(d.as_secs())
292}
293
294fn deserialize_duration_secs<'de, D>(d: D) -> Result<Duration, D::Error>
295where
296 D: serde::Deserializer<'de>,
297{
298 let secs = u64::deserialize(d)?;
299 Ok(Duration::from_secs(secs))
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn planning_config_defaults() {
308 let cfg = PlanningConfig::default();
309 assert_eq!(cfg.max_planning_depth, 10);
310 assert!((cfg.max_planning_cost_usd - 1.0).abs() < f64::EPSILON);
311 assert_eq!(cfg.planning_step_timeout, Duration::from_secs(60));
312 assert_eq!(cfg.circuit_breaker_no_op_limit, 3);
313 }
314
315 #[test]
316 fn planning_config_serde() {
317 let cfg = PlanningConfig {
318 max_planning_depth: 5,
319 max_planning_cost_usd: 0.5,
320 planning_step_timeout: Duration::from_secs(30),
321 circuit_breaker_no_op_limit: 2,
322 };
323 let json = serde_json::to_string(&cfg).unwrap();
324 let restored: PlanningConfig = serde_json::from_str(&json).unwrap();
325 assert_eq!(restored.max_planning_depth, 5);
326 assert!((restored.max_planning_cost_usd - 0.5).abs() < f64::EPSILON);
327 assert_eq!(restored.planning_step_timeout, Duration::from_secs(30));
328 assert_eq!(restored.circuit_breaker_no_op_limit, 2);
329 }
330
331 #[test]
332 fn guard_rails_no_limit_hit() {
333 let router = PlanningRouter::with_defaults(PlanningStrategy::React);
334 assert!(router.check_guard_rails(0, 0.0, 0).is_none());
335 assert!(router.check_guard_rails(5, 0.5, 1).is_none());
336 }
337
338 #[test]
339 fn guard_rails_max_depth() {
340 let router = PlanningRouter::with_defaults(PlanningStrategy::React);
341 let result = router.check_guard_rails(10, 0.0, 0);
342 assert_eq!(result, Some(TerminationReason::MaxDepthReached));
343 }
344
345 #[test]
346 fn guard_rails_budget_exceeded() {
347 let router = PlanningRouter::with_defaults(PlanningStrategy::React);
348 let result = router.check_guard_rails(5, 1.0, 0);
349 assert_eq!(result, Some(TerminationReason::BudgetExceeded));
350 }
351
352 #[test]
353 fn guard_rails_circuit_breaker() {
354 let router = PlanningRouter::with_defaults(PlanningStrategy::React);
355 let result = router.check_guard_rails(5, 0.5, 3);
356 assert_eq!(result, Some(TerminationReason::CircuitBreaker));
357 }
358
359 #[test]
360 fn guard_rails_custom_limits() {
361 let router = PlanningRouter::new(
362 PlanningStrategy::PlanAndExecute,
363 PlanningConfig {
364 max_planning_depth: 3,
365 max_planning_cost_usd: 0.25,
366 circuit_breaker_no_op_limit: 1,
367 ..Default::default()
368 },
369 );
370 assert_eq!(
372 router.check_guard_rails(3, 0.0, 0),
373 Some(TerminationReason::MaxDepthReached)
374 );
375 assert_eq!(
377 router.check_guard_rails(1, 0.25, 0),
378 Some(TerminationReason::BudgetExceeded)
379 );
380 assert_eq!(
382 router.check_guard_rails(1, 0.0, 1),
383 Some(TerminationReason::CircuitBreaker)
384 );
385 }
386
387 #[test]
388 fn explain_termination_messages() {
389 let router = PlanningRouter::with_defaults(PlanningStrategy::React);
390
391 let msg = router.explain_termination(&TerminationReason::Completed, 5, 0.3);
392 assert!(msg.contains("successfully"));
393 assert!(msg.contains("5 steps"));
394
395 let msg = router.explain_termination(
396 &TerminationReason::MaxDepthReached,
397 10,
398 0.8,
399 );
400 assert!(msg.contains("maximum depth"));
401 assert!(msg.contains("Partial results"));
402
403 let msg = router.explain_termination(
404 &TerminationReason::BudgetExceeded,
405 7,
406 1.2,
407 );
408 assert!(msg.contains("budget cap"));
409
410 let msg = router.explain_termination(
411 &TerminationReason::CircuitBreaker,
412 4,
413 0.1,
414 );
415 assert!(msg.contains("no-op steps"));
416 assert!(msg.contains("circuit breaker"));
417
418 let msg = router.explain_termination(
419 &TerminationReason::StepTimeout,
420 3,
421 0.2,
422 );
423 assert!(msg.contains("step timeout"));
424
425 let msg = router.explain_termination(
426 &TerminationReason::Cancelled,
427 2,
428 0.05,
429 );
430 assert!(msg.contains("cancelled"));
431 }
432
433 #[test]
434 fn planning_strategy_serde() {
435 let json = serde_json::to_string(&PlanningStrategy::React).unwrap();
436 assert_eq!(json, "\"react\"");
437
438 let json = serde_json::to_string(&PlanningStrategy::PlanAndExecute).unwrap();
439 assert_eq!(json, "\"plan_and_execute\"");
440
441 let restored: PlanningStrategy =
442 serde_json::from_str("\"react\"").unwrap();
443 assert_eq!(restored, PlanningStrategy::React);
444
445 let restored: PlanningStrategy =
446 serde_json::from_str("\"plan_and_execute\"").unwrap();
447 assert_eq!(restored, PlanningStrategy::PlanAndExecute);
448 }
449
450 #[test]
451 fn termination_reason_serde() {
452 let json = serde_json::to_string(&TerminationReason::Completed).unwrap();
453 assert_eq!(json, "\"completed\"");
454
455 let json = serde_json::to_string(&TerminationReason::CircuitBreaker).unwrap();
456 assert_eq!(json, "\"circuit_breaker\"");
457 }
458
459 #[test]
460 fn router_accessors() {
461 let router = PlanningRouter::with_defaults(PlanningStrategy::PlanAndExecute);
462 assert_eq!(router.strategy(), PlanningStrategy::PlanAndExecute);
463 assert_eq!(router.config().max_planning_depth, 10);
464 }
465}