Skip to main content

oxios_kernel/
agent_lifecycle.rs

1//! Agent lifecycle management — fork, register, run, cleanup.
2//!
3//! Extracted from Orchestrator to reduce the god-object scope.
4//! Handles: fork agent → register A2A → check permissions →
5//! submit to scheduler → run → unregister → complete/fail.
6
7use anyhow::{Result, bail};
8use std::sync::Arc;
9
10use tokio::time::{Duration, timeout};
11
12use crate::a2a::{A2AProtocol, AgentCard};
13use crate::access_manager::{AccessManager, Role, Subject};
14use crate::event_bus::{EventBus, KernelEvent};
15use crate::metrics::get_metrics;
16use crate::supervisor::Supervisor;
17use crate::types::{AgentId, AgentStatus};
18use oxios_ouroboros::{Directive, ExecEnv, ExecutionResult};
19
20/// Manages the full lifecycle of a single agent from fork to cleanup.
21pub struct AgentLifecycleManager {
22    supervisor: Arc<dyn Supervisor>,
23    access_manager: Arc<parking_lot::Mutex<AccessManager>>,
24    a2a: Arc<A2AProtocol>,
25    event_bus: EventBus,
26    /// Maximum execution time in seconds for agent tasks (0 = no limit).
27    max_execution_time_secs: std::sync::atomic::AtomicU64,
28    /// Default allowed tools from config.
29    allowed_tools: Vec<String>,
30    /// Whether agents get network access by default.
31    network_access: bool,
32    /// Workspace path for path sandbox.
33    workspace_path: String,
34}
35
36impl Clone for AgentLifecycleManager {
37    fn clone(&self) -> Self {
38        Self {
39            supervisor: self.supervisor.clone(),
40            access_manager: self.access_manager.clone(),
41            a2a: self.a2a.clone(),
42            event_bus: self.event_bus.clone(),
43            max_execution_time_secs: std::sync::atomic::AtomicU64::new(
44                self.max_execution_time_secs
45                    .load(std::sync::atomic::Ordering::Relaxed),
46            ),
47            allowed_tools: self.allowed_tools.clone(),
48            network_access: self.network_access,
49            workspace_path: self.workspace_path.clone(),
50        }
51    }
52}
53
54impl AgentLifecycleManager {
55    /// Create a new lifecycle manager.
56    #[allow(clippy::too_many_arguments)]
57    pub fn new(
58        supervisor: Arc<dyn Supervisor>,
59        access_manager: Arc<parking_lot::Mutex<AccessManager>>,
60        a2a: Arc<A2AProtocol>,
61        event_bus: EventBus,
62        max_execution_time_secs: u64,
63        allowed_tools: Vec<String>,
64        network_access: bool,
65        workspace_path: String,
66    ) -> Self {
67        Self {
68            supervisor,
69            access_manager,
70            a2a,
71            event_bus,
72            max_execution_time_secs: std::sync::atomic::AtomicU64::new(max_execution_time_secs),
73            allowed_tools,
74            network_access,
75            workspace_path,
76        }
77    }
78
79    /// Hot-reload max execution time without restart.
80    pub fn set_max_execution_time(&self, secs: u64) {
81        self.max_execution_time_secs
82            .store(secs, std::sync::atomic::Ordering::Relaxed);
83        tracing::info!(
84            max_execution_time_secs = secs,
85            "Lifecycle config hot-reloaded"
86        );
87    }
88
89    /// Fork an agent, register it in A2A and access control, submit to
90    /// scheduler, run the directive + exec env, then clean up (RFC-027).
91    pub async fn execute_directive(
92        &self,
93        directive: &Directive,
94        env: &ExecEnv,
95    ) -> Result<ExecutionResult> {
96        // 1. Fork
97        let agent_id = self.supervisor.fork_directive(directive, env).await?;
98        let agent_name = format!("agent-{agent_id}");
99        tracing::info!(agent_id = %agent_id, "Agent forked from directive");
100
101        // 2. Register A2A card
102        let card = self.build_agent_card_directive(agent_id, &agent_name, directive);
103        if let Err(e) = self.a2a.registry().register_agent(card).await {
104            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to register A2A card");
105        }
106
107        // 2b. Deliver any pending A2A messages to this agent
108        if let Err(e) = self.a2a.deliver_pending_messages(agent_id).await {
109            tracing::debug!(agent_id = %agent_id, error = %e, "No pending A2A messages");
110        }
111
112        // 3. Ensure access permissions
113        self.ensure_permissions(&agent_name);
114
115        get_metrics().agents_forked.inc();
116
117        // 5. Run — always cleanup even on failure
118        let max_secs = self
119            .max_execution_time_secs
120            .load(std::sync::atomic::Ordering::Relaxed);
121        let result = if max_secs > 0 {
122            let exec_timeout = Duration::from_secs(max_secs);
123            match timeout(
124                exec_timeout,
125                self.supervisor.run_with_directive(agent_id, directive, env),
126            )
127            .await
128            {
129                Ok(Ok(r)) => r,
130                Ok(Err(e)) => {
131                    tracing::warn!(agent_id = %agent_id, error = %e, "Agent execution failed, cleaning up");
132                    self.cleanup_on_failure(agent_id).await;
133                    return Err(e);
134                }
135                Err(_) => {
136                    let secs = exec_timeout.as_secs();
137                    tracing::warn!(
138                        agent_id = %agent_id,
139                        secs,
140                        "Agent execution timed out after {}s",
141                        secs
142                    );
143                    // Abort the detached execution body. Previously the
144                    // timeout only dropped the awaiting future while the
145                    // spawned task kept running — leaking tokens/resources.
146                    self.cleanup_on_failure(agent_id).await;
147                    bail!("Agent execution timed out after {secs} seconds");
148                }
149            }
150        } else {
151            match self
152                .supervisor
153                .run_with_directive(agent_id, directive, env)
154                .await
155            {
156                Ok(r) => r,
157                Err(e) => {
158                    tracing::warn!(agent_id = %agent_id, error = %e, "Agent execution failed, cleaning up");
159                    self.cleanup_on_failure(agent_id).await;
160                    return Err(e);
161                }
162            }
163        };
164
165        // 6. Cleanup on success
166        self.cleanup(agent_id, &result).await;
167
168        Ok(result)
169    }
170
171    /// Execute a directive with feedback from a previous failed attempt (RFC-027).
172    ///
173    /// Injects the previous result's output and the review gaps into the
174    /// directive's constraints so the agent sees what went wrong.
175    pub async fn execute_with_feedback(
176        &self,
177        directive: &Directive,
178        env: &ExecEnv,
179        prev_result: &ExecutionResult,
180        gaps: &[String],
181    ) -> Result<ExecutionResult> {
182        // Augment the directive with feedback from the previous attempt.
183        let mut augmented = directive.clone();
184        let feedback = format!(
185            "## Previous attempt failed\n{}\n\n## Unmet criteria\n{}\n\n\
186             Review the above output and fix the unmet criteria.",
187            prev_result.output,
188            gaps.iter()
189                .enumerate()
190                .map(|(i, g)| format!("{}. {g}", i + 1))
191                .collect::<Vec<_>>()
192                .join("\n")
193        );
194        augmented.constraints.push(feedback);
195
196        self.execute_directive(&augmented, env).await
197    }
198
199    /// Kill an agent and clean up all registered state.
200    pub async fn terminate(&self, agent_id: AgentId) -> Result<()> {
201        if let Err(e) = self.supervisor.kill(agent_id).await {
202            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to kill agent");
203        }
204        if let Err(e) = self.a2a.registry().unregister_agent(agent_id).await {
205            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to unregister A2A card");
206        }
207        let _ = self.event_bus.publish(KernelEvent::AgentStopped {
208            id: agent_id,
209            success: false,
210        });
211        Ok(())
212    }
213
214    /// Build an A2A agent card from a Directive (RFC-027).
215    ///
216    /// Reads the goal from a Directive and advertises `execute-directive`
217    /// so A2A consumers know the agent follows the directive path.
218    fn build_agent_card_directive(
219        &self,
220        agent_id: AgentId,
221        agent_name: &str,
222        directive: &Directive,
223    ) -> AgentCard {
224        let goal_lower = directive.goal.to_lowercase();
225
226        let mut card = AgentCard::new(
227            agent_id,
228            agent_name,
229            format!("Agent executing directive: {}", directive.goal),
230        )
231        .with_capability("execute-directive")
232        .with_status(AgentStatus::Starting);
233
234        // Infer capabilities from goal.
235        if goal_lower.contains("review") || goal_lower.contains("code") {
236            card = card.with_capability("code-review");
237        }
238        if goal_lower.contains("test") {
239            card = card.with_capability("testing");
240        }
241        if goal_lower.contains("refactor") || goal_lower.contains("improve") {
242            card = card.with_capability("refactoring");
243        }
244        if goal_lower.contains("write")
245            || goal_lower.contains("create")
246            || goal_lower.contains("implement")
247        {
248            card = card.with_capability("code-generation");
249        }
250        if goal_lower.contains("debug") || goal_lower.contains("fix") {
251            card = card.with_capability("debugging");
252        }
253
254        card
255    }
256
257    /// Ensure default tool permissions exist for an agent.
258    ///
259    /// Applies config.toml `[security]` settings:
260    /// - `allowed_tools` → agent's tool set
261    /// - `network_access` → network permission
262    /// - workspace path → path sandbox
263    /// - RBAC `Superuser` role → allows all tools and paths
264    fn ensure_permissions(&self, agent_name: &str) {
265        let mut access = self.access_manager.lock();
266        let perms = access.get_or_create_permissions(agent_name);
267
268        // Grant all tools from config
269        for tool in &self.allowed_tools {
270            if !perms.allowed_tools.contains(tool.as_str()) {
271                perms.allow_tool(tool);
272            }
273        }
274
275        // Add workspace path to allowed paths
276        let ws_pattern = format!("{}/**", self.workspace_path.trim_end_matches('/'));
277        if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
278            perms.allow_path(&ws_pattern);
279        }
280        // Also allow /tmp for agent temp files
281        if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
282            perms.allow_path("/tmp/**");
283        }
284
285        // Apply network access from config
286        if self.network_access {
287            perms.enable_network();
288        }
289
290        // Assign Superuser RBAC role so AccessGate passes
291        // (config.toml already defines which tools are allowed)
292        let subject = Subject::Agent(
293            agent_name
294                .strip_prefix("agent-")
295                .and_then(|s| s.parse().ok())
296                .unwrap_or_default(),
297        );
298        access
299            .rbac_manager_mut()
300            .assign_role(subject, Role::Superuser);
301    }
302
303    /// Unregister A2A card on completion.
304    async fn cleanup(&self, agent_id: AgentId, _result: &ExecutionResult) {
305        if let Err(e) = self.a2a.registry().unregister_agent(agent_id).await {
306            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to unregister A2A card");
307        }
308    }
309
310    /// Cleanup when agent execution fails (no ExecutionResult available).
311    async fn cleanup_on_failure(&self, agent_id: AgentId) {
312        if let Err(e) = self.a2a.registry().unregister_agent(agent_id).await {
313            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to unregister A2A card");
314        }
315    }
316}