a3s-code-core 6.4.0

A3S Code Core - Embeddable AI agent library with tool execution
Documentation
//! Provider-agnostic fallback policy for web search.

use super::engines::canonical_engine_shortcut;
use super::{safe_search_result_url, sanitize_http_urls};
use crate::tools::types::ToolErrorKind;
use a3s_search::{EngineFailure, SearchResults};
use std::time::Duration;

const FALLBACK_ENGINE_ORDER: [&str; 4] = ["ddg", "brave", "bing", "wiki"];

pub(super) fn configured_engine<'a>(
    config: &'a crate::config::SearchConfig,
    shortcut: &str,
) -> Option<&'a crate::config::SearchEngineConfig> {
    config.engines.get(shortcut).or_else(|| {
        let canonical = canonical_engine_shortcut(shortcut);
        config.engines.iter().find_map(|(name, engine)| {
            canonical_engine_shortcut(name)
                .eq_ignore_ascii_case(canonical)
                .then_some(engine)
        })
    })
}

pub(super) fn fallback_engine_shortcuts(
    attempted_engines: &[String],
    config: Option<&crate::config::SearchConfig>,
) -> Vec<&'static str> {
    FALLBACK_ENGINE_ORDER
        .iter()
        .copied()
        .filter(|candidate| {
            let candidate = canonical_engine_shortcut(candidate);
            !attempted_engines.iter().any(|attempted| {
                canonical_engine_shortcut(attempted).eq_ignore_ascii_case(candidate)
            }) && config
                .and_then(|config| configured_engine(config, candidate))
                .is_none_or(|engine| engine.enabled)
        })
        .collect()
}

pub(super) fn fallback_engine_names(engines: &[&str]) -> String {
    let names = engines
        .iter()
        .map(|engine| match *engine {
            "ddg" => "DuckDuckGo",
            "brave" => "Brave",
            "bing" => "Bing",
            "wiki" => "Wikipedia",
            other => other,
        })
        .collect::<Vec<_>>();
    match names.as_slice() {
        [] => String::new(),
        [name] => (*name).to_string(),
        [first, second] => format!("{first} and {second}"),
        _ => names.join(", "),
    }
}

pub(super) fn primary_search_timeout(total: Duration, has_fallback: bool) -> Duration {
    if !has_fallback {
        return total;
    }
    let total_ms = u64::try_from(total.as_millis()).unwrap_or(u64::MAX);
    let reserve_ms = (total_ms / 3).clamp(500, 5_000).min(total_ms / 2);
    Duration::from_millis(total_ms.saturating_sub(reserve_ms).max(1))
}

pub(super) fn failure_reason(kind: &str) -> &'static str {
    match kind {
        "provider_quota" => "quota is exhausted",
        "provider_rate_limited" | "rate_limited" => "was rate limited",
        "provider_authentication" => "authentication failed",
        "provider_permission" | "permission_denied" => "denied access",
        "provider_unavailable" | "engine_suspended" => "is unavailable",
        "timeout" | "http_timeout" => "timed out",
        "provider_transport" | "network" | "http_connect" => "could not be reached",
        "provider_invalid_response" | "parse" | "http_decode" => "returned an invalid response",
        _ => "failed",
    }
}

pub(super) fn failure_summary(failures: &[EngineFailure]) -> String {
    let mut seen = std::collections::HashSet::new();
    failures
        .iter()
        .filter_map(|failure| {
            let engine = crate::text::truncate_utf8(&sanitize_http_urls(&failure.engine), 96)
                .trim()
                .to_string();
            let key = (engine.to_ascii_lowercase(), failure.kind.clone());
            (!engine.is_empty() && seen.insert(key))
                .then(|| format!("{engine} {}", failure_reason(&failure.kind)))
        })
        .take(4)
        .collect::<Vec<_>>()
        .join("; ")
}

pub(super) fn failure_metadata(failures: &[EngineFailure]) -> Vec<serde_json::Value> {
    failures
        .iter()
        .map(|failure| {
            serde_json::json!({
                "engine": crate::text::truncate_utf8(
                    &sanitize_http_urls(&failure.engine),
                    96,
                ),
                "provider": failure.provider.as_deref(),
                "kind": &failure.kind,
                "transient": failure.transient,
            })
        })
        .collect()
}

pub(super) fn tool_error_kind_for_failures(
    failures: &[EngineFailure],
    timeout: Duration,
) -> Option<ToolErrorKind> {
    if failures.is_empty() {
        return None;
    }

    if failures.iter().all(|failure| {
        matches!(
            failure.kind.as_str(),
            "provider_rate_limited" | "rate_limited"
        )
    }) {
        return Some(ToolErrorKind::RateLimited {
            retry_after_ms: None,
        });
    }

    failures
        .iter()
        .all(|failure| matches!(failure.kind.as_str(), "timeout" | "http_timeout"))
        .then(|| ToolErrorKind::Timeout {
            op: "web_search".to_string(),
            duration_ms: u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
        })
}

pub(super) fn usable_result_engines(results: &SearchResults) -> Vec<String> {
    let mut engines = results
        .items()
        .iter()
        .filter(|result| !safe_search_result_url(result).is_empty())
        .flat_map(|result| result.engines.iter().cloned())
        .collect::<Vec<_>>();
    engines.sort_unstable();
    engines.dedup();
    engines
}

pub(super) fn merge_search_results(target: &mut SearchResults, source: &SearchResults) {
    for result in source.items() {
        target.add_result(result.clone());
    }
    for suggestion in source.suggestions() {
        target.add_suggestion(suggestion.clone());
    }
    for answer in source.answers() {
        target.add_answer(answer.clone());
    }
    for image in source.images() {
        target.add_image(image.clone());
    }
    for report in source.reports() {
        target.add_report(report.clone());
    }
    if source.failures().is_empty() {
        for (engine, error) in source.errors() {
            target.add_error(engine.clone(), error.clone());
        }
    } else {
        for failure in source.failures() {
            target.add_failure(failure.clone());
        }
    }
}

pub(super) fn text_notice_note(notices: &[String]) -> String {
    if notices.is_empty() {
        return String::new();
    }
    let mut note = String::new();
    for notice in notices {
        note.push_str(&format!("\nNotice: {notice}\n"));
    }
    note
}