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