roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
//! Unified shortcut dispatcher — replaces the 983-line `try_execution_shortcut()` god function.
//!
//! Each pre-LLM shortcut is a standalone `ShortcutHandler` struct registered in the
//! `ShortcutDispatcher`.  The dispatcher iterates handlers in priority order, skipping
//! shortcuts when:
//!   - `is_correction_turn` is true (corrections always reach the LLM), or
//!   - `requires_cache_bypass()` is true but no cache-bypass intent is present.

mod conversational;

pub(super) use conversational::AcknowledgementShortcut;

use roboticus_core::InputAuthority;

use super::AppState;
use super::core::InferenceOutput;
use super::decomposition::DelegationProvenance;
use super::intent_registry::Intent;

// ── Context ──────────────────────────────────────────────────────────────

/// Everything a shortcut handler needs to execute.
pub(super) struct ShortcutContext<'a> {
    // Fields marked dead_code are populated by the pipeline for context but
    // not currently read by the surviving AcknowledgementShortcut. They remain
    // part of the contract so future shortcuts don't need pipeline changes.
    #[allow(dead_code)]
    pub state: &'a AppState,
    pub user_content: &'a str,
    pub turn_id: &'a str,
    pub intents: &'a [Intent],
    #[allow(dead_code)]
    pub agent_name: &'a str,
    #[allow(dead_code)]
    pub channel_label: &'a str,
    pub prepared_model: &'a str,
    #[allow(dead_code)]
    pub authority: InputAuthority,
    #[allow(dead_code)]
    pub delegation_provenance: &'a mut DelegationProvenance,
    pub is_correction_turn: bool,
    /// Whether the session has prior conversation history (turn > 1).
    /// When true, conversational shortcuts (static responses) are suppressed
    /// to avoid context-free infrastructure leaks in active conversations.
    pub has_conversation_context: bool,
}

/// Build a zero-cost `InferenceOutput` with the shortcut's content and quality.
fn shortcut_output(
    model: &str,
    content: String,
    quality: f64,
    tool_results: Vec<(String, String)>,
    turn_id: &str,
) -> InferenceOutput {
    InferenceOutput {
        content,
        model: model.to_string(),
        tokens_in: 0,
        tokens_out: 0,
        cost: 0.0,
        react_turns: 1,
        latency_ms: 0,
        quality_score: quality,
        escalated: false,
        tool_results,
        react_trace: Box::new(super::flight_recorder::ReactTrace::new(turn_id)),
    }
}

// ── Trait & Dispatcher ───────────────────────────────────────────────────

#[async_trait::async_trait]
pub(super) trait ShortcutHandler: Send + Sync {
    /// Check if this handler should fire given the classified intents.
    fn handles(&self, intents: &[Intent]) -> bool;

    /// Whether the handler requires at least one cache-bypass intent.
    /// Default: `true`.  Override to `false` for shortcuts like Acknowledgement
    /// that should fire even when no cache-bypass intent is present.
    fn requires_cache_bypass(&self) -> bool {
        true
    }

    /// Execution confidence for the shortcut gate (0.0–1.0).
    ///
    /// Answers: "Given the user's prompt, how strong is the evidence that this
    /// shortcut should fire NOW rather than letting the LLM handle it?"
    ///
    /// `handles()` answers "is this the right category?" (from intent classification).
    /// `execution_confidence()` answers "is there enough evidence to short-circuit?"
    ///
    /// Returns 0.0 by default (only fires with explicit evidence).
    /// Override to return > 0.0 based on concrete signal types:
    /// - **Path hints**: explicit filesystem paths matching the shortcut's domain
    /// - **Structural phrasing**: "count files in ...", "scan my vault", "list ..."
    /// - **Domain nouns**: obsidian, vault, cron, wallet, markdown
    /// - **Output-shape requests**: "just the number", "count only"
    ///
    /// Avoid returning high confidence from vague semantic similarity alone.
    /// The returned value and evidence should be loggable for observability.
    fn execution_confidence(&self, _ctx: &ShortcutContext) -> (f64, &'static str) {
        (0.0, "no evidence")
    }

    /// Execute the shortcut.  Returns `Ok(Some(...))` to short-circuit the pipeline,
    /// `Ok(None)` to fall through to the next handler, or `Err(...)` on failure.
    async fn execute(
        &self,
        ctx: &mut ShortcutContext<'_>,
    ) -> Result<Option<InferenceOutput>, String>;
}

/// Registry of all shortcut handlers, iterated in priority order.
pub(super) struct ShortcutDispatcher {
    handlers: Vec<Box<dyn ShortcutHandler>>,
}

