use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::stream::StreamTransport;
use crate::tool::ApprovalMode;
use crate::types::ModelSpec;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
pub max_attempts: u32,
pub base_delay_ms: u64,
pub max_delay_ms: u64,
pub multiplier: f64,
pub jitter: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
let default = crate::retry::DefaultRetryStrategy::default();
Self {
max_attempts: default.max_attempts,
base_delay_ms: default
.base_delay
.as_millis()
.try_into()
.unwrap_or(u64::MAX),
max_delay_ms: default.max_delay.as_millis().try_into().unwrap_or(u64::MAX),
multiplier: default.multiplier,
jitter: default.jitter,
}
}
}
impl From<&crate::retry::DefaultRetryStrategy> for RetryConfig {
fn from(s: &crate::retry::DefaultRetryStrategy) -> Self {
Self {
max_attempts: s.max_attempts,
base_delay_ms: s.base_delay.as_millis().try_into().unwrap_or(u64::MAX),
max_delay_ms: s.max_delay.as_millis().try_into().unwrap_or(u64::MAX),
multiplier: s.multiplier,
jitter: s.jitter,
}
}
}
impl RetryConfig {
#[must_use]
pub const fn to_retry_strategy(&self) -> crate::retry::DefaultRetryStrategy {
crate::retry::DefaultRetryStrategy {
max_attempts: self.max_attempts,
base_delay: Duration::from_millis(self.base_delay_ms),
max_delay: Duration::from_millis(self.max_delay_ms),
multiplier: self.multiplier,
jitter: self.jitter,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StreamOptionsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default)]
pub transport: StreamTransport,
}
impl From<&crate::stream::StreamOptions> for StreamOptionsConfig {
fn from(opts: &crate::stream::StreamOptions) -> Self {
Self {
temperature: opts.temperature,
max_tokens: opts.max_tokens,
session_id: opts.session_id.clone(),
transport: opts.transport,
}
}
}
impl StreamOptionsConfig {
#[must_use]
pub fn to_stream_options(&self) -> crate::stream::StreamOptions {
crate::stream::StreamOptions {
temperature: self.temperature,
max_tokens: self.max_tokens,
session_id: self.session_id.clone(),
api_key: None,
transport: self.transport,
cache_strategy: crate::stream::CacheStrategy::default(),
on_raw_payload: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SteeringModeConfig {
All,
#[default]
OneAtATime,
}
impl From<crate::agent::SteeringMode> for SteeringModeConfig {
fn from(m: crate::agent::SteeringMode) -> Self {
match m {
crate::agent::SteeringMode::All => Self::All,
crate::agent::SteeringMode::OneAtATime => Self::OneAtATime,
}
}
}
impl From<SteeringModeConfig> for crate::agent::SteeringMode {
fn from(m: SteeringModeConfig) -> Self {
match m {
SteeringModeConfig::All => Self::All,
SteeringModeConfig::OneAtATime => Self::OneAtATime,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FollowUpModeConfig {
All,
#[default]
OneAtATime,
}
impl From<crate::agent::FollowUpMode> for FollowUpModeConfig {
fn from(m: crate::agent::FollowUpMode) -> Self {
match m {
crate::agent::FollowUpMode::All => Self::All,
crate::agent::FollowUpMode::OneAtATime => Self::OneAtATime,
}
}
}
impl From<FollowUpModeConfig> for crate::agent::FollowUpMode {
fn from(m: FollowUpModeConfig) -> Self {
match m {
FollowUpModeConfig::All => Self::All,
FollowUpModeConfig::OneAtATime => Self::OneAtATime,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalModeConfig {
#[default]
Enabled,
Smart,
Bypassed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfigData {
pub ttl_ms: u64,
pub min_tokens: usize,
pub cache_intervals: usize,
}
impl From<&crate::context_cache::CacheConfig> for CacheConfigData {
fn from(c: &crate::context_cache::CacheConfig) -> Self {
Self {
ttl_ms: c.ttl.as_millis().try_into().unwrap_or(u64::MAX),
min_tokens: c.min_tokens,
cache_intervals: c.cache_intervals,
}
}
}
impl CacheConfigData {
#[must_use]
pub const fn to_cache_config(&self) -> crate::context_cache::CacheConfig {
crate::context_cache::CacheConfig::new(
std::time::Duration::from_millis(self.ttl_ms),
self.min_tokens,
self.cache_intervals,
)
}
}
impl From<ApprovalMode> for ApprovalModeConfig {
fn from(m: ApprovalMode) -> Self {
match m {
ApprovalMode::Enabled => Self::Enabled,
ApprovalMode::Smart => Self::Smart,
ApprovalMode::Bypassed => Self::Bypassed,
}
}
}
impl From<ApprovalModeConfig> for ApprovalMode {
fn from(m: ApprovalModeConfig) -> Self {
match m {
ApprovalModeConfig::Enabled => Self::Enabled,
ApprovalModeConfig::Smart => Self::Smart,
ApprovalModeConfig::Bypassed => Self::Bypassed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub system_prompt: String,
pub model: ModelSpec,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_names: Vec<String>,
#[serde(default)]
pub retry: RetryConfig,
#[serde(default)]
pub stream_options: StreamOptionsConfig,
#[serde(default)]
pub steering_mode: SteeringModeConfig,
#[serde(default)]
pub follow_up_mode: FollowUpModeConfig,
#[serde(default = "default_structured_output_max_retries")]
pub structured_output_max_retries: usize,
#[serde(default)]
pub approval_mode: ApprovalModeConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_mode_addendum: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_config: Option<CacheConfigData>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub extra: serde_json::Value,
}
const fn default_structured_output_max_retries() -> usize {
3
}
impl AgentConfig {
#[must_use]
pub fn into_agent_options(
self,
stream_fn: std::sync::Arc<dyn crate::stream::StreamFn>,
convert_to_llm: impl Fn(&crate::types::AgentMessage) -> Option<crate::types::LlmMessage>
+ Send
+ Sync
+ 'static,
) -> crate::agent::AgentOptions {
let mut opts = crate::agent::AgentOptions::new(
self.system_prompt,
self.model,
stream_fn,
convert_to_llm,
);
opts.retry_strategy = Box::new(self.retry.to_retry_strategy());
opts.stream_options = self.stream_options.to_stream_options();
opts.steering_mode = self.steering_mode.into();
opts.follow_up_mode = self.follow_up_mode.into();
opts.structured_output_max_retries = self.structured_output_max_retries;
opts.approval_mode = self.approval_mode.into();
opts.plan_mode_addendum = self.plan_mode_addendum;
opts.cache_config = self.cache_config.map(|c| c.to_cache_config());
opts.transform_context = None;
opts
}
}
impl crate::agent::AgentOptions {
#[must_use]
pub fn to_config(&self) -> AgentConfig {
let tool_names: Vec<String> = self.tools.iter().map(|t| t.name().to_string()).collect();
let retry = downcast_retry_config(&*self.retry_strategy);
AgentConfig {
system_prompt: self.system_prompt.clone(),
model: self.model.clone(),
tool_names,
retry,
stream_options: StreamOptionsConfig::from(&self.stream_options),
steering_mode: self.steering_mode.into(),
follow_up_mode: self.follow_up_mode.into(),
structured_output_max_retries: self.structured_output_max_retries,
approval_mode: self.approval_mode.into(),
plan_mode_addendum: self.plan_mode_addendum.clone(),
cache_config: self.cache_config.as_ref().map(CacheConfigData::from),
extra: serde_json::Value::Null,
}
}
#[must_use]
pub fn from_config(
config: AgentConfig,
stream_fn: std::sync::Arc<dyn crate::stream::StreamFn>,
convert_to_llm: impl Fn(&crate::types::AgentMessage) -> Option<crate::types::LlmMessage>
+ Send
+ Sync
+ 'static,
) -> Self {
config.into_agent_options(stream_fn, convert_to_llm)
}
}
fn downcast_retry_config(strategy: &dyn crate::retry::RetryStrategy) -> RetryConfig {
strategy
.as_any()
.downcast_ref::<crate::retry::DefaultRetryStrategy>()
.map_or_else(RetryConfig::default, RetryConfig::from)
}
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AgentConfig>();
assert_send_sync::<RetryConfig>();
assert_send_sync::<StreamOptionsConfig>();
assert_send_sync::<SteeringModeConfig>();
assert_send_sync::<FollowUpModeConfig>();
assert_send_sync::<ApprovalModeConfig>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ThinkingLevel;
#[test]
fn retry_config_roundtrip() {
let config = RetryConfig {
max_attempts: 5,
base_delay_ms: 2000,
max_delay_ms: 120_000,
multiplier: 3.0,
jitter: false,
};
let json = serde_json::to_string(&config).unwrap();
let restored: RetryConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.max_attempts, 5);
assert_eq!(restored.base_delay_ms, 2000);
assert_eq!(restored.max_delay_ms, 120_000);
assert!((restored.multiplier - 3.0).abs() < f64::EPSILON);
assert!(!restored.jitter);
}
#[test]
fn retry_config_to_strategy_and_back() {
let config = RetryConfig {
max_attempts: 4,
base_delay_ms: 500,
max_delay_ms: 30_000,
multiplier: 1.5,
jitter: true,
};
let strategy = config.to_retry_strategy();
assert_eq!(strategy.max_attempts, 4);
assert_eq!(strategy.base_delay, Duration::from_millis(500));
assert_eq!(strategy.max_delay, Duration::from_millis(30_000));
assert!((strategy.multiplier - 1.5).abs() < f64::EPSILON);
assert!(strategy.jitter);
let back = RetryConfig::from(&strategy);
assert_eq!(back.max_attempts, 4);
assert_eq!(back.base_delay_ms, 500);
}
#[test]
fn stream_options_config_roundtrip() {
let config = StreamOptionsConfig {
temperature: Some(0.7),
max_tokens: Some(4096),
session_id: Some("sess-123".into()),
transport: StreamTransport::Sse,
};
let json = serde_json::to_string(&config).unwrap();
let restored: StreamOptionsConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.temperature, Some(0.7));
assert_eq!(restored.max_tokens, Some(4096));
assert_eq!(restored.session_id.as_deref(), Some("sess-123"));
}
#[test]
fn stream_options_config_omits_api_key() {
let opts = crate::stream::StreamOptions {
temperature: Some(0.5),
max_tokens: None,
session_id: None,
api_key: Some("secret-key".into()),
transport: StreamTransport::Sse,
cache_strategy: crate::stream::CacheStrategy::default(),
on_raw_payload: None,
};
let config = StreamOptionsConfig::from(&opts);
let json = serde_json::to_string(&config).unwrap();
assert!(!json.contains("secret-key"));
let restored_opts = config.to_stream_options();
assert!(restored_opts.api_key.is_none());
assert_eq!(restored_opts.temperature, Some(0.5));
}
#[test]
fn agent_config_serde_roundtrip() {
let config = AgentConfig {
system_prompt: "Be helpful.".into(),
model: ModelSpec::new("anthropic", "claude-sonnet")
.with_thinking_level(ThinkingLevel::Medium),
tool_names: vec!["bash".into(), "read_file".into()],
retry: RetryConfig {
max_attempts: 5,
base_delay_ms: 1000,
max_delay_ms: 60_000,
multiplier: 2.0,
jitter: true,
},
stream_options: StreamOptionsConfig {
temperature: Some(0.7),
max_tokens: Some(8192),
session_id: None,
transport: StreamTransport::Sse,
},
steering_mode: SteeringModeConfig::OneAtATime,
follow_up_mode: FollowUpModeConfig::All,
structured_output_max_retries: 5,
approval_mode: ApprovalModeConfig::Smart,
plan_mode_addendum: Some("Custom plan instructions.".into()),
cache_config: Some(CacheConfigData {
ttl_ms: 300_000,
min_tokens: 1024,
cache_intervals: 4,
}),
extra: serde_json::json!({"custom_key": "custom_value"}),
};
let json = serde_json::to_string_pretty(&config).unwrap();
let restored: AgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.system_prompt, "Be helpful.");
assert_eq!(restored.model.provider, "anthropic");
assert_eq!(restored.model.model_id, "claude-sonnet");
assert_eq!(restored.model.thinking_level, ThinkingLevel::Medium);
assert_eq!(restored.tool_names, vec!["bash", "read_file"]);
assert_eq!(restored.retry.max_attempts, 5);
assert_eq!(restored.stream_options.temperature, Some(0.7));
assert_eq!(restored.stream_options.max_tokens, Some(8192));
assert_eq!(restored.steering_mode, SteeringModeConfig::OneAtATime);
assert_eq!(restored.follow_up_mode, FollowUpModeConfig::All);
assert_eq!(restored.structured_output_max_retries, 5);
assert_eq!(restored.approval_mode, ApprovalModeConfig::Smart);
assert_eq!(
restored.plan_mode_addendum.as_deref(),
Some("Custom plan instructions.")
);
let cc = restored.cache_config.unwrap();
assert_eq!(cc.ttl_ms, 300_000);
assert_eq!(cc.min_tokens, 1024);
assert_eq!(cc.cache_intervals, 4);
assert_eq!(restored.extra["custom_key"], "custom_value");
}
#[test]
fn agent_config_minimal_json_deserializes() {
let json = r#"{
"system_prompt": "Hello",
"model": {
"provider": "openai",
"model_id": "gpt-4",
"thinking_level": "off"
}
}"#;
let config: AgentConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.system_prompt, "Hello");
assert_eq!(config.model.provider, "openai");
assert!(config.tool_names.is_empty());
assert_eq!(config.retry.max_attempts, 3); assert_eq!(config.structured_output_max_retries, 3); }
#[test]
fn old_json_with_removed_fields_still_deserializes() {
let json = r#"{
"system_prompt": "Hello",
"model": { "provider": "openai", "model_id": "gpt-4", "thinking_level": "off" },
"available_models": [{ "provider": "openai", "model_id": "gpt-4o", "thinking_level": "off" }],
"fallback_models": [{ "provider": "openai", "model_id": "gpt-4o-mini", "thinking_level": "off" }],
"budget_guard": { "max_cost": 10.0, "max_tokens": 100000 }
}"#;
let config: AgentConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.system_prompt, "Hello");
assert_eq!(config.model.provider, "openai");
}
#[test]
#[cfg(feature = "testkit")]
fn config_round_trip_only_contains_restorable_fields() {
let config = AgentConfig {
system_prompt: "test".into(),
model: ModelSpec::new("anthropic", "claude-sonnet"),
tool_names: vec!["bash".into()],
retry: RetryConfig {
max_attempts: 7,
base_delay_ms: 500,
max_delay_ms: 10_000,
multiplier: 1.5,
jitter: false,
},
stream_options: StreamOptionsConfig {
temperature: Some(0.3),
max_tokens: Some(2048),
session_id: Some("s1".into()),
transport: StreamTransport::Sse,
},
steering_mode: SteeringModeConfig::All,
follow_up_mode: FollowUpModeConfig::All,
structured_output_max_retries: 10,
approval_mode: ApprovalModeConfig::Bypassed,
plan_mode_addendum: Some("Plan mode text.".into()),
cache_config: Some(CacheConfigData {
ttl_ms: 60_000,
min_tokens: 512,
cache_intervals: 3,
}),
extra: serde_json::json!({"k": "v"}),
};
let stream_fn: std::sync::Arc<dyn crate::stream::StreamFn> =
std::sync::Arc::new(crate::testing::MockStreamFn::new(vec![]));
let opts = config
.clone()
.into_agent_options(stream_fn, crate::agent::default_convert);
assert_eq!(opts.system_prompt, config.system_prompt);
assert_eq!(opts.model.provider, config.model.provider);
assert_eq!(opts.model.model_id, config.model.model_id);
assert_eq!(
opts.stream_options.temperature,
config.stream_options.temperature
);
assert_eq!(
opts.stream_options.max_tokens,
config.stream_options.max_tokens
);
assert_eq!(
opts.structured_output_max_retries,
config.structured_output_max_retries
);
assert!(matches!(
opts.steering_mode,
crate::agent::SteeringMode::All
));
assert!(matches!(
opts.follow_up_mode,
crate::agent::FollowUpMode::All
));
assert!(matches!(
opts.approval_mode,
crate::tool::ApprovalMode::Bypassed
));
assert_eq!(opts.plan_mode_addendum.as_deref(), Some("Plan mode text."));
let cc = opts.cache_config.unwrap();
assert_eq!(cc.ttl.as_millis(), 60_000);
assert_eq!(cc.min_tokens, 512);
assert_eq!(cc.cache_intervals, 3);
}
#[test]
fn approval_mode_config_roundtrip() {
for (mode, expected) in [
(ApprovalModeConfig::Enabled, "\"enabled\""),
(ApprovalModeConfig::Smart, "\"smart\""),
(ApprovalModeConfig::Bypassed, "\"bypassed\""),
] {
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, expected);
let back: ApprovalModeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, mode);
}
}
#[test]
fn cache_config_data_roundtrip() {
let data = CacheConfigData {
ttl_ms: 120_000,
min_tokens: 2048,
cache_intervals: 5,
};
let cc = data.to_cache_config();
assert_eq!(cc.ttl, Duration::from_millis(120_000));
assert_eq!(cc.min_tokens, 2048);
assert_eq!(cc.cache_intervals, 5);
let back = CacheConfigData::from(&cc);
assert_eq!(back.ttl_ms, 120_000);
assert_eq!(back.min_tokens, 2048);
assert_eq!(back.cache_intervals, 5);
}
#[test]
#[cfg(feature = "testkit")]
fn to_config_captures_plan_mode_and_cache() {
let stream_fn: std::sync::Arc<dyn crate::stream::StreamFn> =
std::sync::Arc::new(crate::testing::MockStreamFn::new(vec![]));
let mut opts = crate::agent::AgentOptions::new(
"test",
crate::types::ModelSpec::new("anthropic", "claude-sonnet"),
stream_fn,
crate::agent::default_convert,
);
opts.plan_mode_addendum = Some("custom addendum".into());
opts.cache_config = Some(crate::context_cache::CacheConfig::new(
Duration::from_secs(300),
1024,
4,
));
let config = opts.to_config();
assert_eq!(
config.plan_mode_addendum.as_deref(),
Some("custom addendum")
);
let cc = config.cache_config.unwrap();
assert_eq!(cc.ttl_ms, 300_000);
assert_eq!(cc.min_tokens, 1024);
assert_eq!(cc.cache_intervals, 4);
}
#[test]
fn steering_follow_up_mode_conversions() {
let all: SteeringModeConfig = crate::agent::SteeringMode::All.into();
assert_eq!(all, SteeringModeConfig::All);
let back: crate::agent::SteeringMode = all.into();
assert!(matches!(back, crate::agent::SteeringMode::All));
let one: FollowUpModeConfig = crate::agent::FollowUpMode::OneAtATime.into();
assert_eq!(one, FollowUpModeConfig::OneAtATime);
let back: crate::agent::FollowUpMode = one.into();
assert!(matches!(back, crate::agent::FollowUpMode::OneAtATime));
}
}