1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use super::{
9 DEFAULT_MODEL, capabilities::CapabilitiesConfig, mcp::McpServer, models::GeminiConfig,
10};
11use crate::{
12 hooks::HookEntry, policies::PolicyRule, tools::ToolDefinition, triggers::TriggerEntry,
13};
14
15#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct SystemInstructionSection {
18 pub content: String,
20 #[serde(default = "default_section_title")]
22 pub title: String,
23}
24
25fn default_section_title() -> String {
26 "user_system_instructions".to_owned()
27}
28
29fn default_model_name() -> String {
30 DEFAULT_MODEL.to_owned()
31}
32
33#[non_exhaustive]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum SystemInstructions {
41 Custom(String),
43 Templated {
45 #[serde(default)]
47 identity: Option<String>,
48 #[serde(default)]
50 sections: Vec<SystemInstructionSection>,
51 },
52}
53
54impl SystemInstructions {
55 #[must_use]
57 pub fn custom(text: impl Into<String>) -> Self {
58 Self::Custom(text.into())
59 }
60}
61
62impl From<&str> for SystemInstructions {
63 fn from(s: &str) -> Self {
64 Self::custom(s)
65 }
66}
67
68impl From<String> for SystemInstructions {
69 fn from(s: String) -> Self {
70 Self::custom(s)
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct JsonSchema(serde_json::Value);
80
81impl JsonSchema {
82 #[must_use]
83 pub const fn new(value: serde_json::Value) -> Self {
85 Self(value)
86 }
87
88 #[must_use]
89 pub const fn as_value(&self) -> &serde_json::Value {
91 &self.0
92 }
93
94 pub fn validate(&self) -> Result<(), &'static str> {
104 if self.0.is_object() {
105 Ok(())
106 } else {
107 Err("JSON Schema must be a JSON object at the top level")
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
150#[builder(field_defaults(default))]
151pub struct AgentConfig {
152 #[serde(default = "default_model_name")]
154 #[builder(default = DEFAULT_MODEL.to_owned(), setter(into))]
155 pub model: String,
156 #[serde(default)]
158 #[builder(setter(into, strip_option))]
159 pub api_key: Option<String>,
160 #[builder(setter(into, strip_option))]
162 pub system_instructions: Option<SystemInstructions>,
163 #[serde(default)]
164 #[builder(setter(strip_option))]
166 pub capabilities: Option<CapabilitiesConfig>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
173 pub workspaces: Vec<PathBuf>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<ToolDefinition>>| v.into_iter().map(Into::into).collect()))]
177 pub tools: Vec<ToolDefinition>,
178 #[serde(default = "default_policies")]
179 #[builder(default = default_policies(), setter(transform = |v: impl IntoIterator<Item = impl Into<PolicyRule>>| v.into_iter().map(Into::into).collect()))]
181 pub policies: Vec<PolicyRule>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<TriggerEntry>>| v.into_iter().map(Into::into).collect()))]
185 pub triggers: Vec<TriggerEntry>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<HookEntry>>| v.into_iter().map(Into::into).collect()))]
189 pub hooks: Vec<HookEntry>,
190 #[serde(
191 default,
192 skip_serializing_if = "Vec::is_empty",
193 rename = "skills_paths"
194 )]
195 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
199 pub skills: Vec<PathBuf>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<McpServer>>| v.into_iter().map(Into::into).collect()))]
204 pub mcp_servers: Vec<McpServer>,
205 #[serde(default)]
219 #[builder(setter(into, strip_option))]
220 pub conversation_id: Option<String>,
221 #[serde(default)]
223 #[builder(setter(into, strip_option))]
224 pub save_dir: Option<PathBuf>,
225 #[serde(default)]
227 #[builder(setter(into, strip_option))]
228 pub app_data_dir: Option<PathBuf>,
229 #[serde(default)]
231 #[builder(setter(strip_option))]
232 pub response_schema: Option<JsonSchema>,
233 #[serde(default, rename = "gemini_config")]
240 #[builder(setter(strip_option))]
241 pub gemini: Option<GeminiConfig>,
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 #[builder(default, setter(transform = |v: impl IntoIterator<Item = impl Into<crate::types::ConversationMessage>>| v.into_iter().map(Into::into).collect()))]
253 pub initial_history: Vec<crate::types::ConversationMessage>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 #[builder(default, setter(strip_option))]
257 pub retry_config: Option<RetryConfig>,
258}
259
260#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TypedBuilder)]
262pub struct ModelAPIRetryConfig {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 #[builder(default, setter(into, strip_option))]
266 pub max_retries: Option<u32>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 #[builder(default, setter(into, strip_option))]
270 pub initial_sleep_duration_ms: Option<u32>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 #[builder(default, setter(into, strip_option))]
274 pub exponential_multiplier: Option<f64>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 #[builder(default, setter(into, strip_option))]
278 pub jitter_range: Option<f64>,
279}
280
281#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TypedBuilder)]
283pub struct RetryConfig {
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 #[builder(default, setter(into, strip_option))]
287 pub api_retry: Option<ModelAPIRetryConfig>,
288}
289
290impl RetryConfig {
291 #[must_use]
293 pub fn no_retries() -> Self {
294 Self {
295 api_retry: Some(ModelAPIRetryConfig {
296 max_retries: Some(0),
297 initial_sleep_duration_ms: None,
298 exponential_multiplier: None,
299 jitter_range: None,
300 }),
301 }
302 }
303}
304
305impl Default for AgentConfig {
306 fn default() -> Self {
307 Self::builder().build()
308 }
309}
310
311impl AgentConfig {
312 #[must_use]
320 pub fn effective_api_key(&self) -> Option<String> {
321 self.gemini
322 .as_ref()
323 .and_then(|g| g.models.default.api_key.clone())
324 .or_else(|| self.gemini.as_ref().and_then(|g| g.api_key.clone()))
325 .or_else(|| self.api_key.clone())
326 .or_else(|| std::env::var("GEMINI_API_KEY").ok())
328 }
329
330 #[must_use]
334 pub fn custom_tool_names(&self) -> Vec<String> {
335 self.tools.iter().map(|t| t.name.clone()).collect()
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, Default)]
349pub struct LocalAgentConfig {
350 #[serde(flatten)]
352 pub agent: AgentConfig,
353}
354
355impl LocalAgentConfig {
356 #[must_use]
358 pub const fn new(agent: AgentConfig) -> Self {
359 Self { agent }
360 }
361}
362
363impl From<AgentConfig> for LocalAgentConfig {
364 fn from(agent: AgentConfig) -> Self {
365 Self::new(agent)
366 }
367}
368
369fn default_policies() -> Vec<PolicyRule> {
372 vec![
373 PolicyRule::Deny("run_command".to_string()),
374 PolicyRule::AllowAll,
375 ]
376}
377
378#[cfg(test)]
379#[allow(deprecated)]
381mod tests {
382 use pyo3::types::PyAnyMethods;
383
384 use super::{
385 super::{
386 DEFAULT_IMAGE_GENERATION_MODEL,
387 capabilities::BuiltinTools,
388 models::{
389 GenerationConfig, ModelConfig, ModelEntry, ThinkingLevel, default_image_model_entry,
390 },
391 },
392 *,
393 };
394
395 #[derive(schemars::JsonSchema)]
396 struct CustomToolParams {}
397
398 #[test]
399 fn test_roundtrip_serialization() {
400 let config = AgentConfig {
401 system_instructions: Some(SystemInstructions::Custom("Be helpful".to_string())),
402 capabilities: Some(CapabilitiesConfig {
403 enable_subagents: true,
404 enabled_tools: Some(vec![BuiltinTools::ListDir]),
405 compaction_threshold: Some(4000),
406 ..CapabilitiesConfig::default()
407 }),
408 workspaces: vec![PathBuf::from("/tmp")],
409 ..AgentConfig::default()
410 };
411
412 let json = serde_json::to_string(&config).unwrap();
413 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
414 assert_eq!(parsed.workspaces.len(), 1);
415 assert_eq!(
416 parsed.capabilities.unwrap().enabled_tools.unwrap()[0],
417 BuiltinTools::ListDir
418 );
419 }
420
421 #[test]
422 fn agent_config_builder_with_gemini() {
423 let gemini = GeminiConfig {
424 api_key: Some("test-key".to_string()),
425 base_url: None,
426 models: ModelConfig::default(),
427 };
428 let config = AgentConfig::builder().gemini(gemini).build();
429 let gemini_cfg = config.gemini.expect("gemini should be Some");
430 assert_eq!(gemini_cfg.api_key.as_deref(), Some("test-key"));
431 assert_eq!(gemini_cfg.models.default.name, DEFAULT_MODEL);
432 }
433
434 #[test]
435 fn agent_config_builder_gemini_with_thinking_level() {
436 let gemini = GeminiConfig {
437 api_key: None,
438 base_url: None,
439 models: ModelConfig {
440 default: ModelEntry {
441 name: "gemini-3.5-flash".to_string(),
442 api_key: None,
443 generation: GenerationConfig {
444 thinking_level: Some(ThinkingLevel::High),
445 },
446 },
447 image_generation: default_image_model_entry(),
448 },
449 };
450 let config = AgentConfig::builder().gemini(gemini).build();
451 let gemini_cfg = config.gemini.expect("gemini should be Some");
452 assert_eq!(
453 gemini_cfg.models.default.generation.thinking_level,
454 Some(ThinkingLevel::High)
455 );
456 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
457 }
458
459 #[test]
460 fn agent_config_gemini_none_by_default() {
461 let config = AgentConfig::default();
462 assert!(config.gemini.is_none());
463 }
464
465 #[test]
466 fn agent_config_gemini_serde_roundtrip() {
467 let config = AgentConfig {
468 gemini: Some(GeminiConfig {
469 api_key: Some("roundtrip-key".to_string()),
470 base_url: None,
471 models: ModelConfig {
472 default: ModelEntry {
473 name: "gemini-3.5-flash".to_string(),
474 api_key: Some("model-key".to_string()),
475 generation: GenerationConfig {
476 thinking_level: Some(ThinkingLevel::Medium),
477 },
478 },
479 image_generation: default_image_model_entry(),
480 },
481 }),
482 ..AgentConfig::default()
483 };
484 let json = serde_json::to_string(&config).unwrap();
485 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
486 let gemini_cfg = parsed.gemini.expect("gemini should survive roundtrip");
487 assert_eq!(gemini_cfg.api_key.as_deref(), Some("roundtrip-key"));
488 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
489 assert_eq!(
490 gemini_cfg.models.default.api_key.as_deref(),
491 Some("model-key")
492 );
493 assert_eq!(
494 gemini_cfg.models.default.generation.thinking_level,
495 Some(ThinkingLevel::Medium)
496 );
497 }
498
499 #[test]
500 fn system_instructions_custom_serde() {
501 let instr = SystemInstructions::Custom("Be a helpful assistant".to_string());
502 let json = serde_json::to_string(&instr).unwrap();
503 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
504 match parsed {
505 SystemInstructions::Custom(text) => assert_eq!(text, "Be a helpful assistant"),
506 SystemInstructions::Templated { .. } => {
507 panic!("Expected Custom, got Templated")
508 }
509 }
510 }
511
512 #[test]
513 fn system_instructions_templated_serde() {
514 let instr = SystemInstructions::Templated {
515 identity: Some("a security analyst".to_string()),
516 sections: vec![SystemInstructionSection {
517 content: "Always check permissions".to_string(),
518 title: "security".to_string(),
519 }],
520 };
521 let json = serde_json::to_string(&instr).unwrap();
522 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
523 match parsed {
524 SystemInstructions::Templated { identity, sections } => {
525 assert_eq!(identity.as_deref(), Some("a security analyst"));
526 assert_eq!(sections.len(), 1);
527 assert_eq!(sections[0].content, "Always check permissions");
528 }
529 SystemInstructions::Custom(_) => {
530 panic!("Expected Templated, got Custom")
531 }
532 }
533 }
534
535 #[test]
536 fn agent_config_fully_populated_serde() {
537 let config = AgentConfig {
538 system_instructions: Some(SystemInstructions::Templated {
539 identity: Some("test-identity".to_string()),
540 sections: vec![],
541 }),
542 capabilities: Some(CapabilitiesConfig {
543 enable_subagents: true,
544 disabled_tools: Some(vec![BuiltinTools::RunCommand]),
545 compaction_threshold: Some(1000),
546 ..CapabilitiesConfig::default()
547 }),
548 workspaces: vec![PathBuf::from("/a"), PathBuf::from("/b")],
549 tools: vec![crate::tools::ToolDefinition {
550 name: "custom_tool".to_owned(),
551 description: "A custom tool".to_owned(),
552 parameter_schema: serde_json::to_value(schemars::schema_for!(CustomToolParams))
553 .unwrap(),
554 }],
555 policies: vec![PolicyRule::DenyAll],
556 triggers: vec![TriggerEntry {
557 name: "poll".to_owned(),
558 config: crate::triggers::TriggerConfig::every_secs(30),
559 message_template: "time to poll".to_owned(),
560 }],
561 hooks: vec![HookEntry {
562 name: "pre_gate".to_owned(),
563 point: crate::hooks::HookPoint::PreTurn,
564 callback_id: "cb_pre".to_owned(),
565 }],
566 skills: vec![PathBuf::from("/skills/foo")],
567 ..AgentConfig::default()
568 };
569 let json = serde_json::to_string(&config).unwrap();
570 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
571 assert_eq!(parsed.workspaces.len(), 2);
572 assert_eq!(parsed.tools.len(), 1);
573 assert_eq!(parsed.policies.len(), 1);
574 assert_eq!(parsed.triggers.len(), 1);
575 assert_eq!(parsed.hooks.len(), 1);
576 assert_eq!(parsed.skills.len(), 1);
577 }
578
579 #[test]
580 fn agent_config_empty_defaults_serde() {
581 let json = r#"{"system_instructions":null}"#;
582 let parsed: AgentConfig = serde_json::from_str(json).unwrap();
583 assert!(parsed.system_instructions.is_none());
584 assert!(parsed.capabilities.is_none());
585 assert!(parsed.workspaces.is_empty());
586 assert!(parsed.tools.is_empty());
587 assert_eq!(
588 parsed.policies,
589 vec![
590 PolicyRule::Deny("run_command".to_string()),
591 PolicyRule::AllowAll,
592 ]
593 );
594 assert!(parsed.triggers.is_empty());
595 assert!(parsed.hooks.is_empty());
596 assert!(parsed.skills.is_empty());
597
598 assert!(parsed.gemini.is_none());
599 }
600
601 #[test]
602 fn agent_config_all_optional_fields_roundtrip() {
603 let config = AgentConfig {
604 workspaces: vec![PathBuf::from("/ws")],
605 skills: vec![PathBuf::from("/skills/test")],
606
607 conversation_id: Some("conv-123".to_string()),
608 save_dir: Some(PathBuf::from("/save")),
609 app_data_dir: Some(PathBuf::from("/app")),
610 response_schema: Some(JsonSchema::new(serde_json::json!({"type": "object"}))),
611 ..AgentConfig::default()
612 };
613 let json = serde_json::to_string(&config).unwrap();
614 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
615 assert_eq!(parsed.workspaces.len(), 1);
616 assert_eq!(parsed.conversation_id.as_deref(), Some("conv-123"));
617 assert_eq!(parsed.save_dir.as_ref().unwrap(), &PathBuf::from("/save"));
618 assert!(parsed.response_schema.is_some());
619 }
620
621 #[test]
622 fn agent_config_custom_tools_and_builtin_tools_coexist() {
623 let custom_tool = crate::tools::ToolDefinition {
627 name: "my_custom_tool".to_owned(),
628 description: "Does something custom".to_owned(),
629 parameter_schema: serde_json::json!({"type": "object", "properties": {}}),
630 };
631 let config = AgentConfig {
632 tools: vec![custom_tool],
633 capabilities: Some(CapabilitiesConfig {
634 enabled_tools: Some(vec![BuiltinTools::ViewFile, BuiltinTools::RunCommand]),
635 ..CapabilitiesConfig::default()
636 }),
637 ..AgentConfig::default()
638 };
639
640 let json = serde_json::to_string(&config).unwrap();
642 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
643
644 assert_eq!(parsed.tools.len(), 1);
646 assert_eq!(parsed.tools[0].name, "my_custom_tool");
647
648 let caps = parsed.capabilities.as_ref().unwrap();
650 let enabled = caps.enabled_tools.as_ref().unwrap();
651 assert_eq!(enabled.len(), 2);
652 assert!(enabled.contains(&BuiltinTools::ViewFile));
653 assert!(enabled.contains(&BuiltinTools::RunCommand));
654
655 assert!(caps.validate().is_ok());
657 }
658
659 #[test]
660 fn agent_config_custom_tools_only_no_builtins() {
661 let config = AgentConfig {
663 tools: vec![crate::tools::ToolDefinition {
664 name: "fetch_data".to_owned(),
665 description: "Fetches data".to_owned(),
666 parameter_schema: serde_json::json!({"type": "object"}),
667 }],
668 capabilities: Some(CapabilitiesConfig::custom_tools_only()),
669 ..AgentConfig::default()
670 };
671
672 let caps = config.capabilities.as_ref().unwrap();
673 assert!(caps.enabled_tools.as_ref().unwrap().is_empty());
674 assert!(caps.validate().is_ok());
675 assert_eq!(config.tools.len(), 1);
676 }
677
678 #[test]
681 fn local_agent_config_default() {
682 let config = LocalAgentConfig::default();
683 assert_eq!(config.agent.model, DEFAULT_MODEL);
684 }
685
686 #[test]
687 fn local_agent_config_from_agent_config() {
688 let agent_cfg = AgentConfig {
689 model: "gemini-3.5-flash".to_string(),
690 ..AgentConfig::default()
691 };
692 let local: LocalAgentConfig = agent_cfg.into();
693 assert_eq!(local.agent.model, "gemini-3.5-flash");
694 }
695
696 #[test]
697 fn local_agent_config_serde_roundtrip() {
698 let config = LocalAgentConfig::new(AgentConfig::default());
699 let json = serde_json::to_string(&config).unwrap();
700 let parsed: LocalAgentConfig = serde_json::from_str(&json).unwrap();
701 assert_eq!(parsed.agent.model, DEFAULT_MODEL);
702 }
703
704 #[test]
710 fn skills_serializes_as_skills_paths() {
711 let config = AgentConfig::builder()
712 .skills(vec![PathBuf::from("/skill/a.md")])
713 .build();
714 let json = serde_json::to_string(&config).unwrap();
715 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
716 assert!(
717 v.get("skills_paths").is_some(),
718 "Expected JSON key 'skills_paths', got: {json}"
719 );
720 assert!(
721 v.get("skills").is_none(),
722 "Should not have 'skills' key in JSON"
723 );
724 }
725
726 #[test]
727 fn skills_paths_deserializes_to_skills_field() {
728 let json = r#"{"skills_paths": ["/skill/a.md"]}"#;
729 let config: AgentConfig = serde_json::from_str(json).unwrap();
730 assert_eq!(config.skills.len(), 1);
731 assert_eq!(config.skills[0], PathBuf::from("/skill/a.md"));
732 }
733
734 #[test]
735 fn gemini_serializes_as_gemini_config() {
736 let config = AgentConfig::builder()
737 .gemini(super::super::GeminiConfig::default())
738 .build();
739 let json = serde_json::to_string(&config).unwrap();
740 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
741 assert!(
742 v.get("gemini_config").is_some(),
743 "Expected JSON key 'gemini_config', got: {json}"
744 );
745 assert!(
746 v.get("gemini").is_none(),
747 "Should not have 'gemini' key in JSON"
748 );
749 }
750
751 #[test]
752 fn gemini_config_deserializes_to_gemini_field() {
753 let json = r#"{"gemini_config": {"api_key": "test-key"}}"#;
754 let config: AgentConfig = serde_json::from_str(json).unwrap();
755 assert_eq!(
756 config.gemini.as_ref().unwrap().api_key.as_deref(),
757 Some("test-key")
758 );
759 }
760
761 #[test]
764 fn empty_vecs_omitted_from_json() {
765 let config = AgentConfig::default();
766 let json = serde_json::to_string(&config).unwrap();
767 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
768 for key in &[
770 "workspaces",
771 "tools",
772 "triggers",
773 "hooks",
774 "skills_paths",
775 "mcp_servers",
776 ] {
777 assert!(
778 v.get(key).is_none(),
779 "Empty vec field '{key}' should be omitted from JSON, got: {json}"
780 );
781 }
782 assert!(
784 v.get("policies").is_some(),
785 "policies should always be serialized"
786 );
787 }
788
789 #[test]
790 fn populated_vecs_included_in_json() {
791 let config = AgentConfig::builder()
792 .skills(vec![PathBuf::from("/skill.md")])
793 .workspaces(vec![PathBuf::from("/ws")])
794 .build();
795 let json = serde_json::to_string(&config).unwrap();
796 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
797 assert!(
798 v.get("skills_paths").is_some(),
799 "Non-empty skills should be present"
800 );
801 assert!(
802 v.get("workspaces").is_some(),
803 "Non-empty workspaces should be present"
804 );
805 }
806
807 #[test]
810 fn default_policies_deny_run_command_allow_rest() {
811 let config = AgentConfig::default();
812 assert_eq!(config.policies.len(), 2);
813 assert_eq!(
814 config.policies[0],
815 PolicyRule::Deny("run_command".to_string())
816 );
817 assert_eq!(config.policies[1], PolicyRule::AllowAll);
818 }
819
820 fn py_str_attr(module: &str, attr: &str) -> String {
829 pyo3::Python::initialize();
830 pyo3::Python::attach(|py| {
831 crate::runtime::venv::configure_python_sys_path(py)
832 .unwrap_or_else(|e| panic!("Failed to configure python sys.path: {e}"));
833 let m = crate::runtime::py_scripts::import_serialized(py, module)
834 .unwrap_or_else(|e| panic!("Failed to import {module}: {e}"));
835 m.getattr(attr)
836 .unwrap_or_else(|e| panic!("Failed to get {module}.{attr}: {e}"))
837 .extract::<String>()
838 .unwrap_or_else(|e| panic!("Failed to extract {module}.{attr} as String: {e}"))
839 })
840 }
841
842 #[test]
843 fn default_model_matches_python_sdk() {
844 let py_val = py_str_attr("google.antigravity.models", "DEFAULT_MODEL");
845 assert_eq!(
846 DEFAULT_MODEL, py_val,
847 "Rust DEFAULT_MODEL ({DEFAULT_MODEL}) != Python SDK ({py_val})"
848 );
849 }
850
851 #[test]
852 fn default_image_model_matches_python_sdk() {
853 let py_val = py_str_attr(
854 "google.antigravity.models",
855 "DEFAULT_IMAGE_GENERATION_MODEL",
856 );
857 assert_eq!(
858 DEFAULT_IMAGE_GENERATION_MODEL, py_val,
859 "Rust DEFAULT_IMAGE_GENERATION_MODEL ({DEFAULT_IMAGE_GENERATION_MODEL}) != Python SDK ({py_val})"
860 );
861 }
862
863 #[test]
866 fn effective_api_key_prefers_per_model_key() {
867 let config = AgentConfig::builder()
868 .api_key("top-level-key")
869 .gemini(super::super::GeminiConfig {
870 api_key: Some("shared-key".into()),
871 base_url: None,
872 models: super::super::ModelConfig {
873 default: super::super::ModelEntry {
874 name: "gemini-3.5-flash".into(),
875 api_key: Some("per-model-key".into()),
876 generation: super::super::GenerationConfig::default(),
877 },
878 image_generation: super::super::ModelEntry {
879 name: "imagen-4.0-generate-preview-06-03".into(),
880 api_key: None,
881 generation: super::super::GenerationConfig::default(),
882 },
883 },
884 })
885 .build();
886 assert_eq!(config.effective_api_key().as_deref(), Some("per-model-key"));
887 }
888
889 #[test]
890 fn effective_api_key_falls_back_to_gemini_shared_key() {
891 let config = AgentConfig::builder()
892 .gemini(super::super::GeminiConfig {
893 api_key: Some("shared-key".into()),
894 ..Default::default()
895 })
896 .build();
897 assert_eq!(config.effective_api_key().as_deref(), Some("shared-key"));
898 }
899
900 #[test]
901 fn effective_api_key_falls_back_to_top_level() {
902 let config = AgentConfig::builder().api_key("top-level-key").build();
903 assert_eq!(config.effective_api_key().as_deref(), Some("top-level-key"));
904 }
905
906 #[test]
907 fn effective_api_key_none_without_any_key() {
908 let config = AgentConfig::builder().build();
914 let result = config.effective_api_key();
915 match std::env::var("GEMINI_API_KEY").ok() {
917 Some(env_key) => assert_eq!(result.as_deref(), Some(env_key.as_str())),
918 None => assert!(result.is_none()),
919 }
920 }
921
922 #[test]
923 fn initial_history_empty_by_default() {
924 let config = AgentConfig::default();
925 assert!(config.initial_history.is_empty());
926
927 let json = serde_json::to_string(&config).unwrap();
929 assert!(
930 !json.contains("initial_history"),
931 "empty initial_history should be skipped in JSON"
932 );
933 }
934
935 #[test]
936 fn initial_history_roundtrip() {
937 use crate::types::{ConversationMessage, MessageRole};
938
939 let config = AgentConfig::builder()
940 .initial_history(vec![
941 ConversationMessage {
942 role: MessageRole::User,
943 content: "Hello".to_string(),
944 },
945 ConversationMessage {
946 role: MessageRole::Model,
947 content: "Hi there!".to_string(),
948 },
949 ])
950 .build();
951
952 assert_eq!(config.initial_history.len(), 2);
953
954 let json = serde_json::to_string(&config).unwrap();
955 assert!(json.contains("initial_history"));
956
957 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
958 assert_eq!(parsed.initial_history.len(), 2);
959 assert_eq!(parsed.initial_history[0].role, MessageRole::User);
960 assert_eq!(parsed.initial_history[0].content, "Hello");
961 assert_eq!(parsed.initial_history[1].role, MessageRole::Model);
962 assert_eq!(parsed.initial_history[1].content, "Hi there!");
963 }
964}