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::Sender<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 pub max_tool_result_bytes: Option<usize>,
130 }
134
135impl Default for AgentRuntimeConfig {
136 fn default() -> Self {
137 Self {
138 model_id: String::new(),
139 tool_execution: ToolExecutionMode::Parallel,
140 auto_retry_enabled: true,
141 workspace_dir: None,
142 api_key: None,
143 provider_options: None,
144 rate_limit_per_minute: 0,
145 token_budget: 0,
146 audit_tool_calls: false,
147 provider_rpm: 0,
148 max_tool_result_bytes: None,
149 }
150 }
151}
152
153#[derive(Default)]
155struct ExecuteState {
156 final_content: String,
157 steps_completed: usize,
158 success: bool,
159 reasoning_text: String,
164 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
167 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
171 tool_call_ids: Vec<String>,
174 tool_args_map: std::collections::HashMap<String, String>,
176 tool_error_map: std::collections::HashMap<String, bool>,
178 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
180 total_input_tokens: u64,
182 total_output_tokens: u64,
184}
185
186pub struct AgentRuntime {
195 engine_handle: Arc<crate::engine::EngineHandle>,
196 config: AgentRuntimeConfig,
197 kernel_handle: Arc<KernelHandle>,
199 persona_manager: Option<Arc<PersonaManager>>,
201 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
203 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
205 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
207 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
209}
210
211impl AgentRuntime {
212 pub fn new(
218 engine_handle: Arc<crate::engine::EngineHandle>,
219 kernel_handle: Arc<KernelHandle>,
220 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
221 ) -> Self {
222 Self {
223 engine_handle,
224 config: AgentRuntimeConfig::default(),
225 kernel_handle,
226 persona_manager: None,
227 tool_retriever: None,
228 routing_stats,
229 persistence_hook: None,
230 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
231 }
232 }
233
234 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
236 self.persona_manager = Some(pm);
237 self
238 }
239
240 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
242 self.config = config;
243 self
244 }
245
246 pub fn with_tool_retriever(
248 mut self,
249 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
250 ) -> Self {
251 self.tool_retriever = Some(retriever);
252 self
253 }
254
255 pub fn with_persistence_hook(
257 mut self,
258 hook: Arc<crate::persistence_hook::PersistenceHook>,
259 ) -> Self {
260 self.persistence_hook = Some(hook);
261 self
262 }
263
264 pub async fn execute_directive(
270 &self,
271 agent_id: AgentId,
272 directive: &Directive,
273 env: &ExecEnv,
274 session_ctx: &mut SessionContext,
275 ) -> Result<ExecutionResult> {
276 let session_id: Option<String> = env
282 .session_id
283 .clone()
284 .or_else(|| Some(agent_id.to_string()));
285 self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
286 .await
287 }
288 pub async fn execute_directive_with_session(
291 &self,
292 agent_id: AgentId,
293 directive: &Directive,
294 env: &ExecEnv,
295 session_ctx: &mut SessionContext,
296 session_id: Option<String>,
297 ) -> Result<ExecutionResult> {
298 self.execute_inner(
299 agent_id,
300 &directive.goal,
301 &directive.original_request,
302 &directive.constraints,
303 &directive.acceptance_criteria,
304 env.cspace_hint.as_deref(),
305 &env.mount_paths,
306 env.workspace_context.as_deref(),
307 session_ctx,
308 session_id,
309 Some(directive),
310 env.model_override.as_deref(),
311 env.role.as_deref(),
312 env.restore_state.as_ref(),
313 )
314 .await
315 }
316
317 #[allow(clippy::too_many_arguments)]
324 async fn execute_inner(
325 &self,
326 agent_id: AgentId,
327 goal: &str,
328 original_request: &str,
329 constraints: &[String],
330 acceptance_criteria: &[String],
331 cspace_hint: Option<&str>,
332 mount_paths: &[std::path::PathBuf],
333 workspace_context: Option<&str>,
334 session_ctx: &mut SessionContext,
335 session_id: Option<String>,
336 persistence_directive: Option<&Directive>,
337 model_override: Option<&str>,
338 role: Option<&str>,
339 restore_state: Option<&serde_json::Value>,
340 ) -> Result<ExecutionResult> {
341 let prompt = build_user_prompt_inner(goal, acceptance_criteria);
342
343 let persona_prompt = self
345 .persona_manager
346 .as_ref()
347 .map(|pm| pm.active_system_prompt())
348 .filter(|s| !s.trim().is_empty());
349
350 let persona_role = self
352 .persona_manager
353 .as_ref()
354 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
355
356 let cspace = resolve_cspace(
358 cspace_hint,
359 persona_role.as_deref(),
360 Some("worker"),
361 agent_id,
362 );
363
364 let mut system_prompt = build_system_prompt_inner(
367 goal,
368 original_request,
369 constraints,
370 acceptance_criteria,
371 workspace_context,
372 persona_prompt.as_deref(),
373 None,
374 None,
375 );
376
377 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
379 match retriever.embedder().embed(goal).await {
380 Ok(query_vec) => {
381 let results = retriever.retrieve(&query_vec, 8);
382 if results.is_empty() {
383 None
384 } else {
385 let xml = crate::tools::retrieval::format_capability_index(&results);
386 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
387 Some(xml)
388 }
389 }
390 Err(e) => {
391 tracing::warn!(error = %e, "Failed to embed goal for retrieval");
392 None
393 }
394 }
395 } else {
396 None
397 };
398
399 let kernel_manifest = {
401 let domains = cspace.active_domains();
402 if domains.is_empty() {
403 None
404 } else {
405 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
406 }
407 };
408
409 if capabilities_xml.is_some() || kernel_manifest.is_some() {
411 system_prompt = build_system_prompt_inner(
412 goal,
413 original_request,
414 constraints,
415 acceptance_criteria,
416 workspace_context,
417 persona_prompt.as_deref(),
418 capabilities_xml.as_deref(),
419 kernel_manifest.as_deref(),
420 );
421 }
422
423 let memory_manager = self.kernel_handle.agents.memory_manager();
425 match memory_manager
426 .recall_with_proactive(goal, &mut session_ctx.recall_timing)
427 .await
428 {
429 Ok(memories) if !memories.is_empty() => {
430 tracing::info!(count = memories.len(), "Recalled memories for task");
431 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
432 }
433 Ok(_) => tracing::debug!("No memories recalled"),
434 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
435 }
436
437 if let Some(sona) = memory_manager.sona_engine() {
439 match sona.adapt(goal).await {
440 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
441 tracing::info!(
442 domain = %pattern.domain,
443 confidence = pattern.confidence,
444 "SONA learned pattern injected"
445 );
446 system_prompt.push_str(&format!(
447 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
448 pattern.confidence * 100.0,
449 pattern.strategy,
450 ));
451 }
452 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
453 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
454 }
455 }
456
457 match self
459 .kernel_handle
460 .knowledge_lens
461 .recall_for_context(goal, 5)
462 .await
463 {
464 Ok(ctx) if !ctx.notes.is_empty() => {
465 tracing::info!(
466 notes = ctx.notes.len(),
467 memories = ctx.memories.len(),
468 "Recalled knowledge context for task"
469 );
470 let knowledge_blend = ctx
471 .notes
472 .iter()
473 .take(3)
474 .map(|n| format!("## {}\n\n{}", n.name, n.content))
475 .collect::<Vec<_>>()
476 .join("\n\n");
477 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
478 system_prompt.push_str(&knowledge_blend);
479 }
480 Ok(_) => tracing::debug!("No knowledge recalled"),
481 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
482 }
483
484 let effective_role = role.or(persona_role.as_deref());
497 let engine = self.engine_handle.get();
498 let model_id = model_override
499 .map(|s| s.to_string())
500 .or_else(|| effective_role.and_then(|r| self.kernel_handle.engine.model_for_role(r)))
501 .unwrap_or_else(|| engine.default_model_id().to_string());
502 engine.resolve_model(&model_id)?;
504 let exec_id = uuid::Uuid::new_v4();
506
507 let mut config = self.config.clone();
512 config.model_id = model_id;
513 let kernel_handle = Arc::clone(&self.kernel_handle);
514
515 let audit_trail: Option<Arc<AuditTrail>> =
517 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
518
519 let (
520 mut final_content,
521 steps_completed,
522 success,
523 trajectory_steps,
524 agent,
525 tool_call_ids,
526 tool_args_map,
527 tool_error_map,
528 tool_timestamps,
529 total_input_tokens,
530 total_output_tokens,
531 reasoning_text,
532 ) = {
533 run_agent(
534 &config,
535 &engine,
536 kernel_handle,
537 system_prompt,
538 prompt,
539 exec_id,
540 goal.to_string(),
541 agent_id,
542 cspace,
543 audit_trail,
544 self.routing_stats.clone(),
545 session_id.clone(),
546 mount_paths,
547 restore_state,
548 )
549 .await?
550 };
551
552 if final_content.is_empty() && !trajectory_steps.is_empty() {
559 let tool_summary: Vec<String> = trajectory_steps
560 .iter()
561 .enumerate()
562 .map(|(i, step)| {
563 let truncated = if step.output.len() > 800 {
564 let mut end = 800;
568 while end > 0 && !step.output.is_char_boundary(end) {
569 end -= 1;
570 }
571 format!("{}...", &step.output[..end])
572 } else {
573 step.output.clone()
574 };
575 format!("{}. [{}] {}", i + 1, step.input, truncated)
576 })
577 .collect();
578 let summary_prompt = format!(
579 "도구 실행 결과:\n\n{}\n\n\
580 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
581 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
582 tool_summary.join("\n")
583 );
584 match agent.run(summary_prompt).await {
585 Ok((response, _events)) => {
586 if !response.content.is_empty() {
587 tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
588 final_content = response.content;
589 }
590 }
591 Err(e) => {
592 tracing::warn!(error = %e, "Post-execution summary failed");
593 }
594 }
595 }
596
597 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
600 .iter()
601 .enumerate()
602 .map(|(i, step)| {
603 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
604 let args_str = tool_call_ids
605 .get(i)
606 .and_then(|id| tool_args_map.get(id))
607 .cloned()
608 .unwrap_or_default();
609 let is_error = tool_call_ids
610 .get(i)
611 .and_then(|id| tool_error_map.get(id))
612 .copied()
613 .unwrap_or(false);
614 let timestamp = tool_call_ids
615 .get(i)
616 .and_then(|id| tool_timestamps.get(id))
617 .copied();
618 let input_str = truncate_json_str(&args_str, 500);
619 oxios_ouroboros::ToolCallRecord {
620 tool: step.input.clone(),
621 input: input_str,
622 output: step.output.clone(),
623 duration_ms: step.duration_ms,
624 is_error,
625 tool_call_id: tc_id,
626 timestamp,
627 }
628 })
629 .collect();
630
631 tracing::info!(
632 exec_id = %exec_id,
633 steps = steps_completed,
634 success,
635 tool_calls = tool_calls.len(),
636 "AgentRuntime finished"
637 );
638
639 let result = ExecutionResult {
640 output: final_content.clone(),
641 steps_completed,
642 success,
643 tool_calls,
644 failure_class: None,
645 restore_state: None,
646 tokens_input: total_input_tokens,
647 tokens_output: total_output_tokens,
648 model_id: self.engine_handle.get().default_model_id().to_string(),
649 reasoning_text,
650 };
651
652 if let Some(directive) = persistence_directive
655 && success
656 && let Some(hook) = &self.persistence_hook
657 {
658 let already_saved_knowledge = trajectory_steps
659 .iter()
660 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
661 let hook = hook.clone();
662 let directive_clone = directive.clone();
663 let traj_clone = trajectory_steps.clone();
664 let output_clone = final_content.clone();
665 let sid = session_id.clone();
666 let msg_index = {
669 let mut counter = self.session_msg_counter.lock();
670 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
671 let current = *idx;
672 *idx += 1;
673 current
674 };
675 tokio::spawn(async move {
676 match hook
677 .evaluate(
678 &directive_clone,
679 &traj_clone,
680 &output_clone,
681 already_saved_knowledge,
682 )
683 .await
684 {
685 Ok(plan) => {
686 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
687 tracing::info!(
688 memory = plan.memory.len(),
689 knowledge = plan.knowledge.len(),
690 message_index = msg_index,
691 "PersistenceHook executing plan"
692 );
693 let session_id = sid.unwrap_or_default();
694 hook.execute_plan(plan, &session_id, msg_index).await;
695 }
696 }
697 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
698 }
699 });
700 }
701
702 Ok(result)
703 }
704}
705
706#[allow(clippy::too_many_arguments)]
711async fn run_agent(
712 config: &AgentRuntimeConfig,
713 engine: &OxiosEngine,
714 kernel_handle: Arc<KernelHandle>,
715 system_prompt: String,
716 prompt: String,
717 exec_id: uuid::Uuid,
718 goal: String,
719 agent_id: AgentId,
720 cspace: crate::capability::CSpace,
721 audit_trail: Option<Arc<AuditTrail>>,
722 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
723 session_id: Option<String>,
724 mount_paths: &[std::path::PathBuf],
725 restore_state: Option<&serde_json::Value>,
726) -> Result<(
727 String,
728 usize,
729 bool,
730 Vec<oxios_memory::memory::sona::TrajectoryStep>,
731 Arc<Agent>,
732 Vec<String>,
733 std::collections::HashMap<String, String>,
734 std::collections::HashMap<String, bool>,
735 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
736 u64,
737 u64,
738 String,
739)> {
740 let workspace = if !mount_paths.is_empty() {
746 mount_paths[0].clone()
747 } else if let Some(ws) = &config.workspace_dir {
748 ws.clone()
749 } else {
750 std::env::temp_dir()
751 .join("oxios-agent-workspace")
752 .join(agent_id.to_string())
753 };
754
755 let _ = std::fs::create_dir_all(&workspace);
757
758 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
759
760 {
777 use crate::access_manager::{Role, Subject};
778 let agent_name = format!("agent-{agent_id}");
779 let mut am = kernel_handle.exec.access_manager().lock();
780 let perms = am.get_or_create_permissions(&agent_name);
781
782 if let Ok(cwd) = std::env::current_dir() {
784 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
785 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
786 perms.allow_path(&cwd_pattern);
787 tracing::debug!(
788 agent = %agent_name,
789 path = %cwd_pattern,
790 "Added CWD to agent allowed paths"
791 );
792 }
793 }
794
795 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
797 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
798 perms.allow_path(&ws_pattern);
799 }
800
801 for mount_path in mount_paths {
806 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
807 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
808 perms.allow_path(&pattern);
809 tracing::debug!(
810 agent = %agent_name,
811 path = %pattern,
812 "Added Mount path to agent allowed paths (RFC-025)"
813 );
814 }
815 }
816
817 let kernel_ws = kernel_handle
819 .state
820 .workspace_path()
821 .to_string_lossy()
822 .to_string();
823 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
824 if kernel_ws_pattern != ws_pattern
825 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
826 {
827 perms.allow_path(&kernel_ws_pattern);
828 }
829
830 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
832 perms.allow_path("/tmp/**");
833 }
834
835 let rbac_subject = Subject::Agent(agent_id);
837 am.rbac_manager_mut()
838 .assign_role(rbac_subject, Role::Superuser);
839 }
840
841 let _trace_guard = crate::observability::tracer().start(
843 format!("exec-{}", &exec_id.to_string()[..8]).as_str(),
844 oxi_sdk::SpanKind::Agent,
845 );
846
847 let registry = ToolRegistry::new();
849 let search_cache = Arc::new(SearchCache::new());
850
851 let agent_context = AgentContext {
853 agent_id,
854 agent_name: format!("agent-{agent_id}"),
855 cspace: Arc::new(cspace.clone()),
856 };
857
858 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
861 let audit_path = kernel_handle
862 .state
863 .workspace_path()
864 .join("audit")
865 .join("access.jsonl");
866 Arc::new(TrailAuditSink::new(trail, audit_path))
867 } else {
868 Arc::new(TracingAuditSink)
869 };
870
871 let access_gate = Arc::new(AccessGate::new(
873 kernel_handle.exec.access_manager().clone(),
874 Arc::new(kernel_handle.exec.config_snapshot()),
875 audit_sink,
876 ));
877
878 register_tools_from_cspace_gated(
879 ®istry,
880 &kernel_handle,
881 &cspace,
882 search_cache,
883 agent_id,
884 access_gate,
885 agent_context,
886 );
887
888 tracing::info!(
889 exec_id = %exec_id,
890 capabilities = cspace.len(),
891 "Tools registered from CSpace"
892 );
893
894 let agent_config = AgentConfig {
902 name: format!("agent-{agent_id}"),
903 description: None,
904 model_id: config.model_id.clone(),
905 system_prompt: Some(system_prompt.clone()),
906 timeout_seconds: 300,
907 temperature: Some(0.7),
908 max_tokens: Some(8192),
909 compaction_strategy: CompactionStrategy::Threshold(0.8),
910 compaction_instruction: None,
911 context_window: 128_000,
912 api_key: config.api_key.clone(),
913 workspace_dir: Some(workspace.clone()),
914 output_mode: None,
915 provider_options: config.provider_options.clone(),
916 session_id: None,
922 max_tool_result_bytes: config.max_tool_result_bytes,
924 subagent_depth: 0,
929 subagent_runner: Some(
932 crate::subagent_runner::OxiosSubagentRunner::new(engine.oxi().clone())
933 .into_trait_object(),
934 ),
935 ..Default::default()
936 };
937
938 let agent = if config.provider_rpm > 0 {
953 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
955 let provider_name = engine.resolve_model(&config.model_id)?.provider;
956 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
957
958 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
960 if config.rate_limit_per_minute > 0 {
961 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
962 config.rate_limit_per_minute,
963 ));
964 }
965 if config.token_budget > 0 {
966 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
967 config.token_budget,
968 ));
969 }
970 if config.audit_tool_calls {
971 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
972 tracing::Level::INFO,
973 ));
974 }
975
976 let agent = Arc::new(Agent::new_with_resolver(
978 provider,
979 agent_config,
980 Arc::new(registry),
981 resolver,
982 ));
983
984 if !pipeline.is_empty() {
986 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
987 let agent_id_for_hooks = agent_id.to_string();
988 let hooks = oxi_sdk::middleware::build_hooks(
989 Arc::new(pipeline),
990 agent_id_for_hooks,
991 terminate_flag,
992 );
993 agent.set_hooks(hooks);
994 }
995
996 agent
997 } else {
998 let mut builder = engine
1000 .oxi()
1001 .agent(agent_config)
1002 .workspace(&workspace)
1003 .system_prompt(system_prompt);
1004
1005 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
1015 .names()
1016 .into_iter()
1017 .filter_map(|name| registry.get(&name))
1018 .collect();
1019
1020 if let Some(auth) = engine.authorizer() {
1022 builder = builder.authorizer(auth.clone());
1023 }
1024 if let Some(tracer) = engine.tracer() {
1025 builder = builder.tracer(tracer.clone());
1026 }
1027 if let Some(ct) = engine.cost_tracker() {
1028 builder = builder.cost_tracker(ct.clone());
1029 }
1030
1031 if config.rate_limit_per_minute > 0 {
1034 builder = builder.with_rate_limit(config.rate_limit_per_minute);
1035 }
1036 if config.token_budget > 0 {
1037 builder = builder.with_token_budget(config.token_budget);
1038 }
1039 if config.audit_tool_calls {
1040 builder = builder.with_logging();
1041 }
1042
1043 let built = builder.build()?;
1044 let agent = Arc::new(built);
1045
1046 let agent_tools = agent.tools();
1051 for tool in cspace_tool_arcs {
1052 agent_tools.register_arc(tool);
1053 }
1054
1055 agent
1056 };
1057
1058 if let Some(state) = restore_state {
1062 agent.import_state(state.clone()).unwrap_or_else(|e| {
1063 tracing::warn!(agent_id = %agent_id, error = %e, "Failed to restore agent state");
1064 });
1065 }
1066
1067 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
1069 let exec_state_cb = Arc::clone(&exec_state);
1070 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
1071 let session_id_for_callback = exec_id.to_string();
1072 let model_id_for_callback = config.model_id.clone();
1073 let agent_id_for_callback = agent_id.to_string();
1074 let routing_stats_for_cb = routing_stats.clone();
1075 let transparency_session: Option<String> = session_id.clone();
1078 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1079 let streaming_sinks_for_cb: Arc<crate::streaming_sink::StreamingSinkRegistry> =
1083 Arc::clone(&kernel_handle.streaming_sinks);
1084 let mut sent_model_for_cb: bool = false;
1086 let result =
1087 agent
1088 .run_streaming(prompt, move |event| {
1089 if !sent_model_for_cb
1090 && let Some(ref sid) = transparency_session
1091 && !model_id_for_callback.is_empty()
1092 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1093 {
1094 let _ = tx.try_send(StreamDelta::Model(model_id_for_callback.clone()));
1095 sent_model_for_cb = true;
1096 }
1097 let mut s = exec_state_cb.lock();
1098 match event {
1099 AgentEvent::ToolExecutionStart {
1100 tool_name,
1101 tool_call_id,
1102 args,
1103 context,
1104 ..
1105 } => {
1106 let idx = s.trajectory_steps.len();
1108 s.pending_tools
1109 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1110 s.tool_args_map.insert(
1111 tool_call_id.clone(),
1112 serde_json::to_string(&args).unwrap_or_default(),
1113 );
1114 s.tool_timestamps
1115 .insert(tool_call_id.clone(), chrono::Utc::now());
1116 s.tool_call_ids.push(tool_call_id.clone());
1117 s.trajectory_steps
1118 .push(oxios_memory::memory::sona::TrajectoryStep {
1119 input: tool_name.clone(),
1120 output: String::new(),
1121 duration_ms: 0,
1122 confidence: 0.0,
1123 });
1124 if let Some(ref sid) = transparency_session {
1126 let context_json = context
1127 .as_ref()
1128 .map(serde_json::to_value)
1129 .transpose()
1130 .unwrap_or(None);
1131 let _ = kernel_handle_for_cb.infra.publish(
1132 KernelEvent::ToolExecutionStarted {
1133 session_id: sid.clone(),
1134 tool_name: tool_name.clone(),
1135 tool_call_id: tool_call_id.clone(),
1136 tool_args: args.clone(),
1137 context: context_json,
1138 },
1139 );
1140 }
1141 }
1142 AgentEvent::ToolExecutionUpdate {
1143 tool_call_id,
1144 tool_name,
1145 partial_result,
1146 tab_id,
1147 context,
1148 } => {
1149 if let Some(ref sid) = transparency_session {
1159 let context_json = context
1160 .as_ref()
1161 .map(serde_json::to_value)
1162 .transpose()
1163 .unwrap_or(None);
1164 let _ = kernel_handle_for_cb.infra.publish(
1165 KernelEvent::ToolExecutionProgress {
1166 session_id: sid.clone(),
1167 tool_call_id: tool_call_id.clone(),
1168 tool_name: tool_name.clone(),
1169 progress: partial_result,
1170 tab_id,
1171 context: context_json,
1172 },
1173 );
1174 }
1175 }
1176 AgentEvent::ToolExecutionEnd {
1177 tool_name,
1178 tool_call_id,
1179 is_error,
1180 result,
1181 ..
1182 } => {
1183 if !is_error {
1184 s.steps_completed += 1;
1185 }
1186 let mut duration_ms: u64 = 0;
1188 let mut summary = String::new();
1189 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1190 duration_ms = start.elapsed().as_millis() as u64;
1191 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1192 summary = summarize_tool_result(&result.content, 200);
1193 step.output = summary.clone();
1194 step.duration_ms = duration_ms;
1195 step.confidence = if is_error { 0.3 } else { 0.8 };
1196 }
1197 }
1198 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1199 if let Some(ref sid) = transparency_session {
1201 let _ = kernel_handle_for_cb.infra.publish(
1202 KernelEvent::ToolExecutionFinished {
1203 session_id: sid.clone(),
1204 tool_call_id: tool_call_id.clone(),
1205 tool_name: tool_name.clone(),
1206 duration_ms,
1207 is_error,
1208 output_summary: summary,
1209 },
1210 );
1211 }
1212 }
1213 AgentEvent::AgentEnd {
1214 messages,
1215 stop_reason,
1216 ..
1217 } => {
1218 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1219 s.final_content = a.text_content();
1220 }
1221 s.success =
1227 matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1228 }
1229 AgentEvent::Error { message, .. } => {
1230 s.final_content = message.clone();
1231 s.success = false;
1232 }
1233 AgentEvent::Usage {
1234 input_tokens,
1235 output_tokens,
1236 } => {
1237 s.total_input_tokens += input_tokens as u64;
1239 s.total_output_tokens += output_tokens as u64;
1240
1241 let agent_label = format!("agent-{agent_id_for_callback}");
1243 crate::observability::cost_tracker().record(
1244 &agent_label,
1245 &oxi_sdk::Model::new(
1246 &model_id_for_callback,
1247 &model_id_for_callback,
1248 oxi_sdk::Api::OpenAiCompletions,
1249 "unknown",
1250 "https://unknown.com",
1251 ),
1252 oxi_sdk::TokenUsage {
1253 input: input_tokens as u64,
1254 output: output_tokens as u64,
1255 cache_read: 0,
1256 cache_write: 0,
1257 },
1258 );
1259
1260 if let Some(stats) = &routing_stats_for_cb {
1262 let cost = crate::kernel_handle::engine_api::estimate_cost(
1263 &model_id_for_callback,
1264 input_tokens as u64,
1265 output_tokens as u64,
1266 );
1267 stats.record_model_usage(&model_id_for_callback, cost);
1268 }
1269 if let Some(ref sid) = transparency_session {
1271 let _ =
1272 kernel_handle_for_cb
1273 .infra
1274 .publish(KernelEvent::TokenUsageUpdate {
1275 session_id: sid.clone(),
1276 input_tokens: input_tokens as u64,
1277 output_tokens: output_tokens as u64,
1278 });
1279 }
1280 }
1281 AgentEvent::Compaction {
1282 event: CompactionEvent::Completed { result, .. },
1283 } => {
1284 handle_compaction(
1285 result.summary.clone(),
1286 session_id_for_callback.clone(),
1287 memory_for_callback.clone(),
1288 );
1289 if let Some(ref sid) = transparency_session {
1291 let _ = kernel_handle_for_cb.infra.publish(
1292 KernelEvent::ReasoningFragment {
1293 session_id: sid.clone(),
1294 content: result.summary.clone(),
1295 source: "compaction".to_string(),
1296 },
1297 );
1298 }
1299 }
1300 AgentEvent::Compaction {
1301 event: CompactionEvent::Triggered { source, .. },
1302 } => {
1303 if let Some(ref sid) = transparency_session {
1309 let _ = kernel_handle_for_cb.infra.publish(
1310 KernelEvent::CompactionTriggered {
1311 session_id: Some(sid.clone()),
1312 source,
1313 },
1314 );
1315 } else {
1316 let _ = kernel_handle_for_cb.infra.publish(
1317 KernelEvent::CompactionTriggered {
1318 session_id: None,
1319 source,
1320 },
1321 );
1322 }
1323 }
1324 AgentEvent::TextChunk { text } => {
1325 if let Some(ref sid) = transparency_session
1339 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1340 {
1341 let _ = tx.try_send(StreamDelta::Text(text.clone()));
1342 }
1343 }
1344 AgentEvent::Thinking => {
1345 if let Some(ref sid) = transparency_session
1350 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1351 {
1352 let _ = tx.try_send(StreamDelta::Thinking);
1353 }
1354 }
1355 AgentEvent::ThinkingDelta { text } => {
1356 const REASONING_CAP: usize = 4096;
1370 if s.reasoning_text.len() < REASONING_CAP {
1371 s.reasoning_text.push_str(&text);
1372 if s.reasoning_text.len() > REASONING_CAP {
1373 s.reasoning_text.truncate(REASONING_CAP);
1374 }
1375 }
1376 if let Some(ref sid) = transparency_session
1377 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1378 {
1379 let _ = tx.try_send(StreamDelta::ThinkingDelta(text.clone()));
1380 }
1381 }
1382 _ => {}
1383 }
1384 })
1385 .await;
1386
1387 let circuit = get_llm_circuit_breaker();
1389 if result.is_err() {
1390 circuit.record_failure();
1391 crate::metrics::get_metrics()
1392 .llm_circuit_breaker_state
1393 .set(1.0);
1394 } else {
1395 circuit.record_success();
1396 crate::metrics::get_metrics()
1397 .llm_circuit_breaker_state
1398 .set(0.0);
1399 }
1400
1401 if let Err(e) = result {
1402 tracing::error!(exec_id = %exec_id, error = %e, "Agent failed");
1403 let restore_state = agent.export_state().ok();
1408 return Err(crate::resilience::AgentRunError::wrap(e, restore_state).into());
1409 }
1410
1411 let s = exec_state.lock();
1412 tracing::info!(
1413 exec_id = %exec_id,
1414 steps = s.steps_completed,
1415 success = s.success,
1416 "Agent completed"
1417 );
1418
1419 if !s.trajectory_steps.is_empty()
1422 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1423 {
1424 let steps = s.trajectory_steps.clone();
1425 let success = s.success;
1426 let sona = Arc::clone(sona);
1427 let domain = infer_domain(&goal);
1428 tokio::spawn(async move {
1429 let verdict = if success {
1430 oxios_memory::memory::sona::Verdict::Success
1431 } else {
1432 oxios_memory::memory::sona::Verdict::Failure
1433 };
1434 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1435 if let Err(e) = sona.record(trajectory).await {
1436 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1437 }
1438 });
1439 }
1440
1441 Ok((
1442 s.final_content.clone(),
1443 s.steps_completed,
1444 s.success,
1445 s.trajectory_steps.clone(),
1446 agent,
1447 s.tool_call_ids.clone(),
1448 s.tool_args_map.clone(),
1449 s.tool_error_map.clone(),
1450 s.tool_timestamps.clone(),
1451 s.total_input_tokens,
1452 s.total_output_tokens,
1453 s.reasoning_text.clone(),
1454 ))
1455}
1456
1457fn summarize_tool_result(result: &str, max_len: usize) -> String {
1462 let trimmed = result.trim();
1463 if trimmed.chars().count() <= max_len {
1464 return trimmed.to_string();
1465 }
1466 let first_line = trimmed.lines().next().unwrap_or("");
1468 if first_line.chars().count() <= max_len {
1469 first_line.to_string()
1470 } else {
1471 let take = max_len.saturating_sub(3);
1472 let truncated: String = if take == 0 {
1473 first_line.chars().take(max_len).collect()
1474 } else {
1475 first_line.chars().take(take).collect()
1476 };
1477 format!("{truncated}...")
1478 }
1479}
1480fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1481 if json_str.len() <= max_len {
1482 return json_str.to_string();
1483 }
1484 let take = max_len.saturating_sub(3);
1487 if take == 0 {
1488 return json_str.chars().take(max_len).collect();
1489 }
1490 let truncated: String = json_str.chars().take(take).collect();
1491 format!("{truncated}...")
1492}
1493
1494fn infer_domain(goal: &str) -> String {
1499 let lower = goal.to_lowercase();
1500 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1501
1502 if keywords.iter().any(|k| {
1504 [
1505 "test",
1506 "tests",
1507 "spec",
1508 "testing",
1509 "assert",
1510 "unit test",
1511 "integration",
1512 ]
1513 .contains(k)
1514 }) {
1515 return "testing".to_string();
1516 }
1517 if keywords
1518 .iter()
1519 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1520 {
1521 return "deployment".to_string();
1522 }
1523 if keywords
1524 .iter()
1525 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1526 {
1527 return "bugfix".to_string();
1528 }
1529 if keywords
1530 .iter()
1531 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1532 {
1533 return "refactoring".to_string();
1534 }
1535 if keywords
1536 .iter()
1537 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1538 {
1539 return "documentation".to_string();
1540 }
1541 if keywords
1542 .iter()
1543 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1544 {
1545 return "development".to_string();
1546 }
1547 if keywords
1548 .iter()
1549 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1550 {
1551 return "analysis".to_string();
1552 }
1553 if keywords
1554 .iter()
1555 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1556 {
1557 return "configuration".to_string();
1558 }
1559
1560 let meaningful: Vec<&str> = lower
1562 .split_whitespace()
1563 .filter(|w| w.len() > 2)
1564 .take(2)
1565 .collect();
1566 if meaningful.len() >= 2 {
1567 meaningful.join("_")
1568 } else {
1569 "general".to_string()
1570 }
1571}
1572
1573fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1579 let entry = MemoryEntry {
1580 id: uuid::Uuid::new_v4().to_string(),
1581 memory_type: MemoryType::Conversation,
1582 tier: crate::memory::MemoryTier::Warm,
1583 content: summary,
1584 content_hash: 0,
1585 source: "compaction".to_string(),
1586 session_id: Some(session_id),
1587 tags: vec![],
1588 importance: 0.5,
1589 pinned: false,
1590 protection: crate::memory::ProtectionLevel::None,
1591 auto_classified: false,
1592 session_appearances: 0,
1593 user_corrected: false,
1594 seen_in_sessions: vec![],
1595 created_at: chrono::Utc::now(),
1596 accessed_at: chrono::Utc::now(),
1597 modified_at: chrono::Utc::now(),
1598 access_count: 0,
1599 decay_score: 1.0,
1600 compaction_level: 0,
1601 compacted_from: vec![],
1602 related_ids: vec![],
1603 contradicts: None,
1604 };
1605 tokio::spawn(async move {
1606 if let Err(e) = memory_manager.remember(entry).await {
1607 tracing::warn!(error = %e, "Failed to save compaction summary");
1608 }
1609 });
1610}
1611
1612#[allow(dead_code)]
1617fn build_directive_system_prompt(
1618 directive: &Directive,
1619 env: &ExecEnv,
1620 persona_prompt: Option<&str>,
1621 capabilities_xml: Option<&str>,
1622 kernel_manifest: Option<&str>,
1623) -> String {
1624 build_system_prompt_inner(
1625 &directive.goal,
1626 &directive.original_request,
1627 &directive.constraints,
1628 &directive.acceptance_criteria,
1629 env.workspace_context.as_deref(),
1630 persona_prompt,
1631 capabilities_xml,
1632 kernel_manifest,
1633 )
1634}
1635
1636#[allow(clippy::too_many_arguments)]
1642fn build_system_prompt_inner(
1643 goal: &str,
1644 original_request: &str,
1645 constraints: &[String],
1646 acceptance_criteria: &[String],
1647 workspace_context: Option<&str>,
1648 persona_prompt: Option<&str>,
1649 capabilities_xml: Option<&str>,
1650 kernel_manifest: Option<&str>,
1651) -> String {
1652 let mut prompt = String::from(
1653 "You are an autonomous agent in the Oxios operating system.\n\
1654 You execute Seeds — immutable specifications with goals, constraints, and\n\
1655 acceptance criteria.\n\n\
1656 ## Available Tools\n\
1657 You have the following tools:\n\
1658 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1659 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1660 - **Exec**: run shell commands\n\
1661 - **Memory tools**: memory_write (store facts/preferences), memory_read (list entries), memory_search (find relevant memories) — your cross-session recall. Use memory_write proactively when the user shares preferences, facts, or corrections worth remembering.
1662 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1663 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1664 **Important**: When the task involves fetching information from the internet,\n\
1665 websites, or online services, use `web_search` first — do NOT search local files.\n\
1666 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1667 from the web, use `web_search`.\n",
1668 );
1669 prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1670
1671 if !original_request.is_empty() && original_request != goal {
1674 prompt.push_str(&format!(
1675 "\n## User's Original Request\n{}\n",
1676 original_request
1677 ));
1678 }
1679
1680 if !constraints.is_empty() {
1681 prompt.push_str("\n## Constraints\n");
1682 for (i, c) in constraints.iter().enumerate() {
1683 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1684 }
1685 }
1686
1687 if !acceptance_criteria.is_empty() {
1688 prompt.push_str("\n## Acceptance Criteria\n");
1689 for (i, c) in acceptance_criteria.iter().enumerate() {
1690 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1691 }
1692 }
1693
1694 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1698 prompt.push_str("\n## Workspace Context\n");
1699 prompt.push_str(ctx);
1700 prompt.push('\n');
1701 }
1702
1703 if let Some(pp) = persona_prompt {
1705 prompt.push_str("\n## Persona\n");
1706 prompt.push_str(pp);
1707 prompt.push('\n');
1708 }
1709
1710 if let Some(xml) = capabilities_xml {
1712 prompt.push_str("\n## Available Capabilities\n");
1713 prompt.push_str("The following capabilities are relevant to your goal. ");
1714 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1715 prompt.push_str(xml);
1716 prompt.push('\n');
1717 }
1718
1719 if let Some(manifest) = kernel_manifest {
1721 prompt.push('\n');
1722 prompt.push_str(manifest);
1723 prompt.push('\n');
1724 }
1725
1726 prompt.push_str(
1728 "\n## Execution Protocol\n\
1729 1. UNDERSTAND — Read the user's request carefully. If it is a simple\n\
1730 greeting, small talk, or a question you can answer from knowledge,\n\
1731 respond naturally and conversationally — no tools needed.\n\
1732 2. PLAN — For complex tasks, outline your approach before acting.\n\
1733 3. EXECUTE — Use tools only when the task actually requires them.\n\
1734 Prefer the simplest approach. Simple requests need no tools.\n\
1735 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1736 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1737 If the request is ambiguous, use the `ask_user` tool (free-text question)\n\
1738 or the `pi-questionnaire` tool (structured choices) to clarify before\n\
1739 executing — do not guess when a single question would resolve the intent.\n\n\
1740 ## Hard Boundaries\n\
1741 - NEVER modify files outside the workspace scope\n\
1742 - NEVER execute destructive commands without confirming scope\n\
1743 - NEVER claim completion without evidence — show the output, not your opinion\n\
1744 - NEVER add features or improvements beyond the goal's scope\n\
1745 - If you cannot complete the task, say so and explain WHY\n\n\
1746 ## Scope Guard\n\
1747 The goal defines your universe. Do not:\n\
1748 - Refactor code the goal didn't mention\n\
1749 - Add tests the goal didn't require\n\
1750 - Change configuration the goal didn't specify\n\
1751 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1752 ## Error Handling\n\
1753 - If a tool fails, read the error message carefully before retrying\n\
1754 - If a command fails, do NOT immediately retry with --force or sudo\n\
1755 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1756 ## Shape Matching\n\
1757 Match your output to the task: simple task → concise response.\n\
1758 Do not write 50 lines when 5 would do.\n\
1759 Use `exec` for all command execution (git, gh, osascript, etc.).",
1760 );
1761
1762 prompt
1763}
1764#[allow(dead_code)]
1765fn build_directive_user_prompt(directive: &Directive) -> String {
1766 build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1767}
1768
1769fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1771 format!(
1772 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1773 goal,
1774 acceptance_criteria
1775 .iter()
1776 .enumerate()
1777 .map(|(i, c)| format!("{}. {}", i + 1, c))
1778 .collect::<Vec<_>>()
1779 .join("\n")
1780 )
1781}
1782
1783impl std::fmt::Debug for AgentRuntime {
1784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1785 f.debug_struct("AgentRuntime")
1786 .field("model_id", &self.engine_handle.get().default_model_id())
1787 .finish()
1788 }
1789}
1790
1791#[cfg(test)]
1792mod tests {
1793 use super::*;
1794 use async_trait::async_trait;
1795 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1796 use serde_json::Value;
1797
1798 struct DummyTool {
1800 name: String,
1801 }
1802
1803 #[async_trait]
1804 impl AgentTool for DummyTool {
1805 fn name(&self) -> &str {
1806 &self.name
1807 }
1808 fn label(&self) -> &str {
1809 &self.name
1810 }
1811 fn description(&self) -> &str {
1812 "Test tool"
1813 }
1814 fn parameters_schema(&self) -> Value {
1815 serde_json::json!({"type": "object"})
1816 }
1817
1818 async fn execute(
1819 &self,
1820 _tool_call_id: &str,
1821 _params: Value,
1822 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1823 _ctx: &ToolContext,
1824 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1825 Ok(oxi_sdk::AgentToolResult::success("ok"))
1826 }
1827 }
1828
1829 #[test]
1831 fn test_requires_tools_validation_passes() {
1832 let registry = ToolRegistry::new();
1833
1834 registry.register(DummyTool {
1835 name: "read".into(),
1836 });
1837 registry.register(DummyTool {
1838 name: "exec".into(),
1839 });
1840
1841 let missing = registry.missing(&["read", "exec"]);
1842
1843 assert!(
1844 missing.is_empty(),
1845 "Expected no missing tools, got: {:?}",
1846 missing
1847 );
1848 }
1849
1850 #[test]
1852 fn test_requires_tools_validation_fails() {
1853 let registry = ToolRegistry::new();
1854
1855 registry.register(DummyTool {
1856 name: "read".into(),
1857 });
1858
1859 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1860
1861 assert_eq!(missing, vec!["exec", "nonexistent"]);
1862 }
1863
1864 #[test]
1865 fn test_infer_domain_testing() {
1866 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1867 }
1868
1869 #[test]
1870 fn test_infer_domain_deployment() {
1871 assert_eq!(
1872 infer_domain("deploy the web service to production"),
1873 "deployment"
1874 );
1875 }
1876
1877 #[test]
1878 fn test_infer_domain_bugfix() {
1879 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1880 }
1881
1882 #[test]
1883 fn test_infer_domain_development() {
1884 assert_eq!(
1885 infer_domain("create a new REST API endpoint"),
1886 "development"
1887 );
1888 }
1889
1890 #[test]
1891 fn test_infer_domain_analysis() {
1892 assert_eq!(
1893 infer_domain("review the code for security issues"),
1894 "analysis"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_infer_domain_fallback() {
1900 let domain = infer_domain("optimize performance metrics");
1901 assert!(!domain.is_empty());
1903 }
1904}