Skip to main content

everruns_core/
agent.rs

1// Agent domain types
2//
3// Design Decision: Dual-ID pattern (see specs/id-schema.md)
4// - public_id: AgentId (external, API-facing, client-supplied or auto-generated)
5// - internal_id: Uuid (internal PK, used for FK references, never exposed in API)
6//
7// These types represent the Agent entity and its status.
8// Used by both API and worker crates.
9
10use 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/// Agent lifecycle status.
26/// - `active`: Agent is available for use
27/// - `archived`: Agent is hidden from listings and cannot be modified or assigned
28/// - `deleted`: Agent is a tombstone kept only for historical references
29#[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    /// Agent is available for use.
35    Active,
36    /// Agent is hidden from listings and cannot be modified or assigned.
37    Archived,
38    /// Agent is deleted and should only survive as a tombstone for references.
39    Deleted,
40}
41
42/// Reason a version was created. Stored as lower_snake_case text.
43/// One of `auto`, `manual`, `patch`, `minor`, `major`, `import`, `rollback`, `fork`.
44#[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/// Immutable snapshot of an Agent's authored and resolved runtime config.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(ToSchema))]
92pub struct AgentVersion {
93    /// Prefixed public identifier. See [ID Schema](https://docs.everruns.com/advanced/id-schema/).
94    #[serde(rename = "id")]
95    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "agentver_01933b5a000070008000000000000001"))]
96    pub public_id: AgentVersionId,
97    /// Internal database UUID. Not part of the public identifier surface; skipped during serialization.
98    #[serde(skip, default = "uuid::Uuid::nil")]
99    pub internal_id: uuid::Uuid,
100    /// Owning agent's prefixed public identifier.
101    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "agent_01933b5a000070008000000000000001"))]
102    pub agent_id: AgentId,
103    /// Monotonic per-agent version sequence number (1, 2, 3, ...). Increments on every snapshot.
104    #[cfg_attr(feature = "openapi", schema(example = 7))]
105    pub version_number: i32,
106    /// Semantic version major component.
107    #[cfg_attr(feature = "openapi", schema(example = 1))]
108    pub semver_major: i32,
109    /// Semantic version minor component.
110    #[cfg_attr(feature = "openapi", schema(example = 4))]
111    pub semver_minor: i32,
112    /// Semantic version patch component.
113    #[cfg_attr(feature = "openapi", schema(example = 2))]
114    pub semver_patch: i32,
115    /// Combined semver string for display (e.g. `1.4.2`).
116    #[cfg_attr(feature = "openapi", schema(example = "1.4.2"))]
117    pub version: String,
118    /// Whether this version was explicitly published by a user. Published versions are user-controlled semver releases; unpublished rows are automatic draft snapshots kept for audit and rollback.
119    #[cfg_attr(feature = "openapi", schema(example = true))]
120    pub is_published: bool,
121    /// Version this one was forked or branched from, if any.
122    #[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    /// When this version is a copy of another version (e.g. a manual rollback), the original source. `None` for ordinary snapshots.
126    #[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    /// Identity of the principal (user or agent identity) that created this version. `None` for system-generated snapshots.
130    #[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    /// Classification of why this version was created (manual publish, automatic draft, rollback, fork, etc.).
134    pub change_kind: AgentVersionChangeKind,
135    /// Human-readable summary of changes in this version (release notes). `None` if not provided.
136    #[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    /// Stable hash of `resolved_config` used to deduplicate adjacent identical snapshots.
145    #[cfg_attr(
146        feature = "openapi",
147        schema(
148            example = "blake3:9f1e2a4c3d5b6e8a0b2c4d6e8f0a1b3c5d7e9f0a1b2c4d6e8f0a1b2c4d6e8f0a"
149        )
150    )]
151    pub config_hash: String,
152    /// User-authored agent configuration JSON, exactly as submitted. Capabilities, MCP refs, model selection live here.
153    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
154    pub authored_config: serde_json::Value,
155    /// Resolved configuration after applying harness, capability, and platform layers. This is what the runtime executes against.
156    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
157    pub resolved_config: serde_json::Value,
158    /// Timestamp when this version was created (RFC 3339).
159    #[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/// Agent configuration for agentic loop.
184/// An agent defines the behavior and capabilities of an AI assistant.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "openapi", derive(ToSchema))]
187pub struct Agent {
188    /// External identifier (agent_<32-hex>). Shown as "id" in API.
189    /// Client-supplied or auto-generated.
190    #[serde(rename = "id")]
191    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "agent_01933b5a000070008000000000000001"))]
192    pub public_id: AgentId,
193    /// Internal UUID primary key. Used for FK references. Never exposed in API.
194    #[serde(skip, default = "Uuid::nil")]
195    pub internal_id: Uuid,
196    /// Name, unique per org (e.g. "customer-support").
197    #[cfg_attr(feature = "openapi", schema(example = "customer-support"))]
198    pub name: String,
199    /// Human-readable display name shown in UI (e.g. "Customer Support Agent").
200    /// Falls back to `name` when absent.
201    #[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    /// Human-readable description of what the agent does.
205    #[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    /// System prompt that defines the agent's behavior.
212    /// Sent as the first message in every conversation.
213    #[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    /// Default LLM model ID for this agent.
221    /// Can be overridden at the session level.
222    #[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    /// Harness that supplies the base execution environment for this agent.
226    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
227    pub harness_id: HarnessId,
228    /// Default immutable version used by deployments that choose the default policy.
229    #[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    /// Source agent for a forked agent.
233    #[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    /// Source version for a forked agent.
237    #[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    /// Root agent lineage identifier for grouping fork families.
241    #[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    /// Tags for organizing and filtering agents.
245    #[serde(default)]
246    #[cfg_attr(feature = "openapi", schema(example = json!(["support", "production"])))]
247    pub tags: Vec<String>,
248    /// Capabilities enabled for this agent with per-agent configuration.
249    /// Capabilities add tools and system prompt modifications.
250    #[serde(default)]
251    pub capabilities: Vec<AgentCapabilityConfig>,
252    /// Starter files copied into each new session for this agent.
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    pub initial_files: Vec<InitialFile>,
255    /// Network access list controlling which hosts/URLs agent sessions can reach.
256    /// Merged with harness and session layers (allowed: intersect, blocked: union).
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub network_access: Option<NetworkAccessList>,
259    /// Maximum number of LLM iterations per turn for this agent.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    #[cfg_attr(feature = "openapi", schema(example = 50))]
262    pub max_iterations: Option<usize>,
263    /// Request-level parallel tool calling preference (EVE-598).
264    ///
265    /// `None` (default) preserves provider defaults. `Some(true)` signals the
266    /// provider that parallel tool calls are wanted; `Some(false)` requests at
267    /// most one tool call per turn and forces serial execution. Merged across
268    /// harness/agent/session layers (overlay wins).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    #[cfg_attr(feature = "openapi", schema(example = true))]
271    pub parallel_tool_calls: Option<bool>,
272    /// Client-side tools registered for this agent.
273    /// These tools are executed by the client, not the server.
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub tools: Vec<ToolDefinition>,
276    /// Remote MCP servers scoped to this agent and inherited by its sessions.
277    #[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    /// Current lifecycle status of the agent.
285    pub status: AgentStatus,
286    /// Timestamp when the agent was created.
287    #[cfg_attr(feature = "openapi", schema(example = "2026-04-01T10:00:00Z"))]
288    pub created_at: DateTime<Utc>,
289    /// Timestamp when the agent was last updated.
290    #[cfg_attr(feature = "openapi", schema(example = "2026-05-20T14:00:00Z"))]
291    pub updated_at: DateTime<Utc>,
292    /// Timestamp when the agent was archived.
293    #[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    /// Timestamp when the agent was deleted.
297    #[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    /// Cumulative token usage across all sessions for this agent.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub usage: Option<TokenUsage>,
303}
304
305/// Maximum length for an addressable name (agent, harness, etc.).
306pub const MAX_ADDRESSABLE_NAME_LEN: usize = 64;
307
308/// Validate an addressable name: `[a-z0-9]([a-z0-9-]*[a-z0-9])?`.
309/// Max 64 chars, no consecutive hyphens, no leading/trailing hyphens.
310/// Returns `Ok(())` or a human-readable error message.
311pub 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
335/// Generate a new agent public_id using UUIDv7.
336pub fn generate_agent_public_id() -> AgentId {
337    AgentId::new()
338}
339
340/// Validate an agent public_id string.
341/// Must match format: agent_<32-lowercase-hex-chars>
342pub 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); // "agent_" (6) + 32 hex chars
356        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        // Invalid cases
369        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        )); // uppercase
377        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        // public_id should serialize as "id"
436        assert_eq!(json["id"], "agent_01933b5a000070008000000000000001");
437        // internal_id should be skipped
438        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        // internal_id defaults to nil when not present in JSON
461        assert_eq!(agent.internal_id, Uuid::nil());
462    }
463}