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: `AgentLoop::run()` produces a `!Send` future (the internal tool
18//! execution uses `Box<dyn Future>` without `Send`). We keep `spawn_blocking`
19//! to stay compatible with the `Supervisor` trait's `Send` bounds.
20
21use anyhow::Result;
22use oxi_sdk::{CompactionEvent, SearchCache};
23use oxi_sdk::ToolExecutionMode;
24use oxi_sdk::{AgentEvent, AgentLoop, AgentLoopConfig, SharedState, ToolRegistry};
25use oxi_sdk::{CompactionStrategy, Provider};
26use parking_lot::Mutex;
27use std::sync::Arc;
28
29use crate::capability::resolve::resolve_cspace;
30use crate::circuit_breaker::CircuitBreaker;
31use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
32use crate::persona_manager::PersonaManager;
33use crate::tools::registration::register_tools_from_cspace;
34use crate::types::AgentId;
35use crate::KernelHandle;
36use oxios_ouroboros::{ExecutionResult, Seed};
37
38/// Global LLM circuit breaker instance.
39static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<CircuitBreaker> = std::sync::OnceLock::new();
40
41/// Get the global LLM circuit breaker.
42fn get_llm_circuit_breaker() -> &'static CircuitBreaker {
43    LLM_CIRCUIT_BREAKER.get_or_init(CircuitBreaker::default)
44}
45
46/// Configuration for creating AgentRuntime instances.
47#[derive(Debug, Clone)]
48pub struct AgentRuntimeConfig {
49    /// Model ID in `provider/model` format (e.g. `anthropic/claude-sonnet-4-20250514`).
50    pub model_id: String,
51    /// Maximum number of agent turns before forcing a stop.
52    pub max_iterations: usize,
53    /// How to execute tool calls within a single turn.
54    pub tool_execution: ToolExecutionMode,
55    /// Whether auto-retry is enabled for retryable LLM errors.
56    pub auto_retry_enabled: bool,
57    /// Space ID for scoped memory and workspace.
58    pub space_id: Option<uuid::Uuid>,
59    /// Bound project paths. AgentRuntime sets CWD to paths[0].
60    pub project_paths: Vec<std::path::PathBuf>,
61    /// Scratch workspace directory for temp files.
62    pub workspace_dir: Option<std::path::PathBuf>,
63}
64
65impl Default for AgentRuntimeConfig {
66    fn default() -> Self {
67        Self {
68            model_id: "anthropic/claude-sonnet-4-20250514".into(),
69            max_iterations: 20,
70            tool_execution: ToolExecutionMode::Parallel,
71            auto_retry_enabled: true,
72            space_id: None,
73            project_paths: Vec::new(),
74            workspace_dir: None,
75        }
76    }
77}
78
79/// Mutable state shared between the event callback and the main execute flow.
80/// Wrapped in `Arc<Mutex<>>` because `AgentLoop::run()` takes `Fn` (not `FnMut`).
81#[derive(Default)]
82struct ExecuteState {
83    final_content: String,
84    steps_completed: usize,
85    success: bool,
86}
87
88/// Bundled context for `run_agent_loop()`.
89///
90/// All kernel access goes through `kernel_handle`. The CSpace determines
91/// which tools are registered for this agent.
92struct AgentLoopContext {
93    provider: Arc<dyn Provider>,
94    config: AgentRuntimeConfig,
95    system_prompt: String,
96    prompt: String,
97    seed_id: uuid::Uuid,
98    agent_id: AgentId,
99    kernel_handle: Arc<KernelHandle>,
100    cspace: crate::capability::CSpace,
101    /// Persona prompt for system prompt blending.
102    #[allow(dead_code)]
103    persona_prompt: Option<String>,
104}
105
106/// Runtime that wraps an oxi-agent `AgentLoop` for executing Seeds.
107///
108/// Each call to [`AgentRuntime::execute`] creates a fresh `AgentLoop`,
109/// builds a ToolRegistry based on the agent's CSpace, and runs it to completion.
110///
111/// All OS-level access goes through `KernelHandle` — the single syscall-table
112/// for agent control.
113pub struct AgentRuntime {
114    provider: Arc<dyn Provider>,
115    config: AgentRuntimeConfig,
116    /// Single path to all kernel services.
117    kernel_handle: Arc<KernelHandle>,
118    /// Persona manager for system prompt injection.
119    persona_manager: Option<Arc<PersonaManager>>,
120    /// Semantic tool retriever for capability discovery.
121    tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
122}
123
124impl AgentRuntime {
125    /// Creates a new agent runtime with kernel access.
126    ///
127    /// All tool access goes through `kernel_handle`.
128    pub fn new(
129        provider: Arc<dyn Provider>,
130        model_id: impl Into<String>,
131        kernel_handle: Arc<KernelHandle>,
132    ) -> Self {
133        Self {
134            provider,
135            config: AgentRuntimeConfig {
136                model_id: model_id.into(),
137                ..Default::default()
138            },
139            kernel_handle,
140            persona_manager: None,
141            tool_retriever: None,
142        }
143    }
144
145    /// Attach a PersonaManager for persona system prompt injection.
146    pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
147        self.persona_manager = Some(pm);
148        self
149    }
150
151    /// Set the runtime config (overrides defaults).
152    pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
153        self.config = config;
154        self
155    }
156
157    /// Attach a ToolRetriever for semantic capability discovery.
158    pub fn with_tool_retriever(
159        mut self,
160        retriever: Arc<crate::tools::retrieval::ToolRetriever>,
161    ) -> Self {
162        self.tool_retriever = Some(retriever);
163        self
164    }
165
166    /// Execute a Seed by running the tool-calling loop to completion.
167    ///
168    /// 1. Resolves CSpace from persona/role/hint
169    /// 2. Registers tools via CSpace
170    /// 3. Recalls memories if available
171    /// 4. Runs the agent loop
172    pub async fn execute(&self, agent_id: AgentId, seed: &Seed) -> Result<ExecutionResult> {
173        let prompt = build_user_prompt(seed);
174
175        // Get active persona system prompt.
176        let persona_prompt = self
177            .persona_manager
178            .as_ref()
179            .map(|pm| pm.active_system_prompt())
180            .filter(|s| !s.trim().is_empty());
181
182        // Determine persona role for CSpace resolution.
183        let persona_role = self
184            .persona_manager
185            .as_ref()
186            .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
187
188        // Resolve CSpace from persona role, seed hint, or default.
189        let cspace = resolve_cspace(
190            seed.cspace_hint.as_deref(),
191            persona_role.as_deref(),
192            Some("worker"),
193            agent_id,
194        );
195
196        // Build system prompt (without SKILL.md injection — capabilities are
197        // surfaced through the CSpace tool set + semantic retrieval instead).
198        let mut system_prompt = build_system_prompt(seed, persona_prompt.as_deref(), None, None);
199
200        // Semantic capability retrieval: find tools relevant to this seed's goal.
201        let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
202            match retriever.embedder().embed(&seed.goal).await {
203                Ok(query_vec) => {
204                    let results = retriever.retrieve(&query_vec, 8);
205                    if results.is_empty() {
206                        None
207                    } else {
208                        let xml = crate::tools::retrieval::format_capability_index(&results);
209                        tracing::info!(count = results.len(), "Retrieved relevant capabilities");
210                        Some(xml)
211                    }
212                }
213                Err(e) => {
214                    tracing::warn!(error = %e, "Failed to embed seed goal for retrieval");
215                    None
216                }
217            }
218        } else {
219            None
220        };
221
222        // Build kernel manifest from CSpace active domains.
223        let kernel_manifest = {
224            let domains = cspace.active_domains();
225            if domains.is_empty() {
226                None
227            } else {
228                Some(crate::tools::retrieval::build_kernel_manifest(&domains))
229            }
230        };
231
232        // Rebuild system prompt with capabilities and manifest if available.
233        if capabilities_xml.is_some() || kernel_manifest.is_some() {
234            system_prompt = build_system_prompt(
235                seed,
236                persona_prompt.as_deref(),
237                capabilities_xml.as_deref(),
238                kernel_manifest.as_deref(),
239            );
240        }
241
242        // Blend relevant memories into system prompt.
243        let memory_manager = self.kernel_handle.agents.memory_manager();
244        match memory_manager.recall(&seed.goal).await {
245            Ok(memories) if !memories.is_empty() => {
246                tracing::info!(count = memories.len(), "Recalled memories for seed");
247                system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
248            }
249            Ok(_) => tracing::debug!("No memories recalled"),
250            Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
251        }
252
253        // Clone everything to move into spawn_blocking.
254        let config = self.config.clone();
255        let provider = Arc::clone(&self.provider);
256        let seed_id = seed.id;
257        let kernel_handle = Arc::clone(&self.kernel_handle);
258
259        let ctx = AgentLoopContext {
260            provider,
261            config,
262            system_prompt,
263            prompt,
264            seed_id,
265            agent_id,
266            kernel_handle,
267            cspace,
268            persona_prompt,
269        };
270
271        let (final_content, steps_completed, success) =
272            tokio::task::spawn_blocking(move || run_agent_loop(ctx)).await??;
273
274        tracing::info!(
275            seed_id = %seed_id,
276            steps = steps_completed,
277            success,
278            "AgentRuntime finished"
279        );
280
281        Ok(ExecutionResult {
282            output: if final_content.is_empty() {
283                "Agent execution completed".into()
284            } else {
285                final_content
286            },
287            steps_completed,
288            success,
289        })
290    }
291}
292
293/// Run the AgentLoop inside a blocking thread.
294///
295/// Run the agent loop inside a blocking thread.
296///
297/// `AgentLoop::run()` still captures non-`Send` state internally
298/// (`FinalizedToolCallEntry::Future` lacks `+ Send`). Until oxi fixes this,
299/// we use `spawn_blocking` + `Handle::block_on`.
300fn run_agent_loop(ctx: AgentLoopContext) -> Result<(String, usize, bool)> {
301    let AgentLoopContext {
302        provider,
303        config,
304        system_prompt,
305        prompt,
306        seed_id,
307        agent_id,
308        kernel_handle,
309        cspace,
310        persona_prompt: _,
311    } = ctx;
312
313    // Extract workspace before using config
314    let workspace = if !config.project_paths.is_empty() {
315        config.project_paths[0].clone()
316    } else if let Some(ref ws) = config.workspace_dir {
317        ws.clone()
318    } else {
319        std::env::temp_dir()
320            .join("oxios-agent-workspace")
321            .join(agent_id.to_string())
322    };
323
324    // Ensure workspace exists.
325    let _ = std::fs::create_dir_all(&workspace);
326
327    tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
328
329    // ── Register tools based on CSpace ──
330    let registry = ToolRegistry::new();
331    let search_cache = Arc::new(SearchCache::new());
332    register_tools_from_cspace(&registry, &kernel_handle, &cspace, search_cache, agent_id);
333
334    tracing::info!(
335        seed_id = %seed_id,
336        capabilities = cspace.len(),
337        "Tools registered from CSpace"
338    );
339
340    // ── Program tools: registered individually from ProgramManager ──
341    let pm = kernel_handle.extensions.program_manager();
342
343    // Create a separate ExecTool for program routing (needed by ProgramTool).
344    let exec_for_programs: Option<std::sync::Arc<crate::tools::ExecTool>> = if cspace.can(
345        &crate::capability::ResourceRef::Exec {
346            mode: "shell".into(),
347        },
348        crate::capability::Rights::EXECUTE,
349    ) {
350        Some(std::sync::Arc::new(crate::tools::ExecTool::from_kernel(
351            &kernel_handle,
352        )))
353    } else {
354        None
355    };
356
357    {
358        let rt = tokio::runtime::Handle::current();
359        let programs: Vec<_> = rt.block_on(async { pm.list_enabled().await });
360
361        // MCP bridge tools from program configs
362        let mut mcp_server_names: Vec<String> = Vec::new();
363        for program in &programs {
364            for server_config in &program.meta.mcp_servers {
365                if server_config.enabled {
366                    mcp_server_names.push(server_config.name.clone());
367                }
368            }
369        }
370
371        if !mcp_server_names.is_empty() {
372            let bridge = kernel_handle.mcp.bridge();
373            if let Err(e) = rt.block_on(bridge.initialize_all()) {
374                tracing::warn!(error = %e, "MCP bridge init failed — skipping MCP tools");
375            } else {
376                let _ = rt.block_on(bridge.list_tools());
377                for server_name in &mcp_server_names {
378                    if let Some(tool_defs) = rt.block_on(bridge.cached_tools(server_name)) {
379                        for tool_def in tool_defs {
380                            let wrapper = crate::tools::McpToolWrapper::new(
381                                bridge.clone(),
382                                server_name,
383                                &tool_def.name,
384                                tool_def.description.clone(),
385                                serde_json::json!({"type": "object", "properties": {}}),
386                            );
387                            registry.register(wrapper);
388                        }
389                    }
390                }
391            }
392        }
393
394        // Program-defined tools
395        for program in &programs {
396            let missing_tools: Vec<&str> = program
397                .meta
398                .dependencies
399                .iter()
400                .filter(|tool_name| registry.get(tool_name).is_none())
401                .map(|s| s.as_str())
402                .collect();
403            if !missing_tools.is_empty() {
404                tracing::warn!(
405                    program = %program.meta.name,
406                    missing_tools = ?missing_tools,
407                    "Skipping program: required tools not found"
408                );
409                continue;
410            }
411
412            for tool_def in &program.meta.tools {
413                if !tool_def.command.is_empty() {
414                    if let Some(ref exec) = exec_for_programs {
415                        let tool = crate::tools::ProgramTool::from_definition(
416                            &program.meta.name,
417                            tool_def,
418                            &program.meta.host_requirements,
419                            exec.clone(),
420                        );
421                        registry.register(tool);
422                    }
423                }
424            }
425        }
426    }
427
428    let tools = Arc::new(registry);
429
430    // Build the AgentLoop config from our runtime config.
431    let loop_config = AgentLoopConfig {
432        model_id: config.model_id,
433        system_prompt: Some(system_prompt),
434        temperature: 0.7,
435        max_tokens: 8192,
436        max_iterations: config.max_iterations,
437        tool_execution: config.tool_execution,
438        compaction_strategy: CompactionStrategy::Threshold(0.8),
439        context_window: 128_000,
440        compaction_instruction: None,
441        session_id: Some(seed_id.to_string()),
442        transport: None,
443        compact_on_start: false,
444        max_retry_delay_ms: None,
445        auto_retry_enabled: config.auto_retry_enabled,
446        auto_retry_max_attempts: 3,
447        auto_retry_base_delay_ms: 2000,
448        api_key: None,
449        workspace_dir: config.project_paths.first().cloned(), // Use first project path as workspace
450    };
451
452    let state = SharedState::new();
453    let agent_loop = AgentLoop::new(provider, loop_config, tools, state);
454
455    // Shared mutable state for the event callback.
456    let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
457    let exec_state_clone = Arc::clone(&exec_state);
458    let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
459    let session_id_for_callback = seed_id.to_string();
460
461    // Run the agent loop inside a blocking thread.
462    // AgentLoop::run() captures !Send state internally.
463    let rt = tokio::runtime::Handle::current();
464    let rt_for_callback = rt.clone();
465    rt.block_on(async {
466        let result = agent_loop
467            .run(prompt, move |event| {
468                let mut s = exec_state_clone.lock();
469                match event {
470                    AgentEvent::ToolExecutionEnd {
471                        is_error: false, ..
472                    } => {
473                        s.steps_completed += 1;
474                    }
475                    AgentEvent::AgentEnd {
476                        messages,
477                        stop_reason,
478                        ..
479                    } => {
480                        if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
481                            s.final_content = a.text_content();
482                        }
483                        s.success = stop_reason.as_deref() == Some("Stop");
484                    }
485                    AgentEvent::Error { message, .. } => {
486                        s.final_content = message.clone();
487                        s.success = false;
488                    }
489                    AgentEvent::Compaction { event } => {
490                        let mm = &memory_for_callback;
491                        if let CompactionEvent::Completed { result, .. } = event {
492                            let entry = MemoryEntry {
493                                id: uuid::Uuid::new_v4().to_string(),
494                                memory_type: MemoryType::Conversation,
495                                content: result.summary.clone(),
496                                source: "compaction".to_string(),
497                                session_id: Some(session_id_for_callback.clone()),
498                                tags: vec![],
499                                importance: 0.5,
500                                created_at: chrono::Utc::now(),
501                                accessed_at: chrono::Utc::now(),
502                                access_count: 0,
503                            };
504                            if let Err(e) = rt_for_callback.block_on(mm.remember(entry)) {
505                                tracing::warn!(error = %e, "Failed to save compaction summary");
506                            }
507                        }
508                    }
509                    _ => {}
510                }
511            })
512            .await;
513
514        // Record circuit breaker result after agent execution
515        let circuit = get_llm_circuit_breaker();
516        if result.is_err() {
517            circuit.record_failure();
518        } else {
519            circuit.record_success();
520        }
521
522        if let Err(e) = result {
523            tracing::error!(seed_id = %seed_id, error = %e, "AgentLoop failed");
524            let s = exec_state.lock();
525            return Ok((format!("Agent failed: {e}"), s.steps_completed, false));
526        }
527
528        let s = exec_state.lock();
529        tracing::info!(
530            seed_id = %seed_id,
531            steps = s.steps_completed,
532            success = s.success,
533            "AgentLoop completed"
534        );
535        Ok((s.final_content.clone(), s.steps_completed, s.success))
536    })
537}
538
539/// Build a system prompt from the Seed's goal, constraints, persona,
540/// and optionally a capability index and kernel manifest.
541///
542/// Note: SKILL.md content is no longer injected here. Capabilities are
543/// surfaced through the CSpace tool set + semantic retrieval instead.
544fn build_system_prompt(
545    seed: &Seed,
546    persona_prompt: Option<&str>,
547    capabilities_xml: Option<&str>,
548    kernel_manifest: Option<&str>,
549) -> String {
550    let mut prompt = format!(
551        "You are an autonomous agent executing a specific task.\n\n\
552         ## Goal\n\
553         {}\n",
554        seed.goal,
555    );
556
557    if !seed.constraints.is_empty() {
558        prompt.push_str("\n## Constraints\n");
559        for (i, c) in seed.constraints.iter().enumerate() {
560            prompt.push_str(&format!("{}. {}\n", i + 1, c));
561        }
562    }
563
564    if !seed.acceptance_criteria.is_empty() {
565        prompt.push_str("\n## Acceptance Criteria\n");
566        for (i, c) in seed.acceptance_criteria.iter().enumerate() {
567            prompt.push_str(&format!("{}. {}\n", i + 1, c));
568        }
569    }
570
571    if !seed.ontology.is_empty() {
572        prompt.push_str("\n## Domain Entities\n");
573        for e in &seed.ontology {
574            prompt.push_str(&format!(
575                "- **{}** ({}): {}\n",
576                e.name, e.entity_type, e.description
577            ));
578        }
579    }
580
581    // Inject persona system prompt
582    if let Some(pp) = persona_prompt {
583        prompt.push_str("\n## Persona\n");
584        prompt.push_str(pp);
585        prompt.push('\n');
586    }
587
588    // Inject semantic capability index (from ToolRetriever)
589    if let Some(xml) = capabilities_xml {
590        prompt.push_str("\n## Available Capabilities\n");
591        prompt.push_str("The following capabilities are relevant to your goal. ");
592        prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
593        prompt.push_str(xml);
594        prompt.push('\n');
595    }
596
597    // Inject kernel manifest (from CSpace)
598    if let Some(manifest) = kernel_manifest {
599        prompt.push('\n');
600        prompt.push_str(manifest);
601        prompt.push('\n');
602    }
603
604    // Execution environment guidance
605    prompt.push_str(
606        "\n## Execution Environment\n\
607         Use `exec` for all command execution (git, gh, osascript, etc.).\n",
608    );
609
610    prompt.push_str(
611        "\nUse the available tools to accomplish the goal. \
612         Work methodically and verify your work against the acceptance criteria.",
613    );
614
615    prompt
616}
617
618/// Build the user prompt from the seed.
619fn build_user_prompt(seed: &Seed) -> String {
620    format!(
621        "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
622        seed.goal,
623        seed.acceptance_criteria
624            .iter()
625            .enumerate()
626            .map(|(i, c)| format!("{}. {}", i + 1, c))
627            .collect::<Vec<_>>()
628            .join("\n")
629    )
630}
631
632impl std::fmt::Debug for AgentRuntime {
633    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634        f.debug_struct("AgentRuntime")
635            .field("model_id", &self.config.model_id)
636            .finish()
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use async_trait::async_trait;
644    use oxi_sdk::{AgentTool, ToolError};
645    use oxios_ouroboros::Entity;
646    use serde_json::Value;
647
648    /// A test tool that does nothing — used to populate the registry.
649    struct DummyTool {
650        name: String,
651    }
652
653    #[async_trait]
654    impl AgentTool for DummyTool {
655        fn name(&self) -> &str {
656            &self.name
657        }
658        fn label(&self) -> &str {
659            &self.name
660        }
661        fn description(&self) -> &str {
662            "Test tool"
663        }
664        fn parameters_schema(&self) -> Value {
665            serde_json::json!({"type": "object"})
666        }
667
668        async fn execute(
669            &self,
670            _tool_call_id: &str,
671            _params: Value,
672            _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
673        ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
674            Ok(oxi_sdk::AgentToolResult::success("ok"))
675        }
676    }
677
678    /// Test that requires_tools validation passes when all tools are present.
679    #[test]
680    fn test_requires_tools_validation_passes() {
681        let registry = ToolRegistry::new();
682
683        // Register the tools the program depends on.
684        registry.register(DummyTool {
685            name: "read".into(),
686        });
687        registry.register(DummyTool {
688            name: "exec".into(),
689        });
690
691        // Simulate a program that requires "read" and "exec".
692        let required_tools = vec!["read".to_string(), "exec".to_string()];
693
694        // Validation: all required tools must exist in the registry.
695        let missing: Vec<&str> = required_tools
696            .iter()
697            .filter(|name| registry.get(name).is_none())
698            .map(|s| s.as_str())
699            .collect();
700
701        assert!(
702            missing.is_empty(),
703            "Expected no missing tools, got: {:?}",
704            missing
705        );
706    }
707
708    /// Test that requires_tools validation fails when a tool is missing.
709    #[test]
710    fn test_requires_tools_validation_fails() {
711        let registry = ToolRegistry::new();
712
713        // Only register "read", not "exec" or "nonexistent".
714        registry.register(DummyTool {
715            name: "read".into(),
716        });
717
718        // Simulate a program that requires tools that don't exist.
719        let required_tools = vec![
720            "read".to_string(),        // exists
721            "exec".to_string(),        // missing
722            "nonexistent".to_string(), // missing
723        ];
724
725        // Validation: find missing tools.
726        let missing: Vec<&str> = required_tools
727            .iter()
728            .filter(|name| registry.get(name).is_none())
729            .map(|s| s.as_str())
730            .collect();
731
732        assert_eq!(missing, vec!["exec", "nonexistent"]);
733    }
734
735    #[test]
736    fn test_build_system_prompt_includes_goal() {
737        let seed = Seed {
738            id: uuid::Uuid::new_v4(),
739            goal: "Build a web server".into(),
740            constraints: vec!["Must use Rust".into()],
741            acceptance_criteria: vec!["Server responds to requests".into()],
742            ontology: vec![Entity {
743                name: "HttpServer".into(),
744                entity_type: "struct".into(),
745                description: "The main server struct".into(),
746            }],
747            created_at: chrono::Utc::now(),
748            generation: 0,
749            parent_seed_id: None,
750            cspace_hint: None,
751        };
752
753        let prompt = build_system_prompt(&seed, None, None, None);
754
755        // Verify goal is present
756        assert!(prompt.contains("Build a web server"));
757
758        // Verify constraints are present
759        assert!(prompt.contains("Must use Rust"));
760
761        // Verify acceptance criteria is present
762        assert!(prompt.contains("Server responds to requests"));
763
764        // Verify domain entities are present
765        assert!(prompt.contains("HttpServer"));
766        assert!(prompt.contains("struct"));
767    }
768
769    #[test]
770    fn test_build_system_prompt_empty() {
771        let seed = Seed {
772            id: uuid::Uuid::new_v4(),
773            goal: "Test goal".into(),
774            constraints: vec![],
775            acceptance_criteria: vec![],
776            ontology: vec![],
777            created_at: chrono::Utc::now(),
778            generation: 0,
779            parent_seed_id: None,
780            cspace_hint: None,
781        };
782
783        let prompt = build_system_prompt(&seed, None, None, None);
784
785        // Verify goal is present
786        assert!(prompt.contains("Test goal"));
787    }
788}