web-retrieval 0.3.7

Web fetch and web search MCP tools
Documentation
// TODO(2): Tool descriptions are static; consider framework support for injecting runtime context
// (e.g., today's date) into tool descriptions.

//! Haiku summarization via Anthropic API.

use agentic_tools_core::ToolContext;
use agentic_tools_core::error::ToolError;
use anthropic_async::types::ContentBlock;
use anthropic_async::types::MessageParam;
use anthropic_async::types::MessageRole;
use anthropic_async::types::MessagesCreateRequest;
use tracing::debug;

use crate::WebTools;

const MAX_MARKDOWN_CHARS: usize = 100_000;

/// Summarize markdown content using Claude Haiku.
///
/// Lazy-initializes the Anthropic client on first call.
/// Errors are NOT cached in the `OnceCell`, allowing retries.
///
/// # Errors
/// Returns `ToolError` if the Anthropic client cannot be initialized or the API call fails.
pub async fn summarize_markdown(
    tools: &WebTools,
    markdown: &str,
    ctx: &ToolContext,
) -> Result<String, ToolError> {
    if ctx.is_cancelled() {
        return Err(ToolError::cancelled(None));
    }

    // Truncate to avoid context window overflow (~25K tokens, well under 200K limit)
    let markdown: String = markdown.chars().take(MAX_MARKDOWN_CHARS).collect();

    let base_url = tools.anthropic_cfg.base_url.clone();
    let client = tools
        .anthropic
        .get_or_try_init(|| async { init_anthropic_client(&base_url).await })
        .await
        .map_err(|e| ToolError::external(format!("Failed to initialize Anthropic client: {e}")))?;

    let req = MessagesCreateRequest {
        model: tools.cfg.summarizer.model.clone(),
        max_tokens: tools.cfg.summarizer.max_tokens,
        messages: vec![MessageParam {
            role: MessageRole::User,
            content: format!(
                "Summarize the following web page content in 5-8 concise bullet points. \
                 Focus on the key facts and takeaways.\n\n{markdown}"
            )
            .into(),
        }],
        #[expect(clippy::cast_possible_truncation)]
        temperature: Some(tools.cfg.summarizer.temperature as f32),
        ..Default::default()
    };

    let resp = ctx
        .run_cancellable(async {
            client
                .messages()
                .create(req)
                .await
                .map_err(|e| ToolError::external(format!("Haiku API call failed: {e}")))
        })
        .await?;

    // Extract text from content blocks
    let text = resp
        .content
        .iter()
        .filter_map(|block| {
            if let ContentBlock::Text { text, .. } = block {
                Some(text.as_str())
            } else {
                None
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    Ok(text)
}

/// Normalize a key by trimming whitespace; returns None if empty after trim.
fn normalize_key(s: &str) -> Option<String> {
    let t = s.trim();
    if t.is_empty() {
        None
    } else {
        Some(t.to_string())
    }
}

/// Initialize the Anthropic client.
///
/// Attempts to find an API key from:
/// 1. `ANTHROPIC_API_KEY` environment variable (must be non-empty after trim)
/// 2. `OpenCode` provider discovery (fallback)
///
/// Uses the provided `base_url` for API endpoint override.
async fn init_anthropic_client(
    base_url: &str,
) -> Result<anthropic_async::Client<anthropic_async::AnthropicConfig>, ToolError> {
    // Try env var first (only if non-empty after trim)
    if let Ok(key) = std::env::var("ANTHROPIC_API_KEY")
        && let Some(key) = normalize_key(&key)
    {
        debug!("Using ANTHROPIC_API_KEY from environment");
        let config = anthropic_async::AnthropicConfig::new()
            .with_api_base(base_url)
            .with_api_key(key);
        return Ok(anthropic_async::Client::with_config(config));
    }

    // Try OpenCode provider discovery
    match get_anthropic_key_from_opencode().await {
        Ok(key) => {
            debug!("Using Anthropic key from OpenCode provider");
            let config = anthropic_async::AnthropicConfig::new()
                .with_api_base(base_url)
                .with_api_key(key);
            Ok(anthropic_async::Client::with_config(config))
        }
        Err(e) => Err(ToolError::external(format!(
            "No Anthropic credentials available. Set ANTHROPIC_API_KEY or ensure OpenCode is running. Error: {e}"
        ))),
    }
}

/// Try to get an Anthropic API key from `OpenCode`'s provider endpoint.
async fn get_anthropic_key_from_opencode() -> Result<String, String> {
    let client = opencode_rs::Client::builder()
        .build()
        .map_err(|e| format!("Failed to build OpenCode client: {e}"))?;

    let providers = client
        .providers()
        .list()
        .await
        .map_err(|e| format!("Failed to list OpenCode providers: {e}"))?;

    for provider in providers.all {
        if provider.id == "anthropic"
            && let Some(key) = provider.key
            && !key.is_empty()
        {
            return Ok(key);
        }
    }

    Err("No Anthropic provider found in OpenCode".into())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_key_returns_none_for_empty() {
        assert_eq!(normalize_key(""), None);
    }

    #[test]
    fn normalize_key_returns_none_for_whitespace_only() {
        assert_eq!(normalize_key("   "), None);
        assert_eq!(normalize_key("\t\n"), None);
        assert_eq!(normalize_key("  \n  "), None);
    }

    #[test]
    fn normalize_key_trims_and_returns_valid_key() {
        assert_eq!(normalize_key("key"), Some("key".to_string()));
        assert_eq!(normalize_key("  key  "), Some("key".to_string()));
        assert_eq!(normalize_key("\nkey\n"), Some("key".to_string()));
        assert_eq!(
            normalize_key("  my-api-key-123  "),
            Some("my-api-key-123".to_string())
        );
    }
}