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 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    // Inject persona system prompt
601    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    // Inject semantic capability index (from ToolRetriever)
608    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    // Inject kernel manifest (from CSpace)
617    if let Some(manifest) = kernel_manifest {
618        prompt.push('\n');
619        prompt.push_str(manifest);
620        prompt.push('\n');
621    }
622
623    // Execution environment guidance
624    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
640/// Build the user prompt from the seed.
641fn 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    /// A test tool that does nothing — used to populate the registry.
671    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 that requires_tools validation passes when all tools are present.
702    #[test]
703    fn test_requires_tools_validation_passes() {
704        let registry = ToolRegistry::new();
705
706        // Register the tools the program depends on.
707        registry.register(DummyTool {
708            name: "read".into(),
709        });
710        registry.register(DummyTool {
711            name: "exec".into(),
712        });
713
714        // Simulate a program that requires "read" and "exec".
715        let _required_tools = vec!["read".to_string(), "exec".to_string()];
716
717        // Validation: all required tools must exist in the registry.
718        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 that requires_tools validation fails when a tool is missing.
728    #[test]
729    fn test_requires_tools_validation_fails() {
730        let registry = ToolRegistry::new();
731
732        // Only register "read", not "exec" or "nonexistent".
733        registry.register(DummyTool {
734            name: "read".into(),
735        });
736
737        // Simulate a program that requires tools that don't exist.
738        let _required_tools = vec![
739            "read".to_string(),        // exists
740            "exec".to_string(),        // missing
741            "nonexistent".to_string(), // missing
742        ];
743
744        // Validation: find missing tools.
745        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        // Verify goal is present
771        assert!(prompt.contains("Build a web server"));
772
773        // Verify constraints are present
774        assert!(prompt.contains("Must use Rust"));
775
776        // Verify acceptance criteria is present
777        assert!(prompt.contains("Server responds to requests"));
778
779        // Verify domain entities are present
780        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        // Verify goal is present
801        assert!(prompt.contains("Test goal"));
802    }
803}