1use 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
39pub struct AgentRuntime<P: LlmProvider> {
41 pub(super) llm: Arc<P>,
43 pub(super) mcp: McpClient,
45 pub(super) audit: Arc<AuditLog>,
47 pub(super) sessions: SessionStore,
49 pub(super) crypto: Arc<KeyPair>,
51 pub(super) config: RuntimeConfig,
53 pub(super) context: ContextManager,
55 pub(super) boundary: WorkspaceBoundary,
57 pub(super) hooks: Arc<HookManager>,
59 pub(super) tool_registry: ToolRegistry,
61 pub(super) shared_cwd: Arc<tokio::sync::RwLock<std::path::PathBuf>>,
63 pub(super) security_policy: SecurityPolicy,
65 pub(super) subagent_pool: Arc<SubAgentPool>,
67 pub(super) capsule_registry: Option<Arc<tokio::sync::RwLock<CapsuleRegistry>>>,
69 pub(super) capsule_kv_stores:
74 std::sync::Mutex<std::collections::HashMap<String, Arc<dyn KvStore>>>,
75 pub(super) self_arc: tokio::sync::RwLock<Option<std::sync::Weak<Self>>>,
77}
78
79impl<P: LlmProvider + 'static> AgentRuntime<P> {
80 #[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 #[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 #[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 runtime.self_arc = tokio::sync::RwLock::new(Some(weak.clone()));
164 runtime
165 })
166 }
167
168 #[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 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 pub fn save_session(&self, session: &AgentSession) -> RuntimeResult<()> {
200 self.sessions.save(session)
201 }
202
203 pub fn load_session(&self, id: &SessionId) -> RuntimeResult<Option<AgentSession>> {
209 self.sessions.load(id)
210 }
211
212 pub fn list_sessions(&self) -> RuntimeResult<Vec<crate::store::SessionSummary>> {
218 self.sessions.list_with_metadata()
219 }
220
221 #[must_use]
223 pub fn config(&self) -> &RuntimeConfig {
224 &self.config
225 }
226
227 #[must_use]
229 pub fn audit(&self) -> &Arc<AuditLog> {
230 &self.audit
231 }
232
233 #[must_use]
235 pub fn mcp(&self) -> &McpClient {
236 &self.mcp
237 }
238
239 #[must_use]
241 pub fn key_id(&self) -> [u8; 8] {
242 self.crypto.key_id()
243 }
244
245 #[must_use]
247 pub fn boundary(&self) -> &WorkspaceBoundary {
248 &self.boundary
249 }
250
251 pub fn cleanup_capsule_kv_stores(&self, session_id: &SessionId) {
256 let prefix = format!("{session_id}:");
257 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 #[must_use]
267 pub fn with_security_policy(mut self, policy: SecurityPolicy) -> Self {
268 self.security_policy = policy;
269 self
270 }
271
272 #[must_use]
274 pub fn with_hooks(mut self, hooks: HookManager) -> Self {
275 self.hooks = Arc::new(hooks);
276 self
277 }
278
279 #[must_use]
281 pub fn hooks(&self) -> &Arc<HookManager> {
282 &self.hooks
283 }
284
285 #[must_use]
287 pub fn subagent_pool(&self) -> &Arc<SubAgentPool> {
288 &self.subagent_pool
289 }
290
291 pub async fn set_self_arc(self: &Arc<Self>) {
305 *self.self_arc.write().await = Some(Arc::downgrade(self));
306 }
307
308 pub(super) fn read_effective_spark(&self) -> Option<SparkConfig> {
313 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 => { },
324 }
325 }
326 self.config
328 .spark_seed
329 .as_ref()
330 .filter(|s| !s.is_empty())
331 .cloned()
332 }
333
334 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 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 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 #[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 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
420const INPUT_RATE_PER_1K: f64 = 0.003; const OUTPUT_RATE_PER_1K: f64 = 0.015; #[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}