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::{ExecutionResult, Seed};
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)]
71pub struct AgentRuntimeConfig {
72 pub model_id: String,
74 pub tool_execution: ToolExecutionMode,
76 pub auto_retry_enabled: bool,
78 pub project_paths: Vec<std::path::PathBuf>,
80 pub workspace_dir: Option<std::path::PathBuf>,
82 pub api_key: Option<String>,
84 pub provider_options: Option<oxi_sdk::ProviderOptions>,
86 pub rate_limit_per_minute: usize,
88 pub token_budget: usize,
90 pub audit_tool_calls: bool,
92 pub provider_rpm: u32,
95}
96
97impl Default for AgentRuntimeConfig {
98 fn default() -> Self {
99 Self {
100 model_id: String::new(),
101 tool_execution: ToolExecutionMode::Parallel,
102 auto_retry_enabled: true,
103 project_paths: Vec::new(),
104 workspace_dir: None,
105 api_key: None,
106 provider_options: None,
107 rate_limit_per_minute: 0,
108 token_budget: 0,
109 audit_tool_calls: false,
110 provider_rpm: 0,
111 }
112 }
113}
114
115#[derive(Default)]
117struct ExecuteState {
118 final_content: String,
119 steps_completed: usize,
120 success: bool,
121 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
125 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
129 tool_call_ids: Vec<String>,
132 tool_args_map: std::collections::HashMap<String, String>,
134 tool_error_map: std::collections::HashMap<String, bool>,
136 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
138 total_input_tokens: u64,
140 total_output_tokens: u64,
142}
143
144pub struct AgentRuntime {
153 engine_handle: Arc<crate::engine::EngineHandle>,
154 config: AgentRuntimeConfig,
155 kernel_handle: Arc<KernelHandle>,
157 persona_manager: Option<Arc<PersonaManager>>,
159 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
161 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
163 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
165 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
167}
168
169impl AgentRuntime {
170 pub fn new(
176 engine_handle: Arc<crate::engine::EngineHandle>,
177 kernel_handle: Arc<KernelHandle>,
178 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
179 ) -> Self {
180 Self {
181 engine_handle,
182 config: AgentRuntimeConfig::default(),
183 kernel_handle,
184 persona_manager: None,
185 tool_retriever: None,
186 routing_stats,
187 persistence_hook: None,
188 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
189 }
190 }
191
192 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
194 self.persona_manager = Some(pm);
195 self
196 }
197
198 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
200 self.config = config;
201 self
202 }
203
204 pub fn with_tool_retriever(
206 mut self,
207 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
208 ) -> Self {
209 self.tool_retriever = Some(retriever);
210 self
211 }
212
213 pub fn with_persistence_hook(
215 mut self,
216 hook: Arc<crate::persistence_hook::PersistenceHook>,
217 ) -> Self {
218 self.persistence_hook = Some(hook);
219 self
220 }
221
222 pub async fn execute(
230 &self,
231 agent_id: AgentId,
232 seed: &Seed,
233 session_ctx: &mut SessionContext,
234 ) -> Result<ExecutionResult> {
235 let session_id: Option<String> = Some(seed.id.to_string());
239 self.execute_with_session(agent_id, seed, session_ctx, session_id)
240 .await
241 }
242
243 pub async fn execute_with_session(
246 &self,
247 agent_id: AgentId,
248 seed: &Seed,
249 session_ctx: &mut SessionContext,
250 session_id: Option<String>,
251 ) -> Result<ExecutionResult> {
252 let prompt = build_user_prompt(seed);
253
254 let persona_prompt = self
256 .persona_manager
257 .as_ref()
258 .map(|pm| pm.active_system_prompt())
259 .filter(|s| !s.trim().is_empty());
260
261 let persona_role = self
263 .persona_manager
264 .as_ref()
265 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
266
267 let cspace = resolve_cspace(
269 seed.cspace_hint.as_deref(),
270 persona_role.as_deref(),
271 Some("worker"),
272 agent_id,
273 );
274
275 let mut system_prompt = build_system_prompt(
278 seed,
279 persona_prompt.as_deref(),
280 None,
281 None,
282 seed.workspace_context.as_deref(),
283 );
284
285 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
287 match retriever.embedder().embed(&seed.goal).await {
288 Ok(query_vec) => {
289 let results = retriever.retrieve(&query_vec, 8);
290 if results.is_empty() {
291 None
292 } else {
293 let xml = crate::tools::retrieval::format_capability_index(&results);
294 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
295 Some(xml)
296 }
297 }
298 Err(e) => {
299 tracing::warn!(error = %e, "Failed to embed seed goal for retrieval");
300 None
301 }
302 }
303 } else {
304 None
305 };
306
307 let kernel_manifest = {
309 let domains = cspace.active_domains();
310 if domains.is_empty() {
311 None
312 } else {
313 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
314 }
315 };
316
317 if capabilities_xml.is_some() || kernel_manifest.is_some() {
319 system_prompt = build_system_prompt(
320 seed,
321 persona_prompt.as_deref(),
322 capabilities_xml.as_deref(),
323 kernel_manifest.as_deref(),
324 seed.workspace_context.as_deref(),
325 );
326 }
327
328 let memory_manager = self.kernel_handle.agents.memory_manager();
330 match memory_manager
331 .recall_with_proactive(&seed.goal, &mut session_ctx.recall_timing)
332 .await
333 {
334 Ok(memories) if !memories.is_empty() => {
335 tracing::info!(count = memories.len(), "Recalled memories for seed");
336 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
337 }
338 Ok(_) => tracing::debug!("No memories recalled"),
339 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
340 }
341
342 if let Some(sona) = memory_manager.sona_engine() {
344 match sona.adapt(&seed.goal).await {
345 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
346 tracing::info!(
347 domain = %pattern.domain,
348 confidence = pattern.confidence,
349 "SONA learned pattern injected"
350 );
351 system_prompt.push_str(&format!(
352 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
353 pattern.confidence * 100.0,
354 pattern.strategy,
355 ));
356 }
357 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
358 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
359 }
360 }
361
362 match self
364 .kernel_handle
365 .knowledge_lens
366 .recall_for_context(&seed.goal, 5)
367 .await
368 {
369 Ok(ctx) if !ctx.notes.is_empty() => {
370 tracing::info!(
371 notes = ctx.notes.len(),
372 memories = ctx.memories.len(),
373 "Recalled knowledge context for seed"
374 );
375 let knowledge_blend = ctx
376 .notes
377 .iter()
378 .take(3)
379 .map(|n| format!("## {}\n\n{}", n.name, n.content))
380 .collect::<Vec<_>>()
381 .join("\n\n");
382 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
383 system_prompt.push_str(&knowledge_blend);
384 }
385 Ok(_) => tracing::debug!("No knowledge recalled"),
386 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
387 }
388
389 let engine = self.engine_handle.get();
394 let model_id = engine.default_model_id().to_string();
395 engine.resolve_model(&model_id)?;
396 let seed_id = seed.id;
397
398 let mut config = self.config.clone();
403 config.model_id = model_id;
404 let kernel_handle = Arc::clone(&self.kernel_handle);
405
406 let audit_trail: Option<Arc<AuditTrail>> =
408 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
409
410 let (
411 mut final_content,
412 steps_completed,
413 success,
414 trajectory_steps,
415 agent,
416 tool_call_ids,
417 tool_args_map,
418 tool_error_map,
419 tool_timestamps,
420 total_input_tokens,
421 total_output_tokens,
422 ) = {
423 run_agent(
424 &config,
425 &engine,
426 kernel_handle,
427 system_prompt,
428 prompt,
429 seed_id,
430 seed.goal.clone(),
431 agent_id,
432 cspace,
433 audit_trail,
434 self.routing_stats.clone(),
435 session_id.clone(),
436 &seed.mount_paths,
437 )
438 .await?
439 };
440
441 if final_content.is_empty() && !trajectory_steps.is_empty() {
448 let tool_summary: Vec<String> = trajectory_steps
449 .iter()
450 .enumerate()
451 .map(|(i, step)| {
452 let truncated = if step.output.len() > 800 {
453 format!("{}...", &step.output[..800])
454 } else {
455 step.output.clone()
456 };
457 format!("{}. [{}] {}", i + 1, step.input, truncated)
458 })
459 .collect();
460 let summary_prompt = format!(
461 "도구 실행 결과:\n\n{}\n\n\
462 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
463 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
464 tool_summary.join("\n")
465 );
466 match agent.run(summary_prompt).await {
467 Ok((response, _events)) => {
468 if !response.content.is_empty() {
469 tracing::info!(seed_id = %seed_id, "Post-execution summary generated");
470 final_content = response.content;
471 }
472 }
473 Err(e) => {
474 tracing::warn!(error = %e, "Post-execution summary failed");
475 }
476 }
477 }
478
479 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
482 .iter()
483 .enumerate()
484 .map(|(i, step)| {
485 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
486 let args_str = tool_call_ids
487 .get(i)
488 .and_then(|id| tool_args_map.get(id))
489 .cloned()
490 .unwrap_or_default();
491 let is_error = tool_call_ids
492 .get(i)
493 .and_then(|id| tool_error_map.get(id))
494 .copied()
495 .unwrap_or(false);
496 let timestamp = tool_call_ids
497 .get(i)
498 .and_then(|id| tool_timestamps.get(id))
499 .copied();
500 let input_str = truncate_json_str(&args_str, 500);
501 oxios_ouroboros::ToolCallRecord {
502 tool: step.input.clone(),
503 input: input_str,
504 output: step.output.clone(),
505 duration_ms: step.duration_ms,
506 is_error,
507 tool_call_id: tc_id,
508 timestamp,
509 }
510 })
511 .collect();
512
513 tracing::info!(
514 seed_id = %seed_id,
515 steps = steps_completed,
516 success,
517 tool_calls = tool_calls.len(),
518 "AgentRuntime finished"
519 );
520
521 let result = ExecutionResult {
522 output: final_content.clone(),
523 steps_completed,
524 success,
525 tool_calls,
526 tokens_input: total_input_tokens,
527 tokens_output: total_output_tokens,
528 model_id: self.engine_handle.get().default_model_id().to_string(),
529 };
530
531 if success && let Some(hook) = &self.persistence_hook {
534 let already_saved_knowledge = trajectory_steps
535 .iter()
536 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
537 let hook = hook.clone();
538 let seed_clone = seed.clone();
539 let traj_clone = trajectory_steps.clone();
540 let output_clone = final_content.clone();
541 let sid = session_id.clone();
542 let msg_index = {
545 let mut counter = self.session_msg_counter.lock();
546 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
547 let current = *idx;
548 *idx += 1;
549 current
550 };
551 tokio::spawn(async move {
552 match hook
553 .evaluate(
554 &seed_clone,
555 &traj_clone,
556 &output_clone,
557 already_saved_knowledge,
558 )
559 .await
560 {
561 Ok(plan) => {
562 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
563 tracing::info!(
564 memory = plan.memory.len(),
565 knowledge = plan.knowledge.len(),
566 message_index = msg_index,
567 "PersistenceHook executing plan"
568 );
569 let session_id = sid.unwrap_or_default();
570 hook.execute_plan(plan, &session_id, msg_index).await;
571 }
572 }
573 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
574 }
575 });
576 }
577
578 Ok(result)
579 }
580}
581
582#[allow(clippy::too_many_arguments)]
587async fn run_agent(
588 config: &AgentRuntimeConfig,
589 engine: &OxiosEngine,
590 kernel_handle: Arc<KernelHandle>,
591 system_prompt: String,
592 prompt: String,
593 seed_id: uuid::Uuid,
594 seed_goal: String,
595 agent_id: AgentId,
596 cspace: crate::capability::CSpace,
597 audit_trail: Option<Arc<AuditTrail>>,
598 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
599 session_id: Option<String>,
600 mount_paths: &[std::path::PathBuf],
601) -> Result<(
602 String,
603 usize,
604 bool,
605 Vec<oxios_memory::memory::sona::TrajectoryStep>,
606 Arc<Agent>,
607 Vec<String>,
608 std::collections::HashMap<String, String>,
609 std::collections::HashMap<String, bool>,
610 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
611 u64,
612 u64,
613)> {
614 let workspace = if !mount_paths.is_empty() {
618 mount_paths[0].clone()
619 } else if !config.project_paths.is_empty() {
620 config.project_paths[0].clone()
621 } else if let Some(ref ws) = config.workspace_dir {
622 ws.clone()
623 } else {
624 std::env::temp_dir()
625 .join("oxios-agent-workspace")
626 .join(agent_id.to_string())
627 };
628
629 let _ = std::fs::create_dir_all(&workspace);
631
632 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
633
634 {
651 use crate::access_manager::{Role, Subject};
652 let agent_name = format!("agent-{agent_id}");
653 let mut am = kernel_handle.exec.access_manager().lock();
654 let perms = am.get_or_create_permissions(&agent_name);
655
656 if let Ok(cwd) = std::env::current_dir() {
658 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
659 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
660 perms.allow_path(&cwd_pattern);
661 tracing::debug!(
662 agent = %agent_name,
663 path = %cwd_pattern,
664 "Added CWD to agent allowed paths"
665 );
666 }
667 }
668
669 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
671 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
672 perms.allow_path(&ws_pattern);
673 }
674
675 for mount_path in mount_paths {
680 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
681 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
682 perms.allow_path(&pattern);
683 tracing::debug!(
684 agent = %agent_name,
685 path = %pattern,
686 "Added Mount path to agent allowed paths (RFC-025)"
687 );
688 }
689 }
690
691 let kernel_ws = kernel_handle
693 .state
694 .workspace_path()
695 .to_string_lossy()
696 .to_string();
697 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
698 if kernel_ws_pattern != ws_pattern
699 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
700 {
701 perms.allow_path(&kernel_ws_pattern);
702 }
703
704 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
706 perms.allow_path("/tmp/**");
707 }
708
709 let rbac_subject = Subject::Agent(agent_id);
711 am.rbac_manager_mut()
712 .assign_role(rbac_subject, Role::Superuser);
713 }
714
715 let _trace_guard = crate::observability::tracer().start(
717 format!("seed-{}", &seed_id.to_string()[..8]).as_str(),
718 oxi_sdk::SpanKind::Agent,
719 );
720
721 let registry = ToolRegistry::new();
723 let search_cache = Arc::new(SearchCache::new());
724
725 let agent_context = AgentContext {
727 agent_id,
728 agent_name: format!("agent-{agent_id}"),
729 cspace: Arc::new(cspace.clone()),
730 };
731
732 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
735 let audit_path = kernel_handle
736 .state
737 .workspace_path()
738 .join("audit")
739 .join("access.jsonl");
740 Arc::new(TrailAuditSink::new(trail, audit_path))
741 } else {
742 Arc::new(TracingAuditSink)
743 };
744
745 let access_gate = Arc::new(AccessGate::new(
747 kernel_handle.exec.access_manager().clone(),
748 Arc::new(kernel_handle.exec.config_snapshot()),
749 audit_sink,
750 ));
751
752 register_tools_from_cspace_gated(
753 ®istry,
754 &kernel_handle,
755 &cspace,
756 search_cache,
757 agent_id,
758 access_gate,
759 agent_context,
760 );
761
762 tracing::info!(
763 seed_id = %seed_id,
764 capabilities = cspace.len(),
765 "Tools registered from CSpace"
766 );
767
768 let agent_config = AgentConfig {
776 name: format!("agent-{agent_id}"),
777 description: None,
778 model_id: config.model_id.clone(),
779 system_prompt: Some(system_prompt.clone()),
780 timeout_seconds: 300,
781 temperature: Some(0.7),
782 max_tokens: Some(8192),
783 compaction_strategy: CompactionStrategy::Threshold(0.8),
784 compaction_instruction: None,
785 context_window: 128_000,
786 api_key: config.api_key.clone(),
787 workspace_dir: Some(workspace.clone()),
788 output_mode: None,
789 provider_options: config.provider_options.clone(),
790 session_id: None,
796 };
797
798 let agent = if config.provider_rpm > 0 {
813 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
815 let provider_name = engine.resolve_model(&config.model_id)?.provider;
816 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
817
818 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
820 if config.rate_limit_per_minute > 0 {
821 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
822 config.rate_limit_per_minute,
823 ));
824 }
825 if config.token_budget > 0 {
826 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
827 config.token_budget,
828 ));
829 }
830 if config.audit_tool_calls {
831 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
832 tracing::Level::INFO,
833 ));
834 }
835
836 let agent = Arc::new(Agent::new_with_resolver(
838 provider,
839 agent_config,
840 Arc::new(registry),
841 resolver,
842 ));
843
844 if !pipeline.is_empty() {
846 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
847 let agent_id_for_hooks = agent_id.to_string();
848 let hooks = oxi_sdk::middleware::build_hooks(
849 Arc::new(pipeline),
850 agent_id_for_hooks,
851 terminate_flag,
852 );
853 agent.set_hooks(hooks);
854 }
855
856 agent
857 } else {
858 let mut builder = engine
860 .oxi()
861 .agent(agent_config)
862 .workspace(&workspace)
863 .system_prompt(system_prompt);
864
865 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
875 .names()
876 .into_iter()
877 .filter_map(|name| registry.get(&name))
878 .collect();
879
880 if let Some(auth) = engine.authorizer() {
882 builder = builder.authorizer(auth.clone());
883 }
884 if let Some(tracer) = engine.tracer() {
885 builder = builder.tracer(tracer.clone());
886 }
887 if let Some(ct) = engine.cost_tracker() {
888 builder = builder.cost_tracker(ct.clone());
889 }
890
891 if config.rate_limit_per_minute > 0 {
894 builder = builder.with_rate_limit(config.rate_limit_per_minute);
895 }
896 if config.token_budget > 0 {
897 builder = builder.with_token_budget(config.token_budget);
898 }
899 if config.audit_tool_calls {
900 builder = builder.with_logging();
901 }
902
903 let built = builder.build()?;
904 let agent = Arc::new(built);
905
906 let agent_tools = agent.tools();
911 for tool in cspace_tool_arcs {
912 agent_tools.register_arc(tool);
913 }
914
915 agent
916 };
917
918 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
920 let exec_state_cb = Arc::clone(&exec_state);
921 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
922 let session_id_for_callback = seed_id.to_string();
923 let model_id_for_callback = config.model_id.clone();
924 let agent_id_for_callback = agent_id.to_string();
925 let routing_stats_for_cb = routing_stats.clone();
926 let transparency_session: Option<String> = session_id.clone();
929 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
930
931 let result = agent
933 .run_streaming(prompt, move |event| {
934 let mut s = exec_state_cb.lock();
935 match event {
936 AgentEvent::ToolExecutionStart {
937 tool_name,
938 tool_call_id,
939 args,
940 context,
941 ..
942 } => {
943 let idx = s.trajectory_steps.len();
945 s.pending_tools
946 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
947 s.tool_args_map.insert(
948 tool_call_id.clone(),
949 serde_json::to_string(&args).unwrap_or_default(),
950 );
951 s.tool_timestamps
952 .insert(tool_call_id.clone(), chrono::Utc::now());
953 s.tool_call_ids.push(tool_call_id.clone());
954 s.trajectory_steps
955 .push(oxios_memory::memory::sona::TrajectoryStep {
956 input: tool_name.clone(),
957 output: String::new(),
958 duration_ms: 0,
959 confidence: 0.0,
960 });
961 if let Some(ref sid) = transparency_session {
963 let context_json = context
964 .as_ref()
965 .map(serde_json::to_value)
966 .transpose()
967 .unwrap_or(None);
968 let _ =
969 kernel_handle_for_cb
970 .infra
971 .publish(KernelEvent::ToolExecutionStarted {
972 session_id: sid.clone(),
973 tool_name: tool_name.clone(),
974 tool_call_id: tool_call_id.clone(),
975 tool_args: args.clone(),
976 context: context_json,
977 });
978 }
979 }
980 AgentEvent::ToolExecutionUpdate {
981 tool_call_id,
982 tool_name,
983 partial_result,
984 tab_id,
985 context,
986 } => {
987 if let Some(ref sid) = transparency_session {
997 let context_json = context
998 .as_ref()
999 .map(serde_json::to_value)
1000 .transpose()
1001 .unwrap_or(None);
1002 let _ = kernel_handle_for_cb.infra.publish(
1003 KernelEvent::ToolExecutionProgress {
1004 session_id: sid.clone(),
1005 tool_call_id: tool_call_id.clone(),
1006 tool_name: tool_name.clone(),
1007 progress: partial_result,
1008 tab_id,
1009 context: context_json,
1010 },
1011 );
1012 }
1013 }
1014 AgentEvent::ToolExecutionEnd {
1015 tool_name,
1016 tool_call_id,
1017 is_error,
1018 result,
1019 ..
1020 } => {
1021 if !is_error {
1022 s.steps_completed += 1;
1023 }
1024 let mut duration_ms: u64 = 0;
1026 let mut summary = String::new();
1027 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1028 duration_ms = start.elapsed().as_millis() as u64;
1029 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1030 summary = summarize_tool_result(&result.content, 200);
1031 step.output = summary.clone();
1032 step.duration_ms = duration_ms;
1033 step.confidence = if is_error { 0.3 } else { 0.8 };
1034 }
1035 }
1036 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1037 if let Some(ref sid) = transparency_session {
1039 let _ = kernel_handle_for_cb.infra.publish(
1040 KernelEvent::ToolExecutionFinished {
1041 session_id: sid.clone(),
1042 tool_call_id: tool_call_id.clone(),
1043 tool_name: tool_name.clone(),
1044 duration_ms,
1045 is_error,
1046 output_summary: summary,
1047 },
1048 );
1049 }
1050 }
1051 AgentEvent::AgentEnd {
1052 messages,
1053 stop_reason,
1054 ..
1055 } => {
1056 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1057 s.final_content = a.text_content();
1058 }
1059 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1065 }
1066 AgentEvent::Error { message, .. } => {
1067 s.final_content = message.clone();
1068 s.success = false;
1069 }
1070 AgentEvent::Usage {
1071 input_tokens,
1072 output_tokens,
1073 } => {
1074 s.total_input_tokens += input_tokens as u64;
1076 s.total_output_tokens += output_tokens as u64;
1077
1078 let agent_label = format!("agent-{agent_id_for_callback}");
1080 crate::observability::cost_tracker().record(
1081 &agent_label,
1082 &oxi_sdk::Model::new(
1083 &model_id_for_callback,
1084 &model_id_for_callback,
1085 oxi_sdk::Api::OpenAiCompletions,
1086 "unknown",
1087 "https://unknown.com",
1088 ),
1089 oxi_sdk::TokenUsage {
1090 input: input_tokens as u64,
1091 output: output_tokens as u64,
1092 cache_read: 0,
1093 cache_write: 0,
1094 },
1095 );
1096
1097 if let Some(stats) = &routing_stats_for_cb {
1099 let cost = crate::kernel_handle::engine_api::estimate_cost(
1100 &model_id_for_callback,
1101 input_tokens as u64,
1102 output_tokens as u64,
1103 );
1104 stats.record_model_usage(&model_id_for_callback, cost);
1105 }
1106 if let Some(ref sid) = transparency_session {
1108 let _ = kernel_handle_for_cb
1109 .infra
1110 .publish(KernelEvent::TokenUsageUpdate {
1111 session_id: sid.clone(),
1112 input_tokens: input_tokens as u64,
1113 output_tokens: output_tokens as u64,
1114 });
1115 }
1116 }
1117 AgentEvent::Compaction {
1118 event: CompactionEvent::Completed { result, .. },
1119 } => {
1120 handle_compaction(
1121 result.summary.clone(),
1122 session_id_for_callback.clone(),
1123 memory_for_callback.clone(),
1124 );
1125 if let Some(ref sid) = transparency_session {
1127 let _ =
1128 kernel_handle_for_cb
1129 .infra
1130 .publish(KernelEvent::ReasoningFragment {
1131 session_id: sid.clone(),
1132 content: result.summary.clone(),
1133 source: "compaction".to_string(),
1134 });
1135 }
1136 }
1137 _ => {}
1138 }
1139 })
1140 .await;
1141
1142 let circuit = get_llm_circuit_breaker();
1144 if result.is_err() {
1145 circuit.record_failure();
1146 crate::metrics::get_metrics()
1147 .llm_circuit_breaker_state
1148 .set(1.0);
1149 } else {
1150 circuit.record_success();
1151 crate::metrics::get_metrics()
1152 .llm_circuit_breaker_state
1153 .set(0.0);
1154 }
1155
1156 if let Err(e) = result {
1157 tracing::error!(seed_id = %seed_id, error = %e, "Agent failed");
1158 let s = exec_state.lock();
1159 return Ok((
1160 format!("Agent failed: {e}"),
1161 s.steps_completed,
1162 false,
1163 s.trajectory_steps.clone(),
1164 agent,
1165 s.tool_call_ids.clone(),
1166 s.tool_args_map.clone(),
1167 s.tool_error_map.clone(),
1168 s.tool_timestamps.clone(),
1169 s.total_input_tokens,
1170 s.total_output_tokens,
1171 ));
1172 }
1173
1174 let s = exec_state.lock();
1175 tracing::info!(
1176 seed_id = %seed_id,
1177 steps = s.steps_completed,
1178 success = s.success,
1179 "Agent completed"
1180 );
1181
1182 if !s.trajectory_steps.is_empty()
1185 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1186 {
1187 let steps = s.trajectory_steps.clone();
1188 let success = s.success;
1189 let sona = Arc::clone(sona);
1190 let domain = infer_domain(&seed_goal);
1191 tokio::spawn(async move {
1192 let verdict = if success {
1193 oxios_memory::memory::sona::Verdict::Success
1194 } else {
1195 oxios_memory::memory::sona::Verdict::Failure
1196 };
1197 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1198 if let Err(e) = sona.record(trajectory).await {
1199 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1200 }
1201 });
1202 }
1203
1204 Ok((
1205 s.final_content.clone(),
1206 s.steps_completed,
1207 s.success,
1208 s.trajectory_steps.clone(),
1209 agent,
1210 s.tool_call_ids.clone(),
1211 s.tool_args_map.clone(),
1212 s.tool_error_map.clone(),
1213 s.tool_timestamps.clone(),
1214 s.total_input_tokens,
1215 s.total_output_tokens,
1216 ))
1217}
1218
1219fn summarize_tool_result(result: &str, max_len: usize) -> String {
1224 let trimmed = result.trim();
1225 if trimmed.chars().count() <= max_len {
1226 return trimmed.to_string();
1227 }
1228 let first_line = trimmed.lines().next().unwrap_or("");
1230 if first_line.chars().count() <= max_len {
1231 first_line.to_string()
1232 } else {
1233 let truncated: String = first_line.chars().take(max_len - 3).collect();
1234 format!("{truncated}...")
1235 }
1236}
1237
1238fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1242 if json_str.len() <= max_len {
1243 return json_str.to_string();
1244 }
1245 let truncated: String = json_str.chars().take(max_len - 3).collect();
1246 format!("{truncated}...")
1247}
1248
1249fn infer_domain(goal: &str) -> String {
1254 let lower = goal.to_lowercase();
1255 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1256
1257 if keywords.iter().any(|k| {
1259 [
1260 "test",
1261 "tests",
1262 "spec",
1263 "testing",
1264 "assert",
1265 "unit test",
1266 "integration",
1267 ]
1268 .contains(k)
1269 }) {
1270 return "testing".to_string();
1271 }
1272 if keywords
1273 .iter()
1274 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1275 {
1276 return "deployment".to_string();
1277 }
1278 if keywords
1279 .iter()
1280 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1281 {
1282 return "bugfix".to_string();
1283 }
1284 if keywords
1285 .iter()
1286 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1287 {
1288 return "refactoring".to_string();
1289 }
1290 if keywords
1291 .iter()
1292 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1293 {
1294 return "documentation".to_string();
1295 }
1296 if keywords
1297 .iter()
1298 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1299 {
1300 return "development".to_string();
1301 }
1302 if keywords
1303 .iter()
1304 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1305 {
1306 return "analysis".to_string();
1307 }
1308 if keywords
1309 .iter()
1310 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1311 {
1312 return "configuration".to_string();
1313 }
1314
1315 let meaningful: Vec<&str> = lower
1317 .split_whitespace()
1318 .filter(|w| w.len() > 2)
1319 .take(2)
1320 .collect();
1321 if meaningful.len() >= 2 {
1322 meaningful.join("_")
1323 } else {
1324 "general".to_string()
1325 }
1326}
1327
1328fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1334 let entry = MemoryEntry {
1335 id: uuid::Uuid::new_v4().to_string(),
1336 memory_type: MemoryType::Conversation,
1337 tier: crate::memory::MemoryTier::Warm,
1338 content: summary,
1339 content_hash: 0,
1340 source: "compaction".to_string(),
1341 session_id: Some(session_id),
1342 tags: vec![],
1343 importance: 0.5,
1344 pinned: false,
1345 protection: crate::memory::ProtectionLevel::None,
1346 auto_classified: false,
1347 session_appearances: 0,
1348 user_corrected: false,
1349 seen_in_sessions: vec![],
1350 created_at: chrono::Utc::now(),
1351 accessed_at: chrono::Utc::now(),
1352 modified_at: chrono::Utc::now(),
1353 access_count: 0,
1354 decay_score: 1.0,
1355 compaction_level: 0,
1356 compacted_from: vec![],
1357 related_ids: vec![],
1358 contradicts: None,
1359 };
1360 tokio::spawn(async move {
1361 if let Err(e) = memory_manager.remember(entry).await {
1362 tracing::warn!(error = %e, "Failed to save compaction summary");
1363 }
1364 });
1365}
1366
1367fn build_system_prompt(
1373 seed: &Seed,
1374 persona_prompt: Option<&str>,
1375 capabilities_xml: Option<&str>,
1376 kernel_manifest: Option<&str>,
1377 workspace_context: Option<&str>,
1378) -> String {
1379 let mut prompt = String::from(
1380 "You are an autonomous agent in the Oxios operating system.\n\
1381 You execute Seeds — immutable specifications with goals, constraints, and\n\
1382 acceptance criteria.\n\n\
1383 ## Available Tools\n\
1384 You have the following tools:\n\
1385 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1386 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1387 - **Exec**: run shell commands\n\
1388 - **Memory tools**: memory_read, memory_write, memory_search — agent's internal recall\n\
1389 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1390 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1391 **Important**: When the task involves fetching information from the internet,\n\
1392 websites, or online services, use `web_search` first — do NOT search local files.\n\
1393 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1394 from the web, use `web_search`.\n",
1395 );
1396 prompt.push_str(&format!("\n## Goal\n{}\n", seed.goal));
1397
1398 if !seed.original_request.is_empty() && seed.original_request != seed.goal {
1401 prompt.push_str(&format!(
1402 "\n## User's Original Request\n{}\n",
1403 seed.original_request
1404 ));
1405 }
1406
1407 if !seed.constraints.is_empty() {
1408 prompt.push_str("\n## Constraints\n");
1409 for (i, c) in seed.constraints.iter().enumerate() {
1410 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1411 }
1412 }
1413
1414 if !seed.acceptance_criteria.is_empty() {
1415 prompt.push_str("\n## Acceptance Criteria\n");
1416 for (i, c) in seed.acceptance_criteria.iter().enumerate() {
1417 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1418 }
1419 }
1420
1421 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1425 prompt.push_str("\n## Workspace Context\n");
1426 prompt.push_str(ctx);
1427 prompt.push('\n');
1428 }
1429
1430 if !seed.ontology.is_empty() {
1431 prompt.push_str("\n## Domain Entities\n");
1432 for e in &seed.ontology {
1433 prompt.push_str(&format!(
1434 "- **{}** ({}): {}\n",
1435 e.name, e.entity_type, e.description
1436 ));
1437 }
1438 }
1439
1440 if let Some(pp) = persona_prompt {
1442 prompt.push_str("\n## Persona\n");
1443 prompt.push_str(pp);
1444 prompt.push('\n');
1445 }
1446
1447 if let Some(xml) = capabilities_xml {
1449 prompt.push_str("\n## Available Capabilities\n");
1450 prompt.push_str("The following capabilities are relevant to your goal. ");
1451 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1452 prompt.push_str(xml);
1453 prompt.push('\n');
1454 }
1455
1456 if let Some(manifest) = kernel_manifest {
1458 prompt.push('\n');
1459 prompt.push_str(manifest);
1460 prompt.push('\n');
1461 }
1462
1463 prompt.push_str(
1465 "\n## Execution Protocol\n\
1466 1. UNDERSTAND — Read the Seed completely before acting.\n\
1467 2. PLAN — Determine the minimal set of actions needed.\n\
1468 3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
1469 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1470 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1471 ## Hard Boundaries\n\
1472 - NEVER modify files outside the workspace scope\n\
1473 - NEVER execute destructive commands without confirming scope\n\
1474 - NEVER claim completion without evidence — show the output, not your opinion\n\
1475 - NEVER add features or improvements beyond the Seed scope\n\
1476 - If you cannot complete the Seed, say so and explain WHY\n\n\
1477 ## Scope Guard\n\
1478 The Seed defines your universe. Do not:\n\
1479 - Refactor code the Seed didn't mention\n\
1480 - Add tests the Seed didn't require\n\
1481 - Change configuration the Seed didn't specify\n\
1482 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1483 ## Error Handling\n\
1484 - If a tool fails, read the error message carefully before retrying\n\
1485 - If a command fails, do NOT immediately retry with --force or sudo\n\
1486 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1487 ## Shape Matching\n\
1488 Match your output to the task: simple task → concise response.\n\
1489 Do not write 50 lines when 5 would do.\n\
1490 Use `exec` for all command execution (git, gh, osascript, etc.).",
1491 );
1492
1493 prompt
1494}
1495
1496fn build_user_prompt(seed: &Seed) -> String {
1498 format!(
1499 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1500 seed.goal,
1501 seed.acceptance_criteria
1502 .iter()
1503 .enumerate()
1504 .map(|(i, c)| format!("{}. {}", i + 1, c))
1505 .collect::<Vec<_>>()
1506 .join("\n")
1507 )
1508}
1509
1510impl std::fmt::Debug for AgentRuntime {
1511 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1512 f.debug_struct("AgentRuntime")
1513 .field("model_id", &self.engine_handle.get().default_model_id())
1514 .finish()
1515 }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521 use async_trait::async_trait;
1522 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1523 use oxios_ouroboros::Entity;
1524 use serde_json::Value;
1525
1526 struct DummyTool {
1528 name: String,
1529 }
1530
1531 #[async_trait]
1532 impl AgentTool for DummyTool {
1533 fn name(&self) -> &str {
1534 &self.name
1535 }
1536 fn label(&self) -> &str {
1537 &self.name
1538 }
1539 fn description(&self) -> &str {
1540 "Test tool"
1541 }
1542 fn parameters_schema(&self) -> Value {
1543 serde_json::json!({"type": "object"})
1544 }
1545
1546 async fn execute(
1547 &self,
1548 _tool_call_id: &str,
1549 _params: Value,
1550 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1551 _ctx: &ToolContext,
1552 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1553 Ok(oxi_sdk::AgentToolResult::success("ok"))
1554 }
1555 }
1556
1557 #[test]
1559 fn test_requires_tools_validation_passes() {
1560 let registry = ToolRegistry::new();
1561
1562 registry.register(DummyTool {
1563 name: "read".into(),
1564 });
1565 registry.register(DummyTool {
1566 name: "exec".into(),
1567 });
1568
1569 let missing = registry.missing(&["read", "exec"]);
1570
1571 assert!(
1572 missing.is_empty(),
1573 "Expected no missing tools, got: {:?}",
1574 missing
1575 );
1576 }
1577
1578 #[test]
1580 fn test_requires_tools_validation_fails() {
1581 let registry = ToolRegistry::new();
1582
1583 registry.register(DummyTool {
1584 name: "read".into(),
1585 });
1586
1587 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1588
1589 assert_eq!(missing, vec!["exec", "nonexistent"]);
1590 }
1591
1592 #[test]
1593 fn test_build_system_prompt_includes_goal() {
1594 let seed = Seed {
1595 id: uuid::Uuid::new_v4(),
1596 goal: "Build a web server".into(),
1597 constraints: vec!["Must use Rust".into()],
1598 acceptance_criteria: vec!["Server responds to requests".into()],
1599 ontology: vec![Entity {
1600 name: "HttpServer".into(),
1601 entity_type: "struct".into(),
1602 description: "The main server struct".into(),
1603 }],
1604 created_at: chrono::Utc::now(),
1605 generation: 0,
1606 parent_seed_id: None,
1607 cspace_hint: None,
1608 original_request: String::new(),
1609 output_schema: None,
1610 project_id: None,
1611 workspace_context: None,
1612 mount_paths: Vec::new(),
1613 };
1614
1615 let prompt = build_system_prompt(&seed, None, None, None, None);
1616
1617 assert!(prompt.contains("Build a web server"));
1618 assert!(prompt.contains("Must use Rust"));
1619 assert!(prompt.contains("Server responds to requests"));
1620 assert!(prompt.contains("HttpServer"));
1621 assert!(prompt.contains("struct"));
1622 }
1623
1624 #[test]
1625 fn test_build_system_prompt_empty() {
1626 let seed = Seed {
1627 id: uuid::Uuid::new_v4(),
1628 goal: "Test goal".into(),
1629 constraints: vec![],
1630 acceptance_criteria: vec![],
1631 ontology: vec![],
1632 created_at: chrono::Utc::now(),
1633 generation: 0,
1634 parent_seed_id: None,
1635 cspace_hint: None,
1636 original_request: String::new(),
1637 output_schema: None,
1638 project_id: None,
1639 workspace_context: None,
1640 mount_paths: Vec::new(),
1641 };
1642
1643 let prompt = build_system_prompt(&seed, None, None, None, None);
1644
1645 assert!(prompt.contains("Test goal"));
1646 }
1647
1648 #[test]
1649 fn test_infer_domain_testing() {
1650 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1651 }
1652
1653 #[test]
1654 fn test_infer_domain_deployment() {
1655 assert_eq!(
1656 infer_domain("deploy the web service to production"),
1657 "deployment"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_infer_domain_bugfix() {
1663 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1664 }
1665
1666 #[test]
1667 fn test_infer_domain_development() {
1668 assert_eq!(
1669 infer_domain("create a new REST API endpoint"),
1670 "development"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_infer_domain_analysis() {
1676 assert_eq!(
1677 infer_domain("review the code for security issues"),
1678 "analysis"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_infer_domain_fallback() {
1684 let domain = infer_domain("optimize performance metrics");
1685 assert!(!domain.is_empty());
1687 }
1688}