use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use typed_builder::TypedBuilder;
use super::{
DEFAULT_MODEL, capabilities::CapabilitiesConfig, mcp::McpServer, models::GeminiConfig,
};
use crate::{
hooks::HookEntry, policies::PolicyRule, tools::ToolDefinition, triggers::TriggerEntry,
};
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SystemInstructionSection {
pub content: String,
#[serde(default = "default_section_title")]
pub title: String,
}
fn default_section_title() -> String {
"user_system_instructions".to_owned()
}
fn default_model_name() -> String {
DEFAULT_MODEL.to_owned()
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SystemInstructions {
Custom(String),
Templated {
#[serde(default)]
identity: Option<String>,
#[serde(default)]
sections: Vec<SystemInstructionSection>,
},
}
impl SystemInstructions {
#[must_use]
pub fn custom(text: impl Into<String>) -> Self {
Self::Custom(text.into())
}
}
impl From<&str> for SystemInstructions {
fn from(s: &str) -> Self {
Self::custom(s)
}
}
impl From<String> for SystemInstructions {
fn from(s: String) -> Self {
Self::custom(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct JsonSchema(serde_json::Value);
impl JsonSchema {
#[must_use]
pub const fn new(value: serde_json::Value) -> Self {
Self(value)
}
#[must_use]
pub const fn as_value(&self) -> &serde_json::Value {
&self.0
}
pub fn validate(&self) -> Result<(), &'static str> {
if self.0.is_object() {
Ok(())
} else {
Err("JSON Schema must be a JSON object at the top level")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
#[builder(field_defaults(default))]
pub struct AgentConfig {
#[serde(default = "default_model_name")]
#[builder(default = DEFAULT_MODEL.to_owned(), setter(into))]
pub model: String,
#[serde(default)]
#[builder(setter(into, strip_option))]
pub api_key: Option<String>,
#[builder(setter(into, strip_option))]
pub system_instructions: Option<SystemInstructions>,
#[serde(default)]
#[builder(setter(strip_option))]
pub capabilities: Option<CapabilitiesConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
pub workspaces: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<ToolDefinition>>| v.into_iter().map(Into::into).collect()))]
pub tools: Vec<ToolDefinition>,
#[serde(default = "default_policies")]
#[builder(default = default_policies(), setter(transform = |v: impl IntoIterator<Item = impl Into<PolicyRule>>| v.into_iter().map(Into::into).collect()))]
pub policies: Vec<PolicyRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<TriggerEntry>>| v.into_iter().map(Into::into).collect()))]
pub triggers: Vec<TriggerEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<HookEntry>>| v.into_iter().map(Into::into).collect()))]
pub hooks: Vec<HookEntry>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
rename = "skills_paths"
)]
#[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
pub skills: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<McpServer>>| v.into_iter().map(Into::into).collect()))]
pub mcp_servers: Vec<McpServer>,
#[serde(default)]
#[builder(setter(into, strip_option))]
pub conversation_id: Option<String>,
#[serde(default)]
#[builder(setter(into, strip_option))]
pub save_dir: Option<PathBuf>,
#[serde(default)]
#[builder(setter(into, strip_option))]
pub app_data_dir: Option<PathBuf>,
#[serde(default)]
#[builder(setter(strip_option))]
pub response_schema: Option<JsonSchema>,
#[serde(default, rename = "gemini_config")]
#[builder(setter(strip_option))]
pub gemini: Option<GeminiConfig>,
#[serde(default)]
#[builder(setter(into, strip_option))]
pub max_quota_retries: Option<u32>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl AgentConfig {
#[must_use]
pub fn effective_api_key(&self) -> Option<String> {
self.gemini
.as_ref()
.and_then(|g| g.models.default.api_key.clone())
.or_else(|| self.gemini.as_ref().and_then(|g| g.api_key.clone()))
.or_else(|| self.api_key.clone())
.or_else(|| std::env::var("GEMINI_API_KEY").ok())
}
#[must_use]
pub fn custom_tool_names(&self) -> Vec<String> {
self.tools.iter().map(|t| t.name.clone()).collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LocalAgentConfig {
#[serde(flatten)]
pub agent: AgentConfig,
}
impl LocalAgentConfig {
#[must_use]
pub const fn new(agent: AgentConfig) -> Self {
Self { agent }
}
}
impl From<AgentConfig> for LocalAgentConfig {
fn from(agent: AgentConfig) -> Self {
Self::new(agent)
}
}
fn default_policies() -> Vec<PolicyRule> {
vec![
PolicyRule::Deny("run_command".to_string()),
PolicyRule::AllowAll,
]
}
#[cfg(test)]
mod tests {
use pyo3::types::PyAnyMethods;
use super::{
super::{
DEFAULT_IMAGE_GENERATION_MODEL,
capabilities::BuiltinTools,
models::{
GenerationConfig, ModelConfig, ModelEntry, ThinkingLevel, default_image_model_entry,
},
},
*,
};
#[derive(schemars::JsonSchema)]
struct CustomToolParams {}
#[test]
fn test_roundtrip_serialization() {
let config = AgentConfig {
system_instructions: Some(SystemInstructions::Custom("Be helpful".to_string())),
capabilities: Some(CapabilitiesConfig {
enable_subagents: true,
enabled_tools: Some(vec![BuiltinTools::ListDir]),
compaction_threshold: Some(4000),
..CapabilitiesConfig::default()
}),
workspaces: vec![PathBuf::from("/tmp")],
..AgentConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.workspaces.len(), 1);
assert_eq!(
parsed.capabilities.unwrap().enabled_tools.unwrap()[0],
BuiltinTools::ListDir
);
}
#[test]
fn agent_config_builder_with_gemini() {
let gemini = GeminiConfig {
api_key: Some("test-key".to_string()),
base_url: None,
models: ModelConfig::default(),
};
let config = AgentConfig::builder().gemini(gemini).build();
let gemini_cfg = config.gemini.expect("gemini should be Some");
assert_eq!(gemini_cfg.api_key.as_deref(), Some("test-key"));
assert_eq!(gemini_cfg.models.default.name, DEFAULT_MODEL);
}
#[test]
fn agent_config_builder_gemini_with_thinking_level() {
let gemini = GeminiConfig {
api_key: None,
base_url: None,
models: ModelConfig {
default: ModelEntry {
name: "gemini-3.5-flash".to_string(),
api_key: None,
generation: GenerationConfig {
thinking_level: Some(ThinkingLevel::High),
},
},
image_generation: default_image_model_entry(),
},
};
let config = AgentConfig::builder().gemini(gemini).build();
let gemini_cfg = config.gemini.expect("gemini should be Some");
assert_eq!(
gemini_cfg.models.default.generation.thinking_level,
Some(ThinkingLevel::High)
);
assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
}
#[test]
fn agent_config_gemini_none_by_default() {
let config = AgentConfig::default();
assert!(config.gemini.is_none());
}
#[test]
fn agent_config_gemini_serde_roundtrip() {
let config = AgentConfig {
gemini: Some(GeminiConfig {
api_key: Some("roundtrip-key".to_string()),
base_url: None,
models: ModelConfig {
default: ModelEntry {
name: "gemini-3.5-flash".to_string(),
api_key: Some("model-key".to_string()),
generation: GenerationConfig {
thinking_level: Some(ThinkingLevel::Medium),
},
},
image_generation: default_image_model_entry(),
},
}),
..AgentConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
let gemini_cfg = parsed.gemini.expect("gemini should survive roundtrip");
assert_eq!(gemini_cfg.api_key.as_deref(), Some("roundtrip-key"));
assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
assert_eq!(
gemini_cfg.models.default.api_key.as_deref(),
Some("model-key")
);
assert_eq!(
gemini_cfg.models.default.generation.thinking_level,
Some(ThinkingLevel::Medium)
);
}
#[test]
fn system_instructions_custom_serde() {
let instr = SystemInstructions::Custom("Be a helpful assistant".to_string());
let json = serde_json::to_string(&instr).unwrap();
let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
match parsed {
SystemInstructions::Custom(text) => assert_eq!(text, "Be a helpful assistant"),
SystemInstructions::Templated { .. } => {
panic!("Expected Custom, got Templated")
}
}
}
#[test]
fn system_instructions_templated_serde() {
let instr = SystemInstructions::Templated {
identity: Some("a security analyst".to_string()),
sections: vec![SystemInstructionSection {
content: "Always check permissions".to_string(),
title: "security".to_string(),
}],
};
let json = serde_json::to_string(&instr).unwrap();
let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
match parsed {
SystemInstructions::Templated { identity, sections } => {
assert_eq!(identity.as_deref(), Some("a security analyst"));
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].content, "Always check permissions");
}
SystemInstructions::Custom(_) => {
panic!("Expected Templated, got Custom")
}
}
}
#[test]
fn agent_config_fully_populated_serde() {
let config = AgentConfig {
system_instructions: Some(SystemInstructions::Templated {
identity: Some("test-identity".to_string()),
sections: vec![],
}),
capabilities: Some(CapabilitiesConfig {
enable_subagents: true,
disabled_tools: Some(vec![BuiltinTools::RunCommand]),
compaction_threshold: Some(1000),
..CapabilitiesConfig::default()
}),
workspaces: vec![PathBuf::from("/a"), PathBuf::from("/b")],
tools: vec![crate::tools::ToolDefinition {
name: "custom_tool".to_owned(),
description: "A custom tool".to_owned(),
parameter_schema: serde_json::to_value(schemars::schema_for!(CustomToolParams))
.unwrap(),
}],
policies: vec![PolicyRule::DenyAll],
triggers: vec![TriggerEntry {
name: "poll".to_owned(),
config: crate::triggers::TriggerConfig::every_secs(30),
message_template: "time to poll".to_owned(),
}],
hooks: vec![HookEntry {
name: "pre_gate".to_owned(),
point: crate::hooks::HookPoint::PreTurn,
callback_id: "cb_pre".to_owned(),
}],
skills: vec![PathBuf::from("/skills/foo")],
..AgentConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.workspaces.len(), 2);
assert_eq!(parsed.tools.len(), 1);
assert_eq!(parsed.policies.len(), 1);
assert_eq!(parsed.triggers.len(), 1);
assert_eq!(parsed.hooks.len(), 1);
assert_eq!(parsed.skills.len(), 1);
}
#[test]
fn agent_config_empty_defaults_serde() {
let json = r#"{"system_instructions":null}"#;
let parsed: AgentConfig = serde_json::from_str(json).unwrap();
assert!(parsed.system_instructions.is_none());
assert!(parsed.capabilities.is_none());
assert!(parsed.workspaces.is_empty());
assert!(parsed.tools.is_empty());
assert_eq!(
parsed.policies,
vec![
PolicyRule::Deny("run_command".to_string()),
PolicyRule::AllowAll,
]
);
assert!(parsed.triggers.is_empty());
assert!(parsed.hooks.is_empty());
assert!(parsed.skills.is_empty());
assert!(parsed.gemini.is_none());
}
#[test]
fn thinking_level_all_variants_python_str() {
assert_eq!(ThinkingLevel::Minimal.as_str(), "minimal");
assert_eq!(ThinkingLevel::Low.as_str(), "low");
assert_eq!(ThinkingLevel::Medium.as_str(), "medium");
assert_eq!(ThinkingLevel::High.as_str(), "high");
}
#[test]
fn thinking_level_all_variants_serde() {
for (variant, expected) in [
(ThinkingLevel::Minimal, "\"minimal\""),
(ThinkingLevel::Low, "\"low\""),
(ThinkingLevel::Medium, "\"medium\""),
(ThinkingLevel::High, "\"high\""),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected);
let parsed: ThinkingLevel = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant);
}
}
#[test]
fn system_instructions_custom_variant() {
let instr = SystemInstructions::Custom("Be helpful".to_string());
let json = serde_json::to_string(&instr).unwrap();
let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
match parsed {
SystemInstructions::Custom(text) => assert_eq!(text, "Be helpful"),
SystemInstructions::Templated { .. } => {
panic!("Expected Custom, got Templated")
}
}
}
#[test]
fn agent_config_all_optional_fields_roundtrip() {
let config = AgentConfig {
workspaces: vec![PathBuf::from("/ws")],
skills: vec![PathBuf::from("/skills/test")],
conversation_id: Some("conv-123".to_string()),
save_dir: Some(PathBuf::from("/save")),
app_data_dir: Some(PathBuf::from("/app")),
response_schema: Some(JsonSchema::new(serde_json::json!({"type": "object"}))),
..AgentConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.workspaces.len(), 1);
assert_eq!(parsed.conversation_id.as_deref(), Some("conv-123"));
assert_eq!(parsed.save_dir.as_ref().unwrap(), &PathBuf::from("/save"));
assert!(parsed.response_schema.is_some());
}
#[test]
fn agent_config_custom_tools_and_builtin_tools_coexist() {
let custom_tool = crate::tools::ToolDefinition {
name: "my_custom_tool".to_owned(),
description: "Does something custom".to_owned(),
parameter_schema: serde_json::json!({"type": "object", "properties": {}}),
};
let config = AgentConfig {
tools: vec![custom_tool],
capabilities: Some(CapabilitiesConfig {
enabled_tools: Some(vec![BuiltinTools::ViewFile, BuiltinTools::RunCommand]),
..CapabilitiesConfig::default()
}),
..AgentConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tools.len(), 1);
assert_eq!(parsed.tools[0].name, "my_custom_tool");
let caps = parsed.capabilities.as_ref().unwrap();
let enabled = caps.enabled_tools.as_ref().unwrap();
assert_eq!(enabled.len(), 2);
assert!(enabled.contains(&BuiltinTools::ViewFile));
assert!(enabled.contains(&BuiltinTools::RunCommand));
assert!(caps.validate().is_ok());
}
#[test]
fn agent_config_custom_tools_only_no_builtins() {
let config = AgentConfig {
tools: vec![crate::tools::ToolDefinition {
name: "fetch_data".to_owned(),
description: "Fetches data".to_owned(),
parameter_schema: serde_json::json!({"type": "object"}),
}],
capabilities: Some(CapabilitiesConfig::custom_tools_only()),
..AgentConfig::default()
};
let caps = config.capabilities.as_ref().unwrap();
assert!(caps.enabled_tools.as_ref().unwrap().is_empty());
assert!(caps.validate().is_ok());
assert_eq!(config.tools.len(), 1);
}
#[test]
fn local_agent_config_default() {
let config = LocalAgentConfig::default();
assert_eq!(config.agent.model, DEFAULT_MODEL);
}
#[test]
fn local_agent_config_from_agent_config() {
let agent_cfg = AgentConfig {
model: "gemini-3.5-flash".to_string(),
..AgentConfig::default()
};
let local: LocalAgentConfig = agent_cfg.into();
assert_eq!(local.agent.model, "gemini-3.5-flash");
}
#[test]
fn local_agent_config_serde_roundtrip() {
let config = LocalAgentConfig::new(AgentConfig::default());
let json = serde_json::to_string(&config).unwrap();
let parsed: LocalAgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.agent.model, DEFAULT_MODEL);
}
#[test]
fn skills_serializes_as_skills_paths() {
let config = AgentConfig::builder()
.skills(vec![PathBuf::from("/skill/a.md")])
.build();
let json = serde_json::to_string(&config).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v.get("skills_paths").is_some(),
"Expected JSON key 'skills_paths', got: {json}"
);
assert!(
v.get("skills").is_none(),
"Should not have 'skills' key in JSON"
);
}
#[test]
fn skills_paths_deserializes_to_skills_field() {
let json = r#"{"skills_paths": ["/skill/a.md"]}"#;
let config: AgentConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.skills.len(), 1);
assert_eq!(config.skills[0], PathBuf::from("/skill/a.md"));
}
#[test]
fn gemini_serializes_as_gemini_config() {
let config = AgentConfig::builder()
.gemini(super::super::GeminiConfig::default())
.build();
let json = serde_json::to_string(&config).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v.get("gemini_config").is_some(),
"Expected JSON key 'gemini_config', got: {json}"
);
assert!(
v.get("gemini").is_none(),
"Should not have 'gemini' key in JSON"
);
}
#[test]
fn gemini_config_deserializes_to_gemini_field() {
let json = r#"{"gemini_config": {"api_key": "test-key"}}"#;
let config: AgentConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.gemini.as_ref().unwrap().api_key.as_deref(),
Some("test-key")
);
}
#[test]
fn empty_vecs_omitted_from_json() {
let config = AgentConfig::default();
let json = serde_json::to_string(&config).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
for key in &[
"workspaces",
"tools",
"triggers",
"hooks",
"skills_paths",
"mcp_servers",
] {
assert!(
v.get(key).is_none(),
"Empty vec field '{key}' should be omitted from JSON, got: {json}"
);
}
assert!(
v.get("policies").is_some(),
"policies should always be serialized"
);
}
#[test]
fn populated_vecs_included_in_json() {
let config = AgentConfig::builder()
.skills(vec![PathBuf::from("/skill.md")])
.workspaces(vec![PathBuf::from("/ws")])
.build();
let json = serde_json::to_string(&config).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v.get("skills_paths").is_some(),
"Non-empty skills should be present"
);
assert!(
v.get("workspaces").is_some(),
"Non-empty workspaces should be present"
);
}
#[test]
fn default_policies_deny_run_command_allow_rest() {
let config = AgentConfig::default();
assert_eq!(config.policies.len(), 2);
assert_eq!(
config.policies[0],
PolicyRule::Deny("run_command".to_string())
);
assert_eq!(config.policies[1], PolicyRule::AllowAll);
}
fn py_str_attr(module: &str, attr: &str) -> String {
pyo3::prepare_freethreaded_python();
pyo3::Python::with_gil(|py| {
crate::runtime::venv::configure_python_sys_path(py)
.unwrap_or_else(|e| panic!("Failed to configure python sys.path: {e}"));
let m = py
.import_bound(module)
.unwrap_or_else(|e| panic!("Failed to import {module}: {e}"));
m.getattr(attr)
.unwrap_or_else(|e| panic!("Failed to get {module}.{attr}: {e}"))
.extract::<String>()
.unwrap_or_else(|e| panic!("Failed to extract {module}.{attr} as String: {e}"))
})
}
#[test]
fn default_model_matches_python_sdk() {
let py_val = py_str_attr("google.antigravity.types", "DEFAULT_MODEL");
assert_eq!(
DEFAULT_MODEL, py_val,
"Rust DEFAULT_MODEL ({DEFAULT_MODEL}) != Python SDK ({py_val})"
);
}
#[test]
fn default_image_model_matches_python_sdk() {
let py_val = py_str_attr("google.antigravity.types", "DEFAULT_IMAGE_GENERATION_MODEL");
assert_eq!(
DEFAULT_IMAGE_GENERATION_MODEL, py_val,
"Rust DEFAULT_IMAGE_GENERATION_MODEL ({DEFAULT_IMAGE_GENERATION_MODEL}) != Python SDK ({py_val})"
);
}
#[test]
fn effective_api_key_prefers_per_model_key() {
let config = AgentConfig::builder()
.api_key("top-level-key")
.gemini(super::super::GeminiConfig {
api_key: Some("shared-key".into()),
base_url: None,
models: super::super::ModelConfig {
default: super::super::ModelEntry {
name: "gemini-3.5-flash".into(),
api_key: Some("per-model-key".into()),
generation: super::super::GenerationConfig::default(),
},
image_generation: super::super::ModelEntry {
name: "imagen-4.0-generate-preview-06-03".into(),
api_key: None,
generation: super::super::GenerationConfig::default(),
},
},
})
.build();
assert_eq!(config.effective_api_key().as_deref(), Some("per-model-key"));
}
#[test]
fn effective_api_key_falls_back_to_gemini_shared_key() {
let config = AgentConfig::builder()
.gemini(super::super::GeminiConfig {
api_key: Some("shared-key".into()),
..Default::default()
})
.build();
assert_eq!(config.effective_api_key().as_deref(), Some("shared-key"));
}
#[test]
fn effective_api_key_falls_back_to_top_level() {
let config = AgentConfig::builder().api_key("top-level-key").build();
assert_eq!(config.effective_api_key().as_deref(), Some("top-level-key"));
}
#[test]
fn effective_api_key_none_without_any_key() {
let saved = std::env::var("GEMINI_API_KEY").ok();
unsafe { std::env::remove_var("GEMINI_API_KEY") };
let config = AgentConfig::builder().build();
let result = config.effective_api_key();
if let Some(v) = saved {
unsafe { std::env::set_var("GEMINI_API_KEY", v) };
}
assert!(result.is_none());
}
}