rho-coding-agent 0.22.0

A lightweight agent harness inspired by Pi
Documentation
pub(super) mod brave;
mod exa;
pub(super) mod openai;

use crate::{
    config::{Config, SearchProvider},
    credentials::{load_web_search_api_key, OsCredentialStore, WebSearchCredential},
    tool::ToolError,
};

use super::fetch::fetch_url_text;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct SearchItem {
    pub(super) title: Option<String>,
    pub(super) url: Option<String>,
    pub(super) snippet: String,
}

#[derive(Clone, Debug)]
pub(super) struct SearchBackendConfig {
    pub(super) provider: SearchProvider,
    pub(super) legacy_openai_api_key: Option<String>,
    pub(super) legacy_exa_api_key: Option<String>,
    pub(super) legacy_brave_api_key: Option<String>,
}

impl SearchBackendConfig {
    pub(super) fn from_config(config: &Config) -> Self {
        Self {
            provider: config.web_search_provider,
            legacy_openai_api_key: config
                .legacy_web_search_api_key(WebSearchCredential::OpenAi)
                .map(str::to_string),
            legacy_exa_api_key: config
                .legacy_web_search_api_key(WebSearchCredential::Exa)
                .map(str::to_string),
            legacy_brave_api_key: config
                .legacy_web_search_api_key(WebSearchCredential::Brave)
                .map(str::to_string),
        }
    }

    pub(super) fn credential(&self, credential: WebSearchCredential) -> Option<String> {
        let stored = load_web_search_api_key(&OsCredentialStore, credential)
            .ok()
            .flatten();
        stored.or_else(|| match credential {
            WebSearchCredential::OpenAi => self.legacy_openai_api_key.clone(),
            WebSearchCredential::Exa => self.legacy_exa_api_key.clone(),
            WebSearchCredential::Brave => self.legacy_brave_api_key.clone(),
        })
    }
}

pub(super) fn openai_available(config: &SearchBackendConfig) -> bool {
    openai::is_available(config)
}

pub(super) fn brave_available(config: &SearchBackendConfig) -> bool {
    brave::resolve_api_key(config).is_some()
}

pub(super) async fn run_search_query(
    query: &str,
    num_results: usize,
    provider: SearchProvider,
    recency_filter: Option<&str>,
    domain_filter: Option<&[String]>,
    config: &SearchBackendConfig,
) -> Result<Vec<SearchItem>, ToolError> {
    match provider {
        SearchProvider::Auto => {
            if let Ok(results) =
                openai::search(query, num_results, recency_filter, domain_filter, config).await
            {
                return Ok(results);
            }
            if let Ok(results) =
                exa::search(query, num_results, recency_filter, domain_filter, config).await
            {
                return Ok(results);
            }
            brave::search(query, num_results, recency_filter, domain_filter, config).await
        }
        SearchProvider::OpenAi => {
            openai::search(query, num_results, recency_filter, domain_filter, config).await
        }
        SearchProvider::Exa => {
            exa::search(query, num_results, recency_filter, domain_filter, config).await
        }
        SearchProvider::Brave => {
            brave::search(query, num_results, recency_filter, domain_filter, config).await
        }
        SearchProvider::Parallel
        | SearchProvider::Tavily
        | SearchProvider::Perplexity
        | SearchProvider::Gemini => Err(ToolError::Message(format!(
            "provider '{provider}' is not configured in this local MVP"
        ))),
        SearchProvider::Disabled => Err(ToolError::Message(
            "web search is disabled in config".into(),
        )),
    }
}

pub(super) async fn item_content(
    item: &SearchItem,
    include_content: bool,
) -> (String, &'static str) {
    if !include_content {
        return (item.snippet.clone(), "snippet");
    }
    let Some(url) = item.url.as_deref() else {
        return (item.snippet.clone(), "snippet");
    };
    match fetch_url_text(url).await {
        Ok(content) => (content, "source_page"),
        Err(err) => {
            let warning = format!("content fetch failed for {url}: {err}");
            if item.snippet.is_empty() {
                (warning, "fetch_failed")
            } else {
                (
                    format!("{}\n\n{warning}", item.snippet),
                    "snippet_with_fetch_warning",
                )
            }
        }
    }
}