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