1use anyhow::Result;
31use oxi_sdk::observability::AuditTrail;
32use oxi_sdk::{
33 Agent, AgentConfig, AgentEvent, CompactionEvent, CompactionStrategy, ProviderResolver,
34};
35use oxi_sdk::{SearchCache, ToolExecutionMode, ToolRegistry};
36use parking_lot::Mutex;
37use std::collections::HashMap;
38use std::sync::Arc;
39use crate::access_manager::{AccessGate, AgentContext, TracingAuditSink, TrailAuditSink};
43use crate::capability::resolve::resolve_cspace;
44use crate::engine::OxiosEngine;
45use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
46use crate::persona::PersonaManager;
47use crate::tools::registration::register_tools_from_cspace_gated;
48
49use crate::KernelHandle;
50use crate::event_bus::KernelEvent;
51use crate::session_context::SessionContext;
52use crate::types::AgentId;
53use oxios_ouroboros::{Directive, Entity, ExecEnv, ExecutionResult, Seed};
54
55static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<oxi_sdk::ProviderCircuitBreaker> =
57 std::sync::OnceLock::new();
58
59fn get_llm_circuit_breaker() -> &'static oxi_sdk::ProviderCircuitBreaker {
61 LLM_CIRCUIT_BREAKER.get_or_init(|| {
62 oxi_sdk::ProviderCircuitBreaker::new(
63 "global".to_string(),
64 oxi_sdk::CircuitBreakerConfig::default(),
65 )
66 })
67}
68
69#[derive(Debug, Clone)]
71pub struct AgentRuntimeConfig {
72 pub model_id: String,
74 pub tool_execution: ToolExecutionMode,
76 pub auto_retry_enabled: bool,
78 pub project_paths: Vec<std::path::PathBuf>,
80 pub workspace_dir: Option<std::path::PathBuf>,
82 pub api_key: Option<String>,
84 pub provider_options: Option<oxi_sdk::ProviderOptions>,
86 pub rate_limit_per_minute: usize,
88 pub token_budget: usize,
90 pub audit_tool_calls: bool,
92 pub provider_rpm: u32,
95}
96
97impl Default for AgentRuntimeConfig {
98 fn default() -> Self {
99 Self {
100 model_id: String::new(),
101 tool_execution: ToolExecutionMode::Parallel,
102 auto_retry_enabled: true,
103 project_paths: Vec::new(),
104 workspace_dir: None,
105 api_key: None,
106 provider_options: None,
107 rate_limit_per_minute: 0,
108 token_budget: 0,
109 audit_tool_calls: false,
110 provider_rpm: 0,
111 }
112 }
113}
114
115#[derive(Default)]
117struct ExecuteState {
118 final_content: String,
119 steps_completed: usize,
120 success: bool,
121 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
125 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
129 tool_call_ids: Vec<String>,
132 tool_args_map: std::collections::HashMap<String, String>,
134 tool_error_map: std::collections::HashMap<String, bool>,
136 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
138 total_input_tokens: u64,
140 total_output_tokens: u64,
142}
143
144pub struct AgentRuntime {
153 engine_handle: Arc<crate::engine::EngineHandle>,
154 config: AgentRuntimeConfig,
155 kernel_handle: Arc<KernelHandle>,
157 persona_manager: Option<Arc<PersonaManager>>,
159 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
161 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
163 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
165 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
167}
168
169impl AgentRuntime {
170 pub fn new(
176 engine_handle: Arc<crate::engine::EngineHandle>,
177 kernel_handle: Arc<KernelHandle>,
178 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
179 ) -> Self {
180 Self {
181 engine_handle,
182 config: AgentRuntimeConfig::default(),
183 kernel_handle,
184 persona_manager: None,
185 tool_retriever: None,
186 routing_stats,
187 persistence_hook: None,
188 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
189 }
190 }
191
192 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
194 self.persona_manager = Some(pm);
195 self
196 }
197
198 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
200 self.config = config;
201 self
202 }
203
204 pub fn with_tool_retriever(
206 mut self,
207 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
208 ) -> Self {
209 self.tool_retriever = Some(retriever);
210 self
211 }
212
213 pub fn with_persistence_hook(
215 mut self,
216 hook: Arc<crate::persistence_hook::PersistenceHook>,
217 ) -> Self {
218 self.persistence_hook = Some(hook);
219 self
220 }
221
222 pub async fn execute(
230 &self,
231 agent_id: AgentId,
232 seed: &Seed,
233 session_ctx: &mut SessionContext,
234 ) -> Result<ExecutionResult> {
235 let session_id: Option<String> = Some(seed.id.to_string());
239 self.execute_with_session(agent_id, seed, session_ctx, session_id)
240 .await
241 }
242
243 pub async fn execute_with_session(
246 &self,
247 agent_id: AgentId,
248 seed: &Seed,
249 session_ctx: &mut SessionContext,
250 session_id: Option<String>,
251 ) -> Result<ExecutionResult> {
252 self.execute_inner(
253 agent_id,
254 &seed.goal,
255 &seed.original_request,
256 &seed.constraints,
257 &seed.acceptance_criteria,
258 &seed.ontology,
259 seed.cspace_hint.as_deref(),
260 &seed.mount_paths,
261 seed.workspace_context.as_deref(),
262 session_ctx,
263 session_id,
264 Some(seed),
265 )
266 .await
267 }
268
269 pub async fn execute_directive(
277 &self,
278 agent_id: AgentId,
279 directive: &Directive,
280 env: &ExecEnv,
281 session_ctx: &mut SessionContext,
282 ) -> Result<ExecutionResult> {
283 let session_id: Option<String> = Some(agent_id.to_string());
287 self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
288 .await
289 }
290
291 pub async fn execute_directive_with_session(
294 &self,
295 agent_id: AgentId,
296 directive: &Directive,
297 env: &ExecEnv,
298 session_ctx: &mut SessionContext,
299 session_id: Option<String>,
300 ) -> Result<ExecutionResult> {
301 let ontology: &[Entity] = &[];
302 self.execute_inner(
303 agent_id,
304 &directive.goal,
305 &directive.original_request,
306 &directive.constraints,
307 &directive.acceptance_criteria,
308 ontology,
309 env.cspace_hint.as_deref(),
310 &env.mount_paths,
311 env.workspace_context.as_deref(),
312 session_ctx,
313 session_id,
314 None,
315 )
316 .await
317 }
318
319 #[allow(clippy::too_many_arguments)]
327 async fn execute_inner(
328 &self,
329 agent_id: AgentId,
330 goal: &str,
331 original_request: &str,
332 constraints: &[String],
333 acceptance_criteria: &[String],
334 ontology: &[Entity],
335 cspace_hint: Option<&str>,
336 mount_paths: &[std::path::PathBuf],
337 workspace_context: Option<&str>,
338 session_ctx: &mut SessionContext,
339 session_id: Option<String>,
340 persistence_seed: Option<&Seed>,
341 ) -> Result<ExecutionResult> {
342 let prompt = build_user_prompt_inner(goal, acceptance_criteria);
343
344 let persona_prompt = self
346 .persona_manager
347 .as_ref()
348 .map(|pm| pm.active_system_prompt())
349 .filter(|s| !s.trim().is_empty());
350
351 let persona_role = self
353 .persona_manager
354 .as_ref()
355 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
356
357 let cspace = resolve_cspace(
359 cspace_hint,
360 persona_role.as_deref(),
361 Some("worker"),
362 agent_id,
363 );
364
365 let mut system_prompt = build_system_prompt_inner(
368 goal,
369 original_request,
370 constraints,
371 acceptance_criteria,
372 ontology,
373 workspace_context,
374 persona_prompt.as_deref(),
375 None,
376 None,
377 );
378
379 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
381 match retriever.embedder().embed(goal).await {
382 Ok(query_vec) => {
383 let results = retriever.retrieve(&query_vec, 8);
384 if results.is_empty() {
385 None
386 } else {
387 let xml = crate::tools::retrieval::format_capability_index(&results);
388 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
389 Some(xml)
390 }
391 }
392 Err(e) => {
393 tracing::warn!(error = %e, "Failed to embed goal for retrieval");
394 None
395 }
396 }
397 } else {
398 None
399 };
400
401 let kernel_manifest = {
403 let domains = cspace.active_domains();
404 if domains.is_empty() {
405 None
406 } else {
407 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
408 }
409 };
410
411 if capabilities_xml.is_some() || kernel_manifest.is_some() {
413 system_prompt = build_system_prompt_inner(
414 goal,
415 original_request,
416 constraints,
417 acceptance_criteria,
418 ontology,
419 workspace_context,
420 persona_prompt.as_deref(),
421 capabilities_xml.as_deref(),
422 kernel_manifest.as_deref(),
423 );
424 }
425
426 let memory_manager = self.kernel_handle.agents.memory_manager();
428 match memory_manager
429 .recall_with_proactive(goal, &mut session_ctx.recall_timing)
430 .await
431 {
432 Ok(memories) if !memories.is_empty() => {
433 tracing::info!(count = memories.len(), "Recalled memories for task");
434 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
435 }
436 Ok(_) => tracing::debug!("No memories recalled"),
437 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
438 }
439
440 if let Some(sona) = memory_manager.sona_engine() {
442 match sona.adapt(goal).await {
443 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
444 tracing::info!(
445 domain = %pattern.domain,
446 confidence = pattern.confidence,
447 "SONA learned pattern injected"
448 );
449 system_prompt.push_str(&format!(
450 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
451 pattern.confidence * 100.0,
452 pattern.strategy,
453 ));
454 }
455 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
456 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
457 }
458 }
459
460 match self
462 .kernel_handle
463 .knowledge_lens
464 .recall_for_context(goal, 5)
465 .await
466 {
467 Ok(ctx) if !ctx.notes.is_empty() => {
468 tracing::info!(
469 notes = ctx.notes.len(),
470 memories = ctx.memories.len(),
471 "Recalled knowledge context for task"
472 );
473 let knowledge_blend = ctx
474 .notes
475 .iter()
476 .take(3)
477 .map(|n| format!("## {}\n\n{}", n.name, n.content))
478 .collect::<Vec<_>>()
479 .join("\n\n");
480 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
481 system_prompt.push_str(&knowledge_blend);
482 }
483 Ok(_) => tracing::debug!("No knowledge recalled"),
484 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
485 }
486
487 let engine = self.engine_handle.get();
492 let model_id = engine.default_model_id().to_string();
493 engine.resolve_model(&model_id)?;
494 let exec_id = persistence_seed
497 .map(|s| s.id)
498 .unwrap_or_else(uuid::Uuid::new_v4);
499
500 let mut config = self.config.clone();
505 config.model_id = model_id;
506 let kernel_handle = Arc::clone(&self.kernel_handle);
507
508 let audit_trail: Option<Arc<AuditTrail>> =
510 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
511
512 let (
513 mut final_content,
514 steps_completed,
515 success,
516 trajectory_steps,
517 agent,
518 tool_call_ids,
519 tool_args_map,
520 tool_error_map,
521 tool_timestamps,
522 total_input_tokens,
523 total_output_tokens,
524 ) = {
525 run_agent(
526 &config,
527 &engine,
528 kernel_handle,
529 system_prompt,
530 prompt,
531 exec_id,
532 goal.to_string(),
533 agent_id,
534 cspace,
535 audit_trail,
536 self.routing_stats.clone(),
537 session_id.clone(),
538 mount_paths,
539 )
540 .await?
541 };
542
543 if final_content.is_empty() && !trajectory_steps.is_empty() {
550 let tool_summary: Vec<String> = trajectory_steps
551 .iter()
552 .enumerate()
553 .map(|(i, step)| {
554 let truncated = if step.output.len() > 800 {
555 let mut end = 800;
559 while end > 0 && !step.output.is_char_boundary(end) {
560 end -= 1;
561 }
562 format!("{}...", &step.output[..end])
563 } else {
564 step.output.clone()
565 };
566 format!("{}. [{}] {}", i + 1, step.input, truncated)
567 })
568 .collect();
569 let summary_prompt = format!(
570 "도구 실행 결과:\n\n{}\n\n\
571 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
572 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
573 tool_summary.join("\n")
574 );
575 match agent.run(summary_prompt).await {
576 Ok((response, _events)) => {
577 if !response.content.is_empty() {
578 tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
579 final_content = response.content;
580 }
581 }
582 Err(e) => {
583 tracing::warn!(error = %e, "Post-execution summary failed");
584 }
585 }
586 }
587
588 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
591 .iter()
592 .enumerate()
593 .map(|(i, step)| {
594 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
595 let args_str = tool_call_ids
596 .get(i)
597 .and_then(|id| tool_args_map.get(id))
598 .cloned()
599 .unwrap_or_default();
600 let is_error = tool_call_ids
601 .get(i)
602 .and_then(|id| tool_error_map.get(id))
603 .copied()
604 .unwrap_or(false);
605 let timestamp = tool_call_ids
606 .get(i)
607 .and_then(|id| tool_timestamps.get(id))
608 .copied();
609 let input_str = truncate_json_str(&args_str, 500);
610 oxios_ouroboros::ToolCallRecord {
611 tool: step.input.clone(),
612 input: input_str,
613 output: step.output.clone(),
614 duration_ms: step.duration_ms,
615 is_error,
616 tool_call_id: tc_id,
617 timestamp,
618 }
619 })
620 .collect();
621
622 tracing::info!(
623 exec_id = %exec_id,
624 steps = steps_completed,
625 success,
626 tool_calls = tool_calls.len(),
627 "AgentRuntime finished"
628 );
629
630 let result = ExecutionResult {
631 output: final_content.clone(),
632 steps_completed,
633 success,
634 tool_calls,
635 tokens_input: total_input_tokens,
636 tokens_output: total_output_tokens,
637 model_id: self.engine_handle.get().default_model_id().to_string(),
638 };
639
640 if let Some(seed) = persistence_seed
645 && success
646 && let Some(hook) = &self.persistence_hook
647 {
648 let already_saved_knowledge = trajectory_steps
649 .iter()
650 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
651 let hook = hook.clone();
652 let seed_clone = seed.clone();
653 let traj_clone = trajectory_steps.clone();
654 let output_clone = final_content.clone();
655 let sid = session_id.clone();
656 let msg_index = {
659 let mut counter = self.session_msg_counter.lock();
660 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
661 let current = *idx;
662 *idx += 1;
663 current
664 };
665 tokio::spawn(async move {
666 match hook
667 .evaluate(
668 &seed_clone,
669 &traj_clone,
670 &output_clone,
671 already_saved_knowledge,
672 )
673 .await
674 {
675 Ok(plan) => {
676 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
677 tracing::info!(
678 memory = plan.memory.len(),
679 knowledge = plan.knowledge.len(),
680 message_index = msg_index,
681 "PersistenceHook executing plan"
682 );
683 let session_id = sid.unwrap_or_default();
684 hook.execute_plan(plan, &session_id, msg_index).await;
685 }
686 }
687 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
688 }
689 });
690 }
691
692 Ok(result)
693 }
694}
695
696#[allow(clippy::too_many_arguments)]
701async fn run_agent(
702 config: &AgentRuntimeConfig,
703 engine: &OxiosEngine,
704 kernel_handle: Arc<KernelHandle>,
705 system_prompt: String,
706 prompt: String,
707 seed_id: uuid::Uuid,
708 seed_goal: String,
709 agent_id: AgentId,
710 cspace: crate::capability::CSpace,
711 audit_trail: Option<Arc<AuditTrail>>,
712 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
713 session_id: Option<String>,
714 mount_paths: &[std::path::PathBuf],
715) -> Result<(
716 String,
717 usize,
718 bool,
719 Vec<oxios_memory::memory::sona::TrajectoryStep>,
720 Arc<Agent>,
721 Vec<String>,
722 std::collections::HashMap<String, String>,
723 std::collections::HashMap<String, bool>,
724 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
725 u64,
726 u64,
727)> {
728 let workspace = if !mount_paths.is_empty() {
732 mount_paths[0].clone()
733 } else if !config.project_paths.is_empty() {
734 config.project_paths[0].clone()
735 } else if let Some(ref ws) = config.workspace_dir {
736 ws.clone()
737 } else {
738 std::env::temp_dir()
739 .join("oxios-agent-workspace")
740 .join(agent_id.to_string())
741 };
742
743 let _ = std::fs::create_dir_all(&workspace);
745
746 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
747
748 {
765 use crate::access_manager::{Role, Subject};
766 let agent_name = format!("agent-{agent_id}");
767 let mut am = kernel_handle.exec.access_manager().lock();
768 let perms = am.get_or_create_permissions(&agent_name);
769
770 if let Ok(cwd) = std::env::current_dir() {
772 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
773 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
774 perms.allow_path(&cwd_pattern);
775 tracing::debug!(
776 agent = %agent_name,
777 path = %cwd_pattern,
778 "Added CWD to agent allowed paths"
779 );
780 }
781 }
782
783 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
785 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
786 perms.allow_path(&ws_pattern);
787 }
788
789 for mount_path in mount_paths {
794 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
795 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
796 perms.allow_path(&pattern);
797 tracing::debug!(
798 agent = %agent_name,
799 path = %pattern,
800 "Added Mount path to agent allowed paths (RFC-025)"
801 );
802 }
803 }
804
805 let kernel_ws = kernel_handle
807 .state
808 .workspace_path()
809 .to_string_lossy()
810 .to_string();
811 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
812 if kernel_ws_pattern != ws_pattern
813 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
814 {
815 perms.allow_path(&kernel_ws_pattern);
816 }
817
818 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
820 perms.allow_path("/tmp/**");
821 }
822
823 let rbac_subject = Subject::Agent(agent_id);
825 am.rbac_manager_mut()
826 .assign_role(rbac_subject, Role::Superuser);
827 }
828
829 let _trace_guard = crate::observability::tracer().start(
831 format!("seed-{}", &seed_id.to_string()[..8]).as_str(),
832 oxi_sdk::SpanKind::Agent,
833 );
834
835 let registry = ToolRegistry::new();
837 let search_cache = Arc::new(SearchCache::new());
838
839 let agent_context = AgentContext {
841 agent_id,
842 agent_name: format!("agent-{agent_id}"),
843 cspace: Arc::new(cspace.clone()),
844 };
845
846 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
849 let audit_path = kernel_handle
850 .state
851 .workspace_path()
852 .join("audit")
853 .join("access.jsonl");
854 Arc::new(TrailAuditSink::new(trail, audit_path))
855 } else {
856 Arc::new(TracingAuditSink)
857 };
858
859 let access_gate = Arc::new(AccessGate::new(
861 kernel_handle.exec.access_manager().clone(),
862 Arc::new(kernel_handle.exec.config_snapshot()),
863 audit_sink,
864 ));
865
866 register_tools_from_cspace_gated(
867 ®istry,
868 &kernel_handle,
869 &cspace,
870 search_cache,
871 agent_id,
872 access_gate,
873 agent_context,
874 );
875
876 tracing::info!(
877 seed_id = %seed_id,
878 capabilities = cspace.len(),
879 "Tools registered from CSpace"
880 );
881
882 let agent_config = AgentConfig {
890 name: format!("agent-{agent_id}"),
891 description: None,
892 model_id: config.model_id.clone(),
893 system_prompt: Some(system_prompt.clone()),
894 timeout_seconds: 300,
895 temperature: Some(0.7),
896 max_tokens: Some(8192),
897 compaction_strategy: CompactionStrategy::Threshold(0.8),
898 compaction_instruction: None,
899 context_window: 128_000,
900 api_key: config.api_key.clone(),
901 workspace_dir: Some(workspace.clone()),
902 output_mode: None,
903 provider_options: config.provider_options.clone(),
904 session_id: None,
910 ..Default::default()
911 };
912
913 let agent = if config.provider_rpm > 0 {
928 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
930 let provider_name = engine.resolve_model(&config.model_id)?.provider;
931 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
932
933 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
935 if config.rate_limit_per_minute > 0 {
936 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
937 config.rate_limit_per_minute,
938 ));
939 }
940 if config.token_budget > 0 {
941 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
942 config.token_budget,
943 ));
944 }
945 if config.audit_tool_calls {
946 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
947 tracing::Level::INFO,
948 ));
949 }
950
951 let agent = Arc::new(Agent::new_with_resolver(
953 provider,
954 agent_config,
955 Arc::new(registry),
956 resolver,
957 ));
958
959 if !pipeline.is_empty() {
961 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
962 let agent_id_for_hooks = agent_id.to_string();
963 let hooks = oxi_sdk::middleware::build_hooks(
964 Arc::new(pipeline),
965 agent_id_for_hooks,
966 terminate_flag,
967 );
968 agent.set_hooks(hooks);
969 }
970
971 agent
972 } else {
973 let mut builder = engine
975 .oxi()
976 .agent(agent_config)
977 .workspace(&workspace)
978 .system_prompt(system_prompt);
979
980 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
990 .names()
991 .into_iter()
992 .filter_map(|name| registry.get(&name))
993 .collect();
994
995 if let Some(auth) = engine.authorizer() {
997 builder = builder.authorizer(auth.clone());
998 }
999 if let Some(tracer) = engine.tracer() {
1000 builder = builder.tracer(tracer.clone());
1001 }
1002 if let Some(ct) = engine.cost_tracker() {
1003 builder = builder.cost_tracker(ct.clone());
1004 }
1005
1006 if config.rate_limit_per_minute > 0 {
1009 builder = builder.with_rate_limit(config.rate_limit_per_minute);
1010 }
1011 if config.token_budget > 0 {
1012 builder = builder.with_token_budget(config.token_budget);
1013 }
1014 if config.audit_tool_calls {
1015 builder = builder.with_logging();
1016 }
1017
1018 let built = builder.build()?;
1019 let agent = Arc::new(built);
1020
1021 let agent_tools = agent.tools();
1026 for tool in cspace_tool_arcs {
1027 agent_tools.register_arc(tool);
1028 }
1029
1030 agent
1031 };
1032
1033 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
1035 let exec_state_cb = Arc::clone(&exec_state);
1036 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
1037 let session_id_for_callback = seed_id.to_string();
1038 let model_id_for_callback = config.model_id.clone();
1039 let agent_id_for_callback = agent_id.to_string();
1040 let routing_stats_for_cb = routing_stats.clone();
1041 let transparency_session: Option<String> = session_id.clone();
1044 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1045
1046 let result = agent
1048 .run_streaming(prompt, move |event| {
1049 let mut s = exec_state_cb.lock();
1050 match event {
1051 AgentEvent::ToolExecutionStart {
1052 tool_name,
1053 tool_call_id,
1054 args,
1055 context,
1056 ..
1057 } => {
1058 let idx = s.trajectory_steps.len();
1060 s.pending_tools
1061 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1062 s.tool_args_map.insert(
1063 tool_call_id.clone(),
1064 serde_json::to_string(&args).unwrap_or_default(),
1065 );
1066 s.tool_timestamps
1067 .insert(tool_call_id.clone(), chrono::Utc::now());
1068 s.tool_call_ids.push(tool_call_id.clone());
1069 s.trajectory_steps
1070 .push(oxios_memory::memory::sona::TrajectoryStep {
1071 input: tool_name.clone(),
1072 output: String::new(),
1073 duration_ms: 0,
1074 confidence: 0.0,
1075 });
1076 if let Some(ref sid) = transparency_session {
1078 let context_json = context
1079 .as_ref()
1080 .map(serde_json::to_value)
1081 .transpose()
1082 .unwrap_or(None);
1083 let _ =
1084 kernel_handle_for_cb
1085 .infra
1086 .publish(KernelEvent::ToolExecutionStarted {
1087 session_id: sid.clone(),
1088 tool_name: tool_name.clone(),
1089 tool_call_id: tool_call_id.clone(),
1090 tool_args: args.clone(),
1091 context: context_json,
1092 });
1093 }
1094 }
1095 AgentEvent::ToolExecutionUpdate {
1096 tool_call_id,
1097 tool_name,
1098 partial_result,
1099 tab_id,
1100 context,
1101 } => {
1102 if let Some(ref sid) = transparency_session {
1112 let context_json = context
1113 .as_ref()
1114 .map(serde_json::to_value)
1115 .transpose()
1116 .unwrap_or(None);
1117 let _ = kernel_handle_for_cb.infra.publish(
1118 KernelEvent::ToolExecutionProgress {
1119 session_id: sid.clone(),
1120 tool_call_id: tool_call_id.clone(),
1121 tool_name: tool_name.clone(),
1122 progress: partial_result,
1123 tab_id,
1124 context: context_json,
1125 },
1126 );
1127 }
1128 }
1129 AgentEvent::ToolExecutionEnd {
1130 tool_name,
1131 tool_call_id,
1132 is_error,
1133 result,
1134 ..
1135 } => {
1136 if !is_error {
1137 s.steps_completed += 1;
1138 }
1139 let mut duration_ms: u64 = 0;
1141 let mut summary = String::new();
1142 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1143 duration_ms = start.elapsed().as_millis() as u64;
1144 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1145 summary = summarize_tool_result(&result.content, 200);
1146 step.output = summary.clone();
1147 step.duration_ms = duration_ms;
1148 step.confidence = if is_error { 0.3 } else { 0.8 };
1149 }
1150 }
1151 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1152 if let Some(ref sid) = transparency_session {
1154 let _ = kernel_handle_for_cb.infra.publish(
1155 KernelEvent::ToolExecutionFinished {
1156 session_id: sid.clone(),
1157 tool_call_id: tool_call_id.clone(),
1158 tool_name: tool_name.clone(),
1159 duration_ms,
1160 is_error,
1161 output_summary: summary,
1162 },
1163 );
1164 }
1165 }
1166 AgentEvent::AgentEnd {
1167 messages,
1168 stop_reason,
1169 ..
1170 } => {
1171 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1172 s.final_content = a.text_content();
1173 }
1174 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1180 }
1181 AgentEvent::Error { message, .. } => {
1182 s.final_content = message.clone();
1183 s.success = false;
1184 }
1185 AgentEvent::Usage {
1186 input_tokens,
1187 output_tokens,
1188 } => {
1189 s.total_input_tokens += input_tokens as u64;
1191 s.total_output_tokens += output_tokens as u64;
1192
1193 let agent_label = format!("agent-{agent_id_for_callback}");
1195 crate::observability::cost_tracker().record(
1196 &agent_label,
1197 &oxi_sdk::Model::new(
1198 &model_id_for_callback,
1199 &model_id_for_callback,
1200 oxi_sdk::Api::OpenAiCompletions,
1201 "unknown",
1202 "https://unknown.com",
1203 ),
1204 oxi_sdk::TokenUsage {
1205 input: input_tokens as u64,
1206 output: output_tokens as u64,
1207 cache_read: 0,
1208 cache_write: 0,
1209 },
1210 );
1211
1212 if let Some(stats) = &routing_stats_for_cb {
1214 let cost = crate::kernel_handle::engine_api::estimate_cost(
1215 &model_id_for_callback,
1216 input_tokens as u64,
1217 output_tokens as u64,
1218 );
1219 stats.record_model_usage(&model_id_for_callback, cost);
1220 }
1221 if let Some(ref sid) = transparency_session {
1223 let _ = kernel_handle_for_cb
1224 .infra
1225 .publish(KernelEvent::TokenUsageUpdate {
1226 session_id: sid.clone(),
1227 input_tokens: input_tokens as u64,
1228 output_tokens: output_tokens as u64,
1229 });
1230 }
1231 }
1232 AgentEvent::Compaction {
1233 event: CompactionEvent::Completed { result, .. },
1234 } => {
1235 handle_compaction(
1236 result.summary.clone(),
1237 session_id_for_callback.clone(),
1238 memory_for_callback.clone(),
1239 );
1240 if let Some(ref sid) = transparency_session {
1242 let _ =
1243 kernel_handle_for_cb
1244 .infra
1245 .publish(KernelEvent::ReasoningFragment {
1246 session_id: sid.clone(),
1247 content: result.summary.clone(),
1248 source: "compaction".to_string(),
1249 });
1250 }
1251 }
1252 _ => {}
1253 }
1254 })
1255 .await;
1256
1257 let circuit = get_llm_circuit_breaker();
1259 if result.is_err() {
1260 circuit.record_failure();
1261 crate::metrics::get_metrics()
1262 .llm_circuit_breaker_state
1263 .set(1.0);
1264 } else {
1265 circuit.record_success();
1266 crate::metrics::get_metrics()
1267 .llm_circuit_breaker_state
1268 .set(0.0);
1269 }
1270
1271 if let Err(e) = result {
1272 tracing::error!(seed_id = %seed_id, error = %e, "Agent failed");
1273 let s = exec_state.lock();
1274 return Ok((
1275 format!("Agent failed: {e}"),
1276 s.steps_completed,
1277 false,
1278 s.trajectory_steps.clone(),
1279 agent,
1280 s.tool_call_ids.clone(),
1281 s.tool_args_map.clone(),
1282 s.tool_error_map.clone(),
1283 s.tool_timestamps.clone(),
1284 s.total_input_tokens,
1285 s.total_output_tokens,
1286 ));
1287 }
1288
1289 let s = exec_state.lock();
1290 tracing::info!(
1291 seed_id = %seed_id,
1292 steps = s.steps_completed,
1293 success = s.success,
1294 "Agent completed"
1295 );
1296
1297 if !s.trajectory_steps.is_empty()
1300 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1301 {
1302 let steps = s.trajectory_steps.clone();
1303 let success = s.success;
1304 let sona = Arc::clone(sona);
1305 let domain = infer_domain(&seed_goal);
1306 tokio::spawn(async move {
1307 let verdict = if success {
1308 oxios_memory::memory::sona::Verdict::Success
1309 } else {
1310 oxios_memory::memory::sona::Verdict::Failure
1311 };
1312 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1313 if let Err(e) = sona.record(trajectory).await {
1314 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1315 }
1316 });
1317 }
1318
1319 Ok((
1320 s.final_content.clone(),
1321 s.steps_completed,
1322 s.success,
1323 s.trajectory_steps.clone(),
1324 agent,
1325 s.tool_call_ids.clone(),
1326 s.tool_args_map.clone(),
1327 s.tool_error_map.clone(),
1328 s.tool_timestamps.clone(),
1329 s.total_input_tokens,
1330 s.total_output_tokens,
1331 ))
1332}
1333
1334fn summarize_tool_result(result: &str, max_len: usize) -> String {
1339 let trimmed = result.trim();
1340 if trimmed.chars().count() <= max_len {
1341 return trimmed.to_string();
1342 }
1343 let first_line = trimmed.lines().next().unwrap_or("");
1345 if first_line.chars().count() <= max_len {
1346 first_line.to_string()
1347 } else {
1348 let take = max_len.saturating_sub(3);
1349 let truncated: String = if take == 0 {
1350 first_line.chars().take(max_len).collect()
1351 } else {
1352 first_line.chars().take(take).collect()
1353 };
1354 format!("{truncated}...")
1355 }
1356}
1357fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1358 if json_str.len() <= max_len {
1359 return json_str.to_string();
1360 }
1361 let take = max_len.saturating_sub(3);
1364 if take == 0 {
1365 return json_str.chars().take(max_len).collect();
1366 }
1367 let truncated: String = json_str.chars().take(take).collect();
1368 format!("{truncated}...")
1369}
1370
1371fn infer_domain(goal: &str) -> String {
1376 let lower = goal.to_lowercase();
1377 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1378
1379 if keywords.iter().any(|k| {
1381 [
1382 "test",
1383 "tests",
1384 "spec",
1385 "testing",
1386 "assert",
1387 "unit test",
1388 "integration",
1389 ]
1390 .contains(k)
1391 }) {
1392 return "testing".to_string();
1393 }
1394 if keywords
1395 .iter()
1396 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1397 {
1398 return "deployment".to_string();
1399 }
1400 if keywords
1401 .iter()
1402 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1403 {
1404 return "bugfix".to_string();
1405 }
1406 if keywords
1407 .iter()
1408 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1409 {
1410 return "refactoring".to_string();
1411 }
1412 if keywords
1413 .iter()
1414 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1415 {
1416 return "documentation".to_string();
1417 }
1418 if keywords
1419 .iter()
1420 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1421 {
1422 return "development".to_string();
1423 }
1424 if keywords
1425 .iter()
1426 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1427 {
1428 return "analysis".to_string();
1429 }
1430 if keywords
1431 .iter()
1432 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1433 {
1434 return "configuration".to_string();
1435 }
1436
1437 let meaningful: Vec<&str> = lower
1439 .split_whitespace()
1440 .filter(|w| w.len() > 2)
1441 .take(2)
1442 .collect();
1443 if meaningful.len() >= 2 {
1444 meaningful.join("_")
1445 } else {
1446 "general".to_string()
1447 }
1448}
1449
1450fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1456 let entry = MemoryEntry {
1457 id: uuid::Uuid::new_v4().to_string(),
1458 memory_type: MemoryType::Conversation,
1459 tier: crate::memory::MemoryTier::Warm,
1460 content: summary,
1461 content_hash: 0,
1462 source: "compaction".to_string(),
1463 session_id: Some(session_id),
1464 tags: vec![],
1465 importance: 0.5,
1466 pinned: false,
1467 protection: crate::memory::ProtectionLevel::None,
1468 auto_classified: false,
1469 session_appearances: 0,
1470 user_corrected: false,
1471 seen_in_sessions: vec![],
1472 created_at: chrono::Utc::now(),
1473 accessed_at: chrono::Utc::now(),
1474 modified_at: chrono::Utc::now(),
1475 access_count: 0,
1476 decay_score: 1.0,
1477 compaction_level: 0,
1478 compacted_from: vec![],
1479 related_ids: vec![],
1480 contradicts: None,
1481 };
1482 tokio::spawn(async move {
1483 if let Err(e) = memory_manager.remember(entry).await {
1484 tracing::warn!(error = %e, "Failed to save compaction summary");
1485 }
1486 });
1487}
1488
1489#[allow(dead_code)]
1492fn build_system_prompt(
1493 seed: &Seed,
1494 persona_prompt: Option<&str>,
1495 capabilities_xml: Option<&str>,
1496 kernel_manifest: Option<&str>,
1497 workspace_context: Option<&str>,
1498) -> String {
1499 build_system_prompt_inner(
1500 &seed.goal,
1501 &seed.original_request,
1502 &seed.constraints,
1503 &seed.acceptance_criteria,
1504 &seed.ontology,
1505 workspace_context,
1506 persona_prompt,
1507 capabilities_xml,
1508 kernel_manifest,
1509 )
1510}
1511
1512#[allow(dead_code)]
1517fn build_directive_system_prompt(
1518 directive: &Directive,
1519 env: &ExecEnv,
1520 persona_prompt: Option<&str>,
1521 capabilities_xml: Option<&str>,
1522 kernel_manifest: Option<&str>,
1523) -> String {
1524 let ontology: &[Entity] = &[];
1525 build_system_prompt_inner(
1526 &directive.goal,
1527 &directive.original_request,
1528 &directive.constraints,
1529 &directive.acceptance_criteria,
1530 ontology,
1531 env.workspace_context.as_deref(),
1532 persona_prompt,
1533 capabilities_xml,
1534 kernel_manifest,
1535 )
1536}
1537
1538#[allow(clippy::too_many_arguments)]
1545fn build_system_prompt_inner(
1546 goal: &str,
1547 original_request: &str,
1548 constraints: &[String],
1549 acceptance_criteria: &[String],
1550 ontology: &[Entity],
1551 workspace_context: Option<&str>,
1552 persona_prompt: Option<&str>,
1553 capabilities_xml: Option<&str>,
1554 kernel_manifest: Option<&str>,
1555) -> String {
1556 let mut prompt = String::from(
1557 "You are an autonomous agent in the Oxios operating system.\n\
1558 You execute Seeds — immutable specifications with goals, constraints, and\n\
1559 acceptance criteria.\n\n\
1560 ## Available Tools\n\
1561 You have the following tools:\n\
1562 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1563 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1564 - **Exec**: run shell commands\n\
1565 - **Memory tools**: memory_read, memory_write, memory_search — agent's internal recall\n\
1566 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1567 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1568 **Important**: When the task involves fetching information from the internet,\n\
1569 websites, or online services, use `web_search` first — do NOT search local files.\n\
1570 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1571 from the web, use `web_search`.\n",
1572 );
1573 prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1574
1575 if !original_request.is_empty() && original_request != goal {
1578 prompt.push_str(&format!(
1579 "\n## User's Original Request\n{}\n",
1580 original_request
1581 ));
1582 }
1583
1584 if !constraints.is_empty() {
1585 prompt.push_str("\n## Constraints\n");
1586 for (i, c) in constraints.iter().enumerate() {
1587 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1588 }
1589 }
1590
1591 if !acceptance_criteria.is_empty() {
1592 prompt.push_str("\n## Acceptance Criteria\n");
1593 for (i, c) in acceptance_criteria.iter().enumerate() {
1594 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1595 }
1596 }
1597
1598 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1602 prompt.push_str("\n## Workspace Context\n");
1603 prompt.push_str(ctx);
1604 prompt.push('\n');
1605 }
1606
1607 if !ontology.is_empty() {
1608 prompt.push_str("\n## Domain Entities\n");
1609 for e in ontology {
1610 prompt.push_str(&format!(
1611 "- **{}** ({}): {}\n",
1612 e.name, e.entity_type, e.description
1613 ));
1614 }
1615 }
1616
1617 if let Some(pp) = persona_prompt {
1619 prompt.push_str("\n## Persona\n");
1620 prompt.push_str(pp);
1621 prompt.push('\n');
1622 }
1623
1624 if let Some(xml) = capabilities_xml {
1626 prompt.push_str("\n## Available Capabilities\n");
1627 prompt.push_str("The following capabilities are relevant to your goal. ");
1628 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1629 prompt.push_str(xml);
1630 prompt.push('\n');
1631 }
1632
1633 if let Some(manifest) = kernel_manifest {
1635 prompt.push('\n');
1636 prompt.push_str(manifest);
1637 prompt.push('\n');
1638 }
1639
1640 prompt.push_str(
1642 "\n## Execution Protocol\n\
1643 1. UNDERSTAND — Read the Seed completely before acting.\n\
1644 2. PLAN — Determine the minimal set of actions needed.\n\
1645 3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
1646 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1647 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1648 ## Hard Boundaries\n\
1649 - NEVER modify files outside the workspace scope\n\
1650 - NEVER execute destructive commands without confirming scope\n\
1651 - NEVER claim completion without evidence — show the output, not your opinion\n\
1652 - NEVER add features or improvements beyond the Seed scope\n\
1653 - If you cannot complete the Seed, say so and explain WHY\n\n\
1654 ## Scope Guard\n\
1655 The Seed defines your universe. Do not:\n\
1656 - Refactor code the Seed didn't mention\n\
1657 - Add tests the Seed didn't require\n\
1658 - Change configuration the Seed didn't specify\n\
1659 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1660 ## Error Handling\n\
1661 - If a tool fails, read the error message carefully before retrying\n\
1662 - If a command fails, do NOT immediately retry with --force or sudo\n\
1663 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1664 ## Shape Matching\n\
1665 Match your output to the task: simple task → concise response.\n\
1666 Do not write 50 lines when 5 would do.\n\
1667 Use `exec` for all command execution (git, gh, osascript, etc.).",
1668 );
1669
1670 prompt
1671}
1672#[allow(dead_code)]
1673fn build_user_prompt(seed: &Seed) -> String {
1674 build_user_prompt_inner(&seed.goal, &seed.acceptance_criteria)
1675}
1676#[allow(dead_code)]
1677fn build_directive_user_prompt(directive: &Directive) -> String {
1678 build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1679}
1680
1681fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1683 format!(
1684 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1685 goal,
1686 acceptance_criteria
1687 .iter()
1688 .enumerate()
1689 .map(|(i, c)| format!("{}. {}", i + 1, c))
1690 .collect::<Vec<_>>()
1691 .join("\n")
1692 )
1693}
1694
1695impl std::fmt::Debug for AgentRuntime {
1696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1697 f.debug_struct("AgentRuntime")
1698 .field("model_id", &self.engine_handle.get().default_model_id())
1699 .finish()
1700 }
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705 use super::*;
1706 use async_trait::async_trait;
1707 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1708 use oxios_ouroboros::Entity;
1709 use serde_json::Value;
1710
1711 struct DummyTool {
1713 name: String,
1714 }
1715
1716 #[async_trait]
1717 impl AgentTool for DummyTool {
1718 fn name(&self) -> &str {
1719 &self.name
1720 }
1721 fn label(&self) -> &str {
1722 &self.name
1723 }
1724 fn description(&self) -> &str {
1725 "Test tool"
1726 }
1727 fn parameters_schema(&self) -> Value {
1728 serde_json::json!({"type": "object"})
1729 }
1730
1731 async fn execute(
1732 &self,
1733 _tool_call_id: &str,
1734 _params: Value,
1735 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1736 _ctx: &ToolContext,
1737 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1738 Ok(oxi_sdk::AgentToolResult::success("ok"))
1739 }
1740 }
1741
1742 #[test]
1744 fn test_requires_tools_validation_passes() {
1745 let registry = ToolRegistry::new();
1746
1747 registry.register(DummyTool {
1748 name: "read".into(),
1749 });
1750 registry.register(DummyTool {
1751 name: "exec".into(),
1752 });
1753
1754 let missing = registry.missing(&["read", "exec"]);
1755
1756 assert!(
1757 missing.is_empty(),
1758 "Expected no missing tools, got: {:?}",
1759 missing
1760 );
1761 }
1762
1763 #[test]
1765 fn test_requires_tools_validation_fails() {
1766 let registry = ToolRegistry::new();
1767
1768 registry.register(DummyTool {
1769 name: "read".into(),
1770 });
1771
1772 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1773
1774 assert_eq!(missing, vec!["exec", "nonexistent"]);
1775 }
1776
1777 #[test]
1778 fn test_build_system_prompt_includes_goal() {
1779 let seed = Seed {
1780 id: uuid::Uuid::new_v4(),
1781 goal: "Build a web server".into(),
1782 constraints: vec!["Must use Rust".into()],
1783 acceptance_criteria: vec!["Server responds to requests".into()],
1784 ontology: vec![Entity {
1785 name: "HttpServer".into(),
1786 entity_type: "struct".into(),
1787 description: "The main server struct".into(),
1788 }],
1789 created_at: chrono::Utc::now(),
1790 generation: 0,
1791 parent_seed_id: None,
1792 cspace_hint: None,
1793 original_request: String::new(),
1794 output_schema: None,
1795 project_id: None,
1796 workspace_context: None,
1797 mount_paths: Vec::new(),
1798 };
1799
1800 let prompt = build_system_prompt(&seed, None, None, None, None);
1801
1802 assert!(prompt.contains("Build a web server"));
1803 assert!(prompt.contains("Must use Rust"));
1804 assert!(prompt.contains("Server responds to requests"));
1805 assert!(prompt.contains("HttpServer"));
1806 assert!(prompt.contains("struct"));
1807 }
1808
1809 #[test]
1810 fn test_build_system_prompt_empty() {
1811 let seed = Seed {
1812 id: uuid::Uuid::new_v4(),
1813 goal: "Test goal".into(),
1814 constraints: vec![],
1815 acceptance_criteria: vec![],
1816 ontology: vec![],
1817 created_at: chrono::Utc::now(),
1818 generation: 0,
1819 parent_seed_id: None,
1820 cspace_hint: None,
1821 original_request: String::new(),
1822 output_schema: None,
1823 project_id: None,
1824 workspace_context: None,
1825 mount_paths: Vec::new(),
1826 };
1827
1828 let prompt = build_system_prompt(&seed, None, None, None, None);
1829
1830 assert!(prompt.contains("Test goal"));
1831 }
1832
1833 #[test]
1834 fn test_infer_domain_testing() {
1835 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1836 }
1837
1838 #[test]
1839 fn test_infer_domain_deployment() {
1840 assert_eq!(
1841 infer_domain("deploy the web service to production"),
1842 "deployment"
1843 );
1844 }
1845
1846 #[test]
1847 fn test_infer_domain_bugfix() {
1848 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1849 }
1850
1851 #[test]
1852 fn test_infer_domain_development() {
1853 assert_eq!(
1854 infer_domain("create a new REST API endpoint"),
1855 "development"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_infer_domain_analysis() {
1861 assert_eq!(
1862 infer_domain("review the code for security issues"),
1863 "analysis"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_infer_domain_fallback() {
1869 let domain = infer_domain("optimize performance metrics");
1870 assert!(!domain.is_empty());
1872 }
1873}