Add TinyFish integration tools (#2125)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Pranav Janakiraman
2026-05-19 19:45:30 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent b2346c2753
commit f684d30e67
12 changed files with 1135 additions and 9 deletions
+2 -1
View File
@@ -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).
+11
View File
@@ -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",
+1
View File
@@ -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());
}
+120 -3
View File
@@ -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<String>,
}
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());
}
}
+3
View File
@@ -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]
+115
View File
@@ -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<Value>| 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<Value>| 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<Value> = 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<Value>| 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")
@@ -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]
+594
View File
@@ -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<TinyFishSearchResult>,
#[serde(default)]
total_results: Option<u64>,
#[serde(rename = "costUsd", default)]
cost_usd: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct TinyFishSearchResult {
#[serde(default)]
position: Option<u64>,
#[serde(default)]
site_name: Option<String>,
#[serde(default)]
title: String,
#[serde(default)]
snippet: String,
#[serde(default)]
url: String,
}
#[derive(Debug, Deserialize)]
struct TinyFishFetchResponse {
#[serde(default)]
results: Vec<TinyFishFetchResult>,
#[serde(default)]
errors: Vec<TinyFishFetchError>,
#[serde(rename = "costUsd", default)]
cost_usd: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct TinyFishFetchResult {
#[serde(default)]
url: String,
#[serde(default)]
final_url: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
language: Option<String>,
#[serde(default)]
text: serde_json::Value,
#[serde(default)]
links: Option<Vec<String>>,
#[serde(default)]
image_links: Option<Vec<String>>,
#[serde(default)]
latency_ms: Option<f64>,
#[serde(default)]
format: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TinyFishFetchError {
#[serde(default)]
url: String,
#[serde(default)]
error: String,
#[serde(default)]
status: Option<u16>,
}
#[derive(Debug, Deserialize)]
struct TinyFishAgentRunResponse {
#[serde(default)]
run_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
result: Option<serde_json::Value>,
#[serde(default)]
error: Option<serde_json::Value>,
#[serde(default)]
num_of_steps: Option<u64>,
#[serde(rename = "costUsd", default)]
cost_usd: Option<f64>,
}
/// Search the web with TinyFish Search.
pub struct TinyFishSearchTool {
client: Arc<IntegrationClient>,
}
impl TinyFishSearchTool {
pub fn new(client: Arc<IntegrationClient>) -> 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<ToolResult> {
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::<TinyFishSearchResponse>("/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<IntegrationClient>,
}
impl TinyFishFetchTool {
pub fn new(client: Arc<IntegrationClient>) -> 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<ToolResult> {
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::<TinyFishFetchResponse>("/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<IntegrationClient>,
}
impl TinyFishAgentRunTool {
pub fn new(client: Arc<IntegrationClient>) -> 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<ToolResult> {
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::<TinyFishAgentRunResponse>("/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;
@@ -0,0 +1,180 @@
use super::*;
fn test_client() -> Arc<IntegrationClient> {
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));
}
+2
View File
@@ -24,6 +24,8 @@ pub struct PricingIntegrations {
pub google_places: Option<IntegrationPricingEntry>,
#[serde(default)]
pub parallel: Option<IntegrationPricingEntry>,
#[serde(default)]
pub tinyfish: Option<IntegrationPricingEntry>,
}
#[derive(Debug, Clone, Deserialize)]
+19 -5
View File
@@ -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)),
));
+76
View File
@@ -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;