1use anyhow::Result;
21use oxi_sdk::ToolExecutionMode;
22use oxi_sdk::{AgentEvent, AgentLoop, AgentLoopConfig, SharedState, ToolRegistry};
23use oxi_sdk::{CompactionEvent, SearchCache};
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 match self
254 .kernel_handle
255 .knowledge_lens
256 .recall_for_context(&seed.goal, 5)
257 .await
258 {
259 Ok(ctx) if !ctx.notes.is_empty() => {
260 tracing::info!(
261 notes = ctx.notes.len(),
262 memories = ctx.memories.len(),
263 "Recalled knowledge context for seed"
264 );
265 let knowledge_blend = ctx
266 .notes
267 .iter()
268 .take(3)
269 .map(|n| format!("## {}\n\n{}", n.name, n.content))
270 .collect::<Vec<_>>()
271 .join("\n\n");
272 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
273 system_prompt.push_str(&knowledge_blend);
274 }
275 Ok(_) => tracing::debug!("No knowledge recalled"),
276 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
277 }
278
279 let config = self.config.clone();
281 let provider = Arc::clone(&self.provider);
282 let seed_id = seed.id;
283 let kernel_handle = Arc::clone(&self.kernel_handle);
284
285 let ctx = AgentLoopContext {
286 provider,
287 config,
288 system_prompt,
289 prompt,
290 seed_id,
291 agent_id,
292 kernel_handle,
293 cspace,
294 persona_prompt,
295 };
296
297 let (final_content, steps_completed, success) = run_agent_loop(ctx).await?;
298
299 tracing::info!(
300 seed_id = %seed_id,
301 steps = steps_completed,
302 success,
303 "AgentRuntime finished"
304 );
305
306 Ok(ExecutionResult {
307 output: if final_content.is_empty() {
308 "Agent execution completed".into()
309 } else {
310 final_content
311 },
312 steps_completed,
313 success,
314 })
315 }
316}
317
318async fn run_agent_loop(ctx: AgentLoopContext) -> Result<(String, usize, bool)> {
323 let AgentLoopContext {
324 provider,
325 config,
326 system_prompt,
327 prompt,
328 seed_id,
329 agent_id,
330 kernel_handle,
331 cspace,
332 persona_prompt: _,
333 } = ctx;
334
335 let workspace = if !config.project_paths.is_empty() {
337 config.project_paths[0].clone()
338 } else if let Some(ref ws) = config.workspace_dir {
339 ws.clone()
340 } else {
341 std::env::temp_dir()
342 .join("oxios-agent-workspace")
343 .join(agent_id.to_string())
344 };
345
346 let _ = std::fs::create_dir_all(&workspace);
348
349 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
350
351 let registry = ToolRegistry::new();
353 let search_cache = Arc::new(SearchCache::new());
354 register_tools_from_cspace(®istry, &kernel_handle, &cspace, search_cache, agent_id);
355
356 tracing::info!(
357 seed_id = %seed_id,
358 capabilities = cspace.len(),
359 "Tools registered from CSpace"
360 );
361
362 let pm = kernel_handle.extensions.program_manager();
364
365 let exec_for_programs: Option<std::sync::Arc<crate::tools::ExecTool>> = if cspace.can(
367 &crate::capability::ResourceRef::Exec {
368 mode: "shell".into(),
369 },
370 crate::capability::Rights::EXECUTE,
371 ) {
372 Some(std::sync::Arc::new(crate::tools::ExecTool::from_kernel(
373 &kernel_handle,
374 )))
375 } else {
376 None
377 };
378
379 let programs: Vec<_> = pm.list_enabled().await;
381
382 let mut mcp_server_names: Vec<String> = Vec::new();
384 for program in &programs {
385 for server_config in &program.meta.mcp_servers {
386 if server_config.enabled {
387 mcp_server_names.push(server_config.name.clone());
388 }
389 }
390 }
391
392 if !mcp_server_names.is_empty() {
393 let bridge = kernel_handle.mcp.bridge();
394 if let Err(e) = bridge.initialize_all().await {
395 tracing::warn!(error = %e, "MCP bridge init failed — skipping MCP tools");
396 } else {
397 let _ = bridge.list_tools().await;
398 for server_name in &mcp_server_names {
399 if let Some(tool_defs) = bridge.cached_tools(server_name).await {
400 for tool_def in tool_defs {
401 let wrapper = crate::tools::McpToolWrapper::new(
402 bridge.clone(),
403 server_name,
404 &tool_def.name,
405 tool_def.description.clone(),
406 serde_json::json!({"type": "object", "properties": {}}),
407 );
408 registry.register(wrapper);
409 }
410 }
411 }
412 }
413 }
414
415 for program in &programs {
417 let dep_names: Vec<&str> = program
418 .meta
419 .dependencies
420 .iter()
421 .map(|s| s.as_str())
422 .collect();
423 let missing = registry.missing(&dep_names);
424 if !missing.is_empty() {
425 tracing::warn!(
426 program = %program.meta.name,
427 missing_tools = ?missing,
428 "Skipping program: required tools not found"
429 );
430 continue;
431 }
432
433 for tool_def in &program.meta.tools {
434 if !tool_def.command.is_empty() {
435 if let Some(ref exec) = exec_for_programs {
436 let tool = crate::tools::ProgramTool::from_definition(
437 &program.meta.name,
438 tool_def,
439 &program.meta.host_requirements,
440 exec.clone(),
441 );
442 registry.register(tool);
443 }
444 }
445 }
446 }
447
448 let tools = Arc::new(registry);
449
450 let loop_config = AgentLoopConfig {
452 model_id: config.model_id,
453 system_prompt: Some(system_prompt),
454 temperature: 0.7,
455 max_tokens: 8192,
456 max_iterations: config.max_iterations,
457 tool_execution: config.tool_execution,
458 compaction_strategy: CompactionStrategy::Threshold(0.8),
459 context_window: 128_000,
460 compaction_instruction: None,
461 session_id: Some(seed_id.to_string()),
462 transport: None,
463 compact_on_start: false,
464 max_retry_delay_ms: None,
465 auto_retry_enabled: config.auto_retry_enabled,
466 auto_retry_max_attempts: 3,
467 auto_retry_base_delay_ms: 2000,
468 api_key: None,
469 workspace_dir: config.project_paths.first().cloned(), };
471
472 let state = SharedState::new();
473 let agent_loop = AgentLoop::new(provider, loop_config, tools, state);
474
475 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
477 let exec_state_clone = Arc::clone(&exec_state);
478 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
479 let session_id_for_callback = seed_id.to_string();
480
481 let result = agent_loop
483 .run(prompt, move |event| {
484 let mut s = exec_state_clone.lock();
485 match event {
486 AgentEvent::ToolExecutionEnd {
487 is_error: false, ..
488 } => {
489 s.steps_completed += 1;
490 }
491 AgentEvent::AgentEnd {
492 messages,
493 stop_reason,
494 ..
495 } => {
496 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
497 s.final_content = a.text_content();
498 }
499 s.success = stop_reason.as_deref() == Some("Stop");
500 }
501 AgentEvent::Error { message, .. } => {
502 s.final_content = message.clone();
503 s.success = false;
504 }
505 AgentEvent::Compaction { event } => {
506 let mm = &memory_for_callback;
507 if let CompactionEvent::Completed { result, .. } = event {
508 let entry = MemoryEntry {
509 id: uuid::Uuid::new_v4().to_string(),
510 memory_type: MemoryType::Conversation,
511 content: result.summary.clone(),
512 source: "compaction".to_string(),
513 session_id: Some(session_id_for_callback.clone()),
514 tags: vec![],
515 importance: 0.5,
516 created_at: chrono::Utc::now(),
517 accessed_at: chrono::Utc::now(),
518 access_count: 0,
519 };
520 let mm = mm.clone();
522 tokio::spawn(async move {
523 if let Err(e) = mm.remember(entry).await {
524 tracing::warn!(error = %e, "Failed to save compaction summary");
525 }
526 });
527 }
528 }
529 _ => {}
530 }
531 })
532 .await;
533
534 let circuit = get_llm_circuit_breaker();
536 if result.is_err() {
537 circuit.record_failure();
538 } else {
539 circuit.record_success();
540 }
541
542 if let Err(e) = result {
543 tracing::error!(seed_id = %seed_id, error = %e, "AgentLoop failed");
544 let s = exec_state.lock();
545 return Ok((format!("Agent failed: {e}"), s.steps_completed, false));
546 }
547
548 let s = exec_state.lock();
549 tracing::info!(
550 seed_id = %seed_id,
551 steps = s.steps_completed,
552 success = s.success,
553 "AgentLoop completed"
554 );
555 Ok((s.final_content.clone(), s.steps_completed, s.success))
556}
557
558fn build_system_prompt(
564 seed: &Seed,
565 persona_prompt: Option<&str>,
566 capabilities_xml: Option<&str>,
567 kernel_manifest: Option<&str>,
568) -> String {
569 let mut prompt = format!(
570 "You are an autonomous agent executing a specific task.\n\n\
571 ## Goal\n\
572 {}\n",
573 seed.goal,
574 );
575
576 if !seed.constraints.is_empty() {
577 prompt.push_str("\n## Constraints\n");
578 for (i, c) in seed.constraints.iter().enumerate() {
579 prompt.push_str(&format!("{}. {}\n", i + 1, c));
580 }
581 }
582
583 if !seed.acceptance_criteria.is_empty() {
584 prompt.push_str("\n## Acceptance Criteria\n");
585 for (i, c) in seed.acceptance_criteria.iter().enumerate() {
586 prompt.push_str(&format!("{}. {}\n", i + 1, c));
587 }
588 }
589
590 if !seed.ontology.is_empty() {
591 prompt.push_str("\n## Domain Entities\n");
592 for e in &seed.ontology {
593 prompt.push_str(&format!(
594 "- **{}** ({}): {}\n",
595 e.name, e.entity_type, e.description
596 ));
597 }
598 }
599
600 if let Some(pp) = persona_prompt {
602 prompt.push_str("\n## Persona\n");
603 prompt.push_str(pp);
604 prompt.push('\n');
605 }
606
607 if let Some(xml) = capabilities_xml {
609 prompt.push_str("\n## Available Capabilities\n");
610 prompt.push_str("The following capabilities are relevant to your goal. ");
611 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
612 prompt.push_str(xml);
613 prompt.push('\n');
614 }
615
616 if let Some(manifest) = kernel_manifest {
618 prompt.push('\n');
619 prompt.push_str(manifest);
620 prompt.push('\n');
621 }
622
623 prompt.push_str(
625 "\n## Execution Environment\n\
626 Use `exec` for all command execution (git, gh, osascript, etc.).\n",
627 );
628
629 prompt.push_str(
630 "\nUse the available tools to accomplish the goal. \
631 Work methodically and verify your work against the acceptance criteria. \
632 After completing the task, ALWAYS verify your work by reading back any files \
633 you created or checking the results of commands you ran. \
634 Include the verification output in your final response.",
635 );
636
637 prompt
638}
639
640fn build_user_prompt(seed: &Seed) -> String {
642 format!(
643 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
644 seed.goal,
645 seed.acceptance_criteria
646 .iter()
647 .enumerate()
648 .map(|(i, c)| format!("{}. {}", i + 1, c))
649 .collect::<Vec<_>>()
650 .join("\n")
651 )
652}
653
654impl std::fmt::Debug for AgentRuntime {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 f.debug_struct("AgentRuntime")
657 .field("model_id", &self.config.model_id)
658 .finish()
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use async_trait::async_trait;
666 use oxi_sdk::{AgentTool, ToolContext, ToolError};
667 use oxios_ouroboros::Entity;
668 use serde_json::Value;
669
670 struct DummyTool {
672 name: String,
673 }
674
675 #[async_trait]
676 impl AgentTool for DummyTool {
677 fn name(&self) -> &str {
678 &self.name
679 }
680 fn label(&self) -> &str {
681 &self.name
682 }
683 fn description(&self) -> &str {
684 "Test tool"
685 }
686 fn parameters_schema(&self) -> Value {
687 serde_json::json!({"type": "object"})
688 }
689
690 async fn execute(
691 &self,
692 _tool_call_id: &str,
693 _params: Value,
694 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
695 _ctx: &ToolContext,
696 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
697 Ok(oxi_sdk::AgentToolResult::success("ok"))
698 }
699 }
700
701 #[test]
703 fn test_requires_tools_validation_passes() {
704 let registry = ToolRegistry::new();
705
706 registry.register(DummyTool {
708 name: "read".into(),
709 });
710 registry.register(DummyTool {
711 name: "exec".into(),
712 });
713
714 let _required_tools = vec!["read".to_string(), "exec".to_string()];
716
717 let missing = registry.missing(&["read", "exec"]);
719
720 assert!(
721 missing.is_empty(),
722 "Expected no missing tools, got: {:?}",
723 missing
724 );
725 }
726
727 #[test]
729 fn test_requires_tools_validation_fails() {
730 let registry = ToolRegistry::new();
731
732 registry.register(DummyTool {
734 name: "read".into(),
735 });
736
737 let _required_tools = vec![
739 "read".to_string(), "exec".to_string(), "nonexistent".to_string(), ];
743
744 let missing = registry.missing(&["read", "exec", "nonexistent"]);
746
747 assert_eq!(missing, vec!["exec", "nonexistent"]);
748 }
749
750 #[test]
751 fn test_build_system_prompt_includes_goal() {
752 let seed = Seed {
753 id: uuid::Uuid::new_v4(),
754 goal: "Build a web server".into(),
755 constraints: vec!["Must use Rust".into()],
756 acceptance_criteria: vec!["Server responds to requests".into()],
757 ontology: vec![Entity {
758 name: "HttpServer".into(),
759 entity_type: "struct".into(),
760 description: "The main server struct".into(),
761 }],
762 created_at: chrono::Utc::now(),
763 generation: 0,
764 parent_seed_id: None,
765 cspace_hint: None,
766 };
767
768 let prompt = build_system_prompt(&seed, None, None, None);
769
770 assert!(prompt.contains("Build a web server"));
772
773 assert!(prompt.contains("Must use Rust"));
775
776 assert!(prompt.contains("Server responds to requests"));
778
779 assert!(prompt.contains("HttpServer"));
781 assert!(prompt.contains("struct"));
782 }
783
784 #[test]
785 fn test_build_system_prompt_empty() {
786 let seed = Seed {
787 id: uuid::Uuid::new_v4(),
788 goal: "Test goal".into(),
789 constraints: vec![],
790 acceptance_criteria: vec![],
791 ontology: vec![],
792 created_at: chrono::Utc::now(),
793 generation: 0,
794 parent_seed_id: None,
795 cspace_hint: None,
796 };
797
798 let prompt = build_system_prompt(&seed, None, None, None);
799
800 assert!(prompt.contains("Test goal"));
802 }
803}