Skip to main content

oxios_kernel/
agent_runtime.rs

1//! Agent runtime: wraps oxi-agent's AgentLoop for use by the kernel.
2//!
3//! The AgentRuntime creates an oxi-agent `AgentLoop` session, configures it
4//! with a custom ToolRegistry based on the agent's CSpace (capability space),
5//! and executes a Seed's goal through the multi-turn LLM tool-calling loop.
6//!
7//! # Architecture
8//!
9//! All tool access goes through `KernelHandle` — the single syscall-table-like
10//! path for agent OS control. The runtime:
11//!
12//! 1. Resolves the agent's CSpace from persona/role/hint
13//! 2. Registers tools via `register_tools_from_cspace()`
14//! 3. Optionally queries `ToolRetriever` for semantic capability hints
15//! 4. Runs the agent loop with the assembled tool set
16//!
17//! Note: Since oxi-sdk 0.19+, `AgentLoop::run()` produces a `Send` future.
18//! We call it directly from async context without `spawn_blocking`.
19
20use 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
37/// Global LLM circuit breaker instance.
38static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<CircuitBreaker> = std::sync::OnceLock::new();
39
40/// Get the global LLM circuit breaker.
41fn get_llm_circuit_breaker() -> &'static CircuitBreaker {
42    LLM_CIRCUIT_BREAKER.get_or_init(CircuitBreaker::default)
43}
44
45/// Configuration for creating AgentRuntime instances.
46#[derive(Debug, Clone)]
47pub struct AgentRuntimeConfig {
48    /// Model ID in `provider/model` format (e.g. `anthropic/claude-sonnet-4-20250514`).
49    pub model_id: String,
50    /// Maximum number of agent turns before forcing a stop.
51    pub max_iterations: usize,
52    /// How to execute tool calls within a single turn.
53    pub tool_execution: ToolExecutionMode,
54    /// Whether auto-retry is enabled for retryable LLM errors.
55    pub auto_retry_enabled: bool,
56    /// Space ID for scoped memory and workspace.
57    pub space_id: Option<uuid::Uuid>,
58    /// Bound project paths. AgentRuntime sets CWD to paths[0].
59    pub project_paths: Vec<std::path::PathBuf>,
60    /// Scratch workspace directory for temp files.
61    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/// Mutable state shared between the event callback and the main execute flow.
79/// Wrapped in `Arc<Mutex<>>` because `AgentLoop::run()` takes `Fn` (not `FnMut`).
80#[derive(Default)]
81struct ExecuteState {
82    final_content: String,
83    steps_completed: usize,
84    success: bool,
85}
86
87/// Bundled context for `run_agent_loop()`.
88///
89/// All kernel access goes through `kernel_handle`. The CSpace determines
90/// which tools are registered for this agent.
91struct 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    /// Persona prompt for system prompt blending.
101    #[allow(dead_code)]
102    persona_prompt: Option<String>,
103}
104
105/// Runtime that wraps an oxi-agent `AgentLoop` for executing Seeds.
106///
107/// Each call to [`AgentRuntime::execute`] creates a fresh `AgentLoop`,
108/// builds a ToolRegistry based on the agent's CSpace, and runs it to completion.
109///
110/// All OS-level access goes through `KernelHandle` — the single syscall-table
111/// for agent control.
112pub struct AgentRuntime {
113    provider: Arc<dyn Provider>,
114    config: AgentRuntimeConfig,
115    /// Single path to all kernel services.
116    kernel_handle: Arc<KernelHandle>,
117    /// Persona manager for system prompt injection.
118    persona_manager: Option<Arc<PersonaManager>>,
119    /// Semantic tool retriever for capability discovery.
120    tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
121}
122
123impl AgentRuntime {
124    /// Creates a new agent runtime with kernel access.
125    ///
126    /// All tool access goes through `kernel_handle`.
127    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    /// Attach a PersonaManager for persona system prompt injection.
145    pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
146        self.persona_manager = Some(pm);
147        self
148    }
149
150    /// Set the runtime config (overrides defaults).
151    pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
152        self.config = config;
153        self
154    }
155
156    /// Attach a ToolRetriever for semantic capability discovery.
157    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    /// Execute a Seed by running the tool-calling loop to completion.
166    ///
167    /// 1. Resolves CSpace from persona/role/hint
168    /// 2. Registers tools via CSpace
169    /// 3. Recalls memories if available
170    /// 4. Runs the agent loop
171    pub async fn execute(&self, agent_id: AgentId, seed: &Seed) -> Result<ExecutionResult> {
172        let prompt = build_user_prompt(seed);
173
174        // Get active persona system prompt.
175        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        // Determine persona role for CSpace resolution.
182        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        // Resolve CSpace from persona role, seed hint, or default.
188        let cspace = resolve_cspace(
189            seed.cspace_hint.as_deref(),
190            persona_role.as_deref(),
191            Some("worker"),
192            agent_id,
193        );
194
195        // Build system prompt (without SKILL.md injection — capabilities are
196        // surfaced through the CSpace tool set + semantic retrieval instead).
197        let mut system_prompt = build_system_prompt(seed, persona_prompt.as_deref(), None, None);
198
199        // Semantic capability retrieval: find tools relevant to this seed's goal.
200        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        // Build kernel manifest from CSpace active domains.
222        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        // Rebuild system prompt with capabilities and manifest if available.
232        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        // Blend relevant memories into system prompt.
242        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        // Blend relevant knowledge notes into system prompt (KnowledgeLens, RFC-003 Phase 3).
253        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        // Clone everything to move into spawn_blocking.
280        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
318/// Run the AgentLoop.
319///
320/// Since oxi-sdk 0.19+, `AgentLoop::run()` produces a `Send` future,
321/// so we can call it directly from async context without `spawn_blocking`.
322async 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    // Extract workspace before using config
336    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    // Ensure workspace exists.
347    let _ = std::fs::create_dir_all(&workspace);
348
349    tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
350
351    // ── Register tools based on CSpace ──
352    let registry = ToolRegistry::new();
353    let search_cache = Arc::new(SearchCache::new());
354    register_tools_from_cspace(&registry, &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    // ── Program tools: registered individually from ProgramManager ──
363    let pm = kernel_handle.extensions.program_manager();
364
365    // Create a separate ExecTool for program routing (needed by ProgramTool).
366    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    // ── Load programs and register their tools ──
380    let programs: Vec<_> = pm.list_enabled().await;
381
382    // MCP bridge tools from program configs
383    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    // Program-defined tools
416    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    // Build the AgentLoop config from our runtime config.
451    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(), // Use first project path as workspace
470    };
471
472    let state = SharedState::new();
473    let agent_loop = AgentLoop::new(provider, loop_config, tools, state);
474
475    // Shared mutable state for the event callback.
476    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    // Run the agent loop. Since oxi-sdk 0.19+, `run()` produces a `Send` future.
482    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                        // Fire-and-forget: spawn async remember from sync callback
521                        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    // Record circuit breaker result after agent execution
535    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
558/// Build a system prompt from the Seed's goal, constraints, persona,
559/// and optionally a capability index and kernel manifest.
560///
561/// Note: SKILL.md content is no longer injected here. Capabilities are
562/// surfaced through the CSpace tool set + semantic retrieval instead.
563fn 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    // Preserve user's original wording so the agent sees exact language,
580    // filenames, and nuances that may have been abstracted in the goal.
581    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    // Inject persona system prompt
613    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    // Inject semantic capability index (from ToolRetriever)
620    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    // Inject kernel manifest (from CSpace)
629    if let Some(manifest) = kernel_manifest {
630        prompt.push('\n');
631        prompt.push_str(manifest);
632        prompt.push('\n');
633    }
634
635    // Execution environment guidance
636    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
668/// Build the user prompt from the seed.
669fn 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    /// A test tool that does nothing — used to populate the registry.
699    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 that requires_tools validation passes when all tools are present.
730    #[test]
731    fn test_requires_tools_validation_passes() {
732        let registry = ToolRegistry::new();
733
734        // Register the tools the program depends on.
735        registry.register(DummyTool {
736            name: "read".into(),
737        });
738        registry.register(DummyTool {
739            name: "exec".into(),
740        });
741
742        // Simulate a program that requires "read" and "exec".
743        let _required_tools = vec!["read".to_string(), "exec".to_string()];
744
745        // Validation: all required tools must exist in the registry.
746        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 that requires_tools validation fails when a tool is missing.
756    #[test]
757    fn test_requires_tools_validation_fails() {
758        let registry = ToolRegistry::new();
759
760        // Only register "read", not "exec" or "nonexistent".
761        registry.register(DummyTool {
762            name: "read".into(),
763        });
764
765        // Simulate a program that requires tools that don't exist.
766        let _required_tools = vec![
767            "read".to_string(),        // exists
768            "exec".to_string(),        // missing
769            "nonexistent".to_string(), // missing
770        ];
771
772        // Validation: find missing tools.
773        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        // Verify goal is present
800        assert!(prompt.contains("Build a web server"));
801
802        // Verify constraints are present
803        assert!(prompt.contains("Must use Rust"));
804
805        // Verify acceptance criteria is present
806        assert!(prompt.contains("Server responds to requests"));
807
808        // Verify domain entities are present
809        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        // Verify goal is present
831        assert!(prompt.contains("Test goal"));
832    }
833}