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