Skip to main content

astrid_runtime/runtime/
mod.rs

1//! Agent runtime - the main orchestration component.
2//!
3//! Coordinates LLM, MCP, capabilities, and audit systems.
4
5use astrid_approval::{SecurityInterceptor, SecurityPolicy};
6use astrid_audit::AuditLog;
7use astrid_capsule::registry::CapsuleRegistry;
8use astrid_core::{Frontend, SessionId};
9use astrid_crypto::KeyPair;
10use astrid_hooks::result::HookContext;
11use astrid_hooks::{HookEvent, HookManager};
12use astrid_llm::LlmProvider;
13use astrid_mcp::McpClient;
14use astrid_storage::KvStore;
15use astrid_tools::{SparkConfig, ToolContext, ToolRegistry};
16use astrid_workspace::WorkspaceBoundary;
17use std::path::Path;
18use std::sync::Arc;
19use tracing::{debug, info};
20
21use crate::context::ContextManager;
22use crate::error::RuntimeResult;
23use crate::session::AgentSession;
24use crate::store::SessionStore;
25use crate::subagent::SubAgentPool;
26use crate::subagent_executor::SubAgentExecutor;
27
28mod config;
29mod execution;
30mod security;
31mod tool_execution;
32mod workspace;
33
34#[cfg(test)]
35mod tests;
36
37pub use config::RuntimeConfig;
38
39/// The main agent runtime.
40pub struct AgentRuntime<P: LlmProvider> {
41    /// LLM provider.
42    pub(super) llm: Arc<P>,
43    /// MCP client.
44    pub(super) mcp: McpClient,
45    /// Audit log.
46    pub(super) audit: Arc<AuditLog>,
47    /// Session store.
48    pub(super) sessions: SessionStore,
49    /// Runtime signing key.
50    pub(super) crypto: Arc<KeyPair>,
51    /// Configuration.
52    pub(super) config: RuntimeConfig,
53    /// Context manager.
54    pub(super) context: ContextManager,
55    /// Pre-compiled workspace boundary checker.
56    pub(super) boundary: WorkspaceBoundary,
57    /// Hook manager for user-defined extension points.
58    pub(super) hooks: Arc<HookManager>,
59    /// Built-in tool registry.
60    pub(super) tool_registry: ToolRegistry,
61    /// Shared current working directory (persists across turns).
62    pub(super) shared_cwd: Arc<tokio::sync::RwLock<std::path::PathBuf>>,
63    /// Security policy (shared across sessions).
64    pub(super) security_policy: SecurityPolicy,
65    /// Sub-agent pool (shared across turns).
66    pub(super) subagent_pool: Arc<SubAgentPool>,
67    /// Capsule registry (shared with the gateway).
68    pub(super) capsule_registry: Option<Arc<tokio::sync::RwLock<CapsuleRegistry>>>,
69    /// Per-plugin KV stores that persist across tool calls.
70    /// Keyed by `{session_id}:{server}` to isolate sessions from each other.
71    /// Call [`cleanup_capsule_kv_stores`](Self::cleanup_capsule_kv_stores) when a
72    /// session ends to prevent unbounded growth.
73    pub(super) capsule_kv_stores:
74        std::sync::Mutex<std::collections::HashMap<String, Arc<dyn KvStore>>>,
75    /// Weak self-reference for spawner injection (set via `set_self_arc`).
76    pub(super) self_arc: tokio::sync::RwLock<Option<std::sync::Weak<Self>>>,
77}
78
79impl<P: LlmProvider + 'static> AgentRuntime<P> {
80    /// Create a new runtime.
81    #[must_use]
82    pub fn new(
83        llm: P,
84        mcp: McpClient,
85        audit: AuditLog,
86        sessions: SessionStore,
87        crypto: KeyPair,
88        config: RuntimeConfig,
89    ) -> Self {
90        let context =
91            ContextManager::new(config.max_context_tokens).keep_recent(config.keep_recent_count);
92        let boundary = WorkspaceBoundary::new(config.workspace.clone());
93
94        let tool_registry = ToolRegistry::with_defaults();
95        let shared_cwd = Arc::new(tokio::sync::RwLock::new(config.workspace.root.clone()));
96        let subagent_pool = Arc::new(SubAgentPool::new(
97            config.max_concurrent_subagents,
98            config.max_subagent_depth,
99        ));
100
101        info!(
102            workspace_root = %config.workspace.root.display(),
103            workspace_mode = ?config.workspace.mode,
104            max_concurrent_subagents = config.max_concurrent_subagents,
105            max_subagent_depth = config.max_subagent_depth,
106            "Workspace boundary initialized"
107        );
108
109        Self {
110            llm: Arc::new(llm),
111            mcp,
112            audit: Arc::new(audit),
113            sessions,
114            crypto: Arc::new(crypto),
115            config,
116            context,
117            boundary,
118            hooks: Arc::new(HookManager::new()),
119            tool_registry,
120            shared_cwd,
121            security_policy: SecurityPolicy::default(),
122            subagent_pool,
123            capsule_registry: None,
124            capsule_kv_stores: std::sync::Mutex::new(std::collections::HashMap::new()),
125            self_arc: tokio::sync::RwLock::new(None),
126        }
127    }
128
129    /// Set the capsule registry for capsule tool integration.
130    #[must_use]
131    pub fn with_capsule_registry(
132        mut self,
133        registry: Arc<tokio::sync::RwLock<CapsuleRegistry>>,
134    ) -> Self {
135        self.capsule_registry = Some(registry);
136        self
137    }
138
139    /// Create a new runtime wrapped in `Arc` with the self-reference pre-set.
140    ///
141    /// Uses `Arc::new_cyclic` to avoid the two-step `new()` + `set_self_arc()` pattern.
142    /// Accepts an optional `HookManager` since `with_hooks()` can't be chained after
143    /// Arc wrapping. Accepts an optional `PluginRegistry` for capsule tool integration.
144    #[must_use]
145    #[allow(clippy::too_many_arguments)]
146    pub fn new_arc(
147        llm: P,
148        mcp: McpClient,
149        audit: AuditLog,
150        sessions: SessionStore,
151        crypto: KeyPair,
152        config: RuntimeConfig,
153        hooks: Option<HookManager>,
154        capsule_registry: Option<Arc<tokio::sync::RwLock<CapsuleRegistry>>>,
155    ) -> Arc<Self> {
156        Arc::new_cyclic(|weak| {
157            let mut runtime = Self::new(llm, mcp, audit, sessions, crypto, config);
158            if let Some(hook_manager) = hooks {
159                runtime.hooks = Arc::new(hook_manager);
160            }
161            runtime.capsule_registry = capsule_registry;
162            // Pre-set the self-reference (no async needed — field is initialized directly).
163            runtime.self_arc = tokio::sync::RwLock::new(Some(weak.clone()));
164            runtime
165        })
166    }
167
168    /// Create a new session.
169    ///
170    /// Uses `build_system_prompt()` to dynamically assemble a workspace-aware
171    /// prompt with tool guidelines and project instructions. If the user has
172    /// explicitly set a custom `system_prompt` in config, that takes priority.
173    ///
174    /// An optional `workspace_override` can be supplied to use a different
175    /// workspace root than the one in the runtime config (e.g. the CLI
176    /// client's actual working directory).
177    #[must_use]
178    pub fn create_session(&self, workspace_override: Option<&Path>) -> AgentSession {
179        let workspace_root = workspace_override.unwrap_or(&self.config.workspace.root);
180
181        // Build the base system prompt WITHOUT spark identity.
182        // Spark is layered on each loop iteration for hot-reload support.
183        let system_prompt = if self.config.system_prompt.is_empty() {
184            astrid_tools::build_system_prompt(workspace_root, None)
185        } else {
186            self.config.system_prompt.clone()
187        };
188
189        let session = AgentSession::new(self.crypto.key_id(), system_prompt);
190        info!(session_id = %session.id, "Created new session");
191        session
192    }
193
194    /// Save a session.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if the session cannot be serialized or written to disk.
199    pub fn save_session(&self, session: &AgentSession) -> RuntimeResult<()> {
200        self.sessions.save(session)
201    }
202
203    /// Load a session.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the session file cannot be read or deserialized.
208    pub fn load_session(&self, id: &SessionId) -> RuntimeResult<Option<AgentSession>> {
209        self.sessions.load(id)
210    }
211
212    /// List sessions.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the session directory cannot be read or session files cannot be parsed.
217    pub fn list_sessions(&self) -> RuntimeResult<Vec<crate::store::SessionSummary>> {
218        self.sessions.list_with_metadata()
219    }
220
221    /// Get runtime configuration.
222    #[must_use]
223    pub fn config(&self) -> &RuntimeConfig {
224        &self.config
225    }
226
227    /// Get the audit log.
228    #[must_use]
229    pub fn audit(&self) -> &Arc<AuditLog> {
230        &self.audit
231    }
232
233    /// Get the MCP client.
234    #[must_use]
235    pub fn mcp(&self) -> &McpClient {
236        &self.mcp
237    }
238
239    /// Get the runtime key ID.
240    #[must_use]
241    pub fn key_id(&self) -> [u8; 8] {
242        self.crypto.key_id()
243    }
244
245    /// Get the workspace boundary.
246    #[must_use]
247    pub fn boundary(&self) -> &WorkspaceBoundary {
248        &self.boundary
249    }
250
251    /// Remove plugin KV stores for a session that has ended.
252    ///
253    /// Should be called when a session is finished to prevent unbounded growth
254    /// of the `capsule_kv_stores` map in long-running processes.
255    pub fn cleanup_capsule_kv_stores(&self, session_id: &SessionId) {
256        let prefix = format!("{session_id}:");
257        // SAFETY: no .await while lock is held — HashMap::retain is synchronous.
258        let mut stores = self
259            .capsule_kv_stores
260            .lock()
261            .unwrap_or_else(std::sync::PoisonError::into_inner);
262        stores.retain(|key, _| !key.starts_with(&prefix));
263    }
264
265    /// Set a custom security policy.
266    #[must_use]
267    pub fn with_security_policy(mut self, policy: SecurityPolicy) -> Self {
268        self.security_policy = policy;
269        self
270    }
271
272    /// Set a pre-configured hook manager.
273    #[must_use]
274    pub fn with_hooks(mut self, hooks: HookManager) -> Self {
275        self.hooks = Arc::new(hooks);
276        self
277    }
278
279    /// Get the hook manager.
280    #[must_use]
281    pub fn hooks(&self) -> &Arc<HookManager> {
282        &self.hooks
283    }
284
285    /// Get the sub-agent pool.
286    #[must_use]
287    pub fn subagent_pool(&self) -> &Arc<SubAgentPool> {
288        &self.subagent_pool
289    }
290
291    /// Store a weak self-reference for sub-agent spawner injection.
292    ///
293    /// **Important**: Callers must wrap the runtime in `Arc` and call this method
294    /// for sub-agent support to work. Without it, the `task` tool will return
295    /// "not available in this context".
296    ///
297    /// ```ignore
298    /// let runtime = Arc::new(AgentRuntime::new(/* ... */));
299    /// runtime.set_self_arc(&runtime).await;
300    /// ```
301    ///
302    // TODO(Phase 7): Consider migrating to `Arc::new_cyclic` to eliminate the two-step
303    // initialization pattern and make the self-reference setup infallible.
304    pub async fn set_self_arc(self: &Arc<Self>) {
305        *self.self_arc.write().await = Some(Arc::downgrade(self));
306    }
307
308    /// Read the effective spark identity (hot-reload support).
309    ///
310    /// Priority: `spark.toml` (living document) > `[spark]` in config (static seed).
311    /// Returns `None` when no spark is configured or both sources are empty.
312    pub(super) fn read_effective_spark(&self) -> Option<SparkConfig> {
313        // 1. Try spark.toml (living document, takes priority)
314        if let Some(ref path) = self.config.spark_file {
315            match SparkConfig::load_from_file(path) {
316                Some(spark) if !spark.is_empty() => return Some(spark),
317                None if path.exists() => {
318                    tracing::warn!(
319                        path = %path.display(),
320                        "spark.toml exists but failed to parse; falling back to config seed"
321                    );
322                },
323                Some(_) | None => { /* empty or missing, fall through to seed */ },
324            }
325        }
326        // 2. Fall back to [spark] from config (static seed)
327        self.config
328            .spark_seed
329            .as_ref()
330            .filter(|s| !s.is_empty())
331            .cloned()
332    }
333
334    /// Inject a `SubAgentExecutor` into the per-turn `ToolContext`.
335    ///
336    /// Does nothing if `set_self_arc` was never called (graceful degradation).
337    pub(super) async fn inject_subagent_spawner<F: Frontend + 'static>(
338        &self,
339        tool_ctx: &ToolContext,
340        session: &AgentSession,
341        frontend: &Arc<F>,
342        parent_subagent_id: Option<crate::subagent::SubAgentId>,
343    ) {
344        let self_arc = {
345            let guard = self.self_arc.read().await;
346            guard.as_ref().and_then(std::sync::Weak::upgrade)
347        };
348
349        if let Some(runtime_arc) = self_arc {
350            // Read callsign from effective spark for sub-agent identity inheritance.
351            let parent_callsign = self.read_effective_spark().and_then(|s| {
352                if s.callsign.is_empty() {
353                    None
354                } else {
355                    Some(s.callsign)
356                }
357            });
358
359            let executor = SubAgentExecutor::new(
360                runtime_arc,
361                Arc::clone(&self.subagent_pool),
362                Arc::clone(frontend),
363                session.user_id,
364                parent_subagent_id,
365                session.id.clone(),
366                Arc::clone(&session.allowance_store),
367                Arc::clone(&session.capabilities),
368                Arc::clone(&session.budget_tracker),
369                self.config.default_subagent_timeout,
370                parent_callsign,
371                session.capsule_context.clone(),
372            );
373            tool_ctx
374                .set_subagent_spawner(Some(Arc::new(executor)))
375                .await;
376        } else {
377            debug!("No self_arc set — sub-agent spawning disabled for this turn");
378        }
379    }
380
381    /// Convert a `[u8; 8]` user ID to a UUID by zero-padding to 16 bytes.
382    pub(super) fn user_uuid(user_id: [u8; 8]) -> uuid::Uuid {
383        let mut uuid_bytes = [0u8; 16];
384        uuid_bytes[..8].copy_from_slice(&user_id);
385        uuid::Uuid::from_bytes(uuid_bytes)
386    }
387
388    /// Build a hook context with session info.
389    #[allow(clippy::unused_self)]
390    pub(super) fn build_hook_context(
391        &self,
392        session: &AgentSession,
393        event: HookEvent,
394    ) -> HookContext {
395        HookContext::new(event)
396            .with_session(session.id.0)
397            .with_user(Self::user_uuid(session.user_id))
398    }
399
400    /// Build a `SecurityInterceptor` for the given session.
401    ///
402    /// Cheap to create — just Arc clones of shared state.
403    /// Uses the session's per-session budget tracker so budget persists across restarts.
404    pub(super) fn build_interceptor(&self, session: &AgentSession) -> SecurityInterceptor {
405        SecurityInterceptor::new(
406            Arc::clone(&session.capabilities),
407            Arc::clone(&session.approval_manager),
408            self.security_policy.clone(),
409            Arc::clone(&session.budget_tracker),
410            Arc::clone(&self.audit),
411            Arc::clone(&self.crypto),
412            session.id.clone(),
413            Arc::clone(&session.allowance_store),
414            Some(self.config.workspace.root.clone()),
415            session.workspace_budget_tracker.clone(),
416        )
417    }
418}
419
420// ---------------------------------------------------------------------------
421// Cost tracking helpers
422// ---------------------------------------------------------------------------
423
424/// Hardcoded Claude model rates (USD per 1K tokens).
425/// These will be configurable via TOML config in Step 3.
426const INPUT_RATE_PER_1K: f64 = 0.003; // $3 per million input tokens
427const OUTPUT_RATE_PER_1K: f64 = 0.015; // $15 per million output tokens
428
429/// Convert token counts to estimated USD cost.
430#[allow(clippy::cast_precision_loss)]
431pub(super) fn tokens_to_usd(input_tokens: usize, output_tokens: usize) -> f64 {
432    let input_cost = (input_tokens as f64 / 1000.0) * INPUT_RATE_PER_1K;
433    let output_cost = (output_tokens as f64 / 1000.0) * OUTPUT_RATE_PER_1K;
434    input_cost + output_cost
435}