Skip to main content

oxios_kernel/
orchestrator.rs

1//! Orchestrator: coordinates the unified intent lifecycle (RFC-027).
2//!
3//! The orchestrator is the "brain" that processes every user message:
4//! 1. assess — classify the message (conversation / clarify / task)
5//! 2. crystallize — build a Directive for substantial tasks
6//! 3. execute — run the agent via the lifecycle manager
7//! 4. review — check the result against acceptance criteria
8//! 5. retry — re-execute with feedback if review fails
9
10use std::sync::Arc;
11
12use anyhow::Result;
13use oxios_ouroboros::ExecutionResult;
14use parking_lot::RwLock;
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::agent_lifecycle::AgentLifecycleManager;
19use crate::event_bus::EventBus;
20use crate::git_layer::GitLayer;
21use crate::metrics::get_metrics;
22use crate::mount::{MountId, MountManager};
23use crate::project::{ConversationBuffer, ProjectManager};
24use crate::scheduler::Priority;
25use crate::state_store::StateStore;
26use crate::types::AgentId;
27
28/// Role of an agent within a group.
29#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
30pub enum AgentRole {
31    /// Executes a specific subtask.
32    #[default]
33    Worker,
34    /// Coordinates subtasks, synthesizes results.
35    Manager,
36}
37
38/// A subtask within a multi-agent plan.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SubTask {
41    /// Unique subtask ID.
42    pub id: Uuid,
43    /// Human-readable description.
44    pub description: String,
45    /// Capability required (e.g., "code-review", "testing").
46    pub required_capability: Option<String>,
47    /// Result of the subtask (filled after execution).
48    pub result: Option<String>,
49    /// Whether this subtask succeeded.
50    pub success: bool,
51    /// Role of the agent assigned to this subtask.
52    #[serde(default)]
53    pub role: AgentRole,
54}
55
56impl SubTask {
57    /// Create a new subtask with the given description.
58    pub fn new(description: impl Into<String>) -> Self {
59        Self {
60            id: Uuid::new_v4(),
61            description: description.into(),
62            required_capability: None,
63            result: None,
64            success: false,
65            role: AgentRole::default(),
66        }
67    }
68
69    /// Set the required capability for this subtask.
70    pub fn with_capability(mut self, cap: impl Into<String>) -> Self {
71        self.required_capability = Some(cap.into());
72        self
73    }
74}
75
76/// The orchestrator coordinates the unified intent lifecycle (RFC-027).
77#[allow(dead_code)]
78pub struct Orchestrator {
79    /// IntentEngine for the unified handle() path (RFC-027).
80    /// Lazily available when the kernel wires it; None in legacy constructions.
81    intent_engine: RwLock<Option<Arc<dyn oxios_ouroboros::IntentEngineOps>>>,
82    event_bus: EventBus,
83    state_store: Arc<StateStore>,
84    /// Git version control layer for auto-commits.
85    git_layer: Option<Arc<GitLayer>>,
86    /// Agent lifecycle manager (fork, register, run, cleanup).
87    lifecycle: AgentLifecycleManager,
88    /// A2A protocol for inter-agent task delegation.
89    a2a: Option<Arc<crate::a2a::A2AProtocol>>,
90    /// Project manager for context partitioning.
91    project_manager: RwLock<Option<Arc<ProjectManager>>>,
92    /// Mount manager for path-alias context (RFC-025).
93    mount_manager: RwLock<Option<Arc<MountManager>>>,
94    /// Conversation buffer for topic shift detection.
95    conversation_buffer: RwLock<ConversationBuffer>,
96    /// Orchestrator configuration (Ouroboros protocol settings).
97    delegation_config: DelegationConfig,
98    /// A2A circuit breaker for delegation reliability.
99    a2a_breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>,
100    /// RFC-027 intent config (retry settings, etc).
101    intent_config: RwLock<crate::config::IntentConfig>,
102    /// RFC-029 recovery coordinator. When `Some`, the orchestrator's
103    /// execute path routes through it (L1 backoff / L2 model swap)
104    /// instead of calling lifecycle directly.
105    recovery: RwLock<Option<Arc<crate::resilience::RecoveryCoordinator>>>,
106}
107
108/// Configuration for A2A delegation retries.
109#[allow(dead_code)]
110struct DelegationConfig {
111    /// Maximum retry attempts for A2A delegation.
112    max_retries: u32,
113    /// Base delay for exponential backoff (milliseconds).
114    base_delay_ms: u64,
115    /// Maximum delay cap for exponential backoff (milliseconds).
116    max_delay_ms: u64,
117    /// Timeout per delegation attempt (milliseconds).
118    #[allow(dead_code)]
119    timeout_ms: u64,
120}
121
122impl Default for DelegationConfig {
123    fn default() -> Self {
124        Self {
125            max_retries: 3,
126            base_delay_ms: 100,
127            max_delay_ms: 5000,
128            timeout_ms: 5000,
129        }
130    }
131}
132
133#[allow(dead_code)]
134impl DelegationConfig {
135    /// Calculate exponential backoff delay.
136    fn backoff_delay(&self, attempt: u32) -> u64 {
137        let delay = self.base_delay_ms * 2_u64.saturating_pow(attempt.min(10));
138        delay.min(self.max_delay_ms)
139    }
140}
141
142impl Orchestrator {
143    /// Creates a new orchestrator.
144    pub fn new(
145        event_bus: EventBus,
146        state_store: Arc<StateStore>,
147        lifecycle: AgentLifecycleManager,
148    ) -> Self {
149        Self::with_config(
150            event_bus,
151            state_store,
152            lifecycle,
153            crate::config::OrchestratorConfig::default(),
154        )
155    }
156
157    /// Creates a new orchestrator with custom config.
158    pub fn with_config(
159        event_bus: EventBus,
160        state_store: Arc<StateStore>,
161        lifecycle: AgentLifecycleManager,
162        _config: crate::config::OrchestratorConfig,
163    ) -> Self {
164        Self {
165            intent_engine: RwLock::new(None),
166            event_bus,
167            state_store,
168            git_layer: None,
169            lifecycle,
170            a2a: None,
171            project_manager: RwLock::new(None),
172            mount_manager: RwLock::new(None),
173            conversation_buffer: RwLock::new(ConversationBuffer::default()),
174            delegation_config: DelegationConfig::default(),
175            intent_config: RwLock::new(crate::config::IntentConfig::default()),
176            a2a_breaker: Arc::new(crate::a2a::circuit_breaker::A2ACircuitBreaker::new(5, 30)),
177            recovery: RwLock::new(None),
178        }
179    }
180
181    /// Wire the IntentEngine for unified handle() calls (RFC-027).
182    /// Called by the kernel assembler after construction.
183    pub fn set_intent_engine(&self, engine: Arc<dyn oxios_ouroboros::IntentEngineOps>) {
184        *self.intent_engine.write() = Some(engine);
185    }
186
187    /// Wire the RFC-029 recovery coordinator. Called by the kernel
188    /// assembler after construction (shares `RoutingStats` with
189    /// `EngineApi` / `AgentRuntime`).
190    pub fn set_recovery(&self, coordinator: Arc<crate::resilience::RecoveryCoordinator>) {
191        *self.recovery.write() = Some(coordinator);
192    }
193
194    /// Whether the IntentEngine is wired (unified path available).
195    pub fn has_intent_engine(&self) -> bool {
196        self.intent_engine.read().is_some()
197    }
198
199    /// Set the ProjectManager for context partitioning.
200    pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
201        *self.project_manager.write() = Some(manager);
202    }
203
204    /// Set the MountManager for path-alias context (RFC-025).
205    pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
206        *self.mount_manager.write() = Some(manager);
207    }
208
209    /// Get a reference to the MountManager, if set (RFC-025).
210    pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
211        self.mount_manager.read().as_ref().cloned()
212    }
213
214    /// Get a reference to the ProjectManager, if set.
215    pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
216        self.project_manager.read().as_ref().cloned()
217    }
218
219    /// Detect a project from a message, returning tag string.
220    pub fn detect_project_tag(&self, message: &str) -> Option<String> {
221        self.project_manager.read().as_ref().and_then(|pm| {
222            let projects = pm.list_projects();
223            let result = crate::project::detect_project(message, &projects);
224            match result {
225                crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
226                crate::project::DetectionResult::NoMatch { .. } => None,
227            }
228        })
229    }
230
231    /// Resolve the active Mounts for a message (RFC-025).
232    ///
233    /// Parses explicit `mount_ids` ("uuid1,uuid2,...", primary first); when
234    /// none are given, auto-detects from the message. Returns:
235    /// - the ordered list of active [`MountId`]s,
236    /// - the rendered `## Workspace Context` body (without the header),
237    /// - all resolved filesystem paths (primary first),
238    /// - a display tag like `[🔧 oxios + oxi-sdk]`.
239    ///
240    /// Honors the sticky-primary model: when `mount_ids` is explicitly
241    /// provided they are used as-is (detection is skipped). Detection only
242    /// runs when `mount_ids` is `None`, seeding the primary slot — it never
243    /// replaces an explicit primary, only appends a secondary.
244    fn resolve_mount_workspace(
245        &self,
246        mount_ids: Option<&str>,
247        project_ids: Option<&str>,
248        user_message: &str,
249    ) -> (
250        Vec<MountId>,
251        Option<String>,
252        Vec<std::path::PathBuf>,
253        String,
254    ) {
255        use crate::mount::Mount;
256
257        let Some(mm) = self.mount_manager() else {
258            return (Vec::new(), None, Vec::new(), String::new());
259        };
260
261        // Parse explicit mount_ids; otherwise auto-detect (seeds the primary slot).
262        let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
263            ids_str
264                .split(',')
265                .filter_map(|s| MountId::parse_str(s.trim()).ok())
266                .collect()
267        } else {
268            match mm.detect(user_message) {
269                crate::mount::DetectionResult::Found(id) => vec![id],
270                crate::mount::DetectionResult::NoMatch { .. } => vec![],
271            }
272        };
273        // De-duplicate while preserving order (handles non-consecutive dups).
274        let mut seen = std::collections::HashSet::new();
275        ids.retain(|id| seen.insert(*id));
276
277        // ── Project-referenced Mount activation (RFC-025) ──
278        // When a project_id is provided, auto-activate its referenced Mounts
279        // BEFORE we derive mounts/tag/context/paths, so they are fully
280        // visible in the system prompt and the badge — not just granted
281        // path access. (Previously this ran after the prompt was built, so
282        // project-referenced Mounts were invisible in the context body.)
283        let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
284            project_ids
285            && let Some(first_id_str) = project_ids_str.split(',').next()
286            && let Some(pm) = self.project_manager()
287            && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
288        {
289            let proj = pm.get_project(pid);
290            if let Some(ref project) = proj {
291                for mid in &project.mount_ids {
292                    if !ids.contains(mid) {
293                        ids.push(*mid);
294                    }
295                }
296            }
297            proj
298        } else {
299            None
300        };
301
302        if ids.is_empty() {
303            return (Vec::new(), None, Vec::new(), String::new());
304        }
305
306        // Touch each active Mount (record activity) — now includes any
307        // Project-referenced Mounts activated above.
308        for id in &ids {
309            mm.touch(*id);
310        }
311
312        let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
313        if mounts.is_empty() {
314            return (Vec::new(), None, Vec::new(), String::new());
315        }
316
317        // Collect all paths (primary first, deduped) over the full Mount set.
318        let mut paths: Vec<std::path::PathBuf> = Vec::new();
319        for m in &mounts {
320            for p in &m.paths {
321                if !paths.contains(p) {
322                    paths.push(p.clone());
323                }
324            }
325        }
326
327        // Legacy fallback (RFC-025 migration window): a Project created
328        // before Mounts may carry explicit `paths` but no `mount_ids`. In
329        // that case grant path access directly so pre-RFC-025 Projects still
330        // resolve a CWD and populate `allowed_paths` (see agent_runtime.rs).
331        if let Some(project) = &project_for_instructions
332            && project.mount_ids.is_empty()
333            && !project.paths.is_empty()
334        {
335            for p in &project.paths {
336                if !paths.contains(p) {
337                    paths.push(p.clone());
338                }
339            }
340        }
341
342        // Display tag.
343        let tag = if mounts.len() == 1 {
344            mounts[0].tag()
345        } else {
346            let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
347            format!("[🔧 {}]", names.join(" + "))
348        };
349
350        let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
351
352        // ── Project instructions (RFC-025) ──
353        // Inject the project's instructions into the context body. The
354        // "### Active Mounts" header above is only present when there are
355        // actual mount entries in `context`; the Project Instructions section
356        // stands on its own when only instructions exist.
357        if let Some(project) = project_for_instructions {
358            // Cap instructions to stay within the prompt budget (~500 tokens).
359            let instructions = if project.instructions.len() > 2000 {
360                let mut end = 2000;
361                while end > 0 && !project.instructions.is_char_boundary(end) {
362                    end -= 1;
363                }
364                format!("{}...", &project.instructions[..end])
365            } else {
366                project.instructions.clone()
367            };
368            if !instructions.is_empty() {
369                context.push_str(&format!(
370                    "\n### Project Instructions: {}\n{}\n",
371                    project.name, instructions
372                ));
373            }
374        }
375
376        // Enforce a hard prompt budget on the final context body (~1500 tokens).
377        const MAX_CONTEXT_CHARS: usize = 6000;
378        if context.len() > MAX_CONTEXT_CHARS {
379            let mut end = MAX_CONTEXT_CHARS;
380            while end > 0 && !context.is_char_boundary(end) {
381                end -= 1;
382            }
383            context.truncate(end);
384            context.push_str("\n...(context truncated)...\n");
385        }
386
387        let context_opt = if context.is_empty() {
388            None
389        } else {
390            Some(context)
391        };
392        (ids, context_opt, paths, tag)
393    }
394
395    /// Set the A2A protocol for inter-agent task delegation.
396    pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
397        self.a2a = Some(a2a);
398    }
399
400    /// Set the GitLayer for auto-commits after state saves.
401    pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
402        self.git_layer = Some(git_layer);
403    }
404
405    /// Restore sessions from persisted state.
406    ///
407    /// RFC-027: the in-memory interview session map is no longer used.
408    /// Clarify state is restored from the session store's conversation
409    /// history on demand by `handle_unified`. This function is a no-op.
410    pub async fn restore_sessions(&self) {
411        // No-op — see doc comment above.
412    }
413
414    #[allow(dead_code)]
415    fn git_commit(&self, rel_path: &str, message: &str) {
416        if let Some(ref gl) = self.git_layer
417            && gl.is_enabled()
418        {
419            let _ = gl.commit_file(rel_path, message);
420        }
421    }
422
423    // ──────────────────────────────────────────────────────────────────
424    // RFC-027 §3 — Unified intent handler
425    // ──────────────────────────────────────────────────────────────────
426    //
427    // Phase 5 entry point. Routes a single user message through:
428    //   assess → (crystallize) → execute → (review + retry) → Response
429    //
430    // The legacy `handle_message()` and `chat()` paths remain intact;
431    // this method coexists alongside them until Phase 6 cuts over.
432
433    /// Unified entry point for every user message (RFC-027 §3).
434    ///
435    /// One method, one match — depth falls out of [`Scope`]:
436    ///
437    /// 1. [`IntentEngine::assess`] classifies the message (conversation /
438    ///    clarify / task + scope). Always called once.
439    /// 2. For `Assessment::Task(scope)` we build a [`Directive`]:
440    ///    - `Scope::Trivial` — `Directive::from_message(msg)` verbatim.
441    ///    - `Scope::Substantial` — [`IntentEngine::crystallize`] produces a
442    ///      structured directive with goal, constraints, and acceptance
443    ///      criteria.
444    /// 3. The [`ExecEnv`] is resolved from [`MsgCtx`] independently of the
445    ///    directive (Mount workspace context, paths, project ID, cspace hint).
446    /// 4. Execution delegates to the existing [`AgentLifecycleManager`].
447    ///    In Phase 5 the Directive is shimmed into a [`Seed`] for the
448    ///    current pipeline; Phase 6 will replace this with
449    ///    `lifecycle.execute_directive(&Directive, &ExecEnv)`.
450    /// 5. For `Scope::Substantial` we call [`IntentEngine::review`] and, if
451    ///    the verdict fails, retry once with the verdict's gaps folded back
452    ///    into the directive as additional constraints.
453    ///
454    /// Does not modify or call the legacy `handle_message()` / `chat()`
455    /// paths — they remain the canonical entry points until Phase 6 cuts
456    /// the gateway over to `handle()`.
457    ///
458    /// # Parameters
459    /// - `engine` — the LLM-backed intent engine (assess/crystallize/review).
460    /// - `msg` — the user's raw message text.
461    /// - `ctx` — per-message context (session, history, project/mount hints).
462    pub async fn handle(
463        &self,
464        engine: &dyn oxios_ouroboros::IntentEngineOps,
465        msg: &str,
466        ctx: &oxios_ouroboros::MsgCtx,
467    ) -> Result<HandleResponse> {
468        // 1. assess — always called once, routes the message.
469        let assessment = engine.assess(msg, ctx).await?;
470
471        match assessment {
472            oxios_ouroboros::Assessment::Conversation(reply) => Ok(HandleResponse::Reply(reply)),
473
474            oxios_ouroboros::Assessment::Clarify { questions } => {
475                Ok(HandleResponse::Clarify(questions))
476            }
477
478            oxios_ouroboros::Assessment::Task(scope) => {
479                // 2. Build the Directive based on scope.
480                let mut directive = match scope {
481                    oxios_ouroboros::Scope::Trivial => {
482                        oxios_ouroboros::Directive::from_message(msg)
483                    }
484                    oxios_ouroboros::Scope::Substantial => engine.crystallize(msg, ctx).await?,
485                };
486
487                // 3. Resolve the execution environment from MsgCtx.
488                let env = self.resolve_exec_env(ctx, msg);
489
490                // 4. Execute (always). Trivial tasks skip review; substantial
491                //    tasks go through verify_or_retry below.
492                let mut result = self.execute_directive(&directive, &env).await?;
493
494                // 5. Verify + optional retry (Substantial only).
495                let (verdict, evaluation_passed) = match scope {
496                    oxios_ouroboros::Scope::Trivial => (None, None),
497                    oxios_ouroboros::Scope::Substantial => {
498                        let (r, v) = self
499                            .verify_or_retry(engine, &mut directive, &env, result, msg, ctx)
500                            .await?;
501                        result = r;
502                        let passed = v.all_passed();
503                        (Some(v), Some(passed))
504                    }
505                };
506
507                self.lifecycle.reap_zombies();
508
509                Ok(HandleResponse::Task {
510                    scope,
511                    directive: Box::new(directive),
512                    env: Box::new(env),
513                    result: Box::new(result),
514                    verdict,
515                    evaluation_passed,
516                })
517            }
518        }
519    }
520
521    /// Unified entry point that accepts legacy-style parameters and returns
522    /// an `OrchestrationResult` (RFC-027).
523    ///
524    /// Builds a [`MsgCtx`] from the session history (if any), then delegates
525    /// to [`handle`](Self::handle). Falls back to `handle_message` if no
526    /// `IntentEngine` is wired.
527    pub async fn handle_unified(
528        &self,
529        user_id: &str,
530        msg: &str,
531        session_id: Option<&str>,
532        project_ids: Option<&str>,
533        mount_ids: Option<&str>,
534        request_id: &str,
535    ) -> Result<OrchestrationResult> {
536        // Get the IntentEngine (always wired by the kernel assembler).
537        let engine = self
538            .intent_engine
539            .read()
540            .clone()
541            .expect("IntentEngine not wired — kernel assembler bug");
542
543        // Build MsgCtx.
544        let sid = session_id.unwrap_or(request_id).to_string();
545        let history = self.load_session_history(&sid).await;
546        let ctx = oxios_ouroboros::MsgCtx {
547            session_id: sid.clone(),
548            history,
549            project_ids: project_ids.map(String::from),
550            mount_ids: mount_ids.map(String::from),
551            user_id: user_id.to_string(),
552        };
553
554        // Call the unified path.
555        let start = std::time::Instant::now();
556        let response = self.handle(engine.as_ref(), msg, &ctx).await?;
557        let duration_ms = start.elapsed().as_millis() as u64;
558
559        Ok(self.handle_response_to_orchestration_result(response, &ctx, duration_ms))
560    }
561
562    /// Load conversation history for a session from the state store.
563    async fn load_session_history(&self, session_id: &str) -> Vec<oxios_ouroboros::Exchange> {
564        let sid = crate::state_store::SessionId(session_id.to_string());
565        match self.state_store.load_session(&sid).await {
566            Ok(Some(session)) => session
567                .user_messages
568                .iter()
569                .zip(session.agent_responses.iter())
570                .map(|(u, a)| oxios_ouroboros::Exchange {
571                    user: u.content.clone(),
572                    agent: a.content.clone(),
573                })
574                .collect(),
575            _ => Vec::new(),
576        }
577    }
578
579    fn handle_response_to_orchestration_result(
580        &self,
581        response: HandleResponse,
582        ctx: &oxios_ouroboros::MsgCtx,
583        duration_ms: u64,
584    ) -> OrchestrationResult {
585        let metrics = get_metrics();
586        metrics.orch_duration.observe(duration_ms as f64 / 1000.0);
587
588        match response {
589            HandleResponse::Reply(reply) => OrchestrationResult {
590                session_id: Some(ctx.session_id.clone()),
591                primary_project_id: None,
592                project_tag: None,
593                active_mount_ids: Vec::new(),
594                mount_tag: None,
595                response: reply,
596                agent_id: None,
597                phase_reached: "interview".to_string(),
598                evaluation_passed: None,
599                output: None,
600                tool_calls: Vec::new(),
601                interview_questions: None,
602                interview_round: None,
603            },
604            HandleResponse::Clarify(questions) => {
605                let questions_text = questions
606                    .iter()
607                    .map(|q| q.text.clone())
608                    .collect::<Vec<_>>()
609                    .join("\n");
610                let structured = Some(
611                    questions
612                        .iter()
613                        .map(|q| oxios_ouroboros::InterviewQuestionOutput {
614                            id: q.id.clone(),
615                            text: q.text.clone(),
616                            kind: format!("{:?}", q.kind).to_lowercase(),
617                            options: q
618                                .options
619                                .iter()
620                                .map(|o| oxios_ouroboros::InterviewOptionOutput {
621                                    value: o.value.clone(),
622                                    label: o.label.clone(),
623                                    description: String::new(),
624                                })
625                                .collect(),
626                        })
627                        .collect(),
628                );
629                OrchestrationResult {
630                    session_id: Some(ctx.session_id.clone()),
631                    primary_project_id: None,
632                    project_tag: None,
633                    active_mount_ids: Vec::new(),
634                    mount_tag: None,
635                    response: questions_text,
636                    agent_id: None,
637                    phase_reached: "interview".to_string(),
638                    evaluation_passed: None,
639                    output: None,
640                    tool_calls: Vec::new(),
641                    interview_questions: structured,
642                    interview_round: Some(((ctx.history.len() / 2) as u32).max(1)),
643                }
644            }
645            HandleResponse::Task {
646                scope: _,
647                directive,
648                env,
649                result,
650                verdict,
651                evaluation_passed,
652            } => {
653                let response_text = if directive.acceptance_criteria.is_empty() {
654                    result.output.clone()
655                } else {
656                    match &verdict {
657                        Some(v) if v.all_passed() => result.output.clone(),
658                        Some(v) => format!(
659                            "{}\n\n⚠ Review notes:\n{}",
660                            result.output,
661                            v.notes.join("\n")
662                        ),
663                        None => result.output.clone(),
664                    }
665                };
666                if evaluation_passed.unwrap_or(false) {
667                    metrics.agents_completed.inc();
668                } else {
669                    metrics.agents_failed.inc();
670                }
671                OrchestrationResult {
672                    session_id: Some(ctx.session_id.clone()),
673                    primary_project_id: env.project_id,
674                    project_tag: None,
675                    active_mount_ids: Vec::new(),
676                    mount_tag: None,
677                    response: response_text,
678                    agent_id: None,
679                    phase_reached: "execute".to_string(),
680                    evaluation_passed,
681                    output: Some(result.output.clone()),
682                    tool_calls: result.tool_calls.clone(),
683                    interview_questions: None,
684                    interview_round: None,
685                }
686            }
687        }
688    }
689
690    /// Resolve an [`ExecEnv`] from the per-message context.
691    ///
692    /// Mirrors the Mount workspace resolution done by `handle_message()`
693    /// and `chat()` but packages the result as the new [`ExecEnv`] type.
694    /// Independent of the directive — runs whether the task is Trivial
695    /// or Substantial.
696    fn resolve_exec_env(
697        &self,
698        ctx: &oxios_ouroboros::MsgCtx,
699        msg: &str,
700    ) -> oxios_ouroboros::ExecEnv {
701        let (active_mount_ids, workspace_context, mount_paths, _mount_tag) =
702            self.resolve_mount_workspace(ctx.mount_ids.as_deref(), ctx.project_ids.as_deref(), msg);
703        // active_mount_ids + mount_tag are surfaced via the legacy path;
704        // ExecEnv carries the resolved paths/context/project that the
705        // agent runtime actually consumes.
706        let _ = active_mount_ids;
707
708        // Resolve a primary project ID (matches handle_message semantics):
709        // explicit project_ids takes precedence over auto-detection.
710        let project_id = ctx
711            .project_ids
712            .as_deref()
713            .and_then(|ids| {
714                ids.split(',')
715                    .next()
716                    .and_then(|s| Uuid::parse_str(s.trim()).ok())
717            })
718            .or_else(|| {
719                self.detect_project_tag(msg).and_then(|_tag| {
720                    self.project_manager().and_then(|pm| {
721                        let projects = pm.list_projects();
722                        match crate::project::detect_project(msg, &projects) {
723                            crate::project::DetectionResult::Found(id) => Some(id),
724                            crate::project::DetectionResult::NoMatch { .. } => None,
725                        }
726                    })
727                })
728            });
729
730        // Touch the project to record activity (mirrors handle_message).
731        if let Some(pid) = project_id
732            && let Some(pm) = self.project_manager()
733        {
734            pm.touch(pid);
735        }
736
737        oxios_ouroboros::ExecEnv {
738            workspace_context,
739            mount_paths,
740            project_id,
741            cspace_hint: None,
742            model_override: None,
743            restore_state: None,
744        }
745    }
746
747    /// Execute a [`Directive`] under an [`ExecEnv`].
748    ///
749    /// **Phase 5 stub:** builds a legacy [`Seed`] from the directive and
750    /// delegates to the existing [`AgentLifecycleManager::spawn_and_run`]
751    /// pipeline. Phase 6 will replace this with
752    /// `lifecycle.execute_directive(&directive, &env)` once
753    /// AgentRuntime/Supervisor/AgentLifecycleManager have their
754    /// Directive-based methods (see sibling subagents Runtime and
755    /// RuntimeDirective).
756    async fn execute_directive(
757        &self,
758        directive: &oxios_ouroboros::Directive,
759        env: &oxios_ouroboros::ExecEnv,
760    ) -> Result<ExecutionResult> {
761        // RFC-029: route through the recovery coordinator when wired
762        // (L1 backoff / L2 model swap on provider failure). Falls back
763        // to a direct lifecycle call when no coordinator is set.
764        //
765        // Clone the Arc out of the read guard so the parking_lot guard
766        // (which is !Send) is dropped before the .await — otherwise the
767        // future is !Send and breaks tokio::spawn in the gateway.
768        let coordinator = self.recovery.read().as_ref().cloned();
769        if let Some(coordinator) = coordinator {
770            coordinator.execute(&self.lifecycle, directive, env).await
771        } else {
772            self.lifecycle
773                .execute_directive(directive, env, Priority::Normal)
774                .await
775        }
776    }
777
778    /// Review the result against the directive's criteria; on failure,
779    /// retry once with the verdict's gaps folded back as constraints.
780    ///
781    /// Phase 5 caps retries at one explicit attempt; once IntentConfig
782    /// lands (Phase 5 sibling subtask) this will read the configured
783    /// `max_retries` instead.
784    async fn verify_or_retry(
785        &self,
786        engine: &dyn oxios_ouroboros::IntentEngineOps,
787        directive: &mut oxios_ouroboros::Directive,
788        env: &oxios_ouroboros::ExecEnv,
789        initial_result: ExecutionResult,
790        _msg: &str,
791        _ctx: &oxios_ouroboros::MsgCtx,
792    ) -> Result<(ExecutionResult, oxios_ouroboros::Verdict)> {
793        let verdict = engine.review(directive, &initial_result).await?;
794
795        if verdict.all_passed() || verdict.gaps.is_empty() {
796            return Ok((initial_result, verdict));
797        }
798
799        // Check if retry is enabled (RFC-027 Decision 6).
800        // When disabled, return the initial result with the failed verdict.
801        let enable_retry = self.intent_config.read().enable_retry;
802        if !enable_retry {
803            tracing::info!("Review failed but retry disabled (enable_retry=false)");
804            return Ok((initial_result, verdict));
805        }
806
807        let metrics = get_metrics();
808        metrics.retry_attempted.inc();
809
810        tracing::info!(
811            gaps = verdict.gaps.len(),
812            "Review failed — retrying with feedback"
813        );
814
815        // Execute with feedback: previous output + gaps injected.
816        let retry_result = self
817            .lifecycle
818            .execute_with_feedback(
819                directive,
820                env,
821                &initial_result,
822                &verdict.gaps,
823                Priority::Normal,
824            )
825            .await?;
826
827        // Re-review.
828        let retry_verdict = engine.review(directive, &retry_result).await?;
829
830        // Track retry effectiveness.
831        if retry_verdict.score > verdict.score {
832            metrics.retry_improved.inc();
833        } else if retry_verdict.score < verdict.score {
834            metrics.retry_degraded.inc();
835        } else {
836            metrics.retry_unchanged.inc();
837        }
838
839        // Return best result.
840        let chosen_result = if retry_verdict.score >= verdict.score {
841            retry_result
842        } else {
843            initial_result
844        };
845
846        Ok((chosen_result, retry_verdict))
847    }
848}
849
850/// Response envelope for [`Orchestrator::handle`] (RFC-027 §3).
851///
852/// One variant per terminal state of the unified handler:
853///
854/// - [`HandleResponse::Reply`] — conversational answer, no agent spawned.
855/// - [`HandleResponse::Clarify`] — the message was ambiguous; ask these
856///   structured questions before acting.
857/// - [`HandleResponse::Task`] — an agent executed the task. Carries the
858///   scope, the directive that was run, the resolved environment, the
859///   execution result, and (for substantial tasks) the review verdict +
860///   pass/fail.
861#[derive(Debug, Clone)]
862pub enum HandleResponse {
863    /// Conversational reply — no agent was spawned.
864    Reply(String),
865    /// Structured clarifying questions to ask before acting.
866    Clarify(Vec<oxios_ouroboros::Question>),
867    /// A task was executed by an agent.
868    Task {
869        /// The scope decided by `assess` — Trivial skips review.
870        scope: oxios_ouroboros::Scope,
871        /// The directive that was executed (post-retry if a retry ran).
872        directive: Box<oxios_ouroboros::Directive>,
873        /// The execution environment resolved for this message.
874        env: Box<oxios_ouroboros::ExecEnv>,
875        /// The execution result.
876        result: Box<ExecutionResult>,
877        /// The review verdict — `None` for `Scope::Trivial`.
878        verdict: Option<oxios_ouroboros::Verdict>,
879        /// Whether the (final) verdict passed — `None` for `Scope::Trivial`.
880        evaluation_passed: Option<bool>,
881    },
882}
883
884/// Result of a full orchestration cycle.
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct OrchestrationResult {
887    /// Session ID for multi-turn interviews. Pass this on follow-up messages.
888    #[serde(skip_serializing_if = "Option::is_none")]
889    pub session_id: Option<String>,
890    /// The Space ID that handled this message.
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub primary_project_id: Option<Uuid>,
893    /// Space decoration tag for the response (e.g. "[🔧 oxios]").
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub project_tag: Option<String>,
896    /// Active Mount IDs for this message (RFC-025), primary first.
897    #[serde(default, skip_serializing_if = "Vec::is_empty")]
898    pub active_mount_ids: Vec<MountId>,
899    /// Mount decoration tag for the response (e.g. "[🔧 oxios + oxi-sdk]").
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub mount_tag: Option<String>,
902    /// The response to send back to the user.
903    pub response: String,
904    /// The agent that executed (if execute phase was reached).
905    #[serde(skip_serializing_if = "Option::is_none")]
906    pub agent_id: Option<AgentId>,
907    /// The furthest phase reached: "interview" (conversation/clarify) or "execute" (task executed).
908    pub phase_reached: String,
909    /// Whether evaluation passed.
910    ///
911    /// - `None` — evaluation was not applicable (interview, chat, non-task).
912    /// - `Some(true)` — evaluation passed.
913    /// - `Some(false)` — evaluation failed or execution unsuccessful.
914    pub evaluation_passed: Option<bool>,
915    /// Output or notes from evaluation.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub output: Option<String>,
918    /// Tool calls recorded during execution.
919    #[serde(default, skip_serializing_if = "Vec::is_empty")]
920    pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
921    /// Structured interview questions (chat UI redesign — interactive
922    /// interview). Populated when the interview phase needs clarification
923    /// and the LLM produced a structured form of the questions. The
924    /// Gateway forwards this to the WebSocket as an `interview` chunk;
925    /// the Web UI renders it as interactive widgets (chips, yes/no
926    /// buttons). When `None`, the frontend falls back to rendering
927    /// `response` as plain markdown.
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub interview_questions: Option<Vec<oxios_ouroboros::InterviewQuestionOutput>>,
930    /// Current interview round (1-based). Populated alongside
931    /// `interview_questions`. Drives the "Round N/M" indicator.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub interview_round: Option<u32>,
934}
935
936/// Render the body of the `## Workspace Context` prompt section (RFC-025).
937///
938/// The caller (`build_system_prompt`) wraps this in the `## Workspace
939/// Context` header. Returns `None` when there are no Mounts to describe.
940///
941/// Fill order respects the prompt budget (~1500 tokens soft):
942/// 1. Primary Mount — full (description + summary + path).
943/// 2. Secondary Mounts — name + path + one-line summary only.
944fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
945    if mounts.is_empty() {
946        return None;
947    }
948    let mut out = String::new();
949    out.push_str("### Active Mounts\n");
950
951    for (i, m) in mounts.iter().enumerate() {
952        let primary = i == 0;
953        let path = m
954            .primary_path()
955            .map(|p| p.to_string_lossy().to_string())
956            .unwrap_or_else(|| "(no path)".to_string());
957
958        if primary {
959            out.push_str(&format!("- **{}** → {}\n", m.name, path));
960            if !m.auto_description.is_empty() {
961                // First ~3 lines of the agent-written description.
962                let desc: String = m
963                    .auto_description
964                    .lines()
965                    .take(3)
966                    .collect::<Vec<_>>()
967                    .join("\n  ");
968                out.push_str(&format!("  {}\n", desc));
969            }
970            let summary = m.summary_line();
971            if !summary.is_empty() {
972                out.push_str(&format!("  _{}_\n", summary));
973            }
974            if m.enrichment_pending {
975                out.push_str("  _(content changed — consider re-scanning this Mount)_\n");
976            }
977        } else {
978            // Secondary: name + path + one-line summary only.
979            let summary = m.summary_line();
980            let suffix = if summary.is_empty() {
981                String::new()
982            } else {
983                format!(" — {}", summary)
984            };
985            out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
986        }
987    }
988
989    Some(out)
990}
991
992#[cfg(test)]
993mod mount_workspace_tests {
994    use super::*;
995    use crate::mount::{Mount, MountSource};
996    use std::path::PathBuf;
997
998    #[test]
999    fn test_workspace_context_primary_full_secondary_terse() {
1000        let mut oxios =
1001            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1002        oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
1003        oxios.auto_meta.summary = "Rust agent OS".to_string();
1004
1005        let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
1006        oxi.auto_meta.summary = "SDK".to_string();
1007
1008        let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
1009        assert!(body.contains("### Active Mounts"));
1010        // Primary gets full description.
1011        assert!(body.contains("Agent OS."));
1012        assert!(body.contains("_Rust agent OS_"));
1013        // Secondary is terse.
1014        assert!(body.contains("**oxi** → /oxi — SDK"));
1015    }
1016
1017    #[test]
1018    fn test_workspace_context_empty_is_none() {
1019        assert!(build_workspace_context_body(&[]).is_none());
1020    }
1021
1022    /// End-to-end: a real MountManager + Orchestrator-less call to
1023    /// `resolve_mount_workspace` proves that detection seeds the primary,
1024    /// builds the context body, and collects all paths (multi-path access).
1025    #[test]
1026    fn test_resolve_mount_workspace_detects_and_collects_paths() {
1027        use crate::mount::MountManager;
1028        use oxios_memory::memory::sqlite::MemoryDatabase;
1029        use std::sync::Arc;
1030
1031        let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
1032        let mm = Arc::new(MountManager::new(db, None).unwrap());
1033
1034        // Register two mounts.
1035        let oxios = mm
1036            .create_mount(
1037                "oxios".to_string(),
1038                vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
1039                MountSource::Manual,
1040            )
1041            .unwrap();
1042        let oxi_sdk = mm
1043            .create_mount(
1044                "oxi-sdk".to_string(),
1045                vec![PathBuf::from("/Users/me/oxi")],
1046                MountSource::Manual,
1047            )
1048            .unwrap();
1049        mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
1050            .unwrap();
1051
1052        // Build a minimal Orchestrator-free resolver path: replicate what
1053        // resolve_mount_workspace does, but against the manager directly,
1054        // since the full Orchestrator needs many subsystems.
1055        let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
1056        assert_eq!(mounts.len(), 2);
1057
1058        let body = build_workspace_context_body(&mounts).unwrap();
1059        assert!(body.contains("oxios"));
1060        assert!(body.contains("Agent OS in Rust."));
1061        assert!(body.contains("oxi-sdk"));
1062
1063        // Collect paths like the orchestrator does.
1064        let mut paths = Vec::new();
1065        for m in &mounts {
1066            for p in &m.paths {
1067                if !paths.contains(p) {
1068                    paths.push(p.clone());
1069                }
1070            }
1071        }
1072        assert_eq!(paths.len(), 2);
1073        assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1074        assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
1075    }
1076
1077    /// Detection layer 1 (name match) seeds the primary when no explicit
1078    /// mount_ids are given — the core promise of RFC-025.
1079    #[test]
1080    fn test_detection_seeds_primary_on_name_mention() {
1081        use crate::mount::{DetectionResult, detect_mounts};
1082
1083        let oxios =
1084            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1085        let result = detect_mounts("oxios 코드리뷰해줘", std::slice::from_ref(&oxios));
1086        assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
1087    }
1088}