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