1use anyhow::Result;
31use oxi_sdk::observability::AuditTrail;
32use oxi_sdk::{
33 Agent, AgentConfig, AgentEvent, CompactionEvent, CompactionStrategy, ProviderResolver,
34};
35use oxi_sdk::{SearchCache, ToolExecutionMode, ToolRegistry};
36use parking_lot::Mutex;
37use std::collections::HashMap;
38use std::sync::Arc;
39use crate::access_manager::{AccessGate, AgentContext, TracingAuditSink, TrailAuditSink};
43use crate::capability::resolve::resolve_cspace;
44use crate::engine::OxiosEngine;
45use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
46use crate::persona::PersonaManager;
47use crate::tools::registration::register_tools_from_cspace_gated;
48
49use crate::KernelHandle;
50use crate::event_bus::KernelEvent;
51use crate::session_context::SessionContext;
52use crate::types::AgentId;
53use oxios_ouroboros::{Directive, ExecEnv, ExecutionResult};
54
55static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<oxi_sdk::ProviderCircuitBreaker> =
57 std::sync::OnceLock::new();
58
59fn get_llm_circuit_breaker() -> &'static oxi_sdk::ProviderCircuitBreaker {
61 LLM_CIRCUIT_BREAKER.get_or_init(|| {
62 oxi_sdk::ProviderCircuitBreaker::new(
63 "global".to_string(),
64 oxi_sdk::CircuitBreakerConfig::default(),
65 )
66 })
67}
68
69#[derive(Debug, Clone)]
75#[non_exhaustive]
76pub enum StreamDelta {
77 Text(String),
79
80 Thinking,
83 ThinkingDelta(String),
87}
88
89pub type StreamingSinkTx = std::sync::Arc<tokio::sync::mpsc::UnboundedSender<StreamDelta>>;
96
97#[derive(Debug, Clone)]
99pub struct AgentRuntimeConfig {
100 pub model_id: String,
102 pub tool_execution: ToolExecutionMode,
104 pub auto_retry_enabled: bool,
106 pub workspace_dir: Option<std::path::PathBuf>,
108 pub api_key: Option<String>,
110 pub provider_options: Option<oxi_sdk::ProviderOptions>,
112 pub rate_limit_per_minute: usize,
114 pub token_budget: usize,
116 pub audit_tool_calls: bool,
118 pub provider_rpm: u32,
121}
122
123impl Default for AgentRuntimeConfig {
124 fn default() -> Self {
125 Self {
126 model_id: String::new(),
127 tool_execution: ToolExecutionMode::Parallel,
128 auto_retry_enabled: true,
129 workspace_dir: None,
130 api_key: None,
131 provider_options: None,
132 rate_limit_per_minute: 0,
133 token_budget: 0,
134 audit_tool_calls: false,
135 provider_rpm: 0,
136 }
137 }
138}
139
140#[derive(Default)]
142struct ExecuteState {
143 final_content: String,
144 steps_completed: usize,
145 success: bool,
146 reasoning_text: String,
151 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
154 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
158 tool_call_ids: Vec<String>,
161 tool_args_map: std::collections::HashMap<String, String>,
163 tool_error_map: std::collections::HashMap<String, bool>,
165 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
167 total_input_tokens: u64,
169 total_output_tokens: u64,
171}
172
173pub struct AgentRuntime {
182 engine_handle: Arc<crate::engine::EngineHandle>,
183 config: AgentRuntimeConfig,
184 kernel_handle: Arc<KernelHandle>,
186 persona_manager: Option<Arc<PersonaManager>>,
188 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
190 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
192 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
194 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
196}
197
198impl AgentRuntime {
199 pub fn new(
205 engine_handle: Arc<crate::engine::EngineHandle>,
206 kernel_handle: Arc<KernelHandle>,
207 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
208 ) -> Self {
209 Self {
210 engine_handle,
211 config: AgentRuntimeConfig::default(),
212 kernel_handle,
213 persona_manager: None,
214 tool_retriever: None,
215 routing_stats,
216 persistence_hook: None,
217 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
218 }
219 }
220
221 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
223 self.persona_manager = Some(pm);
224 self
225 }
226
227 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
229 self.config = config;
230 self
231 }
232
233 pub fn with_tool_retriever(
235 mut self,
236 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
237 ) -> Self {
238 self.tool_retriever = Some(retriever);
239 self
240 }
241
242 pub fn with_persistence_hook(
244 mut self,
245 hook: Arc<crate::persistence_hook::PersistenceHook>,
246 ) -> Self {
247 self.persistence_hook = Some(hook);
248 self
249 }
250
251 pub async fn execute_directive(
257 &self,
258 agent_id: AgentId,
259 directive: &Directive,
260 env: &ExecEnv,
261 session_ctx: &mut SessionContext,
262 ) -> Result<ExecutionResult> {
263 let session_id: Option<String> = env
269 .session_id
270 .clone()
271 .or_else(|| Some(agent_id.to_string()));
272 self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
273 .await
274 }
275 pub async fn execute_directive_with_session(
278 &self,
279 agent_id: AgentId,
280 directive: &Directive,
281 env: &ExecEnv,
282 session_ctx: &mut SessionContext,
283 session_id: Option<String>,
284 ) -> Result<ExecutionResult> {
285 self.execute_inner(
286 agent_id,
287 &directive.goal,
288 &directive.original_request,
289 &directive.constraints,
290 &directive.acceptance_criteria,
291 env.cspace_hint.as_deref(),
292 &env.mount_paths,
293 env.workspace_context.as_deref(),
294 session_ctx,
295 session_id,
296 Some(directive),
297 env.model_override.as_deref(),
298 env.role.as_deref(),
299 env.restore_state.as_ref(),
300 )
301 .await
302 }
303
304 #[allow(clippy::too_many_arguments)]
311 async fn execute_inner(
312 &self,
313 agent_id: AgentId,
314 goal: &str,
315 original_request: &str,
316 constraints: &[String],
317 acceptance_criteria: &[String],
318 cspace_hint: Option<&str>,
319 mount_paths: &[std::path::PathBuf],
320 workspace_context: Option<&str>,
321 session_ctx: &mut SessionContext,
322 session_id: Option<String>,
323 persistence_directive: Option<&Directive>,
324 model_override: Option<&str>,
325 role: Option<&str>,
326 restore_state: Option<&serde_json::Value>,
327 ) -> Result<ExecutionResult> {
328 let prompt = build_user_prompt_inner(goal, acceptance_criteria);
329
330 let persona_prompt = self
332 .persona_manager
333 .as_ref()
334 .map(|pm| pm.active_system_prompt())
335 .filter(|s| !s.trim().is_empty());
336
337 let persona_role = self
339 .persona_manager
340 .as_ref()
341 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
342
343 let cspace = resolve_cspace(
345 cspace_hint,
346 persona_role.as_deref(),
347 Some("worker"),
348 agent_id,
349 );
350
351 let mut system_prompt = build_system_prompt_inner(
354 goal,
355 original_request,
356 constraints,
357 acceptance_criteria,
358 workspace_context,
359 persona_prompt.as_deref(),
360 None,
361 None,
362 );
363
364 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
366 match retriever.embedder().embed(goal).await {
367 Ok(query_vec) => {
368 let results = retriever.retrieve(&query_vec, 8);
369 if results.is_empty() {
370 None
371 } else {
372 let xml = crate::tools::retrieval::format_capability_index(&results);
373 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
374 Some(xml)
375 }
376 }
377 Err(e) => {
378 tracing::warn!(error = %e, "Failed to embed goal for retrieval");
379 None
380 }
381 }
382 } else {
383 None
384 };
385
386 let kernel_manifest = {
388 let domains = cspace.active_domains();
389 if domains.is_empty() {
390 None
391 } else {
392 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
393 }
394 };
395
396 if capabilities_xml.is_some() || kernel_manifest.is_some() {
398 system_prompt = build_system_prompt_inner(
399 goal,
400 original_request,
401 constraints,
402 acceptance_criteria,
403 workspace_context,
404 persona_prompt.as_deref(),
405 capabilities_xml.as_deref(),
406 kernel_manifest.as_deref(),
407 );
408 }
409
410 let memory_manager = self.kernel_handle.agents.memory_manager();
412 match memory_manager
413 .recall_with_proactive(goal, &mut session_ctx.recall_timing)
414 .await
415 {
416 Ok(memories) if !memories.is_empty() => {
417 tracing::info!(count = memories.len(), "Recalled memories for task");
418 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
419 }
420 Ok(_) => tracing::debug!("No memories recalled"),
421 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
422 }
423
424 if let Some(sona) = memory_manager.sona_engine() {
426 match sona.adapt(goal).await {
427 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
428 tracing::info!(
429 domain = %pattern.domain,
430 confidence = pattern.confidence,
431 "SONA learned pattern injected"
432 );
433 system_prompt.push_str(&format!(
434 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
435 pattern.confidence * 100.0,
436 pattern.strategy,
437 ));
438 }
439 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
440 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
441 }
442 }
443
444 match self
446 .kernel_handle
447 .knowledge_lens
448 .recall_for_context(goal, 5)
449 .await
450 {
451 Ok(ctx) if !ctx.notes.is_empty() => {
452 tracing::info!(
453 notes = ctx.notes.len(),
454 memories = ctx.memories.len(),
455 "Recalled knowledge context for task"
456 );
457 let knowledge_blend = ctx
458 .notes
459 .iter()
460 .take(3)
461 .map(|n| format!("## {}\n\n{}", n.name, n.content))
462 .collect::<Vec<_>>()
463 .join("\n\n");
464 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
465 system_prompt.push_str(&knowledge_blend);
466 }
467 Ok(_) => tracing::debug!("No knowledge recalled"),
468 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
469 }
470
471 let engine = self.engine_handle.get();
482 let model_id = model_override
483 .map(|s| s.to_string())
484 .or_else(|| role.and_then(|r| self.kernel_handle.engine.model_for_role(r)))
485 .unwrap_or_else(|| engine.default_model_id().to_string());
486 engine.resolve_model(&model_id)?;
488 let exec_id = uuid::Uuid::new_v4();
490
491 let mut config = self.config.clone();
496 config.model_id = model_id;
497 let kernel_handle = Arc::clone(&self.kernel_handle);
498
499 let audit_trail: Option<Arc<AuditTrail>> =
501 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
502
503 let (
504 mut final_content,
505 steps_completed,
506 success,
507 trajectory_steps,
508 agent,
509 tool_call_ids,
510 tool_args_map,
511 tool_error_map,
512 tool_timestamps,
513 total_input_tokens,
514 total_output_tokens,
515 reasoning_text,
516 ) = {
517 run_agent(
518 &config,
519 &engine,
520 kernel_handle,
521 system_prompt,
522 prompt,
523 exec_id,
524 goal.to_string(),
525 agent_id,
526 cspace,
527 audit_trail,
528 self.routing_stats.clone(),
529 session_id.clone(),
530 mount_paths,
531 restore_state,
532 )
533 .await?
534 };
535
536 if final_content.is_empty() && !trajectory_steps.is_empty() {
543 let tool_summary: Vec<String> = trajectory_steps
544 .iter()
545 .enumerate()
546 .map(|(i, step)| {
547 let truncated = if step.output.len() > 800 {
548 let mut end = 800;
552 while end > 0 && !step.output.is_char_boundary(end) {
553 end -= 1;
554 }
555 format!("{}...", &step.output[..end])
556 } else {
557 step.output.clone()
558 };
559 format!("{}. [{}] {}", i + 1, step.input, truncated)
560 })
561 .collect();
562 let summary_prompt = format!(
563 "도구 실행 결과:\n\n{}\n\n\
564 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
565 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
566 tool_summary.join("\n")
567 );
568 match agent.run(summary_prompt).await {
569 Ok((response, _events)) => {
570 if !response.content.is_empty() {
571 tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
572 final_content = response.content;
573 }
574 }
575 Err(e) => {
576 tracing::warn!(error = %e, "Post-execution summary failed");
577 }
578 }
579 }
580
581 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
584 .iter()
585 .enumerate()
586 .map(|(i, step)| {
587 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
588 let args_str = tool_call_ids
589 .get(i)
590 .and_then(|id| tool_args_map.get(id))
591 .cloned()
592 .unwrap_or_default();
593 let is_error = tool_call_ids
594 .get(i)
595 .and_then(|id| tool_error_map.get(id))
596 .copied()
597 .unwrap_or(false);
598 let timestamp = tool_call_ids
599 .get(i)
600 .and_then(|id| tool_timestamps.get(id))
601 .copied();
602 let input_str = truncate_json_str(&args_str, 500);
603 oxios_ouroboros::ToolCallRecord {
604 tool: step.input.clone(),
605 input: input_str,
606 output: step.output.clone(),
607 duration_ms: step.duration_ms,
608 is_error,
609 tool_call_id: tc_id,
610 timestamp,
611 }
612 })
613 .collect();
614
615 tracing::info!(
616 exec_id = %exec_id,
617 steps = steps_completed,
618 success,
619 tool_calls = tool_calls.len(),
620 "AgentRuntime finished"
621 );
622
623 let result = ExecutionResult {
624 output: final_content.clone(),
625 steps_completed,
626 success,
627 tool_calls,
628 failure_class: None,
629 restore_state: None,
630 tokens_input: total_input_tokens,
631 tokens_output: total_output_tokens,
632 model_id: self.engine_handle.get().default_model_id().to_string(),
633 reasoning_text,
634 };
635
636 if let Some(directive) = persistence_directive
639 && success
640 && let Some(hook) = &self.persistence_hook
641 {
642 let already_saved_knowledge = trajectory_steps
643 .iter()
644 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
645 let hook = hook.clone();
646 let directive_clone = directive.clone();
647 let traj_clone = trajectory_steps.clone();
648 let output_clone = final_content.clone();
649 let sid = session_id.clone();
650 let msg_index = {
653 let mut counter = self.session_msg_counter.lock();
654 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
655 let current = *idx;
656 *idx += 1;
657 current
658 };
659 tokio::spawn(async move {
660 match hook
661 .evaluate(
662 &directive_clone,
663 &traj_clone,
664 &output_clone,
665 already_saved_knowledge,
666 )
667 .await
668 {
669 Ok(plan) => {
670 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
671 tracing::info!(
672 memory = plan.memory.len(),
673 knowledge = plan.knowledge.len(),
674 message_index = msg_index,
675 "PersistenceHook executing plan"
676 );
677 let session_id = sid.unwrap_or_default();
678 hook.execute_plan(plan, &session_id, msg_index).await;
679 }
680 }
681 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
682 }
683 });
684 }
685
686 Ok(result)
687 }
688}
689
690#[allow(clippy::too_many_arguments)]
695async fn run_agent(
696 config: &AgentRuntimeConfig,
697 engine: &OxiosEngine,
698 kernel_handle: Arc<KernelHandle>,
699 system_prompt: String,
700 prompt: String,
701 exec_id: uuid::Uuid,
702 goal: String,
703 agent_id: AgentId,
704 cspace: crate::capability::CSpace,
705 audit_trail: Option<Arc<AuditTrail>>,
706 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
707 session_id: Option<String>,
708 mount_paths: &[std::path::PathBuf],
709 restore_state: Option<&serde_json::Value>,
710) -> Result<(
711 String,
712 usize,
713 bool,
714 Vec<oxios_memory::memory::sona::TrajectoryStep>,
715 Arc<Agent>,
716 Vec<String>,
717 std::collections::HashMap<String, String>,
718 std::collections::HashMap<String, bool>,
719 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
720 u64,
721 u64,
722 String,
723)> {
724 let workspace = if !mount_paths.is_empty() {
730 mount_paths[0].clone()
731 } else if let Some(ws) = &config.workspace_dir {
732 ws.clone()
733 } else {
734 std::env::temp_dir()
735 .join("oxios-agent-workspace")
736 .join(agent_id.to_string())
737 };
738
739 let _ = std::fs::create_dir_all(&workspace);
741
742 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
743
744 {
761 use crate::access_manager::{Role, Subject};
762 let agent_name = format!("agent-{agent_id}");
763 let mut am = kernel_handle.exec.access_manager().lock();
764 let perms = am.get_or_create_permissions(&agent_name);
765
766 if let Ok(cwd) = std::env::current_dir() {
768 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
769 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
770 perms.allow_path(&cwd_pattern);
771 tracing::debug!(
772 agent = %agent_name,
773 path = %cwd_pattern,
774 "Added CWD to agent allowed paths"
775 );
776 }
777 }
778
779 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
781 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
782 perms.allow_path(&ws_pattern);
783 }
784
785 for mount_path in mount_paths {
790 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
791 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
792 perms.allow_path(&pattern);
793 tracing::debug!(
794 agent = %agent_name,
795 path = %pattern,
796 "Added Mount path to agent allowed paths (RFC-025)"
797 );
798 }
799 }
800
801 let kernel_ws = kernel_handle
803 .state
804 .workspace_path()
805 .to_string_lossy()
806 .to_string();
807 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
808 if kernel_ws_pattern != ws_pattern
809 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
810 {
811 perms.allow_path(&kernel_ws_pattern);
812 }
813
814 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
816 perms.allow_path("/tmp/**");
817 }
818
819 let rbac_subject = Subject::Agent(agent_id);
821 am.rbac_manager_mut()
822 .assign_role(rbac_subject, Role::Superuser);
823 }
824
825 let _trace_guard = crate::observability::tracer().start(
827 format!("exec-{}", &exec_id.to_string()[..8]).as_str(),
828 oxi_sdk::SpanKind::Agent,
829 );
830
831 let registry = ToolRegistry::new();
833 let search_cache = Arc::new(SearchCache::new());
834
835 let agent_context = AgentContext {
837 agent_id,
838 agent_name: format!("agent-{agent_id}"),
839 cspace: Arc::new(cspace.clone()),
840 };
841
842 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
845 let audit_path = kernel_handle
846 .state
847 .workspace_path()
848 .join("audit")
849 .join("access.jsonl");
850 Arc::new(TrailAuditSink::new(trail, audit_path))
851 } else {
852 Arc::new(TracingAuditSink)
853 };
854
855 let access_gate = Arc::new(AccessGate::new(
857 kernel_handle.exec.access_manager().clone(),
858 Arc::new(kernel_handle.exec.config_snapshot()),
859 audit_sink,
860 ));
861
862 register_tools_from_cspace_gated(
863 ®istry,
864 &kernel_handle,
865 &cspace,
866 search_cache,
867 agent_id,
868 access_gate,
869 agent_context,
870 );
871
872 tracing::info!(
873 exec_id = %exec_id,
874 capabilities = cspace.len(),
875 "Tools registered from CSpace"
876 );
877
878 let agent_config = AgentConfig {
886 name: format!("agent-{agent_id}"),
887 description: None,
888 model_id: config.model_id.clone(),
889 system_prompt: Some(system_prompt.clone()),
890 timeout_seconds: 300,
891 temperature: Some(0.7),
892 max_tokens: Some(8192),
893 compaction_strategy: CompactionStrategy::Threshold(0.8),
894 compaction_instruction: None,
895 context_window: 128_000,
896 api_key: config.api_key.clone(),
897 workspace_dir: Some(workspace.clone()),
898 output_mode: None,
899 provider_options: config.provider_options.clone(),
900 session_id: None,
906 ..Default::default()
907 };
908
909 let agent = if config.provider_rpm > 0 {
924 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
926 let provider_name = engine.resolve_model(&config.model_id)?.provider;
927 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
928
929 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
931 if config.rate_limit_per_minute > 0 {
932 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
933 config.rate_limit_per_minute,
934 ));
935 }
936 if config.token_budget > 0 {
937 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
938 config.token_budget,
939 ));
940 }
941 if config.audit_tool_calls {
942 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
943 tracing::Level::INFO,
944 ));
945 }
946
947 let agent = Arc::new(Agent::new_with_resolver(
949 provider,
950 agent_config,
951 Arc::new(registry),
952 resolver,
953 ));
954
955 if !pipeline.is_empty() {
957 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
958 let agent_id_for_hooks = agent_id.to_string();
959 let hooks = oxi_sdk::middleware::build_hooks(
960 Arc::new(pipeline),
961 agent_id_for_hooks,
962 terminate_flag,
963 );
964 agent.set_hooks(hooks);
965 }
966
967 agent
968 } else {
969 let mut builder = engine
971 .oxi()
972 .agent(agent_config)
973 .workspace(&workspace)
974 .system_prompt(system_prompt);
975
976 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
986 .names()
987 .into_iter()
988 .filter_map(|name| registry.get(&name))
989 .collect();
990
991 if let Some(auth) = engine.authorizer() {
993 builder = builder.authorizer(auth.clone());
994 }
995 if let Some(tracer) = engine.tracer() {
996 builder = builder.tracer(tracer.clone());
997 }
998 if let Some(ct) = engine.cost_tracker() {
999 builder = builder.cost_tracker(ct.clone());
1000 }
1001
1002 if config.rate_limit_per_minute > 0 {
1005 builder = builder.with_rate_limit(config.rate_limit_per_minute);
1006 }
1007 if config.token_budget > 0 {
1008 builder = builder.with_token_budget(config.token_budget);
1009 }
1010 if config.audit_tool_calls {
1011 builder = builder.with_logging();
1012 }
1013
1014 let built = builder.build()?;
1015 let agent = Arc::new(built);
1016
1017 let agent_tools = agent.tools();
1022 for tool in cspace_tool_arcs {
1023 agent_tools.register_arc(tool);
1024 }
1025
1026 agent
1027 };
1028
1029 if let Some(state) = restore_state {
1033 agent.import_state(state.clone()).unwrap_or_else(|e| {
1034 tracing::warn!(agent_id = %agent_id, error = %e, "Failed to restore agent state");
1035 });
1036 }
1037
1038 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
1040 let exec_state_cb = Arc::clone(&exec_state);
1041 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
1042 let session_id_for_callback = exec_id.to_string();
1043 let model_id_for_callback = config.model_id.clone();
1044 let agent_id_for_callback = agent_id.to_string();
1045 let routing_stats_for_cb = routing_stats.clone();
1046 let transparency_session: Option<String> = session_id.clone();
1049 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1050 let streaming_sinks_for_cb: Arc<crate::streaming_sink::StreamingSinkRegistry> =
1054 Arc::clone(&kernel_handle.streaming_sinks);
1055 let result = agent
1057 .run_streaming(prompt, move |event| {
1058 let mut s = exec_state_cb.lock();
1059 match event {
1060 AgentEvent::ToolExecutionStart {
1061 tool_name,
1062 tool_call_id,
1063 args,
1064 context,
1065 ..
1066 } => {
1067 let idx = s.trajectory_steps.len();
1069 s.pending_tools
1070 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1071 s.tool_args_map.insert(
1072 tool_call_id.clone(),
1073 serde_json::to_string(&args).unwrap_or_default(),
1074 );
1075 s.tool_timestamps
1076 .insert(tool_call_id.clone(), chrono::Utc::now());
1077 s.tool_call_ids.push(tool_call_id.clone());
1078 s.trajectory_steps
1079 .push(oxios_memory::memory::sona::TrajectoryStep {
1080 input: tool_name.clone(),
1081 output: String::new(),
1082 duration_ms: 0,
1083 confidence: 0.0,
1084 });
1085 if let Some(ref sid) = transparency_session {
1087 let context_json = context
1088 .as_ref()
1089 .map(serde_json::to_value)
1090 .transpose()
1091 .unwrap_or(None);
1092 let _ =
1093 kernel_handle_for_cb
1094 .infra
1095 .publish(KernelEvent::ToolExecutionStarted {
1096 session_id: sid.clone(),
1097 tool_name: tool_name.clone(),
1098 tool_call_id: tool_call_id.clone(),
1099 tool_args: args.clone(),
1100 context: context_json,
1101 });
1102 }
1103 }
1104 AgentEvent::ToolExecutionUpdate {
1105 tool_call_id,
1106 tool_name,
1107 partial_result,
1108 tab_id,
1109 context,
1110 } => {
1111 if let Some(ref sid) = transparency_session {
1121 let context_json = context
1122 .as_ref()
1123 .map(serde_json::to_value)
1124 .transpose()
1125 .unwrap_or(None);
1126 let _ = kernel_handle_for_cb.infra.publish(
1127 KernelEvent::ToolExecutionProgress {
1128 session_id: sid.clone(),
1129 tool_call_id: tool_call_id.clone(),
1130 tool_name: tool_name.clone(),
1131 progress: partial_result,
1132 tab_id,
1133 context: context_json,
1134 },
1135 );
1136 }
1137 }
1138 AgentEvent::ToolExecutionEnd {
1139 tool_name,
1140 tool_call_id,
1141 is_error,
1142 result,
1143 ..
1144 } => {
1145 if !is_error {
1146 s.steps_completed += 1;
1147 }
1148 let mut duration_ms: u64 = 0;
1150 let mut summary = String::new();
1151 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1152 duration_ms = start.elapsed().as_millis() as u64;
1153 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1154 summary = summarize_tool_result(&result.content, 200);
1155 step.output = summary.clone();
1156 step.duration_ms = duration_ms;
1157 step.confidence = if is_error { 0.3 } else { 0.8 };
1158 }
1159 }
1160 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1161 if let Some(ref sid) = transparency_session {
1163 let _ = kernel_handle_for_cb.infra.publish(
1164 KernelEvent::ToolExecutionFinished {
1165 session_id: sid.clone(),
1166 tool_call_id: tool_call_id.clone(),
1167 tool_name: tool_name.clone(),
1168 duration_ms,
1169 is_error,
1170 output_summary: summary,
1171 },
1172 );
1173 }
1174 }
1175 AgentEvent::AgentEnd {
1176 messages,
1177 stop_reason,
1178 ..
1179 } => {
1180 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1181 s.final_content = a.text_content();
1182 }
1183 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1189 }
1190 AgentEvent::Error { message, .. } => {
1191 s.final_content = message.clone();
1192 s.success = false;
1193 }
1194 AgentEvent::Usage {
1195 input_tokens,
1196 output_tokens,
1197 } => {
1198 s.total_input_tokens += input_tokens as u64;
1200 s.total_output_tokens += output_tokens as u64;
1201
1202 let agent_label = format!("agent-{agent_id_for_callback}");
1204 crate::observability::cost_tracker().record(
1205 &agent_label,
1206 &oxi_sdk::Model::new(
1207 &model_id_for_callback,
1208 &model_id_for_callback,
1209 oxi_sdk::Api::OpenAiCompletions,
1210 "unknown",
1211 "https://unknown.com",
1212 ),
1213 oxi_sdk::TokenUsage {
1214 input: input_tokens as u64,
1215 output: output_tokens as u64,
1216 cache_read: 0,
1217 cache_write: 0,
1218 },
1219 );
1220
1221 if let Some(stats) = &routing_stats_for_cb {
1223 let cost = crate::kernel_handle::engine_api::estimate_cost(
1224 &model_id_for_callback,
1225 input_tokens as u64,
1226 output_tokens as u64,
1227 );
1228 stats.record_model_usage(&model_id_for_callback, cost);
1229 }
1230 if let Some(ref sid) = transparency_session {
1232 let _ = kernel_handle_for_cb
1233 .infra
1234 .publish(KernelEvent::TokenUsageUpdate {
1235 session_id: sid.clone(),
1236 input_tokens: input_tokens as u64,
1237 output_tokens: output_tokens as u64,
1238 });
1239 }
1240 }
1241 AgentEvent::Compaction {
1242 event: CompactionEvent::Completed { result, .. },
1243 } => {
1244 handle_compaction(
1245 result.summary.clone(),
1246 session_id_for_callback.clone(),
1247 memory_for_callback.clone(),
1248 );
1249 if let Some(ref sid) = transparency_session {
1251 let _ =
1252 kernel_handle_for_cb
1253 .infra
1254 .publish(KernelEvent::ReasoningFragment {
1255 session_id: sid.clone(),
1256 content: result.summary.clone(),
1257 source: "compaction".to_string(),
1258 });
1259 }
1260 }
1261 AgentEvent::TextChunk { text } => {
1262 if let Some(ref sid) = transparency_session
1276 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1277 {
1278 let _ = tx.send(StreamDelta::Text(text.clone()));
1279 }
1280 }
1281 AgentEvent::Thinking => {
1282 if let Some(ref sid) = transparency_session
1287 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1288 {
1289 let _ = tx.send(StreamDelta::Thinking);
1290 }
1291 }
1292 AgentEvent::ThinkingDelta { text } => {
1293 const REASONING_CAP: usize = 4096;
1307 if s.reasoning_text.len() < REASONING_CAP {
1308 s.reasoning_text.push_str(&text);
1309 if s.reasoning_text.len() > REASONING_CAP {
1310 s.reasoning_text.truncate(REASONING_CAP);
1311 }
1312 }
1313 if let Some(ref sid) = transparency_session
1314 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1315 {
1316 let _ = tx.send(StreamDelta::ThinkingDelta(text.clone()));
1317 }
1318 }
1319 _ => {}
1320 }
1321 })
1322 .await;
1323
1324 let circuit = get_llm_circuit_breaker();
1326 if result.is_err() {
1327 circuit.record_failure();
1328 crate::metrics::get_metrics()
1329 .llm_circuit_breaker_state
1330 .set(1.0);
1331 } else {
1332 circuit.record_success();
1333 crate::metrics::get_metrics()
1334 .llm_circuit_breaker_state
1335 .set(0.0);
1336 }
1337
1338 if let Err(e) = result {
1339 tracing::error!(exec_id = %exec_id, error = %e, "Agent failed");
1340 let restore_state = agent.export_state().ok();
1345 return Err(crate::resilience::AgentRunError::wrap(e, restore_state).into());
1346 }
1347
1348 let s = exec_state.lock();
1349 tracing::info!(
1350 exec_id = %exec_id,
1351 steps = s.steps_completed,
1352 success = s.success,
1353 "Agent completed"
1354 );
1355
1356 if !s.trajectory_steps.is_empty()
1359 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1360 {
1361 let steps = s.trajectory_steps.clone();
1362 let success = s.success;
1363 let sona = Arc::clone(sona);
1364 let domain = infer_domain(&goal);
1365 tokio::spawn(async move {
1366 let verdict = if success {
1367 oxios_memory::memory::sona::Verdict::Success
1368 } else {
1369 oxios_memory::memory::sona::Verdict::Failure
1370 };
1371 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1372 if let Err(e) = sona.record(trajectory).await {
1373 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1374 }
1375 });
1376 }
1377
1378 Ok((
1379 s.final_content.clone(),
1380 s.steps_completed,
1381 s.success,
1382 s.trajectory_steps.clone(),
1383 agent,
1384 s.tool_call_ids.clone(),
1385 s.tool_args_map.clone(),
1386 s.tool_error_map.clone(),
1387 s.tool_timestamps.clone(),
1388 s.total_input_tokens,
1389 s.total_output_tokens,
1390 s.reasoning_text.clone(),
1391 ))
1392}
1393
1394fn summarize_tool_result(result: &str, max_len: usize) -> String {
1399 let trimmed = result.trim();
1400 if trimmed.chars().count() <= max_len {
1401 return trimmed.to_string();
1402 }
1403 let first_line = trimmed.lines().next().unwrap_or("");
1405 if first_line.chars().count() <= max_len {
1406 first_line.to_string()
1407 } else {
1408 let take = max_len.saturating_sub(3);
1409 let truncated: String = if take == 0 {
1410 first_line.chars().take(max_len).collect()
1411 } else {
1412 first_line.chars().take(take).collect()
1413 };
1414 format!("{truncated}...")
1415 }
1416}
1417fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1418 if json_str.len() <= max_len {
1419 return json_str.to_string();
1420 }
1421 let take = max_len.saturating_sub(3);
1424 if take == 0 {
1425 return json_str.chars().take(max_len).collect();
1426 }
1427 let truncated: String = json_str.chars().take(take).collect();
1428 format!("{truncated}...")
1429}
1430
1431fn infer_domain(goal: &str) -> String {
1436 let lower = goal.to_lowercase();
1437 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1438
1439 if keywords.iter().any(|k| {
1441 [
1442 "test",
1443 "tests",
1444 "spec",
1445 "testing",
1446 "assert",
1447 "unit test",
1448 "integration",
1449 ]
1450 .contains(k)
1451 }) {
1452 return "testing".to_string();
1453 }
1454 if keywords
1455 .iter()
1456 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1457 {
1458 return "deployment".to_string();
1459 }
1460 if keywords
1461 .iter()
1462 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1463 {
1464 return "bugfix".to_string();
1465 }
1466 if keywords
1467 .iter()
1468 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1469 {
1470 return "refactoring".to_string();
1471 }
1472 if keywords
1473 .iter()
1474 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1475 {
1476 return "documentation".to_string();
1477 }
1478 if keywords
1479 .iter()
1480 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1481 {
1482 return "development".to_string();
1483 }
1484 if keywords
1485 .iter()
1486 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1487 {
1488 return "analysis".to_string();
1489 }
1490 if keywords
1491 .iter()
1492 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1493 {
1494 return "configuration".to_string();
1495 }
1496
1497 let meaningful: Vec<&str> = lower
1499 .split_whitespace()
1500 .filter(|w| w.len() > 2)
1501 .take(2)
1502 .collect();
1503 if meaningful.len() >= 2 {
1504 meaningful.join("_")
1505 } else {
1506 "general".to_string()
1507 }
1508}
1509
1510fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1516 let entry = MemoryEntry {
1517 id: uuid::Uuid::new_v4().to_string(),
1518 memory_type: MemoryType::Conversation,
1519 tier: crate::memory::MemoryTier::Warm,
1520 content: summary,
1521 content_hash: 0,
1522 source: "compaction".to_string(),
1523 session_id: Some(session_id),
1524 tags: vec![],
1525 importance: 0.5,
1526 pinned: false,
1527 protection: crate::memory::ProtectionLevel::None,
1528 auto_classified: false,
1529 session_appearances: 0,
1530 user_corrected: false,
1531 seen_in_sessions: vec![],
1532 created_at: chrono::Utc::now(),
1533 accessed_at: chrono::Utc::now(),
1534 modified_at: chrono::Utc::now(),
1535 access_count: 0,
1536 decay_score: 1.0,
1537 compaction_level: 0,
1538 compacted_from: vec![],
1539 related_ids: vec![],
1540 contradicts: None,
1541 };
1542 tokio::spawn(async move {
1543 if let Err(e) = memory_manager.remember(entry).await {
1544 tracing::warn!(error = %e, "Failed to save compaction summary");
1545 }
1546 });
1547}
1548
1549#[allow(dead_code)]
1554fn build_directive_system_prompt(
1555 directive: &Directive,
1556 env: &ExecEnv,
1557 persona_prompt: Option<&str>,
1558 capabilities_xml: Option<&str>,
1559 kernel_manifest: Option<&str>,
1560) -> String {
1561 build_system_prompt_inner(
1562 &directive.goal,
1563 &directive.original_request,
1564 &directive.constraints,
1565 &directive.acceptance_criteria,
1566 env.workspace_context.as_deref(),
1567 persona_prompt,
1568 capabilities_xml,
1569 kernel_manifest,
1570 )
1571}
1572
1573#[allow(clippy::too_many_arguments)]
1579fn build_system_prompt_inner(
1580 goal: &str,
1581 original_request: &str,
1582 constraints: &[String],
1583 acceptance_criteria: &[String],
1584 workspace_context: Option<&str>,
1585 persona_prompt: Option<&str>,
1586 capabilities_xml: Option<&str>,
1587 kernel_manifest: Option<&str>,
1588) -> String {
1589 let mut prompt = String::from(
1590 "You are an autonomous agent in the Oxios operating system.\n\
1591 You execute Seeds — immutable specifications with goals, constraints, and\n\
1592 acceptance criteria.\n\n\
1593 ## Available Tools\n\
1594 You have the following tools:\n\
1595 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1596 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1597 - **Exec**: run shell commands\n\
1598 - **Memory tools**: memory_read, memory_write, memory_search — agent's internal recall\n\
1599 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1600 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1601 **Important**: When the task involves fetching information from the internet,\n\
1602 websites, or online services, use `web_search` first — do NOT search local files.\n\
1603 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1604 from the web, use `web_search`.\n",
1605 );
1606 prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1607
1608 if !original_request.is_empty() && original_request != goal {
1611 prompt.push_str(&format!(
1612 "\n## User's Original Request\n{}\n",
1613 original_request
1614 ));
1615 }
1616
1617 if !constraints.is_empty() {
1618 prompt.push_str("\n## Constraints\n");
1619 for (i, c) in constraints.iter().enumerate() {
1620 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1621 }
1622 }
1623
1624 if !acceptance_criteria.is_empty() {
1625 prompt.push_str("\n## Acceptance Criteria\n");
1626 for (i, c) in acceptance_criteria.iter().enumerate() {
1627 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1628 }
1629 }
1630
1631 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1635 prompt.push_str("\n## Workspace Context\n");
1636 prompt.push_str(ctx);
1637 prompt.push('\n');
1638 }
1639
1640 if let Some(pp) = persona_prompt {
1642 prompt.push_str("\n## Persona\n");
1643 prompt.push_str(pp);
1644 prompt.push('\n');
1645 }
1646
1647 if let Some(xml) = capabilities_xml {
1649 prompt.push_str("\n## Available Capabilities\n");
1650 prompt.push_str("The following capabilities are relevant to your goal. ");
1651 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1652 prompt.push_str(xml);
1653 prompt.push('\n');
1654 }
1655
1656 if let Some(manifest) = kernel_manifest {
1658 prompt.push('\n');
1659 prompt.push_str(manifest);
1660 prompt.push('\n');
1661 }
1662
1663 prompt.push_str(
1665 "\n## Execution Protocol\n\
1666 1. UNDERSTAND — Read the user's request carefully. If it is a simple\n\
1667 greeting, small talk, or a question you can answer from knowledge,\n\
1668 respond naturally and conversationally — no tools needed.\n\
1669 2. PLAN — For complex tasks, outline your approach before acting.\n\
1670 3. EXECUTE — Use tools only when the task actually requires them.\n\
1671 Prefer the simplest approach. Simple requests need no tools.\n\
1672 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1673 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1674 If the request is ambiguous, use the `ask_user` tool (free-text question)\n\
1675 or the `pi-questionnaire` tool (structured choices) to clarify before\n\
1676 executing — do not guess when a single question would resolve the intent.\n\n\
1677 ## Hard Boundaries\n\
1678 - NEVER modify files outside the workspace scope\n\
1679 - NEVER execute destructive commands without confirming scope\n\
1680 - NEVER claim completion without evidence — show the output, not your opinion\n\
1681 - NEVER add features or improvements beyond the goal's scope\n\
1682 - If you cannot complete the task, say so and explain WHY\n\n\
1683 ## Scope Guard\n\
1684 The goal defines your universe. Do not:\n\
1685 - Refactor code the goal didn't mention\n\
1686 - Add tests the goal didn't require\n\
1687 - Change configuration the goal didn't specify\n\
1688 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1689 ## Error Handling\n\
1690 - If a tool fails, read the error message carefully before retrying\n\
1691 - If a command fails, do NOT immediately retry with --force or sudo\n\
1692 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1693 ## Shape Matching\n\
1694 Match your output to the task: simple task → concise response.\n\
1695 Do not write 50 lines when 5 would do.\n\
1696 Use `exec` for all command execution (git, gh, osascript, etc.).",
1697 );
1698
1699 prompt
1700}
1701#[allow(dead_code)]
1702fn build_directive_user_prompt(directive: &Directive) -> String {
1703 build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1704}
1705
1706fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1708 format!(
1709 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1710 goal,
1711 acceptance_criteria
1712 .iter()
1713 .enumerate()
1714 .map(|(i, c)| format!("{}. {}", i + 1, c))
1715 .collect::<Vec<_>>()
1716 .join("\n")
1717 )
1718}
1719
1720impl std::fmt::Debug for AgentRuntime {
1721 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1722 f.debug_struct("AgentRuntime")
1723 .field("model_id", &self.engine_handle.get().default_model_id())
1724 .finish()
1725 }
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730 use super::*;
1731 use async_trait::async_trait;
1732 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1733 use serde_json::Value;
1734
1735 struct DummyTool {
1737 name: String,
1738 }
1739
1740 #[async_trait]
1741 impl AgentTool for DummyTool {
1742 fn name(&self) -> &str {
1743 &self.name
1744 }
1745 fn label(&self) -> &str {
1746 &self.name
1747 }
1748 fn description(&self) -> &str {
1749 "Test tool"
1750 }
1751 fn parameters_schema(&self) -> Value {
1752 serde_json::json!({"type": "object"})
1753 }
1754
1755 async fn execute(
1756 &self,
1757 _tool_call_id: &str,
1758 _params: Value,
1759 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1760 _ctx: &ToolContext,
1761 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1762 Ok(oxi_sdk::AgentToolResult::success("ok"))
1763 }
1764 }
1765
1766 #[test]
1768 fn test_requires_tools_validation_passes() {
1769 let registry = ToolRegistry::new();
1770
1771 registry.register(DummyTool {
1772 name: "read".into(),
1773 });
1774 registry.register(DummyTool {
1775 name: "exec".into(),
1776 });
1777
1778 let missing = registry.missing(&["read", "exec"]);
1779
1780 assert!(
1781 missing.is_empty(),
1782 "Expected no missing tools, got: {:?}",
1783 missing
1784 );
1785 }
1786
1787 #[test]
1789 fn test_requires_tools_validation_fails() {
1790 let registry = ToolRegistry::new();
1791
1792 registry.register(DummyTool {
1793 name: "read".into(),
1794 });
1795
1796 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1797
1798 assert_eq!(missing, vec!["exec", "nonexistent"]);
1799 }
1800
1801 #[test]
1802 fn test_infer_domain_testing() {
1803 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1804 }
1805
1806 #[test]
1807 fn test_infer_domain_deployment() {
1808 assert_eq!(
1809 infer_domain("deploy the web service to production"),
1810 "deployment"
1811 );
1812 }
1813
1814 #[test]
1815 fn test_infer_domain_bugfix() {
1816 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1817 }
1818
1819 #[test]
1820 fn test_infer_domain_development() {
1821 assert_eq!(
1822 infer_domain("create a new REST API endpoint"),
1823 "development"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_infer_domain_analysis() {
1829 assert_eq!(
1830 infer_domain("review the code for security issues"),
1831 "analysis"
1832 );
1833 }
1834
1835 #[test]
1836 fn test_infer_domain_fallback() {
1837 let domain = infer_domain("optimize performance metrics");
1838 assert!(!domain.is_empty());
1840 }
1841}