Skip to main content

ryo_executor/decider/
deciders.rs

1//! Decider implementations
2//!
3//! Various strategies for agents to decide their next action.
4
5use super::action::{Action, ActionKind};
6use super::context::DecisionContext;
7use super::state::AgentState;
8use std::fmt::Debug;
9
10// ============================================================================
11// Decider Trait
12// ============================================================================
13
14/// Trait for decision-making strategies
15///
16/// A Decider takes the current context and state, and returns the next Action.
17pub trait Decider: Send + Sync + Debug {
18    /// Decide the next action based on context and state
19    fn decide(&self, context: &DecisionContext, state: &AgentState) -> Action;
20
21    /// Name of this decider (for logging)
22    fn name(&self) -> &'static str;
23}
24
25// ============================================================================
26// PlainDecider: Pass-through (no autonomous decisions)
27// ============================================================================
28
29/// PlainDecider: Just returns the next work item as a read action
30///
31/// This is for "plain mode" where agents don't make autonomous decisions
32/// but simply execute the work assigned to them.
33#[derive(Debug, Clone, Default)]
34pub struct PlainDecider;
35
36impl Decider for PlainDecider {
37    fn decide(&self, context: &DecisionContext, _state: &AgentState) -> Action {
38        // If there's remaining work, return the next item as a mutation
39        if let Some(target) = context.next_work_item() {
40            return Action::mutate("Execute", target.clone());
41        }
42
43        // No more work
44        Action::done()
45    }
46
47    fn name(&self) -> &'static str {
48        "PlainDecider"
49    }
50}
51
52// ============================================================================
53// ParameterizedDecider: Uses state for smarter decisions
54// ============================================================================
55
56/// ParameterizedDecider: Makes decisions based on current state
57///
58/// Considers errors, recent results, and other state to make better decisions.
59#[derive(Debug, Clone)]
60pub struct ParameterizedDecider {
61    /// Weight for error-focused decisions (0.0 - 1.0)
62    pub error_weight: f64,
63    /// Weight for recent-file-focused decisions (0.0 - 1.0)
64    pub recency_weight: f64,
65}
66
67impl Default for ParameterizedDecider {
68    fn default() -> Self {
69        Self {
70            error_weight: 0.7,
71            recency_weight: 0.3,
72        }
73    }
74}
75
76impl ParameterizedDecider {
77    /// Create with custom weights
78    pub fn with_weights(error_weight: f64, recency_weight: f64) -> Self {
79        Self {
80            error_weight,
81            recency_weight,
82        }
83    }
84}
85
86impl Decider for ParameterizedDecider {
87    fn decide(&self, context: &DecisionContext, state: &AgentState) -> Action {
88        // 1. If there are errors and error_weight is high, focus on errors
89        if state.has_errors() && self.error_weight > 0.5 {
90            if let Some(errored_file) = state.most_errored_file() {
91                return Action::read(errored_file.to_string_lossy())
92                    .with_reason(format!("fixing {} errors", state.error_count()));
93            }
94        }
95
96        // 2. If last action failed, try something different
97        if context.last_failed() {
98            // Try a different file or approach
99            let available = state.available_files(context.agent_id);
100            if let Some(file) = available.first() {
101                return Action::read(file.to_string_lossy())
102                    .with_reason("trying different file after failure");
103            }
104        }
105
106        // 3. If success rate is low, consider escalation
107        if context.recent_success_rate() < 0.3 && context.recent_results.len() >= 3 {
108            return Action::new(ActionKind::Escalate)
109                .with_reason("low success rate, requesting help");
110        }
111
112        // 4. Normal operation: use remaining work or investigate
113        if let Some(target) = context.next_work_item() {
114            return Action::mutate("Execute", target.clone());
115        }
116
117        let available = state.available_files(context.agent_id);
118        if let Some(file) = available.first() {
119            return Action::read(file.to_string_lossy())
120                .with_reason("investigating available file");
121        }
122
123        Action::done()
124    }
125
126    fn name(&self) -> &'static str {
127        "ParameterizedDecider"
128    }
129}
130
131// ============================================================================
132// MurmurationDecider: Swarm-like behavior with collision avoidance
133// ============================================================================
134
135/// MurmurationDecider: Swarm behavior for parallel agents
136///
137/// Implements three rules:
138/// 1. Separation: Don't work on the same file as other agents
139/// 2. Alignment: Coordinate with nearby agents
140/// 3. Cohesion: Move towards the overall goal
141#[derive(Debug, Clone)]
142pub struct MurmurationDecider {
143    /// Weight for separation behavior (0.0 - 1.0)
144    pub separation_weight: f64,
145}
146
147impl Default for MurmurationDecider {
148    fn default() -> Self {
149        Self {
150            separation_weight: 1.0,
151        }
152    }
153}
154
155impl MurmurationDecider {
156    /// Create with custom separation weight
157    pub fn with_separation(separation_weight: f64) -> Self {
158        Self { separation_weight }
159    }
160}
161
162impl Decider for MurmurationDecider {
163    fn decide(&self, context: &DecisionContext, state: &AgentState) -> Action {
164        // 1. Separation: Find an unoccupied, uninvestigated file
165        let available = state.available_files(context.agent_id);
166
167        if let Some(file) = available.first() {
168            return Action::read(file.to_string_lossy()).with_reason(format!(
169                "agent #{} investigating (separation)",
170                context.agent_id
171            ));
172        }
173
174        // 2. All files are occupied or investigated
175        if !state.uninvestigated_files().is_empty() {
176            // Some files are being investigated by others, wait
177            return Action::rest("waiting for other agents");
178        }
179
180        // 3. Cohesion: All done, contribute to final goal
181        if let Some(target) = context.next_work_item() {
182            return Action::mutate("Execute", target.clone())
183                .with_reason("cohesion: executing final work");
184        }
185
186        Action::done()
187    }
188
189    fn name(&self) -> &'static str {
190        "MurmurationDecider"
191    }
192}
193
194// ============================================================================
195// Decision Modifier: Adjusts decisions based on conditions
196// ============================================================================
197
198/// Trait for modifying decisions based on conditions
199///
200/// Modifiers are applied after the base decider makes its decision,
201/// allowing for layered behavior like "weather awareness" on top of
202/// "flocking behavior".
203pub trait DecisionModifier: Send + Sync + Debug {
204    /// Modify an action based on context and state
205    ///
206    /// Can return the same action unchanged, modify it, or replace it entirely.
207    fn modify(&self, action: Action, context: &DecisionContext, state: &AgentState) -> Action;
208
209    /// Name of this modifier (for logging)
210    fn name(&self) -> &'static str;
211}
212
213// ============================================================================
214// Pre-built Modifiers
215// ============================================================================
216
217/// ErrorAwareModifier: Prioritizes files with errors
218///
219/// Like birds avoiding storms - if there are errors, focus on fixing them.
220#[derive(Debug, Clone)]
221pub struct ErrorAwareModifier {
222    /// Weight for error prioritization (0.0 - 1.0)
223    pub weight: f64,
224}
225
226impl ErrorAwareModifier {
227    pub fn new(weight: f64) -> Self {
228        Self { weight }
229    }
230}
231
232impl DecisionModifier for ErrorAwareModifier {
233    fn modify(&self, action: Action, _context: &DecisionContext, state: &AgentState) -> Action {
234        // If weight is high and there are errors, redirect to error file
235        if self.weight > 0.5 && state.has_errors() {
236            if let Some(errored_file) = state.most_errored_file() {
237                return Action::read(errored_file.to_string_lossy()).with_reason(format!(
238                    "error-aware: {} errors in file",
239                    state.error_count()
240                ));
241            }
242        }
243        action
244    }
245
246    fn name(&self) -> &'static str {
247        "ErrorAwareModifier"
248    }
249}
250
251/// StallDetectionModifier: Escalates when progress stalls
252///
253/// Like birds changing course when stuck in a thermal.
254#[derive(Debug, Clone)]
255pub struct StallDetectionModifier {
256    /// Number of ticks without progress before escalating
257    pub threshold: u64,
258}
259
260impl StallDetectionModifier {
261    pub fn new(threshold: u64) -> Self {
262        Self { threshold }
263    }
264}
265
266impl DecisionModifier for StallDetectionModifier {
267    fn modify(&self, action: Action, context: &DecisionContext, state: &AgentState) -> Action {
268        if state.is_stalled(context.tick, self.threshold) {
269            return Action::new(ActionKind::Escalate).with_reason(format!(
270                "stall-detection: no progress for {} ticks",
271                self.threshold
272            ));
273        }
274        action
275    }
276
277    fn name(&self) -> &'static str {
278        "StallDetectionModifier"
279    }
280}
281
282/// SuccessRateModifier: Escalates on low success rate
283///
284/// Like birds seeking shelter when conditions are bad.
285#[derive(Debug, Clone)]
286pub struct SuccessRateModifier {
287    /// Minimum success rate before escalating (0.0 - 1.0)
288    pub threshold: f64,
289    /// Minimum number of results before checking
290    pub min_samples: usize,
291}
292
293impl SuccessRateModifier {
294    pub fn new(threshold: f64) -> Self {
295        Self {
296            threshold,
297            min_samples: 3,
298        }
299    }
300}
301
302impl DecisionModifier for SuccessRateModifier {
303    fn modify(&self, action: Action, context: &DecisionContext, _state: &AgentState) -> Action {
304        if context.recent_results.len() >= self.min_samples
305            && context.recent_success_rate() < self.threshold
306        {
307            return Action::new(ActionKind::Escalate).with_reason(format!(
308                "success-rate: {:.0}% below threshold",
309                context.recent_success_rate() * 100.0
310            ));
311        }
312        action
313    }
314
315    fn name(&self) -> &'static str {
316        "SuccessRateModifier"
317    }
318}
319
320/// RetryModifier: Retries on failure with backoff
321#[derive(Debug, Clone)]
322pub struct RetryModifier {
323    /// Maximum retries
324    pub max_retries: u32,
325}
326
327impl RetryModifier {
328    pub fn new(max_retries: u32) -> Self {
329        Self { max_retries }
330    }
331}
332
333impl DecisionModifier for RetryModifier {
334    fn modify(&self, action: Action, context: &DecisionContext, _state: &AgentState) -> Action {
335        // If last action failed and we haven't exceeded retries, retry
336        if context.last_failed() {
337            let consecutive_failures = context
338                .recent_results
339                .iter()
340                .rev()
341                .take_while(|r| !r.success)
342                .count() as u32;
343
344            if consecutive_failures < self.max_retries {
345                // Retry the same action
346                if let Some(last) = context.last_result() {
347                    return last
348                        .action
349                        .clone()
350                        .with_reason(format!("retry: attempt {}", consecutive_failures + 1));
351                }
352            }
353        }
354        action
355    }
356
357    fn name(&self) -> &'static str {
358        "RetryModifier"
359    }
360}
361
362// ============================================================================
363// Composable Decider: Base + Modifiers
364// ============================================================================
365
366/// ComposableDecider: Combines a base decider with modifiers
367///
368/// This enables layered decision-making:
369/// - Base decider handles target selection (WHERE to act)
370/// - Modifiers adjust behavior based on conditions (HOW to act)
371///
372/// # Example
373///
374/// ```rust,ignore
375/// let decider = ComposableDecider::new(MurmurationDecider::default())
376///     .with_modifier(ErrorAwareModifier::new(0.7))
377///     .with_modifier(StallDetectionModifier::new(5));
378/// ```
379#[derive(Debug)]
380pub struct ComposableDecider {
381    base: Box<dyn Decider>,
382    modifiers: Vec<Box<dyn DecisionModifier>>,
383}
384
385impl ComposableDecider {
386    /// Create a new composable decider with the given base
387    pub fn new(base: impl Decider + 'static) -> Self {
388        Self {
389            base: Box::new(base),
390            modifiers: Vec::new(),
391        }
392    }
393
394    /// Add a modifier to the chain
395    pub fn with_modifier(mut self, modifier: impl DecisionModifier + 'static) -> Self {
396        self.modifiers.push(Box::new(modifier));
397        self
398    }
399
400    /// Add an error-aware modifier
401    pub fn error_aware(self, weight: f64) -> Self {
402        self.with_modifier(ErrorAwareModifier::new(weight))
403    }
404
405    /// Add a stall detection modifier
406    pub fn stall_detection(self, threshold: u64) -> Self {
407        self.with_modifier(StallDetectionModifier::new(threshold))
408    }
409
410    /// Add a success rate modifier
411    pub fn success_rate(self, threshold: f64) -> Self {
412        self.with_modifier(SuccessRateModifier::new(threshold))
413    }
414
415    /// Add a retry modifier
416    pub fn with_retry(self, max_retries: u32) -> Self {
417        self.with_modifier(RetryModifier::new(max_retries))
418    }
419}
420
421impl Decider for ComposableDecider {
422    fn decide(&self, context: &DecisionContext, state: &AgentState) -> Action {
423        // 1. Get base decision
424        let mut action = self.base.decide(context, state);
425
426        // 2. Apply modifiers in order
427        for modifier in &self.modifiers {
428            action = modifier.modify(action, context, state);
429        }
430
431        action
432    }
433
434    fn name(&self) -> &'static str {
435        "ComposableDecider"
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::path::PathBuf;
443
444    fn create_test_context() -> DecisionContext {
445        DecisionContext::new(0, "rename foo to bar")
446            .with_tick(1)
447            .with_remaining_work(vec!["task1".to_string(), "task2".to_string()])
448    }
449
450    fn create_test_state() -> AgentState {
451        let mut state = AgentState::new();
452        state.update_file(
453            PathBuf::from("a.rs"),
454            super::super::state::FileState::new(100, 2000),
455        );
456        state.update_file(
457            PathBuf::from("b.rs"),
458            super::super::state::FileState::new(50, 1000),
459        );
460        state
461    }
462
463    #[test]
464    fn test_plain_decider() {
465        let decider = PlainDecider;
466        let context = create_test_context();
467        let state = create_test_state();
468
469        let action = decider.decide(&context, &state);
470        assert_eq!(action.kind, ActionKind::Mutate);
471        assert_eq!(action.target, Some("task1".to_string()));
472    }
473
474    #[test]
475    fn test_murmuration_decider() {
476        let decider = MurmurationDecider::default();
477        let context = DecisionContext::new(0, "investigate").with_tick(1);
478        let state = create_test_state();
479
480        let action = decider.decide(&context, &state);
481        assert_eq!(action.kind, ActionKind::Read);
482        assert!(action.target.is_some());
483    }
484
485    #[test]
486    fn test_parameterized_decider_with_errors() {
487        let decider = ParameterizedDecider::default();
488        let context = DecisionContext::new(0, "fix errors");
489
490        let mut state = create_test_state();
491        state.add_error(super::super::state::ErrorInfo::new(
492            PathBuf::from("a.rs"),
493            10,
494            "error",
495        ));
496
497        let action = decider.decide(&context, &state);
498        assert_eq!(action.kind, ActionKind::Read);
499        // Should target the file with errors
500        assert!(action.target.unwrap().contains("a.rs"));
501    }
502
503    #[test]
504    fn test_composable_decider_basic() {
505        // Base: MurmurationDecider
506        let decider = ComposableDecider::new(MurmurationDecider::default());
507
508        let context = DecisionContext::new(0, "investigate").with_tick(1);
509        let state = create_test_state();
510
511        let action = decider.decide(&context, &state);
512        assert_eq!(action.kind, ActionKind::Read);
513    }
514
515    #[test]
516    fn test_composable_decider_with_error_modifier() {
517        // Base: Murmuration + Error awareness
518        let decider = ComposableDecider::new(MurmurationDecider::default()).error_aware(0.8);
519
520        let context = DecisionContext::new(0, "investigate").with_tick(1);
521        let mut state = create_test_state();
522
523        // Add error to a.rs
524        state.add_error(super::super::state::ErrorInfo::new(
525            PathBuf::from("a.rs"),
526            10,
527            "compile error",
528        ));
529
530        let action = decider.decide(&context, &state);
531        // Should redirect to error file
532        assert_eq!(action.kind, ActionKind::Read);
533        assert!(action.target.unwrap().contains("a.rs"));
534        assert!(action.reason.unwrap().contains("error-aware"));
535    }
536
537    #[test]
538    fn test_composable_decider_stall_detection() {
539        let decider = ComposableDecider::new(PlainDecider).stall_detection(5);
540
541        let context = DecisionContext::new(0, "task")
542            .with_tick(10)
543            .with_remaining_work(vec!["task1".to_string()]);
544
545        let mut state = create_test_state();
546        // Simulate stall: last progress was at tick 0
547        state.last_progress_tick = 0;
548
549        let action = decider.decide(&context, &state);
550        // Should escalate due to stall
551        assert_eq!(action.kind, ActionKind::Escalate);
552        assert!(action.reason.unwrap().contains("stall"));
553    }
554
555    #[test]
556    fn test_composable_decider_chain() {
557        // Full chain: Murmuration + Error + Stall + Retry
558        let decider = ComposableDecider::new(MurmurationDecider::default())
559            .error_aware(0.7)
560            .stall_detection(10)
561            .with_retry(3);
562
563        let context = DecisionContext::new(0, "investigate").with_tick(1);
564        let state = create_test_state();
565
566        // Normal operation - should use base decider
567        let action = decider.decide(&context, &state);
568        assert_eq!(action.kind, ActionKind::Read);
569    }
570
571    #[test]
572    fn test_success_rate_modifier() {
573        use super::super::action::ActionResult;
574
575        let decider = ComposableDecider::new(PlainDecider).success_rate(0.5);
576
577        // Create context with many failures
578        let mut context =
579            DecisionContext::new(0, "task").with_remaining_work(vec!["task1".to_string()]);
580
581        // Add 4 failures
582        for _ in 0..4 {
583            context.add_result(ActionResult::failure(Action::read("test.rs"), "error"));
584        }
585
586        let state = create_test_state();
587        let action = decider.decide(&context, &state);
588
589        // Should escalate due to low success rate
590        assert_eq!(action.kind, ActionKind::Escalate);
591        assert!(action.reason.unwrap().contains("success-rate"));
592    }
593}