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 session_id: None,
792 };
793
794 let agent = if config.provider_rpm > 0 {
809 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
811 let provider_name = engine.resolve_model(&config.model_id)?.provider;
812 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
813
814 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
816 if config.rate_limit_per_minute > 0 {
817 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
818 config.rate_limit_per_minute,
819 ));
820 }
821 if config.token_budget > 0 {
822 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
823 config.token_budget,
824 ));
825 }
826 if config.audit_tool_calls {
827 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
828 tracing::Level::INFO,
829 ));
830 }
831
832 let agent = Arc::new(Agent::new_with_resolver(
834 provider,
835 agent_config,
836 Arc::new(registry),
837 resolver,
838 ));
839
840 if !pipeline.is_empty() {
842 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
843 let agent_id_for_hooks = agent_id.to_string();
844 let hooks = oxi_sdk::middleware::build_hooks(
845 Arc::new(pipeline),
846 agent_id_for_hooks,
847 terminate_flag,
848 );
849 agent.set_hooks(hooks);
850 }
851
852 agent
853 } else {
854 let mut builder = engine
856 .oxi()
857 .agent(agent_config)
858 .workspace(&workspace)
859 .system_prompt(system_prompt);
860
861 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
871 .names()
872 .into_iter()
873 .filter_map(|name| registry.get(&name))
874 .collect();
875
876 if let Some(auth) = engine.authorizer() {
878 builder = builder.authorizer(auth.clone());
879 }
880 if let Some(tracer) = engine.tracer() {
881 builder = builder.tracer(tracer.clone());
882 }
883 if let Some(ct) = engine.cost_tracker() {
884 builder = builder.cost_tracker(ct.clone());
885 }
886
887 if config.rate_limit_per_minute > 0 {
890 builder = builder.with_rate_limit(config.rate_limit_per_minute);
891 }
892 if config.token_budget > 0 {
893 builder = builder.with_token_budget(config.token_budget);
894 }
895 if config.audit_tool_calls {
896 builder = builder.with_logging();
897 }
898
899 let built = builder.build()?;
900 let agent = Arc::new(built);
901
902 let agent_tools = agent.tools();
907 for tool in cspace_tool_arcs {
908 agent_tools.register_arc(tool);
909 }
910
911 agent
912 };
913
914 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
916 let exec_state_cb = Arc::clone(&exec_state);
917 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
918 let session_id_for_callback = seed_id.to_string();
919 let model_id_for_callback = config.model_id.clone();
920 let agent_id_for_callback = agent_id.to_string();
921 let routing_stats_for_cb = routing_stats.clone();
922 let transparency_session: Option<String> = session_id.clone();
925 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
926
927 let result = agent
929 .run_streaming(prompt, move |event| {
930 let mut s = exec_state_cb.lock();
931 match event {
932 AgentEvent::ToolExecutionStart {
933 tool_name,
934 tool_call_id,
935 args,
936 context,
937 ..
938 } => {
939 let idx = s.trajectory_steps.len();
941 s.pending_tools
942 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
943 s.tool_args_map.insert(
944 tool_call_id.clone(),
945 serde_json::to_string(&args).unwrap_or_default(),
946 );
947 s.tool_timestamps
948 .insert(tool_call_id.clone(), chrono::Utc::now());
949 s.tool_call_ids.push(tool_call_id.clone());
950 s.trajectory_steps
951 .push(oxios_memory::memory::sona::TrajectoryStep {
952 input: tool_name.clone(),
953 output: String::new(),
954 duration_ms: 0,
955 confidence: 0.0,
956 });
957 if let Some(ref sid) = transparency_session {
959 let context_json = context
960 .as_ref()
961 .map(serde_json::to_value)
962 .transpose()
963 .unwrap_or(None);
964 let _ =
965 kernel_handle_for_cb
966 .infra
967 .publish(KernelEvent::ToolExecutionStarted {
968 session_id: sid.clone(),
969 tool_name: tool_name.clone(),
970 tool_call_id: tool_call_id.clone(),
971 tool_args: args.clone(),
972 context: context_json,
973 });
974 }
975 }
976 AgentEvent::ToolExecutionUpdate {
977 tool_call_id,
978 tool_name,
979 partial_result,
980 tab_id,
981 context,
982 } => {
983 if let Some(ref sid) = transparency_session {
993 let context_json = context
994 .as_ref()
995 .map(serde_json::to_value)
996 .transpose()
997 .unwrap_or(None);
998 let _ = kernel_handle_for_cb.infra.publish(
999 KernelEvent::ToolExecutionProgress {
1000 session_id: sid.clone(),
1001 tool_call_id: tool_call_id.clone(),
1002 tool_name: tool_name.clone(),
1003 progress: partial_result,
1004 tab_id,
1005 context: context_json,
1006 },
1007 );
1008 }
1009 }
1010 AgentEvent::ToolExecutionEnd {
1011 tool_name,
1012 tool_call_id,
1013 is_error,
1014 result,
1015 ..
1016 } => {
1017 if !is_error {
1018 s.steps_completed += 1;
1019 }
1020 let mut duration_ms: u64 = 0;
1022 let mut summary = String::new();
1023 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1024 duration_ms = start.elapsed().as_millis() as u64;
1025 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1026 summary = summarize_tool_result(&result.content, 200);
1027 step.output = summary.clone();
1028 step.duration_ms = duration_ms;
1029 step.confidence = if is_error { 0.3 } else { 0.8 };
1030 }
1031 }
1032 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1033 if let Some(ref sid) = transparency_session {
1035 let _ = kernel_handle_for_cb.infra.publish(
1036 KernelEvent::ToolExecutionFinished {
1037 session_id: sid.clone(),
1038 tool_call_id: tool_call_id.clone(),
1039 tool_name: tool_name.clone(),
1040 duration_ms,
1041 is_error,
1042 output_summary: summary,
1043 },
1044 );
1045 }
1046 }
1047 AgentEvent::AgentEnd {
1048 messages,
1049 stop_reason,
1050 ..
1051 } => {
1052 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1053 s.final_content = a.text_content();
1054 }
1055 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1061 }
1062 AgentEvent::Error { message, .. } => {
1063 s.final_content = message.clone();
1064 s.success = false;
1065 }
1066 AgentEvent::Usage {
1067 input_tokens,
1068 output_tokens,
1069 } => {
1070 s.total_input_tokens += input_tokens as u64;
1072 s.total_output_tokens += output_tokens as u64;
1073
1074 let agent_label = format!("agent-{agent_id_for_callback}");
1076 crate::observability::cost_tracker().record(
1077 &agent_label,
1078 &oxi_sdk::Model::new(
1079 &model_id_for_callback,
1080 &model_id_for_callback,
1081 oxi_sdk::Api::OpenAiCompletions,
1082 "unknown",
1083 "https://unknown.com",
1084 ),
1085 oxi_sdk::TokenUsage {
1086 input: input_tokens as u64,
1087 output: output_tokens as u64,
1088 cache_read: 0,
1089 cache_write: 0,
1090 },
1091 );
1092
1093 if let Some(stats) = &routing_stats_for_cb {
1095 let cost = crate::kernel_handle::engine_api::estimate_cost(
1096 &model_id_for_callback,
1097 input_tokens as u64,
1098 output_tokens as u64,
1099 );
1100 stats.record_model_usage(&model_id_for_callback, cost);
1101 }
1102 if let Some(ref sid) = transparency_session {
1104 let _ = kernel_handle_for_cb
1105 .infra
1106 .publish(KernelEvent::TokenUsageUpdate {
1107 session_id: sid.clone(),
1108 input_tokens: input_tokens as u64,
1109 output_tokens: output_tokens as u64,
1110 });
1111 }
1112 }
1113 AgentEvent::Compaction {
1114 event: CompactionEvent::Completed { result, .. },
1115 } => {
1116 handle_compaction(
1117 result.summary.clone(),
1118 session_id_for_callback.clone(),
1119 memory_for_callback.clone(),
1120 );
1121 if let Some(ref sid) = transparency_session {
1123 let _ =
1124 kernel_handle_for_cb
1125 .infra
1126 .publish(KernelEvent::ReasoningFragment {
1127 session_id: sid.clone(),
1128 content: result.summary.clone(),
1129 source: "compaction".to_string(),
1130 });
1131 }
1132 }
1133 _ => {}
1134 }
1135 })
1136 .await;
1137
1138 let circuit = get_llm_circuit_breaker();
1140 if result.is_err() {
1141 circuit.record_failure();
1142 crate::metrics::get_metrics()
1143 .llm_circuit_breaker_state
1144 .set(1.0);
1145 } else {
1146 circuit.record_success();
1147 crate::metrics::get_metrics()
1148 .llm_circuit_breaker_state
1149 .set(0.0);
1150 }
1151
1152 if let Err(e) = result {
1153 tracing::error!(seed_id = %seed_id, error = %e, "Agent failed");
1154 let s = exec_state.lock();
1155 return Ok((
1156 format!("Agent failed: {e}"),
1157 s.steps_completed,
1158 false,
1159 s.trajectory_steps.clone(),
1160 agent,
1161 s.tool_call_ids.clone(),
1162 s.tool_args_map.clone(),
1163 s.tool_error_map.clone(),
1164 s.tool_timestamps.clone(),
1165 s.total_input_tokens,
1166 s.total_output_tokens,
1167 ));
1168 }
1169
1170 let s = exec_state.lock();
1171 tracing::info!(
1172 seed_id = %seed_id,
1173 steps = s.steps_completed,
1174 success = s.success,
1175 "Agent completed"
1176 );
1177
1178 if !s.trajectory_steps.is_empty()
1181 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1182 {
1183 let steps = s.trajectory_steps.clone();
1184 let success = s.success;
1185 let sona = Arc::clone(sona);
1186 let domain = infer_domain(&seed_goal);
1187 tokio::spawn(async move {
1188 let verdict = if success {
1189 oxios_memory::memory::sona::Verdict::Success
1190 } else {
1191 oxios_memory::memory::sona::Verdict::Failure
1192 };
1193 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1194 if let Err(e) = sona.record(trajectory).await {
1195 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1196 }
1197 });
1198 }
1199
1200 Ok((
1201 s.final_content.clone(),
1202 s.steps_completed,
1203 s.success,
1204 s.trajectory_steps.clone(),
1205 agent,
1206 s.tool_call_ids.clone(),
1207 s.tool_args_map.clone(),
1208 s.tool_error_map.clone(),
1209 s.tool_timestamps.clone(),
1210 s.total_input_tokens,
1211 s.total_output_tokens,
1212 ))
1213}
1214
1215fn summarize_tool_result(result: &str, max_len: usize) -> String {
1220 let trimmed = result.trim();
1221 if trimmed.chars().count() <= max_len {
1222 return trimmed.to_string();
1223 }
1224 let first_line = trimmed.lines().next().unwrap_or("");
1226 if first_line.chars().count() <= max_len {
1227 first_line.to_string()
1228 } else {
1229 let truncated: String = first_line.chars().take(max_len - 3).collect();
1230 format!("{truncated}...")
1231 }
1232}
1233
1234fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1238 if json_str.len() <= max_len {
1239 return json_str.to_string();
1240 }
1241 let truncated: String = json_str.chars().take(max_len - 3).collect();
1242 format!("{truncated}...")
1243}
1244
1245fn infer_domain(goal: &str) -> String {
1250 let lower = goal.to_lowercase();
1251 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1252
1253 if keywords.iter().any(|k| {
1255 [
1256 "test",
1257 "tests",
1258 "spec",
1259 "testing",
1260 "assert",
1261 "unit test",
1262 "integration",
1263 ]
1264 .contains(k)
1265 }) {
1266 return "testing".to_string();
1267 }
1268 if keywords
1269 .iter()
1270 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1271 {
1272 return "deployment".to_string();
1273 }
1274 if keywords
1275 .iter()
1276 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1277 {
1278 return "bugfix".to_string();
1279 }
1280 if keywords
1281 .iter()
1282 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1283 {
1284 return "refactoring".to_string();
1285 }
1286 if keywords
1287 .iter()
1288 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1289 {
1290 return "documentation".to_string();
1291 }
1292 if keywords
1293 .iter()
1294 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1295 {
1296 return "development".to_string();
1297 }
1298 if keywords
1299 .iter()
1300 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1301 {
1302 return "analysis".to_string();
1303 }
1304 if keywords
1305 .iter()
1306 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1307 {
1308 return "configuration".to_string();
1309 }
1310
1311 let meaningful: Vec<&str> = lower
1313 .split_whitespace()
1314 .filter(|w| w.len() > 2)
1315 .take(2)
1316 .collect();
1317 if meaningful.len() >= 2 {
1318 meaningful.join("_")
1319 } else {
1320 "general".to_string()
1321 }
1322}
1323
1324fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1330 let entry = MemoryEntry {
1331 id: uuid::Uuid::new_v4().to_string(),
1332 memory_type: MemoryType::Conversation,
1333 tier: crate::memory::MemoryTier::Warm,
1334 content: summary,
1335 content_hash: 0,
1336 source: "compaction".to_string(),
1337 session_id: Some(session_id),
1338 tags: vec![],
1339 importance: 0.5,
1340 pinned: false,
1341 protection: crate::memory::ProtectionLevel::None,
1342 auto_classified: false,
1343 session_appearances: 0,
1344 user_corrected: false,
1345 seen_in_sessions: vec![],
1346 created_at: chrono::Utc::now(),
1347 accessed_at: chrono::Utc::now(),
1348 modified_at: chrono::Utc::now(),
1349 access_count: 0,
1350 decay_score: 1.0,
1351 compaction_level: 0,
1352 compacted_from: vec![],
1353 related_ids: vec![],
1354 contradicts: None,
1355 };
1356 tokio::spawn(async move {
1357 if let Err(e) = memory_manager.remember(entry).await {
1358 tracing::warn!(error = %e, "Failed to save compaction summary");
1359 }
1360 });
1361}
1362
1363fn build_system_prompt(
1369 seed: &Seed,
1370 persona_prompt: Option<&str>,
1371 capabilities_xml: Option<&str>,
1372 kernel_manifest: Option<&str>,
1373 workspace_context: Option<&str>,
1374) -> String {
1375 let mut prompt = String::from(
1376 "You are an autonomous agent in the Oxios operating system.\n\
1377 You execute Seeds — immutable specifications with goals, constraints, and\n\
1378 acceptance criteria.\n\n\
1379 ## Available Tools\n\
1380 You have the following tools:\n\
1381 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1382 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1383 - **Exec**: run shell commands\n\
1384 - **Memory tools**: memory_read, memory_write, memory_search — agent's internal recall\n\
1385 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1386 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1387 **Important**: When the task involves fetching information from the internet,\n\
1388 websites, or online services, use `web_search` first — do NOT search local files.\n\
1389 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1390 from the web, use `web_search`.\n",
1391 );
1392 prompt.push_str(&format!("\n## Goal\n{}\n", seed.goal));
1393
1394 if !seed.original_request.is_empty() && seed.original_request != seed.goal {
1397 prompt.push_str(&format!(
1398 "\n## User's Original Request\n{}\n",
1399 seed.original_request
1400 ));
1401 }
1402
1403 if !seed.constraints.is_empty() {
1404 prompt.push_str("\n## Constraints\n");
1405 for (i, c) in seed.constraints.iter().enumerate() {
1406 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1407 }
1408 }
1409
1410 if !seed.acceptance_criteria.is_empty() {
1411 prompt.push_str("\n## Acceptance Criteria\n");
1412 for (i, c) in seed.acceptance_criteria.iter().enumerate() {
1413 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1414 }
1415 }
1416
1417 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1421 prompt.push_str("\n## Workspace Context\n");
1422 prompt.push_str(ctx);
1423 prompt.push('\n');
1424 }
1425
1426 if !seed.ontology.is_empty() {
1427 prompt.push_str("\n## Domain Entities\n");
1428 for e in &seed.ontology {
1429 prompt.push_str(&format!(
1430 "- **{}** ({}): {}\n",
1431 e.name, e.entity_type, e.description
1432 ));
1433 }
1434 }
1435
1436 if let Some(pp) = persona_prompt {
1438 prompt.push_str("\n## Persona\n");
1439 prompt.push_str(pp);
1440 prompt.push('\n');
1441 }
1442
1443 if let Some(xml) = capabilities_xml {
1445 prompt.push_str("\n## Available Capabilities\n");
1446 prompt.push_str("The following capabilities are relevant to your goal. ");
1447 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1448 prompt.push_str(xml);
1449 prompt.push('\n');
1450 }
1451
1452 if let Some(manifest) = kernel_manifest {
1454 prompt.push('\n');
1455 prompt.push_str(manifest);
1456 prompt.push('\n');
1457 }
1458
1459 prompt.push_str(
1461 "\n## Execution Protocol\n\
1462 1. UNDERSTAND — Read the Seed completely before acting.\n\
1463 2. PLAN — Determine the minimal set of actions needed.\n\
1464 3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
1465 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1466 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1467 ## Hard Boundaries\n\
1468 - NEVER modify files outside the workspace scope\n\
1469 - NEVER execute destructive commands without confirming scope\n\
1470 - NEVER claim completion without evidence — show the output, not your opinion\n\
1471 - NEVER add features or improvements beyond the Seed scope\n\
1472 - If you cannot complete the Seed, say so and explain WHY\n\n\
1473 ## Scope Guard\n\
1474 The Seed defines your universe. Do not:\n\
1475 - Refactor code the Seed didn't mention\n\
1476 - Add tests the Seed didn't require\n\
1477 - Change configuration the Seed didn't specify\n\
1478 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1479 ## Error Handling\n\
1480 - If a tool fails, read the error message carefully before retrying\n\
1481 - If a command fails, do NOT immediately retry with --force or sudo\n\
1482 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1483 ## Shape Matching\n\
1484 Match your output to the task: simple task → concise response.\n\
1485 Do not write 50 lines when 5 would do.\n\
1486 Use `exec` for all command execution (git, gh, osascript, etc.).",
1487 );
1488
1489 prompt
1490}
1491
1492fn build_user_prompt(seed: &Seed) -> String {
1494 format!(
1495 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1496 seed.goal,
1497 seed.acceptance_criteria
1498 .iter()
1499 .enumerate()
1500 .map(|(i, c)| format!("{}. {}", i + 1, c))
1501 .collect::<Vec<_>>()
1502 .join("\n")
1503 )
1504}
1505
1506impl std::fmt::Debug for AgentRuntime {
1507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1508 f.debug_struct("AgentRuntime")
1509 .field("model_id", &self.config.model_id)
1510 .finish()
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use async_trait::async_trait;
1518 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1519 use oxios_ouroboros::Entity;
1520 use serde_json::Value;
1521
1522 struct DummyTool {
1524 name: String,
1525 }
1526
1527 #[async_trait]
1528 impl AgentTool for DummyTool {
1529 fn name(&self) -> &str {
1530 &self.name
1531 }
1532 fn label(&self) -> &str {
1533 &self.name
1534 }
1535 fn description(&self) -> &str {
1536 "Test tool"
1537 }
1538 fn parameters_schema(&self) -> Value {
1539 serde_json::json!({"type": "object"})
1540 }
1541
1542 async fn execute(
1543 &self,
1544 _tool_call_id: &str,
1545 _params: Value,
1546 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1547 _ctx: &ToolContext,
1548 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1549 Ok(oxi_sdk::AgentToolResult::success("ok"))
1550 }
1551 }
1552
1553 #[test]
1555 fn test_requires_tools_validation_passes() {
1556 let registry = ToolRegistry::new();
1557
1558 registry.register(DummyTool {
1559 name: "read".into(),
1560 });
1561 registry.register(DummyTool {
1562 name: "exec".into(),
1563 });
1564
1565 let missing = registry.missing(&["read", "exec"]);
1566
1567 assert!(
1568 missing.is_empty(),
1569 "Expected no missing tools, got: {:?}",
1570 missing
1571 );
1572 }
1573
1574 #[test]
1576 fn test_requires_tools_validation_fails() {
1577 let registry = ToolRegistry::new();
1578
1579 registry.register(DummyTool {
1580 name: "read".into(),
1581 });
1582
1583 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1584
1585 assert_eq!(missing, vec!["exec", "nonexistent"]);
1586 }
1587
1588 #[test]
1589 fn test_build_system_prompt_includes_goal() {
1590 let seed = Seed {
1591 id: uuid::Uuid::new_v4(),
1592 goal: "Build a web server".into(),
1593 constraints: vec!["Must use Rust".into()],
1594 acceptance_criteria: vec!["Server responds to requests".into()],
1595 ontology: vec![Entity {
1596 name: "HttpServer".into(),
1597 entity_type: "struct".into(),
1598 description: "The main server struct".into(),
1599 }],
1600 created_at: chrono::Utc::now(),
1601 generation: 0,
1602 parent_seed_id: None,
1603 cspace_hint: None,
1604 original_request: String::new(),
1605 output_schema: None,
1606 project_id: None,
1607 workspace_context: None,
1608 mount_paths: Vec::new(),
1609 };
1610
1611 let prompt = build_system_prompt(&seed, None, None, None, None);
1612
1613 assert!(prompt.contains("Build a web server"));
1614 assert!(prompt.contains("Must use Rust"));
1615 assert!(prompt.contains("Server responds to requests"));
1616 assert!(prompt.contains("HttpServer"));
1617 assert!(prompt.contains("struct"));
1618 }
1619
1620 #[test]
1621 fn test_build_system_prompt_empty() {
1622 let seed = Seed {
1623 id: uuid::Uuid::new_v4(),
1624 goal: "Test goal".into(),
1625 constraints: vec![],
1626 acceptance_criteria: vec![],
1627 ontology: vec![],
1628 created_at: chrono::Utc::now(),
1629 generation: 0,
1630 parent_seed_id: None,
1631 cspace_hint: None,
1632 original_request: String::new(),
1633 output_schema: None,
1634 project_id: None,
1635 workspace_context: None,
1636 mount_paths: Vec::new(),
1637 };
1638
1639 let prompt = build_system_prompt(&seed, None, None, None, None);
1640
1641 assert!(prompt.contains("Test goal"));
1642 }
1643
1644 #[test]
1645 fn test_infer_domain_testing() {
1646 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1647 }
1648
1649 #[test]
1650 fn test_infer_domain_deployment() {
1651 assert_eq!(
1652 infer_domain("deploy the web service to production"),
1653 "deployment"
1654 );
1655 }
1656
1657 #[test]
1658 fn test_infer_domain_bugfix() {
1659 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1660 }
1661
1662 #[test]
1663 fn test_infer_domain_development() {
1664 assert_eq!(
1665 infer_domain("create a new REST API endpoint"),
1666 "development"
1667 );
1668 }
1669
1670 #[test]
1671 fn test_infer_domain_analysis() {
1672 assert_eq!(
1673 infer_domain("review the code for security issues"),
1674 "analysis"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_infer_domain_fallback() {
1680 let domain = infer_domain("optimize performance metrics");
1681 assert!(!domain.is_empty());
1683 }
1684}