Skip to main content

oxios_kernel/
agent_runtime.rs

1//! Agent runtime: wraps oxi-sdk's Agent for Seed 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::sync::Arc;
38// RFC-014 Phase D: `ToolRegistry::register_arc` is used in the AgentBuilder
39// path to attach CSpace tools after `builder.build()` returns.
40
41use crate::access_manager::{AccessGate, AgentContext, TracingAuditSink, TrailAuditSink};
42use crate::capability::resolve::resolve_cspace;
43use crate::engine::OxiosEngine;
44use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
45use crate::persona::PersonaManager;
46use crate::tools::registration::register_tools_from_cspace_gated;
47
48use crate::event_bus::KernelEvent;
49use crate::session_context::SessionContext;
50use crate::types::AgentId;
51use crate::KernelHandle;
52use oxios_ouroboros::{ExecutionResult, Seed};
53
54/// Global LLM circuit breaker instance — delegates to oxi-sdk's ProviderCircuitBreaker.
55static LLM_CIRCUIT_BREAKER: std::sync::OnceLock<oxi_sdk::ProviderCircuitBreaker> =
56    std::sync::OnceLock::new();
57
58/// Get the global LLM circuit breaker.
59fn get_llm_circuit_breaker() -> &'static oxi_sdk::ProviderCircuitBreaker {
60    LLM_CIRCUIT_BREAKER.get_or_init(|| {
61        oxi_sdk::ProviderCircuitBreaker::new(
62            "global".to_string(),
63            oxi_sdk::CircuitBreakerConfig::default(),
64        )
65    })
66}
67
68/// Configuration for creating AgentRuntime instances.
69#[derive(Debug, Clone)]
70pub struct AgentRuntimeConfig {
71    /// Model ID in `provider/model` format (e.g. `anthropic/claude-sonnet-4-20250514`).
72    pub model_id: String,
73    /// Maximum number of agent turns before forcing a stop.
74    pub max_iterations: usize,
75    /// How to execute tool calls within a single turn.
76    pub tool_execution: ToolExecutionMode,
77    /// Whether auto-retry is enabled for retryable LLM errors.
78    pub auto_retry_enabled: bool,
79    /// Bound project paths. AgentRuntime sets CWD to paths[0].
80    pub project_paths: Vec<std::path::PathBuf>,
81    /// Scratch workspace directory for temp files.
82    pub workspace_dir: Option<std::path::PathBuf>,
83    /// API key resolved from CredentialStore at build time.
84    pub api_key: Option<String>,
85    /// Per-provider options for fine-grained control.
86    pub provider_options: Option<oxi_sdk::ProviderOptions>,
87    /// Rate limit for tool calls (requests per minute). 0 = unlimited.
88    pub rate_limit_per_minute: usize,
89    /// Token budget per agent execution. 0 = unlimited.
90    pub token_budget: usize,
91    /// Enable audit logging for all tool executions.
92    pub audit_tool_calls: bool,
93    /// Provider-level RPM for rate-limited provider pool. 0 = no pooling.
94    /// When set, uses `OxiosEngine::pooled_provider()` instead of `create_provider()`.
95    pub provider_rpm: u32,
96}
97
98impl Default for AgentRuntimeConfig {
99    fn default() -> Self {
100        Self {
101            model_id: String::new(),
102            max_iterations: 8,
103            tool_execution: ToolExecutionMode::Parallel,
104            auto_retry_enabled: true,
105            project_paths: Vec::new(),
106            workspace_dir: None,
107            api_key: None,
108            provider_options: None,
109            rate_limit_per_minute: 0,
110            token_budget: 0,
111            audit_tool_calls: false,
112            provider_rpm: 0,
113        }
114    }
115}
116
117/// Mutable state shared between the event callback and the main execute flow.
118#[derive(Default)]
119struct ExecuteState {
120    final_content: String,
121    steps_completed: usize,
122    success: bool,
123    /// Collected trajectory steps for SONA learning (RFC-020 Phase 2).
124    /// Ordered by insertion — parallel tools get their final position
125    /// resolved when they complete, preserving approximate execution order.
126    trajectory_steps: Vec<oxios_memory::memory::sona::TrajectoryStep>,
127    /// Map of tool_call_id → (start instant, index into trajectory_steps).
128    /// Used to correlate ToolExecutionEnd with the correct step when
129    /// parallel tool calls complete out of order.
130    pending_tools: std::collections::HashMap<String, (std::time::Instant, usize)>,
131}
132
133/// Runtime that wraps an oxi-sdk `Agent` for executing Seeds.
134///
135/// Each call to [`AgentRuntime::execute`] creates a fresh `Agent`,
136/// builds a ToolRegistry based on the agent's CSpace, and runs it to completion.
137///
138/// All OS-level access goes through `KernelHandle` — the single syscall table
139/// for agent control. Provider/model resolution goes through `EngineHandle`,
140/// which returns the latest `OxiosEngine` (hot-swapped on config change).
141pub struct AgentRuntime {
142    engine_handle: Arc<crate::engine::EngineHandle>,
143    config: AgentRuntimeConfig,
144    /// Single path to all kernel services.
145    kernel_handle: Arc<KernelHandle>,
146    /// Persona manager for system prompt injection.
147    persona_manager: Option<Arc<PersonaManager>>,
148    /// Semantic tool retriever for capability discovery.
149    tool_retriever: Option<Arc<crate::tools::retrieval::ToolRetriever>>,
150    /// Shared routing stats (shared with EngineApi).
151    routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
152}
153
154impl AgentRuntime {
155    /// Creates a new agent runtime with engine handle and kernel access.
156    ///
157    /// Provider/model resolution goes through `engine_handle` (hot-swapped on config change).
158    /// Tool access goes through `kernel_handle`.
159    pub fn new(
160        engine_handle: Arc<crate::engine::EngineHandle>,
161        model_id: impl Into<String>,
162        kernel_handle: Arc<KernelHandle>,
163        routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
164    ) -> Self {
165        Self {
166            engine_handle,
167            config: AgentRuntimeConfig {
168                model_id: model_id.into(),
169                ..Default::default()
170            },
171            kernel_handle,
172            persona_manager: None,
173            tool_retriever: None,
174            routing_stats,
175        }
176    }
177
178    /// Attach a PersonaManager for persona system prompt injection.
179    pub fn with_persona_manager(mut self, pm: Arc<PersonaManager>) -> Self {
180        self.persona_manager = Some(pm);
181        self
182    }
183
184    /// Set the runtime config (overrides defaults).
185    pub fn with_config(mut self, config: AgentRuntimeConfig) -> Self {
186        self.config = config;
187        self
188    }
189
190    /// Attach a ToolRetriever for semantic capability discovery.
191    pub fn with_tool_retriever(
192        mut self,
193        retriever: Arc<crate::tools::retrieval::ToolRetriever>,
194    ) -> Self {
195        self.tool_retriever = Some(retriever);
196        self
197    }
198
199    /// Execute a Seed by running the tool-calling agent to completion.
200    ///
201    /// 1. Resolves CSpace from persona/role/hint
202    /// 2. Registers tools via CSpace
203    /// 3. Recalls memories if available
204    /// 4. Creates Agent via `Agent::new_with_resolver()`
205    /// 5. Runs via `Agent::run_streaming()`
206    pub async fn execute(
207        &self,
208        agent_id: AgentId,
209        seed: &Seed,
210        session_ctx: &mut SessionContext,
211    ) -> Result<ExecutionResult> {
212        // RFC-015: session_id is derived from seed.id for chat transparency
213        // event publishing. Most callers run one Seed per session turn, so
214        // seed.id is a usable session identifier.
215        let session_id: Option<String> = Some(seed.id.to_string());
216        self.execute_with_session(agent_id, seed, session_ctx, session_id)
217            .await
218    }
219
220    /// Like [`execute`](Self::execute) but with an explicit session_id for
221    /// RFC-015 chat transparency event publishing.
222    pub async fn execute_with_session(
223        &self,
224        agent_id: AgentId,
225        seed: &Seed,
226        session_ctx: &mut SessionContext,
227        session_id: Option<String>,
228    ) -> Result<ExecutionResult> {
229        let prompt = build_user_prompt(seed);
230
231        // Get active persona system prompt.
232        let persona_prompt = self
233            .persona_manager
234            .as_ref()
235            .map(|pm| pm.active_system_prompt())
236            .filter(|s| !s.trim().is_empty());
237
238        // Determine persona role for CSpace resolution.
239        let persona_role = self
240            .persona_manager
241            .as_ref()
242            .and_then(|pm| pm.get_active_persona().map(|p| p.role.clone()));
243
244        // Resolve CSpace from persona role, seed hint, or default.
245        let cspace = resolve_cspace(
246            seed.cspace_hint.as_deref(),
247            persona_role.as_deref(),
248            Some("worker"),
249            agent_id,
250        );
251
252        // Build system prompt (without SKILL.md injection — capabilities are
253        // surfaced through the CSpace tool set + semantic retrieval instead).
254        let mut system_prompt = build_system_prompt(seed, persona_prompt.as_deref(), None, None);
255
256        // Semantic capability retrieval: find tools relevant to this seed's goal.
257        let capabilities_xml = if let Some(ref retriever) = self.tool_retriever {
258            match retriever.embedder().embed(&seed.goal).await {
259                Ok(query_vec) => {
260                    let results = retriever.retrieve(&query_vec, 8);
261                    if results.is_empty() {
262                        None
263                    } else {
264                        let xml = crate::tools::retrieval::format_capability_index(&results);
265                        tracing::info!(count = results.len(), "Retrieved relevant capabilities");
266                        Some(xml)
267                    }
268                }
269                Err(e) => {
270                    tracing::warn!(error = %e, "Failed to embed seed goal for retrieval");
271                    None
272                }
273            }
274        } else {
275            None
276        };
277
278        // Build kernel manifest from CSpace active domains.
279        let kernel_manifest = {
280            let domains = cspace.active_domains();
281            if domains.is_empty() {
282                None
283            } else {
284                Some(crate::tools::retrieval::build_kernel_manifest(&domains))
285            }
286        };
287
288        // Rebuild system prompt with capabilities and manifest if available.
289        if capabilities_xml.is_some() || kernel_manifest.is_some() {
290            system_prompt = build_system_prompt(
291                seed,
292                persona_prompt.as_deref(),
293                capabilities_xml.as_deref(),
294                kernel_manifest.as_deref(),
295            );
296        }
297
298        // Blend relevant memories into system prompt.
299        let memory_manager = self.kernel_handle.agents.memory_manager();
300        match memory_manager
301            .recall_with_proactive(&seed.goal, &mut session_ctx.recall_timing)
302            .await
303        {
304            Ok(memories) if !memories.is_empty() => {
305                tracing::info!(count = memories.len(), "Recalled memories for seed");
306                system_prompt = memory_manager.blend_into_prompt(&memories, &system_prompt);
307            }
308            Ok(_) => tracing::debug!("No memories recalled"),
309            Err(e) => tracing::warn!(error = %e, "Failed to recall memories"),
310        }
311
312        // Inject learned strategy from SONA (RFC-020 Phase 2).
313        if let Some(sona) = memory_manager.sona_engine() {
314            match sona.adapt(&seed.goal).await {
315                Ok(Some(pattern)) if pattern.confidence > 0.5 => {
316                    tracing::info!(
317                        domain = %pattern.domain,
318                        confidence = pattern.confidence,
319                        "SONA learned pattern injected"
320                    );
321                    system_prompt.push_str(&format!(
322                        "\n\n## Learned Strategy (confidence: {:.0}%)\n{}\n",
323                        pattern.confidence * 100.0,
324                        pattern.strategy,
325                    ));
326                }
327                Ok(_) => tracing::debug!("No high-confidence SONA pattern found"),
328                Err(e) => tracing::debug!(error = %e, "SONA adapt failed (non-fatal)"),
329            }
330        }
331
332        // Blend relevant knowledge notes into system prompt (KnowledgeLens, RFC-003 Phase 3).
333        match self
334            .kernel_handle
335            .knowledge_lens
336            .recall_for_context(&seed.goal, 5)
337            .await
338        {
339            Ok(ctx) if !ctx.notes.is_empty() => {
340                tracing::info!(
341                    notes = ctx.notes.len(),
342                    memories = ctx.memories.len(),
343                    "Recalled knowledge context for seed"
344                );
345                let knowledge_blend = ctx
346                    .notes
347                    .iter()
348                    .take(3)
349                    .map(|n| format!("## {}\n\n{}", n.name, n.content))
350                    .collect::<Vec<_>>()
351                    .join("\n\n");
352                system_prompt.push_str("\n\n## Relevant Knowledge\n\n");
353                system_prompt.push_str(&knowledge_blend);
354            }
355            Ok(_) => tracing::debug!("No knowledge recalled"),
356            Err(e) => tracing::warn!(error = %e, "Failed to recall knowledge context"),
357        }
358
359        // Resolve model from engine (provider resolution happens inside AgentBuilder).
360        // Get the latest engine — may have been hot-swapped via Web UI config change.
361        let engine = self.engine_handle.get();
362        let _model = engine.resolve_model(&self.config.model_id)?;
363        let seed_id = seed.id;
364
365        // Build the agent.
366        let config = self.config.clone();
367        let kernel_handle = Arc::clone(&self.kernel_handle);
368
369        // Extract audit trail from kernel for TrailAuditSink wiring.
370        let audit_trail: Option<Arc<AuditTrail>> =
371            Some(Arc::clone(&self.kernel_handle.security.audit_trail));
372
373        let (final_content, steps_completed, success, trajectory_steps, _agent) = {
374            run_agent(
375                &config,
376                &engine,
377                kernel_handle,
378                system_prompt,
379                prompt,
380                seed_id,
381                seed.goal.clone(),
382                agent_id,
383                cspace,
384                audit_trail,
385                self.routing_stats.clone(),
386                session_id.clone(),
387            )
388            .await?
389        };
390
391        // Map trajectory steps to tool call records for the execution result.
392        let tool_calls: Vec<oxios_ouroboros::ToolCallRecord> = trajectory_steps
393            .iter()
394            .map(|s| oxios_ouroboros::ToolCallRecord {
395                tool: s.input.clone(),
396                input: String::new(), // Input is summarized in the trajectory step's input field
397                output: s.output.clone(),
398                duration_ms: s.duration_ms,
399            })
400            .collect();
401
402        tracing::info!(
403            seed_id = %seed_id,
404            steps = steps_completed,
405            success,
406            tool_calls = tool_calls.len(),
407            "AgentRuntime finished"
408        );
409
410        Ok(ExecutionResult {
411            output: if final_content.is_empty() {
412                "Agent execution completed".into()
413            } else {
414                final_content
415            },
416            steps_completed,
417            success,
418            tool_calls,
419        })
420    }
421}
422
423/// Create and run an oxi-sdk `Agent` with CSpace-based tool registration.
424///
425/// Uses `engine.oxi().agent()` (AgentBuilder) for full middleware,
426/// observability, and security integration from oxi-sdk 0.23.0.
427#[allow(clippy::too_many_arguments)]
428async fn run_agent(
429    config: &AgentRuntimeConfig,
430    engine: &OxiosEngine,
431    kernel_handle: Arc<KernelHandle>,
432    system_prompt: String,
433    prompt: String,
434    seed_id: uuid::Uuid,
435    seed_goal: String,
436    agent_id: AgentId,
437    cspace: crate::capability::CSpace,
438    audit_trail: Option<Arc<AuditTrail>>,
439    routing_stats: Option<Arc<crate::kernel_handle::RoutingStats>>,
440    session_id: Option<String>,
441) -> Result<(
442    String,
443    usize,
444    bool,
445    Vec<oxios_memory::memory::sona::TrajectoryStep>,
446    Arc<Agent>,
447)> {
448    // Extract workspace.
449    let workspace = if !config.project_paths.is_empty() {
450        config.project_paths[0].clone()
451    } else if let Some(ref ws) = config.workspace_dir {
452        ws.clone()
453    } else {
454        std::env::temp_dir()
455            .join("oxios-agent-workspace")
456            .join(agent_id.to_string())
457    };
458
459    // Ensure workspace exists.
460    let _ = std::fs::create_dir_all(&workspace);
461
462    tracing::debug!(workspace = %workspace.display(), "Agent workspace scoped");
463
464    // Start distributed trace span for this agent execution.
465    let _trace_guard = crate::observability::tracer().start(
466        format!("seed-{}", &seed_id.to_string()[..8]).as_str(),
467        oxi_sdk::SpanKind::Agent,
468    );
469
470    // ── Register tools based on CSpace (with access gate) ──
471    let registry = ToolRegistry::new();
472    let search_cache = Arc::new(SearchCache::new());
473
474    // Build agent context for security
475    let agent_context = AgentContext {
476        agent_id,
477        agent_name: format!("agent-{agent_id}"),
478        cspace: Arc::new(cspace.clone()),
479    };
480
481    // Build audit sink: TrailAuditSink (Merkle chain + JSONL) when audit_trail
482    // is available, otherwise fall back to TracingAuditSink.
483    let audit_sink: Arc<dyn crate::access_manager::AuditSink> = if let Some(trail) = audit_trail {
484        let audit_path = kernel_handle
485            .state
486            .workspace_path()
487            .join("audit")
488            .join("access.jsonl");
489        Arc::new(TrailAuditSink::new(trail, audit_path))
490    } else {
491        Arc::new(TracingAuditSink)
492    };
493
494    // Build access gate from kernel's security infrastructure
495    let access_gate = Arc::new(AccessGate::new(
496        kernel_handle.exec.access_manager().clone(),
497        Arc::new(kernel_handle.exec.config_snapshot()),
498        audit_sink,
499    ));
500
501    register_tools_from_cspace_gated(
502        &registry,
503        &kernel_handle,
504        &cspace,
505        search_cache,
506        agent_id,
507        access_gate,
508        agent_context,
509    );
510
511    tracing::info!(
512        seed_id = %seed_id,
513        capabilities = cspace.len(),
514        "Tools registered from CSpace"
515    );
516
517    // ── Build AgentConfig ──
518    //
519    // RFC-014 Phase D: `system_prompt` is also passed to the new
520    // `AgentBuilder::system_prompt()` (which overrides the value embedded
521    // in `AgentConfig` at build time). We clone here so the builder path
522    // can consume the value while the legacy `Agent::new_with_resolver`
523    // path still sees it in the config.
524    let agent_config = AgentConfig {
525        name: format!("agent-{agent_id}"),
526        description: None,
527        model_id: config.model_id.clone(),
528        system_prompt: Some(system_prompt.clone()),
529        max_iterations: config.max_iterations,
530        timeout_seconds: 300,
531        temperature: Some(0.7),
532        max_tokens: Some(8192),
533        compaction_strategy: CompactionStrategy::Threshold(0.8),
534        compaction_instruction: None,
535        context_window: 128_000,
536        api_key: config.api_key.clone(),
537        workspace_dir: config.project_paths.first().cloned(),
538        output_mode: None,
539        provider_options: config.provider_options.clone(),
540    };
541
542    // ── Build Agent (RFC-014 Phase D) ──
543    //
544    // Two paths:
545    //   1. `provider_rpm == 0` (common): use oxi-sdk 0.26.2's new
546    //      `AgentBuilder` API. The builder unifies model resolution, provider
547    //      creation, and (optionally) middleware wiring. Engine-level
548    //      `authorizer` / `tracer` / `cost_tracker` are propagated through
549    //      the new builder methods.
550    //   2. `provider_rpm > 0` (rare): keep the legacy
551    //      `Agent::new_with_resolver` + `set_hooks` path because the
552    //      AgentBuilder does not expose a way to inject a pre-built
553    //      `ProviderPool` for rate-limited access. This is a deliberate
554    //      scope-limit per RFC-014/phase-d-agentbuilder.md §2 "Provider
555    //      선택 로직은 보존".
556    let agent = if config.provider_rpm > 0 {
557        // ── Legacy path: rate-limited provider pool ──
558        let resolver: Arc<dyn ProviderResolver> = Arc::new(engine.oxi().clone());
559        let provider_name = engine.resolve_model(&config.model_id)?.provider;
560        let provider = engine.pooled_provider(&provider_name, config.provider_rpm)?;
561
562        // Build middleware pipeline.
563        let mut pipeline = oxi_sdk::MiddlewarePipeline::new();
564        if config.rate_limit_per_minute > 0 {
565            pipeline = pipeline.push(oxi_sdk::middleware::builtins::RateLimitMiddleware::new(
566                config.rate_limit_per_minute,
567            ));
568        }
569        if config.token_budget > 0 {
570            pipeline = pipeline.push(oxi_sdk::middleware::builtins::TokenBudgetMiddleware::new(
571                config.token_budget,
572            ));
573        }
574        if config.audit_tool_calls {
575            pipeline = pipeline.push(oxi_sdk::middleware::builtins::LoggingMiddleware::new(
576                tracing::Level::INFO,
577            ));
578        }
579
580        // Create Agent with CSpace tool registry and provider resolver.
581        let agent = Arc::new(Agent::new_with_resolver(
582            provider,
583            agent_config,
584            Arc::new(registry),
585            resolver,
586        ));
587
588        // Wire middleware pipeline → AgentHooks.
589        if !pipeline.is_empty() {
590            let terminate_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
591            let agent_id_for_hooks = agent_id.to_string();
592            let hooks = oxi_sdk::middleware::build_hooks(
593                Arc::new(pipeline),
594                agent_id_for_hooks,
595                terminate_flag,
596            );
597            agent.set_hooks(hooks);
598        }
599
600        agent
601    } else {
602        // ── New path: AgentBuilder (RFC-014 Phase D) ──
603        let mut builder = engine
604            .oxi()
605            .agent(agent_config)
606            .workspace(&workspace)
607            .system_prompt(system_prompt);
608
609        // CSpace-based tool registration is oxios-specific and is preserved.
610        //
611        // The builder's `.tool()` method takes `impl AgentTool + 'static`
612        // (a concrete value), but oxios' CSpace tools are `Arc<dyn AgentTool>`.
613        // The SDK does not expose a way to inject a pre-built `ToolRegistry`
614        // into the builder, so we register them on the agent's tool registry
615        // after `build()` returns. This keeps CSpace semantics intact.
616        //
617        // We capture the tool names now and apply them once the agent exists.
618        let cspace_tool_arcs: Vec<Arc<dyn oxi_sdk::AgentTool>> = registry
619            .names()
620            .into_iter()
621            .filter_map(|name| registry.get(&name))
622            .collect();
623
624        // Engine-level observability/security → AgentBuilder (new API).
625        if let Some(auth) = engine.authorizer() {
626            builder = builder.authorizer(auth.clone());
627        }
628        if let Some(tracer) = engine.tracer() {
629            builder = builder.tracer(tracer.clone());
630        }
631        if let Some(ct) = engine.cost_tracker() {
632            builder = builder.cost_tracker(ct.clone());
633        }
634
635        // Middleware: AgentBuilder convenience helpers replace the manual
636        // `MiddlewarePipeline` + `build_hooks()` + `set_hooks()` triple.
637        if config.rate_limit_per_minute > 0 {
638            builder = builder.with_rate_limit(config.rate_limit_per_minute);
639        }
640        if config.token_budget > 0 {
641            builder = builder.with_token_budget(config.token_budget);
642        }
643        if config.audit_tool_calls {
644            builder = builder.with_logging();
645        }
646
647        let built = builder.build()?;
648        let agent = Arc::new(built);
649
650        // Attach CSpace tools to the agent's tool registry.
651        // `Agent::tools()` returns the same `Arc<ToolRegistry>` that
652        // `AgentBuilder` populated, so `register_arc` is the canonical
653        // extension point for `Arc<dyn AgentTool>` values.
654        let agent_tools = agent.tools();
655        for tool in cspace_tool_arcs {
656            agent_tools.register_arc(tool);
657        }
658
659        agent
660    };
661
662    // Shared mutable state for the event callback.
663    let exec_state = Arc::new(Mutex::new(ExecuteState::default()));
664    let exec_state_cb = Arc::clone(&exec_state);
665    let memory_for_callback: Arc<MemoryManager> = (*kernel_handle.agents.memory_manager()).clone();
666    let session_id_for_callback = seed_id.to_string();
667    let model_id_for_callback = config.model_id.clone();
668    let agent_id_for_callback = agent_id.to_string();
669    let routing_stats_for_cb = routing_stats.clone();
670    // RFC-015: real-time event publishing for chat transparency.
671    // Falls back to None when the caller did not opt in.
672    let transparency_session: Option<String> = session_id.clone();
673    let kernel_handle_for_cb: Arc<KernelHandle> = Arc::clone(&kernel_handle);
674
675    // Run the agent with streaming events.
676    let result = agent
677        .run_streaming(prompt, move |event| {
678            let mut s = exec_state_cb.lock();
679            match event {
680                AgentEvent::ToolExecutionStart {
681                    tool_name,
682                    tool_call_id,
683                    ..
684                } => {
685                    // Record start time and push a placeholder step.
686                    let idx = s.trajectory_steps.len();
687                    s.pending_tools
688                        .insert(tool_call_id.clone(), (std::time::Instant::now(), idx));
689                    s.trajectory_steps
690                        .push(oxios_memory::memory::sona::TrajectoryStep {
691                            input: tool_name.clone(),
692                            output: String::new(),
693                            duration_ms: 0,
694                            confidence: 0.0,
695                        });
696                    // RFC-015: broadcast tool start so Web UI can show progress.
697                    if let Some(ref sid) = transparency_session {
698                        let _ =
699                            kernel_handle_for_cb
700                                .infra
701                                .publish(KernelEvent::ToolExecutionStarted {
702                                    session_id: sid.clone(),
703                                    tool_name: tool_name.clone(),
704                                    tool_call_id: tool_call_id.clone(),
705                                    tool_args: serde_json::Value::Null,
706                                });
707                    }
708                }
709                AgentEvent::ToolExecutionUpdate {
710                    tool_call_id,
711                    tool_name,
712                    partial_result,
713                    tab_id,
714                    context,
715                } => {
716                    // RFC-015: forward real-time progress to the event bus
717                    // so the Web UI can show a spinner and progress text
718                    // while the tool is still executing. Best-effort —
719                    // publish failures (e.g. lagged subscribers) are ignored.
720                    //
721                    // `tab_id` and `context` come from oxi-agent 0.29+
722                    // (ToolCallContext: PageVisit, WebSearch, etc.).
723                    // Older agent versions won't send these — they default
724                    // to None and the UI gracefully ignores them.
725                    if let Some(ref sid) = transparency_session {
726                        let context_json = context
727                            .as_ref()
728                            .map(serde_json::to_value)
729                            .transpose()
730                            .unwrap_or(None);
731                        let _ = kernel_handle_for_cb.infra.publish(
732                            KernelEvent::ToolExecutionProgress {
733                                session_id: sid.clone(),
734                                tool_call_id: tool_call_id.clone(),
735                                tool_name: tool_name.clone(),
736                                progress: partial_result,
737                                tab_id,
738                                context: context_json,
739                            },
740                        );
741                    }
742                }
743                AgentEvent::ToolExecutionEnd {
744                    tool_name,
745                    tool_call_id,
746                    is_error,
747                    result,
748                    ..
749                } => {
750                    if !is_error {
751                        s.steps_completed += 1;
752                    }
753                    // Look up the exact step by tool_call_id.
754                    let mut duration_ms: u64 = 0;
755                    let mut summary = String::new();
756                    if let Some((start, idx)) = s.pending_tools.remove(tool_call_id.as_str()) {
757                        duration_ms = start.elapsed().as_millis() as u64;
758                        if let Some(step) = s.trajectory_steps.get_mut(idx) {
759                            summary = summarize_tool_result(&result.content, 200);
760                            step.output = summary.clone();
761                            step.duration_ms = duration_ms;
762                            step.confidence = if is_error { 0.3 } else { 0.8 };
763                        }
764                    }
765                    // RFC-015: broadcast tool completion.
766                    if let Some(ref sid) = transparency_session {
767                        let _ = kernel_handle_for_cb.infra.publish(
768                            KernelEvent::ToolExecutionFinished {
769                                session_id: sid.clone(),
770                                tool_call_id: tool_call_id.clone(),
771                                tool_name: tool_name.clone(),
772                                duration_ms,
773                                is_error,
774                                output_summary: summary,
775                            },
776                        );
777                    }
778                }
779                AgentEvent::AgentEnd {
780                    messages,
781                    stop_reason,
782                    ..
783                } => {
784                    if let Some(oxi_sdk::Message::Assistant(a)) = messages.last() {
785                        s.final_content = a.text_content();
786                    }
787                    s.success = stop_reason.as_deref() == Some("Stop");
788                }
789                AgentEvent::Error { message, .. } => {
790                    s.final_content = message.clone();
791                    s.success = false;
792                }
793                AgentEvent::Usage {
794                    input_tokens,
795                    output_tokens,
796                } => {
797                    // Record token usage to cost tracker (existing).
798                    let agent_label = format!("agent-{agent_id_for_callback}");
799                    crate::observability::cost_tracker().record(
800                        &agent_label,
801                        &oxi_sdk::Model::new(
802                            &model_id_for_callback,
803                            &model_id_for_callback,
804                            oxi_sdk::Api::OpenAiCompletions,
805                            "unknown",
806                            "https://unknown.com",
807                        ),
808                        oxi_sdk::TokenUsage {
809                            input: input_tokens as u64,
810                            output: output_tokens as u64,
811                            cache_read: 0,
812                            cache_write: 0,
813                        },
814                    );
815
816                    // Record to routing stats (RFC-011).
817                    if let Some(stats) = &routing_stats_for_cb {
818                        let cost = crate::kernel_handle::engine_api::estimate_cost(
819                            &model_id_for_callback,
820                            input_tokens as u64,
821                            output_tokens as u64,
822                        );
823                        stats.record_model_usage(&model_id_for_callback, cost);
824                    }
825                    // RFC-015: publish cumulative token usage.
826                    if let Some(ref sid) = transparency_session {
827                        let _ = kernel_handle_for_cb
828                            .infra
829                            .publish(KernelEvent::TokenUsageUpdate {
830                                session_id: sid.clone(),
831                                input_tokens: input_tokens as u64,
832                                output_tokens: output_tokens as u64,
833                            });
834                    }
835                }
836                AgentEvent::Compaction {
837                    event: CompactionEvent::Completed { result, .. },
838                } => {
839                    handle_compaction(
840                        result.summary.clone(),
841                        session_id_for_callback.clone(),
842                        memory_for_callback.clone(),
843                    );
844                    // RFC-015: compaction is a form of reasoning — expose it.
845                    if let Some(ref sid) = transparency_session {
846                        let _ =
847                            kernel_handle_for_cb
848                                .infra
849                                .publish(KernelEvent::ReasoningFragment {
850                                    session_id: sid.clone(),
851                                    content: result.summary.clone(),
852                                    source: "compaction".to_string(),
853                                });
854                    }
855                }
856                _ => {}
857            }
858        })
859        .await;
860
861    // Record circuit breaker result after agent execution.
862    let circuit = get_llm_circuit_breaker();
863    if result.is_err() {
864        circuit.record_failure();
865        crate::metrics::get_metrics()
866            .llm_circuit_breaker_state
867            .set(1.0);
868    } else {
869        circuit.record_success();
870        crate::metrics::get_metrics()
871            .llm_circuit_breaker_state
872            .set(0.0);
873    }
874
875    if let Err(e) = result {
876        tracing::error!(seed_id = %seed_id, error = %e, "Agent failed");
877        let s = exec_state.lock();
878        return Ok((
879            format!("Agent failed: {e}"),
880            s.steps_completed,
881            false,
882            s.trajectory_steps.clone(),
883            agent,
884        ));
885    }
886
887    let s = exec_state.lock();
888    tracing::info!(
889        seed_id = %seed_id,
890        steps = s.steps_completed,
891        success = s.success,
892        "Agent completed"
893    );
894
895    // Record trajectory to SONA learning engine (RFC-020 Phase 2).
896    // Fire-and-forget: don't block the result on learning.
897    if !s.trajectory_steps.is_empty() {
898        if let Some(sona) = kernel_handle.agents.memory_manager().sona_engine() {
899            let steps = s.trajectory_steps.clone();
900            let success = s.success;
901            let sona = Arc::clone(sona);
902            let domain = infer_domain(&seed_goal);
903            tokio::spawn(async move {
904                let verdict = if success {
905                    oxios_memory::memory::sona::Verdict::Success
906                } else {
907                    oxios_memory::memory::sona::Verdict::Failure
908                };
909                let trajectory =
910                    oxios_memory::memory::sona::Trajectory::new(steps, verdict, &domain);
911                if let Err(e) = sona.record(trajectory).await {
912                    tracing::debug!(error = %e, "SONA trajectory recording failed (non-fatal)");
913                }
914            });
915        }
916    }
917
918    Ok((
919        s.final_content.clone(),
920        s.steps_completed,
921        s.success,
922        s.trajectory_steps.clone(),
923        agent,
924    ))
925}
926
927/// Summarize a tool result string to fit within `max_len` characters.
928///
929/// Uses char-aware truncation to avoid panicking on multi-byte UTF-8
930/// (e.g., Korean, CJK, emoji).
931fn summarize_tool_result(result: &str, max_len: usize) -> String {
932    let trimmed = result.trim();
933    if trimmed.chars().count() <= max_len {
934        return trimmed.to_string();
935    }
936    // Take the first line or truncate.
937    let first_line = trimmed.lines().next().unwrap_or("");
938    if first_line.chars().count() <= max_len {
939        first_line.to_string()
940    } else {
941        let truncated: String = first_line.chars().take(max_len - 3).collect();
942        format!("{truncated}...")
943    }
944}
945
946/// Infer a domain category from a seed goal for SONA trajectory grouping.
947///
948/// Extracts the core verb + object from the goal to create a meaningful
949/// domain label. Falls back to "general" for unrecognizable patterns.
950fn infer_domain(goal: &str) -> String {
951    let lower = goal.to_lowercase();
952    let keywords: Vec<&str> = lower.split_whitespace().take(8).collect();
953
954    // Check for known domain indicators.
955    if keywords.iter().any(|k| {
956        [
957            "test",
958            "tests",
959            "spec",
960            "testing",
961            "assert",
962            "unit test",
963            "integration",
964        ]
965        .contains(k)
966    }) {
967        return "testing".to_string();
968    }
969    if keywords
970        .iter()
971        .any(|k| ["deploy", "release", "publish", "ship"].contains(k))
972    {
973        return "deployment".to_string();
974    }
975    if keywords
976        .iter()
977        .any(|k| ["fix", "bug", "patch", "repair", "debug"].contains(k))
978    {
979        return "bugfix".to_string();
980    }
981    if keywords
982        .iter()
983        .any(|k| ["refactor", "restructure", "reorganize", "rewrite"].contains(k))
984    {
985        return "refactoring".to_string();
986    }
987    if keywords
988        .iter()
989        .any(|k| ["doc", "document", "readme", "guide", "explain"].contains(k))
990    {
991        return "documentation".to_string();
992    }
993    if keywords
994        .iter()
995        .any(|k| ["build", "create", "implement", "add", "make", "new"].contains(k))
996    {
997        return "development".to_string();
998    }
999    if keywords
1000        .iter()
1001        .any(|k| ["analyze", "review", "audit", "inspect", "check"].contains(k))
1002    {
1003        return "analysis".to_string();
1004    }
1005    if keywords
1006        .iter()
1007        .any(|k| ["config", "setup", "install", "configure", "init"].contains(k))
1008    {
1009        return "configuration".to_string();
1010    }
1011
1012    // Fallback: first 2 meaningful words
1013    let meaningful: Vec<&str> = lower
1014        .split_whitespace()
1015        .filter(|w| w.len() > 2)
1016        .take(2)
1017        .collect();
1018    if meaningful.len() >= 2 {
1019        meaningful.join("_")
1020    } else {
1021        "general".to_string()
1022    }
1023}
1024
1025/// Handle compaction completion by storing the summary as a Warm memory.
1026///
1027/// Extracts the compaction summary from the event and spawns a background
1028/// task to persist it via MemoryManager. This replaces the inline 30-line
1029/// block that was previously in the event callback.
1030fn handle_compaction(summary: String, session_id: String, memory_manager: Arc<MemoryManager>) {
1031    let entry = MemoryEntry {
1032        id: uuid::Uuid::new_v4().to_string(),
1033        memory_type: MemoryType::Conversation,
1034        tier: crate::memory::MemoryTier::Warm,
1035        content: summary,
1036        content_hash: 0,
1037        source: "compaction".to_string(),
1038        session_id: Some(session_id),
1039        tags: vec![],
1040        importance: 0.5,
1041        pinned: false,
1042        protection: crate::memory::ProtectionLevel::None,
1043        auto_classified: false,
1044        session_appearances: 0,
1045        user_corrected: false,
1046        seen_in_sessions: vec![],
1047        created_at: chrono::Utc::now(),
1048        accessed_at: chrono::Utc::now(),
1049        modified_at: chrono::Utc::now(),
1050        access_count: 0,
1051        decay_score: 1.0,
1052        compaction_level: 0,
1053        compacted_from: vec![],
1054        related_ids: vec![],
1055        contradicts: None,
1056    };
1057    tokio::spawn(async move {
1058        if let Err(e) = memory_manager.remember(entry).await {
1059            tracing::warn!(error = %e, "Failed to save compaction summary");
1060        }
1061    });
1062}
1063
1064/// Build a system prompt from the Seed's goal, constraints, persona,
1065/// and optionally a capability index and kernel manifest.
1066///
1067/// Note: SKILL.md content is no longer injected here. Capabilities are
1068/// surfaced through the CSpace tool set + semantic retrieval instead.
1069fn build_system_prompt(
1070    seed: &Seed,
1071    persona_prompt: Option<&str>,
1072    capabilities_xml: Option<&str>,
1073    kernel_manifest: Option<&str>,
1074) -> String {
1075    let mut prompt = format!(
1076        "You are an autonomous agent in the Oxios operating system.\n\
1077         You execute Seeds — immutable specifications with goals, constraints, and\n\
1078         acceptance criteria. You have tools for reading, writing, editing files,\n\
1079         running commands, and accessing kernel services.\n\n\
1080         ## Goal\n\
1081         {}\n",
1082        seed.goal,
1083    );
1084
1085    // Preserve user's original wording so the agent sees exact language,
1086    // filenames, and nuances that may have been abstracted in the goal.
1087    if !seed.original_request.is_empty() && seed.original_request != seed.goal {
1088        prompt.push_str(&format!(
1089            "\n## User's Original Request\n{}\n",
1090            seed.original_request
1091        ));
1092    }
1093
1094    if !seed.constraints.is_empty() {
1095        prompt.push_str("\n## Constraints\n");
1096        for (i, c) in seed.constraints.iter().enumerate() {
1097            prompt.push_str(&format!("{}. {}\n", i + 1, c));
1098        }
1099    }
1100
1101    if !seed.acceptance_criteria.is_empty() {
1102        prompt.push_str("\n## Acceptance Criteria\n");
1103        for (i, c) in seed.acceptance_criteria.iter().enumerate() {
1104            prompt.push_str(&format!("{}. {}\n", i + 1, c));
1105        }
1106    }
1107
1108    if !seed.ontology.is_empty() {
1109        prompt.push_str("\n## Domain Entities\n");
1110        for e in &seed.ontology {
1111            prompt.push_str(&format!(
1112                "- **{}** ({}): {}\n",
1113                e.name, e.entity_type, e.description
1114            ));
1115        }
1116    }
1117
1118    // Inject persona system prompt
1119    if let Some(pp) = persona_prompt {
1120        prompt.push_str("\n## Persona\n");
1121        prompt.push_str(pp);
1122        prompt.push('\n');
1123    }
1124
1125    // Inject semantic capability index (from ToolRetriever)
1126    if let Some(xml) = capabilities_xml {
1127        prompt.push_str("\n## Available Capabilities\n");
1128        prompt.push_str("The following capabilities are relevant to your goal. ");
1129        prompt.push_str("Use the `read` tool to load SKILL.md for any program.\n\n");
1130        prompt.push_str(xml);
1131        prompt.push('\n');
1132    }
1133
1134    // Inject kernel manifest (from CSpace)
1135    if let Some(manifest) = kernel_manifest {
1136        prompt.push('\n');
1137        prompt.push_str(manifest);
1138        prompt.push('\n');
1139    }
1140
1141    // Execution environment guidance
1142    prompt.push_str(
1143        "\n## Execution Protocol\n\
1144         1. UNDERSTAND — Read the Seed completely before acting.\n\
1145         2. PLAN — Determine the minimal set of actions needed.\n\
1146         3. EXECUTE — Use tools to accomplish the goal. Prefer the simplest approach.\n\
1147         4. VERIFY — After each action, check the result: created a file? read it back.\n\
1148         5. REPORT — Summarize how each acceptance criterion was met, with evidence.\n\n\
1149         ## Hard Boundaries\n\
1150         - NEVER modify files outside the workspace scope\n\
1151         - NEVER execute destructive commands without confirming scope\n\
1152         - NEVER claim completion without evidence — show the output, not your opinion\n\
1153         - NEVER add features or improvements beyond the Seed scope\n\
1154         - If you cannot complete the Seed, say so and explain WHY\n\n\
1155         ## Scope Guard\n\
1156         The Seed defines your universe. Do not:\n\
1157         - Refactor code the Seed didn't mention\n\
1158         - Add tests the Seed didn't require\n\
1159         - Change configuration the Seed didn't specify\n\
1160         - \"Improve\" anything beyond what the acceptance criteria demand\n\n\
1161         ## Error Handling\n\
1162         - If a tool fails, read the error message carefully before retrying\n\
1163         - If a command fails, do NOT immediately retry with --force or sudo\n\
1164         - If stuck after 3 attempts, report the blocker rather than continuing to fail\n\n\
1165         ## Shape Matching\n\
1166         Match your output to the task: simple task → concise response.\n\
1167         Do not write 50 lines when 5 would do.\n\
1168         Use `exec` for all command execution (git, gh, osascript, etc.).",
1169    );
1170
1171    prompt
1172}
1173
1174/// Build the user prompt from the seed.
1175fn build_user_prompt(seed: &Seed) -> String {
1176    format!(
1177        "Execute the following goal:\n\n{}\n\nAcceptance criteria:\n{}",
1178        seed.goal,
1179        seed.acceptance_criteria
1180            .iter()
1181            .enumerate()
1182            .map(|(i, c)| format!("{}. {}", i + 1, c))
1183            .collect::<Vec<_>>()
1184            .join("\n")
1185    )
1186}
1187
1188impl std::fmt::Debug for AgentRuntime {
1189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1190        f.debug_struct("AgentRuntime")
1191            .field("model_id", &self.config.model_id)
1192            .finish()
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use async_trait::async_trait;
1200    use oxi_sdk::{AgentTool, ToolContext, ToolError};
1201    use oxios_ouroboros::Entity;
1202    use serde_json::Value;
1203
1204    /// A test tool that does nothing — used to populate the registry.
1205    struct DummyTool {
1206        name: String,
1207    }
1208
1209    #[async_trait]
1210    impl AgentTool for DummyTool {
1211        fn name(&self) -> &str {
1212            &self.name
1213        }
1214        fn label(&self) -> &str {
1215            &self.name
1216        }
1217        fn description(&self) -> &str {
1218            "Test tool"
1219        }
1220        fn parameters_schema(&self) -> Value {
1221            serde_json::json!({"type": "object"})
1222        }
1223
1224        async fn execute(
1225            &self,
1226            _tool_call_id: &str,
1227            _params: Value,
1228            _shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
1229            _ctx: &ToolContext,
1230        ) -> Result<oxi_sdk::AgentToolResult, ToolError> {
1231            Ok(oxi_sdk::AgentToolResult::success("ok"))
1232        }
1233    }
1234
1235    /// Test that requires_tools validation passes when all tools are present.
1236    #[test]
1237    fn test_requires_tools_validation_passes() {
1238        let registry = ToolRegistry::new();
1239
1240        registry.register(DummyTool {
1241            name: "read".into(),
1242        });
1243        registry.register(DummyTool {
1244            name: "exec".into(),
1245        });
1246
1247        let missing = registry.missing(&["read", "exec"]);
1248
1249        assert!(
1250            missing.is_empty(),
1251            "Expected no missing tools, got: {:?}",
1252            missing
1253        );
1254    }
1255
1256    /// Test that requires_tools validation fails when a tool is missing.
1257    #[test]
1258    fn test_requires_tools_validation_fails() {
1259        let registry = ToolRegistry::new();
1260
1261        registry.register(DummyTool {
1262            name: "read".into(),
1263        });
1264
1265        let missing = registry.missing(&["read", "exec", "nonexistent"]);
1266
1267        assert_eq!(missing, vec!["exec", "nonexistent"]);
1268    }
1269
1270    #[test]
1271    fn test_build_system_prompt_includes_goal() {
1272        let seed = Seed {
1273            id: uuid::Uuid::new_v4(),
1274            goal: "Build a web server".into(),
1275            constraints: vec!["Must use Rust".into()],
1276            acceptance_criteria: vec!["Server responds to requests".into()],
1277            ontology: vec![Entity {
1278                name: "HttpServer".into(),
1279                entity_type: "struct".into(),
1280                description: "The main server struct".into(),
1281            }],
1282            created_at: chrono::Utc::now(),
1283            generation: 0,
1284            parent_seed_id: None,
1285            cspace_hint: None,
1286            original_request: String::new(),
1287            output_schema: None,
1288        };
1289
1290        let prompt = build_system_prompt(&seed, None, None, None);
1291
1292        assert!(prompt.contains("Build a web server"));
1293        assert!(prompt.contains("Must use Rust"));
1294        assert!(prompt.contains("Server responds to requests"));
1295        assert!(prompt.contains("HttpServer"));
1296        assert!(prompt.contains("struct"));
1297    }
1298
1299    #[test]
1300    fn test_build_system_prompt_empty() {
1301        let seed = Seed {
1302            id: uuid::Uuid::new_v4(),
1303            goal: "Test goal".into(),
1304            constraints: vec![],
1305            acceptance_criteria: vec![],
1306            ontology: vec![],
1307            created_at: chrono::Utc::now(),
1308            generation: 0,
1309            parent_seed_id: None,
1310            cspace_hint: None,
1311            original_request: String::new(),
1312            output_schema: None,
1313        };
1314
1315        let prompt = build_system_prompt(&seed, None, None, None);
1316
1317        assert!(prompt.contains("Test goal"));
1318    }
1319
1320    #[test]
1321    fn test_infer_domain_testing() {
1322        assert_eq!(infer_domain("run all unit tests for the kernel"), "testing");
1323    }
1324
1325    #[test]
1326    fn test_infer_domain_deployment() {
1327        assert_eq!(
1328            infer_domain("deploy the web service to production"),
1329            "deployment"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_infer_domain_bugfix() {
1335        assert_eq!(infer_domain("fix the null pointer error in main"), "bugfix");
1336    }
1337
1338    #[test]
1339    fn test_infer_domain_development() {
1340        assert_eq!(
1341            infer_domain("create a new REST API endpoint"),
1342            "development"
1343        );
1344    }
1345
1346    #[test]
1347    fn test_infer_domain_analysis() {
1348        assert_eq!(
1349            infer_domain("review the code for security issues"),
1350            "analysis"
1351        );
1352    }
1353
1354    #[test]
1355    fn test_infer_domain_fallback() {
1356        let domain = infer_domain("optimize performance metrics");
1357        // Should fall back to first 2 meaningful words
1358        assert!(!domain.is_empty());
1359    }
1360}