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