Skip to main content

quorum_rs/
control_plane.rs

1//! HITL control plane traits and types.
2//!
3//! Defines the [`AgentControlPlane`] trait for runtime agent control
4//! (pause, config patching, buffer management) and the [`ConfigPatch`] struct
5//! for partial live-updates to [`AgentConfig`](crate::AgentConfig) fields.
6//!
7//! Any agent implementation can satisfy [`AgentControlPlane`] to expose a
8//! control plane; the reference implementation ships with the embedded
9//! status server in this crate.
10
11use crate::workers::buffer::BufferEntrySummary;
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// ConfigPatch
16// ---------------------------------------------------------------------------
17
18/// Subset of [`AgentConfig`](crate::AgentConfig) fields that can be live-patched
19/// at runtime via the HITL control plane.
20///
21/// All fields are `Option` — only non-`None` values are applied.
22/// Changes are **in-memory only** (lost on restart).
23#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
24#[serde(deny_unknown_fields)]
25pub struct ConfigPatch {
26    #[schema(minimum = 0.0, maximum = 2.0)]
27    pub temperature: Option<f32>,
28    #[schema(minimum = -2.0, maximum = 2.0)]
29    pub frequency_penalty: Option<f32>,
30    #[schema(minimum = -2.0, maximum = 2.0)]
31    pub presence_penalty: Option<f32>,
32    pub persona: Option<String>,
33    pub textual_feedback: Option<bool>,
34    #[schema(minimum = 1)]
35    pub max_react_iterations: Option<i32>,
36    #[schema(minimum = 0)]
37    pub max_retries: Option<i32>,
38}
39
40impl ConfigPatch {
41    /// Validate all patch values before applying.
42    ///
43    /// Returns an error message describing the first invalid field found.
44    pub fn validate(&self) -> Result<(), String> {
45        if let Some(t) = self.temperature {
46            if !t.is_finite() || !(0.0..=2.0).contains(&t) {
47                return Err(format!(
48                    "temperature must be finite and in [0.0, 2.0], got {}",
49                    t
50                ));
51            }
52        }
53        if let Some(fp) = self.frequency_penalty {
54            if !fp.is_finite() || !(-2.0..=2.0).contains(&fp) {
55                return Err(format!(
56                    "frequency_penalty must be finite and in [-2.0, 2.0], got {}",
57                    fp
58                ));
59            }
60        }
61        if let Some(pp) = self.presence_penalty {
62            if !pp.is_finite() || !(-2.0..=2.0).contains(&pp) {
63                return Err(format!(
64                    "presence_penalty must be finite and in [-2.0, 2.0], got {}",
65                    pp
66                ));
67            }
68        }
69        if let Some(mri) = self.max_react_iterations {
70            if mri < 1 {
71                return Err(format!("max_react_iterations must be >= 1, got {}", mri));
72            }
73        }
74        if let Some(mr) = self.max_retries {
75            if mr < 0 {
76                return Err(format!("max_retries must be >= 0, got {}", mr));
77            }
78        }
79        Ok(())
80    }
81
82    /// Validate and apply this patch to an [`AgentConfig`](crate::AgentConfig).
83    ///
84    /// Returns `Err` if any patched value is out of range.
85    pub fn apply(&self, config: &mut crate::AgentConfig) -> Result<(), String> {
86        self.validate()?;
87        if let Some(t) = self.temperature {
88            config.temperature = t;
89        }
90        if let Some(fp) = self.frequency_penalty {
91            config.frequency_penalty = Some(fp);
92        }
93        if let Some(pp) = self.presence_penalty {
94            config.presence_penalty = Some(pp);
95        }
96        if let Some(ref p) = self.persona {
97            config.persona = Some(p.clone());
98        }
99        if let Some(tf) = self.textual_feedback {
100            config.textual_feedback = tf;
101        }
102        if let Some(mri) = self.max_react_iterations {
103            config.max_react_iterations = Some(mri);
104        }
105        if let Some(mr) = self.max_retries {
106            config.max_retries = Some(mr);
107        }
108        Ok(())
109    }
110}
111
112// ---------------------------------------------------------------------------
113// AgentControlPlane trait
114// ---------------------------------------------------------------------------
115
116/// Trait for controlling agents at runtime (pause, config, buffer ops).
117///
118/// Reference implementation: the embedded status server (feature `status-server`).
119#[async_trait::async_trait]
120pub trait AgentControlPlane: Send + Sync {
121    /// Pause or resume an agent by name.
122    async fn set_paused(&self, agent: &str, paused: bool) -> anyhow::Result<()>;
123
124    /// Apply a partial configuration update to an agent.
125    async fn update_config(&self, agent: &str, patch: ConfigPatch) -> anyhow::Result<()>;
126
127    /// List buffered responses for an agent.
128    async fn list_buffer(&self, agent: &str) -> anyhow::Result<Vec<BufferEntrySummary>>;
129
130    /// Force-release a specific buffer entry (publish + ack).
131    async fn release_buffer_entry(&self, agent: &str, entry_id: &str) -> anyhow::Result<()>;
132
133    /// Reject (discard) a specific buffer entry (ack without publishing).
134    async fn reject_buffer_entry(&self, agent: &str, entry_id: &str) -> anyhow::Result<()>;
135}
136
137// ---------------------------------------------------------------------------
138// Tests
139// ---------------------------------------------------------------------------
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_config_patch_serde() {
147        let patch = ConfigPatch {
148            temperature: Some(0.9),
149            frequency_penalty: Some(0.3),
150            presence_penalty: Some(1.2),
151            persona: Some("skeptical analyst".into()),
152            textual_feedback: Some(false),
153            max_react_iterations: Some(5),
154            max_retries: Some(2),
155        };
156        let json = serde_json::to_string(&patch).unwrap();
157        let roundtripped: ConfigPatch = serde_json::from_str(&json).unwrap();
158        assert_eq!(roundtripped.temperature, Some(0.9));
159        assert_eq!(roundtripped.persona, Some("skeptical analyst".into()));
160        assert_eq!(roundtripped.max_retries, Some(2));
161    }
162
163    #[test]
164    fn test_config_patch_all_none() {
165        let patch = ConfigPatch::default();
166        assert!(patch.temperature.is_none());
167        assert!(patch.frequency_penalty.is_none());
168        assert!(patch.presence_penalty.is_none());
169        assert!(patch.persona.is_none());
170        assert!(patch.textual_feedback.is_none());
171        assert!(patch.max_react_iterations.is_none());
172        assert!(patch.max_retries.is_none());
173
174        // Empty patch serializes with all nulls
175        let json = serde_json::to_string(&patch).unwrap();
176        let roundtripped: ConfigPatch = serde_json::from_str(&json).unwrap();
177        assert!(roundtripped.temperature.is_none());
178    }
179
180    #[test]
181    fn test_config_patch_partial() {
182        let json = r#"{"temperature": 0.5}"#;
183        let patch: ConfigPatch = serde_json::from_str(json).unwrap();
184        assert_eq!(patch.temperature, Some(0.5));
185        assert!(patch.frequency_penalty.is_none());
186        assert!(patch.persona.is_none());
187    }
188
189    #[test]
190    fn test_config_patch_apply() {
191        let mut config = crate::AgentConfig {
192            name: "test".into(),
193            provider_id: "p".into(),
194            model_name: "m".into(),
195            temperature: 0.7,
196            frequency_penalty: Some(0.1),
197            presence_penalty: Some(1.5),
198            persona: Some("default persona".into()),
199            textual_feedback: true,
200            max_react_iterations: Some(10),
201            max_retries: Some(3),
202            ..Default::default()
203        };
204
205        let patch = ConfigPatch {
206            temperature: Some(1.2),
207            persona: Some("aggressive debater".into()),
208            max_retries: Some(5),
209            ..Default::default()
210        };
211        patch.apply(&mut config).expect("valid patch should apply");
212
213        assert_eq!(config.temperature, 1.2);
214        assert_eq!(config.persona, Some("aggressive debater".into()));
215        assert_eq!(config.max_retries, Some(5));
216        // Unpatched fields unchanged
217        assert_eq!(config.frequency_penalty, Some(0.1));
218        assert_eq!(config.presence_penalty, Some(1.5));
219        assert!(config.textual_feedback);
220        assert_eq!(config.max_react_iterations, Some(10));
221    }
222
223    // -------------------------------------------------------------------
224    // ConfigPatch validation tests
225    // -------------------------------------------------------------------
226
227    #[test]
228    fn test_validate_rejects_temperature_above_range() {
229        let patch = ConfigPatch {
230            temperature: Some(2.5),
231            ..Default::default()
232        };
233        let err = patch.validate().unwrap_err();
234        assert!(err.contains("temperature"), "err: {}", err);
235    }
236
237    #[test]
238    fn test_validate_rejects_negative_temperature() {
239        let patch = ConfigPatch {
240            temperature: Some(-0.1),
241            ..Default::default()
242        };
243        assert!(patch.validate().is_err());
244    }
245
246    #[test]
247    fn test_validate_rejects_nan_temperature() {
248        let patch = ConfigPatch {
249            temperature: Some(f32::NAN),
250            ..Default::default()
251        };
252        assert!(patch.validate().is_err());
253    }
254
255    #[test]
256    fn test_validate_rejects_inf_temperature() {
257        let patch = ConfigPatch {
258            temperature: Some(f32::INFINITY),
259            ..Default::default()
260        };
261        assert!(patch.validate().is_err());
262    }
263
264    #[test]
265    fn test_validate_accepts_boundary_temperature() {
266        let patch_zero = ConfigPatch {
267            temperature: Some(0.0),
268            ..Default::default()
269        };
270        assert!(patch_zero.validate().is_ok());
271        let patch_two = ConfigPatch {
272            temperature: Some(2.0),
273            ..Default::default()
274        };
275        assert!(patch_two.validate().is_ok());
276    }
277
278    #[test]
279    fn test_validate_rejects_frequency_penalty_out_of_range() {
280        let patch = ConfigPatch {
281            frequency_penalty: Some(3.0),
282            ..Default::default()
283        };
284        let err = patch.validate().unwrap_err();
285        assert!(err.contains("frequency_penalty"), "err: {}", err);
286    }
287
288    #[test]
289    fn test_validate_rejects_presence_penalty_out_of_range() {
290        let patch = ConfigPatch {
291            presence_penalty: Some(-2.5),
292            ..Default::default()
293        };
294        assert!(patch.validate().is_err());
295    }
296
297    #[test]
298    fn test_validate_rejects_zero_max_react_iterations() {
299        let patch = ConfigPatch {
300            max_react_iterations: Some(0),
301            ..Default::default()
302        };
303        let err = patch.validate().unwrap_err();
304        assert!(err.contains("max_react_iterations"), "err: {}", err);
305    }
306
307    #[test]
308    fn test_validate_rejects_negative_max_retries() {
309        let patch = ConfigPatch {
310            max_retries: Some(-1),
311            ..Default::default()
312        };
313        let err = patch.validate().unwrap_err();
314        assert!(err.contains("max_retries"), "err: {}", err);
315    }
316
317    #[test]
318    fn test_validate_accepts_valid_patch() {
319        let patch = ConfigPatch {
320            temperature: Some(1.0),
321            frequency_penalty: Some(-1.5),
322            presence_penalty: Some(2.0),
323            max_react_iterations: Some(5),
324            max_retries: Some(0),
325            ..Default::default()
326        };
327        assert!(patch.validate().is_ok());
328    }
329
330    #[test]
331    fn test_validate_accepts_empty_patch() {
332        let patch = ConfigPatch::default();
333        assert!(patch.validate().is_ok());
334    }
335
336    #[test]
337    fn test_apply_rejects_invalid_and_does_not_mutate() {
338        let mut config = crate::AgentConfig {
339            name: "test".into(),
340            provider_id: "p".into(),
341            model_name: "m".into(),
342            temperature: 0.7,
343            ..Default::default()
344        };
345
346        let patch = ConfigPatch {
347            temperature: Some(5.0), // invalid
348            persona: Some("should not be applied".into()),
349            ..Default::default()
350        };
351        assert!(patch.apply(&mut config).is_err());
352        // Config should be unchanged
353        assert_eq!(config.temperature, 0.7);
354        assert!(config.persona.is_none());
355    }
356
357    // -------------------------------------------------------------------
358    // Boundary validation tests
359    // -------------------------------------------------------------------
360
361    /// Test that -2.0 and 2.0 are valid frequency_penalty values,
362    /// but -2.01 and 2.01 are rejected.
363    #[test]
364    fn test_validate_frequency_penalty_exact_boundaries() {
365        // -2.0 is valid (inclusive lower bound)
366        let patch_lower = ConfigPatch {
367            frequency_penalty: Some(-2.0),
368            ..Default::default()
369        };
370        assert!(
371            patch_lower.validate().is_ok(),
372            "frequency_penalty -2.0 should be valid"
373        );
374
375        // 2.0 is valid (inclusive upper bound)
376        let patch_upper = ConfigPatch {
377            frequency_penalty: Some(2.0),
378            ..Default::default()
379        };
380        assert!(
381            patch_upper.validate().is_ok(),
382            "frequency_penalty 2.0 should be valid"
383        );
384
385        // -2.01 is invalid (below lower bound)
386        let patch_below = ConfigPatch {
387            frequency_penalty: Some(-2.01),
388            ..Default::default()
389        };
390        let err = patch_below.validate().unwrap_err();
391        assert!(
392            err.contains("frequency_penalty"),
393            "error should mention frequency_penalty: {}",
394            err
395        );
396
397        // 2.01 is invalid (above upper bound)
398        let patch_above = ConfigPatch {
399            frequency_penalty: Some(2.01),
400            ..Default::default()
401        };
402        let err = patch_above.validate().unwrap_err();
403        assert!(
404            err.contains("frequency_penalty"),
405            "error should mention frequency_penalty: {}",
406            err
407        );
408    }
409
410    /// Test that -2.0 and 2.0 are valid presence_penalty values,
411    /// but -2.01 and 2.01 are rejected.
412    #[test]
413    fn test_validate_presence_penalty_exact_boundaries() {
414        // -2.0 is valid (inclusive lower bound)
415        let patch_lower = ConfigPatch {
416            presence_penalty: Some(-2.0),
417            ..Default::default()
418        };
419        assert!(
420            patch_lower.validate().is_ok(),
421            "presence_penalty -2.0 should be valid"
422        );
423
424        // 2.0 is valid (inclusive upper bound)
425        let patch_upper = ConfigPatch {
426            presence_penalty: Some(2.0),
427            ..Default::default()
428        };
429        assert!(
430            patch_upper.validate().is_ok(),
431            "presence_penalty 2.0 should be valid"
432        );
433
434        // -2.01 is invalid (below lower bound)
435        let patch_below = ConfigPatch {
436            presence_penalty: Some(-2.01),
437            ..Default::default()
438        };
439        let err = patch_below.validate().unwrap_err();
440        assert!(
441            err.contains("presence_penalty"),
442            "error should mention presence_penalty: {}",
443            err
444        );
445
446        // 2.01 is invalid (above upper bound)
447        let patch_above = ConfigPatch {
448            presence_penalty: Some(2.01),
449            ..Default::default()
450        };
451        let err = patch_above.validate().unwrap_err();
452        assert!(
453            err.contains("presence_penalty"),
454            "error should mention presence_penalty: {}",
455            err
456        );
457    }
458
459    /// Test that a very large value like i32::MAX is accepted for
460    /// max_react_iterations (validation only requires >= 1).
461    #[test]
462    fn test_validate_max_react_iterations_large_value() {
463        let patch = ConfigPatch {
464            max_react_iterations: Some(i32::MAX),
465            ..Default::default()
466        };
467        assert!(
468            patch.validate().is_ok(),
469            "max_react_iterations = i32::MAX ({}) should be valid",
470            i32::MAX
471        );
472    }
473
474    /// Test that persona = Some("") passes validation.
475    ///
476    /// The current validation does not reject empty strings for persona.
477    /// This test documents this behavior: an empty persona is accepted
478    /// at the ConfigPatch level; any semantic rejection (if desired)
479    /// would happen at a higher layer.
480    #[test]
481    fn test_validate_persona_empty_string() {
482        let patch = ConfigPatch {
483            persona: Some("".into()),
484            ..Default::default()
485        };
486        assert!(
487            patch.validate().is_ok(),
488            "empty persona string should pass validation"
489        );
490
491        // Also verify it applies correctly
492        let mut config = crate::AgentConfig {
493            name: "test".into(),
494            provider_id: "p".into(),
495            model_name: "m".into(),
496            temperature: 0.7,
497            persona: Some("original persona".into()),
498            ..Default::default()
499        };
500        patch
501            .apply(&mut config)
502            .expect("empty persona patch should apply");
503        assert_eq!(
504            config.persona,
505            Some("".into()),
506            "persona should be set to empty string, not None"
507        );
508    }
509
510    /// Exercises ALL ConfigPatch fields in apply(), ensuring every branch
511    /// in the apply() method is covered — specifically frequency_penalty,
512    /// presence_penalty, textual_feedback, and max_react_iterations.
513    #[test]
514    fn test_apply_all_config_patch_fields() {
515        let mut config = crate::AgentConfig {
516            name: "full-patch-test".into(),
517            provider_id: "provider".into(),
518            model_name: "model".into(),
519            temperature: 0.5,
520            frequency_penalty: None,
521            presence_penalty: None,
522            persona: None,
523            textual_feedback: false,
524            max_react_iterations: None,
525            max_retries: None,
526            ..Default::default()
527        };
528
529        // Patch ALL fields at once
530        let patch = ConfigPatch {
531            temperature: Some(1.5),
532            frequency_penalty: Some(-0.5),
533            presence_penalty: Some(0.8),
534            persona: Some("devil's advocate".into()),
535            textual_feedback: Some(true),
536            max_react_iterations: Some(7),
537            max_retries: Some(2),
538        };
539        patch.apply(&mut config).expect("full patch should apply");
540
541        // Verify every field was applied
542        assert_eq!(config.temperature, 1.5, "temperature should be updated");
543        assert_eq!(
544            config.frequency_penalty,
545            Some(-0.5),
546            "frequency_penalty should be set"
547        );
548        assert_eq!(
549            config.presence_penalty,
550            Some(0.8),
551            "presence_penalty should be set"
552        );
553        assert_eq!(
554            config.persona,
555            Some("devil's advocate".into()),
556            "persona should be set"
557        );
558        assert!(config.textual_feedback, "textual_feedback should be true");
559        assert_eq!(
560            config.max_react_iterations,
561            Some(7),
562            "max_react_iterations should be set"
563        );
564        assert_eq!(config.max_retries, Some(2), "max_retries should be set");
565
566        // Now apply a second patch that only sets the previously-uncovered fields
567        // to different values, verifying they overwrite correctly.
568        let patch2 = ConfigPatch {
569            frequency_penalty: Some(1.0),
570            presence_penalty: Some(-1.0),
571            textual_feedback: Some(false),
572            max_react_iterations: Some(3),
573            ..Default::default()
574        };
575        patch2
576            .apply(&mut config)
577            .expect("second patch should apply");
578
579        assert_eq!(
580            config.frequency_penalty,
581            Some(1.0),
582            "frequency_penalty should be overwritten"
583        );
584        assert_eq!(
585            config.presence_penalty,
586            Some(-1.0),
587            "presence_penalty should be overwritten"
588        );
589        assert!(
590            !config.textual_feedback,
591            "textual_feedback should be toggled back to false"
592        );
593        assert_eq!(
594            config.max_react_iterations,
595            Some(3),
596            "max_react_iterations should be overwritten"
597        );
598        // Fields not in patch2 should remain from patch1
599        assert_eq!(
600            config.temperature, 1.5,
601            "temperature should be unchanged by patch2"
602        );
603        assert_eq!(
604            config.persona,
605            Some("devil's advocate".into()),
606            "persona should be unchanged by patch2"
607        );
608    }
609}