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