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 Model(String),
81 Text(String),
83
84 Thinking,
87 ThinkingDelta(String),
91 ThinkingEnd,
99}
100
101pub type StreamingSinkTx = std::sync::Arc<tokio::sync::mpsc::Sender<StreamDelta>>;
108
109#[derive(Debug, Clone)]
111pub struct AgentRuntimeConfig {
112 pub model_id: String,
114 pub tool_execution: ToolExecutionMode,
116 pub auto_retry_enabled: bool,
118 pub workspace_dir: Option<std::path::PathBuf>,
120 pub api_key: Option<String>,
122 pub provider_options: Option<oxi_sdk::ProviderOptions>,
124 pub rate_limit_per_minute: usize,
126 pub token_budget: usize,
128 pub audit_tool_calls: bool,
130 pub provider_rpm: u32,
133 pub max_tool_result_bytes: Option<usize>,
138 pub model_params: Option<oxios_ouroboros::ModelParams>,
143 }
147
148impl Default for AgentRuntimeConfig {
149 fn default() -> Self {
150 Self {
151 model_id: String::new(),
152 tool_execution: ToolExecutionMode::Parallel,
153 auto_retry_enabled: true,
154 workspace_dir: None,
155 api_key: None,
156 provider_options: None,
157 rate_limit_per_minute: 0,
158 token_budget: 0,
159 audit_tool_calls: false,
160 provider_rpm: 0,
161 max_tool_result_bytes: None,
162 model_params: None,
163 }
164 }
165}
166
167#[derive(Default)]
169struct ExecuteState {
170 final_content: String,
171 steps_completed: usize,
172 success: bool,
173 reasoning_text: String,
178 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
181 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
185 tool_call_ids: Vec<String>,
188 tool_args_map: std::collections::HashMap<String, String>,
190 tool_error_map: std::collections::HashMap<String, bool>,
192 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
194 total_input_tokens: u64,
196 total_output_tokens: u64,
198}
199
200pub struct AgentRuntime {
209 engine_handle: Arc<crate::engine::EngineHandle>,
210 config: AgentRuntimeConfig,
211 kernel_handle: Arc<KernelHandle>,
213 persona_manager: Option<Arc<PersonaManager>>,
215 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
217 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
219 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
221 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
223}
224
225impl AgentRuntime {
226 pub fn new(
232 engine_handle: Arc<crate::engine::EngineHandle>,
233 kernel_handle: Arc<KernelHandle>,
234 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
235 ) -> Self {
236 Self {
237 engine_handle,
238 config: AgentRuntimeConfig::default(),
239 kernel_handle,
240 persona_manager: None,
241 tool_retriever: None,
242 routing_stats,
243 persistence_hook: None,
244 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
245 }
246 }
247
248 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
250 self.persona_manager = Some(pm);
251 self
252 }
253
254 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
256 self.config = config;
257 self
258 }
259
260 pub fn with_tool_retriever(
262 mut self,
263 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
264 ) -> Self {
265 self.tool_retriever = Some(retriever);
266 self
267 }
268
269 pub fn with_persistence_hook(
271 mut self,
272 hook: Arc<crate::persistence_hook::PersistenceHook>,
273 ) -> Self {
274 self.persistence_hook = Some(hook);
275 self
276 }
277
278 pub async fn execute_directive(
284 &self,
285 agent_id: AgentId,
286 directive: &Directive,
287 env: &ExecEnv,
288 session_ctx: &mut SessionContext,
289 ) -> Result<ExecutionResult> {
290 let session_id: Option<String> = env
296 .session_id
297 .clone()
298 .or_else(|| Some(agent_id.to_string()));
299 self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
300 .await
301 }
302 pub async fn execute_directive_with_session(
305 &self,
306 agent_id: AgentId,
307 directive: &Directive,
308 env: &ExecEnv,
309 session_ctx: &mut SessionContext,
310 session_id: Option<String>,
311 ) -> Result<ExecutionResult> {
312 self.execute_inner(
313 agent_id,
314 &directive.goal,
315 &directive.original_request,
316 &directive.constraints,
317 &directive.acceptance_criteria,
318 env.cspace_hint.as_deref(),
319 &env.mount_paths,
320 env.workspace_context.as_deref(),
321 session_ctx,
322 session_id,
323 Some(directive),
324 env.model_override.as_deref(),
325 env.role.as_deref(),
326 env.restore_state.as_ref(),
327 )
328 .await
329 }
330
331 #[allow(clippy::too_many_arguments)]
338 async fn execute_inner(
339 &self,
340 agent_id: AgentId,
341 goal: &str,
342 original_request: &str,
343 constraints: &[String],
344 acceptance_criteria: &[String],
345 cspace_hint: Option<&str>,
346 mount_paths: &[std::path::PathBuf],
347 workspace_context: Option<&str>,
348 session_ctx: &mut SessionContext,
349 session_id: Option<String>,
350 persistence_directive: Option<&Directive>,
351 model_override: Option<&str>,
352 role: Option<&str>,
353 restore_state: Option<&serde_json::Value>,
354 ) -> Result<ExecutionResult> {
355 let prompt = build_user_prompt_inner(goal, acceptance_criteria);
356
357 let persona_prompt = self
359 .persona_manager
360 .as_ref()
361 .map(|pm| pm.active_system_prompt())
362 .filter(|s| !s.trim().is_empty());
363
364 let persona_role = self
366 .persona_manager
367 .as_ref()
368 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
369
370 let cspace = resolve_cspace(
372 cspace_hint,
373 persona_role.as_deref(),
374 Some("worker"),
375 agent_id,
376 );
377
378 let mut system_prompt = build_system_prompt_inner(
381 goal,
382 original_request,
383 constraints,
384 acceptance_criteria,
385 workspace_context,
386 persona_prompt.as_deref(),
387 None,
388 None,
389 );
390
391 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
393 match retriever.embedder().embed(goal).await {
394 Ok(query_vec) => {
395 let results = retriever.retrieve(&query_vec, 8);
396 if results.is_empty() {
397 None
398 } else {
399 let xml = crate::tools::retrieval::format_capability_index(&results);
400 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
401 Some(xml)
402 }
403 }
404 Err(e) => {
405 tracing::warn!(error = %e, "Failed to embed goal for retrieval");
406 None
407 }
408 }
409 } else {
410 None
411 };
412
413 let kernel_manifest = {
415 let domains = cspace.active_domains();
416 if domains.is_empty() {
417 None
418 } else {
419 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
420 }
421 };
422
423 if capabilities_xml.is_some() || kernel_manifest.is_some() {
425 system_prompt = build_system_prompt_inner(
426 goal,
427 original_request,
428 constraints,
429 acceptance_criteria,
430 workspace_context,
431 persona_prompt.as_deref(),
432 capabilities_xml.as_deref(),
433 kernel_manifest.as_deref(),
434 );
435 }
436
437 let memory_manager = self.kernel_handle.agents.memory_manager();
439 match memory_manager
440 .recall_with_proactive(goal, &mut session_ctx.recall_timing)
441 .await
442 {
443 Ok(memories) if !memories.is_empty() => {
444 tracing::info!(count = memories.len(), "Recalled memories for task");
445 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
446 }
447 Ok(_) => tracing::debug!("No memories recalled"),
448 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
449 }
450
451 if let Some(sona) = memory_manager.sona_engine() {
453 match sona.adapt(goal).await {
454 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
455 tracing::info!(
456 domain = %pattern.domain,
457 confidence = pattern.confidence,
458 "SONA learned pattern injected"
459 );
460 system_prompt.push_str(&format!(
461 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
462 pattern.confidence * 100.0,
463 pattern.strategy,
464 ));
465 }
466 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
467 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
468 }
469 }
470
471 match self
473 .kernel_handle
474 .knowledge_lens
475 .recall_for_context(goal, 5)
476 .await
477 {
478 Ok(ctx) if !ctx.notes.is_empty() => {
479 tracing::info!(
480 notes = ctx.notes.len(),
481 memories = ctx.memories.len(),
482 "Recalled knowledge context for task"
483 );
484 let knowledge_blend = ctx
485 .notes
486 .iter()
487 .take(3)
488 .map(|n| format!("## {}\n\n{}", n.name, n.content))
489 .collect::<Vec<_>>()
490 .join("\n\n");
491 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
492 system_prompt.push_str(&knowledge_blend);
493 }
494 Ok(_) => tracing::debug!("No knowledge recalled"),
495 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
496 }
497
498 let effective_role = role.or(persona_role.as_deref());
511 let engine = self.engine_handle.get();
512 let model_id = model_override
513 .map(|s| s.to_string())
514 .or_else(|| effective_role.and_then(|r| self.kernel_handle.engine.model_for_role(r)))
515 .unwrap_or_else(|| engine.default_model_id().to_string());
516 engine.resolve_model(&model_id)?;
518 let exec_id = uuid::Uuid::new_v4();
520
521 let mut config = self.config.clone();
526 config.model_id = model_id;
527 let kernel_handle = Arc::clone(&self.kernel_handle);
528
529 let audit_trail: Option<Arc<AuditTrail>> =
531 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
532
533 let (
534 mut final_content,
535 steps_completed,
536 success,
537 trajectory_steps,
538 agent,
539 tool_call_ids,
540 tool_args_map,
541 tool_error_map,
542 tool_timestamps,
543 total_input_tokens,
544 total_output_tokens,
545 reasoning_text,
546 ) = {
547 run_agent(
548 &config,
549 &engine,
550 kernel_handle,
551 system_prompt,
552 prompt,
553 exec_id,
554 goal.to_string(),
555 agent_id,
556 cspace,
557 audit_trail,
558 self.routing_stats.clone(),
559 session_id.clone(),
560 mount_paths,
561 restore_state,
562 )
563 .await?
564 };
565
566 if final_content.is_empty() && !trajectory_steps.is_empty() {
573 let tool_summary: Vec<String> = trajectory_steps
574 .iter()
575 .enumerate()
576 .map(|(i, step)| {
577 let truncated = if step.output.len() > 800 {
578 let mut end = 800;
582 while end > 0 && !step.output.is_char_boundary(end) {
583 end -= 1;
584 }
585 format!("{}...", &step.output[..end])
586 } else {
587 step.output.clone()
588 };
589 format!("{}. [{}] {}", i + 1, step.input, truncated)
590 })
591 .collect();
592 let summary_prompt = format!(
593 "도구 실행 결과:\n\n{}\n\n\
594 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
595 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
596 tool_summary.join("\n")
597 );
598 match agent.run(summary_prompt).await {
599 Ok((response, _events)) => {
600 if !response.content.is_empty() {
601 tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
602 final_content = response.content;
603 }
604 }
605 Err(e) => {
606 tracing::warn!(error = %e, "Post-execution summary failed");
607 }
608 }
609 }
610
611 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
614 .iter()
615 .enumerate()
616 .map(|(i, step)| {
617 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
618 let args_str = tool_call_ids
619 .get(i)
620 .and_then(|id| tool_args_map.get(id))
621 .cloned()
622 .unwrap_or_default();
623 let is_error = tool_call_ids
624 .get(i)
625 .and_then(|id| tool_error_map.get(id))
626 .copied()
627 .unwrap_or(false);
628 let timestamp = tool_call_ids
629 .get(i)
630 .and_then(|id| tool_timestamps.get(id))
631 .copied();
632 let input_str = truncate_json_str(&args_str, 500);
633 oxios_ouroboros::ToolCallRecord {
634 tool: step.input.clone(),
635 input: input_str,
636 output: step.output.clone(),
637 duration_ms: step.duration_ms,
638 is_error,
639 tool_call_id: tc_id,
640 timestamp,
641 }
642 })
643 .collect();
644
645 tracing::info!(
646 exec_id = %exec_id,
647 steps = steps_completed,
648 success,
649 tool_calls = tool_calls.len(),
650 "AgentRuntime finished"
651 );
652
653 let result = ExecutionResult {
654 output: final_content.clone(),
655 steps_completed,
656 success,
657 tool_calls,
658 failure_class: None,
659 restore_state: None,
660 tokens_input: total_input_tokens,
661 tokens_output: total_output_tokens,
662 model_id: self.engine_handle.get().default_model_id().to_string(),
663 reasoning_text,
664 };
665
666 if let Some(directive) = persistence_directive
669 && success
670 && let Some(hook) = &self.persistence_hook
671 {
672 let already_saved_knowledge = trajectory_steps
673 .iter()
674 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
675 let hook = hook.clone();
676 let directive_clone = directive.clone();
677 let traj_clone = trajectory_steps.clone();
678 let output_clone = final_content.clone();
679 let sid = session_id.clone();
680 let msg_index = {
683 let mut counter = self.session_msg_counter.lock();
684 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
685 let current = *idx;
686 *idx += 1;
687 current
688 };
689 tokio::spawn(async move {
690 match hook
691 .evaluate(
692 &directive_clone,
693 &traj_clone,
694 &output_clone,
695 already_saved_knowledge,
696 )
697 .await
698 {
699 Ok(plan) => {
700 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
701 tracing::info!(
702 memory = plan.memory.len(),
703 knowledge = plan.knowledge.len(),
704 message_index = msg_index,
705 "PersistenceHook executing plan"
706 );
707 let session_id = sid.unwrap_or_default();
708 hook.execute_plan(plan, &session_id, msg_index).await;
709 }
710 }
711 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
712 }
713 });
714 }
715
716 Ok(result)
717 }
718}
719
720#[allow(clippy::too_many_arguments)]
725async fn run_agent(
726 config: &AgentRuntimeConfig,
727 engine: &OxiosEngine,
728 kernel_handle: Arc<KernelHandle>,
729 system_prompt: String,
730 prompt: String,
731 exec_id: uuid::Uuid,
732 goal: String,
733 agent_id: AgentId,
734 cspace: crate::capability::CSpace,
735 audit_trail: Option<Arc<AuditTrail>>,
736 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
737 session_id: Option<String>,
738 mount_paths: &[std::path::PathBuf],
739 restore_state: Option<&serde_json::Value>,
740) -> Result<(
741 String,
742 usize,
743 bool,
744 Vec<oxios_memory::memory::sona::TrajectoryStep>,
745 Arc<Agent>,
746 Vec<String>,
747 std::collections::HashMap<String, String>,
748 std::collections::HashMap<String, bool>,
749 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
750 u64,
751 u64,
752 String,
753)> {
754 let workspace = if !mount_paths.is_empty() {
760 mount_paths[0].clone()
761 } else if let Some(ws) = &config.workspace_dir {
762 ws.clone()
763 } else {
764 std::env::temp_dir()
765 .join("oxios-agent-workspace")
766 .join(agent_id.to_string())
767 };
768
769 let _ = std::fs::create_dir_all(&workspace);
771
772 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
773
774 {
791 use crate::access_manager::{Role, Subject};
792 let agent_name = format!("agent-{agent_id}");
793 let mut am = kernel_handle.exec.access_manager().lock();
794 let perms = am.get_or_create_permissions(&agent_name);
795
796 if let Ok(cwd) = std::env::current_dir() {
798 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
799 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
800 perms.allow_path(&cwd_pattern);
801 tracing::debug!(
802 agent = %agent_name,
803 path = %cwd_pattern,
804 "Added CWD to agent allowed paths"
805 );
806 }
807 }
808
809 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
811 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
812 perms.allow_path(&ws_pattern);
813 }
814
815 for mount_path in mount_paths {
820 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
821 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
822 perms.allow_path(&pattern);
823 tracing::debug!(
824 agent = %agent_name,
825 path = %pattern,
826 "Added Mount path to agent allowed paths (RFC-025)"
827 );
828 }
829 }
830
831 let kernel_ws = kernel_handle
833 .state
834 .workspace_path()
835 .to_string_lossy()
836 .to_string();
837 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
838 if kernel_ws_pattern != ws_pattern
839 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
840 {
841 perms.allow_path(&kernel_ws_pattern);
842 }
843
844 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
846 perms.allow_path("/tmp/**");
847 }
848
849 let rbac_subject = Subject::Agent(agent_id);
851 am.rbac_manager_mut()
852 .assign_role(rbac_subject, Role::Superuser);
853 }
854
855 let _trace_guard = crate::observability::tracer().start(
857 format!("exec-{}", &exec_id.to_string()[..8]).as_str(),
858 oxi_sdk::SpanKind::Agent,
859 );
860
861 let registry = ToolRegistry::new();
863 let search_cache = Arc::new(SearchCache::new());
864
865 let agent_context = AgentContext {
867 agent_id,
868 agent_name: format!("agent-{agent_id}"),
869 cspace: Arc::new(cspace.clone()),
870 };
871
872 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
875 let audit_path = kernel_handle
876 .state
877 .workspace_path()
878 .join("audit")
879 .join("access.jsonl");
880 Arc::new(TrailAuditSink::new(trail, audit_path))
881 } else {
882 Arc::new(TracingAuditSink)
883 };
884 let access_gate = Arc::new(AccessGate::new(
886 kernel_handle.exec.access_manager().clone(),
887 Arc::new(kernel_handle.exec.config_snapshot()),
888 audit_sink,
889 ));
890
891 let approval_config = kernel_handle.infra.approval_config_handle();
901 let exec_snapshot = kernel_handle.exec.config_snapshot();
902 let exec_resolver = crate::approval::ExecPolicyResolver {
903 allowed_commands: Arc::new(parking_lot::RwLock::new(
904 exec_snapshot.allowed_commands.clone(),
905 )),
906 };
907 let mut dynamic_resolvers = std::collections::HashMap::new();
908 dynamic_resolvers.insert(
909 "exec".to_string(),
910 Box::new(exec_resolver) as Box<dyn crate::approval::ToolPolicyResolver>,
911 );
912 let approval_gate = Arc::new(crate::approval::ApprovalGate::with_dynamic_resolvers(
913 crate::approval::default_tool_policy_map(),
914 approval_config,
915 vec![Box::new(crate::approval::SecurityBlacklist::new(
916 crate::approval::default_blacklist_rules(),
917 ))],
918 dynamic_resolvers,
919 ));
920 let approval_event_bus = kernel_handle.infra.event_bus_clone();
921 let approval_pending = kernel_handle.infra.pending_tool_approvals();
922 let path_access_pending = kernel_handle.infra.pending_path_access();
923
924 register_tools_from_cspace_gated(
925 ®istry,
926 &kernel_handle,
927 &cspace,
928 search_cache,
929 agent_id,
930 access_gate,
931 agent_context,
932 Some(approval_gate),
933 Some(approval_event_bus),
934 Some(approval_pending),
935 Some(path_access_pending),
936 );
937
938 tracing::info!(
939 exec_id = %exec_id,
940 capabilities = cspace.len(),
941 "Tools registered from CSpace"
942 );
943
944 let agent_config = AgentConfig {
952 name: format!("agent-{agent_id}"),
953 description: None,
954 model_id: config.model_id.clone(),
955 system_prompt: Some(system_prompt.clone()),
956 timeout_seconds: 300,
957 temperature: config
958 .model_params
959 .as_ref()
960 .and_then(|p| p.temperature)
961 .or(Some(0.7)),
962 max_tokens: config
963 .model_params
964 .as_ref()
965 .and_then(|p| p.max_tokens)
966 .map(|v| v as usize)
967 .or(Some(8192)),
968 compaction_strategy: CompactionStrategy::Threshold(0.8),
969 compaction_instruction: None,
970 context_window: 128_000,
971 workspace_dir: Some(workspace.clone()),
972 output_mode: None,
973 provider_options: config.provider_options.clone(),
974 session_id: None,
975 max_tool_result_bytes: config.max_tool_result_bytes,
977 subagent_depth: 0,
982 subagent_runner: Some(
985 crate::subagent_runner::OxiosSubagentRunner::new(engine.oxi().clone())
986 .into_trait_object(),
987 ),
988 ..Default::default()
989 };
990
991 let agent = if config.provider_rpm > 0 {
1006 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
1008 let provider_name = engine.resolve_model(&config.model_id)?.provider;
1009 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
1010
1011 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
1013 if config.rate_limit_per_minute > 0 {
1014 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
1015 config.rate_limit_per_minute,
1016 ));
1017 }
1018 if config.token_budget > 0 {
1019 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
1020 config.token_budget,
1021 ));
1022 }
1023 if config.audit_tool_calls {
1024 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
1025 tracing::Level::INFO,
1026 ));
1027 }
1028
1029 let agent = Arc::new(Agent::new_with_resolver(
1031 provider,
1032 agent_config,
1033 Arc::new(registry),
1034 resolver,
1035 ));
1036
1037 if !pipeline.is_empty() {
1039 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
1040 let agent_id_for_hooks = agent_id.to_string();
1041 let hooks = oxi_sdk::middleware::build_hooks(
1042 Arc::new(pipeline),
1043 agent_id_for_hooks,
1044 terminate_flag,
1045 );
1046 agent.set_hooks(hooks);
1047 }
1048
1049 agent
1050 } else {
1051 let mut builder = engine
1053 .oxi()
1054 .agent(agent_config)
1055 .workspace(&workspace)
1056 .system_prompt(system_prompt);
1057
1058 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
1068 .names()
1069 .into_iter()
1070 .filter_map(|name| registry.get(&name))
1071 .collect();
1072
1073 if let Some(auth) = engine.authorizer() {
1075 builder = builder.authorizer(auth.clone());
1076 }
1077 if let Some(tracer) = engine.tracer() {
1078 builder = builder.tracer(tracer.clone());
1079 }
1080 if let Some(ct) = engine.cost_tracker() {
1081 builder = builder.cost_tracker(ct.clone());
1082 }
1083
1084 if config.rate_limit_per_minute > 0 {
1087 builder = builder.with_rate_limit(config.rate_limit_per_minute);
1088 }
1089 if config.token_budget > 0 {
1090 builder = builder.with_token_budget(config.token_budget);
1091 }
1092 if config.audit_tool_calls {
1093 builder = builder.with_logging();
1094 }
1095
1096 let built = builder.build()?;
1097 let agent = Arc::new(built);
1098
1099 let agent_tools = agent.tools();
1104 for tool in cspace_tool_arcs {
1105 agent_tools.register_arc(tool);
1106 }
1107
1108 agent
1109 };
1110
1111 if let Some(state) = restore_state {
1115 agent.import_state(state.clone()).unwrap_or_else(|e| {
1116 tracing::warn!(agent_id = %agent_id, error = %e, "Failed to restore agent state");
1117 });
1118 }
1119
1120 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
1122 let exec_state_cb = Arc::clone(&exec_state);
1123 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
1124 let session_id_for_callback = exec_id.to_string();
1125 let model_id_for_callback = config.model_id.clone();
1126 let agent_id_for_callback = agent_id.to_string();
1127 let routing_stats_for_cb = routing_stats.clone();
1128 let transparency_session: Option<String> = session_id.clone();
1131 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1132 let streaming_sinks_for_cb: Arc<crate::streaming_sink::StreamingSinkRegistry> =
1136 Arc::clone(&kernel_handle.streaming_sinks);
1137 let mut sent_model_for_cb: bool = false;
1139 let result = agent
1140 .run_streaming(prompt, move |event| {
1141 if !sent_model_for_cb
1142 && let Some(ref sid) = transparency_session
1143 && !model_id_for_callback.is_empty()
1144 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1145 {
1146 let _ = tx.try_send(StreamDelta::Model(model_id_for_callback.clone()));
1147 sent_model_for_cb = true;
1148 }
1149 let mut s = exec_state_cb.lock();
1150 match event {
1151 AgentEvent::ToolExecutionStart {
1152 tool_name,
1153 tool_call_id,
1154 args,
1155 context,
1156 ..
1157 } => {
1158 let idx = s.trajectory_steps.len();
1160 s.pending_tools
1161 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1162 s.tool_args_map.insert(
1163 tool_call_id.clone(),
1164 serde_json::to_string(&args).unwrap_or_default(),
1165 );
1166 s.tool_timestamps
1167 .insert(tool_call_id.clone(), chrono::Utc::now());
1168 s.tool_call_ids.push(tool_call_id.clone());
1169 s.trajectory_steps
1170 .push(oxios_memory::memory::sona::TrajectoryStep {
1171 input: tool_name.clone(),
1172 output: String::new(),
1173 duration_ms: 0,
1174 confidence: 0.0,
1175 });
1176 if let Some(ref sid) = transparency_session {
1178 let context_json = context
1179 .as_ref()
1180 .map(serde_json::to_value)
1181 .transpose()
1182 .unwrap_or(None);
1183 let _ =
1184 kernel_handle_for_cb
1185 .infra
1186 .publish(KernelEvent::ToolExecutionStarted {
1187 session_id: sid.clone(),
1188 tool_name: tool_name.clone(),
1189 tool_call_id: tool_call_id.clone(),
1190 tool_args: args.clone(),
1191 context: context_json,
1192 });
1193 }
1194 }
1195 AgentEvent::ToolExecutionUpdate {
1196 tool_call_id,
1197 tool_name,
1198 partial_result,
1199 tab_id,
1200 context,
1201 } => {
1202 if let Some(ref sid) = transparency_session {
1212 let context_json = context
1213 .as_ref()
1214 .map(serde_json::to_value)
1215 .transpose()
1216 .unwrap_or(None);
1217 let _ = kernel_handle_for_cb.infra.publish(
1218 KernelEvent::ToolExecutionProgress {
1219 session_id: sid.clone(),
1220 tool_call_id: tool_call_id.clone(),
1221 tool_name: tool_name.clone(),
1222 progress: partial_result,
1223 tab_id,
1224 context: context_json,
1225 },
1226 );
1227 }
1228 }
1229 AgentEvent::ToolExecutionEnd {
1230 tool_name,
1231 tool_call_id,
1232 is_error,
1233 result,
1234 ..
1235 } => {
1236 if !is_error {
1237 s.steps_completed += 1;
1238 }
1239 let mut duration_ms: u64 = 0;
1241 let mut summary = String::new();
1242 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1243 duration_ms = start.elapsed().as_millis() as u64;
1244 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1245 summary = summarize_tool_result(&result.content, 200);
1246 step.output = summary.clone();
1247 step.duration_ms = duration_ms;
1248 step.confidence = if is_error { 0.3 } else { 0.8 };
1249 }
1250 }
1251 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1252 if let Some(ref sid) = transparency_session {
1254 let _ = kernel_handle_for_cb.infra.publish(
1255 KernelEvent::ToolExecutionFinished {
1256 session_id: sid.clone(),
1257 tool_call_id: tool_call_id.clone(),
1258 tool_name: tool_name.clone(),
1259 duration_ms,
1260 is_error,
1261 output_summary: summary,
1262 },
1263 );
1264 }
1265 }
1266 AgentEvent::AgentEnd {
1267 messages,
1268 stop_reason,
1269 ..
1270 } => {
1271 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1272 s.final_content = a.text_content();
1273 }
1274 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1280 }
1281 AgentEvent::Error { message, .. } => {
1282 s.final_content = message.clone();
1283 s.success = false;
1284 }
1285 AgentEvent::Usage {
1286 input_tokens,
1287 output_tokens,
1288 } => {
1289 s.total_input_tokens += input_tokens as u64;
1291 s.total_output_tokens += output_tokens as u64;
1292
1293 let agent_label = format!("agent-{agent_id_for_callback}");
1295 crate::observability::cost_tracker().record(
1296 &agent_label,
1297 &oxi_sdk::Model::new(
1298 &model_id_for_callback,
1299 &model_id_for_callback,
1300 oxi_sdk::Api::OpenAiCompletions,
1301 "unknown",
1302 "https://unknown.com",
1303 ),
1304 oxi_sdk::TokenUsage {
1305 input: input_tokens as u64,
1306 output: output_tokens as u64,
1307 cache_read: 0,
1308 cache_write: 0,
1309 },
1310 );
1311
1312 if let Some(stats) = &routing_stats_for_cb {
1314 let cost = crate::kernel_handle::engine_api::estimate_cost(
1315 &model_id_for_callback,
1316 input_tokens as u64,
1317 output_tokens as u64,
1318 );
1319 stats.record_model_usage(&model_id_for_callback, cost);
1320 }
1321 if let Some(ref sid) = transparency_session {
1323 let _ = kernel_handle_for_cb
1324 .infra
1325 .publish(KernelEvent::TokenUsageUpdate {
1326 session_id: sid.clone(),
1327 input_tokens: input_tokens as u64,
1328 output_tokens: output_tokens as u64,
1329 });
1330 }
1331 }
1332 AgentEvent::Compaction {
1333 event: CompactionEvent::Completed { result, .. },
1334 } => {
1335 handle_compaction(
1336 result.summary.clone(),
1337 session_id_for_callback.clone(),
1338 memory_for_callback.clone(),
1339 );
1340 if let Some(ref sid) = transparency_session {
1342 let _ =
1343 kernel_handle_for_cb
1344 .infra
1345 .publish(KernelEvent::ReasoningFragment {
1346 session_id: sid.clone(),
1347 content: result.summary.clone(),
1348 source: "compaction".to_string(),
1349 });
1350 }
1351 }
1352 AgentEvent::Compaction {
1353 event: CompactionEvent::Triggered { source, .. },
1354 } => {
1355 if let Some(ref sid) = transparency_session {
1361 let _ =
1362 kernel_handle_for_cb
1363 .infra
1364 .publish(KernelEvent::CompactionTriggered {
1365 session_id: Some(sid.clone()),
1366 source,
1367 });
1368 } else {
1369 let _ =
1370 kernel_handle_for_cb
1371 .infra
1372 .publish(KernelEvent::CompactionTriggered {
1373 session_id: None,
1374 source,
1375 });
1376 }
1377 }
1378 AgentEvent::TextChunk { text } => {
1379 if let Some(ref sid) = transparency_session
1393 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1394 {
1395 let _ = tx.try_send(StreamDelta::Text(text.clone()));
1396 }
1397 }
1398 AgentEvent::Thinking => {
1399 if let Some(ref sid) = transparency_session
1404 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1405 {
1406 let _ = tx.try_send(StreamDelta::Thinking);
1407 }
1408 }
1409 AgentEvent::ThinkingDelta { text } => {
1410 const REASONING_CAP: usize = 4096;
1424 if s.reasoning_text.len() < REASONING_CAP {
1425 s.reasoning_text.push_str(&text);
1426 if s.reasoning_text.len() > REASONING_CAP {
1427 s.reasoning_text.truncate(REASONING_CAP);
1428 }
1429 }
1430 if let Some(ref sid) = transparency_session
1431 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1432 {
1433 let _ = tx.try_send(StreamDelta::ThinkingDelta(text.clone()));
1434 }
1435 }
1436 AgentEvent::ThinkingEnd => {
1437 if let Some(ref sid) = transparency_session
1443 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1444 {
1445 let _ = tx.try_send(StreamDelta::ThinkingEnd);
1446 }
1447 }
1448 AgentEvent::ToolCallDelta {
1449 tool_call_id,
1450 args_delta,
1451 } => {
1452 if let Some(ref sid) = transparency_session {
1457 let _ = kernel_handle_for_cb
1458 .infra
1459 .publish(KernelEvent::ToolArgsDelta {
1460 session_id: sid.clone(),
1461 tool_call_id: tool_call_id.clone(),
1462 args_delta: args_delta.clone(),
1463 });
1464 }
1465 }
1466 _ => {}
1467 }
1468 })
1469 .await;
1470
1471 let circuit = get_llm_circuit_breaker();
1473 if result.is_err() {
1474 circuit.record_failure();
1475 crate::metrics::get_metrics()
1476 .llm_circuit_breaker_state
1477 .set(1.0);
1478 } else {
1479 circuit.record_success();
1480 crate::metrics::get_metrics()
1481 .llm_circuit_breaker_state
1482 .set(0.0);
1483 }
1484
1485 if let Err(e) = result {
1486 tracing::error!(exec_id = %exec_id, error = %e, "Agent failed");
1487 let restore_state = agent.export_state().ok();
1492 return Err(crate::resilience::AgentRunError::wrap(e, restore_state).into());
1493 }
1494
1495 let s = exec_state.lock();
1496 tracing::info!(
1497 exec_id = %exec_id,
1498 steps = s.steps_completed,
1499 success = s.success,
1500 "Agent completed"
1501 );
1502
1503 if !s.trajectory_steps.is_empty()
1506 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1507 {
1508 let steps = s.trajectory_steps.clone();
1509 let success = s.success;
1510 let sona = Arc::clone(sona);
1511 let domain = infer_domain(&goal);
1512 tokio::spawn(async move {
1513 let verdict = if success {
1514 oxios_memory::memory::sona::Verdict::Success
1515 } else {
1516 oxios_memory::memory::sona::Verdict::Failure
1517 };
1518 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1519 if let Err(e) = sona.record(trajectory).await {
1520 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1521 }
1522 });
1523 }
1524
1525 Ok((
1526 s.final_content.clone(),
1527 s.steps_completed,
1528 s.success,
1529 s.trajectory_steps.clone(),
1530 agent,
1531 s.tool_call_ids.clone(),
1532 s.tool_args_map.clone(),
1533 s.tool_error_map.clone(),
1534 s.tool_timestamps.clone(),
1535 s.total_input_tokens,
1536 s.total_output_tokens,
1537 s.reasoning_text.clone(),
1538 ))
1539}
1540
1541fn summarize_tool_result(result: &str, max_len: usize) -> String {
1546 let trimmed = result.trim();
1547 if trimmed.chars().count() <= max_len {
1548 return trimmed.to_string();
1549 }
1550 let first_line = trimmed.lines().next().unwrap_or("");
1552 if first_line.chars().count() <= max_len {
1553 first_line.to_string()
1554 } else {
1555 let take = max_len.saturating_sub(3);
1556 let truncated: String = if take == 0 {
1557 first_line.chars().take(max_len).collect()
1558 } else {
1559 first_line.chars().take(take).collect()
1560 };
1561 format!("{truncated}...")
1562 }
1563}
1564fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1565 if json_str.len() <= max_len {
1566 return json_str.to_string();
1567 }
1568 let take = max_len.saturating_sub(3);
1571 if take == 0 {
1572 return json_str.chars().take(max_len).collect();
1573 }
1574 let truncated: String = json_str.chars().take(take).collect();
1575 format!("{truncated}...")
1576}
1577
1578fn infer_domain(goal: &str) -> String {
1583 let lower = goal.to_lowercase();
1584 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1585
1586 if keywords.iter().any(|k| {
1588 [
1589 "test",
1590 "tests",
1591 "spec",
1592 "testing",
1593 "assert",
1594 "unit test",
1595 "integration",
1596 ]
1597 .contains(k)
1598 }) {
1599 return "testing".to_string();
1600 }
1601 if keywords
1602 .iter()
1603 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1604 {
1605 return "deployment".to_string();
1606 }
1607 if keywords
1608 .iter()
1609 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1610 {
1611 return "bugfix".to_string();
1612 }
1613 if keywords
1614 .iter()
1615 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1616 {
1617 return "refactoring".to_string();
1618 }
1619 if keywords
1620 .iter()
1621 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1622 {
1623 return "documentation".to_string();
1624 }
1625 if keywords
1626 .iter()
1627 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1628 {
1629 return "development".to_string();
1630 }
1631 if keywords
1632 .iter()
1633 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1634 {
1635 return "analysis".to_string();
1636 }
1637 if keywords
1638 .iter()
1639 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1640 {
1641 return "configuration".to_string();
1642 }
1643
1644 let meaningful: Vec<&str> = lower
1646 .split_whitespace()
1647 .filter(|w| w.len() > 2)
1648 .take(2)
1649 .collect();
1650 if meaningful.len() >= 2 {
1651 meaningful.join("_")
1652 } else {
1653 "general".to_string()
1654 }
1655}
1656
1657fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1663 let entry = MemoryEntry {
1664 id: uuid::Uuid::new_v4().to_string(),
1665 memory_type: MemoryType::Conversation,
1666 tier: crate::memory::MemoryTier::Warm,
1667 content: summary,
1668 content_hash: 0,
1669 source: "compaction".to_string(),
1670 session_id: Some(session_id),
1671 tags: vec![],
1672 importance: 0.5,
1673 pinned: false,
1674 protection: crate::memory::ProtectionLevel::None,
1675 auto_classified: false,
1676 session_appearances: 0,
1677 user_corrected: false,
1678 seen_in_sessions: vec![],
1679 created_at: chrono::Utc::now(),
1680 accessed_at: chrono::Utc::now(),
1681 modified_at: chrono::Utc::now(),
1682 access_count: 0,
1683 decay_score: 1.0,
1684 compaction_level: 0,
1685 compacted_from: vec![],
1686 related_ids: vec![],
1687 contradicts: None,
1688 };
1689 tokio::spawn(async move {
1690 if let Err(e) = memory_manager.remember(entry).await {
1691 tracing::warn!(error = %e, "Failed to save compaction summary");
1692 }
1693 });
1694}
1695
1696#[allow(dead_code)]
1701fn build_directive_system_prompt(
1702 directive: &Directive,
1703 env: &ExecEnv,
1704 persona_prompt: Option<&str>,
1705 capabilities_xml: Option<&str>,
1706 kernel_manifest: Option<&str>,
1707) -> String {
1708 build_system_prompt_inner(
1709 &directive.goal,
1710 &directive.original_request,
1711 &directive.constraints,
1712 &directive.acceptance_criteria,
1713 env.workspace_context.as_deref(),
1714 persona_prompt,
1715 capabilities_xml,
1716 kernel_manifest,
1717 )
1718}
1719
1720const ARTIFACT_PROTOCOL: &str = "\n\n\
1727 ## Artifacts\n\
1728 When you produce substantial, self-contained content the user will want to\n\
1729 view or interact with separately — a complete HTML page, an SVG graphic, a\n\
1730 Mermaid diagram, or an interactive React component — wrap it in an artifact\n\
1731 tag so the UI shows a live preview panel:\n\n\
1732 <lobeArtifact type=\"...\" title=\"...\" identifier=\"...\">\n\
1733 ...the full content...\n\
1734 </lobeArtifact>\n\n\
1735 Use exactly one of these `type` values (others are not recognised):\n\
1736 - `text/html` → an HTML document or fragment\n\
1737 - `image/svg+xml` → an SVG graphic\n\
1738 - `application/lobe.artifacts.mermaid` → a Mermaid diagram\n\
1739 - `application/lobe.artifacts.react` → a React component (JSX/TSX)\n\n\
1740 - `title` — a short human title for the panel.\n\
1741 - `identifier` — a unique kebab-case id, e.g. `sales-dashboard`.\n\
1742 Put the full, runnable content INSIDE the tag (not in a separate fence).\n\n\
1743 Do NOT wrap non-visual code. Shell commands, Python, Rust, JSON, config, or\n\
1744 short snippets that are part of an explanation belong in a normal fenced\n\
1745 code block. Use an artifact only for content the user would open to view,\n\
1746 not copy-and-paste. Limit one artifact per self-contained piece.\n";
1747#[allow(clippy::too_many_arguments)]
1753fn build_system_prompt_inner(
1754 goal: &str,
1755 original_request: &str,
1756 constraints: &[String],
1757 acceptance_criteria: &[String],
1758 workspace_context: Option<&str>,
1759 persona_prompt: Option<&str>,
1760 capabilities_xml: Option<&str>,
1761 kernel_manifest: Option<&str>,
1762) -> String {
1763 let mut prompt = String::from(
1764 "You are an autonomous agent in the Oxios operating system.\n\
1765 You execute Seeds — immutable specifications with goals, constraints, and\n\
1766 acceptance criteria.\n\n\
1767 ## Available Tools\n\
1768 You have the following tools:\n\
1769 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1770 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1771 - **Exec**: run shell commands\n\
1772 - **Memory tools**: memory_write (store facts/preferences), memory_read (list entries), memory_search (find relevant memories) — your cross-session recall. Use memory_write proactively when the user shares preferences, facts, or corrections worth remembering.
1773 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1774 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1775 **Important**: When the task involves fetching information from the internet,\n\
1776 websites, or online services, use `web_search` first — do NOT search local files.\n\
1777 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1778 from the web, use `web_search`.\n",
1779 );
1780 prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1781
1782 if !original_request.is_empty() && original_request != goal {
1785 prompt.push_str(&format!(
1786 "\n## User's Original Request\n{}\n",
1787 original_request
1788 ));
1789 }
1790
1791 if !constraints.is_empty() {
1792 prompt.push_str("\n## Constraints\n");
1793 for (i, c) in constraints.iter().enumerate() {
1794 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1795 }
1796 }
1797
1798 if !acceptance_criteria.is_empty() {
1799 prompt.push_str("\n## Acceptance Criteria\n");
1800 for (i, c) in acceptance_criteria.iter().enumerate() {
1801 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1802 }
1803 }
1804
1805 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1809 prompt.push_str("\n## Workspace Context\n");
1810 prompt.push_str(ctx);
1811 prompt.push('\n');
1812 }
1813
1814 if let Some(pp) = persona_prompt {
1816 prompt.push_str("\n## Persona\n");
1817 prompt.push_str(pp);
1818 prompt.push('\n');
1819 }
1820
1821 if let Some(xml) = capabilities_xml {
1823 prompt.push_str("\n## Available Capabilities\n");
1824 prompt.push_str("The following capabilities are relevant to your goal. ");
1825 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1826 prompt.push_str(xml);
1827 prompt.push('\n');
1828 }
1829
1830 if let Some(manifest) = kernel_manifest {
1832 prompt.push('\n');
1833 prompt.push_str(manifest);
1834 prompt.push('\n');
1835 }
1836
1837 prompt.push_str(
1839 "\n## Execution Protocol\n\
1840 1. UNDERSTAND — Read the user's request carefully. If it is a simple\n\
1841 greeting, small talk, or a question you can answer from knowledge,\n\
1842 respond naturally and conversationally — no tools needed.\n\
1843 2. PLAN — For complex tasks, outline your approach before acting.\n\
1844 3. EXECUTE — Use tools only when the task actually requires them.\n\
1845 Prefer the simplest approach. Simple requests need no tools.\n\
1846 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1847 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1848 If the request is ambiguous, use the `ask_user` tool (free-text question)\n\
1849 or the `pi-questionnaire` tool (structured choices) to clarify before\n\
1850 executing — do not guess when a single question would resolve the intent.\n\n\
1851 ## Hard Boundaries\n\
1852 - NEVER modify files outside the workspace scope\n\
1853 - NEVER execute destructive commands without confirming scope\n\
1854 - NEVER claim completion without evidence — show the output, not your opinion\n\
1855 - NEVER add features or improvements beyond the goal's scope\n\
1856 - If you cannot complete the task, say so and explain WHY\n\n\
1857 ## Scope Guard\n\
1858 The goal defines your universe. Do not:\n\
1859 - Refactor code the goal didn't mention\n\
1860 - Add tests the goal didn't require\n\
1861 - Change configuration the goal didn't specify\n\
1862 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1863 ## Error Handling\n\
1864 - If a tool fails, read the error message carefully before retrying\n\
1865 - If a command fails, do NOT immediately retry with --force or sudo\n\
1866 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1867 ## Shape Matching\n\
1868 Match your output to the task: simple task → concise response.\n\
1869 Do not write 50 lines when 5 would do.\n\
1870 Use `exec` for all command execution (git, gh, osascript, etc.).",
1871 );
1872 prompt.push_str(ARTIFACT_PROTOCOL);
1873
1874 prompt
1875}
1876#[allow(dead_code)]
1877fn build_directive_user_prompt(directive: &Directive) -> String {
1878 build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1879}
1880
1881fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1883 format!(
1884 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1885 goal,
1886 acceptance_criteria
1887 .iter()
1888 .enumerate()
1889 .map(|(i, c)| format!("{}. {}", i + 1, c))
1890 .collect::<Vec<_>>()
1891 .join("\n")
1892 )
1893}
1894
1895impl std::fmt::Debug for AgentRuntime {
1896 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1897 f.debug_struct("AgentRuntime")
1898 .field("model_id", &self.engine_handle.get().default_model_id())
1899 .finish()
1900 }
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use super::*;
1906 use async_trait::async_trait;
1907 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1908 use serde_json::Value;
1909
1910 struct DummyTool {
1912 name: String,
1913 }
1914
1915 #[async_trait]
1916 impl AgentTool for DummyTool {
1917 fn name(&self) -> &str {
1918 &self.name
1919 }
1920 fn label(&self) -> &str {
1921 &self.name
1922 }
1923 fn description(&self) -> &str {
1924 "Test tool"
1925 }
1926 fn parameters_schema(&self) -> Value {
1927 serde_json::json!({"type": "object"})
1928 }
1929
1930 async fn execute(
1931 &self,
1932 _tool_call_id: &str,
1933 _params: Value,
1934 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1935 _ctx: &ToolContext,
1936 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1937 Ok(oxi_sdk::AgentToolResult::success("ok"))
1938 }
1939 }
1940
1941 #[test]
1943 fn test_requires_tools_validation_passes() {
1944 let registry = ToolRegistry::new();
1945
1946 registry.register(DummyTool {
1947 name: "read".into(),
1948 });
1949 registry.register(DummyTool {
1950 name: "exec".into(),
1951 });
1952
1953 let missing = registry.missing(&["read", "exec"]);
1954
1955 assert!(
1956 missing.is_empty(),
1957 "Expected no missing tools, got: {:?}",
1958 missing
1959 );
1960 }
1961
1962 #[test]
1964 fn test_requires_tools_validation_fails() {
1965 let registry = ToolRegistry::new();
1966
1967 registry.register(DummyTool {
1968 name: "read".into(),
1969 });
1970
1971 let missing = registry.missing(&["read", "exec", "nonexistent"]);
1972
1973 assert_eq!(missing, vec!["exec", "nonexistent"]);
1974 }
1975
1976 #[test]
1977 fn test_infer_domain_testing() {
1978 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1979 }
1980
1981 #[test]
1982 fn test_infer_domain_deployment() {
1983 assert_eq!(
1984 infer_domain("deploy the web service to production"),
1985 "deployment"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_infer_domain_bugfix() {
1991 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1992 }
1993
1994 #[test]
1995 fn test_infer_domain_development() {
1996 assert_eq!(
1997 infer_domain("create a new REST API endpoint"),
1998 "development"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_infer_domain_analysis() {
2004 assert_eq!(
2005 infer_domain("review the code for security issues"),
2006 "analysis"
2007 );
2008 }
2009
2010 #[test]
2011 fn test_infer_domain_fallback() {
2012 let domain = infer_domain("optimize performance metrics");
2013 assert!(!domain.is_empty());
2015 }
2016 #[test]
2017 fn test_system_prompt_includes_artifact_protocol() {
2018 let prompt = build_system_prompt_inner(
2019 "build a dashboard",
2020 "build a dashboard",
2021 &[],
2022 &[],
2023 None,
2024 None,
2025 None,
2026 None,
2027 );
2028 assert!(prompt.contains("<lobeArtifact"));
2031 assert!(prompt.contains("text/html"));
2032 assert!(prompt.contains("image/svg+xml"));
2033 assert!(prompt.contains("application/lobe.artifacts.mermaid"));
2034 assert!(prompt.contains("application/lobe.artifacts.react"));
2035 }
2036}