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