use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::capability_types::AgentCapabilityConfig;
use crate::events::TokenUsage;
use crate::mcp_server::{ScopedMcpServers, scoped_mcp_servers_is_empty};
use crate::network_access::NetworkAccessList;
use crate::session_file::InitialFile;
use crate::tool_types::ToolDefinition;
use crate::typed_id::{AgentId, AgentVersionId, ModelId, PrincipalId};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "active"))]
#[serde(rename_all = "lowercase")]
pub enum AgentStatus {
Active,
Archived,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "manual"))]
#[serde(rename_all = "snake_case")]
pub enum AgentVersionChangeKind {
Auto,
Manual,
Patch,
Minor,
Major,
Import,
Rollback,
Fork,
}
impl std::fmt::Display for AgentVersionChangeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentVersionChangeKind::Auto => write!(f, "auto"),
AgentVersionChangeKind::Manual => write!(f, "manual"),
AgentVersionChangeKind::Patch => write!(f, "patch"),
AgentVersionChangeKind::Minor => write!(f, "minor"),
AgentVersionChangeKind::Major => write!(f, "major"),
AgentVersionChangeKind::Import => write!(f, "import"),
AgentVersionChangeKind::Rollback => write!(f, "rollback"),
AgentVersionChangeKind::Fork => write!(f, "fork"),
}
}
}
impl From<&str> for AgentVersionChangeKind {
fn from(s: &str) -> Self {
match s {
"auto" => AgentVersionChangeKind::Auto,
"patch" => AgentVersionChangeKind::Patch,
"minor" => AgentVersionChangeKind::Minor,
"major" => AgentVersionChangeKind::Major,
"import" => AgentVersionChangeKind::Import,
"rollback" => AgentVersionChangeKind::Rollback,
"fork" => AgentVersionChangeKind::Fork,
_ => AgentVersionChangeKind::Manual,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AgentVersion {
#[serde(rename = "id")]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "agentver_01933b5a000070008000000000000001"))]
pub public_id: AgentVersionId,
#[serde(skip, default = "uuid::Uuid::nil")]
pub internal_id: uuid::Uuid,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "agent_01933b5a000070008000000000000001"))]
pub agent_id: AgentId,
#[cfg_attr(feature = "openapi", schema(example = 7))]
pub version_number: i32,
#[cfg_attr(feature = "openapi", schema(example = 1))]
pub semver_major: i32,
#[cfg_attr(feature = "openapi", schema(example = 4))]
pub semver_minor: i32,
#[cfg_attr(feature = "openapi", schema(example = 2))]
pub semver_patch: i32,
#[cfg_attr(feature = "openapi", schema(example = "1.4.2"))]
pub version: String,
#[cfg_attr(feature = "openapi", schema(example = true))]
pub is_published: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub parent_version_id: Option<AgentVersionId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub source_version_id: Option<AgentVersionId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub created_by_principal_id: Option<PrincipalId>,
pub change_kind: AgentVersionChangeKind,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "openapi",
schema(
example = "Switched default model to claude-sonnet-4-6; added refund-runbook capability."
)
)]
pub summary: Option<String>,
#[cfg_attr(
feature = "openapi",
schema(
example = "blake3:9f1e2a4c3d5b6e8a0b2c4d6e8f0a1b3c5d7e9f0a1b2c4d6e8f0a1b2c4d6e8f0a"
)
)]
pub config_hash: String,
#[cfg_attr(feature = "openapi", schema(value_type = Object))]
pub authored_config: serde_json::Value,
#[cfg_attr(feature = "openapi", schema(value_type = Object))]
pub resolved_config: serde_json::Value,
#[cfg_attr(feature = "openapi", schema(example = "2026-04-20T14:22:00Z"))]
pub created_at: DateTime<Utc>,
}
impl std::fmt::Display for AgentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentStatus::Active => write!(f, "active"),
AgentStatus::Archived => write!(f, "archived"),
AgentStatus::Deleted => write!(f, "deleted"),
}
}
}
impl From<&str> for AgentStatus {
fn from(s: &str) -> Self {
match s {
"archived" => AgentStatus::Archived,
"deleted" => AgentStatus::Deleted,
_ => AgentStatus::Active,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Agent {
#[serde(rename = "id")]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "agent_01933b5a000070008000000000000001"))]
pub public_id: AgentId,
#[serde(skip, default = "Uuid::nil")]
pub internal_id: Uuid,
#[cfg_attr(feature = "openapi", schema(example = "customer-support"))]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = "Customer Support Agent"))]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "openapi",
schema(example = "Handles refund and shipping questions; escalates billing disputes.")
)]
pub description: Option<String>,
#[cfg_attr(
feature = "openapi",
schema(
example = "You are a friendly customer support agent for Acme Corp. Verify orders before issuing refunds. Escalate any billing disputes to a human."
)
)]
pub system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
pub default_model_id: Option<ModelId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
pub default_version_id: Option<AgentVersionId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
pub forked_from_agent_id: Option<AgentId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
pub forked_from_version_id: Option<AgentVersionId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
pub root_agent_id: Option<AgentId>,
#[serde(default)]
#[cfg_attr(feature = "openapi", schema(example = json!(["support", "production"])))]
pub tags: Vec<String>,
#[serde(default)]
pub capabilities: Vec<AgentCapabilityConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_files: Vec<InitialFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_access: Option<NetworkAccessList>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = 50))]
pub max_iterations: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolDefinition>,
#[serde(
default,
rename = "mcpServers",
alias = "mcp_servers",
skip_serializing_if = "scoped_mcp_servers_is_empty"
)]
pub mcp_servers: ScopedMcpServers,
pub status: AgentStatus,
#[cfg_attr(feature = "openapi", schema(example = "2026-04-01T10:00:00Z"))]
pub created_at: DateTime<Utc>,
#[cfg_attr(feature = "openapi", schema(example = "2026-05-20T14:00:00Z"))]
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = "2026-05-26T00:00:00Z"))]
pub archived_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = "2026-05-26T00:00:00Z"))]
pub deleted_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
pub const MAX_ADDRESSABLE_NAME_LEN: usize = 64;
pub fn validate_addressable_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("name must not be empty".to_string());
}
if name.len() > MAX_ADDRESSABLE_NAME_LEN {
return Err(format!(
"name must be at most {MAX_ADDRESSABLE_NAME_LEN} characters"
));
}
if !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err("name must contain only lowercase letters, digits, and hyphens".to_string());
}
if name.starts_with('-') || name.ends_with('-') {
return Err("name must not start or end with a hyphen".to_string());
}
if name.contains("--") {
return Err("name must not contain consecutive hyphens".to_string());
}
Ok(())
}
pub fn generate_agent_public_id() -> AgentId {
AgentId::new()
}
pub fn validate_agent_public_id(s: &str) -> bool {
s.parse::<AgentId>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_agent_public_id() {
let id = generate_agent_public_id();
let s = id.to_string();
assert!(s.starts_with("agent_"));
assert_eq!(s.len(), 38); assert!(validate_agent_public_id(&s));
}
#[test]
fn test_validate_agent_public_id() {
assert!(validate_agent_public_id(
"agent_01933b5a000070008000000000000001"
));
assert!(validate_agent_public_id(
"agent_4ab3e8452f1442e9865e11d2032a579c"
));
assert!(!validate_agent_public_id(""));
assert!(!validate_agent_public_id("agent_"));
assert!(!validate_agent_public_id(
"session_01933b5a000070008000000000000001"
));
assert!(!validate_agent_public_id(
"agent_4AB3E8452F1442E9865E11D2032A579C"
)); assert!(!validate_agent_public_id("agent_short"));
assert!(!validate_agent_public_id("my-custom-agent"));
}
#[test]
fn test_validate_addressable_name_valid() {
assert!(validate_addressable_name("my-agent").is_ok());
assert!(validate_addressable_name("agent1").is_ok());
assert!(validate_addressable_name("a").is_ok());
assert!(validate_addressable_name("customer-support").is_ok());
assert!(validate_addressable_name("a-b-c").is_ok());
}
#[test]
fn test_validate_addressable_name_invalid() {
assert!(validate_addressable_name("").is_err());
assert!(validate_addressable_name("-leading").is_err());
assert!(validate_addressable_name("trailing-").is_err());
assert!(validate_addressable_name("bad--double").is_err());
assert!(validate_addressable_name("UPPERCASE").is_err());
assert!(validate_addressable_name("has space").is_err());
assert!(validate_addressable_name("Customer Support Agent").is_err());
let long = "a".repeat(65);
assert!(validate_addressable_name(&long).is_err());
}
#[test]
fn test_agent_serde_public_id_as_id() {
let agent = Agent {
public_id: "agent_01933b5a000070008000000000000001".parse().unwrap(),
internal_id: Uuid::nil(),
name: "test".to_string(),
display_name: Some("Test".to_string()),
description: None,
system_prompt: "test".to_string(),
default_model_id: None,
default_version_id: None,
forked_from_agent_id: None,
forked_from_version_id: None,
root_agent_id: None,
tags: vec![],
capabilities: vec![],
initial_files: vec![],
network_access: None,
max_iterations: None,
tools: vec![],
mcp_servers: ScopedMcpServers::default(),
status: AgentStatus::Active,
created_at: Utc::now(),
updated_at: Utc::now(),
archived_at: None,
deleted_at: None,
usage: None,
};
let json = serde_json::to_value(&agent).unwrap();
assert_eq!(json["id"], "agent_01933b5a000070008000000000000001");
assert!(json.get("internal_id").is_none());
}
#[test]
fn test_agent_deserialize_from_api_json() {
let json = serde_json::json!({
"id": "agent_01933b5a000070008000000000000001",
"name": "test",
"display_name": "Test",
"system_prompt": "test",
"status": "active",
"tags": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
});
let agent: Agent = serde_json::from_value(json).unwrap();
assert_eq!(
agent.public_id.to_string(),
"agent_01933b5a000070008000000000000001"
);
assert_eq!(agent.internal_id, Uuid::nil());
}
}