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 in the Oxios operating system.\n\
571 You execute Seeds — immutable specifications with goals, constraints, and\n\
572 acceptance criteria. You have tools for reading, writing, editing files,\n\
573 running commands, and accessing kernel services.\n\n\
574 ## Goal\n\
575 {}\n",
576 seed.goal,
577 );
578
579 if !seed.original_request.is_empty() && seed.original_request != seed.goal {
582 prompt.push_str(&format!(
583 "\n## User's Original Request\n{}\n",
584 seed.original_request
585 ));
586 }
587
588 if !seed.constraints.is_empty() {
589 prompt.push_str("\n## Constraints\n");
590 for (i, c) in seed.constraints.iter().enumerate() {
591 prompt.push_str(&format!("{}. {}\n", i + 1, c));
592 }
593 }
594
595 if !seed.acceptance_criteria.is_empty() {
596 prompt.push_str("\n## Acceptance Criteria\n");
597 for (i, c) in seed.acceptance_criteria.iter().enumerate() {
598 prompt.push_str(&format!("{}. {}\n", i + 1, c));
599 }
600 }
601
602 if !seed.ontology.is_empty() {
603 prompt.push_str("\n## Domain Entities\n");
604 for e in &seed.ontology {
605 prompt.push_str(&format!(
606 "- **{}** ({}): {}\n",
607 e.name, e.entity_type, e.description
608 ));
609 }
610 }
611
612 if let Some(pp) = persona_prompt {
614 prompt.push_str("\n## Persona\n");
615 prompt.push_str(pp);
616 prompt.push('\n');
617 }
618
619 if let Some(xml) = capabilities_xml {
621 prompt.push_str("\n## Available Capabilities\n");
622 prompt.push_str("The following capabilities are relevant to your goal. ");
623 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
624 prompt.push_str(xml);
625 prompt.push('\n');
626 }
627
628 if let Some(manifest) = kernel_manifest {
630 prompt.push('\n');
631 prompt.push_str(manifest);
632 prompt.push('\n');
633 }
634
635 prompt.push_str(
637 "\n## Execution Protocol\n\
638 1. UNDERSTAND — Read the Seed completely before acting.\n\
639 2. PLAN — Determine the minimal set of actions needed.\n\
640 3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
641 4. VERIFY — After each action, check the result: created a file? read it back.\n\
642 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
643 ## Hard Boundaries\n\
644 - NEVER modify files outside the workspace scope\n\
645 - NEVER execute destructive commands without confirming scope\n\
646 - NEVER claim completion without evidence — show the output, not your opinion\n\
647 - NEVER add features or improvements beyond the Seed scope\n\
648 - If you cannot complete the Seed, say so and explain WHY\n\n\
649 ## Scope Guard\n\
650 The Seed defines your universe. Do not:\n\
651 - Refactor code the Seed didn't mention\n\
652 - Add tests the Seed didn't require\n\
653 - Change configuration the Seed didn't specify\n\
654 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
655 ## Error Handling\n\
656 - If a tool fails, read the error message carefully before retrying\n\
657 - If a command fails, do NOT immediately retry with --force or sudo\n\
658 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
659 ## Shape Matching\n\
660 Match your output to the task: simple task → concise response.\n\
661 Do not write 50 lines when 5 would do.\n\
662 Use `exec` for all command execution (git, gh, osascript, etc.).",
663 );
664
665 prompt
666}
667
668fn build_user_prompt(seed: &Seed) -> String {
670 format!(
671 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
672 seed.goal,
673 seed.acceptance_criteria
674 .iter()
675 .enumerate()
676 .map(|(i, c)| format!("{}. {}", i + 1, c))
677 .collect::<Vec<_>>()
678 .join("\n")
679 )
680}
681
682impl std::fmt::Debug for AgentRuntime {
683 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684 f.debug_struct("AgentRuntime")
685 .field("model_id", &self.config.model_id)
686 .finish()
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use async_trait::async_trait;
694 use oxi_sdk::{AgentTool, ToolContext, ToolError};
695 use oxios_ouroboros::Entity;
696 use serde_json::Value;
697
698 struct DummyTool {
700 name: String,
701 }
702
703 #[async_trait]
704 impl AgentTool for DummyTool {
705 fn name(&self) -> &str {
706 &self.name
707 }
708 fn label(&self) -> &str {
709 &self.name
710 }
711 fn description(&self) -> &str {
712 "Test tool"
713 }
714 fn parameters_schema(&self) -> Value {
715 serde_json::json!({"type": "object"})
716 }
717
718 async fn execute(
719 &self,
720 _tool_call_id: &str,
721 _params: Value,
722 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
723 _ctx: &ToolContext,
724 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
725 Ok(oxi_sdk::AgentToolResult::success("ok"))
726 }
727 }
728
729 #[test]
731 fn test_requires_tools_validation_passes() {
732 let registry = ToolRegistry::new();
733
734 registry.register(DummyTool {
736 name: "read".into(),
737 });
738 registry.register(DummyTool {
739 name: "exec".into(),
740 });
741
742 let _required_tools = vec!["read".to_string(), "exec".to_string()];
744
745 let missing = registry.missing(&["read", "exec"]);
747
748 assert!(
749 missing.is_empty(),
750 "Expected no missing tools, got: {:?}",
751 missing
752 );
753 }
754
755 #[test]
757 fn test_requires_tools_validation_fails() {
758 let registry = ToolRegistry::new();
759
760 registry.register(DummyTool {
762 name: "read".into(),
763 });
764
765 let _required_tools = vec![
767 "read".to_string(), "exec".to_string(), "nonexistent".to_string(), ];
771
772 let missing = registry.missing(&["read", "exec", "nonexistent"]);
774
775 assert_eq!(missing, vec!["exec", "nonexistent"]);
776 }
777
778 #[test]
779 fn test_build_system_prompt_includes_goal() {
780 let seed = Seed {
781 id: uuid::Uuid::new_v4(),
782 goal: "Build a web server".into(),
783 constraints: vec!["Must use Rust".into()],
784 acceptance_criteria: vec!["Server responds to requests".into()],
785 ontology: vec![Entity {
786 name: "HttpServer".into(),
787 entity_type: "struct".into(),
788 description: "The main server struct".into(),
789 }],
790 created_at: chrono::Utc::now(),
791 generation: 0,
792 parent_seed_id: None,
793 cspace_hint: None,
794 original_request: String::new(),
795 };
796
797 let prompt = build_system_prompt(&seed, None, None, None);
798
799 assert!(prompt.contains("Build a web server"));
801
802 assert!(prompt.contains("Must use Rust"));
804
805 assert!(prompt.contains("Server responds to requests"));
807
808 assert!(prompt.contains("HttpServer"));
810 assert!(prompt.contains("struct"));
811 }
812
813 #[test]
814 fn test_build_system_prompt_empty() {
815 let seed = Seed {
816 id: uuid::Uuid::new_v4(),
817 goal: "Test goal".into(),
818 constraints: vec![],
819 acceptance_criteria: vec![],
820 ontology: vec![],
821 created_at: chrono::Utc::now(),
822 generation: 0,
823 parent_seed_id: None,
824 cspace_hint: None,
825 original_request: String::new(),
826 };
827
828 let prompt = build_system_prompt(&seed, None, None, None);
829
830 assert!(prompt.contains("Test goal"));
832 }
833}