1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::capability_types::AgentCapabilityConfig;
15use crate::events::TokenUsage;
16use crate::mcp_server::{ScopedMcpServers, scoped_mcp_servers_is_empty};
17use crate::network_access::NetworkAccessList;
18use crate::session_file::InitialFile;
19use crate::tool_types::ToolDefinition;
20use crate::typed_id::{AgentId, AgentVersionId, HarnessId, ModelId, PrincipalId};
21
22#[cfg(feature = "openapi")]
23use utoipa::ToSchema;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[cfg_attr(feature = "openapi", derive(ToSchema))]
31#[cfg_attr(feature = "openapi", schema(example = "active"))]
32#[serde(rename_all = "lowercase")]
33pub enum AgentStatus {
34 Active,
36 Archived,
38 Deleted,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[cfg_attr(feature = "openapi", derive(ToSchema))]
46#[cfg_attr(feature = "openapi", schema(example = "manual"))]
47#[serde(rename_all = "snake_case")]
48pub enum AgentVersionChangeKind {
49 Auto,
50 Manual,
51 Patch,
52 Minor,
53 Major,
54 Import,
55 Rollback,
56 Fork,
57}
58
59impl std::fmt::Display for AgentVersionChangeKind {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 AgentVersionChangeKind::Auto => write!(f, "auto"),
63 AgentVersionChangeKind::Manual => write!(f, "manual"),
64 AgentVersionChangeKind::Patch => write!(f, "patch"),
65 AgentVersionChangeKind::Minor => write!(f, "minor"),
66 AgentVersionChangeKind::Major => write!(f, "major"),
67 AgentVersionChangeKind::Import => write!(f, "import"),
68 AgentVersionChangeKind::Rollback => write!(f, "rollback"),
69 AgentVersionChangeKind::Fork => write!(f, "fork"),
70 }
71 }
72}
73
74impl From<&str> for AgentVersionChangeKind {
75 fn from(s: &str) -> Self {
76 match s {
77 "auto" => AgentVersionChangeKind::Auto,
78 "patch" => AgentVersionChangeKind::Patch,
79 "minor" => AgentVersionChangeKind::Minor,
80 "major" => AgentVersionChangeKind::Major,
81 "import" => AgentVersionChangeKind::Import,
82 "rollback" => AgentVersionChangeKind::Rollback,
83 "fork" => AgentVersionChangeKind::Fork,
84 _ => AgentVersionChangeKind::Manual,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(ToSchema))]
92pub struct AgentVersion {
93 #[serde(rename = "id")]
95 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "agentver_01933b5a000070008000000000000001"))]
96 pub public_id: AgentVersionId,
97 #[serde(skip, default = "uuid::Uuid::nil")]
99 pub internal_id: uuid::Uuid,
100 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "agent_01933b5a000070008000000000000001"))]
102 pub agent_id: AgentId,
103 #[cfg_attr(feature = "openapi", schema(example = 7))]
105 pub version_number: i32,
106 #[cfg_attr(feature = "openapi", schema(example = 1))]
108 pub semver_major: i32,
109 #[cfg_attr(feature = "openapi", schema(example = 4))]
111 pub semver_minor: i32,
112 #[cfg_attr(feature = "openapi", schema(example = 2))]
114 pub semver_patch: i32,
115 #[cfg_attr(feature = "openapi", schema(example = "1.4.2"))]
117 pub version: String,
118 #[cfg_attr(feature = "openapi", schema(example = true))]
120 pub is_published: bool,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
124 pub parent_version_id: Option<AgentVersionId>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
128 pub source_version_id: Option<AgentVersionId>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
132 pub created_by_principal_id: Option<PrincipalId>,
133 pub change_kind: AgentVersionChangeKind,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 #[cfg_attr(
138 feature = "openapi",
139 schema(
140 example = "Switched default model to claude-sonnet-4-6; added refund-runbook capability."
141 )
142 )]
143 pub summary: Option<String>,
144 #[cfg_attr(
146 feature = "openapi",
147 schema(
148 example = "blake3:9f1e2a4c3d5b6e8a0b2c4d6e8f0a1b3c5d7e9f0a1b2c4d6e8f0a1b2c4d6e8f0a"
149 )
150 )]
151 pub config_hash: String,
152 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
154 pub authored_config: serde_json::Value,
155 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
157 pub resolved_config: serde_json::Value,
158 #[cfg_attr(feature = "openapi", schema(example = "2026-04-20T14:22:00Z"))]
160 pub created_at: DateTime<Utc>,
161}
162
163impl std::fmt::Display for AgentStatus {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 AgentStatus::Active => write!(f, "active"),
167 AgentStatus::Archived => write!(f, "archived"),
168 AgentStatus::Deleted => write!(f, "deleted"),
169 }
170 }
171}
172
173impl From<&str> for AgentStatus {
174 fn from(s: &str) -> Self {
175 match s {
176 "archived" => AgentStatus::Archived,
177 "deleted" => AgentStatus::Deleted,
178 _ => AgentStatus::Active,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "openapi", derive(ToSchema))]
187pub struct Agent {
188 #[serde(rename = "id")]
191 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "agent_01933b5a000070008000000000000001"))]
192 pub public_id: AgentId,
193 #[serde(skip, default = "Uuid::nil")]
195 pub internal_id: Uuid,
196 #[cfg_attr(feature = "openapi", schema(example = "customer-support"))]
198 pub name: String,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 #[cfg_attr(feature = "openapi", schema(example = "Customer Support Agent"))]
203 pub display_name: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 #[cfg_attr(
207 feature = "openapi",
208 schema(example = "Handles refund and shipping questions; escalates billing disputes.")
209 )]
210 pub description: Option<String>,
211 #[cfg_attr(
214 feature = "openapi",
215 schema(
216 example = "You are a friendly customer support agent for Acme Corp. Verify orders before issuing refunds. Escalate any billing disputes to a human."
217 )
218 )]
219 pub system_prompt: String,
220 #[serde(skip_serializing_if = "Option::is_none")]
223 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
224 pub default_model_id: Option<ModelId>,
225 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
227 pub harness_id: HarnessId,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
231 pub default_version_id: Option<AgentVersionId>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
235 pub forked_from_agent_id: Option<AgentId>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
239 pub forked_from_version_id: Option<AgentVersionId>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
243 pub root_agent_id: Option<AgentId>,
244 #[serde(default)]
246 #[cfg_attr(feature = "openapi", schema(example = json!(["support", "production"])))]
247 pub tags: Vec<String>,
248 #[serde(default)]
251 pub capabilities: Vec<AgentCapabilityConfig>,
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub initial_files: Vec<InitialFile>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub network_access: Option<NetworkAccessList>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 #[cfg_attr(feature = "openapi", schema(example = 50))]
262 pub max_iterations: Option<usize>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
270 #[cfg_attr(feature = "openapi", schema(example = true))]
271 pub parallel_tool_calls: Option<bool>,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub tools: Vec<ToolDefinition>,
276 #[serde(
278 default,
279 rename = "mcpServers",
280 alias = "mcp_servers",
281 skip_serializing_if = "scoped_mcp_servers_is_empty"
282 )]
283 pub mcp_servers: ScopedMcpServers,
284 pub status: AgentStatus,
286 #[cfg_attr(feature = "openapi", schema(example = "2026-04-01T10:00:00Z"))]
288 pub created_at: DateTime<Utc>,
289 #[cfg_attr(feature = "openapi", schema(example = "2026-05-20T14:00:00Z"))]
291 pub updated_at: DateTime<Utc>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 #[cfg_attr(feature = "openapi", schema(example = "2026-05-26T00:00:00Z"))]
295 pub archived_at: Option<DateTime<Utc>>,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 #[cfg_attr(feature = "openapi", schema(example = "2026-05-26T00:00:00Z"))]
299 pub deleted_at: Option<DateTime<Utc>>,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub usage: Option<TokenUsage>,
303}
304
305pub const MAX_ADDRESSABLE_NAME_LEN: usize = 64;
307
308pub fn validate_addressable_name(name: &str) -> Result<(), String> {
312 if name.is_empty() {
313 return Err("name must not be empty".to_string());
314 }
315 if name.len() > MAX_ADDRESSABLE_NAME_LEN {
316 return Err(format!(
317 "name must be at most {MAX_ADDRESSABLE_NAME_LEN} characters"
318 ));
319 }
320 if !name
321 .bytes()
322 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
323 {
324 return Err("name must contain only lowercase letters, digits, and hyphens".to_string());
325 }
326 if name.starts_with('-') || name.ends_with('-') {
327 return Err("name must not start or end with a hyphen".to_string());
328 }
329 if name.contains("--") {
330 return Err("name must not contain consecutive hyphens".to_string());
331 }
332 Ok(())
333}
334
335pub fn generate_agent_public_id() -> AgentId {
337 AgentId::new()
338}
339
340pub fn validate_agent_public_id(s: &str) -> bool {
343 s.parse::<AgentId>().is_ok()
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn test_generate_agent_public_id() {
352 let id = generate_agent_public_id();
353 let s = id.to_string();
354 assert!(s.starts_with("agent_"));
355 assert_eq!(s.len(), 38); assert!(validate_agent_public_id(&s));
357 }
358
359 #[test]
360 fn test_validate_agent_public_id() {
361 assert!(validate_agent_public_id(
362 "agent_01933b5a000070008000000000000001"
363 ));
364 assert!(validate_agent_public_id(
365 "agent_4ab3e8452f1442e9865e11d2032a579c"
366 ));
367
368 assert!(!validate_agent_public_id(""));
370 assert!(!validate_agent_public_id("agent_"));
371 assert!(!validate_agent_public_id(
372 "session_01933b5a000070008000000000000001"
373 ));
374 assert!(!validate_agent_public_id(
375 "agent_4AB3E8452F1442E9865E11D2032A579C"
376 )); assert!(!validate_agent_public_id("agent_short"));
378 assert!(!validate_agent_public_id("my-custom-agent"));
379 }
380
381 #[test]
382 fn test_validate_addressable_name_valid() {
383 assert!(validate_addressable_name("my-agent").is_ok());
384 assert!(validate_addressable_name("agent1").is_ok());
385 assert!(validate_addressable_name("a").is_ok());
386 assert!(validate_addressable_name("customer-support").is_ok());
387 assert!(validate_addressable_name("a-b-c").is_ok());
388 }
389
390 #[test]
391 fn test_validate_addressable_name_invalid() {
392 assert!(validate_addressable_name("").is_err());
393 assert!(validate_addressable_name("-leading").is_err());
394 assert!(validate_addressable_name("trailing-").is_err());
395 assert!(validate_addressable_name("bad--double").is_err());
396 assert!(validate_addressable_name("UPPERCASE").is_err());
397 assert!(validate_addressable_name("has space").is_err());
398 assert!(validate_addressable_name("Customer Support Agent").is_err());
399 let long = "a".repeat(65);
400 assert!(validate_addressable_name(&long).is_err());
401 }
402
403 #[test]
404 fn test_agent_serde_public_id_as_id() {
405 let agent = Agent {
406 public_id: "agent_01933b5a000070008000000000000001".parse().unwrap(),
407 internal_id: Uuid::nil(),
408 name: "test".to_string(),
409 display_name: Some("Test".to_string()),
410 description: None,
411 system_prompt: "test".to_string(),
412 default_model_id: None,
413 harness_id: "harness_01933b5a000070008000000000000001".parse().unwrap(),
414 default_version_id: None,
415 forked_from_agent_id: None,
416 forked_from_version_id: None,
417 root_agent_id: None,
418 tags: vec![],
419 capabilities: vec![],
420 initial_files: vec![],
421 network_access: None,
422 max_iterations: None,
423 parallel_tool_calls: None,
424 tools: vec![],
425 mcp_servers: ScopedMcpServers::default(),
426 status: AgentStatus::Active,
427 created_at: Utc::now(),
428 updated_at: Utc::now(),
429 archived_at: None,
430 deleted_at: None,
431 usage: None,
432 };
433
434 let json = serde_json::to_value(&agent).unwrap();
435 assert_eq!(json["id"], "agent_01933b5a000070008000000000000001");
437 assert!(json.get("internal_id").is_none());
439 }
440
441 #[test]
442 fn test_agent_deserialize_from_api_json() {
443 let json = serde_json::json!({
444 "id": "agent_01933b5a000070008000000000000001",
445 "name": "test",
446 "display_name": "Test",
447 "system_prompt": "test",
448 "harness_id": "harness_01933b5a000070008000000000000001",
449 "status": "active",
450 "tags": [],
451 "created_at": "2024-01-01T00:00:00Z",
452 "updated_at": "2024-01-01T00:00:00Z"
453 });
454
455 let agent: Agent = serde_json::from_value(json).unwrap();
456 assert_eq!(
457 agent.public_id.to_string(),
458 "agent_01933b5a000070008000000000000001"
459 );
460 assert_eq!(agent.internal_id, Uuid::nil());
462 }
463}