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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct OutputSchemaSpec {
17 #[serde(rename = "type")]
19 pub schema_type: String,
20
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub properties: Option<HashMap<String, serde_json::Value>>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub required: Option<Vec<String>>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub items: Option<Box<serde_json::Value>>,
32
33 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
35 pub enum_values: Option<Vec<String>>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub description: Option<String>,
40
41 #[serde(default, rename = "additionalProperties")]
43 pub additional_properties: Option<bool>,
44
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub validation_mode: Option<String>,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub on_validation_error: Option<String>,
52
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub max_retries: Option<u32>,
56
57 #[serde(flatten)]
59 pub extra: HashMap<String, serde_json::Value>,
60}
61
62impl OutputSchemaSpec {
63 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 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 pub fn get_validation_mode(&self) -> &str {
105 self.validation_mode.as_deref().unwrap_or("strict")
106 }
107
108 pub fn get_error_behavior(&self) -> &str {
110 self.on_validation_error.as_deref().unwrap_or("fail")
111 }
112
113 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
123impl From<OutputSchemaSpec> for crate::schema::OutputSchema {
125 fn from(spec: OutputSchemaSpec) -> Self {
126 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 if let Some(desc) = description {
135 output = output.with_description(desc);
136 }
137
138 output = output.with_strict(strict);
140
141 output
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(untagged)]
152pub enum MemorySpec {
153 Simple(String),
156
157 Structured(StructuredMemoryConfig),
159}
160
161impl MemorySpec {
162 pub fn memory_type(&self) -> &str {
164 match self {
165 MemorySpec::Simple(s) => {
166 s.split(':').next().unwrap_or(s)
168 }
169 MemorySpec::Structured(config) => &config.memory_type,
170 }
171 }
172
173 pub fn path(&self) -> Option<String> {
175 match self {
176 MemorySpec::Simple(s) => {
177 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 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 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 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 pub fn is_file(&self) -> bool {
222 self.memory_type().to_lowercase() == "file"
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct StructuredMemoryConfig {
229 #[serde(rename = "type")]
231 pub memory_type: String,
232
233 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(untagged)]
262pub enum ToolSpec {
263 Simple(String),
266
267 TypeBased(TypeBasedToolSpec),
270
271 Qualified(QualifiedToolSpec),
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct TypeBasedToolSpec {
279 #[serde(rename = "type")]
281 pub tool_type: TypeBasedToolType,
282
283 #[serde(default)]
285 pub config: serde_json::Value,
286}
287
288#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
290pub enum TypeBasedToolType {
291 Shell,
293 MCP,
295 HTTP,
297}
298
299impl ToolSpec {
300 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 pub fn is_builtin(&self) -> bool {
319 match self {
320 ToolSpec::Simple(_) => true, ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::Shell,
322 ToolSpec::Qualified(spec) => spec.source == ToolSource::Builtin,
323 }
324 }
325
326 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 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 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 pub fn tool_type(&self) -> Option<TypeBasedToolType> {
355 match self {
356 ToolSpec::TypeBased(spec) => Some(spec.tool_type),
357 _ => None,
358 }
359 }
360
361 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 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 pub fn type_based_spec(&self) -> Option<&TypeBasedToolSpec> {
387 match self {
388 ToolSpec::TypeBased(spec) => Some(spec),
389 _ => None,
390 }
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct QualifiedToolSpec {
397 pub name: String,
399
400 #[serde(default)]
402 pub source: ToolSource,
403
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub server: Option<String>,
407
408 #[serde(skip_serializing_if = "Option::is_none")]
412 pub config: Option<serde_json::Value>,
413
414 #[serde(default = "default_enabled")]
416 pub enabled: bool,
417
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub timeout_secs: Option<u64>,
421}
422
423fn default_enabled() -> bool {
424 true
425}
426
427#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
429#[serde(rename_all = "lowercase")]
430pub enum ToolSource {
431 #[default]
433 Builtin,
434 Mcp,
436}
437
438#[async_trait]
443pub trait Agent: Send + Sync {
444 async fn execute(&self, ctx: &mut AgentContext) -> AofResult<String>;
446
447 fn metadata(&self) -> &AgentMetadata;
449
450 async fn init(&mut self) -> AofResult<()> {
452 Ok(())
453 }
454
455 async fn cleanup(&mut self) -> AofResult<()> {
457 Ok(())
458 }
459
460 fn validate(&self) -> AofResult<()> {
462 Ok(())
463 }
464}
465
466#[derive(Debug, Clone)]
468pub struct AgentContext {
469 pub input: String,
471
472 pub messages: Vec<Message>,
474
475 pub state: HashMap<String, serde_json::Value>,
477
478 pub tool_results: Vec<ToolResult>,
480
481 pub metadata: ExecutionMetadata,
483
484 pub output_schema: Option<crate::schema::OutputSchema>,
486
487 pub input_schema: Option<crate::schema::InputSchema>,
489}
490
491#[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 #[serde(skip_serializing_if = "Option::is_none")]
500 pub tool_call_id: Option<String>,
501}
502
503#[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#[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#[derive(Debug, Clone, Default)]
525pub struct ExecutionMetadata {
526 pub input_tokens: usize,
528 pub output_tokens: usize,
530 pub execution_time_ms: u64,
532 pub tool_calls: usize,
534 pub model: Option<String>,
536}
537
538impl AgentContext {
539 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 pub fn with_output_schema(mut self, schema: crate::schema::OutputSchema) -> Self {
554 self.output_schema = Some(schema);
555 self
556 }
557
558 pub fn with_input_schema(mut self, schema: crate::schema::InputSchema) -> Self {
560 self.input_schema = Some(schema);
561 self
562 }
563
564 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct AgentMetadata {
592 pub name: String,
594
595 pub description: String,
597
598 pub version: String,
600
601 pub capabilities: Vec<String>,
603
604 #[serde(flatten)]
606 pub extra: HashMap<String, serde_json::Value>,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
612#[serde(from = "AgentConfigInput")]
613pub struct AgentConfig {
614 pub name: String,
616
617 #[serde(skip_serializing_if = "Option::is_none")]
619 pub system_prompt: Option<String>,
620
621 pub model: String,
623
624 #[serde(skip_serializing_if = "Option::is_none")]
627 pub provider: Option<String>,
628
629 #[serde(default)]
634 pub tools: Vec<ToolSpec>,
635
636 #[serde(default, skip_serializing_if = "Vec::is_empty")]
639 pub mcp_servers: Vec<McpServerConfig>,
640
641 #[serde(skip_serializing_if = "Option::is_none")]
644 pub memory: Option<MemorySpec>,
645
646 #[serde(default = "default_max_context_messages")]
650 pub max_context_messages: usize,
651
652 #[serde(default = "default_max_iterations")]
654 pub max_iterations: usize,
655
656 #[serde(default = "default_temperature")]
658 pub temperature: f32,
659
660 #[serde(skip_serializing_if = "Option::is_none")]
662 pub max_tokens: Option<usize>,
663
664 #[serde(skip_serializing_if = "Option::is_none")]
667 pub output_schema: Option<OutputSchemaSpec>,
668
669 #[serde(flatten)]
671 pub extra: HashMap<String, serde_json::Value>,
672}
673
674impl AgentConfig {
675 pub fn tool_names(&self) -> Vec<&str> {
677 self.tools.iter().map(|t| t.name()).collect()
678 }
679
680 pub fn builtin_tools(&self) -> Vec<&ToolSpec> {
682 self.tools.iter().filter(|t| t.is_builtin()).collect()
683 }
684
685 pub fn mcp_tools(&self) -> Vec<&ToolSpec> {
687 self.tools.iter().filter(|t| t.is_mcp()).collect()
688 }
689
690 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 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 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 pub fn has_type_based_tools(&self) -> bool {
729 self.tools.iter().any(|t| matches!(t, ToolSpec::TypeBased(_)))
730 }
731
732 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 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 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 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 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 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#[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#[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#[derive(Debug, Clone, Deserialize)]
866#[serde(untagged)]
867enum AgentConfigInput {
868 Flat(FlatAgentConfig),
870 Kubernetes(KubernetesConfig),
872}
873
874#[derive(Debug, Clone, Deserialize)]
876struct KubernetesConfig {
877 #[serde(rename = "apiVersion")]
878 api_version: String, kind: String, 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
991pub 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 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 ctx.set_state("count", 42i32).unwrap();
1031 let count: Option<i32> = ctx.get_state("count");
1032 assert_eq!(count, Some(42));
1033
1034 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); assert_eq!(config.temperature, 0.7); 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 let builtin_tools = config.builtin_tools();
1175 assert_eq!(builtin_tools.len(), 2);
1176
1177 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 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 assert!(config.tools[0].is_shell());
1293 assert!(config.tools[0].is_builtin());
1294
1295 assert!(config.tools[1].is_mcp());
1297 assert_eq!(config.tools[1].mcp_server(), Some("kubectl-mcp"));
1298
1299 assert!(config.tools[2].is_http());
1301
1302 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 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 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 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}