rho-coding-agent 1.17.0

A lightweight agent harness inspired by Pi
Documentation
use serde::Deserialize;
use serde_json::{json, Value};

use {
    crate::config::{Config, SearchProvider},
    rho_tools::tool::{truncate, Tool, ToolContext, ToolError, ToolResult, ToolSpec},
};

use super::{
    search::{self, SearchBackendConfig},
    storage::{self, StoredContent, StoredItem, WebAccessStore},
    util::to_pretty_json,
};

pub struct WebSearch {
    config: SearchBackendConfig,
    client: reqwest::Client,
    store: WebAccessStore,
}

pub struct GetSearchContent {
    store: WebAccessStore,
}

impl GetSearchContent {
    pub(super) fn new(store: WebAccessStore) -> Self {
        Self { store }
    }
}

impl WebSearch {
    pub(super) fn with_client(
        config: &Config,
        client: reqwest::Client,
        store: WebAccessStore,
    ) -> Self {
        Self {
            config: SearchBackendConfig::from_config(config),
            client,
            store,
        }
    }

    pub fn is_available(&self) -> bool {
        match self.config.provider {
            SearchProvider::Disabled => false,
            SearchProvider::OpenAi => search::openai_available(&self.config),
            SearchProvider::Brave => search::brave_available(&self.config),
            SearchProvider::Auto
            | SearchProvider::Exa
            | SearchProvider::Parallel
            | SearchProvider::Tavily
            | SearchProvider::Perplexity
            | SearchProvider::Gemini => true,
        }
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebSearchArgs {
    query: Option<String>,
    queries: Option<Vec<String>>,
    num_results: Option<usize>,
    recency_filter: Option<String>,
    domain_filter: Option<Vec<String>>,
    provider: Option<SearchProvider>,
    include_content: Option<bool>,
    workflow: Option<String>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct GetSearchContentArgs {
    pub(super) response_id: String,
    pub(super) query: Option<String>,
    pub(super) query_index: Option<usize>,
    pub(super) url: Option<String>,
    pub(super) url_index: Option<usize>,
}

#[async_trait::async_trait]
impl Tool for WebSearch {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "web_search".into(),
            description: "Search the web through a zero-config interface with optional provider credentials. Returns a concise summary, stores snippets by default under a responseId, and stores full source pages only when includeContent succeeds. Use get_search_content with that responseId when you need stored snippets or source pages.".into(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "queries": {"type": "array", "items": {"type": "string"}, "description": "Search queries. Use one item for a single search, or multiple items for broader research."},
                    "numResults": {"type": "integer", "minimum": 1, "maximum": 20, "description": "Results per query."},
                    "recencyFilter": {"type": "string", "enum": ["day", "week", "month", "year"]},
                    "domainFilter": {"type": "array", "items": {"type": "string"}},
                    "provider": {"type": "string", "enum": ["auto", "openai", "brave", "parallel", "tavily", "exa", "perplexity", "gemini"]},
                    "includeContent": {"type": "boolean", "description": "Try to fetch and store result pages when the selected provider returns URLs."},
                    "workflow": {"type": "string", "enum": ["none", "summary-review", "auto-summary"]}
                },
                "required": ["queries"]
            }),
        }
    }

    async fn call(
        &self,
        args: Value,
        ctx: ToolContext,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let args: WebSearchArgs = serde_json::from_value(args)?;
        let queries = collect_values(args.query, args.queries, "query", "queries")?;
        let num_results = args.num_results.unwrap_or(5).clamp(1, 20);
        let provider = args.provider.unwrap_or(self.config.provider);
        let workflow = args.workflow.unwrap_or_else(|| "summary-review".into());
        let include_content = args.include_content.unwrap_or(false);
        let response_id = storage::new_response_id();
        let mut items = Vec::new();
        let mut summaries = Vec::new();

        for query in queries {
            let result = search::run_search_query(
                &self.client,
                &query,
                num_results,
                provider,
                args.recency_filter.as_deref(),
                args.domain_filter.as_deref(),
                &self.config,
            )
            .await;
            match result {
                Ok(search_items) if !search_items.is_empty() => {
                    for (index, item) in search_items.into_iter().enumerate() {
                        let (content, content_kind) =
                            search::item_content(&self.client, &item, include_content).await;
                        summaries.push(format!(
                            "{}. [{}] {}{}",
                            index + 1,
                            item.title.as_deref().unwrap_or("result"),
                            item.url.as_deref().unwrap_or("no url"),
                            item.snippet
                                .is_empty()
                                .then(String::new)
                                .unwrap_or_else(|| format!(" - {}", item.snippet))
                        ));
                        items.push(StoredItem {
                            url: item.url,
                            query: Some(query.clone()),
                            title: item.title,
                            content,
                            metadata: json!({"provider": provider, "workflow": workflow, "contentKind": content_kind}),
                        });
                    }
                }
                Ok(_) | Err(_) => {
                    let message = format!(
                        "No configured search provider returned live results for '{query}'. Set a provider API key or use fetch_content on known URLs."
                    );
                    summaries.push(message.clone());
                    items.push(StoredItem {
                        url: None,
                        query: Some(query),
                        title: Some("search unavailable".into()),
                        content: message,
                        metadata: json!({"provider": provider, "workflow": workflow, "status": "unavailable", "contentKind": "provider_unavailable"}),
                    });
                }
            }
        }

        let availability = storage::content_availability(&items);
        let stored_content_available = !items.is_empty();
        self.store.store(
            response_id.clone(),
            StoredContent {
                kind: "web_search".into(),
                items,
            },
        )?;

        let content = json!({
            "responseId": response_id,
            "type": "web_search",
            "provider": provider,
            "workflow": workflow,
            "answer": summaries.join("\n"),
            "storedContentAvailable": stored_content_available,
            "snippetContentAvailable": availability.snippets,
            "sourceContentAvailable": availability.sources,
            "fullContentAvailable": availability.sources,
            "note": "Summary is inline. Call get_search_content with only responseId (or an exact original query / queryIndex / url / urlIndex) for stored snippets; full source pages exist only when includeContent succeeded."
        });

        Ok(ToolResult {
            id,
            ok: true,
            content: truncate(to_pretty_json(&content), ctx.max_output_bytes),
        })
    }
}

