Skip to main content

clawbox_types/
agent.rs

1//! Agent-level types for container orchestration.
2//!
3//! Agents are long-lived identities that own containers. Each agent has
4//! a configuration, lifecycle policy, and runtime status.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::policy::{Capabilities, SandboxPolicy};
10
11/// Configuration for registering an agent.
12///
13/// Defines the agent's identity, sandbox policy, capabilities, and
14/// lifecycle rules. Submitted via the agent registration API.
15#[non_exhaustive]
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AgentConfig {
18    /// Unique agent identifier (alphanumeric + hyphens, max 64 chars).
19    pub agent_id: String,
20    /// Human-readable display name.
21    pub name: String,
22    /// Sandbox policy for this agent's container. Defaults to `WasmOnly`.
23    #[serde(default)]
24    pub policy: SandboxPolicy,
25    /// Capabilities (network, credentials, resources).
26    #[serde(default)]
27    pub capabilities: Capabilities,
28    /// Workspace mount configuration. `None` for no persistent workspace.
29    #[serde(default)]
30    pub workspace: Option<WorkspaceConfig>,
31    /// Lifecycle configuration (idle timeout, restart policy).
32    #[serde(default)]
33    pub lifecycle: LifecycleConfig,
34    /// Extra environment variables injected into the agent's container.
35    #[serde(default)]
36    pub env: std::collections::HashMap<String, String>,
37    /// Docker image override. `None` uses the server default.
38    pub image: Option<String>,
39}
40
41impl AgentConfig {
42    /// Create an agent config with the given ID and name, using defaults for everything else.
43    pub fn new(agent_id: impl Into<String>, name: impl Into<String>) -> Self {
44        Self {
45            agent_id: agent_id.into(),
46            name: name.into(),
47            policy: SandboxPolicy::default(),
48            capabilities: Capabilities::default(),
49            workspace: None,
50            lifecycle: LifecycleConfig::default(),
51            env: std::collections::HashMap::new(),
52            image: None,
53        }
54    }
55
56    /// Set the sandbox policy.
57    pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
58        self.policy = policy;
59        self
60    }
61
62    /// Set capabilities.
63    pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
64        self.capabilities = capabilities;
65        self
66    }
67
68    /// Set workspace configuration.
69    pub fn with_workspace(mut self, workspace: WorkspaceConfig) -> Self {
70        self.workspace = Some(workspace);
71        self
72    }
73
74    /// Set lifecycle configuration.
75    pub fn with_lifecycle(mut self, lifecycle: LifecycleConfig) -> Self {
76        self.lifecycle = lifecycle;
77        self
78    }
79
80    /// Set the Docker image.
81    pub fn with_image(mut self, image: impl Into<String>) -> Self {
82        self.image = Some(image.into());
83        self
84    }
85}
86
87/// Workspace mount configuration for an agent.
88///
89/// Controls how the host filesystem is exposed inside the container.
90#[non_exhaustive]
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct WorkspaceConfig {
93    /// Host path to mount. If `None`, auto-generated under the server's workspace root.
94    pub host_path: Option<String>,
95    /// Mount point inside the container. Defaults to `"/workspace"`.
96    #[serde(default = "default_mount_point")]
97    pub mount_point: String,
98    /// Whether the mount is read-only. Defaults to `false`.
99    #[serde(default)]
100    pub read_only: bool,
101}
102
103impl WorkspaceConfig {
104    /// Create a workspace config with defaults (auto-generated path, `/workspace` mount, read-write).
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Set the host path.
110    pub fn with_host_path(mut self, path: impl Into<String>) -> Self {
111        self.host_path = Some(path.into());
112        self
113    }
114
115    /// Set the container mount point.
116    pub fn with_mount_point(mut self, mount_point: impl Into<String>) -> Self {
117        self.mount_point = mount_point.into();
118        self
119    }
120
121    /// Make the mount read-only.
122    pub fn read_only(mut self) -> Self {
123        self.read_only = true;
124        self
125    }
126}
127
128impl Default for WorkspaceConfig {
129    fn default() -> Self {
130        Self {
131            host_path: None,
132            mount_point: default_mount_point(),
133            read_only: false,
134        }
135    }
136}
137
138fn default_mount_point() -> String {
139    "/workspace".into()
140}
141
142/// Lifecycle configuration for an agent container.
143///
144/// Controls idle timeouts, maximum lifetime, and crash recovery.
145#[non_exhaustive]
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct LifecycleConfig {
148    /// Maximum idle time before auto-stop, in milliseconds. Defaults to 3600000 (1 hour).
149    #[serde(default = "default_max_idle_ms")]
150    pub max_idle_ms: u64,
151    /// Maximum total lifetime in milliseconds. `None` means unlimited.
152    pub max_lifetime_ms: Option<u64>,
153    /// Whether to restart the container on crash. Defaults to `false`.
154    #[serde(default)]
155    pub restart_on_crash: bool,
156    /// Maximum restart attempts before giving up. Defaults to 3.
157    #[serde(default = "default_max_restarts")]
158    pub max_restarts: u32,
159}
160
161impl LifecycleConfig {
162    /// Create a lifecycle config with the given idle timeout in milliseconds.
163    pub fn new(max_idle_ms: u64) -> Self {
164        Self {
165            max_idle_ms,
166            ..Self::default()
167        }
168    }
169
170    /// Enable crash restart with the given max attempts.
171    pub fn with_restart(mut self, max_restarts: u32) -> Self {
172        self.restart_on_crash = true;
173        self.max_restarts = max_restarts;
174        self
175    }
176
177    /// Set maximum total lifetime in milliseconds.
178    pub fn with_max_lifetime(mut self, ms: u64) -> Self {
179        self.max_lifetime_ms = Some(ms);
180        self
181    }
182}
183
184impl Default for LifecycleConfig {
185    fn default() -> Self {
186        Self {
187            max_idle_ms: default_max_idle_ms(),
188            max_lifetime_ms: None,
189            restart_on_crash: false,
190            max_restarts: default_max_restarts(),
191        }
192    }
193}
194
195fn default_max_idle_ms() -> u64 {
196    3_600_000
197}
198
199fn default_max_restarts() -> u32 {
200    3
201}
202
203/// Runtime information about a registered agent.
204///
205/// Returned by the agent listing and status APIs.
206#[non_exhaustive]
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct AgentInfo {
209    /// Unique agent identifier.
210    pub agent_id: String,
211    /// Human-readable display name.
212    pub name: String,
213    /// Current lifecycle status.
214    pub status: AgentStatus,
215    /// Docker container ID, if the agent has a running container.
216    pub container_id: Option<String>,
217    /// When this agent was registered.
218    pub created_at: DateTime<Utc>,
219    /// Timestamp of the most recent execution or heartbeat.
220    pub last_activity: DateTime<Utc>,
221    /// Total number of tool executions performed by this agent.
222    pub execution_count: u64,
223    /// Host-side workspace path, if configured.
224    pub workspace_path: Option<String>,
225}
226
227impl AgentInfo {
228    /// Create agent info with the given ID, name, and status.
229    pub fn new(agent_id: impl Into<String>, name: impl Into<String>, status: AgentStatus) -> Self {
230        let now = Utc::now();
231        Self {
232            agent_id: agent_id.into(),
233            name: name.into(),
234            status,
235            container_id: None,
236            created_at: now,
237            last_activity: now,
238            execution_count: 0,
239            workspace_path: None,
240        }
241    }
242}
243
244/// Current lifecycle status of an agent.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247#[non_exhaustive]
248pub enum AgentStatus {
249    /// Agent is registered but not executing anything.
250    Idle,
251    /// Agent is actively executing a tool or task.
252    Running,
253    /// Agent's container is being created.
254    Starting,
255    /// Agent's container is shutting down.
256    Stopping,
257    /// Agent's container crashed or encountered an unrecoverable error.
258    Failed,
259    /// Agent was explicitly deregistered or its container was removed.
260    Terminated,
261}
262
263impl std::fmt::Display for AgentStatus {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            Self::Idle => write!(f, "idle"),
267            Self::Running => write!(f, "running"),
268            Self::Starting => write!(f, "starting"),
269            Self::Stopping => write!(f, "stopping"),
270            Self::Failed => write!(f, "failed"),
271            Self::Terminated => write!(f, "terminated"),
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_lifecycle_defaults() {
282        let lc = LifecycleConfig::default();
283        assert_eq!(lc.max_idle_ms, 3_600_000);
284        assert_eq!(lc.max_restarts, 3);
285        assert!(!lc.restart_on_crash);
286        assert!(lc.max_lifetime_ms.is_none());
287    }
288
289    #[test]
290    fn test_agent_status_display() {
291        assert_eq!(AgentStatus::Idle.to_string(), "idle");
292        assert_eq!(AgentStatus::Running.to_string(), "running");
293    }
294
295    #[test]
296    fn test_agent_config_serde() {
297        let json = r#"{
298            "agent_id": "test-agent",
299            "name": "Test Agent",
300            "policy": "container",
301            "capabilities": {},
302            "lifecycle": {},
303            "env": {},
304            "image": null
305        }"#;
306        let config: AgentConfig = serde_json::from_str(json).unwrap();
307        assert_eq!(config.agent_id, "test-agent");
308        assert_eq!(config.policy, SandboxPolicy::Container);
309    }
310
311    #[test]
312    fn test_agent_config_builder() {
313        let config = AgentConfig::new("my-agent", "My Agent").with_policy(SandboxPolicy::Container);
314        assert_eq!(config.agent_id, "my-agent");
315        assert_eq!(config.policy, SandboxPolicy::Container);
316    }
317
318    #[test]
319    fn test_agent_config_new_defaults() {
320        let config = AgentConfig::new("test", "Test");
321        assert_eq!(config.agent_id, "test");
322        assert_eq!(config.policy, SandboxPolicy::WasmOnly);
323        assert!(config.workspace.is_none());
324        assert!(config.image.is_none());
325    }
326
327    #[test]
328    fn test_agent_config_full_builder() {
329        let config = AgentConfig::new("a1", "Agent One")
330            .with_policy(SandboxPolicy::Container)
331            .with_capabilities(Capabilities::default())
332            .with_workspace(WorkspaceConfig::new().with_host_path("/data").read_only())
333            .with_lifecycle(LifecycleConfig::new(60000).with_restart(5))
334            .with_image("alpine:latest");
335        assert_eq!(config.policy, SandboxPolicy::Container);
336        assert!(config.workspace.as_ref().unwrap().read_only);
337        assert!(config.lifecycle.restart_on_crash);
338        assert_eq!(config.lifecycle.max_restarts, 5);
339    }
340
341    #[test]
342    fn test_workspace_config_defaults() {
343        let ws = WorkspaceConfig::default();
344        assert!(ws.host_path.is_none());
345        assert_eq!(ws.mount_point, "/workspace");
346        assert!(!ws.read_only);
347    }
348
349    #[test]
350    fn test_workspace_config_builder() {
351        let ws = WorkspaceConfig::new()
352            .with_mount_point("/custom")
353            .read_only();
354        assert_eq!(ws.mount_point, "/custom");
355        assert!(ws.read_only);
356    }
357
358    #[test]
359    fn test_agent_info_new() {
360        let info = AgentInfo::new("agent-1", "My Agent", AgentStatus::Idle);
361        assert_eq!(info.agent_id, "agent-1");
362        assert_eq!(info.status, AgentStatus::Idle);
363        assert!(info.container_id.is_none());
364        assert_eq!(info.execution_count, 0);
365    }
366
367    #[test]
368    fn test_lifecycle_config_builder() {
369        let lc = LifecycleConfig::new(30000)
370            .with_restart(10)
371            .with_max_lifetime(600000);
372        assert_eq!(lc.max_idle_ms, 30000);
373        assert!(lc.restart_on_crash);
374        assert_eq!(lc.max_lifetime_ms, Some(600000));
375    }
376
377    #[test]
378    fn test_agent_status_all_variants() {
379        for v in [
380            AgentStatus::Idle,
381            AgentStatus::Running,
382            AgentStatus::Starting,
383            AgentStatus::Stopping,
384            AgentStatus::Failed,
385            AgentStatus::Terminated,
386        ] {
387            assert!(!v.to_string().is_empty());
388        }
389    }
390}