1use crate::workers::buffer::BufferEntrySummary;
12use serde::{Deserialize, Serialize};
13
14#[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 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 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#[async_trait::async_trait]
120pub trait AgentControlPlane: Send + Sync {
121 async fn set_paused(&self, agent: &str, paused: bool) -> anyhow::Result<()>;
123
124 async fn update_config(&self, agent: &str, patch: ConfigPatch) -> anyhow::Result<()>;
126
127 async fn list_buffer(&self, agent: &str) -> anyhow::Result<Vec<BufferEntrySummary>>;
129
130 async fn release_buffer_entry(&self, agent: &str, entry_id: &str) -> anyhow::Result<()>;
132
133 async fn reject_buffer_entry(&self, agent: &str, entry_id: &str) -> anyhow::Result<()>;
135}
136
137#[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 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 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 #[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), persona: Some("should not be applied".into()),
349 ..Default::default()
350 };
351 assert!(patch.apply(&mut config).is_err());
352 assert_eq!(config.temperature, 0.7);
354 assert!(config.persona.is_none());
355 }
356
357 #[test]
364 fn test_validate_frequency_penalty_exact_boundaries() {
365 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 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 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 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]
413 fn test_validate_presence_penalty_exact_boundaries() {
414 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 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 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 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]
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]
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 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 #[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 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 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 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 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}