1use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::stream::StreamTransport;
13use crate::tool::ApprovalMode;
14use crate::types::ModelSpec;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RetryConfig {
21 pub max_attempts: u32,
23 pub base_delay_ms: u64,
25 pub max_delay_ms: u64,
27 pub multiplier: f64,
29 pub jitter: bool,
31}
32
33impl Default for RetryConfig {
34 fn default() -> Self {
35 let default = crate::retry::DefaultRetryStrategy::default();
36 Self {
37 max_attempts: default.max_attempts,
38 base_delay_ms: default
39 .base_delay
40 .as_millis()
41 .try_into()
42 .unwrap_or(u64::MAX),
43 max_delay_ms: default.max_delay.as_millis().try_into().unwrap_or(u64::MAX),
44 multiplier: default.multiplier,
45 jitter: default.jitter,
46 }
47 }
48}
49
50impl From<&crate::retry::DefaultRetryStrategy> for RetryConfig {
51 fn from(s: &crate::retry::DefaultRetryStrategy) -> Self {
52 Self {
53 max_attempts: s.max_attempts,
54 base_delay_ms: s.base_delay.as_millis().try_into().unwrap_or(u64::MAX),
55 max_delay_ms: s.max_delay.as_millis().try_into().unwrap_or(u64::MAX),
56 multiplier: s.multiplier,
57 jitter: s.jitter,
58 }
59 }
60}
61
62impl RetryConfig {
63 #[must_use]
65 pub const fn to_retry_strategy(&self) -> crate::retry::DefaultRetryStrategy {
66 crate::retry::DefaultRetryStrategy {
67 max_attempts: self.max_attempts,
68 base_delay: Duration::from_millis(self.base_delay_ms),
69 max_delay: Duration::from_millis(self.max_delay_ms),
70 multiplier: self.multiplier,
71 jitter: self.jitter,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct StreamOptionsConfig {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub temperature: Option<f64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub max_tokens: Option<u64>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub session_id: Option<String>,
93 #[serde(default)]
95 pub transport: StreamTransport,
96}
97
98impl From<&crate::stream::StreamOptions> for StreamOptionsConfig {
99 fn from(opts: &crate::stream::StreamOptions) -> Self {
100 Self {
101 temperature: opts.temperature,
102 max_tokens: opts.max_tokens,
103 session_id: opts.session_id.clone(),
104 transport: opts.transport,
105 }
106 }
107}
108
109impl StreamOptionsConfig {
110 #[must_use]
112 pub fn to_stream_options(&self) -> crate::stream::StreamOptions {
113 crate::stream::StreamOptions {
114 temperature: self.temperature,
115 max_tokens: self.max_tokens,
116 session_id: self.session_id.clone(),
117 api_key: None,
118 transport: self.transport,
119 cache_strategy: crate::stream::CacheStrategy::default(),
120 on_raw_payload: None,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SteeringModeConfig {
131 All,
132 #[default]
133 OneAtATime,
134}
135
136impl From<crate::agent::SteeringMode> for SteeringModeConfig {
137 fn from(m: crate::agent::SteeringMode) -> Self {
138 match m {
139 crate::agent::SteeringMode::All => Self::All,
140 crate::agent::SteeringMode::OneAtATime => Self::OneAtATime,
141 }
142 }
143}
144
145impl From<SteeringModeConfig> for crate::agent::SteeringMode {
146 fn from(m: SteeringModeConfig) -> Self {
147 match m {
148 SteeringModeConfig::All => Self::All,
149 SteeringModeConfig::OneAtATime => Self::OneAtATime,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum FollowUpModeConfig {
158 All,
159 #[default]
160 OneAtATime,
161}
162
163impl From<crate::agent::FollowUpMode> for FollowUpModeConfig {
164 fn from(m: crate::agent::FollowUpMode) -> Self {
165 match m {
166 crate::agent::FollowUpMode::All => Self::All,
167 crate::agent::FollowUpMode::OneAtATime => Self::OneAtATime,
168 }
169 }
170}
171
172impl From<FollowUpModeConfig> for crate::agent::FollowUpMode {
173 fn from(m: FollowUpModeConfig) -> Self {
174 match m {
175 FollowUpModeConfig::All => Self::All,
176 FollowUpModeConfig::OneAtATime => Self::OneAtATime,
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ApprovalModeConfig {
185 #[default]
186 Enabled,
187 Smart,
188 Bypassed,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct CacheConfigData {
198 pub ttl_ms: u64,
200 pub min_tokens: usize,
202 pub cache_intervals: usize,
204}
205
206impl From<&crate::context_cache::CacheConfig> for CacheConfigData {
207 fn from(c: &crate::context_cache::CacheConfig) -> Self {
208 Self {
209 ttl_ms: c.ttl.as_millis().try_into().unwrap_or(u64::MAX),
210 min_tokens: c.min_tokens,
211 cache_intervals: c.cache_intervals,
212 }
213 }
214}
215
216impl CacheConfigData {
217 #[must_use]
219 pub const fn to_cache_config(&self) -> crate::context_cache::CacheConfig {
220 crate::context_cache::CacheConfig::new(
221 std::time::Duration::from_millis(self.ttl_ms),
222 self.min_tokens,
223 self.cache_intervals,
224 )
225 }
226}
227
228impl From<ApprovalMode> for ApprovalModeConfig {
229 fn from(m: ApprovalMode) -> Self {
230 match m {
231 ApprovalMode::Enabled => Self::Enabled,
232 ApprovalMode::Smart => Self::Smart,
233 ApprovalMode::Bypassed => Self::Bypassed,
234 }
235 }
236}
237
238impl From<ApprovalModeConfig> for ApprovalMode {
239 fn from(m: ApprovalModeConfig) -> Self {
240 match m {
241 ApprovalModeConfig::Enabled => Self::Enabled,
242 ApprovalModeConfig::Smart => Self::Smart,
243 ApprovalModeConfig::Bypassed => Self::Bypassed,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct AgentConfig {
287 pub system_prompt: String,
289
290 pub model: ModelSpec,
292
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
295 pub tool_names: Vec<String>,
296
297 #[serde(default)]
299 pub retry: RetryConfig,
300
301 #[serde(default)]
303 pub stream_options: StreamOptionsConfig,
304
305 #[serde(default)]
307 pub steering_mode: SteeringModeConfig,
308
309 #[serde(default)]
311 pub follow_up_mode: FollowUpModeConfig,
312
313 #[serde(default = "default_structured_output_max_retries")]
315 pub structured_output_max_retries: usize,
316
317 #[serde(default)]
319 pub approval_mode: ApprovalModeConfig,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub plan_mode_addendum: Option<String>,
324
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub cache_config: Option<CacheConfigData>,
328
329 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
335 pub extra: serde_json::Value,
336}
337
338const fn default_structured_output_max_retries() -> usize {
339 3
340}
341
342impl AgentConfig {
343 #[must_use]
349 pub fn into_agent_options(
350 self,
351 stream_fn: std::sync::Arc<dyn crate::stream::StreamFn>,
352 convert_to_llm: impl Fn(&crate::types::AgentMessage) -> Option<crate::types::LlmMessage>
353 + Send
354 + Sync
355 + 'static,
356 ) -> crate::agent::AgentOptions {
357 let mut opts = crate::agent::AgentOptions::new(
358 self.system_prompt,
359 self.model,
360 stream_fn,
361 convert_to_llm,
362 );
363
364 opts.retry_strategy = Box::new(self.retry.to_retry_strategy());
365 opts.stream_options = self.stream_options.to_stream_options();
366 opts.steering_mode = self.steering_mode.into();
367 opts.follow_up_mode = self.follow_up_mode.into();
368 opts.structured_output_max_retries = self.structured_output_max_retries;
369 opts.approval_mode = self.approval_mode.into();
370 opts.plan_mode_addendum = self.plan_mode_addendum;
371 opts.cache_config = self.cache_config.map(|c| c.to_cache_config());
372
373 opts.transform_context = None;
376
377 opts
378 }
379}
380
381impl crate::agent::AgentOptions {
384 #[must_use]
390 pub fn to_config(&self) -> AgentConfig {
391 let tool_names: Vec<String> = self.tools.iter().map(|t| t.name().to_string()).collect();
392
393 let retry = downcast_retry_config(&*self.retry_strategy);
396
397 AgentConfig {
398 system_prompt: self.system_prompt.clone(),
399 model: self.model.clone(),
400 tool_names,
401 retry,
402 stream_options: StreamOptionsConfig::from(&self.stream_options),
403 steering_mode: self.steering_mode.into(),
404 follow_up_mode: self.follow_up_mode.into(),
405 structured_output_max_retries: self.structured_output_max_retries,
406 approval_mode: self.approval_mode.into(),
407 plan_mode_addendum: self.plan_mode_addendum.clone(),
408 cache_config: self.cache_config.as_ref().map(CacheConfigData::from),
409 extra: serde_json::Value::Null,
410 }
411 }
412
413 #[must_use]
418 pub fn from_config(
419 config: AgentConfig,
420 stream_fn: std::sync::Arc<dyn crate::stream::StreamFn>,
421 convert_to_llm: impl Fn(&crate::types::AgentMessage) -> Option<crate::types::LlmMessage>
422 + Send
423 + Sync
424 + 'static,
425 ) -> Self {
426 config.into_agent_options(stream_fn, convert_to_llm)
427 }
428}
429
430fn downcast_retry_config(strategy: &dyn crate::retry::RetryStrategy) -> RetryConfig {
433 strategy
434 .as_any()
435 .downcast_ref::<crate::retry::DefaultRetryStrategy>()
436 .map_or_else(RetryConfig::default, RetryConfig::from)
437}
438
439const _: () = {
442 const fn assert_send_sync<T: Send + Sync>() {}
443 assert_send_sync::<AgentConfig>();
444 assert_send_sync::<RetryConfig>();
445 assert_send_sync::<StreamOptionsConfig>();
446 assert_send_sync::<SteeringModeConfig>();
447 assert_send_sync::<FollowUpModeConfig>();
448 assert_send_sync::<ApprovalModeConfig>();
449};
450
451#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::types::ThinkingLevel;
457
458 #[test]
459 fn retry_config_roundtrip() {
460 let config = RetryConfig {
461 max_attempts: 5,
462 base_delay_ms: 2000,
463 max_delay_ms: 120_000,
464 multiplier: 3.0,
465 jitter: false,
466 };
467 let json = serde_json::to_string(&config).unwrap();
468 let restored: RetryConfig = serde_json::from_str(&json).unwrap();
469 assert_eq!(restored.max_attempts, 5);
470 assert_eq!(restored.base_delay_ms, 2000);
471 assert_eq!(restored.max_delay_ms, 120_000);
472 assert!((restored.multiplier - 3.0).abs() < f64::EPSILON);
473 assert!(!restored.jitter);
474 }
475
476 #[test]
477 fn retry_config_to_strategy_and_back() {
478 let config = RetryConfig {
479 max_attempts: 4,
480 base_delay_ms: 500,
481 max_delay_ms: 30_000,
482 multiplier: 1.5,
483 jitter: true,
484 };
485 let strategy = config.to_retry_strategy();
486 assert_eq!(strategy.max_attempts, 4);
487 assert_eq!(strategy.base_delay, Duration::from_millis(500));
488 assert_eq!(strategy.max_delay, Duration::from_millis(30_000));
489 assert!((strategy.multiplier - 1.5).abs() < f64::EPSILON);
490 assert!(strategy.jitter);
491
492 let back = RetryConfig::from(&strategy);
493 assert_eq!(back.max_attempts, 4);
494 assert_eq!(back.base_delay_ms, 500);
495 }
496
497 #[test]
498 fn stream_options_config_roundtrip() {
499 let config = StreamOptionsConfig {
500 temperature: Some(0.7),
501 max_tokens: Some(4096),
502 session_id: Some("sess-123".into()),
503 transport: StreamTransport::Sse,
504 };
505 let json = serde_json::to_string(&config).unwrap();
506 let restored: StreamOptionsConfig = serde_json::from_str(&json).unwrap();
507 assert_eq!(restored.temperature, Some(0.7));
508 assert_eq!(restored.max_tokens, Some(4096));
509 assert_eq!(restored.session_id.as_deref(), Some("sess-123"));
510 }
511
512 #[test]
513 fn stream_options_config_omits_api_key() {
514 let opts = crate::stream::StreamOptions {
515 temperature: Some(0.5),
516 max_tokens: None,
517 session_id: None,
518 api_key: Some("secret-key".into()),
519 transport: StreamTransport::Sse,
520 cache_strategy: crate::stream::CacheStrategy::default(),
521 on_raw_payload: None,
522 };
523 let config = StreamOptionsConfig::from(&opts);
524 let json = serde_json::to_string(&config).unwrap();
525 assert!(!json.contains("secret-key"));
526
527 let restored_opts = config.to_stream_options();
528 assert!(restored_opts.api_key.is_none());
529 assert_eq!(restored_opts.temperature, Some(0.5));
530 }
531
532 #[test]
533 fn agent_config_serde_roundtrip() {
534 let config = AgentConfig {
535 system_prompt: "Be helpful.".into(),
536 model: ModelSpec::new("anthropic", "claude-sonnet")
537 .with_thinking_level(ThinkingLevel::Medium),
538 tool_names: vec!["bash".into(), "read_file".into()],
539 retry: RetryConfig {
540 max_attempts: 5,
541 base_delay_ms: 1000,
542 max_delay_ms: 60_000,
543 multiplier: 2.0,
544 jitter: true,
545 },
546 stream_options: StreamOptionsConfig {
547 temperature: Some(0.7),
548 max_tokens: Some(8192),
549 session_id: None,
550 transport: StreamTransport::Sse,
551 },
552 steering_mode: SteeringModeConfig::OneAtATime,
553 follow_up_mode: FollowUpModeConfig::All,
554 structured_output_max_retries: 5,
555 approval_mode: ApprovalModeConfig::Smart,
556 plan_mode_addendum: Some("Custom plan instructions.".into()),
557 cache_config: Some(CacheConfigData {
558 ttl_ms: 300_000,
559 min_tokens: 1024,
560 cache_intervals: 4,
561 }),
562 extra: serde_json::json!({"custom_key": "custom_value"}),
563 };
564
565 let json = serde_json::to_string_pretty(&config).unwrap();
566 let restored: AgentConfig = serde_json::from_str(&json).unwrap();
567
568 assert_eq!(restored.system_prompt, "Be helpful.");
569 assert_eq!(restored.model.provider, "anthropic");
570 assert_eq!(restored.model.model_id, "claude-sonnet");
571 assert_eq!(restored.model.thinking_level, ThinkingLevel::Medium);
572 assert_eq!(restored.tool_names, vec!["bash", "read_file"]);
573 assert_eq!(restored.retry.max_attempts, 5);
574 assert_eq!(restored.stream_options.temperature, Some(0.7));
575 assert_eq!(restored.stream_options.max_tokens, Some(8192));
576 assert_eq!(restored.steering_mode, SteeringModeConfig::OneAtATime);
577 assert_eq!(restored.follow_up_mode, FollowUpModeConfig::All);
578 assert_eq!(restored.structured_output_max_retries, 5);
579 assert_eq!(restored.approval_mode, ApprovalModeConfig::Smart);
580 assert_eq!(
581 restored.plan_mode_addendum.as_deref(),
582 Some("Custom plan instructions.")
583 );
584 let cc = restored.cache_config.unwrap();
585 assert_eq!(cc.ttl_ms, 300_000);
586 assert_eq!(cc.min_tokens, 1024);
587 assert_eq!(cc.cache_intervals, 4);
588 assert_eq!(restored.extra["custom_key"], "custom_value");
589 }
590
591 #[test]
592 fn agent_config_minimal_json_deserializes() {
593 let json = r#"{
595 "system_prompt": "Hello",
596 "model": {
597 "provider": "openai",
598 "model_id": "gpt-4",
599 "thinking_level": "off"
600 }
601 }"#;
602
603 let config: AgentConfig = serde_json::from_str(json).unwrap();
604 assert_eq!(config.system_prompt, "Hello");
605 assert_eq!(config.model.provider, "openai");
606 assert!(config.tool_names.is_empty());
607 assert_eq!(config.retry.max_attempts, 3); assert_eq!(config.structured_output_max_retries, 3); }
610
611 #[test]
612 fn old_json_with_removed_fields_still_deserializes() {
613 let json = r#"{
615 "system_prompt": "Hello",
616 "model": { "provider": "openai", "model_id": "gpt-4", "thinking_level": "off" },
617 "available_models": [{ "provider": "openai", "model_id": "gpt-4o", "thinking_level": "off" }],
618 "fallback_models": [{ "provider": "openai", "model_id": "gpt-4o-mini", "thinking_level": "off" }],
619 "budget_guard": { "max_cost": 10.0, "max_tokens": 100000 }
620 }"#;
621 let config: AgentConfig = serde_json::from_str(json).unwrap();
622 assert_eq!(config.system_prompt, "Hello");
623 assert_eq!(config.model.provider, "openai");
624 }
625
626 #[test]
627 #[cfg(feature = "testkit")]
628 fn config_round_trip_only_contains_restorable_fields() {
629 let config = AgentConfig {
634 system_prompt: "test".into(),
635 model: ModelSpec::new("anthropic", "claude-sonnet"),
636 tool_names: vec!["bash".into()],
637 retry: RetryConfig {
638 max_attempts: 7,
639 base_delay_ms: 500,
640 max_delay_ms: 10_000,
641 multiplier: 1.5,
642 jitter: false,
643 },
644 stream_options: StreamOptionsConfig {
645 temperature: Some(0.3),
646 max_tokens: Some(2048),
647 session_id: Some("s1".into()),
648 transport: StreamTransport::Sse,
649 },
650 steering_mode: SteeringModeConfig::All,
651 follow_up_mode: FollowUpModeConfig::All,
652 structured_output_max_retries: 10,
653 approval_mode: ApprovalModeConfig::Bypassed,
654 plan_mode_addendum: Some("Plan mode text.".into()),
655 cache_config: Some(CacheConfigData {
656 ttl_ms: 60_000,
657 min_tokens: 512,
658 cache_intervals: 3,
659 }),
660 extra: serde_json::json!({"k": "v"}),
661 };
662
663 let stream_fn: std::sync::Arc<dyn crate::stream::StreamFn> =
664 std::sync::Arc::new(crate::testing::MockStreamFn::new(vec![]));
665 let opts = config
666 .clone()
667 .into_agent_options(stream_fn, crate::agent::default_convert);
668
669 assert_eq!(opts.system_prompt, config.system_prompt);
670 assert_eq!(opts.model.provider, config.model.provider);
671 assert_eq!(opts.model.model_id, config.model.model_id);
672 assert_eq!(
673 opts.stream_options.temperature,
674 config.stream_options.temperature
675 );
676 assert_eq!(
677 opts.stream_options.max_tokens,
678 config.stream_options.max_tokens
679 );
680 assert_eq!(
681 opts.structured_output_max_retries,
682 config.structured_output_max_retries
683 );
684 assert!(matches!(
685 opts.steering_mode,
686 crate::agent::SteeringMode::All
687 ));
688 assert!(matches!(
689 opts.follow_up_mode,
690 crate::agent::FollowUpMode::All
691 ));
692 assert!(matches!(
693 opts.approval_mode,
694 crate::tool::ApprovalMode::Bypassed
695 ));
696 assert_eq!(opts.plan_mode_addendum.as_deref(), Some("Plan mode text."));
697 let cc = opts.cache_config.unwrap();
698 assert_eq!(cc.ttl.as_millis(), 60_000);
699 assert_eq!(cc.min_tokens, 512);
700 assert_eq!(cc.cache_intervals, 3);
701 }
702
703 #[test]
704 fn approval_mode_config_roundtrip() {
705 for (mode, expected) in [
706 (ApprovalModeConfig::Enabled, "\"enabled\""),
707 (ApprovalModeConfig::Smart, "\"smart\""),
708 (ApprovalModeConfig::Bypassed, "\"bypassed\""),
709 ] {
710 let json = serde_json::to_string(&mode).unwrap();
711 assert_eq!(json, expected);
712 let back: ApprovalModeConfig = serde_json::from_str(&json).unwrap();
713 assert_eq!(back, mode);
714 }
715 }
716
717 #[test]
718 fn cache_config_data_roundtrip() {
719 let data = CacheConfigData {
720 ttl_ms: 120_000,
721 min_tokens: 2048,
722 cache_intervals: 5,
723 };
724 let cc = data.to_cache_config();
725 assert_eq!(cc.ttl, Duration::from_millis(120_000));
726 assert_eq!(cc.min_tokens, 2048);
727 assert_eq!(cc.cache_intervals, 5);
728
729 let back = CacheConfigData::from(&cc);
730 assert_eq!(back.ttl_ms, 120_000);
731 assert_eq!(back.min_tokens, 2048);
732 assert_eq!(back.cache_intervals, 5);
733 }
734
735 #[test]
736 #[cfg(feature = "testkit")]
737 fn to_config_captures_plan_mode_and_cache() {
738 let stream_fn: std::sync::Arc<dyn crate::stream::StreamFn> =
739 std::sync::Arc::new(crate::testing::MockStreamFn::new(vec![]));
740 let mut opts = crate::agent::AgentOptions::new(
741 "test",
742 crate::types::ModelSpec::new("anthropic", "claude-sonnet"),
743 stream_fn,
744 crate::agent::default_convert,
745 );
746 opts.plan_mode_addendum = Some("custom addendum".into());
747 opts.cache_config = Some(crate::context_cache::CacheConfig::new(
748 Duration::from_secs(300),
749 1024,
750 4,
751 ));
752
753 let config = opts.to_config();
754 assert_eq!(
755 config.plan_mode_addendum.as_deref(),
756 Some("custom addendum")
757 );
758 let cc = config.cache_config.unwrap();
759 assert_eq!(cc.ttl_ms, 300_000);
760 assert_eq!(cc.min_tokens, 1024);
761 assert_eq!(cc.cache_intervals, 4);
762 }
763
764 #[test]
765 fn steering_follow_up_mode_conversions() {
766 let all: SteeringModeConfig = crate::agent::SteeringMode::All.into();
768 assert_eq!(all, SteeringModeConfig::All);
769 let back: crate::agent::SteeringMode = all.into();
770 assert!(matches!(back, crate::agent::SteeringMode::All));
771
772 let one: FollowUpModeConfig = crate::agent::FollowUpMode::OneAtATime.into();
774 assert_eq!(one, FollowUpModeConfig::OneAtATime);
775 let back: crate::agent::FollowUpMode = one.into();
776 assert!(matches!(back, crate::agent::FollowUpMode::OneAtATime));
777 }
778}