roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
//! Deterministic guard fallback helpers: quality fallbacks, intent classification helpers,
//! and bullet extraction used when LLM retries are exhausted or not warranted.
//!
//! NOTE: This module uses `Intent::matches()` (deprecated keyword fallback) because
//! these are sync fallback paths that cannot await the semantic classifier. They should
//! be migrated to read from pre-classified intents when the calling context is refactored.
#![allow(deprecated)]

use super::super::guard_registry::{GuardId, extract_bullet_lines, requested_exact_bullet_count};
use super::super::intent_registry::Intent;

/// Returns true if the user prompt is a conversational affirmation.
/// These should NOT go through deterministic fallback — they need full conversation context.
pub(crate) fn is_conversational_affirmation(prompt: &str) -> bool {
    let lower = prompt.trim().to_ascii_lowercase();
    matches!(
        lower.as_str(),
        "awesome"
            | "great"
            | "nice"
            | "perfect"
            | "go ahead"
            | "yes"
            | "ok"
            | "sure"
            | "do it"
            | "proceed"
            | "sounds good"
            | "sounds great"
            | "sounds perfect"
            | "let's do it"
            | "i think those are all great ideas"
    ) || (lower.len() < 40
        && (lower.starts_with("yes")
            || lower.starts_with("go ahead")
            || lower.starts_with("sounds good")
            || lower.starts_with("sounds great")
            || lower.starts_with("let's do")
            || lower.starts_with("i think those are")
            || lower.starts_with("those are great")
            || lower.starts_with("great idea")))
}

pub(crate) fn deterministic_quality_fallback(user_prompt: &str, agent_name: &str) -> String {
    let lower = user_prompt.trim().to_ascii_lowercase();

    if Intent::CurrentEvents.matches(user_prompt) {
        return format!(
            "{agent_name}: I was unable to produce a reliable response on that turn. \
             I can retry now — please specify the scope and I will return it with sourced evidence."
        );
    }
    if Intent::CapabilitySummary.matches(user_prompt) {
        return format!(
            "{agent_name}: I can execute tools, delegate to subagents, inspect runtime state, \
             schedule jobs, and report outcomes with evidence. What would you like me to do?"
        );
    }

    // Preserve the user's topic in the fallback so conversation context
    // isn't destroyed. Truncate the prompt to a reasonable snippet.
    if lower.len() > 10 {
        let snippet: String = user_prompt.chars().take(80).collect();
        format!(
            "{agent_name}: I had trouble producing a quality response to your message \
             about \"{snippet}\". Could you rephrase or give me more specific direction?"
        )
    } else {
        format!(
            "{agent_name}: I had trouble producing a quality response on that turn. \
             Could you rephrase or clarify what you need?"
        )
    }
}

pub(crate) fn is_generic_degraded_fallback(content: &str) -> bool {
    let lower = content.to_ascii_lowercase();
    lower.contains("the prior generation degraded")
        || lower.contains("state the exact outcome format you want")
        || lower.contains("i wasn't able to produce a good response")
        || lower.contains("i need to approach this differently")
        || lower.contains("did not meet quality standards and was discarded")
        || lower.contains("had trouble producing a quality response")
}

pub(crate) fn deterministic_guard_fallback(
    guard_id: GuardId,
    attempted_content: &str,
    tool_results: &[(String, String)],
    user_prompt: &str,
    agent_name: &str,
) -> String {
    if matches!(guard_id, GuardId::TaskDeferral)
        && tool_results
            .iter()
            .any(|(name, _)| name == "get_runtime_context")
        && let Some(path) = first_absolute_path(user_prompt)
    {
        let blocker = format!(
            "Blocked: {path} is outside my allowed runtime boundaries in this environment, so I cannot read it directly."
        );
        if requested_exact_bullet_count(user_prompt).is_some() {
            return format!("- {blocker}");
        }
        return blocker;
    }
    if matches!(
        guard_id,
        GuardId::OutputContract | GuardId::InternalProtocol
    ) && let Some(expected) = requested_exact_bullet_count(user_prompt)
    {
        let delegation_backed = tool_results.iter().any(|(tool_name, _)| {
            let lower = tool_name.to_ascii_lowercase();
            lower.contains("delegate") || lower.contains("assign") || lower.contains("orchestrate")
        });
        let mut bullets = if matches!(guard_id, GuardId::InternalProtocol) && delegation_backed {
            extract_tool_result_bullets(tool_results)
        } else {
            extract_bullet_lines(attempted_content)
        };
        if bullets.len() < expected {
            let fallback_bullets =
                if matches!(guard_id, GuardId::InternalProtocol) && delegation_backed {
                    extract_bullet_lines(attempted_content)
                } else {
                    extract_tool_result_bullets(tool_results)
                };
            bullets.extend(fallback_bullets);
        }
        if bullets.len() >= expected {
            return bullets
                .into_iter()
                .take(expected)
                .collect::<Vec<_>>()
                .join("\n");
        }
    }
    deterministic_quality_fallback(user_prompt, agent_name)
}

