1use anyhow::Result;
22use oxi_sdk::{CompactionEvent, SearchCache};
23use oxi_sdk::ToolExecutionMode;
24use oxi_sdk::{AgentEvent, AgentLoop, AgentLoopConfig, SharedState, ToolRegistry};
25use oxi_sdk::{CompactionStrategy, Provider};
26use parking_lot::Mutex;
27use std::sync::Arc;
28
29use crate::capability::resolve::resolve_cspace;
30use crate::circuit_breaker::CircuitBreaker;
31use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
32use crate::persona_manager::PersonaManager;
33use crate::tools::registration::register_tools_from_cspace;
34use crate::types::AgentId;
35use crate::KernelHandle;
36use oxios_ouroboros::{ExecutionResult, Seed};
37
38static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<CircuitBreaker> = std::sync::OnceLock::new();
40
41fn get_llm_circuit_breaker() -> &'static CircuitBreaker {
43 LLM_CIRCUIT_BREAKER.get_or_init(CircuitBreaker::default)
44}
45
46#[derive(Debug, Clone)]
48pub struct AgentRuntimeConfig {
49 pub model_id: String,
51 pub max_iterations: usize,
53 pub tool_execution: ToolExecutionMode,
55 pub auto_retry_enabled: bool,
57 pub space_id: Option<uuid::Uuid>,
59 pub project_paths: Vec<std::path::PathBuf>,
61 pub workspace_dir: Option<std::path::PathBuf>,
63}
64
65impl Default for AgentRuntimeConfig {
66 fn default() -> Self {
67 Self {
68 model_id: "anthropic/claude-sonnet-4-20250514".into(),
69 max_iterations: 20,
70 tool_execution: ToolExecutionMode::Parallel,
71 auto_retry_enabled: true,
72 space_id: None,
73 project_paths: Vec::new(),
74 workspace_dir: None,
75 }
76 }
77}
78
79#[derive(Default)]
82struct ExecuteState {
83 final_content: String,
84 steps_completed: usize,
85 success: bool,
86}
87
88struct AgentLoopContext {
93 provider: Arc<dyn Provider>,
94 config: AgentRuntimeConfig,
95 system_prompt: String,
96 prompt: String,
97 seed_id: uuid::Uuid,
98 agent_id: AgentId,
99 kernel_handle: Arc<KernelHandle>,
100 cspace: crate::capability::CSpace,
101 #[allow(dead_code)]
103 persona_prompt: Option<String>,
104}
105
106pub struct AgentRuntime {
114 provider: Arc<dyn Provider>,
115 config: AgentRuntimeConfig,
116 kernel_handle: Arc<KernelHandle>,
118 persona_manager: Option<Arc<PersonaManager>>,
120 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
122}
123
124impl AgentRuntime {
125 pub fn new(
129 provider: Arc<dyn Provider>,
130 model_id: impl Into<String>,
131 kernel_handle: Arc<KernelHandle>,
132 ) -> Self {
133 Self {
134 provider,
135 config: AgentRuntimeConfig {
136 model_id: model_id.into(),
137 ..Default::default()
138 },
139 kernel_handle,
140 persona_manager: None,
141 tool_retriever: None,
142 }
143 }
144
145 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
147 self.persona_manager = Some(pm);
148 self
149 }
150
151 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
153 self.config = config;
154 self
155 }
156
157 pub fn with_tool_retriever(
159 mut self,
160 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
161 ) -> Self {
162 self.tool_retriever = Some(retriever);
163 self
164 }
165
166 pub async fn execute(&self, agent_id: AgentId, seed: &Seed) -> Result<ExecutionResult> {
173 let prompt = build_user_prompt(seed);
174
175 let persona_prompt = self
177 .persona_manager
178 .as_ref()
179 .map(|pm| pm.active_system_prompt())
180 .filter(|s| !s.trim().is_empty());
181
182 let persona_role = self
184 .persona_manager
185 .as_ref()
186 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
187
188 let cspace = resolve_cspace(
190 seed.cspace_hint.as_deref(),
191 persona_role.as_deref(),
192 Some("worker"),
193 agent_id,
194 );
195
196 let mut system_prompt = build_system_prompt(seed, persona_prompt.as_deref(), None, None);
199
200 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
202 match retriever.embedder().embed(&seed.goal).await {
203 Ok(query_vec) => {
204 let results = retriever.retrieve(&query_vec, 8);
205 if results.is_empty() {
206 None
207 } else {
208 let xml = crate::tools::retrieval::format_capability_index(&results);
209 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
210 Some(xml)
211 }
212 }
213 Err(e) => {
214 tracing::warn!(error = %e, "Failed to embed seed goal for retrieval");
215 None
216 }
217 }
218 } else {
219 None
220 };
221
222 let kernel_manifest = {
224 let domains = cspace.active_domains();
225 if domains.is_empty() {
226 None
227 } else {
228 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
229 }
230 };
231
232 if capabilities_xml.is_some() || kernel_manifest.is_some() {
234 system_prompt = build_system_prompt(
235 seed,
236 persona_prompt.as_deref(),
237 capabilities_xml.as_deref(),
238 kernel_manifest.as_deref(),
239 );
240 }
241
242 let memory_manager = self.kernel_handle.agents.memory_manager();
244 match memory_manager.recall(&seed.goal).await {
245 Ok(memories) if !memories.is_empty() => {
246 tracing::info!(count = memories.len(), "Recalled memories for seed");
247 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
248 }
249 Ok(_) => tracing::debug!("No memories recalled"),
250 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
251 }
252
253 let config = self.config.clone();
255 let provider = Arc::clone(&self.provider);
256 let seed_id = seed.id;
257 let kernel_handle = Arc::clone(&self.kernel_handle);
258
259 let ctx = AgentLoopContext {
260 provider,
261 config,
262 system_prompt,
263 prompt,
264 seed_id,
265 agent_id,
266 kernel_handle,
267 cspace,
268 persona_prompt,
269 };
270
271 let (final_content, steps_completed, success) =
272 tokio::task::spawn_blocking(move || run_agent_loop(ctx)).await??;
273
274 tracing::info!(
275 seed_id = %seed_id,
276 steps = steps_completed,
277 success,
278 "AgentRuntime finished"
279 );
280
281 Ok(ExecutionResult {
282 output: if final_content.is_empty() {
283 "Agent execution completed".into()
284 } else {
285 final_content
286 },
287 steps_completed,
288 success,
289 })
290 }
291}
292
293fn run_agent_loop(ctx: AgentLoopContext) -> Result<(String, usize, bool)> {
301 let AgentLoopContext {
302 provider,
303 config,
304 system_prompt,
305 prompt,
306 seed_id,
307 agent_id,
308 kernel_handle,
309 cspace,
310 persona_prompt: _,
311 } = ctx;
312
313 let workspace = if !config.project_paths.is_empty() {
315 config.project_paths[0].clone()
316 } else if let Some(ref ws) = config.workspace_dir {
317 ws.clone()
318 } else {
319 std::env::temp_dir()
320 .join("oxios-agent-workspace")
321 .join(agent_id.to_string())
322 };
323
324 let _ = std::fs::create_dir_all(&workspace);
326
327 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
328
329 let registry = ToolRegistry::new();
331 let search_cache = Arc::new(SearchCache::new());
332 register_tools_from_cspace(®istry, &kernel_handle, &cspace, search_cache, agent_id);
333
334 tracing::info!(
335 seed_id = %seed_id,
336 capabilities = cspace.len(),
337 "Tools registered from CSpace"
338 );
339
340 let pm = kernel_handle.extensions.program_manager();
342
343 let exec_for_programs: Option<std::sync::Arc<crate::tools::ExecTool>> = if cspace.can(
345 &crate::capability::ResourceRef::Exec {
346 mode: "shell".into(),
347 },
348 crate::capability::Rights::EXECUTE,
349 ) {
350 Some(std::sync::Arc::new(crate::tools::ExecTool::from_kernel(
351 &kernel_handle,
352 )))
353 } else {
354 None
355 };
356
357 {
358 let rt = tokio::runtime::Handle::current();
359 let programs: Vec<_> = rt.block_on(async { pm.list_enabled().await });
360
361 let mut mcp_server_names: Vec<String> = Vec::new();
363 for program in &programs {
364 for server_config in &program.meta.mcp_servers {
365 if server_config.enabled {
366 mcp_server_names.push(server_config.name.clone());
367 }
368 }
369 }
370
371 if !mcp_server_names.is_empty() {
372 let bridge = kernel_handle.mcp.bridge();
373 if let Err(e) = rt.block_on(bridge.initialize_all()) {
374 tracing::warn!(error = %e, "MCP bridge init failed — skipping MCP tools");
375 } else {
376 let _ = rt.block_on(bridge.list_tools());
377 for server_name in &mcp_server_names {
378 if let Some(tool_defs) = rt.block_on(bridge.cached_tools(server_name)) {
379 for tool_def in tool_defs {
380 let wrapper = crate::tools::McpToolWrapper::new(
381 bridge.clone(),
382 server_name,
383 &tool_def.name,
384 tool_def.description.clone(),
385 serde_json::json!({"type": "object", "properties": {}}),
386 );
387 registry.register(wrapper);
388 }
389 }
390 }
391 }
392 }
393
394 for program in &programs {
396 let missing_tools: Vec<&str> = program
397 .meta
398 .dependencies
399 .iter()
400 .filter(|tool_name| registry.get(tool_name).is_none())
401 .map(|s| s.as_str())
402 .collect();
403 if !missing_tools.is_empty() {
404 tracing::warn!(
405 program = %program.meta.name,
406 missing_tools = ?missing_tools,
407 "Skipping program: required tools not found"
408 );
409 continue;
410 }
411
412 for tool_def in &program.meta.tools {
413 if !tool_def.command.is_empty() {
414 if let Some(ref exec) = exec_for_programs {
415 let tool = crate::tools::ProgramTool::from_definition(
416 &program.meta.name,
417 tool_def,
418 &program.meta.host_requirements,
419 exec.clone(),
420 );
421 registry.register(tool);
422 }
423 }
424 }
425 }
426 }
427
428 let tools = Arc::new(registry);
429
430 let loop_config = AgentLoopConfig {
432 model_id: config.model_id,
433 system_prompt: Some(system_prompt),
434 temperature: 0.7,
435 max_tokens: 8192,
436 max_iterations: config.max_iterations,
437 tool_execution: config.tool_execution,
438 compaction_strategy: CompactionStrategy::Threshold(0.8),
439 context_window: 128_000,
440 compaction_instruction: None,
441 session_id: Some(seed_id.to_string()),
442 transport: None,
443 compact_on_start: false,
444 max_retry_delay_ms: None,
445 auto_retry_enabled: config.auto_retry_enabled,
446 auto_retry_max_attempts: 3,
447 auto_retry_base_delay_ms: 2000,
448 api_key: None,
449 workspace_dir: config.project_paths.first().cloned(), };
451
452 let state = SharedState::new();
453 let agent_loop = AgentLoop::new(provider, loop_config, tools, state);
454
455 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
457 let exec_state_clone = Arc::clone(&exec_state);
458 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
459 let session_id_for_callback = seed_id.to_string();
460
461 let rt = tokio::runtime::Handle::current();
464 let rt_for_callback = rt.clone();
465 rt.block_on(async {
466 let result = agent_loop
467 .run(prompt, move |event| {
468 let mut s = exec_state_clone.lock();
469 match event {
470 AgentEvent::ToolExecutionEnd {
471 is_error: false, ..
472 } => {
473 s.steps_completed += 1;
474 }
475 AgentEvent::AgentEnd {
476 messages,
477 stop_reason,
478 ..
479 } => {
480 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
481 s.final_content = a.text_content();
482 }
483 s.success = stop_reason.as_deref() == Some("Stop");
484 }
485 AgentEvent::Error { message, .. } => {
486 s.final_content = message.clone();
487 s.success = false;
488 }
489 AgentEvent::Compaction { event } => {
490 let mm = &memory_for_callback;
491 if let CompactionEvent::Completed { result, .. } = event {
492 let entry = MemoryEntry {
493 id: uuid::Uuid::new_v4().to_string(),
494 memory_type: MemoryType::Conversation,
495 content: result.summary.clone(),
496 source: "compaction".to_string(),
497 session_id: Some(session_id_for_callback.clone()),
498 tags: vec![],
499 importance: 0.5,
500 created_at: chrono::Utc::now(),
501 accessed_at: chrono::Utc::now(),
502 access_count: 0,
503 };
504 if let Err(e) = rt_for_callback.block_on(mm.remember(entry)) {
505 tracing::warn!(error = %e, "Failed to save compaction summary");
506 }
507 }
508 }
509 _ => {}
510 }
511 })
512 .await;
513
514 let circuit = get_llm_circuit_breaker();
516 if result.is_err() {
517 circuit.record_failure();
518 } else {
519 circuit.record_success();
520 }
521
522 if let Err(e) = result {
523 tracing::error!(seed_id = %seed_id, error = %e, "AgentLoop failed");
524 let s = exec_state.lock();
525 return Ok((format!("Agent failed: {e}"), s.steps_completed, false));
526 }
527
528 let s = exec_state.lock();
529 tracing::info!(
530 seed_id = %seed_id,
531 steps = s.steps_completed,
532 success = s.success,
533 "AgentLoop completed"
534 );
535 Ok((s.final_content.clone(), s.steps_completed, s.success))
536 })
537}
538
539fn build_system_prompt(
545 seed: &Seed,
546 persona_prompt: Option<&str>,
547 capabilities_xml: Option<&str>,
548 kernel_manifest: Option<&str>,
549) -> String {
550 let mut prompt = format!(
551 "You are an autonomous agent executing a specific task.\n\n\
552 ## Goal\n\
553 {}\n",
554 seed.goal,
555 );
556
557 if !seed.constraints.is_empty() {
558 prompt.push_str("\n## Constraints\n");
559 for (i, c) in seed.constraints.iter().enumerate() {
560 prompt.push_str(&format!("{}. {}\n", i + 1, c));
561 }
562 }
563
564 if !seed.acceptance_criteria.is_empty() {
565 prompt.push_str("\n## Acceptance Criteria\n");
566 for (i, c) in seed.acceptance_criteria.iter().enumerate() {
567 prompt.push_str(&format!("{}. {}\n", i + 1, c));
568 }
569 }
570
571 if !seed.ontology.is_empty() {
572 prompt.push_str("\n## Domain Entities\n");
573 for e in &seed.ontology {
574 prompt.push_str(&format!(
575 "- **{}** ({}): {}\n",
576 e.name, e.entity_type, e.description
577 ));
578 }
579 }
580
581 if let Some(pp) = persona_prompt {
583 prompt.push_str("\n## Persona\n");
584 prompt.push_str(pp);
585 prompt.push('\n');
586 }
587
588 if let Some(xml) = capabilities_xml {
590 prompt.push_str("\n## Available Capabilities\n");
591 prompt.push_str("The following capabilities are relevant to your goal. ");
592 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
593 prompt.push_str(xml);
594 prompt.push('\n');
595 }
596
597 if let Some(manifest) = kernel_manifest {
599 prompt.push('\n');
600 prompt.push_str(manifest);
601 prompt.push('\n');
602 }
603
604 prompt.push_str(
606 "\n## Execution Environment\n\
607 Use `exec` for all command execution (git, gh, osascript, etc.).\n",
608 );
609
610 prompt.push_str(
611 "\nUse the available tools to accomplish the goal. \
612 Work methodically and verify your work against the acceptance criteria.",
613 );
614
615 prompt
616}
617
618fn build_user_prompt(seed: &Seed) -> String {
620 format!(
621 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
622 seed.goal,
623 seed.acceptance_criteria
624 .iter()
625 .enumerate()
626 .map(|(i, c)| format!("{}. {}", i + 1, c))
627 .collect::<Vec<_>>()
628 .join("\n")
629 )
630}
631
632impl std::fmt::Debug for AgentRuntime {
633 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634 f.debug_struct("AgentRuntime")
635 .field("model_id", &self.config.model_id)
636 .finish()
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use async_trait::async_trait;
644 use oxi_sdk::{AgentTool, ToolError};
645 use oxios_ouroboros::Entity;
646 use serde_json::Value;
647
648 struct DummyTool {
650 name: String,
651 }
652
653 #[async_trait]
654 impl AgentTool for DummyTool {
655 fn name(&self) -> &str {
656 &self.name
657 }
658 fn label(&self) -> &str {
659 &self.name
660 }
661 fn description(&self) -> &str {
662 "Test tool"
663 }
664 fn parameters_schema(&self) -> Value {
665 serde_json::json!({"type": "object"})
666 }
667
668 async fn execute(
669 &self,
670 _tool_call_id: &str,
671 _params: Value,
672 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
673 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
674 Ok(oxi_sdk::AgentToolResult::success("ok"))
675 }
676 }
677
678 #[test]
680 fn test_requires_tools_validation_passes() {
681 let registry = ToolRegistry::new();
682
683 registry.register(DummyTool {
685 name: "read".into(),
686 });
687 registry.register(DummyTool {
688 name: "exec".into(),
689 });
690
691 let required_tools = vec!["read".to_string(), "exec".to_string()];
693
694 let missing: Vec<&str> = required_tools
696 .iter()
697 .filter(|name| registry.get(name).is_none())
698 .map(|s| s.as_str())
699 .collect();
700
701 assert!(
702 missing.is_empty(),
703 "Expected no missing tools, got: {:?}",
704 missing
705 );
706 }
707
708 #[test]
710 fn test_requires_tools_validation_fails() {
711 let registry = ToolRegistry::new();
712
713 registry.register(DummyTool {
715 name: "read".into(),
716 });
717
718 let required_tools = vec![
720 "read".to_string(), "exec".to_string(), "nonexistent".to_string(), ];
724
725 let missing: Vec<&str> = required_tools
727 .iter()
728 .filter(|name| registry.get(name).is_none())
729 .map(|s| s.as_str())
730 .collect();
731
732 assert_eq!(missing, vec!["exec", "nonexistent"]);
733 }
734
735 #[test]
736 fn test_build_system_prompt_includes_goal() {
737 let seed = Seed {
738 id: uuid::Uuid::new_v4(),
739 goal: "Build a web server".into(),
740 constraints: vec!["Must use Rust".into()],
741 acceptance_criteria: vec!["Server responds to requests".into()],
742 ontology: vec![Entity {
743 name: "HttpServer".into(),
744 entity_type: "struct".into(),
745 description: "The main server struct".into(),
746 }],
747 created_at: chrono::Utc::now(),
748 generation: 0,
749 parent_seed_id: None,
750 cspace_hint: None,
751 };
752
753 let prompt = build_system_prompt(&seed, None, None, None);
754
755 assert!(prompt.contains("Build a web server"));
757
758 assert!(prompt.contains("Must use Rust"));
760
761 assert!(prompt.contains("Server responds to requests"));
763
764 assert!(prompt.contains("HttpServer"));
766 assert!(prompt.contains("struct"));
767 }
768
769 #[test]
770 fn test_build_system_prompt_empty() {
771 let seed = Seed {
772 id: uuid::Uuid::new_v4(),
773 goal: "Test goal".into(),
774 constraints: vec![],
775 acceptance_criteria: vec![],
776 ontology: vec![],
777 created_at: chrono::Utc::now(),
778 generation: 0,
779 parent_seed_id: None,
780 cspace_hint: None,
781 };
782
783 let prompt = build_system_prompt(&seed, None, None, None);
784
785 assert!(prompt.contains("Test goal"));
787 }
788}