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