aof_core/
agent.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use crate::mcp::McpServerConfig;
8use crate::AofResult;
9
10/// Output schema specification using JSON Schema format
11/// Enables structured, validated agent responses
12///
13/// This is the YAML-friendly version for config files. It gets converted
14/// to `crate::schema::OutputSchema` for runtime use.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct OutputSchemaSpec {
17    /// JSON Schema type (object, array, string, number, boolean)
18    #[serde(rename = "type")]
19    pub schema_type: String,
20
21    /// Properties for object type
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub properties: Option<HashMap<String, serde_json::Value>>,
24
25    /// Required properties for object type
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub required: Option<Vec<String>>,
28
29    /// Items schema for array type
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub items: Option<Box<serde_json::Value>>,
32
33    /// Enum values for string type
34    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
35    pub enum_values: Option<Vec<String>>,
36
37    /// Description of the schema
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub description: Option<String>,
40
41    /// Allow additional properties (default: false for strict validation)
42    #[serde(default, rename = "additionalProperties")]
43    pub additional_properties: Option<bool>,
44
45    /// Validation mode: strict (default), lenient, coerce
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub validation_mode: Option<String>,
48
49    /// Behavior on validation error: fail (default), retry, passthrough
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub on_validation_error: Option<String>,
52
53    /// Max retries if on_validation_error is "retry"
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub max_retries: Option<u32>,
56
57    /// Additional JSON Schema properties (oneOf, anyOf, etc.)
58    #[serde(flatten)]
59    pub extra: HashMap<String, serde_json::Value>,
60}
61
62impl OutputSchemaSpec {
63    /// Convert to JSON Schema Value for validation
64    pub fn to_json_schema(&self) -> serde_json::Value {
65        let mut schema = serde_json::json!({
66            "type": self.schema_type
67        });
68
69        if let Some(props) = &self.properties {
70            schema["properties"] = serde_json::json!(props);
71        }
72
73        if let Some(req) = &self.required {
74            schema["required"] = serde_json::json!(req);
75        }
76
77        if let Some(items) = &self.items {
78            schema["items"] = serde_json::json!(items);
79        }
80
81        if let Some(enum_vals) = &self.enum_values {
82            schema["enum"] = serde_json::json!(enum_vals);
83        }
84
85        if let Some(desc) = &self.description {
86            schema["description"] = serde_json::json!(desc);
87        }
88
89        if let Some(additional) = &self.additional_properties {
90            schema["additionalProperties"] = serde_json::json!(additional);
91        }
92
93        // Merge extra fields (oneOf, anyOf, etc.)
94        if let serde_json::Value::Object(ref mut map) = schema {
95            for (key, value) in &self.extra {
96                map.insert(key.clone(), value.clone());
97            }
98        }
99
100        schema
101    }
102
103    /// Get validation mode (defaults to "strict")
104    pub fn get_validation_mode(&self) -> &str {
105        self.validation_mode.as_deref().unwrap_or("strict")
106    }
107
108    /// Get error handling behavior (defaults to "fail")
109    pub fn get_error_behavior(&self) -> &str {
110        self.on_validation_error.as_deref().unwrap_or("fail")
111    }
112
113    /// Generate schema instructions for the LLM
114    pub fn to_instructions(&self) -> String {
115        let schema = self.to_json_schema();
116        format!(
117            "You MUST respond with valid JSON matching this schema:\n```json\n{}\n```\nDo not include any text outside the JSON object.",
118            serde_json::to_string_pretty(&schema).unwrap_or_default()
119        )
120    }
121}
122
123/// Convert YAML-friendly OutputSchemaSpec to runtime OutputSchema
124impl From<OutputSchemaSpec> for crate::schema::OutputSchema {
125    fn from(spec: OutputSchemaSpec) -> Self {
126        // Get validation mode before moving spec
127        let strict = spec.get_validation_mode() == "strict";
128        let description = spec.description.clone();
129
130        let schema = spec.to_json_schema();
131        let mut output = crate::schema::OutputSchema::from_json_schema(schema);
132
133        // Transfer description if present
134        if let Some(desc) = description {
135            output = output.with_description(desc);
136        }
137
138        // Set strict mode based on validation_mode
139        output = output.with_strict(strict);
140
141        output
142    }
143}
144
145/// Memory specification - unified way to configure memory backends
146///
147/// Supports multiple formats:
148/// 1. Simple string: `"file:./memory.json"` or `"in_memory"`
149/// 2. Object with type: `{type: "File", config: {path: "./memory.json", max_messages: 50}}`
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(untagged)]
152pub enum MemorySpec {
153    /// Simple memory specification (backward compatible)
154    /// Format: "type" or "type:path" (e.g., "in_memory", "file:./memory.json")
155    Simple(String),
156
157    /// Structured memory configuration
158    Structured(StructuredMemoryConfig),
159}
160
161impl MemorySpec {
162    /// Get the memory type
163    pub fn memory_type(&self) -> &str {
164        match self {
165            MemorySpec::Simple(s) => {
166                // Extract type from "type" or "type:path" format
167                s.split(':').next().unwrap_or(s)
168            }
169            MemorySpec::Structured(config) => &config.memory_type,
170        }
171    }
172
173    /// Get the file path if this is a file-based memory
174    pub fn path(&self) -> Option<String> {
175        match self {
176            MemorySpec::Simple(s) => {
177                // Extract path from "file:./path.json" format
178                if s.contains(':') {
179                    s.split(':').nth(1).map(|s| s.to_string())
180                } else {
181                    None
182                }
183            }
184            MemorySpec::Structured(config) => config
185                .config
186                .as_ref()
187                .and_then(|c| c.get("path"))
188                .and_then(|v| v.as_str())
189                .map(|s| s.to_string()),
190        }
191    }
192
193    /// Get max_messages configuration if available
194    pub fn max_messages(&self) -> Option<usize> {
195        match self {
196            MemorySpec::Simple(_) => None,
197            MemorySpec::Structured(config) => config
198                .config
199                .as_ref()
200                .and_then(|c| c.get("max_messages"))
201                .and_then(|v| v.as_u64())
202                .map(|n| n as usize),
203        }
204    }
205
206    /// Get the full configuration object
207    pub fn config(&self) -> Option<&serde_json::Value> {
208        match self {
209            MemorySpec::Simple(_) => None,
210            MemorySpec::Structured(config) => config.config.as_ref(),
211        }
212    }
213
214    /// Check if this is an in-memory backend
215    pub fn is_in_memory(&self) -> bool {
216        let t = self.memory_type().to_lowercase();
217        t == "in_memory" || t == "inmemory" || t == "memory"
218    }
219
220    /// Check if this is a file-based backend
221    pub fn is_file(&self) -> bool {
222        self.memory_type().to_lowercase() == "file"
223    }
224}
225
226/// Structured memory configuration with type and config fields
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct StructuredMemoryConfig {
229    /// Memory backend type: "File", "InMemory", etc.
230    #[serde(rename = "type")]
231    pub memory_type: String,
232
233    /// Backend-specific configuration
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub config: Option<serde_json::Value>,
236}
237
238impl fmt::Display for MemorySpec {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match self {
241            MemorySpec::Simple(s) => write!(f, "{}", s),
242            MemorySpec::Structured(config) => {
243                if let Some(path) = self.path() {
244                    write!(f, "{} (path: {})", config.memory_type, path)
245                } else {
246                    write!(f, "{}", config.memory_type)
247                }
248            }
249        }
250    }
251}
252
253/// Tool specification - unified way to configure both built-in and MCP tools
254///
255/// Supports multiple formats:
256/// 1. Simple string: `"shell"` - built-in tool with defaults
257/// 2. Type-based: `{type: "Shell", config: {allowed_commands: [...]}}`
258/// 3. Object with source: `{name: "kubectl_get", source: "builtin", config: {...}}`
259/// 4. MCP tool: `{name: "read_file", source: "mcp", server: "filesystem"}`
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(untagged)]
262pub enum ToolSpec {
263    /// Simple tool name (for backward compatibility)
264    /// Assumes built-in if the tool exists, otherwise tries MCP
265    Simple(String),
266
267    /// Type-based tool specification (type: Shell/MCP/HTTP)
268    /// Format: {type: "Shell", config: {allowed_commands: [...]}}
269    TypeBased(TypeBasedToolSpec),
270
271    /// Fully qualified tool specification
272    Qualified(QualifiedToolSpec),
273}
274
275/// Type-based tool specification
276/// Supports: Shell, MCP, HTTP tool types with their configurations
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct TypeBasedToolSpec {
279    /// Tool type: Shell, MCP, HTTP
280    #[serde(rename = "type")]
281    pub tool_type: TypeBasedToolType,
282
283    /// Tool-specific configuration
284    #[serde(default)]
285    pub config: serde_json::Value,
286}
287
288/// Tool types for type-based specification
289#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
290pub enum TypeBasedToolType {
291    /// Shell command execution with restrictions
292    Shell,
293    /// MCP server tool
294    MCP,
295    /// HTTP API tool
296    HTTP,
297}
298
299impl ToolSpec {
300    /// Get the tool name
301    pub fn name(&self) -> &str {
302        match self {
303            ToolSpec::Simple(name) => name,
304            ToolSpec::TypeBased(spec) => match spec.tool_type {
305                TypeBasedToolType::Shell => "shell",
306                TypeBasedToolType::MCP => spec
307                    .config
308                    .get("name")
309                    .and_then(|v| v.as_str())
310                    .unwrap_or("mcp"),
311                TypeBasedToolType::HTTP => "http",
312            },
313            ToolSpec::Qualified(spec) => &spec.name,
314        }
315    }
316
317    /// Check if this is explicitly a built-in tool
318    pub fn is_builtin(&self) -> bool {
319        match self {
320            ToolSpec::Simple(_) => true, // default to builtin for simple names
321            ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::Shell,
322            ToolSpec::Qualified(spec) => spec.source == ToolSource::Builtin,
323        }
324    }
325
326    /// Check if this is explicitly an MCP tool
327    pub fn is_mcp(&self) -> bool {
328        match self {
329            ToolSpec::Simple(_) => false,
330            ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::MCP,
331            ToolSpec::Qualified(spec) => spec.source == ToolSource::Mcp,
332        }
333    }
334
335    /// Check if this is an HTTP tool
336    pub fn is_http(&self) -> bool {
337        match self {
338            ToolSpec::Simple(_) => false,
339            ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::HTTP,
340            ToolSpec::Qualified(_) => false,
341        }
342    }
343
344    /// Check if this is a Shell tool (type-based)
345    pub fn is_shell(&self) -> bool {
346        match self {
347            ToolSpec::Simple(name) => name == "shell",
348            ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::Shell,
349            ToolSpec::Qualified(spec) => spec.name == "shell",
350        }
351    }
352
353    /// Get the tool type (for type-based specs)
354    pub fn tool_type(&self) -> Option<TypeBasedToolType> {
355        match self {
356            ToolSpec::TypeBased(spec) => Some(spec.tool_type),
357            _ => None,
358        }
359    }
360
361    /// Get the MCP server name (if this is an MCP tool)
362    pub fn mcp_server(&self) -> Option<&str> {
363        match self {
364            ToolSpec::Simple(_) => None,
365            ToolSpec::TypeBased(spec) => {
366                if spec.tool_type == TypeBasedToolType::MCP {
367                    spec.config.get("name").and_then(|v| v.as_str())
368                } else {
369                    None
370                }
371            }
372            ToolSpec::Qualified(spec) => spec.server.as_deref(),
373        }
374    }
375
376    /// Get tool configuration
377    pub fn config(&self) -> Option<&serde_json::Value> {
378        match self {
379            ToolSpec::Simple(_) => None,
380            ToolSpec::TypeBased(spec) => Some(&spec.config),
381            ToolSpec::Qualified(spec) => spec.config.as_ref(),
382        }
383    }
384
385    /// Get type-based spec if this is a type-based tool
386    pub fn type_based_spec(&self) -> Option<&TypeBasedToolSpec> {
387        match self {
388            ToolSpec::TypeBased(spec) => Some(spec),
389            _ => None,
390        }
391    }
392}
393
394/// Fully qualified tool specification
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct QualifiedToolSpec {
397    /// Tool name
398    pub name: String,
399
400    /// Tool source: builtin or mcp
401    #[serde(default)]
402    pub source: ToolSource,
403
404    /// MCP server name (required if source is "mcp")
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub server: Option<String>,
407
408    /// Tool-specific configuration/arguments
409    /// For built-in tools: default values, restrictions, etc.
410    /// For MCP tools: tool-specific options
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub config: Option<serde_json::Value>,
413
414    /// Whether the tool is enabled (default: true)
415    #[serde(default = "default_enabled")]
416    pub enabled: bool,
417
418    /// Timeout override for this specific tool
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub timeout_secs: Option<u64>,
421}
422
423fn default_enabled() -> bool {
424    true
425}
426
427/// Tool source - where the tool comes from
428#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
429#[serde(rename_all = "lowercase")]
430pub enum ToolSource {
431    /// Built-in AOF tool (Rust implementation)
432    #[default]
433    Builtin,
434    /// MCP server tool
435    Mcp,
436}
437
438/// Core agent trait - the foundation of AOF
439///
440/// Agents orchestrate models, tools, and memory to accomplish tasks.
441/// Implementations should be zero-cost wrappers where possible.
442#[async_trait]
443pub trait Agent: Send + Sync {
444    /// Execute the agent with given input
445    async fn execute(&self, ctx: &mut AgentContext) -> AofResult<String>;
446
447    /// Agent metadata
448    fn metadata(&self) -> &AgentMetadata;
449
450    /// Initialize agent (setup resources, validate config)
451    async fn init(&mut self) -> AofResult<()> {
452        Ok(())
453    }
454
455    /// Cleanup agent resources
456    async fn cleanup(&mut self) -> AofResult<()> {
457        Ok(())
458    }
459
460    /// Validate agent configuration
461    fn validate(&self) -> AofResult<()> {
462        Ok(())
463    }
464}
465
466/// Agent execution context - passed through the execution chain
467#[derive(Debug, Clone)]
468pub struct AgentContext {
469    /// User input/query
470    pub input: String,
471
472    /// Conversation history
473    pub messages: Vec<Message>,
474
475    /// Session state/variables
476    pub state: HashMap<String, serde_json::Value>,
477
478    /// Tool execution results
479    pub tool_results: Vec<ToolResult>,
480
481    /// Execution metadata
482    pub metadata: ExecutionMetadata,
483
484    /// Optional output schema for structured responses
485    pub output_schema: Option<crate::schema::OutputSchema>,
486
487    /// Optional input schema for validation
488    pub input_schema: Option<crate::schema::InputSchema>,
489}
490
491/// Message in conversation history
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct Message {
494    pub role: MessageRole,
495    pub content: String,
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub tool_calls: Option<Vec<crate::ToolCall>>,
498    /// Tool call ID (required for Tool role messages)
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub tool_call_id: Option<String>,
501}
502
503/// Message role
504#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
505#[serde(rename_all = "lowercase")]
506pub enum MessageRole {
507    User,
508    Assistant,
509    System,
510    Tool,
511}
512
513/// Tool execution result
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct ToolResult {
516    pub tool_name: String,
517    pub result: serde_json::Value,
518    pub success: bool,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub error: Option<String>,
521}
522
523/// Execution metadata
524#[derive(Debug, Clone, Default)]
525pub struct ExecutionMetadata {
526    /// Tokens used (input)
527    pub input_tokens: usize,
528    /// Tokens used (output)
529    pub output_tokens: usize,
530    /// Execution time (ms)
531    pub execution_time_ms: u64,
532    /// Number of tool calls
533    pub tool_calls: usize,
534    /// Model used
535    pub model: Option<String>,
536}
537
538impl AgentContext {
539    /// Create new context with input
540    pub fn new(input: impl Into<String>) -> Self {
541        Self {
542            input: input.into(),
543            messages: Vec::new(),
544            state: HashMap::new(),
545            tool_results: Vec::new(),
546            metadata: ExecutionMetadata::default(),
547            output_schema: None,
548            input_schema: None,
549        }
550    }
551
552    /// Set output schema for structured responses
553    pub fn with_output_schema(mut self, schema: crate::schema::OutputSchema) -> Self {
554        self.output_schema = Some(schema);
555        self
556    }
557
558    /// Set input schema for validation
559    pub fn with_input_schema(mut self, schema: crate::schema::InputSchema) -> Self {
560        self.input_schema = Some(schema);
561        self
562    }
563
564    /// Add a message to history
565    pub fn add_message(&mut self, role: MessageRole, content: impl Into<String>) {
566        self.messages.push(Message {
567            role,
568            content: content.into(),
569            tool_calls: None,
570            tool_call_id: None,
571        });
572    }
573
574    /// Get state value
575    pub fn get_state<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
576        self.state
577            .get(key)
578            .and_then(|v| serde_json::from_value(v.clone()).ok())
579    }
580
581    /// Set state value
582    pub fn set_state<T: Serialize>(&mut self, key: impl Into<String>, value: T) -> AofResult<()> {
583        let json_value = serde_json::to_value(value)?;
584        self.state.insert(key.into(), json_value);
585        Ok(())
586    }
587}
588
589/// Agent metadata
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct AgentMetadata {
592    /// Agent name
593    pub name: String,
594
595    /// Agent description
596    pub description: String,
597
598    /// Agent version
599    pub version: String,
600
601    /// Supported capabilities
602    pub capabilities: Vec<String>,
603
604    /// Custom metadata
605    #[serde(flatten)]
606    pub extra: HashMap<String, serde_json::Value>,
607}
608
609/// Agent configuration
610/// Supports both flat format and Kubernetes-style format
611#[derive(Debug, Clone, Serialize, Deserialize)]
612#[serde(from = "AgentConfigInput")]
613pub struct AgentConfig {
614    /// Agent name
615    pub name: String,
616
617    /// System prompt (also accepts "instructions" alias)
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub system_prompt: Option<String>,
620
621    /// Model to use (can be "provider:model" format or just "model")
622    pub model: String,
623
624    /// LLM provider (anthropic, openai, google, ollama, groq, bedrock, azure)
625    /// Optional if provider is specified in model string (e.g., "google:gemini-2.0-flash")
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub provider: Option<String>,
628
629    /// Tools available to agent
630    /// Supports both simple strings (backward compatible) and qualified specs
631    /// Simple string: "shell" - uses built-in tool with defaults
632    /// Qualified: {name: "shell", source: "builtin", config: {...}}
633    #[serde(default)]
634    pub tools: Vec<ToolSpec>,
635
636    /// MCP servers configuration (flexible MCP tool sources)
637    /// Each server can use stdio, sse, or http transport
638    #[serde(default, skip_serializing_if = "Vec::is_empty")]
639    pub mcp_servers: Vec<McpServerConfig>,
640
641    /// Memory backend configuration
642    /// Supports both simple string format and structured object format
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub memory: Option<MemorySpec>,
645
646    /// Maximum number of conversation messages to include in context
647    /// Controls token usage by limiting how much history is sent to the LLM.
648    /// Default is 10 messages. Set higher for longer context, lower to save tokens.
649    #[serde(default = "default_max_context_messages")]
650    pub max_context_messages: usize,
651
652    /// Max iterations
653    #[serde(default = "default_max_iterations")]
654    pub max_iterations: usize,
655
656    /// Temperature (0.0-1.0)
657    #[serde(default = "default_temperature")]
658    pub temperature: f32,
659
660    /// Max tokens
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub max_tokens: Option<usize>,
663
664    /// Output schema for structured responses (JSON Schema format)
665    /// When specified, agent responses will be validated against this schema
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub output_schema: Option<OutputSchemaSpec>,
668
669    /// Custom configuration
670    #[serde(flatten)]
671    pub extra: HashMap<String, serde_json::Value>,
672}
673
674impl AgentConfig {
675    /// Get all tool names (for backward compatibility)
676    pub fn tool_names(&self) -> Vec<&str> {
677        self.tools.iter().map(|t| t.name()).collect()
678    }
679
680    /// Get built-in tools only
681    pub fn builtin_tools(&self) -> Vec<&ToolSpec> {
682        self.tools.iter().filter(|t| t.is_builtin()).collect()
683    }
684
685    /// Get MCP tools only
686    pub fn mcp_tools(&self) -> Vec<&ToolSpec> {
687        self.tools.iter().filter(|t| t.is_mcp()).collect()
688    }
689
690    /// Get type-based Shell tools
691    pub fn type_based_shell_tools(&self) -> Vec<&TypeBasedToolSpec> {
692        self.tools
693            .iter()
694            .filter_map(|t| match t {
695                ToolSpec::TypeBased(spec) if spec.tool_type == TypeBasedToolType::Shell => {
696                    Some(spec)
697                }
698                _ => None,
699            })
700            .collect()
701    }
702
703    /// Get type-based MCP tools
704    pub fn type_based_mcp_tools(&self) -> Vec<&TypeBasedToolSpec> {
705        self.tools
706            .iter()
707            .filter_map(|t| match t {
708                ToolSpec::TypeBased(spec) if spec.tool_type == TypeBasedToolType::MCP => Some(spec),
709                _ => None,
710            })
711            .collect()
712    }
713
714    /// Get type-based HTTP tools
715    pub fn type_based_http_tools(&self) -> Vec<&TypeBasedToolSpec> {
716        self.tools
717            .iter()
718            .filter_map(|t| match t {
719                ToolSpec::TypeBased(spec) if spec.tool_type == TypeBasedToolType::HTTP => {
720                    Some(spec)
721                }
722                _ => None,
723            })
724            .collect()
725    }
726
727    /// Check if there are any type-based tools
728    pub fn has_type_based_tools(&self) -> bool {
729        self.tools.iter().any(|t| matches!(t, ToolSpec::TypeBased(_)))
730    }
731
732    /// Convert type-based MCP tools to McpServerConfig
733    /// Returns configs that can be used with create_mcp_executor_from_config
734    pub fn type_based_mcp_to_server_configs(&self) -> Vec<crate::mcp::McpServerConfig> {
735        self.type_based_mcp_tools()
736            .iter()
737            .filter_map(|spec| {
738                let config = &spec.config;
739                let name = config.get("name")?.as_str()?;
740
741                // Extract command - can be string or array
742                let command = config.get("command").and_then(|v| {
743                    if let Some(s) = v.as_str() {
744                        Some(s.to_string())
745                    } else if let Some(arr) = v.as_array() {
746                        arr.first().and_then(|v| v.as_str()).map(|s| s.to_string())
747                    } else {
748                        None
749                    }
750                });
751
752                // Extract args from command array (skip first element)
753                let args: Vec<String> = config
754                    .get("command")
755                    .and_then(|v| v.as_array())
756                    .map(|arr| {
757                        arr.iter()
758                            .skip(1)
759                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
760                            .collect()
761                    })
762                    .unwrap_or_default();
763
764                // Extract env vars
765                let env: std::collections::HashMap<String, String> = config
766                    .get("env")
767                    .and_then(|v| v.as_object())
768                    .map(|obj| {
769                        obj.iter()
770                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
771                            .collect()
772                    })
773                    .unwrap_or_default();
774
775                Some(crate::mcp::McpServerConfig {
776                    name: name.to_string(),
777                    transport: crate::mcp::McpTransport::Stdio,
778                    command,
779                    args,
780                    env,
781                    endpoint: None,
782                    tools: vec![],
783                    init_options: None,
784                    timeout_secs: config
785                        .get("timeout_seconds")
786                        .and_then(|v| v.as_u64())
787                        .unwrap_or(30),
788                    auto_reconnect: true,
789                })
790            })
791            .collect()
792    }
793
794    /// Get Shell tool configuration (allowed_commands, working_directory, etc.)
795    pub fn shell_tool_config(&self) -> Option<ShellToolConfig> {
796        self.type_based_shell_tools().first().map(|spec| {
797            let config = &spec.config;
798            ShellToolConfig {
799                allowed_commands: config
800                    .get("allowed_commands")
801                    .and_then(|v| v.as_array())
802                    .map(|arr| {
803                        arr.iter()
804                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
805                            .collect()
806                    })
807                    .unwrap_or_default(),
808                working_directory: config
809                    .get("working_directory")
810                    .and_then(|v| v.as_str())
811                    .map(|s| s.to_string()),
812                timeout_seconds: config
813                    .get("timeout_seconds")
814                    .and_then(|v| v.as_u64())
815                    .map(|n| n as u32),
816            }
817        })
818    }
819
820    /// Get HTTP tool configuration
821    pub fn http_tool_config(&self) -> Option<HttpToolConfig> {
822        self.type_based_http_tools().first().map(|spec| {
823            let config = &spec.config;
824            HttpToolConfig {
825                base_url: config
826                    .get("base_url")
827                    .and_then(|v| v.as_str())
828                    .map(|s| s.to_string()),
829                timeout_seconds: config
830                    .get("timeout_seconds")
831                    .and_then(|v| v.as_u64())
832                    .map(|n| n as u32),
833                allowed_methods: config
834                    .get("allowed_methods")
835                    .and_then(|v| v.as_array())
836                    .map(|arr| {
837                        arr.iter()
838                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
839                            .collect()
840                    })
841                    .unwrap_or_default(),
842            }
843        })
844    }
845}
846
847/// Shell tool configuration extracted from type-based spec
848#[derive(Debug, Clone, Default)]
849pub struct ShellToolConfig {
850    pub allowed_commands: Vec<String>,
851    pub working_directory: Option<String>,
852    pub timeout_seconds: Option<u32>,
853}
854
855/// HTTP tool configuration extracted from type-based spec
856#[derive(Debug, Clone, Default)]
857pub struct HttpToolConfig {
858    pub base_url: Option<String>,
859    pub timeout_seconds: Option<u32>,
860    pub allowed_methods: Vec<String>,
861}
862
863/// Internal type for flexible config parsing
864/// Supports both flat format and Kubernetes-style format
865#[derive(Debug, Clone, Deserialize)]
866#[serde(untagged)]
867enum AgentConfigInput {
868    /// Flat format (original) - try this first since it has required fields
869    Flat(FlatAgentConfig),
870    /// Kubernetes-style format with apiVersion, kind, metadata, spec
871    Kubernetes(KubernetesConfig),
872}
873
874/// Kubernetes-style config wrapper
875#[derive(Debug, Clone, Deserialize)]
876struct KubernetesConfig {
877    #[serde(rename = "apiVersion")]
878    api_version: String,  // Required for K8s format
879    kind: String,         // Required for K8s format
880    metadata: KubernetesMetadata,
881    spec: AgentSpec,
882}
883
884#[derive(Debug, Clone, Deserialize)]
885struct KubernetesMetadata {
886    name: String,
887    #[serde(default)]
888    labels: HashMap<String, String>,
889    #[serde(default)]
890    annotations: HashMap<String, String>,
891}
892
893#[derive(Debug, Clone, Deserialize)]
894struct AgentSpec {
895    model: String,
896    provider: Option<String>,
897    #[serde(alias = "system_prompt")]
898    instructions: Option<String>,
899    #[serde(default)]
900    tools: Vec<ToolSpec>,
901    #[serde(default)]
902    mcp_servers: Vec<McpServerConfig>,
903    memory: Option<MemorySpec>,
904    #[serde(default = "default_max_context_messages")]
905    max_context_messages: usize,
906    #[serde(default = "default_max_iterations")]
907    max_iterations: usize,
908    #[serde(default = "default_temperature")]
909    temperature: f32,
910    max_tokens: Option<usize>,
911    output_schema: Option<OutputSchemaSpec>,
912    #[serde(flatten)]
913    extra: HashMap<String, serde_json::Value>,
914}
915
916#[derive(Debug, Clone, Deserialize)]
917struct FlatAgentConfig {
918    name: String,
919    #[serde(alias = "instructions")]
920    system_prompt: Option<String>,
921    model: String,
922    provider: Option<String>,
923    #[serde(default)]
924    tools: Vec<ToolSpec>,
925    #[serde(default)]
926    mcp_servers: Vec<McpServerConfig>,
927    memory: Option<MemorySpec>,
928    #[serde(default = "default_max_context_messages")]
929    max_context_messages: usize,
930    #[serde(default = "default_max_iterations")]
931    max_iterations: usize,
932    #[serde(default = "default_temperature")]
933    temperature: f32,
934    max_tokens: Option<usize>,
935    output_schema: Option<OutputSchemaSpec>,
936    #[serde(flatten)]
937    extra: HashMap<String, serde_json::Value>,
938}
939
940impl From<AgentConfigInput> for AgentConfig {
941    fn from(input: AgentConfigInput) -> Self {
942        match input {
943            AgentConfigInput::Flat(flat) => AgentConfig {
944                name: flat.name,
945                system_prompt: flat.system_prompt,
946                model: flat.model,
947                provider: flat.provider,
948                tools: flat.tools,
949                mcp_servers: flat.mcp_servers,
950                memory: flat.memory,
951                max_context_messages: flat.max_context_messages,
952                max_iterations: flat.max_iterations,
953                temperature: flat.temperature,
954                max_tokens: flat.max_tokens,
955                output_schema: flat.output_schema,
956                extra: flat.extra,
957            },
958            AgentConfigInput::Kubernetes(k8s) => {
959                AgentConfig {
960                    name: k8s.metadata.name,
961                    system_prompt: k8s.spec.instructions,
962                    model: k8s.spec.model,
963                    provider: k8s.spec.provider,
964                    tools: k8s.spec.tools,
965                    mcp_servers: k8s.spec.mcp_servers,
966                    memory: k8s.spec.memory,
967                    max_context_messages: k8s.spec.max_context_messages,
968                    max_iterations: k8s.spec.max_iterations,
969                    temperature: k8s.spec.temperature,
970                    max_tokens: k8s.spec.max_tokens,
971                    output_schema: k8s.spec.output_schema,
972                    extra: k8s.spec.extra,
973                }
974            }
975        }
976    }
977}
978
979fn default_max_iterations() -> usize {
980    10
981}
982
983fn default_max_context_messages() -> usize {
984    10
985}
986
987fn default_temperature() -> f32 {
988    0.7
989}
990
991/// Reference-counted agent
992pub type AgentRef = Arc<dyn Agent>;
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    #[test]
999    fn test_agent_context_new() {
1000        let ctx = AgentContext::new("Hello, world!");
1001        assert_eq!(ctx.input, "Hello, world!");
1002        assert!(ctx.messages.is_empty());
1003        assert!(ctx.state.is_empty());
1004        assert!(ctx.tool_results.is_empty());
1005    }
1006
1007    #[test]
1008    fn test_agent_context_add_message() {
1009        let mut ctx = AgentContext::new("test");
1010        ctx.add_message(MessageRole::User, "user message");
1011        ctx.add_message(MessageRole::Assistant, "assistant response");
1012
1013        assert_eq!(ctx.messages.len(), 2);
1014        assert_eq!(ctx.messages[0].role, MessageRole::User);
1015        assert_eq!(ctx.messages[0].content, "user message");
1016        assert_eq!(ctx.messages[1].role, MessageRole::Assistant);
1017        assert_eq!(ctx.messages[1].content, "assistant response");
1018    }
1019
1020    #[test]
1021    fn test_agent_context_state() {
1022        let mut ctx = AgentContext::new("test");
1023
1024        // Set string state
1025        ctx.set_state("name", "test_agent").unwrap();
1026        let name: Option<String> = ctx.get_state("name");
1027        assert_eq!(name, Some("test_agent".to_string()));
1028
1029        // Set numeric state
1030        ctx.set_state("count", 42i32).unwrap();
1031        let count: Option<i32> = ctx.get_state("count");
1032        assert_eq!(count, Some(42));
1033
1034        // Get non-existent key
1035        let missing: Option<String> = ctx.get_state("missing");
1036        assert!(missing.is_none());
1037    }
1038
1039    #[test]
1040    fn test_message_role_serialization() {
1041        let user = MessageRole::User;
1042        let serialized = serde_json::to_string(&user).unwrap();
1043        assert_eq!(serialized, "\"user\"");
1044
1045        let deserialized: MessageRole = serde_json::from_str("\"assistant\"").unwrap();
1046        assert_eq!(deserialized, MessageRole::Assistant);
1047    }
1048
1049    #[test]
1050    fn test_agent_config_defaults() {
1051        let yaml = r#"
1052            name: test-agent
1053            model: claude-3-5-sonnet
1054        "#;
1055        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1056
1057        assert_eq!(config.name, "test-agent");
1058        assert_eq!(config.model, "claude-3-5-sonnet");
1059        assert_eq!(config.max_iterations, 10); // default
1060        assert_eq!(config.temperature, 0.7); // default
1061        assert!(config.tools.is_empty());
1062        assert!(config.system_prompt.is_none());
1063    }
1064
1065    #[test]
1066    fn test_agent_config_full() {
1067        let yaml = r#"
1068            name: full-agent
1069            model: gpt-4
1070            system_prompt: "You are a helpful assistant."
1071            tools:
1072              - read_file
1073              - write_file
1074            max_iterations: 20
1075            temperature: 0.5
1076            max_tokens: 4096
1077        "#;
1078        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1079
1080        assert_eq!(config.name, "full-agent");
1081        assert_eq!(config.model, "gpt-4");
1082        assert_eq!(config.system_prompt, Some("You are a helpful assistant.".to_string()));
1083        assert_eq!(config.tool_names(), vec!["read_file", "write_file"]);
1084        assert_eq!(config.max_iterations, 20);
1085        assert_eq!(config.temperature, 0.5);
1086        assert_eq!(config.max_tokens, Some(4096));
1087    }
1088
1089    #[test]
1090    fn test_tool_spec_simple() {
1091        let yaml = r#"
1092            name: test-agent
1093            model: gpt-4
1094            tools:
1095              - shell
1096              - kubectl_get
1097        "#;
1098        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1099
1100        assert_eq!(config.tools.len(), 2);
1101        assert_eq!(config.tools[0].name(), "shell");
1102        assert!(config.tools[0].is_builtin());
1103        assert!(!config.tools[0].is_mcp());
1104    }
1105
1106    #[test]
1107    fn test_tool_spec_qualified_builtin() {
1108        let yaml = r#"
1109            name: test-agent
1110            model: gpt-4
1111            tools:
1112              - name: shell
1113                source: builtin
1114                config:
1115                  blocked_commands:
1116                    - rm -rf
1117                  timeout_secs: 60
1118        "#;
1119        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1120
1121        assert_eq!(config.tools.len(), 1);
1122        assert_eq!(config.tools[0].name(), "shell");
1123        assert!(config.tools[0].is_builtin());
1124        assert!(config.tools[0].config().is_some());
1125    }
1126
1127    #[test]
1128    fn test_tool_spec_qualified_mcp() {
1129        let yaml = r#"
1130            name: test-agent
1131            model: gpt-4
1132            tools:
1133              - name: read_file
1134                source: mcp
1135                server: filesystem
1136                config:
1137                  allowed_paths:
1138                    - /workspace
1139        "#;
1140        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1141
1142        assert_eq!(config.tools.len(), 1);
1143        assert_eq!(config.tools[0].name(), "read_file");
1144        assert!(config.tools[0].is_mcp());
1145        assert_eq!(config.tools[0].mcp_server(), Some("filesystem"));
1146    }
1147
1148    #[test]
1149    fn test_tool_spec_mixed() {
1150        let yaml = r#"
1151            name: test-agent
1152            model: gpt-4
1153            tools:
1154              # Simple builtin
1155              - shell
1156              # Qualified builtin with config
1157              - name: kubectl_get
1158                source: builtin
1159                timeout_secs: 120
1160              # MCP tool
1161              - name: github_search
1162                source: mcp
1163                server: github
1164            mcp_servers:
1165              - name: github
1166                command: npx
1167                args: ["@modelcontextprotocol/server-github"]
1168        "#;
1169        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1170
1171        assert_eq!(config.tools.len(), 3);
1172
1173        // Check builtin tools
1174        let builtin_tools = config.builtin_tools();
1175        assert_eq!(builtin_tools.len(), 2);
1176
1177        // Check MCP tools
1178        let mcp_tools = config.mcp_tools();
1179        assert_eq!(mcp_tools.len(), 1);
1180        assert_eq!(mcp_tools[0].mcp_server(), Some("github"));
1181    }
1182
1183    #[test]
1184    fn test_tool_spec_type_based_shell() {
1185        let yaml = r#"
1186            name: test-agent
1187            model: gpt-4
1188            tools:
1189              - type: Shell
1190                config:
1191                  allowed_commands:
1192                    - kubectl
1193                    - helm
1194                  working_directory: /tmp
1195                  timeout_seconds: 30
1196        "#;
1197        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1198
1199        assert_eq!(config.tools.len(), 1);
1200        assert_eq!(config.tools[0].name(), "shell");
1201        assert!(config.tools[0].is_shell());
1202        assert!(config.tools[0].is_builtin());
1203        assert!(config.tools[0].config().is_some());
1204
1205        let config_val = config.tools[0].config().unwrap();
1206        assert!(config_val.get("allowed_commands").is_some());
1207    }
1208
1209    #[test]
1210    fn test_tool_spec_type_based_mcp() {
1211        let yaml = r#"
1212            name: test-agent
1213            model: gpt-4
1214            tools:
1215              - type: MCP
1216                config:
1217                  name: kubectl-mcp
1218                  command: ["npx", "-y", "@modelcontextprotocol/server-kubectl"]
1219                  env:
1220                    KUBECONFIG: "${KUBECONFIG}"
1221        "#;
1222        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1223
1224        assert_eq!(config.tools.len(), 1);
1225        assert!(config.tools[0].is_mcp());
1226        assert_eq!(config.tools[0].mcp_server(), Some("kubectl-mcp"));
1227    }
1228
1229    #[test]
1230    fn test_tool_spec_type_based_http() {
1231        let yaml = r#"
1232            name: test-agent
1233            model: gpt-4
1234            tools:
1235              - type: HTTP
1236                config:
1237                  base_url: http://localhost:8080
1238                  timeout_seconds: 10
1239                  allowed_methods: [GET, POST]
1240        "#;
1241        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1242
1243        assert_eq!(config.tools.len(), 1);
1244        assert_eq!(config.tools[0].name(), "http");
1245        assert!(config.tools[0].is_http());
1246
1247        let config_val = config.tools[0].config().unwrap();
1248        assert_eq!(config_val.get("base_url").unwrap(), "http://localhost:8080");
1249    }
1250
1251    #[test]
1252    fn test_tool_spec_type_based_mixed() {
1253        // This is the exact format from the user's final_agents.yaml
1254        let yaml = r#"
1255            apiVersion: aof.dev/v1
1256            kind: Agent
1257            metadata:
1258              name: k8s-helper
1259              labels:
1260                purpose: operations
1261            spec:
1262              model: google:gemini-2.5-flash
1263              instructions: You are a K8s helper.
1264              tools:
1265                - type: Shell
1266                  config:
1267                    allowed_commands:
1268                      - kubectl
1269                      - helm
1270                    working_directory: /tmp
1271                    timeout_seconds: 30
1272                - type: MCP
1273                  config:
1274                    name: kubectl-mcp
1275                    command: ["npx", "-y", "@modelcontextprotocol/server-kubectl"]
1276                - type: HTTP
1277                  config:
1278                    base_url: http://localhost
1279                    timeout_seconds: 10
1280              memory:
1281                type: File
1282                config:
1283                  path: ./k8s-helper-memory.json
1284                  max_messages: 50
1285        "#;
1286        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1287
1288        assert_eq!(config.name, "k8s-helper");
1289        assert_eq!(config.tools.len(), 3);
1290
1291        // First tool: Shell
1292        assert!(config.tools[0].is_shell());
1293        assert!(config.tools[0].is_builtin());
1294
1295        // Second tool: MCP
1296        assert!(config.tools[1].is_mcp());
1297        assert_eq!(config.tools[1].mcp_server(), Some("kubectl-mcp"));
1298
1299        // Third tool: HTTP
1300        assert!(config.tools[2].is_http());
1301
1302        // Memory
1303        assert!(config.memory.is_some());
1304        let memory = config.memory.as_ref().unwrap();
1305        assert!(memory.is_file());
1306        assert_eq!(memory.path(), Some("./k8s-helper-memory.json".to_string()));
1307    }
1308
1309    #[test]
1310    fn test_tool_result_serialization() {
1311        let result = ToolResult {
1312            tool_name: "test_tool".to_string(),
1313            result: serde_json::json!({"output": "success"}),
1314            success: true,
1315            error: None,
1316        };
1317
1318        let json = serde_json::to_string(&result).unwrap();
1319        assert!(json.contains("test_tool"));
1320        assert!(json.contains("success"));
1321
1322        let deserialized: ToolResult = serde_json::from_str(&json).unwrap();
1323        assert_eq!(deserialized.tool_name, "test_tool");
1324        assert!(deserialized.success);
1325    }
1326
1327    #[test]
1328    fn test_execution_metadata_default() {
1329        let meta = ExecutionMetadata::default();
1330        assert_eq!(meta.input_tokens, 0);
1331        assert_eq!(meta.output_tokens, 0);
1332        assert_eq!(meta.execution_time_ms, 0);
1333        assert_eq!(meta.tool_calls, 0);
1334        assert!(meta.model.is_none());
1335    }
1336
1337    #[test]
1338    fn test_agent_metadata_serialization() {
1339        let meta = AgentMetadata {
1340            name: "test".to_string(),
1341            description: "A test agent".to_string(),
1342            version: "1.0.0".to_string(),
1343            capabilities: vec!["coding".to_string(), "testing".to_string()],
1344            extra: HashMap::new(),
1345        };
1346
1347        let json = serde_json::to_string(&meta).unwrap();
1348        let deserialized: AgentMetadata = serde_json::from_str(&json).unwrap();
1349
1350        assert_eq!(deserialized.name, "test");
1351        assert_eq!(deserialized.capabilities.len(), 2);
1352    }
1353
1354    #[test]
1355    fn test_agent_config_with_mcp_servers() {
1356        let yaml = r#"
1357            name: mcp-agent
1358            model: gpt-4
1359            mcp_servers:
1360              - name: filesystem
1361                transport: stdio
1362                command: npx
1363                args:
1364                  - "@anthropic-ai/mcp-server-fs"
1365                env:
1366                  MCP_FS_ROOT: /workspace
1367              - name: remote
1368                transport: sse
1369                endpoint: http://localhost:3000/mcp
1370        "#;
1371        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1372
1373        assert_eq!(config.name, "mcp-agent");
1374        assert_eq!(config.mcp_servers.len(), 2);
1375
1376        // Check first server (stdio)
1377        let fs_server = &config.mcp_servers[0];
1378        assert_eq!(fs_server.name, "filesystem");
1379        assert_eq!(fs_server.transport, crate::mcp::McpTransport::Stdio);
1380        assert_eq!(fs_server.command, Some("npx".to_string()));
1381        assert_eq!(fs_server.args.len(), 1);
1382        assert!(fs_server.env.contains_key("MCP_FS_ROOT"));
1383
1384        // Check second server (sse)
1385        let remote_server = &config.mcp_servers[1];
1386        assert_eq!(remote_server.name, "remote");
1387        assert_eq!(remote_server.transport, crate::mcp::McpTransport::Sse);
1388        assert_eq!(remote_server.endpoint, Some("http://localhost:3000/mcp".to_string()));
1389    }
1390
1391    #[test]
1392    fn test_agent_config_k8s_style_with_mcp_servers() {
1393        let yaml = r#"
1394            apiVersion: aof.dev/v1
1395            kind: Agent
1396            metadata:
1397              name: k8s-mcp-agent
1398              labels:
1399                env: test
1400            spec:
1401              model: claude-3-5-sonnet
1402              instructions: Test agent with MCP
1403              mcp_servers:
1404                - name: tools
1405                  command: ./my-mcp-server
1406        "#;
1407        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1408
1409        assert_eq!(config.name, "k8s-mcp-agent");
1410        assert_eq!(config.mcp_servers.len(), 1);
1411        assert_eq!(config.mcp_servers[0].name, "tools");
1412        assert_eq!(config.mcp_servers[0].command, Some("./my-mcp-server".to_string()));
1413    }
1414
1415    #[test]
1416    fn test_memory_spec_simple_string() {
1417        let yaml = r#"
1418            name: test-agent
1419            model: gpt-4
1420            memory: "file:./memory.json"
1421        "#;
1422        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1423
1424        assert!(config.memory.is_some());
1425        let memory = config.memory.as_ref().unwrap();
1426        assert_eq!(memory.memory_type(), "file");
1427        assert_eq!(memory.path(), Some("./memory.json".to_string()));
1428        assert!(memory.is_file());
1429        assert!(!memory.is_in_memory());
1430    }
1431
1432    #[test]
1433    fn test_memory_spec_simple_in_memory() {
1434        let yaml = r#"
1435            name: test-agent
1436            model: gpt-4
1437            memory: "in_memory"
1438        "#;
1439        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1440
1441        assert!(config.memory.is_some());
1442        let memory = config.memory.as_ref().unwrap();
1443        assert_eq!(memory.memory_type(), "in_memory");
1444        assert!(memory.is_in_memory());
1445        assert!(!memory.is_file());
1446    }
1447
1448    #[test]
1449    fn test_memory_spec_structured_file() {
1450        let yaml = r#"
1451            name: test-agent
1452            model: gpt-4
1453            memory:
1454              type: File
1455              config:
1456                path: ./k8s-helper-memory.json
1457                max_messages: 50
1458        "#;
1459        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1460
1461        assert!(config.memory.is_some());
1462        let memory = config.memory.as_ref().unwrap();
1463        assert_eq!(memory.memory_type(), "File");
1464        assert_eq!(memory.path(), Some("./k8s-helper-memory.json".to_string()));
1465        assert_eq!(memory.max_messages(), Some(50));
1466        assert!(memory.is_file());
1467    }
1468
1469    #[test]
1470    fn test_memory_spec_structured_in_memory() {
1471        let yaml = r#"
1472            name: test-agent
1473            model: gpt-4
1474            memory:
1475              type: InMemory
1476              config:
1477                max_messages: 100
1478        "#;
1479        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1480
1481        assert!(config.memory.is_some());
1482        let memory = config.memory.as_ref().unwrap();
1483        assert_eq!(memory.memory_type(), "InMemory");
1484        assert!(memory.is_in_memory());
1485        assert_eq!(memory.max_messages(), Some(100));
1486    }
1487
1488    #[test]
1489    fn test_memory_spec_k8s_style_with_structured_memory() {
1490        // This is the exact format from the bug report
1491        let yaml = r#"
1492            apiVersion: aof.dev/v1
1493            kind: Agent
1494            metadata:
1495              name: k8s-helper
1496              labels:
1497                purpose: operations
1498                team: platform
1499            spec:
1500              model: google:gemini-2.5-flash
1501              instructions: |
1502                You are a Kubernetes helper.
1503              memory:
1504                type: File
1505                config:
1506                  path: ./k8s-helper-memory.json
1507                  max_messages: 50
1508        "#;
1509        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1510
1511        assert_eq!(config.name, "k8s-helper");
1512        assert!(config.memory.is_some());
1513        let memory = config.memory.as_ref().unwrap();
1514        assert_eq!(memory.memory_type(), "File");
1515        assert_eq!(memory.path(), Some("./k8s-helper-memory.json".to_string()));
1516        assert_eq!(memory.max_messages(), Some(50));
1517    }
1518
1519    #[test]
1520    fn test_memory_spec_no_memory() {
1521        let yaml = r#"
1522            name: test-agent
1523            model: gpt-4
1524        "#;
1525        let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1526        assert!(config.memory.is_none());
1527    }
1528}