Skip to main content

oxios_kernel/
orchestrator.rs

1//! Orchestrator: coordinates the Ouroboros lifecycle for user messages.
2//!
3//! The orchestrator is the "brain" that runs the Ouroboros protocol.
4//! Given a user message:
5//! 1. Conduct the interview (ask clarifying questions if needed)
6//! 2. Generate a seed (via LLM for complex tasks, or ad-hoc for simple tasks)
7//! 3. Execute the agent via the supervisor
8//! 4. Return the result to the user
9//!
10//! The orchestrator does NOT know about channels or HTTP — it only
11//! coordinates Ouroboros + Supervisor + EventBus + StateStore + Scheduler + AccessManager.
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use anyhow::{Context, Result};
17use chrono;
18use oxios_ouroboros::{
19    EvaluationResult, ExecutionResult, InterviewResult, OuroborosProtocol, Phase, Seed,
20};
21use parking_lot::RwLock;
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::agent_lifecycle::AgentLifecycleManager;
26use crate::event_bus::{EventBus, KernelEvent};
27use crate::git_layer::GitLayer;
28use crate::metrics::get_metrics;
29use crate::mount::{MountId, MountManager};
30use crate::project::{ConversationBuffer, ProjectManager};
31use crate::scheduler::Priority;
32use crate::state_store::StateStore;
33use crate::types::AgentId;
34
35/// Role of an agent within a group.
36#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
37pub enum AgentRole {
38    /// Executes a specific subtask.
39    #[default]
40    Worker,
41    /// Coordinates subtasks, synthesizes results.
42    Manager,
43}
44
45/// A subtask within a multi-agent plan.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SubTask {
48    /// Unique subtask ID.
49    pub id: Uuid,
50    /// Human-readable description.
51    pub description: String,
52    /// Capability required (e.g., "code-review", "testing").
53    pub required_capability: Option<String>,
54    /// Result of the subtask (filled after execution).
55    pub result: Option<String>,
56    /// Whether this subtask succeeded.
57    pub success: bool,
58    /// Role of the agent assigned to this subtask.
59    #[serde(default)]
60    pub role: AgentRole,
61}
62
63impl SubTask {
64    /// Create a new subtask with the given description.
65    pub fn new(description: impl Into<String>) -> Self {
66        Self {
67            id: Uuid::new_v4(),
68            description: description.into(),
69            required_capability: None,
70            result: None,
71            success: false,
72            role: AgentRole::default(),
73        }
74    }
75
76    /// Set the required capability for this subtask.
77    pub fn with_capability(mut self, cap: impl Into<String>) -> Self {
78        self.required_capability = Some(cap.into());
79        self
80    }
81}
82
83/// The orchestrator coordinates the full Ouroboros lifecycle.
84pub struct Orchestrator {
85    ouroboros: Arc<dyn OuroborosProtocol>,
86    event_bus: EventBus,
87    state_store: Arc<StateStore>,
88    /// Git version control layer for auto-commits.
89    git_layer: Option<Arc<GitLayer>>,
90    /// Active interview sessions, keyed by session ID.
91    sessions: RwLock<std::collections::HashMap<String, InterviewSession>>,
92    /// Agent lifecycle manager (fork, register, run, cleanup).
93    lifecycle: AgentLifecycleManager,
94    /// A2A protocol for inter-agent task delegation.
95    a2a: Option<Arc<crate::a2a::A2AProtocol>>,
96    /// Project manager for context partitioning.
97    project_manager: RwLock<Option<Arc<ProjectManager>>>,
98    /// Mount manager for path-alias context (RFC-025).
99    mount_manager: RwLock<Option<Arc<MountManager>>>,
100    /// Conversation buffer for topic shift detection.
101    conversation_buffer: RwLock<ConversationBuffer>,
102    /// Orchestrator configuration (Ouroboros protocol settings).
103    delegation_config: DelegationConfig,
104    /// A2A circuit breaker for delegation reliability.
105    a2a_breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>,
106    /// Evolution loop settings.
107    evolution_config: RwLock<EvolutionConfig>,
108}
109
110/// Configuration for A2A delegation retries.
111#[derive(Debug, Clone)]
112struct DelegationConfig {
113    /// Maximum retry attempts for A2A delegation.
114    max_retries: u32,
115    /// Base delay for exponential backoff (milliseconds).
116    base_delay_ms: u64,
117    /// Maximum delay cap for exponential backoff (milliseconds).
118    max_delay_ms: u64,
119    /// Timeout per delegation attempt (milliseconds).
120    #[allow(dead_code)]
121    timeout_ms: u64,
122}
123
124impl Default for DelegationConfig {
125    fn default() -> Self {
126        Self {
127            max_retries: 3,
128            base_delay_ms: 100,
129            max_delay_ms: 5000,
130            timeout_ms: 5000,
131        }
132    }
133}
134
135impl DelegationConfig {
136    /// Calculate exponential backoff delay.
137    fn backoff_delay(&self, attempt: u32) -> u64 {
138        let delay = self.base_delay_ms * 2_u64.saturating_pow(attempt.min(10));
139        delay.min(self.max_delay_ms)
140    }
141}
142
143/// Evolution loop settings extracted from OrchestratorConfig.
144#[derive(Debug, Clone)]
145struct EvolutionConfig {
146    /// Maximum evolution iterations (0 = evaluate only).
147    max_iterations: u32,
148    /// Minimum score to pass evaluation.
149    score_threshold: f64,
150    /// Enable evaluation result caching.
151    #[allow(dead_code)]
152    eval_cache_enabled: bool,
153}
154
155impl From<crate::config::OrchestratorConfig> for EvolutionConfig {
156    fn from(c: crate::config::OrchestratorConfig) -> Self {
157        Self {
158            max_iterations: c.max_evolution_iterations,
159            score_threshold: c.min_evaluation_score,
160            eval_cache_enabled: c.eval_cache_enabled,
161        }
162    }
163}
164
165impl Orchestrator {
166    /// Creates a new orchestrator.
167    pub fn new(
168        ouroboros: Arc<dyn OuroborosProtocol>,
169        event_bus: EventBus,
170        state_store: Arc<StateStore>,
171        lifecycle: AgentLifecycleManager,
172    ) -> Self {
173        Self::with_config(
174            ouroboros,
175            event_bus,
176            state_store,
177            lifecycle,
178            crate::config::OrchestratorConfig::default(),
179        )
180    }
181
182    /// Creates a new orchestrator with custom config.
183    pub fn with_config(
184        ouroboros: Arc<dyn OuroborosProtocol>,
185        event_bus: EventBus,
186        state_store: Arc<StateStore>,
187        lifecycle: AgentLifecycleManager,
188        config: crate::config::OrchestratorConfig,
189    ) -> Self {
190        let evolution_config = EvolutionConfig::from(config.clone());
191        Self {
192            ouroboros,
193            event_bus,
194            state_store,
195            git_layer: None,
196            sessions: RwLock::new(std::collections::HashMap::new()),
197            lifecycle,
198            a2a: None,
199            project_manager: RwLock::new(None),
200            mount_manager: RwLock::new(None),
201            conversation_buffer: RwLock::new(ConversationBuffer::default()),
202            delegation_config: DelegationConfig::default(),
203            a2a_breaker: Arc::new(crate::a2a::circuit_breaker::A2ACircuitBreaker::new(5, 30)),
204            evolution_config: RwLock::new(evolution_config),
205        }
206    }
207
208    /// Set the ProjectManager for context partitioning.
209    pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
210        *self.project_manager.write() = Some(manager);
211    }
212
213    /// Set the MountManager for path-alias context (RFC-025).
214    pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
215        *self.mount_manager.write() = Some(manager);
216    }
217
218    /// Get a reference to the MountManager, if set (RFC-025).
219    pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
220        self.mount_manager.read().as_ref().cloned()
221    }
222
223    /// Get a reference to the ProjectManager, if set.
224    pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
225        self.project_manager.read().as_ref().cloned()
226    }
227
228    /// Detect a project from a message, returning tag string.
229    pub fn detect_project_tag(&self, message: &str) -> Option<String> {
230        self.project_manager.read().as_ref().and_then(|pm| {
231            let projects = pm.list_projects();
232            let result = crate::project::detect_project(message, &projects);
233            match result {
234                crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
235                crate::project::DetectionResult::NoMatch { .. } => None,
236            }
237        })
238    }
239
240    /// Resolve the active Mounts for a message (RFC-025).
241    ///
242    /// Parses explicit `mount_ids` ("uuid1,uuid2,...", primary first); when
243    /// none are given, auto-detects from the message. Returns:
244    /// - the ordered list of active [`MountId`]s,
245    /// - the rendered `## Workspace Context` body (without the header),
246    /// - all resolved filesystem paths (primary first),
247    /// - a display tag like `[🔧 oxios + oxi-sdk]`.
248    ///
249    /// Honors the sticky-primary model: when `mount_ids` is explicitly
250    /// provided they are used as-is (detection is skipped). Detection only
251    /// runs when `mount_ids` is `None`, seeding the primary slot — it never
252    /// replaces an explicit primary, only appends a secondary.
253    fn resolve_mount_workspace(
254        &self,
255        mount_ids: Option<&str>,
256        project_ids: Option<&str>,
257        user_message: &str,
258    ) -> (
259        Vec<MountId>,
260        Option<String>,
261        Vec<std::path::PathBuf>,
262        String,
263    ) {
264        use crate::mount::Mount;
265
266        let Some(mm) = self.mount_manager() else {
267            return (Vec::new(), None, Vec::new(), String::new());
268        };
269
270        // Parse explicit mount_ids; otherwise auto-detect (seeds the primary slot).
271        let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
272            ids_str
273                .split(',')
274                .filter_map(|s| MountId::parse_str(s.trim()).ok())
275                .collect()
276        } else {
277            match mm.detect(user_message) {
278                crate::mount::DetectionResult::Found(id) => vec![id],
279                crate::mount::DetectionResult::NoMatch { .. } => vec![],
280            }
281        };
282        // De-duplicate while preserving order (handles non-consecutive dups).
283        let mut seen = std::collections::HashSet::new();
284        ids.retain(|id| seen.insert(*id));
285
286        // ── Project-referenced Mount activation (RFC-025) ──
287        // When a project_id is provided, auto-activate its referenced Mounts
288        // BEFORE we derive mounts/tag/context/paths, so they are fully
289        // visible in the system prompt and the badge — not just granted
290        // path access. (Previously this ran after the prompt was built, so
291        // project-referenced Mounts were invisible in the context body.)
292        let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
293            project_ids
294            && let Some(first_id_str) = project_ids_str.split(',').next()
295            && let Some(pm) = self.project_manager()
296            && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
297        {
298            let proj = pm.get_project(pid);
299            if let Some(ref project) = proj {
300                for mid in &project.mount_ids {
301                    if !ids.contains(mid) {
302                        ids.push(*mid);
303                    }
304                }
305            }
306            proj
307        } else {
308            None
309        };
310
311        if ids.is_empty() {
312            return (Vec::new(), None, Vec::new(), String::new());
313        }
314
315        // Touch each active Mount (record activity) — now includes any
316        // Project-referenced Mounts activated above.
317        for id in &ids {
318            mm.touch(*id);
319        }
320
321        let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
322        if mounts.is_empty() {
323            return (Vec::new(), None, Vec::new(), String::new());
324        }
325
326        // Collect all paths (primary first, deduped) over the full Mount set.
327        let mut paths: Vec<std::path::PathBuf> = Vec::new();
328        for m in &mounts {
329            for p in &m.paths {
330                if !paths.contains(p) {
331                    paths.push(p.clone());
332                }
333            }
334        }
335
336        // Legacy fallback (RFC-025 migration window): a Project created
337        // before Mounts may carry explicit `paths` but no `mount_ids`. In
338        // that case grant path access directly so pre-RFC-025 Projects still
339        // resolve a CWD and populate `allowed_paths` (see agent_runtime.rs).
340        if let Some(project) = &project_for_instructions
341            && project.mount_ids.is_empty()
342            && !project.paths.is_empty()
343        {
344            for p in &project.paths {
345                if !paths.contains(p) {
346                    paths.push(p.clone());
347                }
348            }
349        }
350
351        // Display tag.
352        let tag = if mounts.len() == 1 {
353            mounts[0].tag()
354        } else {
355            let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
356            format!("[🔧 {}]", names.join(" + "))
357        };
358
359        let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
360
361        // ── Project instructions (RFC-025) ──
362        // Inject the project's instructions into the context body. The
363        // "### Active Mounts" header above is only present when there are
364        // actual mount entries in `context`; the Project Instructions section
365        // stands on its own when only instructions exist.
366        if let Some(project) = project_for_instructions {
367            // Cap instructions to stay within the prompt budget (~500 tokens).
368            let instructions = if project.instructions.len() > 2000 {
369                let mut end = 2000;
370                while end > 0 && !project.instructions.is_char_boundary(end) {
371                    end -= 1;
372                }
373                format!("{}...", &project.instructions[..end])
374            } else {
375                project.instructions.clone()
376            };
377            if !instructions.is_empty() {
378                context.push_str(&format!(
379                    "\n### Project Instructions: {}\n{}\n",
380                    project.name, instructions
381                ));
382            }
383        }
384
385        // Enforce a hard prompt budget on the final context body (~1500 tokens).
386        const MAX_CONTEXT_CHARS: usize = 6000;
387        if context.len() > MAX_CONTEXT_CHARS {
388            let mut end = MAX_CONTEXT_CHARS;
389            while end > 0 && !context.is_char_boundary(end) {
390                end -= 1;
391            }
392            context.truncate(end);
393            context.push_str("\n...(context truncated)...\n");
394        }
395
396        let context_opt = if context.is_empty() {
397            None
398        } else {
399            Some(context)
400        };
401        (ids, context_opt, paths, tag)
402    }
403
404    /// Set the A2A protocol for inter-agent task delegation.
405    pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
406        self.a2a = Some(a2a);
407    }
408
409    /// Set the GitLayer for auto-commits after state saves.
410    pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
411        self.git_layer = Some(git_layer);
412    }
413
414    /// Hot-reload evolution config without restart.
415    ///
416    /// Takes effect on the next orchestration run.
417    pub fn update_evolution_config(&self, config: crate::config::OrchestratorConfig) {
418        *self.evolution_config.write() = EvolutionConfig::from(config);
419        tracing::info!("Orchestrator evolution config hot-reloaded");
420    }
421
422    /// Restore sessions from persisted state.
423    ///
424    /// Loads sessions from the `StateStore` that have an `active_seed_id`
425    /// (meaning they are mid-orchestration) and repopulates the in-memory
426    /// interview session map so that follow-up messages can continue
427    /// the conversation.
428    pub async fn restore_sessions(&self) {
429        let summaries = match self.state_store.list_sessions().await {
430            Ok(s) => s,
431            Err(e) => {
432                tracing::warn!(error = %e, "Failed to list sessions for restore");
433                return;
434            }
435        };
436
437        let mut restored = 0usize;
438        for summary in &summaries {
439            // Only restore sessions that are mid-orchestration (have an active seed).
440            let Some(ref seed_id_str) = summary.active_seed_id else {
441                continue;
442            };
443
444            let session_id = crate::state_store::SessionId(summary.id.clone());
445            let session = match self.state_store.load_session(&session_id).await {
446                Ok(Some(s)) => s,
447                Ok(None) => continue,
448                Err(e) => {
449                    tracing::warn!(
450                        session_id = %summary.id,
451                        error = %e,
452                        "Failed to load session for restore"
453                    );
454                    continue;
455                }
456            };
457
458            // Reconstruct an InterviewSession from the persisted data.
459            // The interview result is rebuilt from conversation history so
460            // that multi-turn context is available on follow-up messages.
461            let mut interview = oxios_ouroboros::InterviewResult::new();
462            interview.is_task = true; // Has active seed → was a task.
463            interview.original_message = session
464                .user_messages
465                .last()
466                .map(|m| m.content.clone())
467                .unwrap_or_default();
468
469            // Rebuild conversation history from user/agent exchanges.
470            let history: Vec<oxios_ouroboros::interview::Exchange> = session
471                .user_messages
472                .iter()
473                .zip(session.agent_responses.iter())
474                .map(|(user, agent)| oxios_ouroboros::interview::Exchange {
475                    user: user.content.clone(),
476                    agent: agent.content.clone(),
477                })
478                .collect();
479            interview.conversation_history = history;
480
481            let seed_id = seed_id_str.parse::<Uuid>().ok();
482
483            let interview_session = InterviewSession {
484                id: session.id.0.clone(),
485                interview,
486                phase: Phase::Execute,
487                seed_id,
488                agent_id: None,
489            };
490
491            {
492                let mut sessions = self.sessions.write();
493                sessions.insert(session.id.0.clone(), interview_session);
494            }
495
496            restored += 1;
497        }
498
499        if restored > 0 {
500            tracing::info!(restored, total = summaries.len(), "Sessions restored");
501        }
502    }
503
504    /// Commit a file to git if GitLayer is configured and enabled.
505    fn git_commit(&self, rel_path: &str, message: &str) {
506        if let Some(ref gl) = self.git_layer
507            && gl.is_enabled()
508        {
509            let _ = gl.commit_file(rel_path, message);
510        }
511    }
512
513    /// Handle a user message through the full Ouroboros loop.
514    ///
515    /// Returns an `OrchestrationResult` with the response and metadata.
516    ///
517    /// If the interview phase needs clarification (ambiguity > 0.2),
518    /// the result will contain the questions and the phase will be
519    /// `Phase::Interview`. The caller should send these questions to
520    /// the user and include the `session_id` in follow-up messages.
521    pub async fn handle_message(
522        &self,
523        user_id: &str,
524        user_message: &str,
525        session_id: Option<&str>,
526        project_ids: Option<&str>,
527        mount_ids: Option<&str>,
528        request_id: &str,
529    ) -> Result<OrchestrationResult> {
530        tracing::info!(name = "orchestrator.handle_message", session_id = %session_id.unwrap_or("new"), request_id = %request_id, "starting");
531        get_metrics().messages.inc();
532        let orch_start = std::time::Instant::now();
533
534        let session_id = session_id
535            .map(String::from)
536            .unwrap_or_else(|| Uuid::new_v4().to_string());
537
538        tracing::info!(session_id = %session_id, user_id = %user_id, request_id = %request_id, content_len = user_message.len(), "Orchestrator handling message");
539
540        // ── Project Detection ──
541        // Parse project IDs from caller ("uuid1,uuid2,...") or auto-detect.
542        let primary_project_id: Option<Uuid> = if let Some(ids_str) = project_ids {
543            // Explicit project IDs from caller
544            ids_str
545                .split(',')
546                .next()
547                .and_then(|s| Uuid::parse_str(s.trim()).ok())
548        } else {
549            // Auto-detect from message
550            self.detect_project_tag(user_message).and_then(|_tag| {
551                // Extract UUID from project manager
552                self.project_manager().and_then(|pm| {
553                    let projects = pm.list_projects();
554                    let result = crate::project::detect_project(user_message, &projects);
555                    match result {
556                        crate::project::DetectionResult::Found(id) => Some(id),
557                        crate::project::DetectionResult::NoMatch { .. } => None,
558                    }
559                })
560            })
561        };
562
563        // Resolve project tag for display
564        let project_tag = primary_project_id
565            .and_then(|id| {
566                self.project_manager()
567                    .and_then(|pm| pm.get_project(id).map(|p| p.tag()))
568            })
569            .unwrap_or_default();
570
571        // Touch the project to record activity
572        if let Some(pid) = primary_project_id
573            && let Some(pm) = self.project_manager()
574        {
575            pm.touch(pid);
576        }
577
578        // ── Mount workspace resolution (RFC-025) ──
579        // Resolve active Mounts (explicit mount_ids or auto-detect), build the
580        // `## Workspace Context` body, and collect all bound paths. These are
581        // applied to the seed once it's created and returned to the caller so
582        // the gateway/frontend can show a detection badge.
583        let (active_mount_ids, workspace_context, mount_paths, mount_tag) =
584            self.resolve_mount_workspace(mount_ids, project_ids, user_message);
585        let mount_tag_opt = if mount_tag.is_empty() {
586            None
587        } else {
588            Some(mount_tag.clone())
589        };
590
591        // RFC-025: suppress project_tag when mount_tag is present — the mount
592        // badge is more specific (shows actual mount names) and avoids showing
593        // two near-identical badges for the same context.
594        let project_tag = if mount_tag_opt.is_some() {
595            String::new()
596        } else {
597            project_tag
598        };
599
600        let _conversation_turns = {
601            let buffer = self.conversation_buffer.read();
602            buffer.turns().iter().cloned().collect::<Vec<_>>()
603        };
604
605        // Record user message in conversation buffer
606        {
607            let mut buffer = self.conversation_buffer.write();
608            buffer.push_user(user_message);
609        }
610
611        // Phase 1: Interview
612        self.publish_phase_started(&session_id, Phase::Interview)
613            .await;
614
615        // Get or create the interview session (pre-fetch to avoid lock across await).
616        let needs_interview;
617        let existing_history: Option<Vec<_>>;
618        {
619            let sessions = self.sessions.read();
620            needs_interview = !sessions.contains_key(&session_id);
621            existing_history = if !needs_interview {
622                sessions
623                    .get(&session_id)
624                    .map(|s| s.interview.conversation_history.clone())
625            } else {
626                None
627            };
628            // Lock dropped here before any .await
629        }
630
631        // Conduct the interview.
632        let interview = {
633            tracing::info!(phase = "interview", "Starting interview phase");
634            if needs_interview {
635                self.ouroboros.interview(user_message).await?
636            } else {
637                // This is a follow-up message in an existing interview.
638                // Build multi-turn context from conversation history.
639                let multi_turn_context = {
640                    let mut context_parts = Vec::new();
641                    if let Some(ref history) = existing_history {
642                        for exchange in history {
643                            context_parts.push(format!(
644                                "User: {}\nAgent: {}",
645                                exchange.user, exchange.agent
646                            ));
647                        }
648                    }
649                    context_parts.push(format!("User: {user_message}"));
650                    context_parts.join("\n\n")
651                };
652
653                // Record all Q&A as a single exchange for multi-turn history.
654                // The formatted `user_message` already contains Q&A context
655                // (sent from the frontend as `text` field). Pair it with
656                // the full question list as the agent side.
657                {
658                    let mut sessions = self.sessions.write();
659                    if let Some(s) = sessions.get_mut(&session_id) {
660                        let all_questions = s.interview.questions.join("\n");
661                        s.interview.add_to_history(user_message, &all_questions);
662                    }
663                }
664
665                // Run another interview pass with full conversation history.
666                self.ouroboros.interview(&multi_turn_context).await?
667            }
668        };
669
670        // If this is a non-task message (greeting, small talk), return the chat response directly.
671        if !interview.is_task {
672            tracing::info!(session_id = %session_id, "Chat response (non-task)");
673
674            let response_text = if interview.chat_response.is_empty() {
675                "Hello! How can I help you today?".to_string()
676            } else {
677                interview.chat_response.clone()
678            };
679
680            // Record agent response in conversation buffer
681            {
682                let mut buffer = self.conversation_buffer.write();
683                buffer.push_agent(&response_text, None);
684            }
685
686            // Record exchange in conversation history for multi-turn
687            // and store session so multi-turn works on follow-up messages
688            {
689                let mut sessions = self.sessions.write();
690                if let Some(session) = sessions.get_mut(&session_id) {
691                    tracing::debug!(session_id = %session_id, history_len = session.interview.conversation_history.len(), "Adding to existing session history");
692                    session
693                        .interview
694                        .add_to_history(user_message, &response_text);
695                } else {
696                    // First non-task message — create a minimal session for history
697                    let mut interview = InterviewResult::new();
698                    interview.is_task = false;
699                    interview.chat_response = response_text.clone();
700                    interview.add_to_history(user_message, &response_text);
701                    sessions.insert(
702                        session_id.clone(),
703                        InterviewSession {
704                            id: session_id.clone(),
705                            interview,
706                            phase: Phase::Interview,
707                            seed_id: None,
708                            agent_id: None,
709                        },
710                    );
711                }
712            }
713
714            self.publish_phase_completed(&session_id, Phase::Interview, "chat")
715                .await;
716
717            return Ok(OrchestrationResult {
718                session_id: Some(session_id.clone()),
719                primary_project_id,
720                project_tag: Some(project_tag.clone()),
721                active_mount_ids: active_mount_ids.clone(),
722                mount_tag: mount_tag_opt.clone(),
723                response: response_text,
724                seed_id: None,
725                agent_id: None,
726                phase_reached: Phase::Interview,
727                evaluation_passed: None,
728                output: None,
729                tool_calls: vec![],
730                interview_questions: None,
731                interview_round: None,
732                interview_ambiguity: None,
733                mode: "ouroboros".to_string(),
734            });
735        }
736
737        // If ambiguity is too high, return questions for the user to answer.
738        if !interview.ready_for_seed {
739            // Record this exchange in conversation history and store the interview.
740            {
741                let mut sessions = self.sessions.write();
742                let session =
743                    sessions
744                        .entry(session_id.clone())
745                        .or_insert_with(|| InterviewSession {
746                            id: session_id.clone(),
747                            interview: interview.clone(),
748                            phase: Phase::Interview,
749                            seed_id: None,
750                            agent_id: None,
751                        });
752
753                let questions_text = interview.questions.join("\n");
754
755                // If this is the first round (no prior history), record the
756                // original user message → agent questions as the first exchange.
757                // Without this the multi-turn context loses the user's intent
758                // and follow-up rounds can't understand the conversation.
759                let is_first_round = session.interview.conversation_history.is_empty();
760                if is_first_round {
761                    let original = if interview.original_message.is_empty() {
762                        user_message.to_string()
763                    } else {
764                        interview.original_message.clone()
765                    };
766                    session.interview.add_to_history(&original, &questions_text);
767                } else {
768                    // Follow-up round: record the user's answer + these questions.
769                    let last_answer = session.interview.answers.last().cloned();
770                    if let Some(ref ans) = last_answer
771                        && !ans.is_empty()
772                    {
773                        session.interview.add_to_history(ans, &questions_text);
774                    }
775                }
776            } // Lock dropped before .await
777
778            let questions = interview
779                .questions
780                .iter()
781                .filter(|q| !q.is_empty())
782                .cloned()
783                .collect::<Vec<_>>();
784
785            tracing::info!(
786                session_id = %session_id,
787                ambiguity = interview.ambiguity.ambiguity(),
788                questions = questions.len(),
789                "Interview needs clarification"
790            );
791
792            self.publish_phase_completed(&session_id, Phase::Interview, "needs clarification")
793                .await;
794
795            // Try to produce structured questions for the interactive
796            // Web UI. This is best-effort: failure here does NOT block
797            // the interview flow — the frontend will fall back to
798            // rendering `response` as plain markdown.
799            //
800            // For follow-up interview rounds, pass the full multi-turn
801            // context so the LLM can produce relevant structured follow-ups.
802            // When this is the first round, multi_turn_input equals
803            // `user_message` so the behavior is unchanged.
804            let structured_input = if existing_history.is_some() {
805                // Rebuild the multi-turn context that was already fed
806                // to `self.ouroboros.interview()` above.
807                let mut context_parts = Vec::new();
808                if let Some(ref history) = existing_history {
809                    for exchange in history {
810                        context_parts.push(format!(
811                            "User: {}\nAgent: {}",
812                            exchange.user, exchange.agent
813                        ));
814                    }
815                }
816                context_parts.push(format!("User: {user_message}"));
817                context_parts.join("\n\n")
818            } else {
819                user_message.to_string()
820            };
821
822            let mut structured = match self.ouroboros.interview_structured(&structured_input).await
823            {
824                Ok(Some(s)) if !s.is_empty() => Some(s),
825                Ok(_) => None,
826                Err(e) => {
827                    tracing::warn!(error = %e, "interview_structured failed; falling back to markdown");
828                    None
829                }
830            };
831
832            // Fallback: if the LLM did not produce structured questions
833            // but we have plain questions from the interview, synthesize
834            // free_text structured questions so the wizard UI still works.
835            // This prevents the second+ round from falling back to
836            // plain markdown when the LLM omits `structured_questions`.
837            if structured.is_none() && !questions.is_empty() {
838                structured = Some(
839                    questions
840                        .iter()
841                        .enumerate()
842                        .map(
843                            |(i, q)| oxios_ouroboros::ouroboros_engine::InterviewQuestionOutput {
844                                id: format!("q{}", i + 1),
845                                text: q.clone(),
846                                kind: "free_text".to_string(),
847                                options: vec![],
848                            },
849                        )
850                        .collect(),
851                );
852            }
853
854            // Round = 1 + number of prior answers in the session's
855            // interview history (best-effort: 1 when not present).
856            let interview_round = {
857                let sessions = self.sessions.read();
858                sessions
859                    .get(&session_id)
860                    .map(|s| (s.interview.answers.len().saturating_sub(1)).max(1) as u32)
861                    .unwrap_or(1)
862            };
863
864            return Ok(OrchestrationResult {
865                session_id: Some(session_id.clone()),
866                primary_project_id,
867                project_tag: Some(project_tag.clone()),
868                active_mount_ids: active_mount_ids.clone(),
869                mount_tag: mount_tag_opt.clone(),
870                response: format_questions(&questions),
871                seed_id: None,
872                agent_id: None,
873                phase_reached: Phase::Interview,
874                evaluation_passed: None,
875                output: None,
876                tool_calls: vec![],
877                interview_questions: structured,
878                interview_round: Some(interview_round),
879                interview_ambiguity: Some(interview.ambiguity.ambiguity()),
880                mode: "ouroboros".to_string(),
881            });
882        }
883
884        // Record agent response in conversation buffer (for topic shift detection)
885        // Note: interview phase returns questions, not a full agent response,
886        // but we record it for completeness.
887        {
888            let mut buffer = self.conversation_buffer.write();
889            buffer.push_agent("[interview: ready]", None);
890        }
891
892        // Interview complete and ready.
893        self.publish_phase_completed(&session_id, Phase::Interview, "ready")
894            .await;
895        self.publish_phase_started(&session_id, Phase::Seed).await;
896
897        // ── Complexity-based routing ──
898        //
899        // "simple" + low ambiguity → create a lightweight Seed from the user
900        // message directly (no LLM call) and skip formal evaluation.
901        // "complex" (or ambiguous simple) → generate a full Seed via LLM.
902        let is_simple = interview.complexity == "simple" && interview.ambiguity.ambiguity() <= 0.3;
903
904        let mut seed = if is_simple {
905            tracing::info!(
906                phase = "seed",
907                method = "from_message",
908                "Simple task — ad-hoc seed"
909            );
910            Seed::from_message(&interview.original_message)
911        } else {
912            tracing::info!(
913                phase = "seed",
914                method = "llm",
915                "Complex task — LLM-generated seed"
916            );
917            self.ouroboros.generate_seed(&interview).await?
918        };
919        seed.project_id = primary_project_id;
920        seed.workspace_context = workspace_context.clone();
921        seed.mount_paths = mount_paths.clone();
922
923        // Save seed to state store.
924        self.save_seed(&seed).await?;
925
926        // Publish seed created event.
927        self.event_bus
928            .publish(KernelEvent::SeedCreated { seed_id: seed.id })?;
929
930        self.publish_phase_completed(&session_id, Phase::Seed, "generated")
931            .await;
932        self.publish_phase_started(&session_id, Phase::Execute)
933            .await;
934
935        // Check if the seed should be split into multi-agent execution.
936        // When the seed has 3+ acceptance criteria, we treat each criterion
937        // as a distinct subtask and delegate to separate agents.
938        if should_split_seed(&seed) {
939            let subtasks = split_into_subtasks(&seed);
940            if subtasks.len() > 1 {
941                tracing::info!(
942                    phase = "delegate",
943                    subtasks = subtasks.len(),
944                    "Delegating to multi-agent"
945                );
946                let results = self.delegate_subtasks(subtasks, &seed).await?;
947
948                // Combine successful results
949                let combined: String = results
950                    .iter()
951                    .filter(|r| r.success)
952                    .filter_map(|r| r.result.as_deref())
953                    .collect::<Vec<_>>()
954                    .join("\n\n");
955
956                let all_passed = results.iter().all(|r| r.success);
957
958                // Clean up the session.
959                {
960                    let mut sessions = self.sessions.write();
961                    sessions.remove(&session_id);
962                }
963
964                tracing::info!(
965                    session_id = %session_id,
966                    subtasks = results.len(),
967                    passed = all_passed,
968                    "Multi-agent orchestration complete"
969                );
970
971                return Ok(OrchestrationResult {
972                    session_id: Some(session_id),
973                    primary_project_id,
974                    project_tag: Some(project_tag.clone()),
975                    active_mount_ids: active_mount_ids.clone(),
976                    mount_tag: mount_tag_opt.clone(),
977                    response: format_result_combined(&combined),
978                    seed_id: Some(seed.id),
979                    agent_id: None,
980                    phase_reached: Phase::Execute,
981                    evaluation_passed: Some(all_passed),
982                    output: Some(combined),
983                    tool_calls: vec![],
984                    interview_questions: None,
985                    interview_round: None,
986                    interview_ambiguity: None,
987                    mode: "ouroboros".to_string(),
988                });
989            }
990        }
991
992        // Record agent response in conversation buffer (for multi-agent case)
993        {
994            let mut buffer = self.conversation_buffer.write();
995            buffer.push_agent("[multi-agent: complete]", None);
996        }
997
998        // Execute agent via lifecycle manager.
999        tracing::info!(phase = "execute", "Starting execution phase");
1000        let exec_result = self
1001            .lifecycle
1002            .spawn_and_run(&seed, Priority::Normal)
1003            .await?;
1004
1005        // Periodically reap zombie tasks.
1006        self.lifecycle.reap_zombies();
1007
1008        self.publish_phase_completed(&session_id, Phase::Execute, "completed")
1009            .await;
1010
1011        // ── Evaluate + Evolve ──
1012        //
1013        // Three paths:
1014        // 1. output_schema → structured validation (no evolution)
1015        // 2. acceptance_criteria present → full evaluate + optional evolve loop
1016        // 3. neither → simple boolean pass/fail
1017        let (final_result, final_seed, passed, phase_reached) = if let Some(ref schema) =
1018            seed.output_schema
1019        {
1020            // Structured output validation — no evolution.
1021            let passed = match oxi_sdk::StructuredOutput::extract(
1022                &exec_result.output,
1023                &oxi_sdk::OutputMode::ValidatedJson {
1024                    schema: schema.clone(),
1025                },
1026            ) {
1027                Ok(_) => {
1028                    tracing::info!(session_id = %session_id, "Structured output validation passed");
1029                    true
1030                }
1031                Err(e) => {
1032                    tracing::warn!(session_id = %session_id, error = %e, "Structured output validation failed");
1033                    false
1034                }
1035            };
1036            (exec_result, seed.clone(), passed, Phase::Execute)
1037        } else if self.should_evaluate(&seed) {
1038            // Full Ouroboros evaluate + optional evolve loop.
1039            self.publish_phase_started(&session_id, Phase::Evaluate)
1040                .await;
1041
1042            let (result, eval, evolved_seed) = self
1043                .run_evolution_loop(&session_id, &seed, exec_result)
1044                .await?;
1045
1046            let passed =
1047                eval.all_passed() && eval.score >= self.evolution_config.read().score_threshold;
1048
1049            self.publish_phase_completed(
1050                &session_id,
1051                Phase::Evaluate,
1052                &format!("score={:.2}", eval.score),
1053            )
1054            .await;
1055
1056            let reached = if evolved_seed.generation > 0 {
1057                Phase::Evolve
1058            } else {
1059                Phase::Evaluate
1060            };
1061
1062            (result, evolved_seed, passed, reached)
1063        } else {
1064            // Simple task: boolean pass/fail, no LLM evaluation.
1065            let passed = exec_result.success;
1066            (exec_result, seed.clone(), passed, Phase::Execute)
1067        };
1068
1069        // Clean up the session.
1070        {
1071            let mut sessions = self.sessions.write();
1072            sessions.remove(&session_id);
1073        }
1074
1075        tracing::info!(
1076            session_id = %session_id,
1077            passed,
1078            phase = %phase_reached,
1079            "Orchestration complete"
1080        );
1081
1082        // Measure orchestration duration.
1083        let metrics = get_metrics();
1084        metrics
1085            .orch_duration
1086            .observe(orch_start.elapsed().as_secs_f64());
1087        if passed {
1088            metrics.agents_completed.inc();
1089        } else {
1090            metrics.agents_failed.inc();
1091        }
1092
1093        // Record agent response in conversation buffer (for topic shift detection)
1094        {
1095            let mut buffer = self.conversation_buffer.write();
1096            buffer.push_agent(&final_seed.goal, None);
1097        }
1098
1099        Ok(OrchestrationResult {
1100            session_id: Some(session_id),
1101            primary_project_id,
1102            project_tag: Some(project_tag.clone()),
1103            active_mount_ids: active_mount_ids.clone(),
1104            mount_tag: mount_tag_opt.clone(),
1105            response: format_execution_result(&final_seed, &final_result),
1106            seed_id: Some(final_seed.id),
1107            agent_id: None,
1108            phase_reached,
1109            evaluation_passed: Some(passed),
1110            output: Some(final_result.output.clone()),
1111            tool_calls: final_result.tool_calls.clone(),
1112            interview_questions: None,
1113            interview_round: None,
1114            interview_ambiguity: None,
1115            mode: "ouroboros".to_string(),
1116        })
1117    }
1118
1119    /// Check whether a seed should go through full evaluate + evolve.
1120    ///
1121    /// Only seeds with acceptance criteria and no output_schema qualify.
1122    /// Simple tasks (from_message, no criteria) get boolean pass/fail.
1123    fn should_evaluate(&self, seed: &Seed) -> bool {
1124        !seed.acceptance_criteria.is_empty() && seed.output_schema.is_none()
1125    }
1126
1127    /// Default chat mode: execute via AgentRuntime directly.
1128    ///
1129    /// Skips interview/seed/evaluate/evolve. Returns fast responses.
1130    pub async fn chat(
1131        &self,
1132        _user_id: &str,
1133        user_message: &str,
1134        session_id: Option<&str>,
1135        project_ids: Option<&str>,
1136        mount_ids: Option<&str>,
1137        request_id: &str,
1138    ) -> Result<OrchestrationResult> {
1139        tracing::info!(name = "orchestrator.chat", session_id = %session_id.unwrap_or("new"), request_id = %request_id, "starting");
1140        let metrics = get_metrics();
1141        metrics.messages.inc();
1142        let orch_start = std::time::Instant::now();
1143
1144        let session_id = session_id
1145            .map(String::from)
1146            .unwrap_or_else(|| Uuid::new_v4().to_string());
1147
1148        // Project detection (same as handle_message)
1149        let primary_project_id: Option<Uuid> = if let Some(ids_str) = project_ids {
1150            ids_str
1151                .split(',')
1152                .next()
1153                .and_then(|s| Uuid::parse_str(s.trim()).ok())
1154        } else {
1155            self.detect_project_tag(user_message).and_then(|_tag| {
1156                self.project_manager().and_then(|pm| {
1157                    let projects = pm.list_projects();
1158                    let result = crate::project::detect_project(user_message, &projects);
1159                    match result {
1160                        crate::project::DetectionResult::Found(id) => Some(id),
1161                        crate::project::DetectionResult::NoMatch { .. } => None,
1162                    }
1163                })
1164            })
1165        };
1166
1167        let project_tag = primary_project_id
1168            .and_then(|id| {
1169                self.project_manager()
1170                    .and_then(|pm| pm.get_project(id).map(|p| p.tag()))
1171            })
1172            .unwrap_or_default();
1173
1174        // ── Mount workspace resolution (RFC-025) ──
1175        let (active_mount_ids, workspace_context, mount_paths, mount_tag) =
1176            self.resolve_mount_workspace(mount_ids, project_ids, user_message);
1177        let mount_tag_opt = if mount_tag.is_empty() {
1178            None
1179        } else {
1180            Some(mount_tag.clone())
1181        };
1182
1183        // RFC-025: suppress project_tag when mount_tag is present.
1184        let project_tag = if mount_tag_opt.is_some() {
1185            String::new()
1186        } else {
1187            project_tag
1188        };
1189
1190        // Lightweight seed — goal only, no constraints/criteria
1191        let mut seed = Seed::from_message(user_message);
1192        seed.project_id = primary_project_id;
1193        seed.workspace_context = workspace_context;
1194        seed.mount_paths = mount_paths;
1195
1196        // Execute via lifecycle manager (fork → run → cleanup)
1197        tracing::info!(
1198            phase = "execute",
1199            mode = "chat",
1200            "Starting direct execution"
1201        );
1202        let exec_result = self
1203            .lifecycle
1204            .spawn_and_run(&seed, Priority::Normal)
1205            .await?;
1206        self.lifecycle.reap_zombies();
1207
1208        let metrics = get_metrics();
1209        metrics
1210            .orch_duration
1211            .observe(orch_start.elapsed().as_secs_f64());
1212        if exec_result.success {
1213            metrics.agents_completed.inc();
1214        } else {
1215            metrics.agents_failed.inc();
1216        }
1217
1218        Ok(OrchestrationResult {
1219            session_id: Some(session_id),
1220            primary_project_id,
1221            project_tag: Some(project_tag),
1222            active_mount_ids: active_mount_ids.clone(),
1223            mount_tag: mount_tag_opt.clone(),
1224            response: exec_result.output.clone(),
1225            seed_id: Some(seed.id),
1226            agent_id: None,
1227            phase_reached: Phase::Execute,
1228            evaluation_passed: None,
1229            output: Some(exec_result.output),
1230            tool_calls: exec_result.tool_calls,
1231            interview_questions: None,
1232            interview_round: None,
1233            interview_ambiguity: None,
1234            mode: "chat".to_string(),
1235        })
1236    }
1237
1238    /// Execute a seed via the lifecycle manager.
1239    async fn execute_seed(&self, seed: &Seed) -> Result<ExecutionResult> {
1240        self.lifecycle.spawn_and_run(seed, Priority::Normal).await
1241    }
1242
1243    /// Evaluate → (optional) Evolve → re-execute loop.
1244    ///
1245    /// Tracks the best result seen across iterations. If evolution
1246    /// degrades the score, returns the previous best.
1247    async fn run_evolution_loop(
1248        &self,
1249        _session_id: &str,
1250        seed: &Seed,
1251        initial_result: ExecutionResult,
1252    ) -> Result<(ExecutionResult, EvaluationResult, Seed)> {
1253        let max_iterations = self.evolution_config.read().max_iterations;
1254        let threshold = self.evolution_config.read().score_threshold;
1255
1256        let mut current_seed = seed.clone();
1257        let mut current_result = initial_result;
1258
1259        // Best-result tracking.
1260        let mut best_result = current_result.clone();
1261        let mut best_seed = current_seed.clone();
1262        let mut best_eval: Option<EvaluationResult> = None;
1263
1264        for iteration in 0..=max_iterations {
1265            // Evaluate
1266            let evaluation = self
1267                .ouroboros
1268                .evaluate(&current_seed, &current_result)
1269                .await?;
1270
1271            tracing::info!(
1272                iteration,
1273                seed_id = %current_seed.id,
1274                score = evaluation.score,
1275                passed = evaluation.all_passed(),
1276                "Evaluation complete"
1277            );
1278
1279            let _ = self.event_bus.publish(KernelEvent::EvaluationComplete {
1280                seed_id: current_seed.id,
1281                passed: evaluation.all_passed(),
1282            });
1283
1284            // Update best if this iteration improved.
1285            if best_eval
1286                .as_ref()
1287                .is_none_or(|b| evaluation.score >= b.score)
1288            {
1289                best_result = current_result.clone();
1290                best_seed = current_seed.clone();
1291                best_eval = Some(evaluation.clone());
1292            }
1293
1294            // Passed or exhausted iterations.
1295            if evaluation.score >= threshold || iteration == max_iterations {
1296                if iteration == max_iterations && max_iterations > 0 {
1297                    let _ = self.event_bus.publish(KernelEvent::EvolutionMaxReached {
1298                        seed_id: current_seed.id,
1299                        final_score: evaluation.score,
1300                        iterations: iteration,
1301                    });
1302                }
1303                return Ok((
1304                    best_result,
1305                    best_eval.ok_or_else(|| {
1306                        anyhow::anyhow!(
1307                            "Evolve loop exited with threshold met but no evaluation was produced"
1308                        )
1309                    })?,
1310                    best_seed,
1311                ));
1312            }
1313
1314            // max_iterations == 0 → evaluate only, no evolution.
1315            if max_iterations == 0 {
1316                return Ok((
1317                    best_result,
1318                    best_eval.ok_or_else(|| {
1319                        anyhow::anyhow!("No iterations configured and no evaluation was produced")
1320                    })?,
1321                    best_seed,
1322                ));
1323            }
1324
1325            // Evolve: produce an improved seed.
1326            let evolved = self.ouroboros.evolve(&current_seed, &evaluation).await?;
1327            match evolved {
1328                Some(new_seed) => {
1329                    tracing::info!(
1330                        old_seed_id = %current_seed.id,
1331                        new_seed_id = %new_seed.id,
1332                        iteration,
1333                        "Seed evolved, re-executing"
1334                    );
1335
1336                    let _ = self.event_bus.publish(KernelEvent::EvolutionStarted {
1337                        seed_id: current_seed.id,
1338                        new_seed_id: new_seed.id,
1339                        iteration,
1340                    });
1341
1342                    // Save the evolved seed.
1343                    self.save_seed(&new_seed).await?;
1344
1345                    current_seed = new_seed;
1346                    current_result = self.execute_seed(&current_seed).await?;
1347                }
1348                None => {
1349                    tracing::info!(
1350                        seed_id = %current_seed.id,
1351                        "Evolve returned None, stopping loop"
1352                    );
1353                    return Ok((
1354                        best_result,
1355                        best_eval.ok_or_else(|| {
1356                            anyhow::anyhow!(
1357                                "Evolve returned no seed and no evaluation was produced"
1358                            )
1359                        })?,
1360                        best_seed,
1361                    ));
1362                }
1363            }
1364        }
1365
1366        // Unreachable: every branch above returns.
1367        unreachable!()
1368    }
1369
1370    /// Save a seed to the state store.
1371    async fn save_seed(&self, seed: &Seed) -> Result<()> {
1372        let key = seed.id.to_string();
1373
1374        self.state_store
1375            .save_json("seeds", &key, seed)
1376            .await
1377            .context("failed to save seed to state store")?;
1378
1379        self.git_commit(&format!("seeds/{key}.json"), "ourobors: save seed");
1380
1381        Ok(())
1382    }
1383
1384    /// Save an evaluation result to the state store.
1385    /// Publish a PhaseStarted event.
1386    async fn publish_phase_started(&self, session_id: &str, phase: Phase) {
1387        let _ = self.event_bus.publish(KernelEvent::PhaseStarted {
1388            session_id: session_id.to_owned(),
1389            phase,
1390        });
1391    }
1392
1393    /// Publish a PhaseCompleted event.
1394    async fn publish_phase_completed(&self, session_id: &str, phase: Phase, result: &str) {
1395        let _ = self.event_bus.publish(KernelEvent::PhaseCompleted {
1396            session_id: session_id.to_owned(),
1397            phase,
1398            result_summary: result.to_owned(),
1399        });
1400    }
1401
1402    /// Execute multiple subtasks using separate agents in parallel.
1403    ///
1404    /// When A2A is available, the orchestrator delegates tasks through the
1405    /// A2A protocol with circuit breaker and retry support.
1406    /// Otherwise, falls back to direct lifecycle execution.
1407    ///
1408    /// Results are collected as they complete using `JoinSet`.
1409    pub async fn delegate_subtasks(
1410        &self,
1411        subtasks: Vec<SubTask>,
1412        parent_seed: &Seed,
1413    ) -> Result<Vec<SubTask>> {
1414        // Single task — execute directly without group overhead.
1415        if subtasks.len() == 1 {
1416            return self.execute_single_subtask(subtasks, parent_seed).await;
1417        }
1418
1419        // Try A2A-based delegation when the protocol is available.
1420        if let Some(ref a2a) = self.a2a {
1421            // Check circuit breaker
1422            if !self.a2a_breaker.is_allowed() {
1423                tracing::warn!(
1424                    state = ?self.a2a_breaker.state(),
1425                    "A2A circuit breaker open, using lifecycle fallback"
1426                );
1427                return self.delegate_via_lifecycle(subtasks, parent_seed).await;
1428            }
1429
1430            // Delegate with retry
1431            return self.delegate_with_retry(subtasks, parent_seed, a2a).await;
1432        }
1433
1434        // Fallback: direct lifecycle execution (no A2A).
1435        self.delegate_via_lifecycle(subtasks, parent_seed).await
1436    }
1437
1438    /// Delegate subtasks via A2A with circuit breaker and retry support.
1439    async fn delegate_with_retry(
1440        &self,
1441        subtasks: Vec<SubTask>,
1442        parent_seed: &Seed,
1443        a2a: &Arc<crate::a2a::A2AProtocol>,
1444    ) -> Result<Vec<SubTask>> {
1445        let mut attempt = 0;
1446        let max_retries = self.delegation_config.max_retries;
1447
1448        loop {
1449            match self
1450                .delegate_via_a2a(subtasks.clone(), parent_seed, a2a)
1451                .await
1452            {
1453                Ok(results) => {
1454                    self.a2a_breaker.record_success();
1455                    return Ok(results);
1456                }
1457                Err(e) => {
1458                    self.a2a_breaker.record_failure();
1459                    attempt += 1;
1460
1461                    if attempt >= max_retries {
1462                        tracing::error!(
1463                            attempts = attempt,
1464                            error = %e,
1465                            "A2A delegation exhausted after {} attempts, using lifecycle fallback",
1466                            attempt
1467                        );
1468                        return self.delegate_via_lifecycle(subtasks, parent_seed).await;
1469                    }
1470
1471                    // Exponential backoff
1472                    let delay = self.delegation_config.backoff_delay(attempt);
1473                    tracing::warn!(
1474                        attempt,
1475                        delay_ms = delay,
1476                        error = %e,
1477                        "A2A delegation failed, retrying with backoff"
1478                    );
1479                    tokio::time::sleep(Duration::from_millis(delay)).await;
1480                }
1481            }
1482        }
1483    }
1484
1485    /// Execute a single subtask directly via lifecycle manager.
1486    async fn execute_single_subtask(
1487        &self,
1488        subtasks: Vec<SubTask>,
1489        parent_seed: &Seed,
1490    ) -> Result<Vec<SubTask>> {
1491        let mut task = subtasks.into_iter().next().ok_or_else(|| {
1492            anyhow::anyhow!("execute_single_subtask called with an empty subtask list")
1493        })?;
1494        let child_seed = Seed {
1495            id: Uuid::new_v4(),
1496            goal: task.description.clone(),
1497            constraints: parent_seed.constraints.clone(),
1498            acceptance_criteria: vec!["Task completes successfully".into()],
1499            ontology: parent_seed.ontology.clone(),
1500            created_at: chrono::Utc::now(),
1501            generation: parent_seed.generation + 1,
1502            parent_seed_id: Some(parent_seed.id),
1503            cspace_hint: None,
1504            original_request: parent_seed.original_request.clone(),
1505            output_schema: None,
1506            project_id: parent_seed.project_id,
1507            workspace_context: parent_seed.workspace_context.clone(),
1508            mount_paths: parent_seed.mount_paths.clone(),
1509        };
1510        match self
1511            .lifecycle
1512            .spawn_and_run(&child_seed, Priority::Normal)
1513            .await
1514        {
1515            Ok(result) => {
1516                task.result = Some(result.output.clone());
1517            }
1518            Err(e) => {
1519                task.result = Some(format!("Failed: {e}"));
1520                task.success = false;
1521            }
1522        }
1523        Ok(vec![task])
1524    }
1525
1526    /// Delegate subtasks via A2A protocol.
1527    ///
1528    /// Queries the AgentCardRegistry for agents matching each subtask's
1529    /// Execute subtasks via A2A dispatch handler.
1530    ///
1531    /// Queries the AgentCardRegistry for agents matching each subtask's
1532    /// required capability, then calls `execute_delegation` which runs
1533    /// the task through the registered handler (lifecycle).
1534    /// Falls back to direct lifecycle execution when no handler is registered.
1535    async fn delegate_via_a2a(
1536        &self,
1537        subtasks: Vec<SubTask>,
1538        parent_seed: &Seed,
1539        a2a: &Arc<crate::a2a::A2AProtocol>,
1540    ) -> Result<Vec<SubTask>> {
1541        use crate::a2a::TaskPriority;
1542        use tokio::task::JoinSet;
1543
1544        tracing::info!(
1545            subtasks = subtasks.len(),
1546            "Delegating subtasks via A2A protocol"
1547        );
1548
1549        let orchestrator_id: crate::types::AgentId = uuid::Uuid::nil();
1550        let subtask_count = subtasks.len();
1551        let mut join_set: JoinSet<(usize, SubTask)> = JoinSet::new();
1552
1553        for (idx, subtask) in subtasks.into_iter().enumerate() {
1554            let capability = subtask.required_capability.clone();
1555            let description = subtask.description.clone();
1556            let subtask_id = subtask.id;
1557            let role = subtask.role.clone();
1558            let a2a = Arc::clone(a2a);
1559            let parent_seed = parent_seed.clone();
1560            let lifecycle = self.lifecycle.clone();
1561
1562            join_set.spawn(async move {
1563                // Find agent with the required capability via A2A registry.
1564                let target: Option<crate::a2a::AgentCard> = if let Some(ref cap) = capability {
1565                    a2a.query_capabilities(cap).await.ok()
1566                        .and_then(|agents| agents.into_iter().next())
1567                } else {
1568                    None
1569                };
1570
1571                let (output, success) = if let Some(ref target_card) = target {
1572                    let target_id = target_card.agent_id;
1573                    tracing::info!(
1574                        subtask_index = idx,
1575                        target = %target_card.name,
1576                        target_id = %target_id,
1577                        "A2A dispatching subtask"
1578                    );
1579
1580                    let task = crate::a2a::TaskSpec::new(&description, serde_json::json!({
1581                        "parent_seed": parent_seed.id.to_string(),
1582                        "goal": description,
1583                    }))
1584                    .with_priority(TaskPriority::Normal);
1585
1586                    // Enqueue audit trail (fire-and-forget into queue).
1587                    let _ = a2a.delegate_task(orchestrator_id, target_id, task.clone()).await;
1588
1589                    // Execute through dispatch handler (blocking).
1590                    match a2a.execute_delegation(orchestrator_id, target_id, task).await {
1591                        Some(Ok(result)) => {
1592                            let out = result.get("output")
1593                                .and_then(|v| v.as_str())
1594                                .unwrap_or("")
1595                                .to_string();
1596                            let ok = result.get("success")
1597                                .and_then(|v| v.as_bool())
1598                                .unwrap_or(false);
1599                            (out, ok)
1600                        }
1601                        Some(Err(e)) => {
1602                            tracing::warn!(subtask_index = idx, error = %e, "execute_delegation failed");
1603                            (format!("Failed: {e}"), false)
1604                        }
1605                        None => {
1606                            // No handler — fallback to lifecycle.
1607                            tracing::warn!(subtask_index = idx, "No dispatch handler, lifecycle fallback");
1608                            run_via_lifecycle(&lifecycle, &parent_seed, &description).await
1609                        }
1610                    }
1611                } else {
1612                    tracing::info!(subtask_index = idx, "No A2A agent found, lifecycle fallback");
1613                    run_via_lifecycle(&lifecycle, &parent_seed, &description).await
1614                };
1615
1616                (idx, SubTask {
1617                    id: subtask_id,
1618                    description,
1619                    required_capability: capability,
1620                    result: Some(output),
1621                    success,
1622                    role,
1623                })
1624            });
1625        }
1626
1627        let mut results: Vec<Option<SubTask>> = vec![None; subtask_count];
1628        while let Some(join_result) = join_set.join_next().await {
1629            match join_result {
1630                Ok((idx, subtask)) => {
1631                    results[idx] = Some(subtask);
1632                }
1633                Err(e) => {
1634                    tracing::error!(error = %e, "A2A task panicked");
1635                }
1636            }
1637        }
1638
1639        let completed: Vec<SubTask> = results.into_iter().flatten().collect();
1640        tracing::info!(
1641            completed = completed.len(),
1642            succeeded = completed.iter().filter(|r| r.success).count(),
1643            "A2A delegation complete"
1644        );
1645        Ok(completed)
1646    }
1647
1648    async fn delegate_via_lifecycle(
1649        &self,
1650        subtasks: Vec<SubTask>,
1651        parent_seed: &Seed,
1652    ) -> Result<Vec<SubTask>> {
1653        use crate::agent_group::OxiosAgentGroup;
1654        use tokio::task::JoinSet;
1655
1656        let descriptions: Vec<String> = subtasks.iter().map(|st| st.description.clone()).collect();
1657        let group = OxiosAgentGroup::new(parent_seed, descriptions);
1658        let group_id = group.id;
1659
1660        self.event_bus.publish(KernelEvent::AgentGroupCreated {
1661            group_id,
1662            agent_count: group.agents.len(),
1663        })?;
1664
1665        tracing::info!(
1666            group_id = %group_id,
1667            agent_count = group.agents.len(),
1668            "Starting parallel multi-agent execution"
1669        );
1670
1671        let mut join_set: JoinSet<(
1672            usize,
1673            crate::types::AgentId,
1674            Result<oxios_ouroboros::ExecutionResult>,
1675        )> = JoinSet::new();
1676
1677        for (idx, agent_entry) in group.agents.iter().enumerate() {
1678            let child_seed = agent_entry.seed.clone();
1679            let agent_id = agent_entry.id;
1680            let lifecycle = self.lifecycle.clone();
1681
1682            join_set.spawn(async move {
1683                let result = lifecycle.spawn_and_run(&child_seed, Priority::Normal).await;
1684                (idx, agent_id, result)
1685            });
1686        }
1687
1688        let subtask_count = subtasks.len();
1689        let mut completed = vec![None; subtask_count];
1690        while let Some(join_result) = join_set.join_next().await {
1691            match join_result {
1692                Ok((idx, agent_id, Ok(exec_result))) => {
1693                    let _ = self
1694                        .event_bus
1695                        .publish(KernelEvent::AgentGroupMemberCompleted {
1696                            group_id,
1697                            agent_id,
1698                            success: exec_result.success,
1699                        });
1700                    completed[idx] = Some(SubTask {
1701                        id: subtasks[idx].id,
1702                        description: subtasks[idx].description.clone(),
1703                        required_capability: subtasks[idx].required_capability.clone(),
1704                        result: Some(exec_result.output.clone()),
1705                        success: exec_result.success,
1706                        role: subtasks[idx].role.clone(),
1707                    });
1708                }
1709                Ok((idx, agent_id, Err(e))) => {
1710                    tracing::warn!(subtask_index = idx, error = %e, "Subtask failed");
1711                    let _ = self
1712                        .event_bus
1713                        .publish(KernelEvent::AgentGroupMemberCompleted {
1714                            group_id,
1715                            agent_id,
1716                            success: false,
1717                        });
1718                    completed[idx] = Some(SubTask {
1719                        id: subtasks[idx].id,
1720                        description: subtasks[idx].description.clone(),
1721                        required_capability: subtasks[idx].required_capability.clone(),
1722                        result: Some(format!("Failed: {e}")),
1723                        success: false,
1724                        role: subtasks[idx].role.clone(),
1725                    });
1726                }
1727                Err(e) => {
1728                    tracing::error!(error = %e, "JoinSet task panicked");
1729                }
1730            }
1731        }
1732
1733        let completed: Vec<SubTask> = completed.into_iter().flatten().collect();
1734        let succeeded = completed.iter().filter(|r| r.success).count();
1735        let total = completed.len();
1736
1737        tracing::info!(
1738            group_id = %group_id,
1739            succeeded,
1740            total,
1741            "Parallel multi-agent execution complete"
1742        );
1743
1744        // Persist group state
1745        let _ = self
1746            .state_store
1747            .save_json("agent_groups", &group_id.to_string(), &group)
1748            .await;
1749        self.git_commit(
1750            &format!("agent_groups/{group_id}.json"),
1751            "orchestrator: save group",
1752        );
1753
1754        Ok(completed)
1755    }
1756}
1757
1758/// Active session state for multi-turn interviews.
1759#[derive(Debug, Clone)]
1760#[allow(unused)]
1761struct InterviewSession {
1762    id: String,
1763    interview: InterviewResult,
1764    phase: Phase,
1765    seed_id: Option<Uuid>,
1766    agent_id: Option<AgentId>,
1767}
1768
1769fn default_chat_mode() -> String {
1770    "chat".into()
1771}
1772
1773/// Result of a full orchestration cycle.
1774#[derive(Debug, Clone, Serialize, Deserialize)]
1775pub struct OrchestrationResult {
1776    /// Session ID for multi-turn interviews. Pass this on follow-up messages.
1777    #[serde(skip_serializing_if = "Option::is_none")]
1778    pub session_id: Option<String>,
1779    /// The Space ID that handled this message.
1780    #[serde(skip_serializing_if = "Option::is_none")]
1781    pub primary_project_id: Option<Uuid>,
1782    /// Space decoration tag for the response (e.g. "[🔧 oxios]").
1783    #[serde(skip_serializing_if = "Option::is_none")]
1784    pub project_tag: Option<String>,
1785    /// Active Mount IDs for this message (RFC-025), primary first.
1786    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1787    pub active_mount_ids: Vec<MountId>,
1788    /// Mount decoration tag for the response (e.g. "[🔧 oxios + oxi-sdk]").
1789    #[serde(skip_serializing_if = "Option::is_none")]
1790    pub mount_tag: Option<String>,
1791    /// The response to send back to the user.
1792    pub response: String,
1793    /// The seed that was created (if seed phase was reached).
1794    #[serde(skip_serializing_if = "Option::is_none")]
1795    pub seed_id: Option<Uuid>,
1796    /// The agent that executed (if execute phase was reached).
1797    #[serde(skip_serializing_if = "Option::is_none")]
1798    pub agent_id: Option<AgentId>,
1799    /// The furthest phase reached.
1800    pub phase_reached: Phase,
1801    /// Whether evaluation passed.
1802    ///
1803    /// - `None` — evaluation was not applicable (interview, chat, non-task).
1804    /// - `Some(true)` — evaluation passed.
1805    /// - `Some(false)` — evaluation failed or execution unsuccessful.
1806    pub evaluation_passed: Option<bool>,
1807    /// Output or notes from evaluation.
1808    #[serde(skip_serializing_if = "Option::is_none")]
1809    pub output: Option<String>,
1810    /// Tool calls recorded during execution.
1811    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1812    pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
1813    /// Structured interview questions (chat UI redesign — interactive
1814    /// interview). Populated when the interview phase needs clarification
1815    /// and the LLM produced a structured form of the questions. The
1816    /// Gateway forwards this to the WebSocket as an `interview` chunk;
1817    /// the Web UI renders it as interactive widgets (chips, yes/no
1818    /// buttons). When `None`, the frontend falls back to rendering
1819    /// `response` as plain markdown.
1820    #[serde(default, skip_serializing_if = "Option::is_none")]
1821    pub interview_questions:
1822        Option<Vec<oxios_ouroboros::ouroboros_engine::InterviewQuestionOutput>>,
1823    /// Current interview round (1-based). Populated alongside
1824    /// `interview_questions`. Drives the "Round N/M" indicator.
1825    #[serde(default, skip_serializing_if = "Option::is_none")]
1826    pub interview_round: Option<u32>,
1827    /// Current ambiguity score (0.0 = clear, 1.0 = fully ambiguous).
1828    /// Populated alongside `interview_questions`. Drives the progress bar.
1829    #[serde(default, skip_serializing_if = "Option::is_none")]
1830    pub interview_ambiguity: Option<f64>,
1831    /// Execution mode: "chat" (default agent) | "ouroboros" (spec-first pipeline).
1832    #[serde(default = "default_chat_mode")]
1833    pub mode: String,
1834}
1835
1836/// Format clarifying questions for display.
1837fn format_questions(questions: &[String]) -> String {
1838    if questions.is_empty() {
1839        "I need a bit more clarification before I can proceed.".to_string()
1840    } else {
1841        format!(
1842            "I'd like to understand your request better. Could you help clarify:\n\n{}",
1843            questions
1844                .iter()
1845                .enumerate()
1846                .map(|(i, q)| format!("{}. {}", i + 1, q))
1847                .collect::<Vec<_>>()
1848                .join("\n")
1849        )
1850    }
1851}
1852
1853/// Format the final result for display.
1854/// Format execution result for display to the user.
1855fn format_execution_result(seed: &Seed, exec: &ExecutionResult) -> String {
1856    let mut lines = Vec::new();
1857
1858    if exec.success {
1859        lines.push(format!("✅ '{}'", seed.goal));
1860    } else {
1861        lines.push(format!(
1862            "âš ī¸ '{}'ė„(ëĨŧ) ė‹œë„í–ˆė§€ë§Œ ė™„ė „ížˆ ė„ąęŗĩí•˜ė§€ ëĒģ했ėŠĩ니다.",
1863            seed.goal
1864        ));
1865    }
1866
1867    // Show a truncated preview of the output if present.
1868    if !exec.output.is_empty() {
1869        let preview = if exec.output.len() > 500 {
1870            format!("{}...", &exec.output[..500])
1871        } else {
1872            exec.output.clone()
1873        };
1874        lines.push(String::new());
1875        lines.push(preview);
1876    }
1877
1878    lines.join("\n")
1879}
1880
1881/// Check if a seed should be split into subtasks.
1882///
1883/// Simple heuristic: if the seed has 3 or more acceptance criteria,
1884/// it likely contains distinct concerns that can be parallelized.
1885fn should_split_seed(seed: &Seed) -> bool {
1886    // Only split for genuinely complex tasks with many criteria.
1887    // Simple tasks (even with 3-4 criteria) are better handled by a single agent
1888    // to preserve context coherence.
1889    seed.acceptance_criteria.len() >= 5
1890}
1891
1892/// Split a seed into subtasks based on acceptance criteria.
1893///
1894/// Each acceptance criterion becomes a separate subtask with the
1895/// parent seed's goal as context. Infers required capability from
1896/// the goal text using the same heuristic as `build_agent_card`.
1897fn split_into_subtasks(seed: &Seed) -> Vec<SubTask> {
1898    seed.acceptance_criteria
1899        .iter()
1900        .map(|criterion| {
1901            let desc = format!("{}: {}", seed.goal, criterion);
1902            let desc_lower = desc.to_lowercase();
1903
1904            // Infer capability from subtask description.
1905            let cap = if desc_lower.contains("review") || desc_lower.contains("code") {
1906                Some("code-review".to_string())
1907            } else if desc_lower.contains("test") {
1908                Some("testing".to_string())
1909            } else if desc_lower.contains("refactor") || desc_lower.contains("improve") {
1910                Some("refactoring".to_string())
1911            } else if desc_lower.contains("write")
1912                || desc_lower.contains("create")
1913                || desc_lower.contains("implement")
1914            {
1915                Some("code-generation".to_string())
1916            } else if desc_lower.contains("debug") || desc_lower.contains("fix") {
1917                Some("debugging".to_string())
1918            } else {
1919                None
1920            };
1921
1922            let mut subtask = SubTask::new(desc);
1923            subtask.required_capability = cap;
1924            subtask
1925        })
1926        .collect()
1927}
1928
1929/// Format combined results from multi-agent execution.
1930fn format_result_combined(combined: &str) -> String {
1931    if combined.is_empty() {
1932        "No subtasks completed successfully.".to_string()
1933    } else {
1934        format!("Multi-agent execution completed:\n\n{combined}")
1935    }
1936}
1937
1938/// Execute a subtask via lifecycle manager, returning (output, success).
1939async fn run_via_lifecycle(
1940    lifecycle: &crate::agent_lifecycle::AgentLifecycleManager,
1941    parent_seed: &Seed,
1942    description: &str,
1943) -> (String, bool) {
1944    let child_seed = Seed {
1945        id: Uuid::new_v4(),
1946        goal: description.to_string(),
1947        constraints: parent_seed.constraints.clone(),
1948        acceptance_criteria: vec!["Task completes successfully".into()],
1949        ontology: parent_seed.ontology.clone(),
1950        created_at: chrono::Utc::now(),
1951        generation: parent_seed.generation + 1,
1952        parent_seed_id: Some(parent_seed.id),
1953        cspace_hint: None,
1954        original_request: parent_seed.original_request.clone(),
1955        output_schema: None,
1956        project_id: parent_seed.project_id,
1957        workspace_context: parent_seed.workspace_context.clone(),
1958        mount_paths: parent_seed.mount_paths.clone(),
1959    };
1960    match lifecycle.spawn_and_run(&child_seed, Priority::Normal).await {
1961        Ok(result) => (result.output, result.success),
1962        Err(e) => (format!("Failed: {e}"), false),
1963    }
1964}
1965
1966/// Render the body of the `## Workspace Context` prompt section (RFC-025).
1967///
1968/// The caller (`build_system_prompt`) wraps this in the `## Workspace
1969/// Context` header. Returns `None` when there are no Mounts to describe.
1970///
1971/// Fill order respects the prompt budget (~1500 tokens soft):
1972/// 1. Primary Mount — full (description + summary + path).
1973/// 2. Secondary Mounts — name + path + one-line summary only.
1974fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
1975    if mounts.is_empty() {
1976        return None;
1977    }
1978    let mut out = String::new();
1979    out.push_str("### Active Mounts\n");
1980
1981    for (i, m) in mounts.iter().enumerate() {
1982        let primary = i == 0;
1983        let path = m
1984            .primary_path()
1985            .map(|p| p.to_string_lossy().to_string())
1986            .unwrap_or_else(|| "(no path)".to_string());
1987
1988        if primary {
1989            out.push_str(&format!("- **{}** → {}\n", m.name, path));
1990            if !m.auto_description.is_empty() {
1991                // First ~3 lines of the agent-written description.
1992                let desc: String = m
1993                    .auto_description
1994                    .lines()
1995                    .take(3)
1996                    .collect::<Vec<_>>()
1997                    .join("\n  ");
1998                out.push_str(&format!("  {}\n", desc));
1999            }
2000            let summary = m.summary_line();
2001            if !summary.is_empty() {
2002                out.push_str(&format!("  _{}_\n", summary));
2003            }
2004            if m.enrichment_pending {
2005                out.push_str("  _(content changed — consider re-scanning this Mount)_\n");
2006            }
2007        } else {
2008            // Secondary: name + path + one-line summary only.
2009            let summary = m.summary_line();
2010            let suffix = if summary.is_empty() {
2011                String::new()
2012            } else {
2013                format!(" — {}", summary)
2014            };
2015            out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
2016        }
2017    }
2018
2019    Some(out)
2020}
2021
2022#[cfg(test)]
2023mod mount_workspace_tests {
2024    use super::*;
2025    use crate::mount::{Mount, MountSource};
2026    use std::path::PathBuf;
2027
2028    #[test]
2029    fn test_workspace_context_primary_full_secondary_terse() {
2030        let mut oxios =
2031            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
2032        oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
2033        oxios.auto_meta.summary = "Rust agent OS".to_string();
2034
2035        let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
2036        oxi.auto_meta.summary = "SDK".to_string();
2037
2038        let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
2039        assert!(body.contains("### Active Mounts"));
2040        // Primary gets full description.
2041        assert!(body.contains("Agent OS."));
2042        assert!(body.contains("_Rust agent OS_"));
2043        // Secondary is terse.
2044        assert!(body.contains("**oxi** → /oxi — SDK"));
2045    }
2046
2047    #[test]
2048    fn test_workspace_context_empty_is_none() {
2049        assert!(build_workspace_context_body(&[]).is_none());
2050    }
2051
2052    /// End-to-end: a real MountManager + Orchestrator-less call to
2053    /// `resolve_mount_workspace` proves that detection seeds the primary,
2054    /// builds the context body, and collects all paths (multi-path access).
2055    #[test]
2056    fn test_resolve_mount_workspace_detects_and_collects_paths() {
2057        use crate::mount::MountManager;
2058        use oxios_memory::memory::sqlite::MemoryDatabase;
2059        use std::sync::Arc;
2060
2061        let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
2062        let mm = Arc::new(MountManager::new(db, None).unwrap());
2063
2064        // Register two mounts.
2065        let oxios = mm
2066            .create_mount(
2067                "oxios".to_string(),
2068                vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
2069                MountSource::Manual,
2070            )
2071            .unwrap();
2072        let oxi_sdk = mm
2073            .create_mount(
2074                "oxi-sdk".to_string(),
2075                vec![PathBuf::from("/Users/me/oxi")],
2076                MountSource::Manual,
2077            )
2078            .unwrap();
2079        mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
2080            .unwrap();
2081
2082        // Build a minimal Orchestrator-free resolver path: replicate what
2083        // resolve_mount_workspace does, but against the manager directly,
2084        // since the full Orchestrator needs many subsystems.
2085        let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
2086        assert_eq!(mounts.len(), 2);
2087
2088        let body = build_workspace_context_body(&mounts).unwrap();
2089        assert!(body.contains("oxios"));
2090        assert!(body.contains("Agent OS in Rust."));
2091        assert!(body.contains("oxi-sdk"));
2092
2093        // Collect paths like the orchestrator does.
2094        let mut paths = Vec::new();
2095        for m in &mounts {
2096            for p in &m.paths {
2097                if !paths.contains(p) {
2098                    paths.push(p.clone());
2099                }
2100            }
2101        }
2102        assert_eq!(paths.len(), 2);
2103        assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
2104        assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
2105    }
2106
2107    /// Detection layer 1 (name match) seeds the primary when no explicit
2108    /// mount_ids are given — the core promise of RFC-025.
2109    #[test]
2110    fn test_detection_seeds_primary_on_name_mention() {
2111        use crate::mount::{DetectionResult, detect_mounts};
2112
2113        let oxios =
2114            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
2115        let result = detect_mounts("oxios ėŊ”드ëĻŦëˇ°í•´ė¤˜", &[oxios.clone()]);
2116        assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
2117    }
2118}