#[async_trait::async_trait]
impl Tool for GetSearchContent {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "get_search_content".into(),
            description: "Retrieve stored web_search snippets/source pages or fetch_content bodies by responseId. Prefer responseId alone, or exact url/urlIndex/query/queryIndex selectors from the prior tool result. query is the original search query or fetch prompt, not a free-text content search.".into(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "responseId": {"type": "string", "pattern": "^[0-9a-f]{32}$", "description": "responseId returned by web_search or fetch_content."},
                    "query": {"type": "string", "description": "Exact original web_search query or fetch_content prompt. Not a keyword search over page text."},
                    "queryIndex": {"type": "integer", "minimum": 0, "description": "Index among stored items that have a query."},
                    "url": {"type": "string", "description": "Exact stored URL to select."},
                    "urlIndex": {"type": "integer", "minimum": 0, "description": "Index into the stored item list."}
                },
                "required": ["responseId"]
            }),
        }
    }

    async fn call(
        &self,
        args: Value,
        ctx: ToolContext,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let args: GetSearchContentArgs = serde_json::from_value(args)?;
        self.execute(args, ctx.max_output_bytes, id)
    }
}

impl GetSearchContent {
    pub(super) fn execute(
        &self,
        args: GetSearchContentArgs,
        max_output_bytes: usize,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let stored = self.store.load(&args.response_id)?;
        let item = select_stored_item(&stored, &args)?;
        let content = json!({
            "responseId": args.response_id,
            "type": stored.kind,
            "title": item.title,
            "url": item.url,
            "query": item.query,
            "metadata": item.metadata,
            "content": item.content,
        });
        Ok(ToolResult {
            id,
            ok: true,
            content: truncate(to_pretty_json(&content), max_output_bytes),
        })
    }
}

fn select_stored_item<'a>(
    stored: &'a StoredContent,
    args: &GetSearchContentArgs,
) -> Result<&'a StoredItem, ToolError> {
    let available = || storage::available_selectors(stored);
    if let Some(url) = &args.url {
        return stored
            .items
            .iter()
            .find(|item| item.url.as_deref() == Some(url.as_str()))
            .ok_or_else(|| {
                ToolError::Message(format!(
                    "url not found for responseId: {url}. Available selectors:\n{}",
                    available()
                ))
            });
    }
    if let Some(index) = args.url_index {
        return stored.items.get(index).ok_or_else(|| {
            ToolError::Message(format!(
                "urlIndex out of range: {index}. Available selectors:\n{}",
                available()
            ))
        });
    }
    if let Some(query) = &args.query {
        return stored
            .items
            .iter()
            .find(|item| item.query.as_deref() == Some(query.as_str()))
            .ok_or_else(|| {
                ToolError::Message(format!(
                    "query not found for responseId: {query}. query must equal an original web_search query or fetch_content prompt, not page keywords. Available selectors:\n{}",
                    available()
                ))
            });
    }
    if let Some(index) = args.query_index {
        return stored
            .items
            .iter()
            .filter(|item| item.query.is_some())
            .nth(index)
            .ok_or_else(|| {
                ToolError::Message(format!(
                    "queryIndex out of range: {index}. Available selectors:\n{}",
                    available()
                ))
            });
    }
    stored
        .items
        .first()
        .ok_or_else(|| ToolError::Message("responseId has no stored content".into()))
}

fn collect_values(
    value: Option<String>,
    values: Option<Vec<String>>,
    singular: &str,
    plural: &str,
) -> Result<Vec<String>, ToolError> {
    let values = value
        .into_iter()
        .chain(values.into_iter().flatten())
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .collect::<Vec<_>>();
    if values.is_empty() {
        Err(ToolError::Message(format!(
            "{singular} or {plural} must include at least one value"
        )))
    } else {
        Ok(values)
    }
}