impl ShortcutDispatcher {
    /// Build the default dispatcher with surviving handlers in priority order.
    ///
    /// RETIRED (v0.11.1) — these shortcuts bypassed the guard chain and
    /// produced unacceptable infrastructure leakage when they misfired:
    /// - CapabilitySummaryShortcut: dumped tool names and model identity
    /// - RandomToolUseShortcut: dumped tool inventory, infrastructure names
    /// - ObsidianInsightsShortcut: ran filesystem scan on gameplay prompts
    /// - FolderScanShortcut: ran bash commands on false-positive matches
    /// - MarkdownCountScanShortcut: same as FolderScanShortcut
    /// - ImageCountScanShortcut: same as FolderScanShortcut
    /// - WalletAddressScanShortcut: ran grep across filesystem on false positives
    /// - PersonalityProfileShortcut: returned static blurb instead of in-character response
    /// - ProviderInventoryShortcut: exposed model routing internals
    ///
    /// The LLM already has the tools (bash, get_runtime_context, get_memory_stats)
    /// to perform all of these operations when genuinely requested, with the
    /// full guard chain protecting the output.
    ///
    /// Task-oriented shortcuts (DelegationShortcut, IntrospectionShortcut,
    /// CurrentEventsShortcut, EmailTriageShortcut) were removed earlier —
    /// those routing decisions flow through the action planner.
    pub fn default_dispatcher() -> Self {
        Self {
            handlers: vec![
                // Acknowledgement: low damage, saves inference on trivial turns.
                // Under observation for retirement when the cost of a trivial
                // inference call is no longer a concern.
                Box::new(AcknowledgementShortcut),
            ],
        }
    }

    /// Try to dispatch a shortcut.
    ///
    /// `bypass_cache`: whether the classified intents include at least one
    /// cache-bypass intent (from `IntentRegistry::should_bypass_cache()`).
    ///
    /// Returns `Ok(None)` when no shortcut matches.
    pub async fn try_dispatch(
        &self,
        ctx: &mut ShortcutContext<'_>,
        bypass_cache: bool,
    ) -> Result<Option<InferenceOutput>, String> {
        if ctx.is_correction_turn {
            return Ok(None);
        }
        // NOTE: try_explicit_tool_invocation() was retired in v0.11.1.
        // It pattern-matched tool names from user prompts and executed them
        // directly, bypassing LLM inference and the guard chain. This was
        // the root cause of the "bashar" → bash bug and produced unguarded
        // tool output in user-facing responses. The LLM now handles all
        // tool invocation through the normal ReAct loop with full guard
        // chain protection.
        for handler in &self.handlers {
            if !handler.handles(ctx.intents) {
                continue;
            }
            if handler.requires_cache_bypass() && !bypass_cache {
                continue;
            }
            // Confidence gate: ALWAYS applied regardless of conversation context.
            // - Cold start (no conversation context): moderate bar (0.4 = need some explicit evidence)
            // - Active conversation: higher bar (0.8 = strong domain evidence required)
            // - Correction turn: already filtered above (line 152)
            //
            // Each handler's execution_confidence() returns a score and evidence
            // string. The dispatcher logs both for observability/retirement analysis.
            let threshold = if ctx.has_conversation_context {
                0.8
            } else {
                0.4
            };
            {
                let (confidence, evidence) = handler.execution_confidence(ctx);
                if confidence < threshold {
                    tracing::debug!(
                        handler = std::any::type_name_of_val(handler),
                        confidence,
                        threshold,
                        evidence,
                        "shortcut suppressed: insufficient execution confidence"
                    );
                    continue;
                }
                tracing::info!(
                    handler = std::any::type_name_of_val(handler),
                    confidence,
                    evidence,
                    threshold,
                    "shortcut firing (passed confidence gate)"
                );
            }
            if let result @ Some(_) = handler.execute(ctx).await? {
                return Ok(result);
            }
        }
        Ok(None)
    }
}

// ═══════════════════════════════════════════════════════════════════════════
//  Tests
// ═══════════════════════════════════════════════════════════════════════════

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn acknowledgement_handles_intent() {
        let s = AcknowledgementShortcut;
        assert!(s.handles(&[Intent::Acknowledgement]));
        assert!(!s.handles(&[Intent::Execution]));
        assert!(!s.requires_cache_bypass());
    }

    #[test]
    fn dispatcher_default_has_all_handlers() {
        let d = ShortcutDispatcher::default_dispatcher();
        // v0.11.1: all shortcuts retired except AcknowledgementShortcut.
        // See docs/architecture/shortcut-registry.md for full retirement rationale.
        assert_eq!(d.handlers.len(), 1);
    }

    #[test]
    fn dispatcher_handler_order_acknowledgement_first() {
        let d = ShortcutDispatcher::default_dispatcher();
        assert!(d.handlers[0].handles(&[Intent::Acknowledgement]));
        assert!(!d.handlers[0].requires_cache_bypass());
    }
}