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