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