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 reasoning_segments: Vec<oxios_ouroboros::ReasoningSegment>,
181 reasoning_bytes: usize,
182 trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
185 pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
189 tool_call_ids: Vec<String>,
192 tool_args_map: std::collections::HashMap<String, String>,
194 tool_error_map: std::collections::HashMap<String, bool>,
196 tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
198 total_input_tokens: u64,
200 total_output_tokens: u64,
202}
203
204pub struct AgentRuntime {
213 engine_handle: Arc<crate::engine::EngineHandle>,
214 config: AgentRuntimeConfig,
215 kernel_handle: Arc<KernelHandle>,
217 persona_manager: Option<Arc<PersonaManager>>,
219 tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
221 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
223 persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
225 session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
227}
228
229impl AgentRuntime {
230 pub fn new(
236 engine_handle: Arc<crate::engine::EngineHandle>,
237 kernel_handle: Arc<KernelHandle>,
238 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
239 ) -> Self {
240 Self {
241 engine_handle,
242 config: AgentRuntimeConfig::default(),
243 kernel_handle,
244 persona_manager: None,
245 tool_retriever: None,
246 routing_stats,
247 persistence_hook: None,
248 session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
249 }
250 }
251
252 pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
254 self.persona_manager = Some(pm);
255 self
256 }
257
258 pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
260 self.config = config;
261 self
262 }
263
264 pub fn with_tool_retriever(
266 mut self,
267 retriever: Arc<crate::tools::retrieval::ToolRetriever>,
268 ) -> Self {
269 self.tool_retriever = Some(retriever);
270 self
271 }
272
273 pub fn with_persistence_hook(
275 mut self,
276 hook: Arc<crate::persistence_hook::PersistenceHook>,
277 ) -> Self {
278 self.persistence_hook = Some(hook);
279 self
280 }
281
282 pub async fn execute_directive(
288 &self,
289 agent_id: AgentId,
290 directive: &Directive,
291 env: &ExecEnv,
292 session_ctx: &mut SessionContext,
293 ) -> Result<ExecutionResult> {
294 let session_id: Option<String> = env
300 .session_id
301 .clone()
302 .or_else(|| Some(agent_id.to_string()));
303 self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
304 .await
305 }
306 pub async fn execute_directive_with_session(
309 &self,
310 agent_id: AgentId,
311 directive: &Directive,
312 env: &ExecEnv,
313 session_ctx: &mut SessionContext,
314 session_id: Option<String>,
315 ) -> Result<ExecutionResult> {
316 self.execute_inner(
317 agent_id,
318 &directive.goal,
319 &directive.original_request,
320 &directive.constraints,
321 &directive.acceptance_criteria,
322 env.cspace_hint.as_deref(),
323 &env.mount_paths,
324 env.workspace_context.as_deref(),
325 session_ctx,
326 session_id,
327 Some(directive),
328 env.model_override.as_deref(),
329 env.role.as_deref(),
330 env.restore_state.as_ref(),
331 )
332 .await
333 }
334
335 #[allow(clippy::too_many_arguments)]
342 async fn execute_inner(
343 &self,
344 agent_id: AgentId,
345 goal: &str,
346 original_request: &str,
347 constraints: &[String],
348 acceptance_criteria: &[String],
349 cspace_hint: Option<&str>,
350 mount_paths: &[std::path::PathBuf],
351 workspace_context: Option<&str>,
352 session_ctx: &mut SessionContext,
353 session_id: Option<String>,
354 persistence_directive: Option<&Directive>,
355 model_override: Option<&str>,
356 role: Option<&str>,
357 restore_state: Option<&serde_json::Value>,
358 ) -> Result<ExecutionResult> {
359 let prompt = build_user_prompt_inner(goal, acceptance_criteria);
360
361 let persona_prompt = self
363 .persona_manager
364 .as_ref()
365 .map(|pm| pm.active_system_prompt())
366 .filter(|s| !s.trim().is_empty());
367
368 let persona_role = self
370 .persona_manager
371 .as_ref()
372 .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
373
374 let cspace = resolve_cspace(
376 cspace_hint,
377 persona_role.as_deref(),
378 Some("worker"),
379 agent_id,
380 );
381
382 let mut system_prompt = build_system_prompt_inner(
385 goal,
386 original_request,
387 constraints,
388 acceptance_criteria,
389 workspace_context,
390 persona_prompt.as_deref(),
391 None,
392 None,
393 );
394
395 let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
397 match retriever.embedder().embed(goal).await {
398 Ok(query_vec) => {
399 let results = retriever.retrieve(&query_vec, 8);
400 if results.is_empty() {
401 None
402 } else {
403 let xml = crate::tools::retrieval::format_capability_index(&results);
404 tracing::info!(count = results.len(), "Retrieved relevant capabilities");
405 Some(xml)
406 }
407 }
408 Err(e) => {
409 tracing::warn!(error = %e, "Failed to embed goal for retrieval");
410 None
411 }
412 }
413 } else {
414 None
415 };
416
417 let kernel_manifest = {
419 let domains = cspace.active_domains();
420 if domains.is_empty() {
421 None
422 } else {
423 Some(crate::tools::retrieval::build_kernel_manifest(&domains))
424 }
425 };
426
427 if capabilities_xml.is_some() || kernel_manifest.is_some() {
429 system_prompt = build_system_prompt_inner(
430 goal,
431 original_request,
432 constraints,
433 acceptance_criteria,
434 workspace_context,
435 persona_prompt.as_deref(),
436 capabilities_xml.as_deref(),
437 kernel_manifest.as_deref(),
438 );
439 }
440
441 let memory_manager = self.kernel_handle.agents.memory_manager();
443 match memory_manager
444 .recall_with_proactive(goal, &mut session_ctx.recall_timing)
445 .await
446 {
447 Ok(memories) if !memories.is_empty() => {
448 tracing::info!(count = memories.len(), "Recalled memories for task");
449 system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
450 }
451 Ok(_) => tracing::debug!("No memories recalled"),
452 Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
453 }
454
455 if let Some(sona) = memory_manager.sona_engine() {
457 match sona.adapt(goal).await {
458 Ok(Some(pattern)) if pattern.confidence > 0.5 => {
459 tracing::info!(
460 domain = %pattern.domain,
461 confidence = pattern.confidence,
462 "SONA learned pattern injected"
463 );
464 system_prompt.push_str(&format!(
465 "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
466 pattern.confidence * 100.0,
467 pattern.strategy,
468 ));
469 }
470 Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
471 Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
472 }
473 }
474
475 match self
477 .kernel_handle
478 .knowledge_lens
479 .recall_for_context(goal, 5)
480 .await
481 {
482 Ok(ctx) if !ctx.notes.is_empty() => {
483 tracing::info!(
484 notes = ctx.notes.len(),
485 memories = ctx.memories.len(),
486 "Recalled knowledge context for task"
487 );
488 let knowledge_blend = ctx
489 .notes
490 .iter()
491 .take(3)
492 .map(|n| format!("## {}\n\n{}", n.name, n.content))
493 .collect::<Vec<_>>()
494 .join("\n\n");
495 system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
496 system_prompt.push_str(&knowledge_blend);
497 }
498 Ok(_) => tracing::debug!("No knowledge recalled"),
499 Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
500 }
501
502 let effective_role = role.or(persona_role.as_deref());
515 let engine = self.engine_handle.get();
516 let model_id = model_override
517 .map(|s| s.to_string())
518 .or_else(|| effective_role.and_then(|r| self.kernel_handle.engine.model_for_role(r)))
519 .unwrap_or_else(|| engine.default_model_id().to_string());
520 engine.resolve_model(&model_id)?;
522 let exec_id = uuid::Uuid::new_v4();
524
525 let mut config = self.config.clone();
530 config.model_id = model_id;
531 let kernel_handle = Arc::clone(&self.kernel_handle);
532
533 let audit_trail: Option<Arc<AuditTrail>> =
535 Some(Arc::clone(&self.kernel_handle.security.audit_trail));
536
537 let (
538 mut final_content,
539 steps_completed,
540 success,
541 trajectory_steps,
542 agent,
543 tool_call_ids,
544 tool_args_map,
545 tool_error_map,
546 tool_timestamps,
547 total_input_tokens,
548 total_output_tokens,
549 reasoning_text,
550 reasoning_segments,
551 ) = {
552 run_agent(
553 &config,
554 &engine,
555 kernel_handle,
556 system_prompt,
557 prompt,
558 exec_id,
559 goal.to_string(),
560 agent_id,
561 cspace,
562 audit_trail,
563 self.routing_stats.clone(),
564 session_id.clone(),
565 mount_paths,
566 restore_state,
567 )
568 .await?
569 };
570
571 if final_content.is_empty() && !trajectory_steps.is_empty() {
578 let tool_summary: Vec<String> = trajectory_steps
579 .iter()
580 .enumerate()
581 .map(|(i, step)| {
582 let truncated = if step.output.len() > 800 {
583 let mut end = 800;
587 while end > 0 && !step.output.is_char_boundary(end) {
588 end -= 1;
589 }
590 format!("{}...", &step.output[..end])
591 } else {
592 step.output.clone()
593 };
594 format!("{}. [{}] {}", i + 1, step.input, truncated)
595 })
596 .collect();
597 let summary_prompt = format!(
598 "도구 실행 결과:\n\n{}\n\n\
599 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
600 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
601 tool_summary.join("\n")
602 );
603 match agent.run(summary_prompt).await {
604 Ok((response, _events)) => {
605 if !response.content.is_empty() {
606 tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
607 final_content = response.content;
608 }
609 }
610 Err(e) => {
611 tracing::warn!(error = %e, "Post-execution summary failed");
612 }
613 }
614 }
615
616 let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
619 .iter()
620 .enumerate()
621 .map(|(i, step)| {
622 let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
623 let args_str = tool_call_ids
624 .get(i)
625 .and_then(|id| tool_args_map.get(id))
626 .cloned()
627 .unwrap_or_default();
628 let is_error = tool_call_ids
629 .get(i)
630 .and_then(|id| tool_error_map.get(id))
631 .copied()
632 .unwrap_or(false);
633 let timestamp = tool_call_ids
634 .get(i)
635 .and_then(|id| tool_timestamps.get(id))
636 .copied();
637 let input_str = truncate_json_str(&args_str, 500);
638 oxios_ouroboros::ToolCallRecord {
639 tool: step.input.clone(),
640 input: input_str,
641 output: step.output.clone(),
642 duration_ms: step.duration_ms,
643 is_error,
644 tool_call_id: tc_id,
645 timestamp,
646 }
647 })
648 .collect();
649
650 tracing::info!(
651 exec_id = %exec_id,
652 steps = steps_completed,
653 success,
654 tool_calls = tool_calls.len(),
655 "AgentRuntime finished"
656 );
657
658 let result = ExecutionResult {
659 output: final_content.clone(),
660 steps_completed,
661 success,
662 tool_calls,
663 failure_class: None,
664 restore_state: None,
665 tokens_input: total_input_tokens,
666 tokens_output: total_output_tokens,
667 model_id: self.engine_handle.get().default_model_id().to_string(),
668 reasoning_text,
669 reasoning_segments,
670 };
671
672 if let Some(directive) = persistence_directive
675 && success
676 && let Some(hook) = &self.persistence_hook
677 {
678 let already_saved_knowledge = trajectory_steps
679 .iter()
680 .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
681 let hook = hook.clone();
682 let directive_clone = directive.clone();
683 let traj_clone = trajectory_steps.clone();
684 let output_clone = final_content.clone();
685 let sid = session_id.clone();
686 let msg_index = {
689 let mut counter = self.session_msg_counter.lock();
690 let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
691 let current = *idx;
692 *idx += 1;
693 current
694 };
695 tokio::spawn(async move {
696 match hook
697 .evaluate(
698 &directive_clone,
699 &traj_clone,
700 &output_clone,
701 already_saved_knowledge,
702 )
703 .await
704 {
705 Ok(plan) => {
706 if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
707 tracing::info!(
708 memory = plan.memory.len(),
709 knowledge = plan.knowledge.len(),
710 message_index = msg_index,
711 "PersistenceHook executing plan"
712 );
713 let session_id = sid.unwrap_or_default();
714 hook.execute_plan(plan, &session_id, msg_index).await;
715 }
716 }
717 Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
718 }
719 });
720 }
721
722 Ok(result)
723 }
724}
725
726#[allow(clippy::too_many_arguments)]
731async fn run_agent(
732 config: &AgentRuntimeConfig,
733 engine: &OxiosEngine,
734 kernel_handle: Arc<KernelHandle>,
735 system_prompt: String,
736 prompt: String,
737 exec_id: uuid::Uuid,
738 goal: String,
739 agent_id: AgentId,
740 cspace: crate::capability::CSpace,
741 audit_trail: Option<Arc<AuditTrail>>,
742 routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
743 session_id: Option<String>,
744 mount_paths: &[std::path::PathBuf],
745 restore_state: Option<&serde_json::Value>,
746) -> Result<(
747 String,
748 usize,
749 bool,
750 Vec<oxios_memory::memory::sona::TrajectoryStep>,
751 Arc<Agent>,
752 Vec<String>,
753 std::collections::HashMap<String, String>,
754 std::collections::HashMap<String, bool>,
755 std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
756 u64,
757 u64,
758 String,
759 Vec<oxios_ouroboros::ReasoningSegment>,
760)> {
761 let workspace = if !mount_paths.is_empty() {
767 mount_paths[0].clone()
768 } else if let Some(ws) = &config.workspace_dir {
769 ws.clone()
770 } else {
771 std::env::temp_dir()
772 .join("oxios-agent-workspace")
773 .join(agent_id.to_string())
774 };
775
776 let _ = std::fs::create_dir_all(&workspace);
778
779 tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
780
781 {
798 use crate::access_manager::{Role, Subject};
799 let agent_name = format!("agent-{agent_id}");
800 let mut am = kernel_handle.exec.access_manager().lock();
801 let perms = am.get_or_create_permissions(&agent_name);
802
803 if let Ok(cwd) = std::env::current_dir() {
805 let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
806 if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
807 perms.allow_path(&cwd_pattern);
808 tracing::debug!(
809 agent = %agent_name,
810 path = %cwd_pattern,
811 "Added CWD to agent allowed paths"
812 );
813 }
814 }
815
816 let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
818 if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
819 perms.allow_path(&ws_pattern);
820 }
821
822 for mount_path in mount_paths {
827 let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
828 if !perms.allowed_paths.iter().any(|p| p == &pattern) {
829 perms.allow_path(&pattern);
830 tracing::debug!(
831 agent = %agent_name,
832 path = %pattern,
833 "Added Mount path to agent allowed paths (RFC-025)"
834 );
835 }
836 }
837
838 let kernel_ws = kernel_handle
840 .state
841 .workspace_path()
842 .to_string_lossy()
843 .to_string();
844 let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
845 if kernel_ws_pattern != ws_pattern
846 && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
847 {
848 perms.allow_path(&kernel_ws_pattern);
849 }
850
851 if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
853 perms.allow_path("/tmp/**");
854 }
855
856 let rbac_subject = Subject::Agent(agent_id);
858 am.rbac_manager_mut()
859 .assign_role(rbac_subject, Role::Superuser);
860 }
861
862 let _trace_guard = crate::observability::tracer().start(
864 format!("exec-{}", &exec_id.to_string()[..8]).as_str(),
865 oxi_sdk::SpanKind::Agent,
866 );
867
868 let registry = ToolRegistry::new();
870 let search_cache = Arc::new(SearchCache::new());
871
872 let agent_context = AgentContext {
874 agent_id,
875 agent_name: format!("agent-{agent_id}"),
876 cspace: Arc::new(cspace.clone()),
877 };
878
879 let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
882 let audit_path = kernel_handle
883 .state
884 .workspace_path()
885 .join("audit")
886 .join("access.jsonl");
887 Arc::new(TrailAuditSink::new(trail, audit_path))
888 } else {
889 Arc::new(TracingAuditSink)
890 };
891 let access_gate = Arc::new(AccessGate::new(
893 kernel_handle.exec.access_manager().clone(),
894 Arc::new(kernel_handle.exec.config_snapshot()),
895 audit_sink,
896 ));
897
898 let approval_config = kernel_handle.infra.approval_config_handle();
908 let exec_snapshot = kernel_handle.exec.config_snapshot();
909 let exec_resolver = crate::approval::ExecPolicyResolver {
910 allowed_commands: Arc::new(parking_lot::RwLock::new(
911 exec_snapshot.allowed_commands.clone(),
912 )),
913 };
914 let mut dynamic_resolvers = std::collections::HashMap::new();
915 dynamic_resolvers.insert(
916 "exec".to_string(),
917 Box::new(exec_resolver) as Box<dyn crate::approval::ToolPolicyResolver>,
918 );
919 let approval_gate = Arc::new(crate::approval::ApprovalGate::with_dynamic_resolvers(
920 crate::approval::default_tool_policy_map(),
921 approval_config,
922 vec![Box::new(crate::approval::SecurityBlacklist::new(
923 crate::approval::default_blacklist_rules(),
924 ))],
925 dynamic_resolvers,
926 ));
927 let approval_event_bus = kernel_handle.infra.event_bus_clone();
928 let approval_pending = kernel_handle.infra.pending_tool_approvals();
929 let path_access_pending = kernel_handle.infra.pending_path_access();
930
931 #[cfg(feature = "native-browser")]
936 if let Some(browser) = &kernel_handle.browser
937 && let Err(e) = browser.engine().await
938 {
939 tracing::warn!("browser engine unavailable, browse tools disabled: {e:#}");
940 }
941
942 register_tools_from_cspace_gated(
943 ®istry,
944 &kernel_handle,
945 &cspace,
946 search_cache,
947 agent_id,
948 access_gate,
949 agent_context,
950 Some(approval_gate),
951 Some(approval_event_bus),
952 Some(approval_pending),
953 Some(path_access_pending),
954 );
955
956 tracing::info!(
957 exec_id = %exec_id,
958 capabilities = cspace.len(),
959 "Tools registered from CSpace"
960 );
961
962 let agent_config = AgentConfig {
970 name: format!("agent-{agent_id}"),
971 description: None,
972 model_id: config.model_id.clone(),
973 system_prompt: Some(system_prompt.clone()),
974 timeout_seconds: 300,
975 temperature: config
976 .model_params
977 .as_ref()
978 .and_then(|p| p.temperature)
979 .or(Some(0.7)),
980 max_tokens: config
981 .model_params
982 .as_ref()
983 .and_then(|p| p.max_tokens)
984 .map(|v| v as usize)
985 .or(Some(8192)),
986 compaction_strategy: CompactionStrategy::Threshold(0.8),
987 compaction_instruction: None,
988 context_window: 128_000,
989 workspace_dir: Some(workspace.clone()),
990 output_mode: None,
991 provider_options: config.provider_options.clone(),
992 session_id: None,
993 max_tool_result_bytes: config.max_tool_result_bytes,
995 subagent_depth: 0,
1000 subagent_runner: Some(
1003 crate::subagent_runner::OxiosSubagentRunner::new(engine.oxi().clone())
1004 .into_trait_object(),
1005 ),
1006 ..Default::default()
1007 };
1008
1009 let agent = if config.provider_rpm > 0 {
1024 let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
1026 let provider_name = engine.resolve_model(&config.model_id)?.provider;
1027 let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
1028
1029 let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
1031 if config.rate_limit_per_minute > 0 {
1032 pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
1033 config.rate_limit_per_minute,
1034 ));
1035 }
1036 if config.token_budget > 0 {
1037 pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
1038 config.token_budget,
1039 ));
1040 }
1041 if config.audit_tool_calls {
1042 pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
1043 tracing::Level::INFO,
1044 ));
1045 }
1046
1047 let agent = Arc::new(Agent::new_with_resolver(
1049 provider,
1050 agent_config,
1051 Arc::new(registry),
1052 resolver,
1053 ));
1054
1055 if !pipeline.is_empty() {
1057 let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
1058 let agent_id_for_hooks = agent_id.to_string();
1059 let hooks = oxi_sdk::middleware::build_hooks(
1060 Arc::new(pipeline),
1061 agent_id_for_hooks,
1062 terminate_flag,
1063 );
1064 agent.set_hooks(hooks);
1065 }
1066
1067 agent
1068 } else {
1069 let mut builder = engine
1071 .oxi()
1072 .agent(agent_config)
1073 .workspace(&workspace)
1074 .system_prompt(system_prompt);
1075
1076 let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
1086 .names()
1087 .into_iter()
1088 .filter_map(|name| registry.get(&name))
1089 .collect();
1090
1091 if let Some(auth) = engine.authorizer() {
1093 builder = builder.authorizer(auth.clone());
1094 }
1095 if let Some(tracer) = engine.tracer() {
1096 builder = builder.tracer(tracer.clone());
1097 }
1098 if let Some(ct) = engine.cost_tracker() {
1099 builder = builder.cost_tracker(ct.clone());
1100 }
1101
1102 if config.rate_limit_per_minute > 0 {
1105 builder = builder.with_rate_limit(config.rate_limit_per_minute);
1106 }
1107 if config.token_budget > 0 {
1108 builder = builder.with_token_budget(config.token_budget);
1109 }
1110 if config.audit_tool_calls {
1111 builder = builder.with_logging();
1112 }
1113
1114 let built = builder.build()?;
1115 let agent = Arc::new(built);
1116
1117 let agent_tools = agent.tools();
1122 for tool in cspace_tool_arcs {
1123 agent_tools.register_arc(tool);
1124 }
1125
1126 agent
1127 };
1128
1129 if let Some(state) = restore_state {
1133 agent.import_state(state.clone()).unwrap_or_else(|e| {
1134 tracing::warn!(agent_id = %agent_id, error = %e, "Failed to restore agent state");
1135 });
1136 }
1137
1138 let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
1140 let exec_state_cb = Arc::clone(&exec_state);
1141 let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
1142 let session_id_for_callback = exec_id.to_string();
1143 let model_id_for_callback = config.model_id.clone();
1144 let agent_id_for_callback = agent_id.to_string();
1145 let routing_stats_for_cb = routing_stats.clone();
1146 let transparency_session: Option<String> = session_id.clone();
1149 let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1150 let streaming_sinks_for_cb: Arc<crate::streaming_sink::StreamingSinkRegistry> =
1154 Arc::clone(&kernel_handle.streaming_sinks);
1155 let mut sent_model_for_cb: bool = false;
1157 let result = agent
1158 .run_streaming(prompt, move |event| {
1159 if !sent_model_for_cb
1160 && let Some(ref sid) = transparency_session
1161 && !model_id_for_callback.is_empty()
1162 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1163 {
1164 let _ = tx.try_send(StreamDelta::Model(model_id_for_callback.clone()));
1165 sent_model_for_cb = true;
1166 }
1167 let mut s = exec_state_cb.lock();
1168 match event {
1169 AgentEvent::ToolExecutionStart {
1170 tool_name,
1171 tool_call_id,
1172 args,
1173 context,
1174 ..
1175 } => {
1176 let idx = s.trajectory_steps.len();
1178 s.pending_tools
1179 .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1180 s.tool_args_map.insert(
1181 tool_call_id.clone(),
1182 serde_json::to_string(&args).unwrap_or_default(),
1183 );
1184 s.tool_timestamps
1185 .insert(tool_call_id.clone(), chrono::Utc::now());
1186 s.tool_call_ids.push(tool_call_id.clone());
1187 s.trajectory_steps
1188 .push(oxios_memory::memory::sona::TrajectoryStep {
1189 input: tool_name.clone(),
1190 output: String::new(),
1191 duration_ms: 0,
1192 confidence: 0.0,
1193 });
1194 if let Some(ref sid) = transparency_session {
1196 let context_json = context
1197 .as_ref()
1198 .map(serde_json::to_value)
1199 .transpose()
1200 .unwrap_or(None);
1201 let _ =
1202 kernel_handle_for_cb
1203 .infra
1204 .publish(KernelEvent::ToolExecutionStarted {
1205 session_id: sid.clone(),
1206 tool_name: tool_name.clone(),
1207 tool_call_id: tool_call_id.clone(),
1208 tool_args: args.clone(),
1209 context: context_json,
1210 });
1211 }
1212 }
1213 AgentEvent::ToolExecutionUpdate {
1214 tool_call_id,
1215 tool_name,
1216 partial_result,
1217 tab_id,
1218 context,
1219 } => {
1220 if let Some(ref sid) = transparency_session {
1230 let context_json = context
1231 .as_ref()
1232 .map(serde_json::to_value)
1233 .transpose()
1234 .unwrap_or(None);
1235 let _ = kernel_handle_for_cb.infra.publish(
1236 KernelEvent::ToolExecutionProgress {
1237 session_id: sid.clone(),
1238 tool_call_id: tool_call_id.clone(),
1239 tool_name: tool_name.clone(),
1240 progress: partial_result,
1241 tab_id,
1242 context: context_json,
1243 },
1244 );
1245 }
1246 }
1247 AgentEvent::ToolExecutionEnd {
1248 tool_name,
1249 tool_call_id,
1250 is_error,
1251 result,
1252 ..
1253 } => {
1254 if !is_error {
1255 s.steps_completed += 1;
1256 }
1257 let mut duration_ms: u64 = 0;
1259 let mut summary = String::new();
1260 if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1261 duration_ms = start.elapsed().as_millis() as u64;
1262 if let Some(step) = s.trajectory_steps.get_mut(idx) {
1263 summary = summarize_tool_result(&result.content, 200);
1264 step.output = summary.clone();
1265 step.duration_ms = duration_ms;
1266 step.confidence = if is_error { 0.3 } else { 0.8 };
1267 }
1268 }
1269 s.tool_error_map.insert(tool_call_id.clone(), is_error);
1270 if let Some(ref sid) = transparency_session {
1272 let _ = kernel_handle_for_cb.infra.publish(
1273 KernelEvent::ToolExecutionFinished {
1274 session_id: sid.clone(),
1275 tool_call_id: tool_call_id.clone(),
1276 tool_name: tool_name.clone(),
1277 duration_ms,
1278 is_error,
1279 output_summary: summary,
1280 },
1281 );
1282 }
1283 }
1284 AgentEvent::AgentEnd {
1285 messages,
1286 stop_reason,
1287 ..
1288 } => {
1289 if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1290 s.final_content = a.text_content();
1291 }
1292 s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1298 }
1299 AgentEvent::Error { message, .. } => {
1300 s.final_content = message.clone();
1301 s.success = false;
1302 }
1303 AgentEvent::Usage {
1304 input_tokens,
1305 output_tokens,
1306 } => {
1307 s.total_input_tokens += input_tokens as u64;
1309 s.total_output_tokens += output_tokens as u64;
1310
1311 let agent_label = format!("agent-{agent_id_for_callback}");
1313 crate::observability::cost_tracker().record(
1314 &agent_label,
1315 &oxi_sdk::Model::new(
1316 &model_id_for_callback,
1317 &model_id_for_callback,
1318 oxi_sdk::Api::OpenAiCompletions,
1319 "unknown",
1320 "https://unknown.com",
1321 ),
1322 oxi_sdk::TokenUsage {
1323 input: input_tokens as u64,
1324 output: output_tokens as u64,
1325 cache_read: 0,
1326 cache_write: 0,
1327 },
1328 );
1329
1330 if let Some(stats) = &routing_stats_for_cb {
1332 let cost = crate::kernel_handle::engine_api::estimate_cost(
1333 &model_id_for_callback,
1334 input_tokens as u64,
1335 output_tokens as u64,
1336 );
1337 stats.record_model_usage(&model_id_for_callback, cost);
1338 }
1339 if let Some(ref sid) = transparency_session {
1341 let _ = kernel_handle_for_cb
1342 .infra
1343 .publish(KernelEvent::TokenUsageUpdate {
1344 session_id: sid.clone(),
1345 input_tokens: input_tokens as u64,
1346 output_tokens: output_tokens as u64,
1347 });
1348 }
1349 }
1350 AgentEvent::Compaction {
1351 event: CompactionEvent::Completed { result, .. },
1352 } => {
1353 handle_compaction(
1354 result.summary.clone(),
1355 session_id_for_callback.clone(),
1356 memory_for_callback.clone(),
1357 );
1358 if let Some(ref sid) = transparency_session {
1360 let _ =
1361 kernel_handle_for_cb
1362 .infra
1363 .publish(KernelEvent::ReasoningFragment {
1364 session_id: sid.clone(),
1365 content: result.summary.clone(),
1366 source: "compaction".to_string(),
1367 });
1368 }
1369 }
1370 AgentEvent::Compaction {
1371 event: CompactionEvent::Triggered { source, .. },
1372 } => {
1373 if let Some(ref sid) = transparency_session {
1379 let _ =
1380 kernel_handle_for_cb
1381 .infra
1382 .publish(KernelEvent::CompactionTriggered {
1383 session_id: Some(sid.clone()),
1384 source,
1385 });
1386 } else {
1387 let _ =
1388 kernel_handle_for_cb
1389 .infra
1390 .publish(KernelEvent::CompactionTriggered {
1391 session_id: None,
1392 source,
1393 });
1394 }
1395 }
1396 AgentEvent::TextChunk { text } => {
1397 if let Some(ref sid) = transparency_session
1411 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1412 {
1413 let _ = tx.try_send(StreamDelta::Text(text.clone()));
1414 }
1415 }
1416 AgentEvent::Thinking => {
1417 if let Some(ref sid) = transparency_session
1422 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1423 {
1424 let _ = tx.try_send(StreamDelta::Thinking);
1425 }
1426 }
1427 AgentEvent::ThinkingDelta { text } => {
1428 const REASONING_CAP: usize = 4096;
1442 if s.reasoning_text.len() < REASONING_CAP {
1443 s.reasoning_text.push_str(&text);
1444 if s.reasoning_text.len() > REASONING_CAP {
1445 s.reasoning_text.truncate(REASONING_CAP);
1446 }
1447 }
1448 if s.reasoning_bytes < REASONING_CAP {
1454 let budget = REASONING_CAP - s.reasoning_bytes;
1455 let mut chunk = String::new();
1456 for ch in text.chars() {
1457 if chunk.len() + ch.len_utf8() > budget {
1458 break;
1459 }
1460 chunk.push(ch);
1461 }
1462 if !chunk.is_empty() {
1463 s.reasoning_bytes += chunk.len();
1464 let pos = s.trajectory_steps.len();
1465 if s.reasoning_segments
1466 .last()
1467 .is_some_and(|l| l.before_step == pos)
1468 {
1469 s.reasoning_segments
1470 .last_mut()
1471 .unwrap()
1472 .text
1473 .push_str(&chunk);
1474 } else {
1475 s.reasoning_segments
1476 .push(oxios_ouroboros::ReasoningSegment {
1477 before_step: pos,
1478 text: chunk,
1479 });
1480 }
1481 }
1482 }
1483 if let Some(ref sid) = transparency_session
1484 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1485 {
1486 let _ = tx.try_send(StreamDelta::ThinkingDelta(text.clone()));
1487 }
1488 }
1489 AgentEvent::ThinkingEnd => {
1490 if let Some(ref sid) = transparency_session
1496 && let Some(tx) = streaming_sinks_for_cb.lookup(sid)
1497 {
1498 let _ = tx.try_send(StreamDelta::ThinkingEnd);
1499 }
1500 }
1501 AgentEvent::ToolCallDelta {
1502 tool_call_id,
1503 args_delta,
1504 } => {
1505 if let Some(ref sid) = transparency_session {
1510 let _ = kernel_handle_for_cb
1511 .infra
1512 .publish(KernelEvent::ToolArgsDelta {
1513 session_id: sid.clone(),
1514 tool_call_id: tool_call_id.clone(),
1515 args_delta: args_delta.clone(),
1516 });
1517 }
1518 }
1519 _ => {}
1520 }
1521 })
1522 .await;
1523
1524 let circuit = get_llm_circuit_breaker();
1526 if result.is_err() {
1527 circuit.record_failure();
1528 crate::metrics::get_metrics()
1529 .llm_circuit_breaker_state
1530 .set(1.0);
1531 } else {
1532 circuit.record_success();
1533 crate::metrics::get_metrics()
1534 .llm_circuit_breaker_state
1535 .set(0.0);
1536 }
1537
1538 if let Err(e) = result {
1539 tracing::error!(exec_id = %exec_id, error = %e, "Agent failed");
1540 let restore_state = agent.export_state().ok();
1545 return Err(crate::resilience::AgentRunError::wrap(e, restore_state).into());
1546 }
1547
1548 let s = exec_state.lock();
1549 tracing::info!(
1550 exec_id = %exec_id,
1551 steps = s.steps_completed,
1552 success = s.success,
1553 "Agent completed"
1554 );
1555
1556 if !s.trajectory_steps.is_empty()
1559 && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1560 {
1561 let steps = s.trajectory_steps.clone();
1562 let success = s.success;
1563 let sona = Arc::clone(sona);
1564 let domain = infer_domain(&goal);
1565 tokio::spawn(async move {
1566 let verdict = if success {
1567 oxios_memory::memory::sona::Verdict::Success
1568 } else {
1569 oxios_memory::memory::sona::Verdict::Failure
1570 };
1571 let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1572 if let Err(e) = sona.record(trajectory).await {
1573 tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1574 }
1575 });
1576 }
1577
1578 Ok((
1579 s.final_content.clone(),
1580 s.steps_completed,
1581 s.success,
1582 s.trajectory_steps.clone(),
1583 agent,
1584 s.tool_call_ids.clone(),
1585 s.tool_args_map.clone(),
1586 s.tool_error_map.clone(),
1587 s.tool_timestamps.clone(),
1588 s.total_input_tokens,
1589 s.total_output_tokens,
1590 s.reasoning_text.clone(),
1591 s.reasoning_segments.clone(),
1592 ))
1593}
1594
1595fn summarize_tool_result(result: &str, max_len: usize) -> String {
1600 let trimmed = result.trim();
1601 if trimmed.chars().count() <= max_len {
1602 return trimmed.to_string();
1603 }
1604 let first_line = trimmed.lines().next().unwrap_or("");
1606 if first_line.chars().count() <= max_len {
1607 first_line.to_string()
1608 } else {
1609 let take = max_len.saturating_sub(3);
1610 let truncated: String = if take == 0 {
1611 first_line.chars().take(max_len).collect()
1612 } else {
1613 first_line.chars().take(take).collect()
1614 };
1615 format!("{truncated}...")
1616 }
1617}
1618fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1619 if json_str.len() <= max_len {
1620 return json_str.to_string();
1621 }
1622 let take = max_len.saturating_sub(3);
1625 if take == 0 {
1626 return json_str.chars().take(max_len).collect();
1627 }
1628 let truncated: String = json_str.chars().take(take).collect();
1629 format!("{truncated}...")
1630}
1631
1632fn infer_domain(goal: &str) -> String {
1637 let lower = goal.to_lowercase();
1638 let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1639
1640 if keywords.iter().any(|k| {
1642 [
1643 "test",
1644 "tests",
1645 "spec",
1646 "testing",
1647 "assert",
1648 "unit test",
1649 "integration",
1650 ]
1651 .contains(k)
1652 }) {
1653 return "testing".to_string();
1654 }
1655 if keywords
1656 .iter()
1657 .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1658 {
1659 return "deployment".to_string();
1660 }
1661 if keywords
1662 .iter()
1663 .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1664 {
1665 return "bugfix".to_string();
1666 }
1667 if keywords
1668 .iter()
1669 .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1670 {
1671 return "refactoring".to_string();
1672 }
1673 if keywords
1674 .iter()
1675 .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1676 {
1677 return "documentation".to_string();
1678 }
1679 if keywords
1680 .iter()
1681 .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1682 {
1683 return "development".to_string();
1684 }
1685 if keywords
1686 .iter()
1687 .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1688 {
1689 return "analysis".to_string();
1690 }
1691 if keywords
1692 .iter()
1693 .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1694 {
1695 return "configuration".to_string();
1696 }
1697
1698 let meaningful: Vec<&str> = lower
1700 .split_whitespace()
1701 .filter(|w| w.len() > 2)
1702 .take(2)
1703 .collect();
1704 if meaningful.len() >= 2 {
1705 meaningful.join("_")
1706 } else {
1707 "general".to_string()
1708 }
1709}
1710
1711fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1717 let entry = MemoryEntry {
1718 id: uuid::Uuid::new_v4().to_string(),
1719 memory_type: MemoryType::Conversation,
1720 tier: crate::memory::MemoryTier::Warm,
1721 content: summary,
1722 content_hash: 0,
1723 source: "compaction".to_string(),
1724 session_id: Some(session_id),
1725 tags: vec![],
1726 importance: 0.5,
1727 pinned: false,
1728 protection: crate::memory::ProtectionLevel::None,
1729 auto_classified: false,
1730 session_appearances: 0,
1731 user_corrected: false,
1732 seen_in_sessions: vec![],
1733 created_at: chrono::Utc::now(),
1734 accessed_at: chrono::Utc::now(),
1735 modified_at: chrono::Utc::now(),
1736 access_count: 0,
1737 decay_score: 1.0,
1738 compaction_level: 0,
1739 compacted_from: vec![],
1740 related_ids: vec![],
1741 contradicts: None,
1742 };
1743 tokio::spawn(async move {
1744 if let Err(e) = memory_manager.remember(entry).await {
1745 tracing::warn!(error = %e, "Failed to save compaction summary");
1746 }
1747 });
1748}
1749
1750#[allow(dead_code)]
1755fn build_directive_system_prompt(
1756 directive: &Directive,
1757 env: &ExecEnv,
1758 persona_prompt: Option<&str>,
1759 capabilities_xml: Option<&str>,
1760 kernel_manifest: Option<&str>,
1761) -> String {
1762 build_system_prompt_inner(
1763 &directive.goal,
1764 &directive.original_request,
1765 &directive.constraints,
1766 &directive.acceptance_criteria,
1767 env.workspace_context.as_deref(),
1768 persona_prompt,
1769 capabilities_xml,
1770 kernel_manifest,
1771 )
1772}
1773
1774const ARTIFACT_PROTOCOL: &str = "\n\n\
1781 ## Artifacts\n\
1782 When you produce substantial, self-contained content the user will want to\n\
1783 view or interact with separately — a complete HTML page, an SVG graphic, a\n\
1784 Mermaid diagram, or an interactive React component — wrap it in an artifact\n\
1785 tag so the UI shows a live preview panel:\n\n\
1786 <lobeArtifact type=\"...\" title=\"...\" identifier=\"...\">\n\
1787 ...the full content...\n\
1788 </lobeArtifact>\n\n\
1789 Use exactly one of these `type` values (others are not recognised):\n\
1790 - `text/html` → an HTML document or fragment\n\
1791 - `image/svg+xml` → an SVG graphic\n\
1792 - `application/lobe.artifacts.mermaid` → a Mermaid diagram\n\
1793 - `application/lobe.artifacts.react` → a React component (JSX/TSX)\n\n\
1794 - `title` — a short human title for the panel.\n\
1795 - `identifier` — a unique kebab-case id, e.g. `sales-dashboard`.\n\
1796 Put the full, runnable content INSIDE the tag (not in a separate fence).\n\n\
1797 Do NOT wrap non-visual code. Shell commands, Python, Rust, JSON, config, or\n\
1798 short snippets that are part of an explanation belong in a normal fenced\n\
1799 code block. Use an artifact only for content the user would open to view,\n\
1800 not copy-and-paste. Limit one artifact per self-contained piece.\n";
1801#[allow(clippy::too_many_arguments)]
1807fn build_system_prompt_inner(
1808 goal: &str,
1809 original_request: &str,
1810 constraints: &[String],
1811 acceptance_criteria: &[String],
1812 workspace_context: Option<&str>,
1813 persona_prompt: Option<&str>,
1814 capabilities_xml: Option<&str>,
1815 kernel_manifest: Option<&str>,
1816) -> String {
1817 let mut prompt = String::from(
1818 "You are an autonomous agent in the Oxios operating system.\n\
1819 You execute Seeds — immutable specifications with goals, constraints, and\n\
1820 acceptance criteria.\n\n\
1821 ## Available Tools\n\
1822 You have the following tools:\n\
1823 - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1824 - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1825 - **Exec**: run shell commands\n\
1826 - **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.
1827 - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1828 - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1829 **Important**: When the task involves fetching information from the internet,\n\
1830 websites, or online services, use `web_search` first — do NOT search local files.\n\
1831 When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1832 from the web, use `web_search`.\n",
1833 );
1834 prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1835
1836 if !original_request.is_empty() && original_request != goal {
1839 prompt.push_str(&format!(
1840 "\n## User's Original Request\n{}\n",
1841 original_request
1842 ));
1843 }
1844
1845 if !constraints.is_empty() {
1846 prompt.push_str("\n## Constraints\n");
1847 for (i, c) in constraints.iter().enumerate() {
1848 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1849 }
1850 }
1851
1852 if !acceptance_criteria.is_empty() {
1853 prompt.push_str("\n## Acceptance Criteria\n");
1854 for (i, c) in acceptance_criteria.iter().enumerate() {
1855 prompt.push_str(&format!("{}. {}\n", i + 1, c));
1856 }
1857 }
1858
1859 if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1863 prompt.push_str("\n## Workspace Context\n");
1864 prompt.push_str(ctx);
1865 prompt.push('\n');
1866 }
1867
1868 if let Some(pp) = persona_prompt {
1870 prompt.push_str("\n## Persona\n");
1871 prompt.push_str(pp);
1872 prompt.push('\n');
1873 }
1874
1875 if let Some(xml) = capabilities_xml {
1877 prompt.push_str("\n## Available Capabilities\n");
1878 prompt.push_str("The following capabilities are relevant to your goal. ");
1879 prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1880 prompt.push_str(xml);
1881 prompt.push('\n');
1882 }
1883
1884 if let Some(manifest) = kernel_manifest {
1886 prompt.push('\n');
1887 prompt.push_str(manifest);
1888 prompt.push('\n');
1889 }
1890
1891 prompt.push_str(
1893 "\n## Execution Protocol\n\
1894 1. UNDERSTAND — Read the user's request carefully. If it is a simple\n\
1895 greeting, small talk, or a question you can answer from knowledge,\n\
1896 respond naturally and conversationally — no tools needed.\n\
1897 2. PLAN — For complex tasks, outline your approach before acting.\n\
1898 3. EXECUTE — Use tools only when the task actually requires them.\n\
1899 Prefer the simplest approach. Simple requests need no tools.\n\
1900 4. VERIFY — After each action, check the result: created a file? read it back.\n\
1901 5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1902 If the request is ambiguous, use the `ask_user` tool (free-text question)\n\
1903 or the `pi-questionnaire` tool (structured choices) to clarify before\n\
1904 executing — do not guess when a single question would resolve the intent.\n\n\
1905 ## Hard Boundaries\n\
1906 - NEVER modify files outside the workspace scope\n\
1907 - NEVER execute destructive commands without confirming scope\n\
1908 - NEVER claim completion without evidence — show the output, not your opinion\n\
1909 - NEVER add features or improvements beyond the goal's scope\n\
1910 - If you cannot complete the task, say so and explain WHY\n\n\
1911 ## Scope Guard\n\
1912 The goal defines your universe. Do not:\n\
1913 - Refactor code the goal didn't mention\n\
1914 - Add tests the goal didn't require\n\
1915 - Change configuration the goal didn't specify\n\
1916 - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1917 ## Error Handling\n\
1918 - If a tool fails, read the error message carefully before retrying\n\
1919 - If a command fails, do NOT immediately retry with --force or sudo\n\
1920 - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1921 ## Shape Matching\n\
1922 Match your output to the task: simple task → concise response.\n\
1923 Do not write 50 lines when 5 would do.\n\
1924 Use `exec` for all command execution (git, gh, osascript, etc.).",
1925 );
1926 prompt.push_str(ARTIFACT_PROTOCOL);
1927
1928 prompt
1929}
1930#[allow(dead_code)]
1931fn build_directive_user_prompt(directive: &Directive) -> String {
1932 build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1933}
1934
1935fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1937 format!(
1938 "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1939 goal,
1940 acceptance_criteria
1941 .iter()
1942 .enumerate()
1943 .map(|(i, c)| format!("{}. {}", i + 1, c))
1944 .collect::<Vec<_>>()
1945 .join("\n")
1946 )
1947}
1948
1949impl std::fmt::Debug for AgentRuntime {
1950 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1951 f.debug_struct("AgentRuntime")
1952 .field("model_id", &self.engine_handle.get().default_model_id())
1953 .finish()
1954 }
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959 use super::*;
1960 use async_trait::async_trait;
1961 use oxi_sdk::{AgentTool, ToolContext, ToolError};
1962 use serde_json::Value;
1963
1964 struct DummyTool {
1966 name: String,
1967 }
1968
1969 #[async_trait]
1970 impl AgentTool for DummyTool {
1971 fn name(&self) -> &str {
1972 &self.name
1973 }
1974 fn label(&self) -> &str {
1975 &self.name
1976 }
1977 fn description(&self) -> &str {
1978 "Test tool"
1979 }
1980 fn parameters_schema(&self) -> Value {
1981 serde_json::json!({"type": "object"})
1982 }
1983
1984 async fn execute(
1985 &self,
1986 _tool_call_id: &str,
1987 _params: Value,
1988 _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1989 _ctx: &ToolContext,
1990 ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1991 Ok(oxi_sdk::AgentToolResult::success("ok"))
1992 }
1993 }
1994
1995 #[test]
1997 fn test_requires_tools_validation_passes() {
1998 let registry = ToolRegistry::new();
1999
2000 registry.register(DummyTool {
2001 name: "read".into(),
2002 });
2003 registry.register(DummyTool {
2004 name: "exec".into(),
2005 });
2006
2007 let missing = registry.missing(&["read", "exec"]);
2008
2009 assert!(
2010 missing.is_empty(),
2011 "Expected no missing tools, got: {:?}",
2012 missing
2013 );
2014 }
2015
2016 #[test]
2018 fn test_requires_tools_validation_fails() {
2019 let registry = ToolRegistry::new();
2020
2021 registry.register(DummyTool {
2022 name: "read".into(),
2023 });
2024
2025 let missing = registry.missing(&["read", "exec", "nonexistent"]);
2026
2027 assert_eq!(missing, vec!["exec", "nonexistent"]);
2028 }
2029
2030 #[test]
2031 fn test_infer_domain_testing() {
2032 assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
2033 }
2034
2035 #[test]
2036 fn test_infer_domain_deployment() {
2037 assert_eq!(
2038 infer_domain("deploy the web service to production"),
2039 "deployment"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_infer_domain_bugfix() {
2045 assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
2046 }
2047
2048 #[test]
2049 fn test_infer_domain_development() {
2050 assert_eq!(
2051 infer_domain("create a new REST API endpoint"),
2052 "development"
2053 );
2054 }
2055
2056 #[test]
2057 fn test_infer_domain_analysis() {
2058 assert_eq!(
2059 infer_domain("review the code for security issues"),
2060 "analysis"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_infer_domain_fallback() {
2066 let domain = infer_domain("optimize performance metrics");
2067 assert!(!domain.is_empty());
2069 }
2070 #[test]
2071 fn test_system_prompt_includes_artifact_protocol() {
2072 let prompt = build_system_prompt_inner(
2073 "build a dashboard",
2074 "build a dashboard",
2075 &[],
2076 &[],
2077 None,
2078 None,
2079 None,
2080 None,
2081 );
2082 assert!(prompt.contains("<lobeArtifact"));
2085 assert!(prompt.contains("text/html"));
2086 assert!(prompt.contains("image/svg+xml"));
2087 assert!(prompt.contains("application/lobe.artifacts.mermaid"));
2088 assert!(prompt.contains("application/lobe.artifacts.react"));
2089 }
2090}