Skip to main content

oxios_kernel/
agent_runtime.rs

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