mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Add TinyFish integration tools (#2125)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
b2346c2753
commit
f684d30e67
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)),
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user