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