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