diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index e861a41bf..90b85ae87 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -258,6 +258,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | ----- | -------------------- | ----- | ----------------------------------- | ------ | ------------------ | | 7.2.1 | HTTP / API Requests | RU+WD | `service-connectivity-flow.spec.ts` | ✅ | | | 7.2.2 | Web Search Execution | WD | `skill-execution-flow.spec.ts` | 🟡 | Generic skill path | +| 7.2.3 | TinyFish Integration Tools | RU | `src/openhuman/integrations/tinyfish_tests.rs`, `src/openhuman/tools/ops_tests.rs::all_tools_executes_tinyfish_family_against_fake_backend` | ✅ | Backend-proxied Search, Fetch, and Agent run tools covered with fake backend | --- @@ -485,7 +486,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 🟡 Partial | 27 | | ❌ Missing | 26 | | 🚫 Manual smoke | 11 | -| **Total leaves** | **130 explicit + nested = 201 product features** | +| **Total leaves** | **131 explicit + nested = 202 product features** | PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test. Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke). diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 1052e1faa..5e5876b82 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -418,6 +418,17 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: None, }, + Capability { + id: "skills.tinyfish_web_automation", + name: "TinyFish Web Automation", + domain: "skills", + category: CapabilityCategory::Skills, + description: + "Search the web, render JavaScript-heavy pages, and run goal-based browser automations through TinyFish.", + how_to: "Conversations > Ask the assistant to search, fetch, or automate a website with TinyFish", + status: CapabilityStatus::Beta, + privacy: DERIVED_TO_BACKEND, + }, Capability { id: "skills.toggle_enabled", name: "Enable or Disable Skills", diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index 64ab45925..12b644e58 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -1093,4 +1093,5 @@ async fn pricing_for_config_short_circuits_in_direct_mode() { assert!(pricing.integrations.twilio.is_none()); assert!(pricing.integrations.google_places.is_none()); assert!(pricing.integrations.parallel.is_none()); + assert!(pricing.integrations.tinyfish.is_none()); } diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index c9e0d105e..70147e939 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -538,18 +538,69 @@ pub struct ComputerControlConfig { // ── Agent integration tools (backend-proxied) ─────────────────────── -/// Per-integration on/off toggle. +/// Routing mode for an integration that supports a backend-managed +/// default and an optional BYO ("bring your own API key") override. +pub const INTEGRATION_MODE_MANAGED: &str = "managed"; +pub const INTEGRATION_MODE_BYO: &str = "byo"; + +fn default_integration_mode() -> String { + INTEGRATION_MODE_MANAGED.into() +} + +/// Per-integration toggle. +/// +/// Defaults to **OpenHuman-managed** routing: the OpenHuman backend +/// owns the upstream API key, billing, and rate limits — the user only +/// has to flip `enabled` to make the tools available. +/// +/// Users who hold their own provider account can switch `mode` to +/// `"byo"` and supply `api_key`. In that case tools register **iff** +/// the integration is `enabled = true` **and** `api_key` is a non-empty +/// trimmed string — see [`IntegrationToggle::is_active`]. This mirrors +/// the rule the Settings UI surfaces to the user ("loaded iff API key +/// is provided and enabled"). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct IntegrationToggle { #[serde(default = "defaults::default_true")] pub enabled: bool, + /// Routing mode. One of [`INTEGRATION_MODE_MANAGED`] (default — the + /// OpenHuman backend proxies the call) or [`INTEGRATION_MODE_BYO`] + /// (the user's own API key is required and tools refuse to + /// register without it). + #[serde(default = "default_integration_mode")] + pub mode: String, + /// API key for [`INTEGRATION_MODE_BYO`]. Ignored in managed mode. + /// Trimmed empty / `None` ⇒ no BYO key configured. + #[serde(default)] + pub api_key: Option, +} + +impl IntegrationToggle { + /// Returns true when the integration should be wired up at tool- + /// registration time. Managed mode requires only `enabled`; BYO + /// mode requires both `enabled` and a non-empty `api_key`. + pub fn is_active(&self) -> bool { + if !self.enabled { + return false; + } + match self.mode.as_str() { + INTEGRATION_MODE_BYO => self + .api_key + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false), + _ => true, + } + } } impl Default for IntegrationToggle { fn default() -> Self { Self { enabled: defaults::default_true(), + mode: default_integration_mode(), + api_key: None, } } } @@ -677,8 +728,11 @@ impl Default for PolymarketConfig { /// Composio in particular is unconditionally enabled and has no toggle: /// as long as the user is signed in, composio tools are available. /// -/// The per-tool toggles below are preserved because integrations may -/// incur per-call costs or may still be in phased rollout. +/// The per-tool `apify`, `twilio`, `google_places`, `parallel`, and `tinyfish` +/// flags below are preserved because those integrations incur per-call +/// costs that the user may legitimately want to turn off; composio +/// costs are metered server-side, so there is no client-side toggle +/// for it. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(default)] pub struct IntegrationsConfig { @@ -698,6 +752,10 @@ pub struct IntegrationsConfig { #[serde(default)] pub parallel: IntegrationToggle, + /// TinyFish web search, fetch, and browser automation integration. + #[serde(default)] + pub tinyfish: IntegrationToggle, + /// Stock-price / market-data integration (Alpha Vantage on the backend). #[serde(default)] pub stock_prices: IntegrationToggle, @@ -706,3 +764,62 @@ pub struct IntegrationsConfig { #[serde(default)] pub polymarket: PolymarketConfig, } + +#[cfg(test)] +mod integration_toggle_tests { + use super::*; + + #[test] + fn managed_mode_active_when_enabled_without_key() { + let toggle = IntegrationToggle { + enabled: true, + mode: INTEGRATION_MODE_MANAGED.into(), + api_key: None, + }; + assert!(toggle.is_active()); + } + + #[test] + fn managed_mode_inactive_when_disabled() { + let toggle = IntegrationToggle { + enabled: false, + mode: INTEGRATION_MODE_MANAGED.into(), + api_key: Some("ignored".into()), + }; + assert!(!toggle.is_active()); + } + + #[test] + fn byo_mode_requires_non_empty_key() { + let mut toggle = IntegrationToggle { + enabled: true, + mode: INTEGRATION_MODE_BYO.into(), + api_key: None, + }; + assert!(!toggle.is_active(), "missing key"); + + toggle.api_key = Some(" ".into()); + assert!(!toggle.is_active(), "whitespace key"); + + toggle.api_key = Some("real-key".into()); + assert!(toggle.is_active()); + } + + #[test] + fn byo_mode_inactive_when_disabled_even_with_key() { + let toggle = IntegrationToggle { + enabled: false, + mode: INTEGRATION_MODE_BYO.into(), + api_key: Some("real-key".into()), + }; + assert!(!toggle.is_active()); + } + + #[test] + fn default_is_managed_and_active() { + let toggle = IntegrationToggle::default(); + assert_eq!(toggle.mode, INTEGRATION_MODE_MANAGED); + assert!(toggle.api_key.is_none()); + assert!(toggle.is_active()); + } +} diff --git a/src/openhuman/integrations/mod.rs b/src/openhuman/integrations/mod.rs index 9f53f213c..4d0f1b8f2 100644 --- a/src/openhuman/integrations/mod.rs +++ b/src/openhuman/integrations/mod.rs @@ -10,6 +10,7 @@ pub mod google_places; pub mod parallel; pub mod seltz; pub mod stock_prices; +pub mod tinyfish; pub mod twilio; pub mod types; @@ -25,6 +26,7 @@ pub use stock_prices::{ StockCommodityTool, StockCryptoSeriesTool, StockExchangeRateTool, StockOptionsTool, StockQuoteTool, }; +pub use tinyfish::{TinyFishAgentRunTool, TinyFishFetchTool, TinyFishSearchTool}; pub use twilio::TwilioCallTool; pub use types::{ BackendResponse, IntegrationPricing, IntegrationPricingEntry, PricingIntegrations, ToolScope, @@ -65,6 +67,7 @@ mod tests { assert!(pricing.integrations.twilio.is_none()); assert!(pricing.integrations.google_places.is_none()); assert!(pricing.integrations.parallel.is_none()); + assert!(pricing.integrations.tinyfish.is_none()); } #[test] diff --git a/src/openhuman/integrations/test_support.rs b/src/openhuman/integrations/test_support.rs index ae5746c99..3cb2801ea 100644 --- a/src/openhuman/integrations/test_support.rs +++ b/src/openhuman/integrations/test_support.rs @@ -661,6 +661,121 @@ pub async fn spawn_fake_integration_backend() -> FakeIntegrationBackend { })) } }), + ) + .route( + "/agent-integrations/tinyfish/search", + post({ + let state = state.clone(); + move |Json(body): Json| async move { + record( + &state, + "POST", + "/agent-integrations/tinyfish/search".to_string(), + body.clone(), + ); + let query = body + .get("query") + .and_then(Value::as_str) + .unwrap_or("unknown"); + Json(json!({ + "success": true, + "data": { + "query": query, + "results": [ + { + "position": 1, + "site_name": "example.com", + "title": format!("TinyFish result for {query}"), + "snippet": "Rendered search result from fake backend", + "url": "https://example.com/tinyfish" + } + ], + "total_results": 1, + "costUsd": 0.0 + } + })) + } + }), + ) + .route( + "/agent-integrations/tinyfish/fetch", + post({ + let state = state.clone(); + move |Json(body): Json| async move { + record( + &state, + "POST", + "/agent-integrations/tinyfish/fetch".to_string(), + body.clone(), + ); + let urls = as_string_array(&body, "urls"); + let format = body + .get("format") + .and_then(Value::as_str) + .unwrap_or("markdown"); + let results: Vec = urls + .iter() + .map(|url| { + json!({ + "url": url, + "final_url": url, + "title": format!("Fetched {url}"), + "description": "Fake TinyFish rendered page", + "language": "en", + "format": format, + "latency_ms": 42, + "text": format!("# Fetched\n\nTinyFish content for {url}"), + "links": ["https://example.com/next"], + "image_links": ["https://example.com/image.png"] + }) + }) + .collect(); + Json(json!({ + "success": true, + "data": { + "results": results, + "errors": [], + "costUsd": 0.0 + } + })) + } + }), + ) + .route( + "/agent-integrations/tinyfish/agent/run", + post({ + let state = state.clone(); + move |Json(body): Json| async move { + record( + &state, + "POST", + "/agent-integrations/tinyfish/agent/run".to_string(), + body.clone(), + ); + let url = body + .get("url") + .and_then(Value::as_str) + .unwrap_or("https://example.com"); + let goal = body + .get("goal") + .and_then(Value::as_str) + .unwrap_or("unknown goal"); + Json(json!({ + "success": true, + "data": { + "run_id": "run_tinyfish_fake", + "status": "COMPLETED", + "num_of_steps": 3, + "result": { + "url": url, + "goal": goal, + "ok": true + }, + "costUsd": 0.12 + } + })) + } + }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") diff --git a/src/openhuman/integrations/test_support_test.rs b/src/openhuman/integrations/test_support_test.rs index afbb96e91..3c0ca83b0 100644 --- a/src/openhuman/integrations/test_support_test.rs +++ b/src/openhuman/integrations/test_support_test.rs @@ -124,6 +124,18 @@ async fn fake_backend_exercises_optional_and_fallback_response_paths() { .await; assert_eq!(search["data"]["searchId"], json!("search-0")); assert_eq!(search["data"]["results"], json!([])); + + let tinyfish = post_json( + &client, + format!("{}/agent-integrations/tinyfish/agent/run", backend.base_url), + json!({ + "url": "https://example.com", + "goal": "Return JSON" + }), + ) + .await; + assert_eq!(tinyfish["data"]["status"], json!("COMPLETED")); + assert_eq!(tinyfish["data"]["result"]["ok"], json!(true)); } #[tokio::test] diff --git a/src/openhuman/integrations/tinyfish.rs b/src/openhuman/integrations/tinyfish.rs new file mode 100644 index 000000000..b446f0e6b --- /dev/null +++ b/src/openhuman/integrations/tinyfish.rs @@ -0,0 +1,594 @@ +//! TinyFish web search, page fetch, and goal-based browser automation tools. +//! +//! **Scope**: All (agent loop + CLI/RPC). +//! +//! **Endpoints**: +//! - `POST /agent-integrations/tinyfish/search` +//! - `POST /agent-integrations/tinyfish/fetch` +//! - `POST /agent-integrations/tinyfish/agent/run` +//! +//! The OpenHuman backend proxies TinyFish calls so API keys, billing, and +//! rate limits stay server-side. Search and Fetch are read-oriented tools; +//! Agent runs execute browser workflows on remote websites. + +use super::IntegrationClient; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; +use std::sync::Arc; + +fn truncate_chars(s: &str, max_chars: usize) -> (&str, bool) { + match s.char_indices().nth(max_chars) { + Some((byte_idx, _)) => (&s[..byte_idx], true), + None => (s, false), + } +} + +fn non_empty_string<'a>(args: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a str> { + args.get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: {key}")) +} + +fn optional_string<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args.get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) +} + +#[derive(Debug, Deserialize)] +struct TinyFishSearchResponse { + #[serde(default)] + results: Vec, + #[serde(default)] + total_results: Option, + #[serde(rename = "costUsd", default)] + cost_usd: Option, +} + +#[derive(Debug, Deserialize)] +struct TinyFishSearchResult { + #[serde(default)] + position: Option, + #[serde(default)] + site_name: Option, + #[serde(default)] + title: String, + #[serde(default)] + snippet: String, + #[serde(default)] + url: String, +} + +#[derive(Debug, Deserialize)] +struct TinyFishFetchResponse { + #[serde(default)] + results: Vec, + #[serde(default)] + errors: Vec, + #[serde(rename = "costUsd", default)] + cost_usd: Option, +} + +#[derive(Debug, Deserialize)] +struct TinyFishFetchResult { + #[serde(default)] + url: String, + #[serde(default)] + final_url: Option, + #[serde(default)] + title: Option, + #[serde(default)] + description: Option, + #[serde(default)] + language: Option, + #[serde(default)] + text: serde_json::Value, + #[serde(default)] + links: Option>, + #[serde(default)] + image_links: Option>, + #[serde(default)] + latency_ms: Option, + #[serde(default)] + format: Option, +} + +#[derive(Debug, Deserialize)] +struct TinyFishFetchError { + #[serde(default)] + url: String, + #[serde(default)] + error: String, + #[serde(default)] + status: Option, +} + +#[derive(Debug, Deserialize)] +struct TinyFishAgentRunResponse { + #[serde(default)] + run_id: Option, + #[serde(default)] + status: String, + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, + #[serde(default)] + num_of_steps: Option, + #[serde(rename = "costUsd", default)] + cost_usd: Option, +} + +/// Search the web with TinyFish Search. +pub struct TinyFishSearchTool { + client: Arc, +} + +impl TinyFishSearchTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for TinyFishSearchTool { + fn name(&self) -> &str { + "tinyfish_search" + } + + fn description(&self) -> &str { + "Search the web with TinyFish and return ranked results with titles, snippets, \ + URLs, and source sites. Use this when you need search results before fetching \ + or automating a page." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "location": { + "type": "string", + "description": "Optional country code for geo-targeted results, e.g. US, GB, FR" + }, + "language": { + "type": "string", + "description": "Optional language code, e.g. en, fr" + }, + "page": { + "type": "integer", + "description": "Optional result page number, starting at 0", + "minimum": 0, + "maximum": 10 + }, + "include_thumbnail": { + "type": "boolean", + "description": "Whether to include thumbnail URLs when available", + "default": false + } + }, + "required": ["query"] + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::Skill + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let query = non_empty_string(&args, "query")?; + let mut body = json!({ "query": query }); + if let Some(location) = optional_string(&args, "location") { + body["location"] = json!(location); + } + if let Some(language) = optional_string(&args, "language") { + body["language"] = json!(language); + } + if let Some(page) = args.get("page").and_then(|v| v.as_u64()) { + body["page"] = json!(page.min(10)); + } + if let Some(include_thumbnail) = args.get("include_thumbnail").and_then(|v| v.as_bool()) { + body["include_thumbnail"] = json!(include_thumbnail); + } + + tracing::debug!(query = query, "[tinyfish_search] searching"); + + match self + .client + .post::("/agent-integrations/tinyfish/search", &body) + .await + { + Ok(resp) => { + if resp.results.is_empty() { + return Ok(ToolResult::success(format!( + "No TinyFish search results found for: {query}" + ))); + } + + let mut lines = vec![format!( + "TinyFish returned {} search result(s) for: {query}", + resp.results.len() + )]; + if let Some(total) = resp.total_results { + lines.push(format!("Total results: {total}")); + } + + for (idx, item) in resp.results.iter().take(10).enumerate() { + let position = item.position.unwrap_or((idx + 1) as u64); + lines.push(format!("\n{}. {}", position, item.title)); + if let Some(site_name) = item.site_name.as_deref() { + if !site_name.is_empty() { + lines.push(format!(" Site: {site_name}")); + } + } + if !item.url.is_empty() { + lines.push(format!(" URL: {}", item.url)); + } + if !item.snippet.is_empty() { + lines.push(format!(" Snippet: {}", item.snippet)); + } + } + + if resp.results.len() > 10 { + lines.push("Output truncated to the first 10 results.".to_string()); + } + if let Some(cost_usd) = resp.cost_usd { + lines.push(format!("\nCost: ${cost_usd:.4}")); + } + + Ok(ToolResult::success(lines.join("\n"))) + } + Err(e) => { + tracing::debug!( + query = query, + error = %e, + "[tinyfish_search] request failed" + ); + Ok(ToolResult::error(format!("TinyFish search failed: {e}"))) + } + } + } +} + +/// Render web pages with TinyFish Fetch and return extracted content. +pub struct TinyFishFetchTool { + client: Arc, +} + +impl TinyFishFetchTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for TinyFishFetchTool { + fn name(&self) -> &str { + "tinyfish_fetch" + } + + fn description(&self) -> &str { + "Render one or more URLs with TinyFish Fetch and extract clean page content. \ + Use this for JavaScript-heavy pages when you already know the URLs." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { "type": "string" }, + "description": "HTTP or HTTPS URLs to fetch (1-10)", + "minItems": 1, + "maxItems": 10 + }, + "format": { + "type": "string", + "enum": ["markdown", "html", "json"], + "description": "Output format for extracted content (default markdown)", + "default": "markdown" + }, + "links": { + "type": "boolean", + "description": "Include absolute outbound links found on each page", + "default": false + }, + "image_links": { + "type": "boolean", + "description": "Include absolute image URLs found on each page", + "default": false + } + }, + "required": ["urls"] + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::Skill + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let urls = args + .get("urls") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: urls"))?; + if urls.is_empty() { + return Ok(ToolResult::error("urls must contain at least one URL")); + } + + let mut normalized_urls = Vec::with_capacity(urls.len().min(10)); + for (idx, url) in urls.iter().take(10).enumerate() { + match url.as_str() { + Some(s) if !s.trim().is_empty() => normalized_urls.push(s), + Some(_) => return Ok(ToolResult::error(format!("urls[{idx}] is empty"))), + None => return Ok(ToolResult::error(format!("urls[{idx}] is not a string"))), + } + } + + let mut body = json!({ "urls": normalized_urls }); + if let Some(format) = optional_string(&args, "format") { + body["format"] = json!(format); + } + if let Some(links) = args.get("links").and_then(|v| v.as_bool()) { + body["links"] = json!(links); + } + if let Some(image_links) = args.get("image_links").and_then(|v| v.as_bool()) { + body["image_links"] = json!(image_links); + } + + tracing::debug!( + url_count = normalized_urls.len(), + "[tinyfish_fetch] fetching URLs" + ); + + match self + .client + .post::("/agent-integrations/tinyfish/fetch", &body) + .await + { + Ok(resp) => { + let mut lines = vec![format!( + "TinyFish fetched {} page(s), {} error(s).", + resp.results.len(), + resp.errors.len() + )]; + + for (idx, page) in resp.results.iter().enumerate() { + lines.push(format!( + "\n{}. {}", + idx + 1, + page.title.as_deref().unwrap_or(&page.url) + )); + lines.push(format!(" URL: {}", page.url)); + if let Some(final_url) = page.final_url.as_deref() { + if final_url != page.url { + lines.push(format!(" Final URL: {final_url}")); + } + } + if let Some(description) = page.description.as_deref() { + if !description.is_empty() { + lines.push(format!(" Description: {description}")); + } + } + if let Some(language) = page.language.as_deref() { + lines.push(format!(" Language: {language}")); + } + if let Some(format) = page.format.as_deref() { + lines.push(format!(" Format: {format}")); + } + if let Some(latency_ms) = page.latency_ms { + lines.push(format!(" Latency: {latency_ms:.0}ms")); + } + if let Some(links) = page.links.as_ref() { + lines.push(format!(" Links: {}", links.len())); + } + if let Some(image_links) = page.image_links.as_ref() { + lines.push(format!(" Image links: {}", image_links.len())); + } + + let text = match page.text.as_str() { + Some(s) => s.to_string(), + None => page.text.to_string(), + }; + if !text.is_empty() && text != "null" { + let (snippet, truncated) = truncate_chars(&text, 1200); + lines.push(" Content:".to_string()); + lines.push(snippet.to_string()); + if truncated { + lines.push(" Content truncated to 1200 characters.".to_string()); + } + } + } + + if !resp.errors.is_empty() { + lines.push("\nErrors:".to_string()); + for err in &resp.errors { + let status = err.status.map(|s| format!(" ({s})")).unwrap_or_default(); + lines.push(format!("- {}: {}{}", err.url, err.error, status)); + } + } + if let Some(cost_usd) = resp.cost_usd { + lines.push(format!("\nCost: ${cost_usd:.4}")); + } + + Ok(ToolResult::success(lines.join("\n"))) + } + Err(e) => { + tracing::debug!( + url_count = normalized_urls.len(), + error = %e, + "[tinyfish_fetch] request failed" + ); + Ok(ToolResult::error(format!("TinyFish fetch failed: {e}"))) + } + } + } +} + +/// Run a TinyFish goal-based browser automation. +pub struct TinyFishAgentRunTool { + client: Arc, +} + +impl TinyFishAgentRunTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for TinyFishAgentRunTool { + fn name(&self) -> &str { + "tinyfish_agent_run" + } + + fn description(&self) -> &str { + "Run a TinyFish goal-based browser automation on a target website. Provide a URL \ + and a specific natural-language goal with the desired output format." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Target website URL to automate" + }, + "goal": { + "type": "string", + "description": "Specific browser automation goal and expected output format" + }, + "output_schema": { + "type": "object", + "description": "Optional structured-output JSON schema supported by TinyFish" + }, + "browser_profile": { + "type": "string", + "enum": ["lite", "stealth"], + "description": "Browser profile for the run (default lite)", + "default": "lite" + }, + "proxy_country_code": { + "type": "string", + "enum": ["US", "GB", "CA", "DE", "FR", "JP", "AU"], + "description": "Optional TinyFish proxy country code" + }, + "use_vault": { + "type": "boolean", + "description": "Allow TinyFish vault credentials for this run when configured", + "default": false + }, + "credential_item_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional credential item IDs scoped to this run; requires use_vault=true" + } + }, + "required": ["url", "goal"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + fn category(&self) -> ToolCategory { + ToolCategory::Skill + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let url = non_empty_string(&args, "url")?; + let goal = non_empty_string(&args, "goal")?; + let mut body = json!({ + "url": url, + "goal": goal, + }); + + if let Some(output_schema) = args.get("output_schema") { + if !output_schema.is_object() { + return Ok(ToolResult::error("output_schema must be a JSON object")); + } + body["output_schema"] = output_schema.clone(); + } + if let Some(browser_profile) = optional_string(&args, "browser_profile") { + body["browser_profile"] = json!(browser_profile); + } + if let Some(country_code) = optional_string(&args, "proxy_country_code") { + body["proxy_config"] = json!({ + "enabled": true, + "type": "tetra", + "country_code": country_code, + }); + } + if let Some(use_vault) = args.get("use_vault").and_then(|v| v.as_bool()) { + body["use_vault"] = json!(use_vault); + } + if let Some(ids) = args.get("credential_item_ids") { + if !ids.is_array() { + return Ok(ToolResult::error("credential_item_ids must be an array")); + } + body["credential_item_ids"] = ids.clone(); + } + + tracing::debug!(url = url, "[tinyfish_agent_run] starting automation"); + + match self + .client + .post::("/agent-integrations/tinyfish/agent/run", &body) + .await + { + Ok(resp) => { + tracing::debug!( + run_id = resp.run_id.as_deref().unwrap_or(""), + status = resp.status.as_str(), + has_error = resp.error.is_some(), + "[tinyfish_agent_run] request finished" + ); + let mut lines = vec!["TinyFish automation finished.".to_string()]; + if let Some(run_id) = resp.run_id.as_deref() { + lines.push(format!("Run ID: {run_id}")); + } + if !resp.status.is_empty() { + lines.push(format!("Status: {}", resp.status)); + } + if let Some(steps) = resp.num_of_steps { + lines.push(format!("Steps: {steps}")); + } + if let Some(result) = resp.result { + lines.push("Result:".to_string()); + lines.push(result.to_string()); + } + if let Some(error) = resp.error { + lines.push("Error:".to_string()); + lines.push(error.to_string()); + } + if let Some(cost_usd) = resp.cost_usd { + lines.push(format!("Cost: ${cost_usd:.4}")); + } + Ok(ToolResult::success(lines.join("\n"))) + } + Err(e) => { + tracing::debug!( + url = url, + error = %e, + "[tinyfish_agent_run] request failed" + ); + Ok(ToolResult::error(format!( + "TinyFish automation failed: {e}" + ))) + } + } + } +} + +#[cfg(test)] +#[path = "tinyfish_tests.rs"] +mod tests; diff --git a/src/openhuman/integrations/tinyfish_tests.rs b/src/openhuman/integrations/tinyfish_tests.rs new file mode 100644 index 000000000..0494be026 --- /dev/null +++ b/src/openhuman/integrations/tinyfish_tests.rs @@ -0,0 +1,180 @@ +use super::*; + +fn test_client() -> Arc { + Arc::new(IntegrationClient::new("http://test".into(), "tok".into())) +} + +#[test] +fn search_tool_metadata_and_schema() { + let tool = TinyFishSearchTool::new(test_client()); + assert_eq!(tool.name(), "tinyfish_search"); + assert_eq!(tool.category(), ToolCategory::Skill); + assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); + assert!(tool.description().contains("TinyFish")); + + let schema = tool.parameters_schema(); + assert!(schema["properties"]["query"].is_object()); + assert!(schema["required"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "query")); +} + +#[tokio::test] +async fn search_rejects_missing_query() { + let tool = TinyFishSearchTool::new(test_client()); + assert!(tool.execute(json!({})).await.is_err()); +} + +#[tokio::test] +async fn search_rejects_empty_query() { + let tool = TinyFishSearchTool::new(test_client()); + assert!(tool.execute(json!({"query": ""})).await.is_err()); +} + +#[test] +fn fetch_tool_metadata_and_schema() { + let tool = TinyFishFetchTool::new(test_client()); + assert_eq!(tool.name(), "tinyfish_fetch"); + assert_eq!(tool.category(), ToolCategory::Skill); + assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); + assert!(tool.description().contains("JavaScript-heavy")); + + let schema = tool.parameters_schema(); + assert!(schema["properties"]["urls"].is_object()); + assert!(schema["properties"]["format"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "markdown")); + assert!(schema["required"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "urls")); +} + +#[tokio::test] +async fn fetch_rejects_empty_urls() { + let tool = TinyFishFetchTool::new(test_client()); + let result = tool.execute(json!({"urls": []})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("at least one URL")); +} + +#[tokio::test] +async fn fetch_rejects_non_string_url() { + let tool = TinyFishFetchTool::new(test_client()); + let result = tool.execute(json!({"urls": [42]})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not a string")); +} + +#[test] +fn agent_run_tool_metadata_and_schema() { + let tool = TinyFishAgentRunTool::new(test_client()); + assert_eq!(tool.name(), "tinyfish_agent_run"); + assert_eq!(tool.category(), ToolCategory::Skill); + assert_eq!(tool.permission_level(), PermissionLevel::Execute); + assert!(tool.description().contains("browser automation")); + + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "url")); + assert!(required.iter().any(|v| v == "goal")); + assert!(schema["properties"]["browser_profile"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "stealth")); +} + +#[tokio::test] +async fn agent_run_rejects_missing_goal() { + let tool = TinyFishAgentRunTool::new(test_client()); + assert!(tool + .execute(json!({"url": "https://example.com"})) + .await + .is_err()); +} + +#[tokio::test] +async fn agent_run_rejects_non_object_output_schema() { + let tool = TinyFishAgentRunTool::new(test_client()); + let result = tool + .execute(json!({ + "url": "https://example.com", + "goal": "Return JSON", + "output_schema": [] + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("output_schema")); +} + +#[test] +fn search_response_deserializes() { + let json = r#"{ + "results": [ + { + "position": 1, + "site_name": "example.com", + "title": "Example", + "snippet": "Example snippet", + "url": "https://example.com" + } + ], + "total_results": 1, + "costUsd": 0.0 + }"#; + let resp: TinyFishSearchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.results.len(), 1); + assert_eq!(resp.results[0].title, "Example"); + assert_eq!(resp.total_results, Some(1)); +} + +#[test] +fn fetch_response_deserializes_with_errors() { + let json = r##"{ + "results": [ + { + "url": "https://example.com", + "final_url": "https://example.com", + "title": "Example", + "text": "# Example", + "links": ["https://example.com/a"], + "image_links": ["https://example.com/a.png"], + "latency_ms": 12 + } + ], + "errors": [ + { + "url": "https://blocked.example", + "error": "bot_blocked", + "status": 403 + } + ] + }"##; + let resp: TinyFishFetchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.results.len(), 1); + assert_eq!(resp.errors.len(), 1); + assert_eq!(resp.errors[0].status, Some(403)); +} + +#[test] +fn agent_run_response_deserializes() { + let json = r#"{ + "run_id": "run_123", + "status": "COMPLETED", + "num_of_steps": 4, + "result": {"ok": true}, + "costUsd": 0.12 + }"#; + let resp: TinyFishAgentRunResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.run_id.as_deref(), Some("run_123")); + assert_eq!(resp.status, "COMPLETED"); + assert_eq!(resp.num_of_steps, Some(4)); + assert_eq!(resp.cost_usd, Some(0.12)); +} diff --git a/src/openhuman/integrations/types.rs b/src/openhuman/integrations/types.rs index 2a0dbab6a..f02577b45 100644 --- a/src/openhuman/integrations/types.rs +++ b/src/openhuman/integrations/types.rs @@ -24,6 +24,8 @@ pub struct PricingIntegrations { pub google_places: Option, #[serde(default)] pub parallel: Option, + #[serde(default)] + pub tinyfish: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 37a65c3ec..682f66764 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -406,7 +406,7 @@ pub fn all_tools_with_runtime( // ── Agent integration tools (backend-proxied) ───────────────── if let Some(client) = crate::openhuman::integrations::build_client(root_config) { tracing::debug!("[integrations] client built successfully"); - if root_config.integrations.apify.enabled { + if root_config.integrations.apify.is_active() { tools.push(Box::new( crate::openhuman::integrations::ApifyRunActorTool::new(Arc::clone(&client)), )); @@ -420,7 +420,7 @@ pub fn all_tools_with_runtime( } else { tracing::debug!("[integrations] apify disabled — skipping"); } - if root_config.integrations.google_places.enabled { + if root_config.integrations.google_places.is_active() { tools.push(Box::new( crate::openhuman::integrations::GooglePlacesSearchTool::new(Arc::clone(&client)), )); @@ -431,7 +431,7 @@ pub fn all_tools_with_runtime( } else { tracing::debug!("[integrations] google_places disabled — skipping"); } - if root_config.integrations.parallel.enabled { + if root_config.integrations.parallel.is_active() { tools.push(Box::new( crate::openhuman::integrations::ParallelSearchTool::new(Arc::clone(&client)), )); @@ -454,7 +454,21 @@ pub fn all_tools_with_runtime( } else { tracing::debug!("[integrations] parallel disabled — skipping"); } - if root_config.integrations.stock_prices.enabled { + if root_config.integrations.tinyfish.is_active() { + tools.push(Box::new( + crate::openhuman::integrations::TinyFishSearchTool::new(Arc::clone(&client)), + )); + tools.push(Box::new( + crate::openhuman::integrations::TinyFishFetchTool::new(Arc::clone(&client)), + )); + tools.push(Box::new( + crate::openhuman::integrations::TinyFishAgentRunTool::new(Arc::clone(&client)), + )); + tracing::debug!("[integrations] registered tinyfish tools"); + } else { + tracing::debug!("[integrations] tinyfish disabled — skipping"); + } + if root_config.integrations.stock_prices.is_active() { tools.push(Box::new( crate::openhuman::integrations::StockQuoteTool::new(Arc::clone(&client)), )); @@ -474,7 +488,7 @@ pub fn all_tools_with_runtime( } else { tracing::debug!("[integrations] stock_prices disabled — skipping"); } - if root_config.integrations.twilio.enabled { + if root_config.integrations.twilio.is_active() { tools.push(Box::new( crate::openhuman::integrations::TwilioCallTool::new(Arc::clone(&client)), )); diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 1b9e3672b..641b7e7b6 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -53,6 +53,7 @@ fn integration_test_config(tmp: &TempDir, backend_url: &str) -> Config { cfg.integrations.apify.enabled = true; cfg.integrations.google_places.enabled = true; cfg.integrations.parallel.enabled = true; + cfg.integrations.tinyfish.enabled = true; cfg.integrations.stock_prices.enabled = true; cfg.integrations.twilio.enabled = true; cfg @@ -872,6 +873,7 @@ fn all_tools_registers_integration_families_when_enabled_and_signed_in() { cfg.integrations.apify.enabled = true; cfg.integrations.google_places.enabled = true; cfg.integrations.parallel.enabled = true; + cfg.integrations.tinyfish.enabled = true; cfg.integrations.stock_prices.enabled = true; cfg.integrations.twilio.enabled = true; cfg.composio.enabled = true; @@ -903,6 +905,9 @@ fn all_tools_registers_integration_families_when_enabled_and_signed_in() { "parallel_research", "parallel_enrich", "parallel_dataset", + "tinyfish_search", + "tinyfish_fetch", + "tinyfish_agent_run", "stock_quote", "stock_exchange_rate", "stock_options", @@ -1161,6 +1166,77 @@ async fn all_tools_executes_parallel_and_web_search_family_against_fake_backend( assert_eq!(requests[6].body["matchLimit"], serde_json::json!(25)); } +#[tokio::test] +async fn all_tools_executes_tinyfish_family_against_fake_backend() { + let backend = integration_test_support::spawn_fake_integration_backend().await; + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, &backend.base_url); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + + let search = find_tool(&tools, "tinyfish_search") + .execute(serde_json::json!({ + "query": "web automation", + "location": "US", + "language": "en", + "page": 2, + "include_thumbnail": true + })) + .await + .expect("tinyfish_search execute"); + assert!(search + .output() + .contains("TinyFish returned 1 search result(s)")); + assert!(search + .output() + .contains("TinyFish result for web automation")); + + let fetch = find_tool(&tools, "tinyfish_fetch") + .execute(serde_json::json!({ + "urls": ["https://example.com/a"], + "format": "markdown", + "links": true, + "image_links": true + })) + .await + .expect("tinyfish_fetch execute"); + assert!(fetch.output().contains("TinyFish fetched 1 page(s)")); + assert!(fetch + .output() + .contains("TinyFish content for https://example.com/a")); + + let run = find_tool(&tools, "tinyfish_agent_run") + .execute(serde_json::json!({ + "url": "https://example.com/shop", + "goal": "Extract product names. Return JSON.", + "browser_profile": "stealth", + "proxy_country_code": "US", + "output_schema": { "type": "object" } + })) + .await + .expect("tinyfish_agent_run execute"); + assert!(run.output().contains("TinyFish automation finished.")); + assert!(run.output().contains("run_tinyfish_fake")); + assert!(run.output().contains("\"ok\":true")); + + let requests = backend.requests(); + let paths: Vec<&str> = requests.iter().map(|req| req.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + "/agent-integrations/tinyfish/search", + "/agent-integrations/tinyfish/fetch", + "/agent-integrations/tinyfish/agent/run", + ] + ); + assert_eq!(requests[0].body["location"], serde_json::json!("US")); + assert_eq!(requests[1].body["links"], serde_json::json!(true)); + assert_eq!( + requests[2].body["proxy_config"]["country_code"], + serde_json::json!("US") + ); +} + #[tokio::test] async fn all_tools_executes_stock_and_twilio_family_against_fake_backend() { let backend = integration_test_support::spawn_fake_integration_backend().await;