fn first_absolute_path(prompt: &str) -> Option<String> {
    prompt.split_whitespace().find_map(|token| {
        let cleaned = token
            .trim_matches(|c: char| matches!(c, '"' | '\'' | ',' | '.' | ';' | ':' | ')' | '('));
        if cleaned.starts_with('/') && cleaned.len() > 1 {
            Some(cleaned.to_string())
        } else {
            None
        }
    })
}

fn extract_tool_result_bullets(tool_results: &[(String, String)]) -> Vec<String> {
    let mut bullets = Vec::new();
    for (tool_name, output) in tool_results {
        let lower_name = tool_name.to_ascii_lowercase();
        if !(lower_name.contains("delegate")
            || lower_name.contains("assign")
            || lower_name.contains("orchestrate"))
        {
            continue;
        }
        for line in output.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty()
                || trimmed.starts_with("delegated_subagent=")
                || trimmed.starts_with("subtask ")
                || trimmed.starts_with("## ")
            {
                continue;
            }
            let cleaned = normalize_candidate_bullet_line(trimmed);
            if !cleaned.is_empty() {
                bullets.push(format!("- {cleaned}"));
            }
        }
    }
    bullets
}

fn normalize_candidate_bullet_line(line: &str) -> String {
    let trimmed = line.trim().replace("**", "");
    let trimmed = trimmed.trim();
    if let Some(rest) = trimmed.strip_prefix("- ") {
        return rest.trim().to_string();
    }
    if let Some(rest) = trimmed.strip_prefix("* ") {
        return rest.trim().to_string();
    }
    if let Some(rest) = trimmed.strip_prefix("") {
        return rest.trim().to_string();
    }
    if let Some((num, rest)) = trimmed.split_once(". ")
        && num.chars().all(|c| c.is_ascii_digit())
    {
        return rest.trim().to_string();
    }
    if trimmed.starts_with("Product:")
        || trimmed.starts_with("SaaS Idea:")
        || trimmed.starts_with("Idea:")
    {
        return trimmed.to_string();
    }
    String::new()
}

pub(crate) fn is_task_like_turn(intents: &[Intent]) -> bool {
    intents.iter().any(|intent| {
        matches!(
            intent,
            Intent::Execution
                | Intent::TaskManagement
                | Intent::Delegation
                | Intent::Cron
                | Intent::FileDistribution
                | Intent::FolderScan
                | Intent::WalletAddressScan
                | Intent::ImageCountScan
                | Intent::MarkdownCountScan
                | Intent::ObsidianInsights
                | Intent::EmailTriage
                | Intent::CurrentEvents
        )
    })
}

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

    #[test]
    fn affirmation_detection_exact_matches() {
        assert!(is_conversational_affirmation("sounds great"));
        assert!(is_conversational_affirmation("yes"));
        assert!(is_conversational_affirmation("go ahead"));
        assert!(is_conversational_affirmation("let's do it"));
        assert!(is_conversational_affirmation("Sounds Good")); // case-insensitive
    }

    #[test]
    fn affirmation_detection_prefix_matches() {
        assert!(is_conversational_affirmation("yes please do that"));
        assert!(is_conversational_affirmation("sounds great, let's go"));
        assert!(is_conversational_affirmation("go ahead with step 1"));
    }

    #[test]
    fn affirmation_detection_rejects_non_affirmations() {
        assert!(!is_conversational_affirmation("tell me about Rust"));
        assert!(!is_conversational_affirmation(
            "what is the meaning of life?"
        ));
        assert!(!is_conversational_affirmation(
            "yes I understand the problem but let me explain my actual concern which is that the architecture doesn't support this"
        ));
    }
}