rho-coding-agent 0.22.0

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

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

use super::{
    display,
    fetch::fetch_target,
    search::{self, SearchBackendConfig},
    storage::{self, StoredContent, StoredItem},
    util::to_pretty_json,
};

pub struct WebSearch {
    config: SearchBackendConfig,
}

pub struct FetchContent;
pub struct GetSearchContent;

impl WebSearch {
    pub fn from_config(config: &Config) -> Self {
        Self {
            config: SearchBackendConfig::from_config(config),
        }
    }

    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")]
struct FetchContentArgs {
    url: Option<String>,
    urls: Option<Vec<String>>,
    prompt: Option<String>,
    timestamp: Option<String>,
    frames: Option<usize>,
    force_clone: Option<bool>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetSearchContentArgs {
    response_id: String,
    query: Option<String>,
    query_index: Option<usize>,
    url: Option<String>,
    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, stored snippets by default, and a responseId; use get_search_content for stored snippets or fetched source content.".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"]
            }),
        }
    }

    fn display_lines(&self, args: &Value, _ctx: &ToolContext, result: &ToolResult) -> Vec<String> {
        vec![display::web_search(args, result)]
    }

    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(
                &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(&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();
        storage::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": "Tool output is intentionally concise. get_search_content returns stored snippets by default; fetched full page content is available only when includeContent succeeds for at least one result."
        });

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

#[async_trait::async_trait]
impl Tool for FetchContent {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "fetch_content".into(),
            description: "Fetch URLs, GitHub repos/files, YouTube/local videos, PDFs, local files, or web pages. Returns previews, artifacts, and responseId handles instead of dumping large content.".into(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "urls": {"type": "array", "items": {"type": "string"}, "description": "URLs or local paths. Use one item for a single fetch, or multiple items to fetch several targets."},
                    "prompt": {"type": "string", "description": "Question for video or page analysis."},
                    "timestamp": {"type": "string", "description": "Video frame timestamp or range, e.g. 23:41 or 23:41-25:00."},
                    "frames": {"type": "integer", "minimum": 1, "maximum": 12},
                    "forceClone": {"type": "boolean", "description": "Clone GitHub repos even over the 350MB threshold."}
                },
                "required": ["urls"]
            }),
        }
    }

    fn display_lines(&self, _args: &Value, _ctx: &ToolContext, result: &ToolResult) -> Vec<String> {
        vec![display::fetch_content(result)]
    }

    async fn call(
        &self,
        args: Value,
        ctx: ToolContext,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let args: FetchContentArgs = serde_json::from_value(args)?;
        let urls = collect_values(args.url, args.urls, "url", "urls")?;
        let frames = args.frames.unwrap_or(6).clamp(1, 12);
        let response_id = storage::new_response_id();
        let mut items = Vec::new();
        let mut previews = Vec::new();

        for target in urls {
            let fetched = fetch_target(
                &target,
                &ctx,
                args.prompt.as_deref(),
                args.timestamp.as_deref(),
                frames,
                args.force_clone.unwrap_or(false),
            )
            .await?;
            previews.push(fetched.preview.clone());
            items.push(StoredItem {
                url: Some(target),
                query: args.prompt.clone(),
                title: fetched.title,
                content: fetched.content,
                metadata: fetched.metadata,
            });
        }

        storage::store(
            response_id.clone(),
            StoredContent {
                kind: "fetch_content".into(),
                items,
            },
        );

        let content = json!({
            "responseId": response_id,
            "type": "fetch_content",
            "items": previews,
            "fullContentAvailable": true,
            "note": "Large fetched content is stored out-of-band. Use get_search_content with responseId to retrieve it."
        });

        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 snippets, fetched source content, or fetch_content artifacts from a previous responseId by query, URL, or index.".into(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "responseId": {"type": "string", "pattern": "^[0-9a-f]{32}$"},
                    "query": {"type": "string"},
                    "queryIndex": {"type": "integer", "minimum": 0},
                    "url": {"type": "string"},
                    "urlIndex": {"type": "integer", "minimum": 0}
                },
                "required": ["responseId"]
            }),
        }
    }

    fn display_lines(&self, _args: &Value, _ctx: &ToolContext, result: &ToolResult) -> Vec<String> {
        vec![display::get_search_content(result)]
    }

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

fn select_stored_item<'a>(
    stored: &'a StoredContent,
    args: &GetSearchContentArgs,
) -> Result<&'a StoredItem, ToolError> {
    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}")));
    }
    if let Some(index) = args.url_index {
        return stored
            .items
            .get(index)
            .ok_or_else(|| ToolError::Message(format!("urlIndex out of range: {index}")));
    }
    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}")));
    }
    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}")));
    }
    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)
    }
}