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, ExecEnv, ExecutionResult};
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)]
75#[non_exhaustive]
76pub enum StreamDelta {
77 Text(String),
79
80 Thinking,
83 ThinkingDelta(String),
87}
88
89pub type StreamingSinkTx = std::sync::Arc<tokio::sync::mpsc::UnboundedSender<StreamDelta>>;
96
97#[derive(Debug, Clone)]
99pub struct AgentRuntimeConfig {
100 pub model_id: String,
102 pub tool_execution: ToolExecutionMode,
104 pub auto_retry_enabled: bool,
106 pub project_paths: Vec<std::path::PathBuf>,
108 pub workspace_dir: Option<std::path::PathBuf>,
110 pub api_key: Option<String>,
112 pub provider_options: Option<oxi_sdk::ProviderOptions>,
114 pub rate_limit_per_minute: usize,
116 pub token_budget: usize,
118 pub audit_tool_calls: bool,
120 pub provider_rpm: u32,
123}
124
125impl Default for AgentRuntimeConfig {
126 fn default() -> Self {
127 Self {
128 model_id: String::new(),
129 tool_execution: ToolExecutionMode::Parallel,
130 auto_retry_enabled: true,
131 project_paths: Vec::new(),
132 workspace_dir: None,
133 api_key: None,
134 provider_options: None,
135 rate_limit_per_minute: 0,
136 token_budget: 0,
137 audit_tool_calls: false,
138 provider_rpm: 0,
139 }
140 }
141}
142
143#[derive(Default)]
145struct ExecuteState {
146 final_content: String,
147 steps_completed: usize,
148 success: bool,
149 reasoning_text: String,
154 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
157 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
161 tool_call_ids: Vec<String>,
164 tool_args_map: std::collections::HashMap<String, String>,
166 tool_error_map: std::collections::HashMap<String, bool>,
168 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
170 total_input_tokens: u64,
172 total_output_tokens: u64,
174}
175
176pub struct AgentRuntime {
185 engine_handle: Arc<crate::engine::EngineHandle>,
186 config: AgentRuntimeConfig,
187 kernel_handle: Arc<KernelHandle>,
189 persona_manager: Option<Arc<PersonaManager>>,
191 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
193 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
195 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
197 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
199}
200
201impl AgentRuntime {
202 pub fn new(
208 engine_handle: Arc<crate::engine::EngineHandle>,
209 kernel_handle: Arc<KernelHandle>,
210 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
211 ) -> Self {
212 Self {
213 engine_handle,
214 config: AgentRuntimeConfig::default(),
215 kernel_handle,
216 persona_manager: None,
217 tool_retriever: None,
218 routing_stats,
219 persistence_hook: None,
220 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
221 }
222 }
223
224 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
226 self.persona_manager = Some(pm);
227 self
228 }
229
230 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
232 self.config = config;
233 self
234 }
235
236 pub fn with_tool_retriever(
238 mut self,
239 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
240 ) -> Self {
241 self.tool_retriever = Some(retriever);
242 self
243 }
244
245 pub fn with_persistence_hook(
247 mut self,
248 hook: Arc<crate::persistence_hook::PersistenceHook>,
249 ) -> Self {
250 self.persistence_hook = Some(hook);
251 self
252 }
253
254 pub async fn execute_directive(
260 &self,
261 agent_id: AgentId,
262 directive: &Directive,
263 env: &ExecEnv,
264 session_ctx: &mut SessionContext,
265 ) -> Result<ExecutionResult> {
266 let session_id: Option<String> = Some(agent_id.to_string());
270 self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
271 .await
272 }
273 pub async fn execute_directive_with_session(
276 &self,
277 agent_id: AgentId,
278 directive: &Directive,
279 env: &ExecEnv,
280 session_ctx: &mut SessionContext,
281 session_id: Option<String>,
282 ) -> Result<ExecutionResult> {
283 self.execute_inner(
284 agent_id,
285 &directive.goal,
286 &directive.original_request,
287 &directive.constraints,
288 &directive.acceptance_criteria,
289 env.cspace_hint.as_deref(),
290 &env.mount_paths,
291 env.workspace_context.as_deref(),
292 session_ctx,
293 session_id,
294 Some(directive),
295 env.model_override.as_deref(),
296 env.restore_state.as_ref(),
297 )
298 .await
299 }
300
301 #[allow(clippy::too_many_arguments)]
308 async fn execute_inner(
309 &self,
310 agent_id: AgentId,
311 goal: &str,
312 original_request: &str,
313 constraints: &[String],
314 acceptance_criteria: &[String],
315 cspace_hint: Option<&str>,
316 mount_paths: &[std::path::PathBuf],
317 workspace_context: Option<&str>,
318 session_ctx: &mut SessionContext,
319 session_id: Option<String>,
320 persistence_directive: Option<&Directive>,
321 model_override: Option<&str>,
322 restore_state: Option<&serde_json::Value>,
323 ) -> Result<ExecutionResult> {
324 let prompt = build_user_prompt_inner(goal, acceptance_criteria);
325
326 let persona_prompt = self
328 .persona_manager
329 .as_ref()
330 .map(|pm| pm.active_system_prompt())
331 .filter(|s| !s.trim().is_empty());
332
333 let persona_role = self
335 .persona_manager
336 .as_ref()
337 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
338
339 let cspace = resolve_cspace(
341 cspace_hint,
342 persona_role.as_deref(),
343 Some("worker"),
344 agent_id,
345 );
346
347 let mut system_prompt = build_system_prompt_inner(
350 goal,
351 original_request,
352 constraints,
353 acceptance_criteria,
354 workspace_context,
355 persona_prompt.as_deref(),
356 None,
357 None,
358 );
359
360 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
362 match retriever.embedder().embed(goal).await {
363 Ok(query_vec) => {
364 let results = retriever.retrieve(&query_vec, 8);
365 if results.is_empty() {
366 None
367 } else {
368 let xml = crate::tools::retrieval::format_capability_index(&results);
369 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
370 Some(xml)
371 }
372 }
373 Err(e) => {
374 tracing::warn!(error = %e, "Failed to embed goal for retrieval");
375 None
376 }
377 }
378 } else {
379 None
380 };
381
382 let kernel_manifest = {
384 let domains = cspace.active_domains();
385 if domains.is_empty() {
386 None
387 } else {
388 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
389 }
390 };
391
392 if capabilities_xml.is_some() || kernel_manifest.is_some() {
394 system_prompt = build_system_prompt_inner(
395 goal,
396 original_request,
397 constraints,
398 acceptance_criteria,
399 workspace_context,
400 persona_prompt.as_deref(),
401 capabilities_xml.as_deref(),
402 kernel_manifest.as_deref(),
403 );
404 }
405
406 let memory_manager = self.kernel_handle.agents.memory_manager();
408 match memory_manager
409 .recall_with_proactive(goal, &mut session_ctx.recall_timing)
410 .await
411 {
412 Ok(memories) if !memories.is_empty() => {
413 tracing::info!(count = memories.len(), "Recalled memories for task");
414 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
415 }
416 Ok(_) => tracing::debug!("No memories recalled"),
417 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
418 }
419
420 if let Some(sona) = memory_manager.sona_engine() {
422 match sona.adapt(goal).await {
423 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
424 tracing::info!(
425 domain = %pattern.domain,
426 confidence = pattern.confidence,
427 "SONA learned pattern injected"
428 );
429 system_prompt.push_str(&format!(
430 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
431 pattern.confidence * 100.0,
432 pattern.strategy,
433 ));
434 }
435 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
436 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
437 }
438 }
439
440 match self
442 .kernel_handle
443 .knowledge_lens
444 .recall_for_context(goal, 5)
445 .await
446 {
447 Ok(ctx) if !ctx.notes.is_empty() => {
448 tracing::info!(
449 notes = ctx.notes.len(),
450 memories = ctx.memories.len(),
451 "Recalled knowledge context for task"
452 );
453 let knowledge_blend = ctx
454 .notes
455 .iter()
456 .take(3)
457 .map(|n| format!("## {}\n\n{}", n.name, n.content))
458 .collect::<Vec<_>>()
459 .join("\n\n");
460 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
461 system_prompt.push_str(&knowledge_blend);
462 }
463 Ok(_) => tracing::debug!("No knowledge recalled"),
464 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
465 }
466
467 let engine = self.engine_handle.get();
472 let model_id = model_override
473 .map(|s| s.to_string())
474 .unwrap_or_else(|| engine.default_model_id().to_string());
475 engine.resolve_model(&model_id)?;
477 let exec_id = uuid::Uuid::new_v4();
479
480 let mut config = self.config.clone();
485 config.model_id = model_id;
486 let kernel_handle = Arc::clone(&self.kernel_handle);
487
488 let audit_trail: Option<Arc<AuditTrail>> =
490 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
491
492 let (
493 mut final_content,
494 steps_completed,
495 success,
496 trajectory_steps,
497 agent,
498 tool_call_ids,
499 tool_args_map,
500 tool_error_map,
501 tool_timestamps,
502 total_input_tokens,
503 total_output_tokens,
504 reasoning_text,
505 ) = {
506 run_agent(
507 &config,
508 &engine,
509 kernel_handle,
510 system_prompt,
511 prompt,
512 exec_id,
513 goal.to_string(),
514 agent_id,
515 cspace,
516 audit_trail,
517 self.routing_stats.clone(),
518 session_id.clone(),
519 mount_paths,
520 restore_state,
521 )
522 .await?
523 };
524
525 if final_content.is_empty() && !trajectory_steps.is_empty() {
532 let tool_summary: Vec<String> = trajectory_steps
533 .iter()
534 .enumerate()
535 .map(|(i, step)| {
536 let truncated = if step.output.len() > 800 {
537 let mut end = 800;
541 while end > 0 && !step.output.is_char_boundary(end) {
542 end -= 1;
543 }
544 format!("{}...", &step.output[..end])
545 } else {
546 step.output.clone()
547 };
548 format!("{}. [{}] {}", i + 1, step.input, truncated)
549 })
550 .collect();
551 let summary_prompt = format!(
552 "도구 실행 결과:\n\n{}\n\n\
553 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
554 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
555 tool_summary.join("\n")
556 );
557 match agent.run(summary_prompt).await {
558 Ok((response, _events)) => {
559 if !response.content.is_empty() {
560 tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
561 final_content = response.content;
562 }
563 }
564 Err(e) => {
565 tracing::warn!(error = %e, "Post-execution summary failed");
566 }
567 }
568 }
569
570 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
573 .iter()
574 .enumerate()
575 .map(|(i, step)| {
576 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
577 let args_str = tool_call_ids
578 .get(i)
579 .and_then(|id| tool_args_map.get(id))
580 .cloned()
581 .unwrap_or_default();
582 let is_error = tool_call_ids
583 .get(i)
584 .and_then(|id| tool_error_map.get(id))
585 .copied()
586 .unwrap_or(false);
587 let timestamp = tool_call_ids
588 .get(i)
589 .and_then(|id| tool_timestamps.get(id))
590 .copied();
591 let input_str = truncate_json_str(&args_str, 500);
592 oxios_ouroboros::ToolCallRecord {
593 tool: step.input.clone(),
594 input: input_str,
595 output: step.output.clone(),
596 duration_ms: step.duration_ms,
597 is_error,
598 tool_call_id: tc_id,
599 timestamp,
600 }
601 })
602 .collect();
603
604 tracing::info!(
605 exec_id = %exec_id,
606 steps = steps_completed,
607 success,
608 tool_calls = tool_calls.len(),
609 "AgentRuntime finished"
610 );
611
612 let result = ExecutionResult {
613 output: final_content.clone(),
614 steps_completed,
615 success,
616 tool_calls,
617 failure_class: None,
618 restore_state: None,
619 tokens_input: total_input_tokens,
620 tokens_output: total_output_tokens,
621 model_id: self.engine_handle.get().default_model_id().to_string(),
622 reasoning_text,
623 };
624
625 if let Some(directive) = persistence_directive
628 && success
629 && let Some(hook) = &self.persistence_hook
630 {
631 let already_saved_knowledge = trajectory_steps
632 .iter()
633 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
634 let hook = hook.clone();
635 let directive_clone = directive.clone();
636 let traj_clone = trajectory_steps.clone();
637 let output_clone = final_content.clone();
638 let sid = session_id.clone();
639 let msg_index = {
642 let mut counter = self.session_msg_counter.lock();
643 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
644 let current = *idx;
645 *idx += 1;
646 current
647 };
648 tokio::spawn(async move {
649 match hook
650 .evaluate(
651 &directive_clone,
652 &traj_clone,
653 &output_clone,
654 already_saved_knowledge,
655 )
656 .await
657 {
658 Ok(plan) => {
659 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
660 tracing::info!(
661 memory = plan.memory.len(),
662 knowledge = plan.knowledge.len(),
663 message_index = msg_index,
664 "PersistenceHook executing plan"
665 );
666 let session_id = sid.unwrap_or_default();
667 hook.execute_plan(plan, &session_id, msg_index).await;
668 }
669 }
670 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
671 }
672 });
673 }
674
675 Ok(result)
676 }
677}
678
679#[allow(clippy::too_many_arguments)]
684async fn run_agent(
685 config: &AgentRuntimeConfig,
686 engine: &OxiosEngine,
687 kernel_handle: Arc<KernelHandle>,
688 system_prompt: String,
689 prompt: String,
690 seed_id: uuid::Uuid,
691 seed_goal: String,
692 agent_id: AgentId,
693 cspace: crate::capability::CSpace,
694 audit_trail: Option<Arc<AuditTrail>>,
695 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
696 session_id: Option<String>,
697 mount_paths: &[std::path::PathBuf],
698 restore_state: Option<&serde_json::Value>,
699) -> Result<(
700 String,
701 usize,
702 bool,
703 Vec<oxios_memory::memory::sona::TrajectoryStep>,
704 Arc<Agent>,
705 Vec<String>,
706 std::collections::HashMap<String, String>,
707 std::collections::HashMap<String, bool>,
708 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
709 u64,
710 u64,
711 String,
712)> {
713 let workspace = if !mount_paths.is_empty() {
717 mount_paths[0].clone()
718 } else if !config.project_paths.is_empty() {
719 config.project_paths[0].clone()
720 } else if let Some(ref ws) = config.workspace_dir {
721 ws.clone()
722 } else {
723 std::env::temp_dir()
724 .join("oxios-agent-workspace")
725 .join(agent_id.to_string())
726 };
727
728 let _ = std::fs::create_dir_all(&workspace);
730
731 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
732
733 {
750 use crate::access_manager::{Role, Subject};
751 let agent_name = format!("agent-{agent_id}");
752 let mut am = kernel_handle.exec.access_manager().lock();
753 let perms = am.get_or_create_permissions(&agent_name);
754
755 if let Ok(cwd) = std::env::current_dir() {
757 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
758 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
759 perms.allow_path(&cwd_pattern);
760 tracing::debug!(
761 agent = %agent_name,
762 path = %cwd_pattern,
763 "Added CWD to agent allowed paths"
764 );
765 }
766 }
767
768 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
770 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
771 perms.allow_path(&ws_pattern);
772 }
773
774 for mount_path in mount_paths {
779 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
780 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
781 perms.allow_path(&pattern);
782 tracing::debug!(
783 agent = %agent_name,
784 path = %pattern,
785 "Added Mount path to agent allowed paths (RFC-025)"
786 );
787 }
788 }
789
790 let kernel_ws = kernel_handle
792 .state
793 .workspace_path()
794 .to_string_lossy()
795 .to_string();
796 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
797 if kernel_ws_pattern != ws_pattern
798 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
799 {
800 perms.allow_path(&kernel_ws_pattern);
801 }
802
803 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
805 perms.allow_path("/tmp/**");
806 }
807
808 let rbac_subject = Subject::Agent(agent_id);
810 am.rbac_manager_mut()
811 .assign_role(rbac_subject, Role::Superuser);
812 }
813
814 let _trace_guard = crate::observability::tracer().start(
816 format!("seed-{}", &seed_id.to_string()[..8]).as_str(),
817 oxi_sdk::SpanKind::Agent,
818 );
819
820 let registry = ToolRegistry::new();
822 let search_cache = Arc::new(SearchCache::new());
823
824 let agent_context = AgentContext {
826 agent_id,
827 agent_name: format!("agent-{agent_id}"),
828 cspace: Arc::new(cspace.clone()),
829 };
830
831 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
834 let audit_path = kernel_handle
835 .state
836 .workspace_path()
837 .join("audit")
838 .join("access.jsonl");
839 Arc::new(TrailAuditSink::new(trail, audit_path))
840 } else {
841 Arc::new(TracingAuditSink)
842 };
843
844 let access_gate = Arc::new(AccessGate::new(
846 kernel_handle.exec.access_manager().clone(),
847 Arc::new(kernel_handle.exec.config_snapshot()),
848 audit_sink,
849 ));
850
851 register_tools_from_cspace_gated(
852 ®istry,
853 &kernel_handle,
854 &cspace,
855 search_cache,
856 agent_id,
857 access_gate,
858 agent_context,
859 );
860
861 tracing::info!(
862 seed_id = %seed_id,
863 capabilities = cspace.len(),
864 "Tools registered from CSpace"
865 );
866
867 let agent_config = AgentConfig {
875 name: format!("agent-{agent_id}"),
876 description: None,
877 model_id: config.model_id.clone(),
878 system_prompt: Some(system_prompt.clone()),
879 timeout_seconds: 300,
880 temperature: Some(0.7),
881 max_tokens: Some(8192),
882 compaction_strategy: CompactionStrategy::Threshold(0.8),
883 compaction_instruction: None,
884 context_window: 128_000,
885 api_key: config.api_key.clone(),
886 workspace_dir: Some(workspace.clone()),
887 output_mode: None,
888 provider_options: config.provider_options.clone(),
889 session_id: None,
895 ..Default::default()
896 };
897
898 let agent = if config.provider_rpm > 0 {
913 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
915 let provider_name = engine.resolve_model(&config.model_id)?.provider;
916 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
917
918 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
920 if config.rate_limit_per_minute > 0 {
921 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
922 config.rate_limit_per_minute,
923 ));
924 }
925 if config.token_budget > 0 {
926 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
927 config.token_budget,
928 ));
929 }
930 if config.audit_tool_calls {
931 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
932 tracing::Level::INFO,
933 ));
934 }
935
936 let agent = Arc::new(Agent::new_with_resolver(
938 provider,
939 agent_config,
940 Arc::new(registry),
941 resolver,
942 ));
943
944 if !pipeline.is_empty() {
946 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
947 let agent_id_for_hooks = agent_id.to_string();
948 let hooks = oxi_sdk::middleware::build_hooks(
949 Arc::new(pipeline),
950 agent_id_for_hooks,
951 terminate_flag,
952 );
953 agent.set_hooks(hooks);
954 }
955
956 agent
957 } else {
958 let mut builder = engine
960 .oxi()
961 .agent(agent_config)
962 .workspace(&workspace)
963 .system_prompt(system_prompt);
964
965 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
975 .names()
976 .into_iter()
977 .filter_map(|name| registry.get(&name))
978 .collect();
979
980 if let Some(auth) = engine.authorizer() {
982 builder = builder.authorizer(auth.clone());
983 }
984 if let Some(tracer) = engine.tracer() {
985 builder = builder.tracer(tracer.clone());
986 }
987 if let Some(ct) = engine.cost_tracker() {
988 builder = builder.cost_tracker(ct.clone());
989 }
990
991 if config.rate_limit_per_minute > 0 {
994 builder = builder.with_rate_limit(config.rate_limit_per_minute);
995 }
996 if config.token_budget > 0 {
997 builder = builder.with_token_budget(config.token_budget);
998 }
999 if config.audit_tool_calls {
1000 builder = builder.with_logging();
1001 }
1002
1003 let built = builder.build()?;
1004 let agent = Arc::new(built);
1005
1006 let agent_tools = agent.tools();
1011 for tool in cspace_tool_arcs {
1012 agent_tools.register_arc(tool);
1013 }
1014
1015 agent
1016 };
1017
1018 if let Some(state) = restore_state {
1022 agent.import_state(state.clone()).unwrap_or_else(|e| {
1023 tracing::warn!(agent_id = %agent_id, error = %e, "Failed to restore agent state");
1024 });
1025 }
1026
1027 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
1029 let exec_state_cb = Arc::clone(&exec_state);
1030 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
1031 let session_id_for_callback = seed_id.to_string();
1032 let model_id_for_callback = config.model_id.clone();
1033 let agent_id_for_callback = agent_id.to_string();
1034 let routing_stats_for_cb = routing_stats.clone();
1035 let transparency_session: Option<String> = session_id.clone();
1038 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1039 let streaming_sinks_for_cb: Arc<crate::streaming_sink::StreamingSinkRegistry> =
1043 Arc::clone(&kernel_handle.streaming_sinks);
1044 let result = agent
1046 .run_streaming(prompt, move |event| {
1047 let mut s = exec_state_cb.lock();
1048 match event {
1049 AgentEvent::ToolExecutionStart {
1050 tool_name,
1051 tool_call_id,
1052 args,
1053 context,
1054 ..
1055 } => {
1056 let idx = s.trajectory_steps.len();
1058 s.pending_tools
1059 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1060 s.tool_args_map.insert(
1061 tool_call_id.clone(),
1062 serde_json::to_string(&args).unwrap_or_default(),
1063 );
1064 s.tool_timestamps
1065 .insert(tool_call_id.clone(), chrono::Utc::now());
1066 s.tool_call_ids.push(tool_call_id.clone());
1067 s.trajectory_steps
1068 .push(oxios_memory::memory::sona::TrajectoryStep {
1069 input: tool_name.clone(),
1070 output: String::new(),
1071 duration_ms: 0,
1072 confidence: 0.0,
1073 });
1074 if let Some(ref sid) = transparency_session {
1076 let context_json = context
1077 .as_ref()
1078 .map(serde_json::to_value)
1079 .transpose()
1080 .unwrap_or(None);
1081 let _ =
1082 kernel_handle_for_cb
1083 .infra
1084 .publish(KernelEvent::ToolExecutionStarted {
1085 session_id: sid.clone(),
1086 tool_name: tool_name.clone(),
1087 tool_call_id: tool_call_id.clone(),
1088 tool_args: args.clone(),
1089 context: context_json,
1090 });
1091 }
1092 }
1093 AgentEvent::ToolExecutionUpdate {
1094 tool_call_id,
1095 tool_name,
1096 partial_result,
1097 tab_id,
1098 context,
1099 } => {
1100 if let Some(ref sid) = transparency_session {
1110 let context_json = context
1111 .as_ref()
1112 .map(serde_json::to_value)
1113 .transpose()
1114 .unwrap_or(None);
1115 let _ = kernel_handle_for_cb.infra.publish(
1116 KernelEvent::ToolExecutionProgress {
1117 session_id: sid.clone(),
1118 tool_call_id: tool_call_id.clone(),
1119 tool_name: tool_name.clone(),
1120 progress: partial_result,
1121 tab_id,
1122 context: context_json,
1123 },
1124 );
1125 }
1126 }
1127 AgentEvent::ToolExecutionEnd {
1128 tool_name,
1129 tool_call_id,
1130 is_error,
1131 result,
1132 ..
1133 } => {
1134 if !is_error {
1135 s.steps_completed += 1;
1136 }
1137 let mut duration_ms: u64 = 0;
1139 let mut summary = String::new();
1140 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1141 duration_ms = start.elapsed().as_millis() as u64;
1142 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1143 summary = summarize_tool_result(&result.content, 200);
1144 step.output = summary.clone();
1145 step.duration_ms = duration_ms;
1146 step.confidence = if is_error { 0.3 } else { 0.8 };
1147 }
1148 }
1149 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1150 if let Some(ref sid) = transparency_session {
1152 let _ = kernel_handle_for_cb.infra.publish(
1153 KernelEvent::ToolExecutionFinished {
1154 session_id: sid.clone(),
1155 tool_call_id: tool_call_id.clone(),
1156 tool_name: tool_name.clone(),
1157 duration_ms,
1158 is_error,
1159 output_summary: summary,
1160 },
1161 );
1162 }
1163 }
1164 AgentEvent::AgentEnd {
1165 messages,
1166 stop_reason,
1167 ..
1168 } => {
1169 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1170 s.final_content = a.text_content();
1171 }
1172 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1178 }
1179 AgentEvent::Error { message, .. } => {
1180 s.final_content = message.clone();
1181 s.success = false;
1182 }
1183 AgentEvent::Usage {
1184 input_tokens,
1185 output_tokens,
1186 } => {
1187 s.total_input_tokens += input_tokens as u64;
1189 s.total_output_tokens += output_tokens as u64;
1190
1191 let agent_label = format!("agent-{agent_id_for_callback}");
1193 crate::observability::cost_tracker().record(
1194 &agent_label,
1195 &oxi_sdk::Model::new(
1196 &model_id_for_callback,
1197 &model_id_for_callback,
1198 oxi_sdk::Api::OpenAiCompletions,
1199 "unknown",
1200 "https://unknown.com",
1201 ),
1202 oxi_sdk::TokenUsage {
1203 input: input_tokens as u64,
1204 output: output_tokens as u64,
1205 cache_read: 0,
1206 cache_write: 0,
1207 },
1208 );
1209
1210 if let Some(stats) = &routing_stats_for_cb {
1212 let cost = crate::kernel_handle::engine_api::estimate_cost(
1213 &model_id_for_callback,
1214 input_tokens as u64,
1215 output_tokens as u64,
1216 );
1217 stats.record_model_usage(&model_id_for_callback, cost);
1218 }
1219 if let Some(ref sid) = transparency_session {
1221 let _ = kernel_handle_for_cb
1222 .infra
1223 .publish(KernelEvent::TokenUsageUpdate {
1224 session_id: sid.clone(),
1225 input_tokens: input_tokens as u64,
1226 output_tokens: output_tokens as u64,
1227 });
1228 }
1229 }
1230 AgentEvent::Compaction {
1231 event: CompactionEvent::Completed { result, .. },
1232 } => {
1233 handle_compaction(
1234 result.summary.clone(),
1235 session_id_for_callback.clone(),
1236 memory_for_callback.clone(),
1237 );
1238 if let Some(ref sid) = transparency_session {
1240 let _ =
1241 kernel_handle_for_cb
1242 .infra
1243 .publish(KernelEvent::ReasoningFragment {
1244 session_id: sid.clone(),
1245 content: result.summary.clone(),
1246 source: "compaction".to_string(),
1247 });
1248 }
1249 }
1250 AgentEvent::TextChunk { text } => {
1251 if let Some(ref sid) = transparency_session
1265 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1266 {
1267 let _ = tx.send(StreamDelta::Text(text.clone()));
1268 }
1269 }
1270 AgentEvent::Thinking => {
1271 if let Some(ref sid) = transparency_session
1276 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1277 {
1278 let _ = tx.send(StreamDelta::Thinking);
1279 }
1280 }
1281 AgentEvent::ThinkingDelta { text } => {
1282 const REASONING_CAP: usize = 4096;
1296 if s.reasoning_text.len() < REASONING_CAP {
1297 s.reasoning_text.push_str(&text);
1298 if s.reasoning_text.len() > REASONING_CAP {
1299 s.reasoning_text.truncate(REASONING_CAP);
1300 }
1301 }
1302 if let Some(ref sid) = transparency_session
1303 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1304 {
1305 let _ = tx.send(StreamDelta::ThinkingDelta(text.clone()));
1306 }
1307 }
1308 _ => {}
1309 }
1310 })
1311 .await;
1312
1313 let circuit = get_llm_circuit_breaker();
1315 if result.is_err() {
1316 circuit.record_failure();
1317 crate::metrics::get_metrics()
1318 .llm_circuit_breaker_state
1319 .set(1.0);
1320 } else {
1321 circuit.record_success();
1322 crate::metrics::get_metrics()
1323 .llm_circuit_breaker_state
1324 .set(0.0);
1325 }
1326
1327 if let Err(e) = result {
1328 tracing::error!(seed_id = %seed_id, error = %e, "Agent failed");
1329 let restore_state = agent.export_state().ok();
1334 return Err(crate::resilience::AgentRunError::wrap(e, restore_state).into());
1335 }
1336
1337 let s = exec_state.lock();
1338 tracing::info!(
1339 seed_id = %seed_id,
1340 steps = s.steps_completed,
1341 success = s.success,
1342 "Agent completed"
1343 );
1344
1345 if !s.trajectory_steps.is_empty()
1348 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1349 {
1350 let steps = s.trajectory_steps.clone();
1351 let success = s.success;
1352 let sona = Arc::clone(sona);
1353 let domain = infer_domain(&seed_goal);
1354 tokio::spawn(async move {
1355 let verdict = if success {
1356 oxios_memory::memory::sona::Verdict::Success
1357 } else {
1358 oxios_memory::memory::sona::Verdict::Failure
1359 };
1360 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1361 if let Err(e) = sona.record(trajectory).await {
1362 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1363 }
1364 });
1365 }
1366
1367 Ok((
1368 s.final_content.clone(),
1369 s.steps_completed,
1370 s.success,
1371 s.trajectory_steps.clone(),
1372 agent,
1373 s.tool_call_ids.clone(),
1374 s.tool_args_map.clone(),
1375 s.tool_error_map.clone(),
1376 s.tool_timestamps.clone(),
1377 s.total_input_tokens,
1378 s.total_output_tokens,
1379 s.reasoning_text.clone(),
1380 ))
1381}
1382
1383fn summarize_tool_result(result: &str, max_len: usize) -> String {
1388 let trimmed = result.trim();
1389 if trimmed.chars().count() <= max_len {
1390 return trimmed.to_string();
1391 }
1392 let first_line = trimmed.lines().next().unwrap_or("");
1394 if first_line.chars().count() <= max_len {
1395 first_line.to_string()
1396 } else {
1397 let take = max_len.saturating_sub(3);
1398 let truncated: String = if take == 0 {
1399 first_line.chars().take(max_len).collect()
1400 } else {
1401 first_line.chars().take(take).collect()
1402 };
1403 format!("{truncated}...")
1404 }
1405}
1406fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1407 if json_str.len() <= max_len {
1408 return json_str.to_string();
1409 }
1410 let take = max_len.saturating_sub(3);
1413 if take == 0 {
1414 return json_str.chars().take(max_len).collect();
1415 }
1416 let truncated: String = json_str.chars().take(take).collect();
1417 format!("{truncated}...")
1418}
1419
1420fn infer_domain(goal: &str) -> String {
1425 let lower = goal.to_lowercase();
1426 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1427
1428 if keywords.iter().any(|k| {
1430 [
1431 "test",
1432 "tests",
1433 "spec",
1434 "testing",
1435 "assert",
1436 "unit test",
1437 "integration",
1438 ]
1439 .contains(k)
1440 }) {
1441 return "testing".to_string();
1442 }
1443 if keywords
1444 .iter()
1445 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1446 {
1447 return "deployment".to_string();
1448 }
1449 if keywords
1450 .iter()
1451 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1452 {
1453 return "bugfix".to_string();
1454 }
1455 if keywords
1456 .iter()
1457 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1458 {
1459 return "refactoring".to_string();
1460 }
1461 if keywords
1462 .iter()
1463 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1464 {
1465 return "documentation".to_string();
1466 }
1467 if keywords
1468 .iter()
1469 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1470 {
1471 return "development".to_string();
1472 }
1473 if keywords
1474 .iter()
1475 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1476 {
1477 return "analysis".to_string();
1478 }
1479 if keywords
1480 .iter()
1481 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1482 {
1483 return "configuration".to_string();
1484 }
1485
1486 let meaningful: Vec<&str> = lower
1488 .split_whitespace()
1489 .filter(|w| w.len() > 2)
1490 .take(2)
1491 .collect();
1492 if meaningful.len() >= 2 {
1493 meaningful.join("_")
1494 } else {
1495 "general".to_string()
1496 }
1497}
1498
1499fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1505 let entry = MemoryEntry {
1506 id: uuid::Uuid::new_v4().to_string(),
1507 memory_type: MemoryType::Conversation,
1508 tier: crate::memory::MemoryTier::Warm,
1509 content: summary,
1510 content_hash: 0,
1511 source: "compaction".to_string(),
1512 session_id: Some(session_id),
1513 tags: vec![],
1514 importance: 0.5,
1515 pinned: false,
1516 protection: crate::memory::ProtectionLevel::None,
1517 auto_classified: false,
1518 session_appearances: 0,
1519 user_corrected: false,
1520 seen_in_sessions: vec![],
1521 created_at: chrono::Utc::now(),
1522 accessed_at: chrono::Utc::now(),
1523 modified_at: chrono::Utc::now(),
1524 access_count: 0,
1525 decay_score: 1.0,
1526 compaction_level: 0,
1527 compacted_from: vec![],
1528 related_ids: vec![],
1529 contradicts: None,
1530 };
1531 tokio::spawn(async move {
1532 if let Err(e) = memory_manager.remember(entry).await {
1533 tracing::warn!(error = %e, "Failed to save compaction summary");
1534 }
1535 });
1536}
1537
1538#[allow(dead_code)]
1543fn build_directive_system_prompt(
1544 directive: &Directive,
1545 env: &ExecEnv,
1546 persona_prompt: Option<&str>,
1547 capabilities_xml: Option<&str>,
1548 kernel_manifest: Option<&str>,
1549) -> String {
1550 build_system_prompt_inner(
1551 &directive.goal,
1552 &directive.original_request,
1553 &directive.constraints,
1554 &directive.acceptance_criteria,
1555 env.workspace_context.as_deref(),
1556 persona_prompt,
1557 capabilities_xml,
1558 kernel_manifest,
1559 )
1560}
1561
1562#[allow(clippy::too_many_arguments)]
1568fn build_system_prompt_inner(
1569 goal: &str,
1570 original_request: &str,
1571 constraints: &[String],
1572 acceptance_criteria: &[String],
1573 workspace_context: Option<&str>,
1574 persona_prompt: Option<&str>,
1575 capabilities_xml: Option<&str>,
1576 kernel_manifest: Option<&str>,
1577) -> String {
1578 let mut prompt = String::from(
1579 "You are an autonomous agent in the Oxios operating system.\n\
1580 You execute Seeds — immutable specifications with goals, constraints, and\n\
1581 acceptance criteria.\n\n\
1582 ## Available Tools\n\
1583 You have the following tools:\n\
1584 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1585 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1586 - **Exec**: run shell commands\n\
1587 - **Memory tools**: memory_read, memory_write, memory_search — agent's internal recall\n\
1588 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1589 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1590 **Important**: When the task involves fetching information from the internet,\n\
1591 websites, or online services, use `web_search` first — do NOT search local files.\n\
1592 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1593 from the web, use `web_search`.\n",
1594 );
1595 prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1596
1597 if !original_request.is_empty() && original_request != goal {
1600 prompt.push_str(&format!(
1601 "\n## User's Original Request\n{}\n",
1602 original_request
1603 ));
1604 }
1605
1606 if !constraints.is_empty() {
1607 prompt.push_str("\n## Constraints\n");
1608 for (i, c) in constraints.iter().enumerate() {
1609 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1610 }
1611 }
1612
1613 if !acceptance_criteria.is_empty() {
1614 prompt.push_str("\n## Acceptance Criteria\n");
1615 for (i, c) in acceptance_criteria.iter().enumerate() {
1616 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1617 }
1618 }
1619
1620 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1624 prompt.push_str("\n## Workspace Context\n");
1625 prompt.push_str(ctx);
1626 prompt.push('\n');
1627 }
1628
1629 if let Some(pp) = persona_prompt {
1631 prompt.push_str("\n## Persona\n");
1632 prompt.push_str(pp);
1633 prompt.push('\n');
1634 }
1635
1636 if let Some(xml) = capabilities_xml {
1638 prompt.push_str("\n## Available Capabilities\n");
1639 prompt.push_str("The following capabilities are relevant to your goal. ");
1640 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1641 prompt.push_str(xml);
1642 prompt.push('\n');
1643 }
1644
1645 if let Some(manifest) = kernel_manifest {
1647 prompt.push('\n');
1648 prompt.push_str(manifest);
1649 prompt.push('\n');
1650 }
1651
1652 prompt.push_str(
1654 "\n## Execution Protocol\n\
1655 1. UNDERSTAND — Read the Seed completely before acting.\n\
1656 2. PLAN — Determine the minimal set of actions needed.\n\
1657 3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
1658 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1659 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1660 ## Hard Boundaries\n\
1661 - NEVER modify files outside the workspace scope\n\
1662 - NEVER execute destructive commands without confirming scope\n\
1663 - NEVER claim completion without evidence — show the output, not your opinion\n\
1664 - NEVER add features or improvements beyond the Seed scope\n\
1665 - If you cannot complete the Seed, say so and explain WHY\n\n\
1666 ## Scope Guard\n\
1667 The Seed defines your universe. Do not:\n\
1668 - Refactor code the Seed didn't mention\n\
1669 - Add tests the Seed didn't require\n\
1670 - Change configuration the Seed didn't specify\n\
1671 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1672 ## Error Handling\n\
1673 - If a tool fails, read the error message carefully before retrying\n\
1674 - If a command fails, do NOT immediately retry with --force or sudo\n\
1675 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1676 ## Shape Matching\n\
1677 Match your output to the task: simple task → concise response.\n\
1678 Do not write 50 lines when 5 would do.\n\
1679 Use `exec` for all command execution (git, gh, osascript, etc.).",
1680 );
1681
1682 prompt
1683}
1684#[allow(dead_code)]
1685fn build_directive_user_prompt(directive: &Directive) -> String {
1686 build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1687}
1688
1689fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1691 format!(
1692 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1693 goal,
1694 acceptance_criteria
1695 .iter()
1696 .enumerate()
1697 .map(|(i, c)| format!("{}. {}", i + 1, c))
1698 .collect::<Vec<_>>()
1699 .join("\n")
1700 )
1701}
1702
1703impl std::fmt::Debug for AgentRuntime {
1704 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1705 f.debug_struct("AgentRuntime")
1706 .field("model_id", &self.engine_handle.get().default_model_id())
1707 .finish()
1708 }
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use super::*;
1714 use async_trait::async_trait;
1715 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1716 use serde_json::Value;
1717
1718 struct DummyTool {
1720 name: String,
1721 }
1722
1723 #[async_trait]
1724 impl AgentTool for DummyTool {
1725 fn name(&self) -> &str {
1726 &self.name
1727 }
1728 fn label(&self) -> &str {
1729 &self.name
1730 }
1731 fn description(&self) -> &str {
1732 "Test tool"
1733 }
1734 fn parameters_schema(&self) -> Value {
1735 serde_json::json!({"type": "object"})
1736 }
1737
1738 async fn execute(
1739 &self,
1740 _tool_call_id: &str,
1741 _params: Value,
1742 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1743 _ctx: &ToolContext,
1744 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1745 Ok(oxi_sdk::AgentToolResult::success("ok"))
1746 }
1747 }
1748
1749 #[test]
1751 fn test_requires_tools_validation_passes() {
1752 let registry = ToolRegistry::new();
1753
1754 registry.register(DummyTool {
1755 name: "read".into(),
1756 });
1757 registry.register(DummyTool {
1758 name: "exec".into(),
1759 });
1760
1761 let missing = registry.missing(&["read", "exec"]);
1762
1763 assert!(
1764 missing.is_empty(),
1765 "Expected no missing tools, got: {:?}",
1766 missing
1767 );
1768 }
1769
1770 #[test]
1772 fn test_requires_tools_validation_fails() {
1773 let registry = ToolRegistry::new();
1774
1775 registry.register(DummyTool {
1776 name: "read".into(),
1777 });
1778
1779 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1780
1781 assert_eq!(missing, vec!["exec", "nonexistent"]);
1782 }
1783
1784 #[test]
1785 fn test_infer_domain_testing() {
1786 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1787 }
1788
1789 #[test]
1790 fn test_infer_domain_deployment() {
1791 assert_eq!(
1792 infer_domain("deploy the web service to production"),
1793 "deployment"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_infer_domain_bugfix() {
1799 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1800 }
1801
1802 #[test]
1803 fn test_infer_domain_development() {
1804 assert_eq!(
1805 infer_domain("create a new REST API endpoint"),
1806 "development"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_infer_domain_analysis() {
1812 assert_eq!(
1813 infer_domain("review the code for security issues"),
1814 "analysis"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_infer_domain_fallback() {
1820 let domain = infer_domain("optimize performance metrics");
1821 assert!(!domain.is_empty());
1823 }
1824}