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