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