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