Skip to main content

clawft_core/
planning.rs

1//! Planning strategies with guard rails.
2//!
3//! Implements ReAct (Reason+Act) and Plan-and-Execute strategies for the
4//! router, with configurable guard rails to prevent infinite loops and
5//! cost runaway.
6//!
7//! # Guard rails
8//!
9//! | Parameter | Default | Description |
10//! |-----------|---------|-------------|
11//! | `max_planning_depth` | 10 | Max planning steps before forced termination |
12//! | `max_planning_cost_usd` | 1.0 | Hard budget cap for LLM calls during planning |
13//! | `planning_step_timeout` | 60s | Max duration for a single planning step |
14//! | Circuit breaker | 3 | Consecutive no-op steps before abort |
15//!
16//! # Configuration
17//!
18//! ```toml
19//! [router.planning]
20//! max_planning_depth = 10
21//! max_planning_cost_usd = 1.0
22//! planning_step_timeout = "60s"
23//! circuit_breaker_no_op_limit = 3
24//! ```
25
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29use tracing::warn;
30
31/// Default maximum planning depth.
32const DEFAULT_MAX_DEPTH: u32 = 10;
33
34/// Default maximum planning cost in USD.
35const DEFAULT_MAX_COST_USD: f64 = 1.0;
36
37/// Default planning step timeout in seconds.
38const DEFAULT_STEP_TIMEOUT_SECS: u64 = 60;
39
40/// Default number of consecutive no-op steps before circuit breaker.
41const DEFAULT_NO_OP_LIMIT: u32 = 3;
42
43/// Planning strategy.
44#[non_exhaustive]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum PlanningStrategy {
48    /// Reason+Act loop: alternate between reasoning about the current
49    /// state and taking an action.
50    #[serde(alias = "ReAct")]
51    React,
52
53    /// Plan-and-Execute: generate a full plan first, then execute
54    /// each step sequentially.
55    #[serde(alias = "PlanAndExecute")]
56    PlanAndExecute,
57}
58
59/// Configuration for the planning router.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PlanningConfig {
62    /// Maximum number of planning steps before forced termination.
63    #[serde(default = "default_max_depth")]
64    pub max_planning_depth: u32,
65
66    /// Hard budget cap for LLM calls during planning (USD).
67    #[serde(default = "default_max_cost")]
68    pub max_planning_cost_usd: f64,
69
70    /// Maximum duration for a single planning step.
71    #[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    /// Number of consecutive no-op steps before circuit breaker triggers.
79    #[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/// Reason a planning session was terminated.
111#[non_exhaustive]
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum TerminationReason {
115    /// Planning completed successfully.
116    Completed,
117    /// Maximum depth reached.
118    MaxDepthReached,
119    /// Budget cap exceeded.
120    BudgetExceeded,
121    /// Step timeout exceeded.
122    StepTimeout,
123    /// Circuit breaker triggered (too many no-op steps).
124    CircuitBreaker,
125    /// Cancelled by user or agent shutdown.
126    Cancelled,
127}
128
129/// Result of a planning step.
130#[derive(Debug, Clone)]
131pub struct PlanningStepResult {
132    /// Step number (0-indexed).
133    pub step: u32,
134    /// Whether this step produced actionable output.
135    pub is_actionable: bool,
136    /// Cost of this step in USD.
137    pub cost_usd: f64,
138    /// Duration of this step.
139    pub duration: Duration,
140    /// Output from this step.
141    pub output: String,
142}
143
144/// Outcome of a planning session, including partial results.
145#[derive(Debug, Clone)]
146pub struct PlanningOutcome {
147    /// Strategy that was used.
148    pub strategy: PlanningStrategy,
149    /// Why the planning session ended.
150    pub termination_reason: TerminationReason,
151    /// Number of steps executed.
152    pub steps_executed: u32,
153    /// Total cost in USD.
154    pub total_cost_usd: f64,
155    /// Total duration.
156    pub total_duration: Duration,
157    /// Results from each step.
158    pub step_results: Vec<PlanningStepResult>,
159    /// Human-readable explanation of the outcome.
160    pub explanation: String,
161}
162
163/// Router that applies planning strategies with guard rails.
164pub struct PlanningRouter {
165    strategy: PlanningStrategy,
166    config: PlanningConfig,
167}
168
169impl PlanningRouter {
170    /// Create a new planning router.
171    pub fn new(strategy: PlanningStrategy, config: PlanningConfig) -> Self {
172        Self { strategy, config }
173    }
174
175    /// Create a planning router with default configuration.
176    pub fn with_defaults(strategy: PlanningStrategy) -> Self {
177        Self::new(strategy, PlanningConfig::default())
178    }
179
180    /// Get the configured strategy.
181    pub fn strategy(&self) -> PlanningStrategy {
182        self.strategy
183    }
184
185    /// Get the planning configuration.
186    pub fn config(&self) -> &PlanningConfig {
187        &self.config
188    }
189
190    /// Validate a planning step against guard rails.
191    ///
192    /// Returns `Some(TerminationReason)` if a limit has been reached,
193    /// or `None` if the step is within limits.
194    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    /// Build a partial results explanation for the given termination reason.
231    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        // Depth limit.
371        assert_eq!(
372            router.check_guard_rails(3, 0.0, 0),
373            Some(TerminationReason::MaxDepthReached)
374        );
375        // Budget limit.
376        assert_eq!(
377            router.check_guard_rails(1, 0.25, 0),
378            Some(TerminationReason::BudgetExceeded)
379        );
380        // Circuit breaker.
381        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}