Skip to main content

oxios_kernel/
agent_runtime.rs

1//! Agent runtime: wraps oxi-sdk's Agent for directive execution.
2//!
3//! The AgentRuntime uses `OxiosEngine.oxi().agent()` (AgentBuilder pattern)
4//! to construct agents with full middleware, observability, and security
5//! integration from oxi-sdk 0.24.0.
6//!
7//! # Architecture
8//!
9//! All tool access goes through `KernelHandle` — the single syscall-table-like
10//! path for agent OS control. The runtime:
11//!
12//! 1. Resolves the agent's CSpace from persona/role/hint
13//! 2. Registers tools via `register_tools_from_cspace()`
14//! 3. Optionally queries `ToolRetriever` for semantic capability hints
15//! 4. Builds an `Agent` via `AgentBuilder` with middleware pipeline
16//! 5. Runs via `Agent::run_streaming()` for real-time event processing
17//!
18//! # oxi-sdk 0.23.0 Integration
19//!
20//! Uses `AgentBuilder` for agent construction with:
21//! - `.with_rate_limit()` — tool call rate limiting
22//! - `.with_token_budget()` — per-execution token caps
23//! - `.tracer()` / `.cost_tracker()` — observability hooks
24//! ## Routing integration (RFC-011)
25//!
26//! Model usage events (`AgentEvent::Usage`) are recorded to the shared
27//! `RoutingStats` so the Web dashboard can display per-model call counts
28//! and estimated costs.
29
30use 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;
39// RFC-014 Phase D: `ToolRegistry::register_arc` is used in the AgentBuilder
40// path to attach CSpace tools after `builder.build()` returns.
41
42use 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
55/// Global LLM circuit breaker instance — delegates to oxi-sdk's ProviderCircuitBreaker.
56static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<oxi_sdk::ProviderCircuitBreaker> =
57    std::sync::OnceLock::new();
58
59/// Get the global LLM circuit breaker.
60fn 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/// Configuration for creating AgentRuntime instances.
70#[derive(Debug, Clone)]
71pub struct AgentRuntimeConfig {
72    /// Model ID in `provider/model` format (e.g. `anthropic/claude-sonnet-4-20250514`).
73    pub model_id: String,
74    /// How to execute tool calls within a single turn.
75    pub tool_execution: ToolExecutionMode,
76    /// Whether auto-retry is enabled for retryable LLM errors.
77    pub auto_retry_enabled: bool,
78    /// Bound project paths. AgentRuntime sets CWD to paths[0].
79    pub project_paths: Vec<std::path::PathBuf>,
80    /// Scratch workspace directory for temp files.
81    pub workspace_dir: Option<std::path::PathBuf>,
82    /// API key resolved from CredentialStore at build time.
83    pub api_key: Option<String>,
84    /// Per-provider options for fine-grained control.
85    pub provider_options: Option<oxi_sdk::ProviderOptions>,
86    /// Rate limit for tool calls (requests per minute). 0 = unlimited.
87    pub rate_limit_per_minute: usize,
88    /// Token budget per agent execution. 0 = unlimited.
89    pub token_budget: usize,
90    /// Enable audit logging for all tool executions.
91    pub audit_tool_calls: bool,
92    /// Provider-level RPM for rate-limited provider pool. 0 = no pooling.
93    /// When set, uses `OxiosEngine::pooled_provider()` instead of `create_provider()`.
94    pub provider_rpm: u32,
95}
96
97impl Default for AgentRuntimeConfig {
98    fn default() -> Self {
99        Self {
100            model_id: String::new(),
101            tool_execution: ToolExecutionMode::Parallel,
102            auto_retry_enabled: true,
103            project_paths: Vec::new(),
104            workspace_dir: None,
105            api_key: None,
106            provider_options: None,
107            rate_limit_per_minute: 0,
108            token_budget: 0,
109            audit_tool_calls: false,
110            provider_rpm: 0,
111        }
112    }
113}
114
115/// Mutable state shared between the event callback and the main execute flow.
116#[derive(Default)]
117struct ExecuteState {
118    final_content: String,
119    steps_completed: usize,
120    success: bool,
121    /// Collected trajectory steps for SONA learning (RFC-020 Phase 2).
122    /// Ordered by insertion — parallel tools get their final position
123    /// resolved when they complete, preserving approximate execution order.
124    trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
125    /// Map of tool_call_id → (start instant, index into trajectory_steps).
126    /// Used to correlate ToolExecutionEnd with the correct step when
127    /// parallel tool calls complete out of order.
128    pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
129    /// Ordered tool_call_ids matching trajectory_steps indices.
130    /// Pushed in ToolExecutionStart, same order as trajectory_steps.
131    tool_call_ids: Vec<String>,
132    /// Per-step tool args (JSON string) captured from ToolExecutionStart.
133    tool_args_map: std::collections::HashMap<String, String>,
134    /// Per-step error flag from ToolExecutionEnd.
135    tool_error_map: std::collections::HashMap<String, bool>,
136    /// Per-step start timestamp (UTC) from ToolExecutionStart.
137    tool_timestamps: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
138    /// Cumulative input tokens from AgentEvent::Usage.
139    total_input_tokens: u64,
140    /// Cumulative output tokens from AgentEvent::Usage.
141    total_output_tokens: u64,
142}
143
144/// Runtime that wraps an oxi-sdk `Agent` for executing directives.
145///
146/// Each call to [`AgentRuntime::execute_directive`] creates a fresh `Agent`,
147/// builds a ToolRegistry based on the agent's CSpace, and runs it to completion.
148///
149/// All OS-level access goes through `KernelHandle` — the single syscall table
150/// for agent control. Provider/model resolution goes through `EngineHandle`,
151/// which returns the latest `OxiosEngine` (hot-swapped on config change).
152pub struct AgentRuntime {
153    engine_handle: Arc<crate::engine::EngineHandle>,
154    config: AgentRuntimeConfig,
155    /// Single path to all kernel services.
156    kernel_handle: Arc<KernelHandle>,
157    /// Persona manager for system prompt injection.
158    persona_manager: Option<Arc<PersonaManager>>,
159    /// Semantic tool retriever for capability discovery.
160    tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
161    /// Shared routing stats (shared with EngineApi).
162    routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
163    /// Autonomous persistence hook (RFC-016).
164    persistence_hook: Option<Arc<crate::persistence_hook::PersistenceHook>>,
165    /// Per-session assistant message index counter (RFC-016).
166    session_msg_counter: Arc<Mutex<HashMap<String, usize>>>,
167}
168
169impl AgentRuntime {
170    /// Creates a new agent runtime with engine handle and kernel access.
171    ///
172    /// The active model is resolved live from `engine_handle` on each
173    /// `execute()` (reads the post-hot-swap default) — there is no frozen
174    /// model id at construction. Tool access goes through `kernel_handle`.
175    pub fn new(
176        engine_handle: Arc<crate::engine::EngineHandle>,
177        kernel_handle: Arc<KernelHandle>,
178        routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
179    ) -> Self {
180        Self {
181            engine_handle,
182            config: AgentRuntimeConfig::default(),
183            kernel_handle,
184            persona_manager: None,
185            tool_retriever: None,
186            routing_stats,
187            persistence_hook: None,
188            session_msg_counter: Arc::new(Mutex::new(HashMap::new())),
189        }
190    }
191
192    /// Attach a PersonaManager for persona system prompt injection.
193    pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
194        self.persona_manager = Some(pm);
195        self
196    }
197
198    /// Set the runtime config (overrides defaults).
199    pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
200        self.config = config;
201        self
202    }
203
204    /// Attach a ToolRetriever for semantic capability discovery.
205    pub fn with_tool_retriever(
206        mut self,
207        retriever: Arc<crate::tools::retrieval::ToolRetriever>,
208    ) -> Self {
209        self.tool_retriever = Some(retriever);
210        self
211    }
212
213    /// Attach a PersistenceHook for autonomous persistence (RFC-016).
214    pub fn with_persistence_hook(
215        mut self,
216        hook: Arc<crate::persistence_hook::PersistenceHook>,
217    ) -> Self {
218        self.persistence_hook = Some(hook);
219        self
220    }
221
222    /// Execute a Directive with its ExecEnv (RFC-027 unified intent handling).
223    ///
224    /// Maps Directive/ExecEnv fields to the agent's runtime inputs and runs
225    /// the tool-calling loop to completion. The persistence hook (RFC-016)
226    /// runs on this path.
227    pub async fn execute_directive(
228        &self,
229        agent_id: AgentId,
230        directive: &Directive,
231        env: &ExecEnv,
232        session_ctx: &mut SessionContext,
233    ) -> Result<ExecutionResult> {
234        // Directive has no stable per-execution ID yet (Phase 6). Derive a
235        // session_id from the agent_id so chat transparency events still
236        // correlate.
237        let session_id: Option<String> = Some(agent_id.to_string());
238        self.execute_directive_with_session(agent_id, directive, env, session_ctx, session_id)
239            .await
240    }
241
242    /// Like [`execute_directive`](Self::execute_directive) but with an
243    /// explicit session_id for RFC-015 chat transparency event publishing.
244    pub async fn execute_directive_with_session(
245        &self,
246        agent_id: AgentId,
247        directive: &Directive,
248        env: &ExecEnv,
249        session_ctx: &mut SessionContext,
250        session_id: Option<String>,
251    ) -> Result<ExecutionResult> {
252        self.execute_inner(
253            agent_id,
254            &directive.goal,
255            &directive.original_request,
256            &directive.constraints,
257            &directive.acceptance_criteria,
258            env.cspace_hint.as_deref(),
259            &env.mount_paths,
260            env.workspace_context.as_deref(),
261            session_ctx,
262            session_id,
263            Some(directive),
264            env.model_override.as_deref(),
265            env.restore_state.as_ref(),
266        )
267        .await
268    }
269
270    /// Shared execution body for the directive path.
271    ///
272    /// Performs the full agent-runtime pipeline: prompt assembly, capability
273    /// retrieval, memory + knowledge recall, CSpace tool registration,
274    /// model resolution, agent run, post-execution summary, and the
275    /// autonomous persistence hook (RFC-016).
276    #[allow(clippy::too_many_arguments)]
277    async fn execute_inner(
278        &self,
279        agent_id: AgentId,
280        goal: &str,
281        original_request: &str,
282        constraints: &[String],
283        acceptance_criteria: &[String],
284        cspace_hint: Option<&str>,
285        mount_paths: &[std::path::PathBuf],
286        workspace_context: Option<&str>,
287        session_ctx: &mut SessionContext,
288        session_id: Option<String>,
289        persistence_directive: Option<&Directive>,
290        model_override: Option<&str>,
291        restore_state: Option<&serde_json::Value>,
292    ) -> Result<ExecutionResult> {
293        let prompt = build_user_prompt_inner(goal, acceptance_criteria);
294
295        // Get active persona system prompt.
296        let persona_prompt = self
297            .persona_manager
298            .as_ref()
299            .map(|pm| pm.active_system_prompt())
300            .filter(|s| !s.trim().is_empty());
301
302        // Determine persona role for CSpace resolution.
303        let persona_role = self
304            .persona_manager
305            .as_ref()
306            .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
307
308        // Resolve CSpace from persona role, hint, or default.
309        let cspace = resolve_cspace(
310            cspace_hint,
311            persona_role.as_deref(),
312            Some("worker"),
313            agent_id,
314        );
315
316        // Build system prompt (without SKILL.md injection — capabilities are
317        // surfaced through the CSpace tool set + semantic retrieval instead).
318        let mut system_prompt = build_system_prompt_inner(
319            goal,
320            original_request,
321            constraints,
322            acceptance_criteria,
323            workspace_context,
324            persona_prompt.as_deref(),
325            None,
326            None,
327        );
328
329        // Semantic capability retrieval: find tools relevant to this task's goal.
330        let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
331            match retriever.embedder().embed(goal).await {
332                Ok(query_vec) => {
333                    let results = retriever.retrieve(&query_vec, 8);
334                    if results.is_empty() {
335                        None
336                    } else {
337                        let xml = crate::tools::retrieval::format_capability_index(&results);
338                        tracing::info!(count = results.len(), "Retrieved relevant capabilities");
339                        Some(xml)
340                    }
341                }
342                Err(e) => {
343                    tracing::warn!(error = %e, "Failed to embed goal for retrieval");
344                    None
345                }
346            }
347        } else {
348            None
349        };
350
351        // Build kernel manifest from CSpace active domains.
352        let kernel_manifest = {
353            let domains = cspace.active_domains();
354            if domains.is_empty() {
355                None
356            } else {
357                Some(crate::tools::retrieval::build_kernel_manifest(&domains))
358            }
359        };
360
361        // Rebuild system prompt with capabilities and manifest if available.
362        if capabilities_xml.is_some() || kernel_manifest.is_some() {
363            system_prompt = build_system_prompt_inner(
364                goal,
365                original_request,
366                constraints,
367                acceptance_criteria,
368                workspace_context,
369                persona_prompt.as_deref(),
370                capabilities_xml.as_deref(),
371                kernel_manifest.as_deref(),
372            );
373        }
374
375        // Blend relevant memories into system prompt.
376        let memory_manager = self.kernel_handle.agents.memory_manager();
377        match memory_manager
378            .recall_with_proactive(goal, &mut session_ctx.recall_timing)
379            .await
380        {
381            Ok(memories) if !memories.is_empty() => {
382                tracing::info!(count = memories.len(), "Recalled memories for task");
383                system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
384            }
385            Ok(_) => tracing::debug!("No memories recalled"),
386            Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
387        }
388
389        // Inject learned strategy from SONA (RFC-020 Phase 2).
390        if let Some(sona) = memory_manager.sona_engine() {
391            match sona.adapt(goal).await {
392                Ok(Some(pattern)) if pattern.confidence > 0.5 => {
393                    tracing::info!(
394                        domain = %pattern.domain,
395                        confidence = pattern.confidence,
396                        "SONA learned pattern injected"
397                    );
398                    system_prompt.push_str(&format!(
399                        "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
400                        pattern.confidence * 100.0,
401                        pattern.strategy,
402                    ));
403                }
404                Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
405                Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
406            }
407        }
408
409        // Blend relevant knowledge notes into system prompt (KnowledgeLens, RFC-003 Phase 3).
410        match self
411            .kernel_handle
412            .knowledge_lens
413            .recall_for_context(goal, 5)
414            .await
415        {
416            Ok(ctx) if !ctx.notes.is_empty() => {
417                tracing::info!(
418                    notes = ctx.notes.len(),
419                    memories = ctx.memories.len(),
420                    "Recalled knowledge context for task"
421                );
422                let knowledge_blend = ctx
423                    .notes
424                    .iter()
425                    .take(3)
426                    .map(|n| format!("## {}\n\n{}", n.name, n.content))
427                    .collect::<Vec<_>>()
428                    .join("\n\n");
429                system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
430                system_prompt.push_str(&knowledge_blend);
431            }
432            Ok(_) => tracing::debug!("No knowledge recalled"),
433            Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
434        }
435
436        // Resolve the model. RFC-029 P2: honor a model_override from the
437        // ExecEnv (set by RecoveryCoordinator for fallback retries) before
438        // falling back to the LIVE default model (post-hot-swap). This is
439        // the single source of truth for model resolution across all phases.
440        let engine = self.engine_handle.get();
441        let model_id = model_override
442            .map(|s| s.to_string())
443            .unwrap_or_else(|| engine.default_model_id().to_string());
444        // Validates fail-fast: a bad model ID is rejected here at execute entry.
445        engine.resolve_model(&model_id)?;
446        // Synthetic per-execution ID for tracing.
447        let exec_id = uuid::Uuid::new_v4();
448
449        // Build the agent. Refresh config.model_id to the live value so every
450        // downstream consumer (AgentConfig, legacy provider path, usage callback)
451        // uses the same model as the interview/seed phases — no frozen boot
452        // string that silently diverges from what interview used.
453        let mut config = self.config.clone();
454        config.model_id = model_id;
455        let kernel_handle = Arc::clone(&self.kernel_handle);
456
457        // Extract audit trail from kernel for TrailAuditSink wiring.
458        let audit_trail: Option<Arc<AuditTrail>> =
459            Some(Arc::clone(&self.kernel_handle.security.audit_trail));
460
461        let (
462            mut final_content,
463            steps_completed,
464            success,
465            trajectory_steps,
466            agent,
467            tool_call_ids,
468            tool_args_map,
469            tool_error_map,
470            tool_timestamps,
471            total_input_tokens,
472            total_output_tokens,
473        ) = {
474            run_agent(
475                &config,
476                &engine,
477                kernel_handle,
478                system_prompt,
479                prompt,
480                exec_id,
481                goal.to_string(),
482                agent_id,
483                cspace,
484                audit_trail,
485                self.routing_stats.clone(),
486                session_id.clone(),
487                mount_paths,
488                restore_state,
489            )
490            .await?
491        };
492
493        // ── Post-execution: safety net for empty final content ──
494        //
495        // oxi 0.32.0 removed max_iterations — the loop now exits naturally
496        // when the LLM produces a text-only response (pi-agent behavior).
497        // This block is kept as a safety net in case the LLM returns empty
498        // text despite a natural exit (rare, but possible).
499        if final_content.is_empty() && !trajectory_steps.is_empty() {
500            let tool_summary: Vec<String> = trajectory_steps
501                .iter()
502                .enumerate()
503                .map(|(i, step)| {
504                    let truncated = if step.output.len() > 800 {
505                        // Char-boundary safe truncation: roll back to the
506                        // nearest UTF-8 boundary so multibyte sequences
507                        // (Korean, CJK, emoji) don't panic on byte slicing.
508                        let mut end = 800;
509                        while end > 0 && !step.output.is_char_boundary(end) {
510                            end -= 1;
511                        }
512                        format!("{}...", &step.output[..end])
513                    } else {
514                        step.output.clone()
515                    };
516                    format!("{}. [{}] {}", i + 1, step.input, truncated)
517                })
518                .collect();
519            let summary_prompt = format!(
520                "도구 실행 결과:\n\n{}\n\n\
521                 위 결과를 바탕으로 사용자의 요청에 대해 자연스럽게 한국어로 답변해주세요. \
522                 도구의 원시 출력을 그대로 복사하지 말고, 의미 있는 내용만 정리해서 전달하세요.",
523                tool_summary.join("\n")
524            );
525            match agent.run(summary_prompt).await {
526                Ok((response, _events)) => {
527                    if !response.content.is_empty() {
528                        tracing::info!(exec_id = %exec_id, "Post-execution summary generated");
529                        final_content = response.content;
530                    }
531                }
532                Err(e) => {
533                    tracing::warn!(error = %e, "Post-execution summary failed");
534                }
535            }
536        }
537
538        // Map trajectory steps to tool call records for the execution result.
539        // tool_call_ids[i] corresponds to trajectory_steps[i].
540        let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
541            .iter()
542            .enumerate()
543            .map(|(i, step)| {
544                let tc_id = tool_call_ids.get(i).cloned().unwrap_or_default();
545                let args_str = tool_call_ids
546                    .get(i)
547                    .and_then(|id| tool_args_map.get(id))
548                    .cloned()
549                    .unwrap_or_default();
550                let is_error = tool_call_ids
551                    .get(i)
552                    .and_then(|id| tool_error_map.get(id))
553                    .copied()
554                    .unwrap_or(false);
555                let timestamp = tool_call_ids
556                    .get(i)
557                    .and_then(|id| tool_timestamps.get(id))
558                    .copied();
559                let input_str = truncate_json_str(&args_str, 500);
560                oxios_ouroboros::ToolCallRecord {
561                    tool: step.input.clone(),
562                    input: input_str,
563                    output: step.output.clone(),
564                    duration_ms: step.duration_ms,
565                    is_error,
566                    tool_call_id: tc_id,
567                    timestamp,
568                }
569            })
570            .collect();
571
572        tracing::info!(
573            exec_id = %exec_id,
574            steps = steps_completed,
575            success,
576            tool_calls = tool_calls.len(),
577            "AgentRuntime finished"
578        );
579
580        let result = ExecutionResult {
581            output: final_content.clone(),
582            steps_completed,
583            success,
584            tool_calls,
585            failure_class: None,
586            restore_state: None,
587            tokens_input: total_input_tokens,
588            tokens_output: total_output_tokens,
589            model_id: self.engine_handle.get().default_model_id().to_string(),
590        };
591
592        // RFC-016: Autonomous persistence hook.
593        // Runs after successful execution, fire-and-forget.
594        if let Some(directive) = persistence_directive
595            && success
596            && let Some(hook) = &self.persistence_hook
597        {
598            let already_saved_knowledge = trajectory_steps
599                .iter()
600                .any(|s| s.input == "knowledge" && s.output.contains("written successfully"));
601            let hook = hook.clone();
602            let directive_clone = directive.clone();
603            let traj_clone = trajectory_steps.clone();
604            let output_clone = final_content.clone();
605            let sid = session_id.clone();
606            // Compute the assistant message index for this execution.
607            // Increment per-session counter, then use the pre-increment value.
608            let msg_index = {
609                let mut counter = self.session_msg_counter.lock();
610                let idx = counter.entry(sid.clone().unwrap_or_default()).or_insert(0);
611                let current = *idx;
612                *idx += 1;
613                current
614            };
615            tokio::spawn(async move {
616                match hook
617                    .evaluate(
618                        &directive_clone,
619                        &traj_clone,
620                        &output_clone,
621                        already_saved_knowledge,
622                    )
623                    .await
624                {
625                    Ok(plan) => {
626                        if !plan.memory.is_empty() || !plan.knowledge.is_empty() {
627                            tracing::info!(
628                                memory = plan.memory.len(),
629                                knowledge = plan.knowledge.len(),
630                                message_index = msg_index,
631                                "PersistenceHook executing plan"
632                            );
633                            let session_id = sid.unwrap_or_default();
634                            hook.execute_plan(plan, &session_id, msg_index).await;
635                        }
636                    }
637                    Err(e) => tracing::warn!(error = %e, "PersistenceHook evaluate failed"),
638                }
639            });
640        }
641
642        Ok(result)
643    }
644}
645
646/// Create and run an oxi-sdk `Agent` with CSpace-based tool registration.
647///
648/// Uses `engine.oxi().agent()` (AgentBuilder) for full middleware,
649/// observability, and security integration from oxi-sdk 0.23.0.
650#[allow(clippy::too_many_arguments)]
651async fn run_agent(
652    config: &AgentRuntimeConfig,
653    engine: &OxiosEngine,
654    kernel_handle: Arc<KernelHandle>,
655    system_prompt: String,
656    prompt: String,
657    seed_id: uuid::Uuid,
658    seed_goal: String,
659    agent_id: AgentId,
660    cspace: crate::capability::CSpace,
661    audit_trail: Option<Arc<AuditTrail>>,
662    routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
663    session_id: Option<String>,
664    mount_paths: &[std::path::PathBuf],
665    restore_state: Option<&serde_json::Value>,
666) -> Result<(
667    String,
668    usize,
669    bool,
670    Vec<oxios_memory::memory::sona::TrajectoryStep>,
671    Arc<Agent>,
672    Vec<String>,
673    std::collections::HashMap<String, String>,
674    std::collections::HashMap<String, bool>,
675    std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
676    u64,
677    u64,
678)> {
679    // Extract workspace.
680    // RFC-025: prefer the primary Mount's first path, then fall back to the
681    // legacy config.project_paths, then workspace_dir, then temp.
682    let workspace = if !mount_paths.is_empty() {
683        mount_paths[0].clone()
684    } else if !config.project_paths.is_empty() {
685        config.project_paths[0].clone()
686    } else if let Some(ref ws) = config.workspace_dir {
687        ws.clone()
688    } else {
689        std::env::temp_dir()
690            .join("oxios-agent-workspace")
691            .join(agent_id.to_string())
692    };
693
694    // Ensure workspace exists.
695    let _ = std::fs::create_dir_all(&workspace);
696
697    tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
698
699    // Ensure all paths the agent might access are in allowed_paths.
700    //
701    // AgentLifecycleManager::ensure_permissions() adds kernel.workspace (~/.oxios/workspace),
702    // but the agent operates in different directories depending on context:
703    //
704    //   1. Process CWD — oxi-sdk 0.35+ bakes `workspace_dir` into file tools
705    //      via `with_cwd`, so ReadTool/LsTool resolve relatives against the
706    //      workspace, NOT the process CWD. However, oxios's own CSpace tools
707    //      (kernel-bridge tools wrapped in GatedTool) and bash/exec
708    //      subprocesses may still resolve against the process CWD. We grant
709    //      it as a safety net so those tools aren't denied by GatedTool.
710    //   2. The designated workspace — computed from mount_paths / workspace_dir / temp.
711    //   3. Kernel workspace — state store path for seeds, sessions, etc.
712    //   4. /tmp -- general temp file access.
713    //
714    // All four must be in allowed_paths before GatedTool wraps any tool.
715    {
716        use crate::access_manager::{Role, Subject};
717        let agent_name = format!("agent-{agent_id}");
718        let mut am = kernel_handle.exec.access_manager().lock();
719        let perms = am.get_or_create_permissions(&agent_name);
720
721        // 1. CWD -- critical: oxi-sdk resolves relative paths here
722        if let Ok(cwd) = std::env::current_dir() {
723            let cwd_pattern = format!("{}/**", cwd.to_string_lossy().trim_end_matches('/'));
724            if !perms.allowed_paths.iter().any(|p| p == &cwd_pattern) {
725                perms.allow_path(&cwd_pattern);
726                tracing::debug!(
727                    agent = %agent_name,
728                    path = %cwd_pattern,
729                    "Added CWD to agent allowed paths"
730                );
731            }
732        }
733
734        // 2. Designated workspace
735        let ws_pattern = format!("{}/**", workspace.to_string_lossy().trim_end_matches('/'));
736        if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
737            perms.allow_path(&ws_pattern);
738        }
739
740        // 2b. RFC-025: every bound Mount grants path access.
741        //     This fixes the latent gap where only project_paths[0] was
742        //     accessible — now all Mount paths (multi-path work) are allowed.
743        //     Parent patterns already covering a path are skipped.
744        for mount_path in mount_paths {
745            let pattern = format!("{}/**", mount_path.to_string_lossy().trim_end_matches('/'));
746            if !perms.allowed_paths.iter().any(|p| p == &pattern) {
747                perms.allow_path(&pattern);
748                tracing::debug!(
749                    agent = %agent_name,
750                    path = %pattern,
751                    "Added Mount path to agent allowed paths (RFC-025)"
752                );
753            }
754        }
755
756        // 3. Kernel workspace (state store path)
757        let kernel_ws = kernel_handle
758            .state
759            .workspace_path()
760            .to_string_lossy()
761            .to_string();
762        let kernel_ws_pattern = format!("{}/**", kernel_ws.trim_end_matches('/'));
763        if kernel_ws_pattern != ws_pattern
764            && !perms.allowed_paths.iter().any(|p| p == &kernel_ws_pattern)
765        {
766            perms.allow_path(&kernel_ws_pattern);
767        }
768
769        // 4. /tmp -- for general temp file access
770        if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
771            perms.allow_path("/tmp/**");
772        }
773
774        // Ensure RBAC Superuser role so AccessGate Layer 1 passes.
775        let rbac_subject = Subject::Agent(agent_id);
776        am.rbac_manager_mut()
777            .assign_role(rbac_subject, Role::Superuser);
778    }
779
780    // Start distributed trace span for this agent execution.
781    let _trace_guard = crate::observability::tracer().start(
782        format!("seed-{}", &seed_id.to_string()[..8]).as_str(),
783        oxi_sdk::SpanKind::Agent,
784    );
785
786    // ── Register tools based on CSpace (with access gate) ──
787    let registry = ToolRegistry::new();
788    let search_cache = Arc::new(SearchCache::new());
789
790    // Build agent context for security
791    let agent_context = AgentContext {
792        agent_id,
793        agent_name: format!("agent-{agent_id}"),
794        cspace: Arc::new(cspace.clone()),
795    };
796
797    // Build audit sink: TrailAuditSink (Merkle chain + JSONL) when audit_trail
798    // is available, otherwise fall back to TracingAuditSink.
799    let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
800        let audit_path = kernel_handle
801            .state
802            .workspace_path()
803            .join("audit")
804            .join("access.jsonl");
805        Arc::new(TrailAuditSink::new(trail, audit_path))
806    } else {
807        Arc::new(TracingAuditSink)
808    };
809
810    // Build access gate from kernel's security infrastructure
811    let access_gate = Arc::new(AccessGate::new(
812        kernel_handle.exec.access_manager().clone(),
813        Arc::new(kernel_handle.exec.config_snapshot()),
814        audit_sink,
815    ));
816
817    register_tools_from_cspace_gated(
818        &registry,
819        &kernel_handle,
820        &cspace,
821        search_cache,
822        agent_id,
823        access_gate,
824        agent_context,
825    );
826
827    tracing::info!(
828        seed_id = %seed_id,
829        capabilities = cspace.len(),
830        "Tools registered from CSpace"
831    );
832
833    // ── Build AgentConfig ──
834    //
835    // RFC-014 Phase D: `system_prompt` is also passed to the new
836    // `AgentBuilder::system_prompt()` (which overrides the value embedded
837    // in `AgentConfig` at build time). We clone here so the builder path
838    // can consume the value while the legacy `Agent::new_with_resolver`
839    // path still sees it in the config.
840    let agent_config = AgentConfig {
841        name: format!("agent-{agent_id}"),
842        description: None,
843        model_id: config.model_id.clone(),
844        system_prompt: Some(system_prompt.clone()),
845        timeout_seconds: 300,
846        temperature: Some(0.7),
847        max_tokens: Some(8192),
848        compaction_strategy: CompactionStrategy::Threshold(0.8),
849        compaction_instruction: None,
850        context_window: 128_000,
851        api_key: config.api_key.clone(),
852        workspace_dir: Some(workspace.clone()),
853        output_mode: None,
854        provider_options: config.provider_options.clone(),
855        // oxi-sdk 0.37.0+: ownership identity for oxi's built-in ownership-gated
856        // tools (e.g. the `issue` tool's flock). `None` preserves the pre-0.37.1
857        // behavior (ToolContext.session_id == None). Oxios runs its own tool
858        // set, so no ownership identity is needed here; set `Some(...)` only if
859        // oxios agents start using oxi ownership-gated tools.
860        session_id: None,
861        ..Default::default()
862    };
863
864    // ── Build Agent (RFC-014 Phase D) ──
865    //
866    // Two paths:
867    //   1. `provider_rpm == 0` (common): use oxi-sdk 0.26.2's new
868    //      `AgentBuilder` API. The builder unifies model resolution, provider
869    //      creation, and (optionally) middleware wiring. Engine-level
870    //      `authorizer` / `tracer` / `cost_tracker` are propagated through
871    //      the new builder methods.
872    //   2. `provider_rpm > 0` (rare): keep the legacy
873    //      `Agent::new_with_resolver` + `set_hooks` path because the
874    //      AgentBuilder does not expose a way to inject a pre-built
875    //      `ProviderPool` for rate-limited access. This is a deliberate
876    //      scope-limit per RFC-014/phase-d-agentbuilder.md §2 "Provider
877    //      선택 로직은 보존".
878    let agent = if config.provider_rpm > 0 {
879        // ── Legacy path: rate-limited provider pool ──
880        let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
881        let provider_name = engine.resolve_model(&config.model_id)?.provider;
882        let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
883
884        // Build middleware pipeline.
885        let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
886        if config.rate_limit_per_minute > 0 {
887            pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
888                config.rate_limit_per_minute,
889            ));
890        }
891        if config.token_budget > 0 {
892            pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
893                config.token_budget,
894            ));
895        }
896        if config.audit_tool_calls {
897            pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
898                tracing::Level::INFO,
899            ));
900        }
901
902        // Create Agent with CSpace tool registry and provider resolver.
903        let agent = Arc::new(Agent::new_with_resolver(
904            provider,
905            agent_config,
906            Arc::new(registry),
907            resolver,
908        ));
909
910        // Wire middleware pipeline → AgentHooks.
911        if !pipeline.is_empty() {
912            let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
913            let agent_id_for_hooks = agent_id.to_string();
914            let hooks = oxi_sdk::middleware::build_hooks(
915                Arc::new(pipeline),
916                agent_id_for_hooks,
917                terminate_flag,
918            );
919            agent.set_hooks(hooks);
920        }
921
922        agent
923    } else {
924        // ── New path: AgentBuilder (RFC-014 Phase D) ──
925        let mut builder = engine
926            .oxi()
927            .agent(agent_config)
928            .workspace(&workspace)
929            .system_prompt(system_prompt);
930
931        // CSpace-based tool registration is oxios-specific and is preserved.
932        //
933        // The builder's `.tool()` method takes `impl AgentTool + 'static`
934        // (a concrete value), but oxios' CSpace tools are `Arc<dyn AgentTool>`.
935        // The SDK does not expose a way to inject a pre-built `ToolRegistry`
936        // into the builder, so we register them on the agent's tool registry
937        // after `build()` returns. This keeps CSpace semantics intact.
938        //
939        // We capture the tool names now and apply them once the agent exists.
940        let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
941            .names()
942            .into_iter()
943            .filter_map(|name| registry.get(&name))
944            .collect();
945
946        // Engine-level observability/security → AgentBuilder (new API).
947        if let Some(auth) = engine.authorizer() {
948            builder = builder.authorizer(auth.clone());
949        }
950        if let Some(tracer) = engine.tracer() {
951            builder = builder.tracer(tracer.clone());
952        }
953        if let Some(ct) = engine.cost_tracker() {
954            builder = builder.cost_tracker(ct.clone());
955        }
956
957        // Middleware: AgentBuilder convenience helpers replace the manual
958        // `MiddlewarePipeline` + `build_hooks()` + `set_hooks()` triple.
959        if config.rate_limit_per_minute > 0 {
960            builder = builder.with_rate_limit(config.rate_limit_per_minute);
961        }
962        if config.token_budget > 0 {
963            builder = builder.with_token_budget(config.token_budget);
964        }
965        if config.audit_tool_calls {
966            builder = builder.with_logging();
967        }
968
969        let built = builder.build()?;
970        let agent = Arc::new(built);
971
972        // Attach CSpace tools to the agent's tool registry.
973        // `Agent::tools()` returns the same `Arc<ToolRegistry>` that
974        // `AgentBuilder` populated, so `register_arc` is the canonical
975        // extension point for `Arc<dyn AgentTool>` values.
976        let agent_tools = agent.tools();
977        for tool in cspace_tool_arcs {
978            agent_tools.register_arc(tool);
979        }
980
981        agent
982    };
983
984    // RFC-029 P2b: restore conversation state from a prior failed run
985    // so the new agent (with a fallback model) continues from the
986    // checkpoint rather than restarting from scratch.
987    if let Some(state) = restore_state {
988        agent.import_state(state.clone()).unwrap_or_else(|e| {
989            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to restore agent state");
990        });
991    }
992
993    // Shared mutable state for the event callback.
994    let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
995    let exec_state_cb = Arc::clone(&exec_state);
996    let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
997    let session_id_for_callback = seed_id.to_string();
998    let model_id_for_callback = config.model_id.clone();
999    let agent_id_for_callback = agent_id.to_string();
1000    let routing_stats_for_cb = routing_stats.clone();
1001    // RFC-015: real-time event publishing for chat transparency.
1002    // Falls back to None when the caller did not opt in.
1003    let transparency_session: Option<String> = session_id.clone();
1004    let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
1005
1006    // Run the agent with streaming events.
1007    let result = agent
1008        .run_streaming(prompt, move |event| {
1009            let mut s = exec_state_cb.lock();
1010            match event {
1011                AgentEvent::ToolExecutionStart {
1012                    tool_name,
1013                    tool_call_id,
1014                    args,
1015                    context,
1016                    ..
1017                } => {
1018                    // Record start time and push a placeholder step.
1019                    let idx = s.trajectory_steps.len();
1020                    s.pending_tools
1021                        .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
1022                    s.tool_args_map.insert(
1023                        tool_call_id.clone(),
1024                        serde_json::to_string(&args).unwrap_or_default(),
1025                    );
1026                    s.tool_timestamps
1027                        .insert(tool_call_id.clone(), chrono::Utc::now());
1028                    s.tool_call_ids.push(tool_call_id.clone());
1029                    s.trajectory_steps
1030                        .push(oxios_memory::memory::sona::TrajectoryStep {
1031                            input: tool_name.clone(),
1032                            output: String::new(),
1033                            duration_ms: 0,
1034                            confidence: 0.0,
1035                        });
1036                    // RFC-015: broadcast tool start so Web UI can show progress.
1037                    if let Some(ref sid) = transparency_session {
1038                        let context_json = context
1039                            .as_ref()
1040                            .map(serde_json::to_value)
1041                            .transpose()
1042                            .unwrap_or(None);
1043                        let _ =
1044                            kernel_handle_for_cb
1045                                .infra
1046                                .publish(KernelEvent::ToolExecutionStarted {
1047                                    session_id: sid.clone(),
1048                                    tool_name: tool_name.clone(),
1049                                    tool_call_id: tool_call_id.clone(),
1050                                    tool_args: args.clone(),
1051                                    context: context_json,
1052                                });
1053                    }
1054                }
1055                AgentEvent::ToolExecutionUpdate {
1056                    tool_call_id,
1057                    tool_name,
1058                    partial_result,
1059                    tab_id,
1060                    context,
1061                } => {
1062                    // RFC-015: forward real-time progress to the event bus
1063                    // so the Web UI can show a spinner and progress text
1064                    // while the tool is still executing. Best-effort —
1065                    // publish failures (e.g. lagged subscribers) are ignored.
1066                    //
1067                    // `tab_id` and `context` come from oxi-agent 0.29+
1068                    // (ToolCallContext: PageVisit, WebSearch, etc.).
1069                    // Older agent versions won't send these — they default
1070                    // to None and the UI gracefully ignores them.
1071                    if let Some(ref sid) = transparency_session {
1072                        let context_json = context
1073                            .as_ref()
1074                            .map(serde_json::to_value)
1075                            .transpose()
1076                            .unwrap_or(None);
1077                        let _ = kernel_handle_for_cb.infra.publish(
1078                            KernelEvent::ToolExecutionProgress {
1079                                session_id: sid.clone(),
1080                                tool_call_id: tool_call_id.clone(),
1081                                tool_name: tool_name.clone(),
1082                                progress: partial_result,
1083                                tab_id,
1084                                context: context_json,
1085                            },
1086                        );
1087                    }
1088                }
1089                AgentEvent::ToolExecutionEnd {
1090                    tool_name,
1091                    tool_call_id,
1092                    is_error,
1093                    result,
1094                    ..
1095                } => {
1096                    if !is_error {
1097                        s.steps_completed += 1;
1098                    }
1099                    // Look up the exact step by tool_call_id.
1100                    let mut duration_ms: u64 = 0;
1101                    let mut summary = String::new();
1102                    if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
1103                        duration_ms = start.elapsed().as_millis() as u64;
1104                        if let Some(step) = s.trajectory_steps.get_mut(idx) {
1105                            summary = summarize_tool_result(&result.content, 200);
1106                            step.output = summary.clone();
1107                            step.duration_ms = duration_ms;
1108                            step.confidence = if is_error { 0.3 } else { 0.8 };
1109                        }
1110                    }
1111                    s.tool_error_map.insert(tool_call_id.clone(), is_error);
1112                    // RFC-015: broadcast tool completion.
1113                    if let Some(ref sid) = transparency_session {
1114                        let _ = kernel_handle_for_cb.infra.publish(
1115                            KernelEvent::ToolExecutionFinished {
1116                                session_id: sid.clone(),
1117                                tool_call_id: tool_call_id.clone(),
1118                                tool_name: tool_name.clone(),
1119                                duration_ms,
1120                                is_error,
1121                                output_summary: summary,
1122                            },
1123                        );
1124                    }
1125                }
1126                AgentEvent::AgentEnd {
1127                    messages,
1128                    stop_reason,
1129                    ..
1130                } => {
1131                    if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
1132                        s.final_content = a.text_content();
1133                    }
1134                    // oxi 0.32.0: loop exits naturally when LLM produces text-only
1135                    // response (StopReason::Stop). Error/Aborted = failure.
1136                    // ToolUse should not occur at AgentEnd in 0.32.0 (the loop
1137                    // continues until text-only), but treat it as non-failure
1138                    // since tool calls were executed successfully.
1139                    s.success = matches!(stop_reason.as_deref(), Some("Stop") | Some("ToolUse"));
1140                }
1141                AgentEvent::Error { message, .. } => {
1142                    s.final_content = message.clone();
1143                    s.success = false;
1144                }
1145                AgentEvent::Usage {
1146                    input_tokens,
1147                    output_tokens,
1148                } => {
1149                    // Accumulate totals for ExecutionResult.
1150                    s.total_input_tokens += input_tokens as u64;
1151                    s.total_output_tokens += output_tokens as u64;
1152
1153                    // Record token usage to cost tracker (existing).
1154                    let agent_label = format!("agent-{agent_id_for_callback}");
1155                    crate::observability::cost_tracker().record(
1156                        &agent_label,
1157                        &oxi_sdk::Model::new(
1158                            &model_id_for_callback,
1159                            &model_id_for_callback,
1160                            oxi_sdk::Api::OpenAiCompletions,
1161                            "unknown",
1162                            "https://unknown.com",
1163                        ),
1164                        oxi_sdk::TokenUsage {
1165                            input: input_tokens as u64,
1166                            output: output_tokens as u64,
1167                            cache_read: 0,
1168                            cache_write: 0,
1169                        },
1170                    );
1171
1172                    // Record to routing stats (RFC-011).
1173                    if let Some(stats) = &routing_stats_for_cb {
1174                        let cost = crate::kernel_handle::engine_api::estimate_cost(
1175                            &model_id_for_callback,
1176                            input_tokens as u64,
1177                            output_tokens as u64,
1178                        );
1179                        stats.record_model_usage(&model_id_for_callback, cost);
1180                    }
1181                    // RFC-015: publish cumulative token usage.
1182                    if let Some(ref sid) = transparency_session {
1183                        let _ = kernel_handle_for_cb
1184                            .infra
1185                            .publish(KernelEvent::TokenUsageUpdate {
1186                                session_id: sid.clone(),
1187                                input_tokens: input_tokens as u64,
1188                                output_tokens: output_tokens as u64,
1189                            });
1190                    }
1191                }
1192                AgentEvent::Compaction {
1193                    event: CompactionEvent::Completed { result, .. },
1194                } => {
1195                    handle_compaction(
1196                        result.summary.clone(),
1197                        session_id_for_callback.clone(),
1198                        memory_for_callback.clone(),
1199                    );
1200                    // RFC-015: compaction is a form of reasoning — expose it.
1201                    if let Some(ref sid) = transparency_session {
1202                        let _ =
1203                            kernel_handle_for_cb
1204                                .infra
1205                                .publish(KernelEvent::ReasoningFragment {
1206                                    session_id: sid.clone(),
1207                                    content: result.summary.clone(),
1208                                    source: "compaction".to_string(),
1209                                });
1210                    }
1211                }
1212                _ => {}
1213            }
1214        })
1215        .await;
1216
1217    // Record circuit breaker result after agent execution.
1218    let circuit = get_llm_circuit_breaker();
1219    if result.is_err() {
1220        circuit.record_failure();
1221        crate::metrics::get_metrics()
1222            .llm_circuit_breaker_state
1223            .set(1.0);
1224    } else {
1225        circuit.record_success();
1226        crate::metrics::get_metrics()
1227            .llm_circuit_breaker_state
1228            .set(0.0);
1229    }
1230
1231    if let Err(e) = result {
1232        tracing::error!(seed_id = %seed_id, error = %e, "Agent failed");
1233        // RFC-029 P2b: capture the agent's accumulated conversation state
1234        // before returning. The supervisor's Err arm unwraps AgentRunError
1235        // and populates ExecutionResult.restore_state so the coordinator
1236        // can inject it into a retry with a different model (snapshot→restore).
1237        let restore_state = agent.export_state().ok();
1238        return Err(crate::resilience::AgentRunError::wrap(e, restore_state).into());
1239    }
1240
1241    let s = exec_state.lock();
1242    tracing::info!(
1243        seed_id = %seed_id,
1244        steps = s.steps_completed,
1245        success = s.success,
1246        "Agent completed"
1247    );
1248
1249    // Record trajectory to SONA learning engine (RFC-020 Phase 2).
1250    // Fire-and-forget: don't block the result on learning.
1251    if !s.trajectory_steps.is_empty()
1252        && let Some(sona) = kernel_handle.agents.memory_manager().sona_engine()
1253    {
1254        let steps = s.trajectory_steps.clone();
1255        let success = s.success;
1256        let sona = Arc::clone(sona);
1257        let domain = infer_domain(&seed_goal);
1258        tokio::spawn(async move {
1259            let verdict = if success {
1260                oxios_memory::memory::sona::Verdict::Success
1261            } else {
1262                oxios_memory::memory::sona::Verdict::Failure
1263            };
1264            let trajectory = oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
1265            if let Err(e) = sona.record(trajectory).await {
1266                tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
1267            }
1268        });
1269    }
1270
1271    Ok((
1272        s.final_content.clone(),
1273        s.steps_completed,
1274        s.success,
1275        s.trajectory_steps.clone(),
1276        agent,
1277        s.tool_call_ids.clone(),
1278        s.tool_args_map.clone(),
1279        s.tool_error_map.clone(),
1280        s.tool_timestamps.clone(),
1281        s.total_input_tokens,
1282        s.total_output_tokens,
1283    ))
1284}
1285
1286/// Summarize a tool result string to fit within `max_len` characters.
1287///
1288/// Uses char-aware truncation to avoid panicking on multi-byte UTF-8
1289/// (e.g., Korean, CJK, emoji).
1290fn summarize_tool_result(result: &str, max_len: usize) -> String {
1291    let trimmed = result.trim();
1292    if trimmed.chars().count() <= max_len {
1293        return trimmed.to_string();
1294    }
1295    // Take the first line or truncate.
1296    let first_line = trimmed.lines().next().unwrap_or("");
1297    if first_line.chars().count() <= max_len {
1298        first_line.to_string()
1299    } else {
1300        let take = max_len.saturating_sub(3);
1301        let truncated: String = if take == 0 {
1302            first_line.chars().take(max_len).collect()
1303        } else {
1304            first_line.chars().take(take).collect()
1305        };
1306        format!("{truncated}...")
1307    }
1308}
1309fn truncate_json_str(json_str: &str, max_len: usize) -> String {
1310    if json_str.len() <= max_len {
1311        return json_str.to_string();
1312    }
1313    // Saturating sub avoids underflow panic when max_len < 3; if there
1314    // isn't room for an ellipsis, return as many chars as fit.
1315    let take = max_len.saturating_sub(3);
1316    if take == 0 {
1317        return json_str.chars().take(max_len).collect();
1318    }
1319    let truncated: String = json_str.chars().take(take).collect();
1320    format!("{truncated}...")
1321}
1322
1323/// Infer a domain category from a seed goal for SONA trajectory grouping.
1324///
1325/// Extracts the core verb + object from the goal to create a meaningful
1326/// domain label. Falls back to "general" for unrecognizable patterns.
1327fn infer_domain(goal: &str) -> String {
1328    let lower = goal.to_lowercase();
1329    let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
1330
1331    // Check for known domain indicators.
1332    if keywords.iter().any(|k| {
1333        [
1334            "test",
1335            "tests",
1336            "spec",
1337            "testing",
1338            "assert",
1339            "unit test",
1340            "integration",
1341        ]
1342        .contains(k)
1343    }) {
1344        return "testing".to_string();
1345    }
1346    if keywords
1347        .iter()
1348        .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
1349    {
1350        return "deployment".to_string();
1351    }
1352    if keywords
1353        .iter()
1354        .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
1355    {
1356        return "bugfix".to_string();
1357    }
1358    if keywords
1359        .iter()
1360        .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
1361    {
1362        return "refactoring".to_string();
1363    }
1364    if keywords
1365        .iter()
1366        .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
1367    {
1368        return "documentation".to_string();
1369    }
1370    if keywords
1371        .iter()
1372        .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
1373    {
1374        return "development".to_string();
1375    }
1376    if keywords
1377        .iter()
1378        .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1379    {
1380        return "analysis".to_string();
1381    }
1382    if keywords
1383        .iter()
1384        .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1385    {
1386        return "configuration".to_string();
1387    }
1388
1389    // Fallback: first 2 meaningful words
1390    let meaningful: Vec<&str> = lower
1391        .split_whitespace()
1392        .filter(|w| w.len() > 2)
1393        .take(2)
1394        .collect();
1395    if meaningful.len() >= 2 {
1396        meaningful.join("_")
1397    } else {
1398        "general".to_string()
1399    }
1400}
1401
1402/// Handle compaction completion by storing the summary as a Warm memory.
1403///
1404/// Extracts the compaction summary from the event and spawns a background
1405/// task to persist it via MemoryManager. This replaces the inline 30-line
1406/// block that was previously in the event callback.
1407fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1408    let entry = MemoryEntry {
1409        id: uuid::Uuid::new_v4().to_string(),
1410        memory_type: MemoryType::Conversation,
1411        tier: crate::memory::MemoryTier::Warm,
1412        content: summary,
1413        content_hash: 0,
1414        source: "compaction".to_string(),
1415        session_id: Some(session_id),
1416        tags: vec![],
1417        importance: 0.5,
1418        pinned: false,
1419        protection: crate::memory::ProtectionLevel::None,
1420        auto_classified: false,
1421        session_appearances: 0,
1422        user_corrected: false,
1423        seen_in_sessions: vec![],
1424        created_at: chrono::Utc::now(),
1425        accessed_at: chrono::Utc::now(),
1426        modified_at: chrono::Utc::now(),
1427        access_count: 0,
1428        decay_score: 1.0,
1429        compaction_level: 0,
1430        compacted_from: vec![],
1431        related_ids: vec![],
1432        contradicts: None,
1433    };
1434    tokio::spawn(async move {
1435        if let Err(e) = memory_manager.remember(entry).await {
1436            tracing::warn!(error = %e, "Failed to save compaction summary");
1437        }
1438    });
1439}
1440
1441/// Build a system prompt from a Directive and ExecEnv (RFC-027).
1442///
1443/// Maps [`Directive`] fields (`goal`, `original_request`, `constraints`,
1444/// `acceptance_criteria`) and [`ExecEnv`] fields (`workspace_context`) into
1445#[allow(dead_code)]
1446fn build_directive_system_prompt(
1447    directive: &Directive,
1448    env: &ExecEnv,
1449    persona_prompt: Option<&str>,
1450    capabilities_xml: Option<&str>,
1451    kernel_manifest: Option<&str>,
1452) -> String {
1453    build_system_prompt_inner(
1454        &directive.goal,
1455        &directive.original_request,
1456        &directive.constraints,
1457        &directive.acceptance_criteria,
1458        env.workspace_context.as_deref(),
1459        persona_prompt,
1460        capabilities_xml,
1461        kernel_manifest,
1462    )
1463}
1464
1465/// Shared system-prompt builder for the directive path.
1466///
1467/// Composes the static agent prelude, goal/constraints/criteria sections,
1468/// optional workspace context, persona, capability index, and
1469/// kernel manifest into a single prompt string.
1470#[allow(clippy::too_many_arguments)]
1471fn build_system_prompt_inner(
1472    goal: &str,
1473    original_request: &str,
1474    constraints: &[String],
1475    acceptance_criteria: &[String],
1476    workspace_context: Option<&str>,
1477    persona_prompt: Option<&str>,
1478    capabilities_xml: Option<&str>,
1479    kernel_manifest: Option<&str>,
1480) -> String {
1481    let mut prompt = String::from(
1482        "You are an autonomous agent in the Oxios operating system.\n\
1483         You execute Seeds — immutable specifications with goals, constraints, and\n\
1484         acceptance criteria.\n\n\
1485         ## Available Tools\n\
1486         You have the following tools:\n\
1487         - **File tools**: read, write, edit files; grep, find, ls for searching\n\
1488         - **Web tools**: web_search for searching the web, get_search_results for retrieving cached results\n\
1489         - **Exec**: run shell commands\n\
1490         - **Memory tools**: memory_read, memory_write, memory_search — agent's internal recall\n\
1491         - **Knowledge**: knowledge — personal markdown vault for documents and notes\n\
1492         - **Kernel tools**: agent, project, persona, cron, security, budget, resource\n\n\
1493         **Important**: When the task involves fetching information from the internet,\n\
1494         websites, or online services, use `web_search` first — do NOT search local files.\n\
1495         When the task asks to \"get\", \"fetch\", \"find online\", or \"look up\" something\n\
1496         from the web, use `web_search`.\n",
1497    );
1498    prompt.push_str(&format!("\n## Goal\n{}\n", goal));
1499
1500    // Preserve user's original wording so the agent sees exact language,
1501    // filenames, and nuances that may have been abstracted in the goal.
1502    if !original_request.is_empty() && original_request != goal {
1503        prompt.push_str(&format!(
1504            "\n## User's Original Request\n{}\n",
1505            original_request
1506        ));
1507    }
1508
1509    if !constraints.is_empty() {
1510        prompt.push_str("\n## Constraints\n");
1511        for (i, c) in constraints.iter().enumerate() {
1512            prompt.push_str(&format!("{}. {}\n", i + 1, c));
1513        }
1514    }
1515
1516    if !acceptance_criteria.is_empty() {
1517        prompt.push_str("\n## Acceptance Criteria\n");
1518        for (i, c) in acceptance_criteria.iter().enumerate() {
1519            prompt.push_str(&format!("{}. {}\n", i + 1, c));
1520        }
1521    }
1522
1523    // ── Workspace Context (RFC-025) ──
1524    // Inject active Mounts + project instructions AFTER the goal/constraints
1525    // and BEFORE the persona, so the agent sees its workspace before it acts.
1526    if let Some(ctx) = workspace_context.filter(|s| !s.trim().is_empty()) {
1527        prompt.push_str("\n## Workspace Context\n");
1528        prompt.push_str(ctx);
1529        prompt.push('\n');
1530    }
1531
1532    // Inject persona system prompt
1533    if let Some(pp) = persona_prompt {
1534        prompt.push_str("\n## Persona\n");
1535        prompt.push_str(pp);
1536        prompt.push('\n');
1537    }
1538
1539    // Inject semantic capability index (from ToolRetriever)
1540    if let Some(xml) = capabilities_xml {
1541        prompt.push_str("\n## Available Capabilities\n");
1542        prompt.push_str("The following capabilities are relevant to your goal. ");
1543        prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1544        prompt.push_str(xml);
1545        prompt.push('\n');
1546    }
1547
1548    // Inject kernel manifest (from CSpace)
1549    if let Some(manifest) = kernel_manifest {
1550        prompt.push('\n');
1551        prompt.push_str(manifest);
1552        prompt.push('\n');
1553    }
1554
1555    // Execution environment guidance
1556    prompt.push_str(
1557        "\n## Execution Protocol\n\
1558         1. UNDERSTAND — Read the Seed completely before acting.\n\
1559         2. PLAN — Determine the minimal set of actions needed.\n\
1560         3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
1561         4. VERIFY — After each action, check the result: created a file? read it back.\n\
1562         5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1563         ## Hard Boundaries\n\
1564         - NEVER modify files outside the workspace scope\n\
1565         - NEVER execute destructive commands without confirming scope\n\
1566         - NEVER claim completion without evidence — show the output, not your opinion\n\
1567         - NEVER add features or improvements beyond the Seed scope\n\
1568         - If you cannot complete the Seed, say so and explain WHY\n\n\
1569         ## Scope Guard\n\
1570         The Seed defines your universe. Do not:\n\
1571         - Refactor code the Seed didn't mention\n\
1572         - Add tests the Seed didn't require\n\
1573         - Change configuration the Seed didn't specify\n\
1574         - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1575         ## Error Handling\n\
1576         - If a tool fails, read the error message carefully before retrying\n\
1577         - If a command fails, do NOT immediately retry with --force or sudo\n\
1578         - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1579         ## Shape Matching\n\
1580         Match your output to the task: simple task → concise response.\n\
1581         Do not write 50 lines when 5 would do.\n\
1582         Use `exec` for all command execution (git, gh, osascript, etc.).",
1583    );
1584
1585    prompt
1586}
1587#[allow(dead_code)]
1588fn build_directive_user_prompt(directive: &Directive) -> String {
1589    build_user_prompt_inner(&directive.goal, &directive.acceptance_criteria)
1590}
1591
1592/// Shared user-prompt builder for the directive path.
1593fn build_user_prompt_inner(goal: &str, acceptance_criteria: &[String]) -> String {
1594    format!(
1595        "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1596        goal,
1597        acceptance_criteria
1598            .iter()
1599            .enumerate()
1600            .map(|(i, c)| format!("{}. {}", i + 1, c))
1601            .collect::<Vec<_>>()
1602            .join("\n")
1603    )
1604}
1605
1606impl std::fmt::Debug for AgentRuntime {
1607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1608        f.debug_struct("AgentRuntime")
1609            .field("model_id", &self.engine_handle.get().default_model_id())
1610            .finish()
1611    }
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616    use super::*;
1617    use async_trait::async_trait;
1618    use oxi_sdk::{AgentTool, ToolContext, ToolError};
1619    use serde_json::Value;
1620
1621    /// A test tool that does nothing — used to populate the registry.
1622    struct DummyTool {
1623        name: String,
1624    }
1625
1626    #[async_trait]
1627    impl AgentTool for DummyTool {
1628        fn name(&self) -> &str {
1629            &self.name
1630        }
1631        fn label(&self) -> &str {
1632            &self.name
1633        }
1634        fn description(&self) -> &str {
1635            "Test tool"
1636        }
1637        fn parameters_schema(&self) -> Value {
1638            serde_json::json!({"type": "object"})
1639        }
1640
1641        async fn execute(
1642            &self,
1643            _tool_call_id: &str,
1644            _params: Value,
1645            _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1646            _ctx: &ToolContext,
1647        ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1648            Ok(oxi_sdk::AgentToolResult::success("ok"))
1649        }
1650    }
1651
1652    /// Test that requires_tools validation passes when all tools are present.
1653    #[test]
1654    fn test_requires_tools_validation_passes() {
1655        let registry = ToolRegistry::new();
1656
1657        registry.register(DummyTool {
1658            name: "read".into(),
1659        });
1660        registry.register(DummyTool {
1661            name: "exec".into(),
1662        });
1663
1664        let missing = registry.missing(&["read", "exec"]);
1665
1666        assert!(
1667            missing.is_empty(),
1668            "Expected no missing tools, got: {:?}",
1669            missing
1670        );
1671    }
1672
1673    /// Test that requires_tools validation fails when a tool is missing.
1674    #[test]
1675    fn test_requires_tools_validation_fails() {
1676        let registry = ToolRegistry::new();
1677
1678        registry.register(DummyTool {
1679            name: "read".into(),
1680        });
1681
1682        let missing = registry.missing(&["read", "exec", "nonexistent"]);
1683
1684        assert_eq!(missing, vec!["exec", "nonexistent"]);
1685    }
1686
1687    #[test]
1688    fn test_infer_domain_testing() {
1689        assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1690    }
1691
1692    #[test]
1693    fn test_infer_domain_deployment() {
1694        assert_eq!(
1695            infer_domain("deploy the web service to production"),
1696            "deployment"
1697        );
1698    }
1699
1700    #[test]
1701    fn test_infer_domain_bugfix() {
1702        assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1703    }
1704
1705    #[test]
1706    fn test_infer_domain_development() {
1707        assert_eq!(
1708            infer_domain("create a new REST API endpoint"),
1709            "development"
1710        );
1711    }
1712
1713    #[test]
1714    fn test_infer_domain_analysis() {
1715        assert_eq!(
1716            infer_domain("review the code for security issues"),
1717            "analysis"
1718        );
1719    }
1720
1721    #[test]
1722    fn test_infer_domain_fallback() {
1723        let domain = infer_domain("optimize performance metrics");
1724        // Should fall back to first 2 meaningful words
1725        assert!(!domain.is_empty());
1726    }
1727}