a3s-code-core 6.6.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, engine_tier, EngineTier};
use super::{safe_search_result_url, sanitize_http_urls};
use crate::tools::types::ToolErrorKind;
use a3s_search::{EngineFailure, EngineOutcome, SearchResults};
use std::time::Duration;

const DEFAULT_HTTP_TIER: [&str; 4] = ["ddg", "brave", "bing", "wiki"];
const DEFAULT_HEADLESS_TIER: [&str; 2] = ["g", "baidu"];

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct EngineTierPlan {
    pub api: Vec<String>,
    pub http: Vec<String>,
    pub headless: Vec<String>,
}

impl EngineTierPlan {
    pub fn is_empty(&self) -> bool {
        self.api.is_empty() && self.http.is_empty() && self.headless.is_empty()
    }
}

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

pub(super) fn tiered_engine_plan(
    selected_engines: &[&str],
    config: Option<&crate::config::SearchConfig>,
    automatic_fallback: bool,
) -> EngineTierPlan {
    let mut plan = EngineTierPlan::default();
    for shortcut in selected_engines {
        add_planned_engine(&mut plan, shortcut, config);
    }

    if automatic_fallback && (!plan.api.is_empty() || !plan.http.is_empty()) {
        for shortcut in DEFAULT_HTTP_TIER {
            add_planned_engine(&mut plan, shortcut, config);
        }
        for shortcut in DEFAULT_HEADLESS_TIER {
            add_planned_engine(&mut plan, shortcut, config);
        }
    }

    plan
}

fn add_planned_engine(
    plan: &mut EngineTierPlan,
    shortcut: &str,
    config: Option<&crate::config::SearchConfig>,
) {
    let normalized = shortcut.trim().to_ascii_lowercase();
    let canonical = canonical_engine_shortcut(&normalized);
    if config
        .and_then(|config| configured_engine(config, canonical))
        .is_some_and(|engine| !engine.enabled)
    {
        return;
    }
    let target = match engine_tier(canonical) {
        Some(EngineTier::Api) => &mut plan.api,
        Some(EngineTier::Http) => &mut plan.http,
        Some(EngineTier::Headless) => &mut plan.headless,
        None => return,
    };
    if !target.iter().any(|current| current == canonical) {
        target.push(canonical.to_string());
    }
}

pub(super) fn tier_timeout(remaining: Duration, remaining_tiers: usize) -> Duration {
    if remaining_tiers == 0 || remaining.is_zero() {
        return remaining;
    }
    let divisor = u128::try_from(remaining_tiers)
        .unwrap_or(u128::MAX)
        .saturating_add(1);
    let milliseconds = (remaining.as_millis() / divisor).max(1);
    Duration::from_millis(u64::try_from(milliseconds).unwrap_or(u64::MAX)).min(remaining)
}

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" | "circuit_open" => "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,
                "retry_after_seconds": failure.retry_after_seconds,
            })
        })
        .collect()
}

pub(super) fn outcome_metadata(outcomes: &[EngineOutcome]) -> Vec<serde_json::Value> {
    outcomes
        .iter()
        .map(|outcome| {
            serde_json::json!({
                "engine": crate::text::truncate_utf8(
                    &sanitize_http_urls(&outcome.engine),
                    96,
                ),
                "shortcut": crate::text::truncate_utf8(&outcome.shortcut, 32),
                "provider": outcome.provider.as_deref(),
                "kind": outcome.kind,
                "result_count": outcome.result_count,
                "duration_ms": outcome.duration_ms,
                "failure_kind": outcome.failure.as_ref().map(|failure| failure.kind.as_str()),
                "retry_after_seconds": outcome
                    .failure
                    .as_ref()
                    .and_then(|failure| failure.retry_after_seconds),
            })
        })
        .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_count(results: &SearchResults) -> usize {
    results
        .items()
        .iter()
        .filter(|result| !safe_search_result_url(result).is_empty())
        .count()
}

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
}