use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use crate::mcp::McpServerConfig;
use crate::AofResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputSchemaSpec {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<serde_json::Value>>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, rename = "additionalProperties")]
pub additional_properties: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub validation_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_validation_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl OutputSchemaSpec {
pub fn to_json_schema(&self) -> serde_json::Value {
let mut schema = serde_json::json!({
"type": self.schema_type
});
if let Some(props) = &self.properties {
schema["properties"] = serde_json::json!(props);
}
if let Some(req) = &self.required {
schema["required"] = serde_json::json!(req);
}
if let Some(items) = &self.items {
schema["items"] = serde_json::json!(items);
}
if let Some(enum_vals) = &self.enum_values {
schema["enum"] = serde_json::json!(enum_vals);
}
if let Some(desc) = &self.description {
schema["description"] = serde_json::json!(desc);
}
if let Some(additional) = &self.additional_properties {
schema["additionalProperties"] = serde_json::json!(additional);
}
if let serde_json::Value::Object(ref mut map) = schema {
for (key, value) in &self.extra {
map.insert(key.clone(), value.clone());
}
}
schema
}
pub fn get_validation_mode(&self) -> &str {
self.validation_mode.as_deref().unwrap_or("strict")
}
pub fn get_error_behavior(&self) -> &str {
self.on_validation_error.as_deref().unwrap_or("fail")
}
pub fn to_instructions(&self) -> String {
let schema = self.to_json_schema();
format!(
"You MUST respond with valid JSON matching this schema:\n```json\n{}\n```\nDo not include any text outside the JSON object.",
serde_json::to_string_pretty(&schema).unwrap_or_default()
)
}
}
impl From<OutputSchemaSpec> for crate::schema::OutputSchema {
fn from(spec: OutputSchemaSpec) -> Self {
let strict = spec.get_validation_mode() == "strict";
let description = spec.description.clone();
let schema = spec.to_json_schema();
let mut output = crate::schema::OutputSchema::from_json_schema(schema);
if let Some(desc) = description {
output = output.with_description(desc);
}
output = output.with_strict(strict);
output
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MemorySpec {
Simple(String),
Structured(StructuredMemoryConfig),
}
impl MemorySpec {
pub fn memory_type(&self) -> &str {
match self {
MemorySpec::Simple(s) => {
s.split(':').next().unwrap_or(s)
}
MemorySpec::Structured(config) => &config.memory_type,
}
}
pub fn path(&self) -> Option<String> {
match self {
MemorySpec::Simple(s) => {
if s.contains(':') {
s.split(':').nth(1).map(|s| s.to_string())
} else {
None
}
}
MemorySpec::Structured(config) => config
.config
.as_ref()
.and_then(|c| c.get("path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
}
}
pub fn max_messages(&self) -> Option<usize> {
match self {
MemorySpec::Simple(_) => None,
MemorySpec::Structured(config) => config
.config
.as_ref()
.and_then(|c| c.get("max_messages"))
.and_then(|v| v.as_u64())
.map(|n| n as usize),
}
}
pub fn config(&self) -> Option<&serde_json::Value> {
match self {
MemorySpec::Simple(_) => None,
MemorySpec::Structured(config) => config.config.as_ref(),
}
}
pub fn is_in_memory(&self) -> bool {
let t = self.memory_type().to_lowercase();
t == "in_memory" || t == "inmemory" || t == "memory"
}
pub fn is_file(&self) -> bool {
self.memory_type().to_lowercase() == "file"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredMemoryConfig {
#[serde(rename = "type")]
pub memory_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
}
impl fmt::Display for MemorySpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MemorySpec::Simple(s) => write!(f, "{}", s),
MemorySpec::Structured(config) => {
if let Some(path) = self.path() {
write!(f, "{} (path: {})", config.memory_type, path)
} else {
write!(f, "{}", config.memory_type)
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolSpec {
Simple(String),
TypeBased(TypeBasedToolSpec),
Qualified(QualifiedToolSpec),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypeBasedToolSpec {
#[serde(rename = "type")]
pub tool_type: TypeBasedToolType,
#[serde(default)]
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum TypeBasedToolType {
Shell,
MCP,
HTTP,
}
impl ToolSpec {
pub fn name(&self) -> &str {
match self {
ToolSpec::Simple(name) => name,
ToolSpec::TypeBased(spec) => match spec.tool_type {
TypeBasedToolType::Shell => "shell",
TypeBasedToolType::MCP => spec
.config
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("mcp"),
TypeBasedToolType::HTTP => "http",
},
ToolSpec::Qualified(spec) => &spec.name,
}
}
pub fn is_builtin(&self) -> bool {
match self {
ToolSpec::Simple(_) => true, ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::Shell,
ToolSpec::Qualified(spec) => spec.source == ToolSource::Builtin,
}
}
pub fn is_mcp(&self) -> bool {
match self {
ToolSpec::Simple(_) => false,
ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::MCP,
ToolSpec::Qualified(spec) => spec.source == ToolSource::Mcp,
}
}
pub fn is_http(&self) -> bool {
match self {
ToolSpec::Simple(_) => false,
ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::HTTP,
ToolSpec::Qualified(_) => false,
}
}
pub fn is_shell(&self) -> bool {
match self {
ToolSpec::Simple(name) => name == "shell",
ToolSpec::TypeBased(spec) => spec.tool_type == TypeBasedToolType::Shell,
ToolSpec::Qualified(spec) => spec.name == "shell",
}
}
pub fn tool_type(&self) -> Option<TypeBasedToolType> {
match self {
ToolSpec::TypeBased(spec) => Some(spec.tool_type),
_ => None,
}
}
pub fn mcp_server(&self) -> Option<&str> {
match self {
ToolSpec::Simple(_) => None,
ToolSpec::TypeBased(spec) => {
if spec.tool_type == TypeBasedToolType::MCP {
spec.config.get("name").and_then(|v| v.as_str())
} else {
None
}
}
ToolSpec::Qualified(spec) => spec.server.as_deref(),
}
}
pub fn config(&self) -> Option<&serde_json::Value> {
match self {
ToolSpec::Simple(_) => None,
ToolSpec::TypeBased(spec) => Some(&spec.config),
ToolSpec::Qualified(spec) => spec.config.as_ref(),
}
}
pub fn type_based_spec(&self) -> Option<&TypeBasedToolSpec> {
match self {
ToolSpec::TypeBased(spec) => Some(spec),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualifiedToolSpec {
pub name: String,
#[serde(default)]
pub source: ToolSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ToolSource {
#[default]
Builtin,
Mcp,
}
#[async_trait]
pub trait Agent: Send + Sync {
async fn execute(&self, ctx: &mut AgentContext) -> AofResult<String>;
fn metadata(&self) -> &AgentMetadata;
async fn init(&mut self) -> AofResult<()> {
Ok(())
}
async fn cleanup(&mut self) -> AofResult<()> {
Ok(())
}
fn validate(&self) -> AofResult<()> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AgentContext {
pub input: String,
pub messages: Vec<Message>,
pub state: HashMap<String, serde_json::Value>,
pub tool_results: Vec<ToolResult>,
pub metadata: ExecutionMetadata,
pub output_schema: Option<crate::schema::OutputSchema>,
pub input_schema: Option<crate::schema::InputSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: MessageRole,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<crate::ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_name: String,
pub result: serde_json::Value,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ExecutionMetadata {
pub input_tokens: usize,
pub output_tokens: usize,
pub execution_time_ms: u64,
pub tool_calls: usize,
pub model: Option<String>,
}
impl AgentContext {
pub fn new(input: impl Into<String>) -> Self {
Self {
input: input.into(),
messages: Vec::new(),
state: HashMap::new(),
tool_results: Vec::new(),
metadata: ExecutionMetadata::default(),
output_schema: None,
input_schema: None,
}
}
pub fn with_output_schema(mut self, schema: crate::schema::OutputSchema) -> Self {
self.output_schema = Some(schema);
self
}
pub fn with_input_schema(mut self, schema: crate::schema::InputSchema) -> Self {
self.input_schema = Some(schema);
self
}
pub fn add_message(&mut self, role: MessageRole, content: impl Into<String>) {
self.messages.push(Message {
role,
content: content.into(),
tool_calls: None,
tool_call_id: None,
});
}
pub fn get_state<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
self.state
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
pub fn set_state<T: Serialize>(&mut self, key: impl Into<String>, value: T) -> AofResult<()> {
let json_value = serde_json::to_value(value)?;
self.state.insert(key.into(), json_value);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMetadata {
pub name: String,
pub description: String,
pub version: String,
pub capabilities: Vec<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(from = "AgentConfigInput")]
pub struct AgentConfig {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default)]
pub tools: Vec<ToolSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<McpServerConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<MemorySpec>,
#[serde(default = "default_max_context_messages")]
pub max_context_messages: usize,
#[serde(default = "default_max_iterations")]
pub max_iterations: usize,
#[serde(default = "default_temperature")]
pub temperature: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_schema: Option<OutputSchemaSpec>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl AgentConfig {
pub fn tool_names(&self) -> Vec<&str> {
self.tools.iter().map(|t| t.name()).collect()
}
pub fn builtin_tools(&self) -> Vec<&ToolSpec> {
self.tools.iter().filter(|t| t.is_builtin()).collect()
}
pub fn mcp_tools(&self) -> Vec<&ToolSpec> {
self.tools.iter().filter(|t| t.is_mcp()).collect()
}
pub fn type_based_shell_tools(&self) -> Vec<&TypeBasedToolSpec> {
self.tools
.iter()
.filter_map(|t| match t {
ToolSpec::TypeBased(spec) if spec.tool_type == TypeBasedToolType::Shell => {
Some(spec)
}
_ => None,
})
.collect()
}
pub fn type_based_mcp_tools(&self) -> Vec<&TypeBasedToolSpec> {
self.tools
.iter()
.filter_map(|t| match t {
ToolSpec::TypeBased(spec) if spec.tool_type == TypeBasedToolType::MCP => Some(spec),
_ => None,
})
.collect()
}
pub fn type_based_http_tools(&self) -> Vec<&TypeBasedToolSpec> {
self.tools
.iter()
.filter_map(|t| match t {
ToolSpec::TypeBased(spec) if spec.tool_type == TypeBasedToolType::HTTP => {
Some(spec)
}
_ => None,
})
.collect()
}
pub fn has_type_based_tools(&self) -> bool {
self.tools.iter().any(|t| matches!(t, ToolSpec::TypeBased(_)))
}
pub fn type_based_mcp_to_server_configs(&self) -> Vec<crate::mcp::McpServerConfig> {
self.type_based_mcp_tools()
.iter()
.filter_map(|spec| {
let config = &spec.config;
let name = config.get("name")?.as_str()?;
let command = config.get("command").and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else if let Some(arr) = v.as_array() {
arr.first().and_then(|v| v.as_str()).map(|s| s.to_string())
} else {
None
}
});
let args: Vec<String> = config
.get("command")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.skip(1)
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let env: std::collections::HashMap<String, String> = config
.get("env")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Some(crate::mcp::McpServerConfig {
name: name.to_string(),
transport: crate::mcp::McpTransport::Stdio,
command,
args,
env,
endpoint: None,
tools: vec![],
init_options: None,
timeout_secs: config
.get("timeout_seconds")
.and_then(|v| v.as_u64())
.unwrap_or(30),
auto_reconnect: true,
})
})
.collect()
}
pub fn shell_tool_config(&self) -> Option<ShellToolConfig> {
self.type_based_shell_tools().first().map(|spec| {
let config = &spec.config;
ShellToolConfig {
allowed_commands: config
.get("allowed_commands")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
working_directory: config
.get("working_directory")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
timeout_seconds: config
.get("timeout_seconds")
.and_then(|v| v.as_u64())
.map(|n| n as u32),
}
})
}
pub fn http_tool_config(&self) -> Option<HttpToolConfig> {
self.type_based_http_tools().first().map(|spec| {
let config = &spec.config;
HttpToolConfig {
base_url: config
.get("base_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
timeout_seconds: config
.get("timeout_seconds")
.and_then(|v| v.as_u64())
.map(|n| n as u32),
allowed_methods: config
.get("allowed_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
}
})
}
}
#[derive(Debug, Clone, Default)]
pub struct ShellToolConfig {
pub allowed_commands: Vec<String>,
pub working_directory: Option<String>,
pub timeout_seconds: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct HttpToolConfig {
pub base_url: Option<String>,
pub timeout_seconds: Option<u32>,
pub allowed_methods: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum AgentConfigInput {
Flat(FlatAgentConfig),
Kubernetes(KubernetesConfig),
}
#[derive(Debug, Clone, Deserialize)]
struct KubernetesConfig {
#[serde(rename = "apiVersion")]
api_version: String, kind: String, metadata: KubernetesMetadata,
spec: AgentSpec,
}
#[derive(Debug, Clone, Deserialize)]
struct KubernetesMetadata {
name: String,
#[serde(default)]
labels: HashMap<String, String>,
#[serde(default)]
annotations: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
struct AgentSpec {
model: String,
provider: Option<String>,
#[serde(alias = "system_prompt")]
instructions: Option<String>,
#[serde(default)]
tools: Vec<ToolSpec>,
#[serde(default)]
mcp_servers: Vec<McpServerConfig>,
memory: Option<MemorySpec>,
#[serde(default = "default_max_context_messages")]
max_context_messages: usize,
#[serde(default = "default_max_iterations")]
max_iterations: usize,
#[serde(default = "default_temperature")]
temperature: f32,
max_tokens: Option<usize>,
output_schema: Option<OutputSchemaSpec>,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
struct FlatAgentConfig {
name: String,
#[serde(alias = "instructions")]
system_prompt: Option<String>,
model: String,
provider: Option<String>,
#[serde(default)]
tools: Vec<ToolSpec>,
#[serde(default)]
mcp_servers: Vec<McpServerConfig>,
memory: Option<MemorySpec>,
#[serde(default = "default_max_context_messages")]
max_context_messages: usize,
#[serde(default = "default_max_iterations")]
max_iterations: usize,
#[serde(default = "default_temperature")]
temperature: f32,
max_tokens: Option<usize>,
output_schema: Option<OutputSchemaSpec>,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
impl From<AgentConfigInput> for AgentConfig {
fn from(input: AgentConfigInput) -> Self {
match input {
AgentConfigInput::Flat(flat) => AgentConfig {
name: flat.name,
system_prompt: flat.system_prompt,
model: flat.model,
provider: flat.provider,
tools: flat.tools,
mcp_servers: flat.mcp_servers,
memory: flat.memory,
max_context_messages: flat.max_context_messages,
max_iterations: flat.max_iterations,
temperature: flat.temperature,
max_tokens: flat.max_tokens,
output_schema: flat.output_schema,
extra: flat.extra,
},
AgentConfigInput::Kubernetes(k8s) => {
AgentConfig {
name: k8s.metadata.name,
system_prompt: k8s.spec.instructions,
model: k8s.spec.model,
provider: k8s.spec.provider,
tools: k8s.spec.tools,
mcp_servers: k8s.spec.mcp_servers,
memory: k8s.spec.memory,
max_context_messages: k8s.spec.max_context_messages,
max_iterations: k8s.spec.max_iterations,
temperature: k8s.spec.temperature,
max_tokens: k8s.spec.max_tokens,
output_schema: k8s.spec.output_schema,
extra: k8s.spec.extra,
}
}
}
}
}
fn default_max_iterations() -> usize {
10
}
fn default_max_context_messages() -> usize {
10
}
fn default_temperature() -> f32 {
0.7
}
pub type AgentRef = Arc<dyn Agent>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_context_new() {
let ctx = AgentContext::new("Hello, world!");
assert_eq!(ctx.input, "Hello, world!");
assert!(ctx.messages.is_empty());
assert!(ctx.state.is_empty());
assert!(ctx.tool_results.is_empty());
}
#[test]
fn test_agent_context_add_message() {
let mut ctx = AgentContext::new("test");
ctx.add_message(MessageRole::User, "user message");
ctx.add_message(MessageRole::Assistant, "assistant response");
assert_eq!(ctx.messages.len(), 2);
assert_eq!(ctx.messages[0].role, MessageRole::User);
assert_eq!(ctx.messages[0].content, "user message");
assert_eq!(ctx.messages[1].role, MessageRole::Assistant);
assert_eq!(ctx.messages[1].content, "assistant response");
}
#[test]
fn test_agent_context_state() {
let mut ctx = AgentContext::new("test");
ctx.set_state("name", "test_agent").unwrap();
let name: Option<String> = ctx.get_state("name");
assert_eq!(name, Some("test_agent".to_string()));
ctx.set_state("count", 42i32).unwrap();
let count: Option<i32> = ctx.get_state("count");
assert_eq!(count, Some(42));
let missing: Option<String> = ctx.get_state("missing");
assert!(missing.is_none());
}
#[test]
fn test_message_role_serialization() {
let user = MessageRole::User;
let serialized = serde_json::to_string(&user).unwrap();
assert_eq!(serialized, "\"user\"");
let deserialized: MessageRole = serde_json::from_str("\"assistant\"").unwrap();
assert_eq!(deserialized, MessageRole::Assistant);
}
#[test]
fn test_agent_config_defaults() {
let yaml = r#"
name: test-agent
model: claude-3-5-sonnet
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "test-agent");
assert_eq!(config.model, "claude-3-5-sonnet");
assert_eq!(config.max_iterations, 10); assert_eq!(config.temperature, 0.7); assert!(config.tools.is_empty());
assert!(config.system_prompt.is_none());
}
#[test]
fn test_agent_config_full() {
let yaml = r#"
name: full-agent
model: gpt-4
system_prompt: "You are a helpful assistant."
tools:
- read_file
- write_file
max_iterations: 20
temperature: 0.5
max_tokens: 4096
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "full-agent");
assert_eq!(config.model, "gpt-4");
assert_eq!(config.system_prompt, Some("You are a helpful assistant.".to_string()));
assert_eq!(config.tool_names(), vec!["read_file", "write_file"]);
assert_eq!(config.max_iterations, 20);
assert_eq!(config.temperature, 0.5);
assert_eq!(config.max_tokens, Some(4096));
}
#[test]
fn test_tool_spec_simple() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
- shell
- kubectl_get
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 2);
assert_eq!(config.tools[0].name(), "shell");
assert!(config.tools[0].is_builtin());
assert!(!config.tools[0].is_mcp());
}
#[test]
fn test_tool_spec_qualified_builtin() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
- name: shell
source: builtin
config:
blocked_commands:
- rm -rf
timeout_secs: 60
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 1);
assert_eq!(config.tools[0].name(), "shell");
assert!(config.tools[0].is_builtin());
assert!(config.tools[0].config().is_some());
}
#[test]
fn test_tool_spec_qualified_mcp() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
- name: read_file
source: mcp
server: filesystem
config:
allowed_paths:
- /workspace
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 1);
assert_eq!(config.tools[0].name(), "read_file");
assert!(config.tools[0].is_mcp());
assert_eq!(config.tools[0].mcp_server(), Some("filesystem"));
}
#[test]
fn test_tool_spec_mixed() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
# Simple builtin
- shell
# Qualified builtin with config
- name: kubectl_get
source: builtin
timeout_secs: 120
# MCP tool
- name: github_search
source: mcp
server: github
mcp_servers:
- name: github
command: npx
args: ["@modelcontextprotocol/server-github"]
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 3);
let builtin_tools = config.builtin_tools();
assert_eq!(builtin_tools.len(), 2);
let mcp_tools = config.mcp_tools();
assert_eq!(mcp_tools.len(), 1);
assert_eq!(mcp_tools[0].mcp_server(), Some("github"));
}
#[test]
fn test_tool_spec_type_based_shell() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
- type: Shell
config:
allowed_commands:
- kubectl
- helm
working_directory: /tmp
timeout_seconds: 30
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 1);
assert_eq!(config.tools[0].name(), "shell");
assert!(config.tools[0].is_shell());
assert!(config.tools[0].is_builtin());
assert!(config.tools[0].config().is_some());
let config_val = config.tools[0].config().unwrap();
assert!(config_val.get("allowed_commands").is_some());
}
#[test]
fn test_tool_spec_type_based_mcp() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
- type: MCP
config:
name: kubectl-mcp
command: ["npx", "-y", "@modelcontextprotocol/server-kubectl"]
env:
KUBECONFIG: "${KUBECONFIG}"
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 1);
assert!(config.tools[0].is_mcp());
assert_eq!(config.tools[0].mcp_server(), Some("kubectl-mcp"));
}
#[test]
fn test_tool_spec_type_based_http() {
let yaml = r#"
name: test-agent
model: gpt-4
tools:
- type: HTTP
config:
base_url: http://localhost:8080
timeout_seconds: 10
allowed_methods: [GET, POST]
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.tools.len(), 1);
assert_eq!(config.tools[0].name(), "http");
assert!(config.tools[0].is_http());
let config_val = config.tools[0].config().unwrap();
assert_eq!(config_val.get("base_url").unwrap(), "http://localhost:8080");
}
#[test]
fn test_tool_spec_type_based_mixed() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Agent
metadata:
name: k8s-helper
labels:
purpose: operations
spec:
model: google:gemini-2.5-flash
instructions: You are a K8s helper.
tools:
- type: Shell
config:
allowed_commands:
- kubectl
- helm
working_directory: /tmp
timeout_seconds: 30
- type: MCP
config:
name: kubectl-mcp
command: ["npx", "-y", "@modelcontextprotocol/server-kubectl"]
- type: HTTP
config:
base_url: http://localhost
timeout_seconds: 10
memory:
type: File
config:
path: ./k8s-helper-memory.json
max_messages: 50
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "k8s-helper");
assert_eq!(config.tools.len(), 3);
assert!(config.tools[0].is_shell());
assert!(config.tools[0].is_builtin());
assert!(config.tools[1].is_mcp());
assert_eq!(config.tools[1].mcp_server(), Some("kubectl-mcp"));
assert!(config.tools[2].is_http());
assert!(config.memory.is_some());
let memory = config.memory.as_ref().unwrap();
assert!(memory.is_file());
assert_eq!(memory.path(), Some("./k8s-helper-memory.json".to_string()));
}
#[test]
fn test_tool_result_serialization() {
let result = ToolResult {
tool_name: "test_tool".to_string(),
result: serde_json::json!({"output": "success"}),
success: true,
error: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("test_tool"));
assert!(json.contains("success"));
let deserialized: ToolResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.tool_name, "test_tool");
assert!(deserialized.success);
}
#[test]
fn test_execution_metadata_default() {
let meta = ExecutionMetadata::default();
assert_eq!(meta.input_tokens, 0);
assert_eq!(meta.output_tokens, 0);
assert_eq!(meta.execution_time_ms, 0);
assert_eq!(meta.tool_calls, 0);
assert!(meta.model.is_none());
}
#[test]
fn test_agent_metadata_serialization() {
let meta = AgentMetadata {
name: "test".to_string(),
description: "A test agent".to_string(),
version: "1.0.0".to_string(),
capabilities: vec!["coding".to_string(), "testing".to_string()],
extra: HashMap::new(),
};
let json = serde_json::to_string(&meta).unwrap();
let deserialized: AgentMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "test");
assert_eq!(deserialized.capabilities.len(), 2);
}
#[test]
fn test_agent_config_with_mcp_servers() {
let yaml = r#"
name: mcp-agent
model: gpt-4
mcp_servers:
- name: filesystem
transport: stdio
command: npx
args:
- "@anthropic-ai/mcp-server-fs"
env:
MCP_FS_ROOT: /workspace
- name: remote
transport: sse
endpoint: http://localhost:3000/mcp
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "mcp-agent");
assert_eq!(config.mcp_servers.len(), 2);
let fs_server = &config.mcp_servers[0];
assert_eq!(fs_server.name, "filesystem");
assert_eq!(fs_server.transport, crate::mcp::McpTransport::Stdio);
assert_eq!(fs_server.command, Some("npx".to_string()));
assert_eq!(fs_server.args.len(), 1);
assert!(fs_server.env.contains_key("MCP_FS_ROOT"));
let remote_server = &config.mcp_servers[1];
assert_eq!(remote_server.name, "remote");
assert_eq!(remote_server.transport, crate::mcp::McpTransport::Sse);
assert_eq!(remote_server.endpoint, Some("http://localhost:3000/mcp".to_string()));
}
#[test]
fn test_agent_config_k8s_style_with_mcp_servers() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Agent
metadata:
name: k8s-mcp-agent
labels:
env: test
spec:
model: claude-3-5-sonnet
instructions: Test agent with MCP
mcp_servers:
- name: tools
command: ./my-mcp-server
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "k8s-mcp-agent");
assert_eq!(config.mcp_servers.len(), 1);
assert_eq!(config.mcp_servers[0].name, "tools");
assert_eq!(config.mcp_servers[0].command, Some("./my-mcp-server".to_string()));
}
#[test]
fn test_memory_spec_simple_string() {
let yaml = r#"
name: test-agent
model: gpt-4
memory: "file:./memory.json"
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.memory.is_some());
let memory = config.memory.as_ref().unwrap();
assert_eq!(memory.memory_type(), "file");
assert_eq!(memory.path(), Some("./memory.json".to_string()));
assert!(memory.is_file());
assert!(!memory.is_in_memory());
}
#[test]
fn test_memory_spec_simple_in_memory() {
let yaml = r#"
name: test-agent
model: gpt-4
memory: "in_memory"
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.memory.is_some());
let memory = config.memory.as_ref().unwrap();
assert_eq!(memory.memory_type(), "in_memory");
assert!(memory.is_in_memory());
assert!(!memory.is_file());
}
#[test]
fn test_memory_spec_structured_file() {
let yaml = r#"
name: test-agent
model: gpt-4
memory:
type: File
config:
path: ./k8s-helper-memory.json
max_messages: 50
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.memory.is_some());
let memory = config.memory.as_ref().unwrap();
assert_eq!(memory.memory_type(), "File");
assert_eq!(memory.path(), Some("./k8s-helper-memory.json".to_string()));
assert_eq!(memory.max_messages(), Some(50));
assert!(memory.is_file());
}
#[test]
fn test_memory_spec_structured_in_memory() {
let yaml = r#"
name: test-agent
model: gpt-4
memory:
type: InMemory
config:
max_messages: 100
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.memory.is_some());
let memory = config.memory.as_ref().unwrap();
assert_eq!(memory.memory_type(), "InMemory");
assert!(memory.is_in_memory());
assert_eq!(memory.max_messages(), Some(100));
}
#[test]
fn test_memory_spec_k8s_style_with_structured_memory() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Agent
metadata:
name: k8s-helper
labels:
purpose: operations
team: platform
spec:
model: google:gemini-2.5-flash
instructions: |
You are a Kubernetes helper.
memory:
type: File
config:
path: ./k8s-helper-memory.json
max_messages: 50
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "k8s-helper");
assert!(config.memory.is_some());
let memory = config.memory.as_ref().unwrap();
assert_eq!(memory.memory_type(), "File");
assert_eq!(memory.path(), Some("./k8s-helper-memory.json".to_string()));
assert_eq!(memory.max_messages(), Some(50));
}
#[test]
fn test_memory_spec_no_memory() {
let yaml = r#"
name: test-agent
model: gpt-4
"#;
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.memory.is_none());
}
}