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::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-027 intent config (retry settings, lightweight model, etc.)
187    /// from the parsed TOML. Called by the kernel assembler after construction;
188    /// before this, retry thresholds hold their `Default` values.
189    pub fn set_intent_config(&self, cfg: crate::config::IntentConfig) {
190        *self.intent_config.write() = cfg;
191    }
192
193    /// Wire the RFC-029 recovery coordinator. Called by the kernel
194    /// assembler after construction (shares `RoutingStats` with
195    /// `EngineApi` / `AgentRuntime`).
196    pub fn set_recovery(&self, coordinator: Arc<crate::resilience::RecoveryCoordinator>) {
197        *self.recovery.write() = Some(coordinator);
198    }
199
200    /// Whether the IntentEngine is wired (unified path available).
201    pub fn has_intent_engine(&self) -> bool {
202        self.intent_engine.read().is_some()
203    }
204
205    /// Set the ProjectManager for context partitioning.
206    pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
207        *self.project_manager.write() = Some(manager);
208    }
209
210    /// Set the MountManager for path-alias context (RFC-025).
211    pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
212        *self.mount_manager.write() = Some(manager);
213    }
214
215    /// Get a reference to the MountManager, if set (RFC-025).
216    pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
217        self.mount_manager.read().as_ref().cloned()
218    }
219
220    /// Get a reference to the ProjectManager, if set.
221    pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
222        self.project_manager.read().as_ref().cloned()
223    }
224
225    /// Detect a project from a message, returning tag string.
226    pub fn detect_project_tag(&self, message: &str) -> Option<String> {
227        self.project_manager.read().as_ref().and_then(|pm| {
228            let projects = pm.list_projects();
229            let result = crate::project::detect_project(message, &projects);
230            match result {
231                crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
232                crate::project::DetectionResult::NoMatch { .. } => None,
233            }
234        })
235    }
236
237    /// Resolve the active Mounts for a message (RFC-025).
238    ///
239    /// Parses explicit `mount_ids` ("uuid1,uuid2,...", primary first); when
240    /// none are given, auto-detects from the message. Returns:
241    /// - the ordered list of active [`MountId`]s,
242    /// - the rendered `## Workspace Context` body (without the header),
243    /// - all resolved filesystem paths (primary first),
244    /// - a display tag like `[🔧 oxios + oxi-sdk]`.
245    ///
246    /// Honors the sticky-primary model: when `mount_ids` is explicitly
247    /// provided they are used as-is (detection is skipped). Detection only
248    /// runs when `mount_ids` is `None`, seeding the primary slot — it never
249    /// replaces an explicit primary, only appends a secondary.
250    fn resolve_mount_workspace(
251        &self,
252        mount_ids: Option<&str>,
253        project_ids: Option<&str>,
254        user_message: &str,
255    ) -> (
256        Vec<MountId>,
257        Option<String>,
258        Vec<std::path::PathBuf>,
259        String,
260    ) {
261        use crate::mount::Mount;
262
263        let Some(mm) = self.mount_manager() else {
264            return (Vec::new(), None, Vec::new(), String::new());
265        };
266
267        // Parse explicit mount_ids; otherwise auto-detect (seeds the primary slot).
268        let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
269            ids_str
270                .split(',')
271                .filter_map(|s| MountId::parse_str(s.trim()).ok())
272                .collect()
273        } else {
274            match mm.detect(user_message) {
275                crate::mount::DetectionResult::Found(id) => vec![id],
276                crate::mount::DetectionResult::NoMatch { .. } => vec![],
277            }
278        };
279        // De-duplicate while preserving order (handles non-consecutive dups).
280        let mut seen = std::collections::HashSet::new();
281        ids.retain(|id| seen.insert(*id));
282
283        // ── Project-referenced Mount activation (RFC-025) ──
284        // When a project_id is provided, auto-activate its referenced Mounts
285        // BEFORE we derive mounts/tag/context/paths, so they are fully
286        // visible in the system prompt and the badge — not just granted
287        // path access. (Previously this ran after the prompt was built, so
288        // project-referenced Mounts were invisible in the context body.)
289        let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
290            project_ids
291            && let Some(first_id_str) = project_ids_str.split(',').next()
292            && let Some(pm) = self.project_manager()
293            && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
294        {
295            let proj = pm.get_project(pid);
296            if let Some(ref project) = proj {
297                for mid in &project.mount_ids {
298                    if !ids.contains(mid) {
299                        ids.push(*mid);
300                    }
301                }
302            }
303            proj
304        } else {
305            None
306        };
307
308        if ids.is_empty() {
309            return (Vec::new(), None, Vec::new(), String::new());
310        }
311
312        // Touch each active Mount (record activity) — now includes any
313        // Project-referenced Mounts activated above.
314        for id in &ids {
315            mm.touch(*id);
316        }
317
318        let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
319        if mounts.is_empty() {
320            return (Vec::new(), None, Vec::new(), String::new());
321        }
322
323        // Collect all paths (primary first, deduped) over the full Mount set.
324        let mut paths: Vec<std::path::PathBuf> = Vec::new();
325        for m in &mounts {
326            for p in &m.paths {
327                if !paths.contains(p) {
328                    paths.push(p.clone());
329                }
330            }
331        }
332
333        // Display tag.
334        let tag = if mounts.len() == 1 {
335            mounts[0].tag()
336        } else {
337            let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
338            format!("[🔧 {}]", names.join(" + "))
339        };
340
341        let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
342
343        // ── Project instructions (RFC-025) ──
344        // Inject the project's instructions into the context body. The
345        // "### Active Mounts" header above is only present when there are
346        // actual mount entries in `context`; the Project Instructions section
347        // stands on its own when only instructions exist.
348        if let Some(project) = project_for_instructions {
349            // Cap instructions to stay within the prompt budget (~500 tokens).
350            let instructions = if project.instructions.len() > 2000 {
351                let mut end = 2000;
352                while end > 0 && !project.instructions.is_char_boundary(end) {
353                    end -= 1;
354                }
355                format!("{}...", &project.instructions[..end])
356            } else {
357                project.instructions.clone()
358            };
359            if !instructions.is_empty() {
360                context.push_str(&format!(
361                    "\n### Project Instructions: {}\n{}\n",
362                    project.name, instructions
363                ));
364            }
365        }
366
367        // Enforce a hard prompt budget on the final context body (~1500 tokens).
368        const MAX_CONTEXT_CHARS: usize = 6000;
369        if context.len() > MAX_CONTEXT_CHARS {
370            let mut end = MAX_CONTEXT_CHARS;
371            while end > 0 && !context.is_char_boundary(end) {
372                end -= 1;
373            }
374            context.truncate(end);
375            context.push_str("\n...(context truncated)...\n");
376        }
377
378        let context_opt = if context.is_empty() {
379            None
380        } else {
381            Some(context)
382        };
383        (ids, context_opt, paths, tag)
384    }
385
386    /// Set the A2A protocol for inter-agent task delegation.
387    pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
388        self.a2a = Some(a2a);
389    }
390
391    /// Set the GitLayer for auto-commits after state saves.
392    pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
393        self.git_layer = Some(git_layer);
394    }
395
396    /// Restore sessions from persisted state.
397    ///
398    /// RFC-027: the in-memory interview session map is no longer used.
399    /// Clarify state is restored from the session store's conversation
400    /// history on demand by `handle_unified`. This function is a no-op.
401    pub async fn restore_sessions(&self) {
402        // No-op — see doc comment above.
403    }
404
405    #[allow(dead_code)]
406    fn git_commit(&self, rel_path: &str, message: &str) {
407        if let Some(ref gl) = self.git_layer
408            && gl.is_enabled()
409        {
410            let _ = gl.commit_file(rel_path, message);
411        }
412    }
413
414    // ──────────────────────────────────────────────────────────────────
415    // RFC-033 — Unified streaming orchestration
416    // ──────────────────────────────────────────────────────────────────
417    //
418    // The assess/crystallize external LLM gates were removed. Every message
419    // streams through the agent loop directly — the agent's own
420    // UNDERSTAND → PLAN → EXECUTE → VERIFY → REPORT protocol classifies and
421    // plans inline. The only surviving external call is `review`, which
422    // fires when a Directive carries acceptance criteria.
423
424    /// Unified entry point for every user message (RFC-033).
425    ///
426    /// A single path with no routing gate: build a [`Directive`] verbatim
427    /// from the message, resolve the [`ExecEnv`], execute via the agent
428    /// loop (which streams every token/tool/thinking event), and — only
429    /// when the directive carries acceptance criteria — run an external
430    /// [`IntentEngineOps::review`] with one retry.
431    ///
432    /// Conversation, clarification, and task depth are all decided *inside*
433    /// the agent loop now (simple chat → plain streaming reply; ambiguity →
434    /// `ask_user` / `pi-questionnaire` tool; complex work → tool calls).
435    /// This matches Claude.ai / Gemini Web, where the model's intelligence
436    /// is the classifier and there is no pre-classification step.
437    ///
438    /// # Why `review` is gated on `needs_review()`
439    /// `Directive::from_message` (used for interactive chat) carries no
440    /// acceptance criteria, so interactive chat never triggers external
441    /// review — the agent's internal VERIFY step replaces it. The review
442    /// path survives for any future/automated producer of criteria-bearing
443    /// directives; until one is wired, `verify_or_retry` is dormant.
444    ///
445    /// # Parameters
446    /// - `engine` — the LLM-backed intent engine (review only, RFC-033).
447    /// - `msg` — the user's raw message text.
448    /// - `ctx` — per-message context (session, history, project/mount hints).
449    pub async fn handle(
450        &self,
451        engine: &dyn oxios_ouroboros::IntentEngineOps,
452        msg: &str,
453        ctx: &oxios_ouroboros::MsgCtx,
454    ) -> Result<HandleResponse> {
455        // 1. Build the Directive verbatim from the message (no crystallize).
456        let mut directive = oxios_ouroboros::Directive::from_message(msg);
457
458        // 2. Resolve the execution environment from MsgCtx.
459        let env = self.resolve_exec_env(ctx, msg);
460
461        // 3. Execute — every message streams through the agent loop.
462        let mut result = self.execute_directive(&directive, &env).await?;
463
464        // 4. Optional external review — only when the directive carries
465        //    acceptance criteria (RFC-033 §3.5). Interactive chat uses
466        //    Directive::from_message (no criteria), so this is skipped and
467        //    the agent's internal VERIFY step stands in for review.
468        let (verdict, evaluation_passed) = if directive.needs_review() {
469            let (r, v) = self
470                .verify_or_retry(engine, &mut directive, &env, result, msg, ctx)
471                .await?;
472            result = r;
473            let passed = v.all_passed();
474            (Some(v), Some(passed))
475        } else {
476            (None, None)
477        };
478
479        Ok(HandleResponse {
480            directive: Box::new(directive),
481            env: Box::new(env),
482            result: Box::new(result),
483            verdict,
484            evaluation_passed,
485        })
486    }
487
488    /// Unified entry point that accepts legacy-style parameters and returns
489    /// an `OrchestrationResult` (RFC-027).
490    ///
491    /// Builds a [`MsgCtx`] from the session history (if any), then delegates
492    /// to [`handle`](Self::handle). Falls back to `handle_message` if no
493    /// `IntentEngine` is wired.
494    #[allow(clippy::too_many_arguments)]
495    pub async fn handle_unified(
496        &self,
497        user_id: &str,
498        msg: &str,
499        session_id: Option<&str>,
500        project_ids: Option<&str>,
501        mount_ids: Option<&str>,
502        role: Option<&str>,
503        model_override: Option<&str>,
504        request_id: &str,
505    ) -> Result<OrchestrationResult> {
506        // Get the IntentEngine (always wired by the kernel assembler).
507        let engine = self
508            .intent_engine
509            .read()
510            .clone()
511            .expect("IntentEngine not wired — kernel assembler bug");
512
513        // Build MsgCtx.
514        let sid = session_id.unwrap_or(request_id).to_string();
515        let history = self.load_session_history(&sid).await;
516        let ctx = oxios_ouroboros::MsgCtx {
517            session_id: sid.clone(),
518            history,
519            project_ids: project_ids.map(String::from),
520            mount_ids: mount_ids.map(String::from),
521            role: role.map(String::from),
522            model_override: model_override.map(String::from),
523            user_id: user_id.to_string(),
524        };
525
526        // Call the unified path.
527        let start = std::time::Instant::now();
528        let response = self.handle(engine.as_ref(), msg, &ctx).await?;
529        let duration_ms = start.elapsed().as_millis() as u64;
530
531        Ok(self.handle_response_to_orchestration_result(response, &ctx, duration_ms))
532    }
533
534    /// Load conversation history for a session from the state store.
535    async fn load_session_history(&self, session_id: &str) -> Vec<oxios_ouroboros::Exchange> {
536        let sid = crate::state_store::SessionId(session_id.to_string());
537        match self.state_store.load_session(&sid).await {
538            Ok(Some(session)) => session
539                .user_messages
540                .iter()
541                .zip(session.agent_responses.iter())
542                .map(|(u, a)| oxios_ouroboros::Exchange {
543                    user: u.content.clone(),
544                    agent: a.content.clone(),
545                })
546                .collect(),
547            _ => Vec::new(),
548        }
549    }
550
551    fn handle_response_to_orchestration_result(
552        &self,
553        response: HandleResponse,
554        ctx: &oxios_ouroboros::MsgCtx,
555        duration_ms: u64,
556    ) -> OrchestrationResult {
557        let metrics = get_metrics();
558        metrics.orch_duration.observe(duration_ms as f64 / 1000.0);
559
560        let HandleResponse {
561            directive,
562            env,
563            result,
564            verdict,
565            evaluation_passed,
566        } = response;
567
568        // RFC-032: when execution failed (budget/quota/auth/etc) and the
569        // output is empty, generate a user-friendly error message so the WS
570        // handler can relay it as an `type: "error"` chunk.
571        let failure_class: Option<oxios_ouroboros::FailureClass> = result.failure_class;
572        let response_text = if !result.success && result.output.trim().is_empty() {
573            failure_class_to_user_message(failure_class.as_ref())
574        } else if directive.acceptance_criteria.is_empty() {
575            result.output.clone()
576        } else {
577            match &verdict {
578                Some(v) if v.all_passed() => result.output.clone(),
579                Some(v) => format!(
580                    "{}\n\n⚠ Review notes:\n{}",
581                    result.output,
582                    v.notes.join("\n")
583                ),
584                None => result.output.clone(),
585            }
586        };
587        if evaluation_passed.unwrap_or(false) {
588            metrics.agents_completed.inc();
589        } else {
590            metrics.agents_failed.inc();
591        }
592        OrchestrationResult {
593            session_id: Some(ctx.session_id.clone()),
594            primary_project_id: env.project_id,
595            project_tag: None,
596            active_mount_ids: Vec::new(),
597            mount_tag: None,
598            response: response_text,
599            agent_id: None,
600            phase_reached: "execute".to_string(),
601            evaluation_passed,
602            output: Some(result.output.clone()),
603            tool_calls: result.tool_calls.clone(),
604            failure_class,
605            interview_questions: None,
606            interview_round: None,
607            reasoning_text: result.reasoning_text.clone(),
608        }
609    }
610    ///
611    /// Mirrors the Mount workspace resolution done by `handle_message()`
612    /// and `chat()` but packages the result as the new [`ExecEnv`] type.
613    /// Independent of the directive — runs whether the task is Trivial
614    /// or Substantial.
615    fn resolve_exec_env(
616        &self,
617        ctx: &oxios_ouroboros::MsgCtx,
618        msg: &str,
619    ) -> oxios_ouroboros::ExecEnv {
620        let (active_mount_ids, workspace_context, mount_paths, _mount_tag) =
621            self.resolve_mount_workspace(ctx.mount_ids.as_deref(), ctx.project_ids.as_deref(), msg);
622        // active_mount_ids + mount_tag are surfaced via the legacy path;
623        // ExecEnv carries the resolved paths/context/project that the
624        // agent runtime actually consumes.
625        let _ = active_mount_ids;
626
627        // Resolve a primary project ID (matches handle_message semantics):
628        // explicit project_ids takes precedence over auto-detection.
629        let project_id = ctx
630            .project_ids
631            .as_deref()
632            .and_then(|ids| {
633                ids.split(',')
634                    .next()
635                    .and_then(|s| Uuid::parse_str(s.trim()).ok())
636            })
637            .or_else(|| {
638                self.detect_project_tag(msg).and_then(|_tag| {
639                    self.project_manager().and_then(|pm| {
640                        let projects = pm.list_projects();
641                        match crate::project::detect_project(msg, &projects) {
642                            crate::project::DetectionResult::Found(id) => Some(id),
643                            crate::project::DetectionResult::NoMatch { .. } => None,
644                        }
645                    })
646                })
647            });
648
649        // Touch the project to record activity (mirrors handle_message).
650        if let Some(pid) = project_id
651            && let Some(pm) = self.project_manager()
652        {
653            pm.touch(pid);
654        }
655
656        oxios_ouroboros::ExecEnv {
657            workspace_context,
658            mount_paths,
659            project_id,
660            cspace_hint: None,
661            model_override: ctx.model_override.clone(),
662            role: ctx.role.clone(),
663            restore_state: None,
664            session_id: Some(ctx.session_id.clone()),
665        }
666    }
667
668    /// Execute a [`Directive`] under an [`ExecEnv`].
669    ///
670    async fn execute_directive(
671        &self,
672        directive: &oxios_ouroboros::Directive,
673        env: &oxios_ouroboros::ExecEnv,
674    ) -> Result<ExecutionResult> {
675        // RFC-029: route through the recovery coordinator when wired
676        // (L1 backoff / L2 model swap on provider failure). Falls back
677        // to a direct lifecycle call when no coordinator is set.
678        //
679        // Clone the Arc out of the read guard so the parking_lot guard
680        // (which is !Send) is dropped before the .await — otherwise the
681        // future is !Send and breaks tokio::spawn in the gateway.
682        let coordinator = self.recovery.read().as_ref().cloned();
683        if let Some(coordinator) = coordinator {
684            coordinator.execute(&self.lifecycle, directive, env).await
685        } else {
686            self.lifecycle.execute_directive(directive, env).await
687        }
688    }
689
690    /// Review the result against the directive's criteria; on failure,
691    /// retry once with the verdict's gaps folded back as constraints.
692    ///
693    /// RFC-033: this is the sole surviving external LLM gate. It is reached
694    /// only when `Directive::needs_review()` is true (acceptance criteria or
695    /// output schema present). `Orchestrator::handle` builds directives via
696    /// `Directive::from_message` for interactive chat, which carries no
697    /// criteria — so for interactive chat this method is **dormant** and the
698    /// agent's internal VERIFY step stands in for review. It remains wired so
699    /// any future/automated producer of criteria-bearing directives gets
700    /// impartial post-execution review. Retries are capped at one attempt.
701    async fn verify_or_retry(
702        &self,
703        engine: &dyn oxios_ouroboros::IntentEngineOps,
704        directive: &mut oxios_ouroboros::Directive,
705        env: &oxios_ouroboros::ExecEnv,
706        initial_result: ExecutionResult,
707        _msg: &str,
708        _ctx: &oxios_ouroboros::MsgCtx,
709    ) -> Result<(ExecutionResult, oxios_ouroboros::Verdict)> {
710        let verdict = engine.review(directive, &initial_result).await?;
711
712        if verdict.all_passed() || verdict.gaps.is_empty() {
713            return Ok((initial_result, verdict));
714        }
715
716        // Check if retry is enabled (RFC-027 Decision 6).
717        // When disabled, return the initial result with the failed verdict.
718        let enable_retry = self.intent_config.read().enable_retry;
719        if !enable_retry {
720            tracing::info!("Review failed but retry disabled (enable_retry=false)");
721            return Ok((initial_result, verdict));
722        }
723
724        let metrics = get_metrics();
725        metrics.retry_attempted.inc();
726
727        tracing::info!(
728            gaps = verdict.gaps.len(),
729            "Review failed — retrying with feedback"
730        );
731
732        // Execute with feedback: previous output + gaps injected.
733        let retry_result = self
734            .lifecycle
735            .execute_with_feedback(directive, env, &initial_result, &verdict.gaps)
736            .await?;
737
738        // Re-review.
739        let retry_verdict = engine.review(directive, &retry_result).await?;
740
741        // Track retry effectiveness.
742        if retry_verdict.score > verdict.score {
743            metrics.retry_improved.inc();
744        } else if retry_verdict.score < verdict.score {
745            metrics.retry_degraded.inc();
746        } else {
747            metrics.retry_unchanged.inc();
748        }
749
750        // Return best result.
751        let chosen_result = if retry_verdict.score >= verdict.score {
752            retry_result
753        } else {
754            initial_result
755        };
756
757        Ok((chosen_result, retry_verdict))
758    }
759}
760
761/// Response envelope for [`Orchestrator::handle`] (RFC-033).
762///
763/// RFC-033 collapsed the former `Reply` / `Clarify` / `Task` variants into a
764/// single shape: every message now executes through the agent loop, so there
765/// is only ever one terminal state. The agent's reply text, tool calls, and
766/// reasoning live in `result`; `verdict` / `evaluation_passed` are `Some`
767/// only when an external review ran (a criteria-bearing directive).
768#[derive(Debug, Clone)]
769pub struct HandleResponse {
770    /// The directive that was executed (post-retry if a retry ran).
771    pub directive: Box<oxios_ouroboros::Directive>,
772    /// The execution environment resolved for this message.
773    pub env: Box<oxios_ouroboros::ExecEnv>,
774    /// The execution result (agent reply text, tool calls, reasoning).
775    pub result: Box<ExecutionResult>,
776    /// The external review verdict — `None` when no review ran.
777    pub verdict: Option<oxios_ouroboros::Verdict>,
778    /// Whether the (final) verdict passed — `None` when no review ran.
779    pub evaluation_passed: Option<bool>,
780}
781
782/// Result of a full orchestration cycle.
783#[derive(Debug, Clone, Serialize, Deserialize)]
784pub struct OrchestrationResult {
785    /// Session ID for multi-turn interviews. Pass this on follow-up messages.
786    #[serde(skip_serializing_if = "Option::is_none")]
787    pub session_id: Option<String>,
788    /// The Space ID that handled this message.
789    #[serde(skip_serializing_if = "Option::is_none")]
790    pub primary_project_id: Option<Uuid>,
791    /// Space decoration tag for the response (e.g. "[🔧 oxios]").
792    #[serde(skip_serializing_if = "Option::is_none")]
793    pub project_tag: Option<String>,
794    /// Active Mount IDs for this message (RFC-025), primary first.
795    #[serde(default, skip_serializing_if = "Vec::is_empty")]
796    pub active_mount_ids: Vec<MountId>,
797    /// Mount decoration tag for the response (e.g. "[🔧 oxios + oxi-sdk]").
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub mount_tag: Option<String>,
800    /// The response to send back to the user.
801    pub response: String,
802    /// The agent that executed (if execute phase was reached).
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub agent_id: Option<AgentId>,
805    /// The furthest phase reached: "interview" (conversation/clarify) or "execute" (task executed).
806    pub phase_reached: String,
807    /// Whether evaluation passed.
808    ///
809    /// - `None` — evaluation was not applicable (interview, chat, non-task).
810    /// - `Some(true)` — evaluation passed.
811    /// - `Some(false)` — evaluation failed or execution unsuccessful.
812    pub evaluation_passed: Option<bool>,
813    /// Output or notes from evaluation.
814    #[serde(skip_serializing_if = "Option::is_none")]
815    pub output: Option<String>,
816    /// Tool calls recorded during execution.
817    #[serde(default, skip_serializing_if = "Vec::is_empty")]
818    pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
819    /// Structured interview questions (chat UI redesign — interactive
820    /// interview). Populated when the interview phase needs clarification
821    /// and the LLM produced a structured form of the questions. The
822    /// Gateway forwards this to the WebSocket as an `interview` chunk;
823    /// the Web UI renders it as interactive widgets (chips, yes/no
824    /// buttons). When `None`, the frontend falls back to rendering
825    /// `response` as plain markdown.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub interview_questions: Option<Vec<oxios_ouroboros::InterviewQuestionOutput>>,
828    /// Current interview round (1-based). Populated alongside
829    /// `interview_questions`. Drives the "Round N/M" indicator.
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub interview_round: Option<u32>,
832
833    /// P4 (§7 persistence): full concatenated reasoning text from the
834    /// agent's `ThinkingDelta` stream. Surfaced into the terminal
835    /// `OutgoingMessage` metadata so chat.rs can persist it alongside
836    /// `tool_calls` and restore on session reopen.
837    #[serde(default, skip_serializing_if = "String::is_empty")]
838    pub reasoning_text: String,
839    /// Provider failure classification (RFC-029). `Some` when execution
840    /// failed with a classifiable provider/infra error; `None` on success,
841    /// interview, clarify, or unclassified failure.
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub failure_class: Option<oxios_ouroboros::FailureClass>,
844}
845/// Generate a user-facing error message based on the failure class.
846/// Used when execution failed with no output text to show.
847fn failure_class_to_user_message(class: Option<&oxios_ouroboros::FailureClass>) -> String {
848    use oxios_ouroboros::FailureClass;
849    match class {
850        Some(FailureClass::BudgetExceeded) => {
851            "\u{26a0}\u{fe0f} Token budget exceeded for this provider. \
852             Try selecting a different model or configuring additional providers \
853             in Settings \u{2192} Engine."
854                .to_string()
855        }
856        Some(FailureClass::QuotaExhausted) => "\u{26a0}\u{fe0f} Provider quota exhausted. \
857             The selected provider has reached its rate or usage limit. \
858             Wait a moment and retry, or switch to a different model."
859            .to_string(),
860        Some(FailureClass::AuthFailure) => "\u{26a0}\u{fe0f} Authentication failed. \
861             Your API key for this provider may be invalid or expired. \
862             Check your credentials in Settings \u{2192} Engine."
863            .to_string(),
864        Some(FailureClass::ModelUnavailable) => "\u{26a0}\u{fe0f} Model unavailable. \
865             The selected model is no longer available or was not found. \
866             Choose a different model in Settings \u{2192} Engine."
867            .to_string(),
868        Some(FailureClass::ContextOverflow) => "\u{26a0}\u{fe0f} Context window exceeded. \
869             The conversation is too long for this model's context limit. \
870             Start a new session or switch to a model with a larger context window."
871            .to_string(),
872        Some(FailureClass::Transient) => {
873            "\u{26a0}\u{fe0f} A temporary error occurred while contacting the provider. \
874             The system will retry automatically. If the issue persists, \
875             try a different model or check your network connection."
876                .to_string()
877        }
878        Some(FailureClass::Unknown) | None => {
879            "\u{26a0}\u{fe0f} An unexpected error occurred during execution. \
880             Please try again. If the problem persists, check your provider \
881             configuration in Settings \u{2192} Engine."
882                .to_string()
883        }
884    }
885}
886
887/// Render the body of the `## Workspace Context` prompt section (RFC-025).
888///
889/// The caller (`build_system_prompt`) wraps this in the `## Workspace
890/// Context` header. Returns `None` when there are no Mounts to describe.
891///
892/// Fill order respects the prompt budget (~1500 tokens soft):
893/// 1. Primary Mount — full (description + summary + path).
894/// 2. Secondary Mounts — name + path + one-line summary only.
895fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
896    if mounts.is_empty() {
897        return None;
898    }
899    let mut out = String::new();
900    out.push_str("### Active Mounts\n");
901
902    for (i, m) in mounts.iter().enumerate() {
903        let primary = i == 0;
904        let path = m
905            .primary_path()
906            .map(|p| p.to_string_lossy().to_string())
907            .unwrap_or_else(|| "(no path)".to_string());
908
909        if primary {
910            out.push_str(&format!("- **{}** → {}\n", m.name, path));
911            if !m.auto_description.is_empty() {
912                // First ~3 lines of the agent-written description.
913                let desc: String = m
914                    .auto_description
915                    .lines()
916                    .take(3)
917                    .collect::<Vec<_>>()
918                    .join("\n  ");
919                out.push_str(&format!("  {}\n", desc));
920            }
921            let summary = m.summary_line();
922            if !summary.is_empty() {
923                out.push_str(&format!("  _{}_\n", summary));
924            }
925            if m.enrichment_pending {
926                out.push_str("  _(content changed — consider re-scanning this Mount)_\n");
927            }
928        } else {
929            // Secondary: name + path + one-line summary only.
930            let summary = m.summary_line();
931            let suffix = if summary.is_empty() {
932                String::new()
933            } else {
934                format!(" — {}", summary)
935            };
936            out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
937        }
938    }
939
940    Some(out)
941}
942
943#[cfg(test)]
944mod mount_workspace_tests {
945    use super::*;
946    use crate::mount::{Mount, MountSource};
947    use std::path::PathBuf;
948
949    #[test]
950    fn test_workspace_context_primary_full_secondary_terse() {
951        let mut oxios =
952            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
953        oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
954        oxios.auto_meta.summary = "Rust agent OS".to_string();
955
956        let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
957        oxi.auto_meta.summary = "SDK".to_string();
958
959        let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
960        assert!(body.contains("### Active Mounts"));
961        // Primary gets full description.
962        assert!(body.contains("Agent OS."));
963        assert!(body.contains("_Rust agent OS_"));
964        // Secondary is terse.
965        assert!(body.contains("**oxi** → /oxi — SDK"));
966    }
967
968    #[test]
969    fn test_workspace_context_empty_is_none() {
970        assert!(build_workspace_context_body(&[]).is_none());
971    }
972
973    /// End-to-end: a real MountManager + Orchestrator-less call to
974    /// `resolve_mount_workspace` proves that detection seeds the primary,
975    /// builds the context body, and collects all paths (multi-path access).
976    #[test]
977    fn test_resolve_mount_workspace_detects_and_collects_paths() {
978        use crate::mount::MountManager;
979        use oxios_memory::memory::sqlite::MemoryDatabase;
980        use std::sync::Arc;
981
982        let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
983        let mm = Arc::new(MountManager::new(db, None).unwrap());
984
985        // Register two mounts.
986        let oxios = mm
987            .create_mount(
988                "oxios".to_string(),
989                vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
990                MountSource::Manual,
991            )
992            .unwrap();
993        let oxi_sdk = mm
994            .create_mount(
995                "oxi-sdk".to_string(),
996                vec![PathBuf::from("/Users/me/oxi")],
997                MountSource::Manual,
998            )
999            .unwrap();
1000        mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
1001            .unwrap();
1002
1003        // Build a minimal Orchestrator-free resolver path: replicate what
1004        // resolve_mount_workspace does, but against the manager directly,
1005        // since the full Orchestrator needs many subsystems.
1006        let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
1007        assert_eq!(mounts.len(), 2);
1008
1009        let body = build_workspace_context_body(&mounts).unwrap();
1010        assert!(body.contains("oxios"));
1011        assert!(body.contains("Agent OS in Rust."));
1012        assert!(body.contains("oxi-sdk"));
1013
1014        // Collect paths like the orchestrator does.
1015        let mut paths = Vec::new();
1016        for m in &mounts {
1017            for p in &m.paths {
1018                if !paths.contains(p) {
1019                    paths.push(p.clone());
1020                }
1021            }
1022        }
1023        assert_eq!(paths.len(), 2);
1024        assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1025        assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
1026    }
1027
1028    /// Detection layer 1 (name match) seeds the primary when no explicit
1029    /// mount_ids are given — the core promise of RFC-025.
1030    #[test]
1031    fn test_detection_seeds_primary_on_name_mention() {
1032        use crate::mount::{DetectionResult, detect_mounts};
1033
1034        let oxios =
1035            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1036        let result = detect_mounts("oxios 코드리뷰해줘", std::slice::from_ref(&oxios));
1037        assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
1038    }
1039}