diff --git a/Cargo.lock b/Cargo.lock index 800846e58..c09095c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,6 +2298,7 @@ dependencies = [ "futures", "glob", "htmd", + "httpmock", "insta", "jsonschema", "libc", diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index b6fa8d2b7..5ed65df8c 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -334,6 +334,22 @@ Tailscale Services and Tailscale Funnel are different ingress features. Services Incoming webhooks are authenticated only by GitHub's `X-Hub-Signature-256` HMAC signature, not by Fabro's bearer/session auth. +### `[server.integrations.search]` section + +Select the HTTP backend for the built-in [`web_search`](/agents/tools#web_search) tool. Brave remains the default when this table is absent. + +```toml title="settings.toml" +[server.integrations.search] +provider = "brave" # "brave" (default) | "venice" +venice_engine = "brave" # venice-only: "brave" | "google" +``` + +- `provider = "brave"`: direct Brave Search. Requires vault `BRAVE_SEARCH_API_KEY`. See [Brave Search](/integrations/brave-search). +- `provider = "venice"`: Venice `POST /api/v1/augment/search`. Requires vault `VENICE_API_KEY` (the same key as the Venice LLM provider). See [Venice Search](/integrations/venice-search). +- `venice_engine = "brave"` is Brave **through Venice** (Firecrawl ZDR, billed as Venice credits). Direct Brave remains `provider = "brave"`. + +The tool is registered only when the selected provider is configured. Failed calls do not fall back between backends. + ### `[run.checkpoint]` section Configure checkpoint behavior for all runs. @@ -360,7 +376,7 @@ Fabro splits server-runtime secrets into two scopes: - `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` when a manual config uses static S3 object-store credentials -`server.env` is not used for Slack, Daytona, Brave Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`. +`server.env` is not used for Slack, Daytona, Brave Search, Venice Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`. During startup, Fabro temporarily migrates recognized legacy optional integration secrets from process env or `server.env` into the vault. When a matching `server.env` entry can be safely removed, Fabro writes a hidden backup beside `server.env` first. Process env values cannot be cleaned up automatically, so remove those from your deployment environment after the vault contains the secret. @@ -402,12 +418,14 @@ These optional server integrations are vault-only: ```bash fabro secret set DAYTONA_API_KEY dtn_... fabro secret set BRAVE_SEARCH_API_KEY BSA... +fabro secret set VENICE_API_KEY venice-... ``` | Variable | Description | |---|---| | `DAYTONA_API_KEY` | Daytona cloud sandbox API key | -| `BRAVE_SEARCH_API_KEY` | Brave Search API key (for the `web_search` tool) | +| `BRAVE_SEARCH_API_KEY` | Brave Search API key (`web_search` when `provider = "brave"`) | +| `VENICE_API_KEY` | Venice API key (LLM provider, and `web_search` when `provider = "venice"`) | ### Server authentication diff --git a/docs/public/administration/troubleshooting.mdx b/docs/public/administration/troubleshooting.mdx index e6da40fa5..d3a59f056 100644 --- a/docs/public/administration/troubleshooting.mdx +++ b/docs/public/administration/troubleshooting.mdx @@ -17,7 +17,7 @@ It checks: - Local user config and storage directory health - Server-reported LLM provider connectivity, with configured providers probed concurrently -- GitHub App, sandbox, and Brave Search credentials, plus Docker daemon reachability when the Docker sandbox provider is enabled +- GitHub App, sandbox, and web search credentials (Brave or Venice), plus Docker daemon reachability when the Docker sandbox provider is enabled - Server authentication and crypto configuration LLM provider probe failures are reported as errors. Use `--verbose` to see the underlying provider error chain when a key, network route, or model endpoint fails. diff --git a/docs/public/agents/tools.mdx b/docs/public/agents/tools.mdx index 62d4e9f98..9b17b8957 100644 --- a/docs/public/agents/tools.mdx +++ b/docs/public/agents/tools.mdx @@ -20,7 +20,7 @@ These tools are registered for every provider profile: | `write_file` | write | Create or overwrite a file | | `grep` | read | Search file contents with regex patterns | | `glob` | read | Find files by name pattern | -| `web_search` | shell | Search the web via Brave Search | +| `web_search` | shell | Search the web via Brave or Venice | | `web_fetch` | shell | Fetch and optionally summarize a URL | ## Provider-specific tools @@ -123,14 +123,28 @@ Patterns are case-sensitive and relative to `path`: `*` and `?` stay within one ### web_search -Searches the web using the Brave Search API. +Searches the web using Brave Search (default) or Venice Search. | Parameter | Type | Required | Description | |---|---|---|---| -| `query` | string | yes | Search query | +| `query` | string | yes | Search query. Venice rejects queries longer than 400 characters before the HTTP call. | | `max_results` | integer | no | Maximum results (default: 5, max: 20) | +| `engine` | `"brave"` \| `"google"` | no | Venice backend only. Overrides `[server.integrations.search].venice_engine`. Brave ignores this parameter. | -Requires `BRAVE_SEARCH_API_KEY` to be configured for the current runtime. Runs read it from the server vault (`fabro secret set BRAVE_SEARCH_API_KEY `) — workers start from a cleared environment and this key is not inherited, so exporting it in the server's shell has no effect. The standalone agent CLI reads it from the invoking shell instead. Returns numbered results with title, URL, and description. +Select the backend in server settings. Brave remains the default when the table is absent: + +```toml +[server.integrations.search] +provider = "venice" # "brave" (default) | "venice" +venice_engine = "brave" # venice-only: "brave" (ZDR) | "google" (anon proxy) +``` + +Runs read the selected provider's key from the server vault — workers start from a cleared environment, so exporting the key in the server's shell has no effect. The standalone agent CLI reads keys from the invoking shell instead. + +- Brave (`provider = "brave"`): `fabro secret set BRAVE_SEARCH_API_KEY `. See [Brave Search](/integrations/brave-search). +- Venice (`provider = "venice"`): reuse `VENICE_API_KEY` (`fabro provider login --provider venice` or `fabro secret set VENICE_API_KEY `). See [Venice Search](/integrations/venice-search). + +The tool is registered only when the **selected** provider is configured. Fabro does not fall back Brave ↔ Venice on a failed call. Returns numbered results with title, URL, and description; Venice includes `date` on a fourth line when present. ### web_fetch diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 546b9d106..e9b019329 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -14231,12 +14231,14 @@ components: ServerIntegrationsSettings: type: object - required: [github, slack] + required: [github, slack, search] properties: github: $ref: "#/components/schemas/GithubIntegrationSettings" slack: $ref: "#/components/schemas/SlackIntegrationSettings" + search: + $ref: "#/components/schemas/SearchIntegrationSettings" GithubIntegrationSettings: type: object @@ -14276,6 +14278,23 @@ components: default_channel: type: ["string", "null"] + SearchIntegrationSettings: + type: object + required: [provider, venice_engine] + properties: + provider: + $ref: "#/components/schemas/SearchProvider" + venice_engine: + $ref: "#/components/schemas/VeniceSearchEngine" + + SearchProvider: + type: string + enum: [brave, venice] + + VeniceSearchEngine: + type: string + enum: [brave, google] + IntegrationWebhooksSettings: type: object required: [strategy] diff --git a/docs/public/changelog/2026-08-21.mdx b/docs/public/changelog/2026-08-21.mdx new file mode 100644 index 000000000..d264b5aad --- /dev/null +++ b/docs/public/changelog/2026-08-21.mdx @@ -0,0 +1,10 @@ +--- +title: "Venice search backend" +date: "2026-08-21" +--- + +## Venice search backend for `web_search` + +The built-in `web_search` tool now has a second HTTP backend. Brave Search remains the default. Set `[server.integrations.search].provider = "venice"` to send queries to Venice `POST /api/v1/augment/search`, reusing vault `VENICE_API_KEY`. Failed calls do not fall back between providers. + +See [Venice Search](/integrations/venice-search) and [Brave Search](/integrations/brave-search). diff --git a/docs/public/docs.json b/docs/public/docs.json index faf0d0f84..9faa178a8 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -102,7 +102,8 @@ "integrations/modal", "integrations/fireworks", "integrations/slack", - "integrations/brave-search" + "integrations/brave-search", + "integrations/venice-search" ] }, { @@ -293,6 +294,13 @@ "tab": "Changelog", "icon": "clock-rotate-left", "groups": [ + { + "group": "August 2026", + "icon": "clock-rotate-left", + "pages": [ + "changelog/2026-08-21" + ] + }, { "group": "July 2026", "icon": "clock-rotate-left", diff --git a/docs/public/integrations/brave-search.mdx b/docs/public/integrations/brave-search.mdx index 4ce0cfc9e..2c46f25dc 100644 --- a/docs/public/integrations/brave-search.mdx +++ b/docs/public/integrations/brave-search.mdx @@ -5,6 +5,8 @@ description: "Give Fabro agents web search capabilities via the Brave Search API Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. It uses the [Brave Search API](https://brave.com/search/api/) to return titles, URLs, and descriptions for any query. Setting the API key is the only configuration needed — the tool is then registered for all provider profiles (Anthropic, OpenAI, Gemini). Without a key the tool is not registered at all, so agents are never offered a search tool they cannot use. +Brave is the default `web_search` backend. To use Venice instead, see [Venice Search](/integrations/venice-search). + ## Setup 1. Get a Brave Search API key from the [Brave Search API dashboard](https://brave.com/search/api/) @@ -21,7 +23,7 @@ fabro secret set BRAVE_SEARCH_API_KEY BSA... fabro doctor ``` -The doctor output should show **Brave Search** as "connected". If the key is missing, web search is reported as a warning — workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt, so agents fall back to other tools. +The doctor output should show **Web Search** as `brave: configured and reachable`. If the key is missing, web search is reported as a warning — workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt, so agents fall back to other tools. The Fabro server reads this key from the vault only. It does not read `BRAVE_SEARCH_API_KEY` from process env or `server.env`. diff --git a/docs/public/integrations/venice-search.mdx b/docs/public/integrations/venice-search.mdx new file mode 100644 index 000000000..2e23cbad0 --- /dev/null +++ b/docs/public/integrations/venice-search.mdx @@ -0,0 +1,84 @@ +--- +title: "Venice Search" +description: "Give Fabro agents web search capabilities via Venice's augment/search API" +--- + +Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. The default backend is [Brave Search](/integrations/brave-search). Set `[server.integrations.search].provider = "venice"` to use [Venice Search](https://docs.venice.ai/api-reference/endpoint/augment/search) instead, reusing the same `VENICE_API_KEY` already used for the Venice LLM provider. + +Agents keep calling `web_search`. Only the HTTP backend changes. + +## Setup + +1. Store a Venice API key on the Fabro server (skip this if the Venice LLM provider is already logged in): + +```bash +fabro provider login --provider venice +# or +fabro secret set VENICE_API_KEY venice-... +``` + +2. Select Venice as the search backend: + +```toml title="settings.toml" +[server.integrations.search] +provider = "venice" +# venice_engine = "brave" # default: Firecrawl ZDR, billed as Venice credits +# venice_engine = "google" # anonymized proxy +``` + +`provider = "brave"` (the default) talks to Brave Search directly and still needs `BRAVE_SEARCH_API_KEY`. `venice_engine = "brave"` is Brave **through Venice**, not the direct Brave backend. + +3. Verify the key is working: + +```bash +fabro doctor +``` + +The doctor output should show **Web Search** as `venice: configured and reachable`. If `VENICE_API_KEY` is missing, web search is reported as a warning — workflows still run, but the `web_search` tool is omitted from the agent's tool set. + +The Fabro server reads this key from the vault only. It does not read `VENICE_API_KEY` from process env or `server.env`. + +## How it works + +Agents call the `web_search` tool with a query string. Fabro `POST`s to Venice `https://api.venice.ai/api/v1/augment/search` and returns numbered results with title, URL, description, and date when Venice includes one: + +``` +1. Rust Lang + https://rust-lang.org + A systems language + 2026-01-02 +``` + +Venice Search is billed by Venice at $0.01 per request and is rate-limited to 20 requests per minute on the Venice side. Queries longer than 400 characters are rejected before the HTTP call. + +If `VENICE_API_KEY` is not configured in the vault, the tool is not registered. Failed calls return an error; Fabro does not fall back to Brave. + +See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details. + +## Permissions + +`web_search` is classified as a `shell` category tool, requiring the `full` [permission level](/agents/permissions) for auto-approval. At lower permission levels: + +- **Interactive mode** — the user is prompted to approve each call +- **Non-interactive mode** (`--auto-approve`) — calls are denied + +## Troubleshooting + +**"VENICE_API_KEY is not configured"** — Add the key with `fabro secret set VENICE_API_KEY ` or `fabro provider login --provider venice`. Run `fabro doctor` to verify. + +**"Venice Search API returned status 401"** — The API key is invalid or expired. Create a new key at [venice.ai](https://venice.ai). + +**"Venice Search API returned status 402"** — The Venice account is out of credits. The error may include a remaining-balance hint. + +**"Venice Search API returned status 429"** — Rate limit exceeded (20 requests per minute on Venice Search). Reduce the frequency of `web_search` calls. + +## Further reading + + + + Full `web_search` tool reference — parameters, output format, and error handling. + + + Direct Brave Search backend (the default when `provider` is unset). + + diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index 9373cc81e..ac161a1b0 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -5,12 +5,14 @@ use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_auth::auth_issue_message; +use fabro_http::Response; use fabro_llm::client::Client as LlmClient; use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe_with_timeout}; use fabro_model::{Catalog, ProviderId}; use fabro_redact::redact_string; use fabro_sandbox::{DockerSandboxProvider, daytona}; use fabro_static::EnvVars; +use fabro_types::settings::SearchProvider; use fabro_types::settings::ServerAuthMethod; use fabro_types::settings::server::GithubIntegrationStrategy; use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus}; @@ -19,6 +21,7 @@ use fabro_util::session_secret; use fabro_util::version::FABRO_VERSION; use futures_util::future::join_all; use serde::Serialize; +use tokio::time::error::Elapsed; use tokio::time::timeout; use crate::server::AppState; @@ -41,21 +44,21 @@ fn http_client_or_check( #[derive(Debug, Serialize)] pub struct DiagnosticsReport { - pub version: String, + pub version: String, pub sections: Vec, } #[derive(Debug, Clone, Serialize)] pub(crate) struct ProviderProbeReport { - pub data: Vec, + pub data: Vec, pub summary: ProviderProbeSummary, } #[derive(Debug, Clone, Serialize)] pub(crate) struct ProviderProbeResult { - pub provider: ProviderId, - pub model_id: Option, - pub status: ProviderProbeStatus, + pub provider: ProviderId, + pub model_id: Option, + pub status: ProviderProbeStatus, pub error_message: Option, #[serde(skip)] diagnostic_detail: Option, @@ -64,7 +67,7 @@ pub(crate) struct ProviderProbeResult { #[derive(Debug, Clone, Serialize)] pub(crate) struct ProviderProbeSummary { pub status: ProviderProbeStatus, - pub total: u32, + pub total: u32, pub passed: u32, pub failed: u32, } @@ -92,24 +95,24 @@ fn validate_session_secret(value: &str) -> Result<(), String> { } pub async fn run_all(state: &AppState) -> DiagnosticsReport { - let (llm, github, docker_sandbox, cloud_sandbox, brave, crypto) = tokio::join!( + let (llm, github, docker_sandbox, cloud_sandbox, web_search, crypto) = tokio::join!( check_llm_providers(state), check_github_app(state), check_docker_sandbox(state), check_cloud_sandbox(state), - check_brave_search(state), + check_web_search(state), check_crypto(state), ); DiagnosticsReport { - version: FABRO_VERSION.to_string(), + version: FABRO_VERSION.to_string(), sections: vec![ CheckSection { - title: "Credentials".to_string(), - checks: vec![llm, github, docker_sandbox, cloud_sandbox, brave], + title: "Credentials".to_string(), + checks: vec![llm, github, docker_sandbox, cloud_sandbox, web_search], }, CheckSection { - title: "Configuration".to_string(), + title: "Configuration".to_string(), checks: vec![crypto, check_storage_dir(state)], }, ], @@ -121,20 +124,20 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { Ok(report) => report, Err(err) => { return CheckResult { - name: "LLM Providers".to_string(), - status: CheckStatus::Error, - summary: "failed to initialize".to_string(), - details: vec![CheckDetail::new(format!("{err:#}"))], + name: "LLM Providers".to_string(), + status: CheckStatus::Error, + summary: "failed to initialize".to_string(), + details: vec![CheckDetail::new(format!("{err:#}"))], remediation: Some("Check configured provider credentials".to_string()), }; } }; if report.data.is_empty() { return CheckResult { - name: "LLM Providers".to_string(), - status: CheckStatus::Error, - summary: "none configured".to_string(), - details: Vec::new(), + name: "LLM Providers".to_string(), + status: CheckStatus::Error, + summary: "none configured".to_string(), + details: Vec::new(), remediation: Some("Set at least one provider API key".to_string()), }; } @@ -156,7 +159,7 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { .clone() .unwrap_or_else(|| format!("{}: {message}", result.provider)); failures.push(ProviderFailure { - provider: result.provider.to_string(), + provider: result.provider.to_string(), summary_line: short_error_line(message), }); details.push(CheckDetail::new(detail)); @@ -195,7 +198,7 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { } struct ProviderFailure { - provider: String, + provider: String, summary_line: String, } @@ -352,10 +355,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { Ok(token) => token.to_string(), Err(err) => { return CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Error, - summary: "token expired".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: "GitHub Token".to_string(), + status: CheckStatus::Error, + summary: "token expired".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some( "Run fabro install or run `fabro secret set GITHUB_TOKEN`" .to_string(), @@ -367,10 +370,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { Ok(Some(_)) => unreachable!("token strategy should not return app credentials"), Ok(None) => { return CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Warning, - summary: "not configured".to_string(), - details: Vec::new(), + name: "GitHub Token".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: Vec::new(), remediation: Some( "Run fabro install or run `fabro secret set GITHUB_TOKEN`".to_string(), ), @@ -379,10 +382,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { Err(err) => { let rendered = format!("{err:#}"); return CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Error, - summary: "missing token".to_string(), - details: vec![CheckDetail::new(rendered.clone())], + name: "GitHub Token".to_string(), + status: CheckStatus::Error, + summary: "missing token".to_string(), + details: vec![CheckDetail::new(rendered.clone())], remediation: Some(rendered), }; } @@ -404,18 +407,18 @@ async fn check_github_app(state: &AppState) -> CheckResult { return match probe { Ok(Ok(response)) if response.status().is_success() => CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Pass, - summary: "configured".to_string(), - details: Vec::new(), + name: "GitHub Token".to_string(), + status: CheckStatus::Pass, + summary: "configured".to_string(), + details: Vec::new(), remediation: None, }, Ok(Ok(response)) if response.status() == fabro_http::StatusCode::UNAUTHORIZED => { CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Error, - summary: "token invalid".to_string(), - details: vec![CheckDetail::new(format!( + name: "GitHub Token".to_string(), + status: CheckStatus::Error, + summary: "token invalid".to_string(), + details: vec![CheckDetail::new(format!( "GitHub returned {}", response.status() ))], @@ -425,10 +428,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { } } Ok(Ok(response)) => CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Error, - summary: "connectivity error".to_string(), - details: vec![CheckDetail::new(format!( + name: "GitHub Token".to_string(), + status: CheckStatus::Error, + summary: "connectivity error".to_string(), + details: vec![CheckDetail::new(format!( "GitHub returned {}", response.status() ))], @@ -437,19 +440,19 @@ async fn check_github_app(state: &AppState) -> CheckResult { ), }, Ok(Err(err)) => CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Error, - summary: "connectivity error".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: "GitHub Token".to_string(), + status: CheckStatus::Error, + summary: "connectivity error".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some( "Check GitHub connectivity and the vault GITHUB_TOKEN".to_string(), ), }, Err(_) => CheckResult { - name: "GitHub Token".to_string(), - status: CheckStatus::Error, - summary: "timeout".to_string(), - details: vec![CheckDetail::new("GitHub probe timed out".to_string())], + name: "GitHub Token".to_string(), + status: CheckStatus::Error, + summary: "timeout".to_string(), + details: vec![CheckDetail::new("GitHub probe timed out".to_string())], remediation: Some( "Check GitHub connectivity and the vault GITHUB_TOKEN".to_string(), ), @@ -483,20 +486,20 @@ async fn check_github_app(state: &AppState) -> CheckResult { && !webhook_secret { return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Warning, - summary: "not configured".to_string(), - details: Vec::new(), + name: "GitHub App".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: Vec::new(), remediation: Some("Configure GitHub App settings and secrets".to_string()), }; } let Some(app_id) = app_id else { return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "missing app_id".to_string(), - details: Vec::new(), + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "missing app_id".to_string(), + details: Vec::new(), remediation: Some( "Set [server.integrations.github].app_id in settings.toml".to_string(), ), @@ -504,10 +507,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { }; let Some(private_key_raw) = private_key_raw else { return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "missing private key".to_string(), - details: Vec::new(), + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "missing private key".to_string(), + details: Vec::new(), remediation: Some("Run `fabro secret set GITHUB_APP_PRIVATE_KEY`".to_string()), }; }; @@ -516,10 +519,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { Ok(value) => value, Err(err) => { return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "private key invalid".to_string(), - details: vec![CheckDetail::new(err.clone())], + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "private key invalid".to_string(), + details: vec![CheckDetail::new(err.clone())], remediation: Some(err), }; } @@ -529,10 +532,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { Ok(jwt) => jwt, Err(err) => { return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "JWT signing failed".to_string(), - details: vec![CheckDetail::new(format!("{err:#}"))], + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "JWT signing failed".to_string(), + details: vec![CheckDetail::new(format!("{err:#}"))], remediation: Some(err.to_string()), }; } @@ -549,24 +552,24 @@ async fn check_github_app(state: &AppState) -> CheckResult { .await; match auth_result { Ok(Ok(_app)) => CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Pass, - summary: slug.unwrap_or_else(|| "configured".to_string()), - details: Vec::new(), + name: "GitHub App".to_string(), + status: CheckStatus::Pass, + summary: slug.unwrap_or_else(|| "configured".to_string()), + details: Vec::new(), remediation: None, }, Ok(Err(err)) => CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "connectivity error".to_string(), - details: vec![CheckDetail::new(format!("{err:#}"))], + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "connectivity error".to_string(), + details: vec![CheckDetail::new(format!("{err:#}"))], remediation: Some("Check GitHub App credentials and network connectivity".to_string()), }, Err(_) => CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "timeout".to_string(), - details: vec![CheckDetail::new("GitHub probe timed out".to_string())], + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "timeout".to_string(), + details: vec![CheckDetail::new("GitHub probe timed out".to_string())], remediation: Some("Check GitHub connectivity and credentials".to_string()), }, } @@ -602,10 +605,10 @@ where { if !enabled { return CheckResult { - name: "Docker Sandbox".to_string(), - status: CheckStatus::Pass, - summary: "disabled".to_string(), - details: vec![CheckDetail::new( + name: "Docker Sandbox".to_string(), + status: CheckStatus::Pass, + summary: "disabled".to_string(), + details: vec![CheckDetail::new( "server.sandbox.providers.docker.enabled = false".to_string(), )], remediation: None, @@ -650,10 +653,10 @@ async fn check_cloud_sandbox(state: &AppState) -> CheckResult { }; let Some(api_key) = api_key else { return CheckResult { - name: "Cloud Sandbox".to_string(), - status: CheckStatus::Warning, - summary: "recommended, not configured".to_string(), - details: Vec::new(), + name: "Cloud Sandbox".to_string(), + status: CheckStatus::Warning, + summary: "recommended, not configured".to_string(), + details: Vec::new(), remediation: Some( "Run `fabro secret set DAYTONA_API_KEY` to enable cloud sandbox execution" .to_string(), @@ -670,17 +673,17 @@ async fn check_cloud_sandbox(state: &AppState) -> CheckResult { fn cloud_sandbox_probe_check(probe: anyhow::Result) -> CheckResult { match probe { Ok(check) if check.ok() => CheckResult { - name: "Cloud Sandbox".to_string(), - status: CheckStatus::Pass, - summary: format!("Daytona configured ({})", check.key_name), - details: Vec::new(), + name: "Cloud Sandbox".to_string(), + status: CheckStatus::Pass, + summary: format!("Daytona configured ({})", check.key_name), + details: Vec::new(), remediation: None, }, Ok(check) => CheckResult { - name: "Cloud Sandbox".to_string(), - status: CheckStatus::Error, - summary: "Daytona API key is missing required scopes".to_string(), - details: vec![CheckDetail::new(format!( + name: "Cloud Sandbox".to_string(), + status: CheckStatus::Error, + summary: "Daytona API key is missing required scopes".to_string(), + details: vec![CheckDetail::new(format!( "missing: {}", check.missing_display() ))], @@ -693,10 +696,10 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result) -> Err(err) => { if let Some(timeout) = err.downcast_ref::() { return CheckResult { - name: "Cloud Sandbox".to_string(), - status: CheckStatus::Error, - summary: format!("timeout ({:?})", timeout.timeout()), - details: vec![CheckDetail::new("Daytona probe timed out".to_string())], + name: "Cloud Sandbox".to_string(), + status: CheckStatus::Error, + summary: format!("timeout ({:?})", timeout.timeout()), + details: vec![CheckDetail::new("Daytona probe timed out".to_string())], remediation: Some( "Verify DAYTONA_API_KEY value and Daytona reachability".to_string(), ), @@ -704,10 +707,10 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result) -> } CheckResult { - name: "Cloud Sandbox".to_string(), - status: CheckStatus::Error, - summary: "Daytona credential rejected".to_string(), - details: vec![CheckDetail::new(format!("{err:#}"))], + name: "Cloud Sandbox".to_string(), + status: CheckStatus::Error, + summary: "Daytona credential rejected".to_string(), + details: vec![CheckDetail::new(format!("{err:#}"))], remediation: Some( "Verify DAYTONA_API_KEY value and Daytona reachability".to_string(), ), @@ -755,25 +758,40 @@ fn check_storage_dir_path(path: &std::path::Path) -> CheckResult { } } +async fn check_web_search(state: &AppState) -> CheckResult { + let search = state.server_settings().server.integrations.search; + match search.provider { + SearchProvider::Brave => check_brave_search(state).await, + SearchProvider::Venice => check_venice_search(state).await, + } +} + +const WEB_SEARCH_CHECK_NAME: &str = "Web Search"; + async fn check_brave_search(state: &AppState) -> CheckResult { - let api_key = - match diagnostic_secret(state, "Web Search (Brave)", EnvVars::BRAVE_SEARCH_API_KEY).await { - Ok(value) => value, - Err(result) => return result, - }; + let api_key = match diagnostic_secret( + state, + WEB_SEARCH_CHECK_NAME, + EnvVars::BRAVE_SEARCH_API_KEY, + ) + .await + { + Ok(value) => value, + Err(result) => return result, + }; let Some(api_key) = api_key else { return CheckResult { - name: "Web Search (Brave)".to_string(), - status: CheckStatus::Warning, - summary: "optional, not configured".to_string(), - details: Vec::new(), + name: WEB_SEARCH_CHECK_NAME.to_string(), + status: CheckStatus::Warning, + summary: "brave: optional, not configured".to_string(), + details: Vec::new(), remediation: Some( "Run `fabro secret set BRAVE_SEARCH_API_KEY` to enable web search".to_string(), ), }; }; - let http = match http_client_or_check("Web Search (Brave)", CheckStatus::Warning) { + let http = match http_client_or_check(WEB_SEARCH_CHECK_NAME, CheckStatus::Warning) { Ok(http) => http, Err(result) => return result, }; @@ -787,36 +805,80 @@ async fn check_brave_search(state: &AppState) -> CheckResult { }) .await; + match_web_search_probe(probe, "brave", "BRAVE_SEARCH_API_KEY") +} + +async fn check_venice_search(state: &AppState) -> CheckResult { + let api_key = + match diagnostic_secret(state, WEB_SEARCH_CHECK_NAME, EnvVars::VENICE_API_KEY).await { + Ok(value) => value, + Err(result) => return result, + }; + let Some(api_key) = api_key else { + return CheckResult { + name: WEB_SEARCH_CHECK_NAME.to_string(), + status: CheckStatus::Warning, + summary: "venice: optional, not configured".to_string(), + details: Vec::new(), + remediation: Some( + "Run `fabro secret set VENICE_API_KEY` to enable web search".to_string(), + ), + }; + }; + + let http = match http_client_or_check(WEB_SEARCH_CHECK_NAME, CheckStatus::Warning) { + Ok(http) => http, + Err(result) => return result, + }; + + let probe = timeout(EXTERNAL_SERVICE_PROBE_TIMEOUT, async move { + http.post("https://api.venice.ai/api/v1/augment/search") + .bearer_auth(api_key) + .json(&serde_json::json!({ "query": "test", "limit": 1 })) + .send() + .await + .map_err(anyhow::Error::new) + }) + .await; + + match_web_search_probe(probe, "venice", "VENICE_API_KEY") +} + +fn match_web_search_probe( + probe: Result, Elapsed>, + provider: &str, + secret_name: &str, +) -> CheckResult { match probe { Ok(Ok(response)) if response.status().is_success() => CheckResult { - name: "Web Search (Brave)".to_string(), - status: CheckStatus::Pass, - summary: "configured and reachable".to_string(), - details: Vec::new(), + name: WEB_SEARCH_CHECK_NAME.to_string(), + status: CheckStatus::Pass, + summary: format!("{provider}: configured and reachable"), + details: Vec::new(), remediation: None, }, Ok(Ok(response)) => CheckResult { - name: "Web Search (Brave)".to_string(), - status: CheckStatus::Warning, - summary: format!("HTTP {}", response.status()), - details: Vec::new(), - remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()), + name: WEB_SEARCH_CHECK_NAME.to_string(), + status: CheckStatus::Warning, + summary: format!("{provider}: HTTP {}", response.status()), + details: Vec::new(), + remediation: Some(format!("Check {secret_name} and network connectivity")), }, Ok(Err(err)) => CheckResult { - name: "Web Search (Brave)".to_string(), - status: CheckStatus::Warning, - summary: "connectivity error".to_string(), - details: vec![CheckDetail::new(format!("{err:#}"))], - remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()), + name: WEB_SEARCH_CHECK_NAME.to_string(), + status: CheckStatus::Warning, + summary: format!("{provider}: connectivity error"), + details: vec![CheckDetail::new(format!("{err:#}"))], + remediation: Some(format!("Check {secret_name} and network connectivity")), }, Err(_) => CheckResult { - name: "Web Search (Brave)".to_string(), - status: CheckStatus::Warning, - summary: "timeout".to_string(), - details: vec![CheckDetail::new( - "Web Search (Brave) probe timed out".to_string(), - )], - remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()), + name: WEB_SEARCH_CHECK_NAME.to_string(), + status: CheckStatus::Warning, + summary: format!("{provider}: timeout"), + details: vec![CheckDetail::new(format!( + "Web Search ({provider}) probe timed out" + ))], + remediation: Some(format!("Check {secret_name} and network connectivity")), }, } } @@ -894,10 +956,10 @@ async fn diagnostic_secret( name: &str, ) -> Result, CheckResult> { state.vault_secret(name).await.map_err(|err| CheckResult { - name: check_name.to_string(), - status: CheckStatus::Error, - summary: "secret store unavailable".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: check_name.to_string(), + status: CheckStatus::Error, + summary: "secret store unavailable".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some("Check the Fabro database and retry".to_string()), }) } @@ -1195,23 +1257,56 @@ enabled = false } #[tokio::test] - async fn check_brave_search_ignores_env_backed_api_key() { + async fn check_web_search_ignores_env_backed_api_key() { let state = TestAppStateBuilder::new() .env_lookup(|name| { (name == EnvVars::BRAVE_SEARCH_API_KEY).then(|| "brave-from-env".to_string()) }) .build(); - let result = check_brave_search(&state).await; + let result = check_web_search(&state).await; + assert_eq!(result.name, "Web Search"); assert_eq!(result.status, CheckStatus::Warning); - assert_eq!(result.summary, "optional, not configured"); + assert_eq!(result.summary, "brave: optional, not configured"); assert_eq!( result.remediation.as_deref(), Some("Run `fabro secret set BRAVE_SEARCH_API_KEY` to enable web search") ); } + #[tokio::test] + async fn check_web_search_venice_ignores_env_backed_api_key() { + let settings = fabro_config::ServerSettingsBuilder::from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.integrations.search] +provider = "venice" +"#, + ) + .expect("venice search settings should parse"); + let state = TestAppStateBuilder::new() + .runtime_settings(settings, RunLayer::default()) + .env_lookup(|name| { + (name == EnvVars::VENICE_API_KEY).then(|| "venice-from-env".to_string()) + }) + .build(); + + let result = check_web_search(&state).await; + + assert_eq!(result.name, "Web Search"); + assert_eq!(result.status, CheckStatus::Warning); + assert_eq!(result.summary, "venice: optional, not configured"); + assert_eq!( + result.remediation.as_deref(), + Some("Run `fabro secret set VENICE_API_KEY` to enable web search") + ); + } + #[tokio::test] async fn check_crypto_requires_github_client_secret_from_vault() { let settings = fabro_config::ServerSettingsBuilder::from_toml( diff --git a/lib/components/fabro-agent/Cargo.toml b/lib/components/fabro-agent/Cargo.toml index 2e9a299b6..91f2e0e98 100644 --- a/lib/components/fabro-agent/Cargo.toml +++ b/lib/components/fabro-agent/Cargo.toml @@ -67,5 +67,6 @@ paste = "1" shlex = "1" fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } fabro-macros = { path = "../../foundation/fabro-macros" } +httpmock = "0.8" fabro-test = { workspace = true } tracing-subscriber.workspace = true diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index 720b8b0af..e16d8fa2f 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -34,16 +34,18 @@ use crate::tool_permissions::{is_auto_approved, tool_category}; use crate::tools::WebFetchSummarizer; use crate::{ AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, Message, Sandbox, Session, - SessionOptions, SessionShutdownReason, + SessionOptions, SessionShutdownReason, search_settings_from_disk, }; #[expect( clippy::disallowed_methods, - reason = "Standalone agent CLI explicitly passes the Brave Search process-env credential into tool configuration." + reason = "Standalone agent CLI explicitly passes search process-env credentials into tool configuration." )] fn cli_tool_secrets() -> ToolSecrets { ToolSecrets { brave_search_api_key: std::env::var(EnvVars::BRAVE_SEARCH_API_KEY).ok(), + venice_api_key: std::env::var(EnvVars::VENICE_API_KEY).ok(), + search: search_settings_from_disk(), } } diff --git a/lib/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index a6c8b5c39..44dce5af0 100644 --- a/lib/components/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -6,6 +6,7 @@ use fabro_llm::types::{ReasoningEffort, Speed}; use fabro_mcp::config::McpServerSettings; use fabro_model::AgentProfileKind; use fabro_types::PermissionLevel; +use fabro_types::settings::SearchIntegrationSettings; /// Callback invoked before each tool execution. Return `Ok(())` to allow, /// `Err(message)` to deny with the given message. @@ -103,6 +104,8 @@ impl ToolHookCallback for ToolApprovalAdapter { #[derive(Clone, Default, PartialEq, Eq)] pub struct ToolSecrets { pub brave_search_api_key: Option, + pub venice_api_key: Option, + pub search: SearchIntegrationSettings, } impl std::fmt::Debug for ToolSecrets { @@ -112,6 +115,8 @@ impl std::fmt::Debug for ToolSecrets { "brave_search_configured", &self.brave_search_api_key.is_some(), ) + .field("venice_search_configured", &self.venice_api_key.is_some()) + .field("search_provider", &self.search.provider.as_str()) .finish() } } @@ -120,8 +125,8 @@ impl std::fmt::Debug for ToolSecrets { #[derive(Clone, Debug, PartialEq, Eq)] pub struct NativeToolOptions { pub default_command_timeout_ms: u64, - pub max_command_timeout_ms: u64, - pub secrets: ToolSecrets, + pub max_command_timeout_ms: u64, + pub secrets: ToolSecrets, } impl NativeToolOptions { @@ -152,8 +157,8 @@ impl Default for NativeToolOptions { fn default() -> Self { Self { default_command_timeout_ms: 10_000, - max_command_timeout_ms: 600_000, - secrets: ToolSecrets::default(), + max_command_timeout_ms: 600_000, + secrets: ToolSecrets::default(), } } } @@ -350,12 +355,17 @@ mod tests { fn tool_secrets_debug_redacts_values() { let secrets = ToolSecrets { brave_search_api_key: Some("brave-secret-value".to_string()), + venice_api_key: Some("venice-secret-value".to_string()), + ..ToolSecrets::default() }; let debug = format!("{secrets:?}"); assert!(debug.contains("brave_search_configured: true")); + assert!(debug.contains("venice_search_configured: true")); + assert!(debug.contains("search_provider: \"brave\"")); assert!(!debug.contains("brave-secret-value")); + assert!(!debug.contains("venice-secret-value")); } #[test] @@ -437,9 +447,12 @@ mod tests { let approval: ToolApprovalFn = Arc::new(|_name, _args| Err("denied".to_string())); let adapter = ToolApprovalAdapter(approval); let decision = adapter.pre_tool_use("shell", &serde_json::json!({})).await; - assert_eq!(decision, ToolHookDecision::Block { - reason: "denied".to_string(), - }); + assert_eq!( + decision, + ToolHookDecision::Block { + reason: "denied".to_string(), + } + ); } #[tokio::test] diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index e9549a716..f7600a7f6 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -31,6 +31,7 @@ pub mod tool_registry; pub mod tools; pub mod truncation; pub mod types; +pub(crate) mod web_search; pub use agent_profile::AgentProfile; pub use config::{ @@ -85,6 +86,7 @@ pub use types::{ AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState, SkillActivationSource, SkillSummary, }; +pub use web_search::search_settings_from_disk; #[cfg(test)] #[allow( diff --git a/lib/components/fabro-agent/src/profiles/claude5.rs b/lib/components/fabro-agent/src/profiles/claude5.rs index ffa4b6ea1..97d35a187 100644 --- a/lib/components/fabro-agent/src/profiles/claude5.rs +++ b/lib/components/fabro-agent/src/profiles/claude5.rs @@ -18,6 +18,7 @@ use crate::todo_tools::{ make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, }; use crate::tool_registry::ToolRegistry; +use crate::web_search::SearchBackend; const CORE_PROMPT: &str = include_str!("prompts/claude5.md.j2"); @@ -43,8 +44,8 @@ impl Claude5Profile { registry.register(claude5_tools::make_edit_tool()); registry.register(claude5_tools::make_bash_tool(options)); registry.register(claude5_tools::make_web_fetch_tool(summarizer)); - if let Some(api_key) = &options.secrets.brave_search_api_key { - registry.register(claude5_tools::make_web_search_tool(api_key.clone())); + if let Some(backend) = SearchBackend::from_secrets(&options.secrets) { + registry.register(claude5_tools::make_web_search_tool(backend)); } registry.register(claude5_tools::strict_object_tool(make_task_create_tool( @@ -173,17 +174,20 @@ mod tests { let profile = Claude5Profile::new("claude-sonnet-5"); let mut names = profile.tool_registry().names(); names.sort(); - assert_eq!(names, vec![ - "Bash", - "Edit", - "Read", - "TaskCreate", - "TaskGet", - "TaskList", - "TaskUpdate", - "WebFetch", - "Write", - ]); + assert_eq!( + names, + vec![ + "Bash", + "Edit", + "Read", + "TaskCreate", + "TaskGet", + "TaskList", + "TaskUpdate", + "WebFetch", + "Write", + ] + ); assert!(!names.iter().any(|name| name == "Grep" || name == "Glob")); } diff --git a/lib/components/fabro-agent/src/profiles/claude5_tools.rs b/lib/components/fabro-agent/src/profiles/claude5_tools.rs index d9dd53aa6..9366194cd 100644 --- a/lib/components/fabro-agent/src/profiles/claude5_tools.rs +++ b/lib/components/fabro-agent/src/profiles/claude5_tools.rs @@ -19,6 +19,7 @@ use crate::session::Session; use crate::subagent::{SessionFactory, SubAgentResult, SubAgentStatus, SubAgentSupervisor}; use crate::tool_registry::{RegisteredTool, ToolContext, ToolSource}; use crate::tools::{self, WebFetchSummarizer}; +use crate::web_search::{self, SearchBackend}; fn definition( tool: NativeTool, @@ -98,7 +99,7 @@ pub(crate) fn make_bash_tool(options: &NativeToolOptions) -> RegisteredTool { "additionalProperties": false }), ), - executor: Arc::new(move |args, ctx| { + executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = tools::required_str(&args, "command")?; let timeout_ms = args @@ -109,25 +110,34 @@ pub(crate) fn make_bash_tool(options: &NativeToolOptions) -> RegisteredTool { tools::run_shell_command(&ctx, command, timeout_ms, None).await }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } #[must_use] -pub(crate) fn make_web_search_tool(api_key: String) -> RegisteredTool { - let mut tool = tools::make_web_search_tool_with_api_key(api_key); +pub(crate) fn make_web_search_tool(backend: SearchBackend) -> RegisteredTool { + let includes_engine = backend.includes_engine_param(); + let mut tool = web_search::make_web_search_tool(backend); + let mut properties = serde_json::json!({ + "query": { + "type": "string", + "description": "The web search query." + } + }); + if includes_engine { + properties["engine"] = serde_json::json!({ + "type": "string", + "enum": ["brave", "google"], + "description": "Venice search engine. brave is ZDR (default); google is an anonymized proxy." + }); + } tool.definition = definition( NativeTool::WebSearch, "Search the web when current external information is needed. Returns result titles, URLs, \ and descriptions; use WebFetch to inspect a specific URL.", serde_json::json!({ "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The web search query." - } - }, + "properties": properties, "required": ["query"], "additionalProperties": false }), @@ -211,7 +221,7 @@ pub(crate) fn make_agent_tool( "additionalProperties": false }), ), - executor: Arc::new(move |args, ctx| { + executor: Arc::new(move |args, ctx| { let supervisor = supervisor.clone(); let session_factory = session_factory.clone(); Box::pin(async move { @@ -249,7 +259,7 @@ pub(crate) fn make_agent_tool( } }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -320,7 +330,7 @@ pub(crate) fn make_task_output_tool(supervisor: SubAgentSupervisor) -> Registere "additionalProperties": false }), ), - executor: Arc::new(move |args, ctx| { + executor: Arc::new(move |args, ctx| { let supervisor = supervisor.clone(); Box::pin(async move { let task_id = tools::required_str(&args, "task_id")?; @@ -374,7 +384,7 @@ pub(crate) fn make_task_output_tool(supervisor: SubAgentSupervisor) -> Registere } }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -396,7 +406,7 @@ pub(crate) fn make_task_stop_tool(supervisor: SubAgentSupervisor) -> RegisteredT "additionalProperties": false }), ), - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let supervisor = supervisor.clone(); Box::pin(async move { let task_id = tools::required_str(&args, "task_id")?; @@ -407,7 +417,7 @@ pub(crate) fn make_task_stop_tool(supervisor: SubAgentSupervisor) -> RegisteredT Ok(format!("Agent {task_id} stopped.")) }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -438,7 +448,7 @@ pub(crate) fn make_send_message_tool(supervisor: SubAgentSupervisor) -> Register "additionalProperties": false }), ), - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let supervisor = supervisor.clone(); Box::pin(async move { let recipient = tools::required_str(&args, "to")?; @@ -449,7 +459,7 @@ pub(crate) fn make_send_message_tool(supervisor: SubAgentSupervisor) -> Register Ok(format!("Message sent to agent {recipient}.")) }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -468,6 +478,7 @@ mod tests { use crate::todo_tools::{ make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, }; + use fabro_types::settings::VeniceSearchEngine; fn property_names(tool: &RegisteredTool) -> BTreeSet<&str> { tool.definition.parameters["properties"] @@ -502,12 +513,12 @@ mod tests { fn context() -> ToolContext { ToolContext { - env: Arc::new(MockSandbox::default()) as Arc, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: Some("root".to_string()), - root_session_id: Some("root".to_string()), - tool_call_id: Some("call".to_string()), + env: Arc::new(MockSandbox::default()) as Arc, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: Some("root".to_string()), + root_session_id: Some("root".to_string()), + tool_call_id: Some("call".to_string()), agent_event_emitter: None, } } @@ -515,13 +526,16 @@ mod tests { #[test] fn core_adapter_schemas_match_the_claude5_contract() { let options = NativeToolOptions::for_profile(fabro_model::AgentProfileKind::Claude5); - assert_schema(&make_read_tool(), &["file_path", "limit", "offset"], &[ - "file_path", - ]); - assert_schema(&make_write_tool(), &["content", "file_path"], &[ - "content", - "file_path", - ]); + assert_schema( + &make_read_tool(), + &["file_path", "limit", "offset"], + &["file_path"], + ); + assert_schema( + &make_write_tool(), + &["content", "file_path"], + &["content", "file_path"], + ); assert_schema( &make_edit_tool(), &["file_path", "new_string", "old_string", "replace_all"], @@ -533,12 +547,24 @@ mod tests { bash.definition.parameters["properties"]["timeout"]["maximum"], 600_000 ); - assert_schema(&make_web_fetch_tool(None), &["prompt", "url"], &[ - "prompt", "url", - ]); - assert_schema(&make_web_search_tool("key".to_string()), &["query"], &[ - "query", - ]); + assert_schema( + &make_web_fetch_tool(None), + &["prompt", "url"], + &["prompt", "url"], + ); + assert_schema( + &make_web_search_tool(SearchBackend::brave("key".to_string())), + &["query"], + &["query"], + ); + assert_schema( + &make_web_search_tool(SearchBackend::venice( + "key".to_string(), + VeniceSearchEngine::Brave, + )), + &["engine", "query"], + &["query"], + ); let todo_runtime = Arc::new(TodoRuntime::new()); assert_schema( @@ -587,13 +613,17 @@ mod tests { &["block", "task_id", "timeout"], &["block", "task_id", "timeout"], ); - assert_schema(&make_task_stop_tool(supervisor.clone()), &["task_id"], &[ - "task_id", - ]); + assert_schema( + &make_task_stop_tool(supervisor.clone()), + &["task_id"], + &["task_id"], + ); let send_message = make_send_message_tool(supervisor); - assert_schema(&send_message, &["message", "summary", "to"], &[ - "message", "to", - ]); + assert_schema( + &send_message, + &["message", "summary", "to"], + &["message", "to"], + ); assert!( send_message .definition diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index dbc0d0c54..2ecc237bb 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -746,6 +746,7 @@ mod tests { ) .with_tool_secrets(ToolSecrets { brave_search_api_key: Some("configured-key".to_string()), + ..ToolSecrets::default() }); // Built twice: one configured builder must outfit both a root // session and the child sessions it spawns. diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index aad8e4e3d..b6515a4a5 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -12,6 +12,7 @@ use tokio::task; use crate::config::NativeToolOptions; use crate::sandbox::{ExecStreamingResult, GrepOptions}; +use crate::web_search::{SearchBackend, make_web_search_tool}; use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource}; use crate::types::AgentEvent; @@ -22,7 +23,7 @@ pub(crate) const DEFAULT_READ_LINES: usize = 2000; /// Configuration for the optional LLM-based summarizer used by `web_fetch`. #[derive(Clone)] pub struct WebFetchSummarizer { - pub client: Client, + pub client: Client, pub model_id: ModelHandle, } @@ -82,13 +83,13 @@ pub(crate) fn register_discovery_and_web_tools( registry.register(make_web_fetch_tool(summarizer)); } -/// Register `web_search` when a Brave Search key is configured. +/// Register `web_search` when the selected search provider is configured. /// /// Separate from [`register_discovery_and_web_tools`] for profiles that offer /// search without fabro's discovery tools. pub(crate) fn register_web_search_tool(registry: &mut ToolRegistry, options: &NativeToolOptions) { - if let Some(api_key) = &options.secrets.brave_search_api_key { - registry.register(make_web_search_tool_with_api_key(api_key.clone())); + if let Some(backend) = SearchBackend::from_secrets(&options.secrets) { + registry.register(make_web_search_tool(backend)); } } @@ -511,9 +512,9 @@ pub fn make_glob_tool() -> RegisteredTool { pub(crate) fn make_read_many_files_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "read_many_files".into(), + name: "read_many_files".into(), description: "Read multiple files at once".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "paths": { @@ -525,7 +526,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { "required": ["paths"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let paths: Vec = args["paths"] .as_array() @@ -564,7 +565,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { Ok(output) }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -572,9 +573,9 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { pub(crate) fn make_list_dir_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "list_dir".into(), + name: "list_dir".into(), description: "List directory contents with depth control".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "path": {"type": "string", "description": "Directory path to list"}, @@ -583,7 +584,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { "required": ["path"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let path = required_str(&args, "path")?; let depth = optional_usize_arg(&args, "depth")?; @@ -606,103 +607,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { Ok(lines.join("\n")) }) }), - source: ToolSource::Native, - } -} - -fn format_brave_results(body: &serde_json::Value) -> String { - let results = body - .get("web") - .and_then(|w| w.get("results")) - .and_then(serde_json::Value::as_array); - - let Some(results) = results else { - return "No results found.".to_string(); - }; - - let mut output = String::new(); - for (i, result) in results.iter().enumerate() { - let title = result - .get("title") - .and_then(serde_json::Value::as_str) - .unwrap_or("(no title)"); - let url = result - .get("url") - .and_then(serde_json::Value::as_str) - .unwrap_or("(no url)"); - let description = result - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let _ = write!( - output, - "{}. {}\n {}\n {}\n\n", - i + 1, - title, - url, - description - ); - } - output -} - -pub(crate) fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTool { - use std::sync::OnceLock; - static CLIENT: OnceLock = OnceLock::new(); - - RegisteredTool { - definition: ToolDefinition { - name: WEB_SEARCH_TOOL_NAME.into(), - description: "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.".into(), - parameters: serde_json::json!({ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "max_results": {"type": "integer", "description": "Maximum number of results (default 5, max 20)"} - }, - "required": ["query"] - }), - }, - executor: Arc::new(move |args, _ctx| { - let api_key = api_key.clone(); - Box::pin(async move { - let query = required_str(&args, "query")?; - let client = CLIENT - .get_or_init(|| { - fabro_http::http_client().expect("Brave Search HTTP client should build") - }) - .clone(); - let count = args - .get("max_results") - .and_then(serde_json::Value::as_u64) - .unwrap_or(5) - .min(20); - - let resp = client - .get("https://api.search.brave.com/res/v1/web/search") - .header("X-Subscription-Token", &api_key) - .header("Accept", "application/json") - .query(&[("q", query), ("count", &count.to_string())]) - .send() - .await - .map_err(|e| format!("HTTP request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!( - "Brave Search API returned status {}", - resp.status() - )); - } - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse response: {e}"))?; - - Ok(format_brave_results(&body)) - }) - }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -820,6 +725,7 @@ mod tests { use super::*; use crate::config::{NativeToolOptions, SessionOptions, ToolSecrets}; + use crate::web_search::make_web_search_tool_with_api_key; use crate::event::{Emitter, SessionBoundEmitter}; use crate::local_sandbox::LocalSandbox; use crate::sandbox::*; @@ -917,15 +823,18 @@ mod tests { files, ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({"file_path": "/test.txt"}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; assert_eq!(result.unwrap(), "1 | hello\n2 | world\n"); } @@ -942,15 +851,18 @@ mod tests { ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({"file_path": "/test.txt"}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await .unwrap(); @@ -991,12 +903,12 @@ mod tests { let result = (tool.executor)( serde_json::json!({"file_path": "/out.txt", "content": "hello"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1026,12 +938,12 @@ mod tests { "new_string": "goodbye" }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1121,12 +1033,12 @@ mod tests { "replace_all": true }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1154,12 +1066,12 @@ mod tests { "new_string": "goodbye" }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1188,8 +1100,8 @@ mod tests { root_session_id: Some("test-session".to_string()), tool_call_id: Some("call_1".to_string()), agent_event_emitter: Some(Arc::new(SessionBoundEmitter { - emitter: emitter.clone(), - session_id: "test-session".to_string(), + emitter: emitter.clone(), + session_id: "test-session".to_string(), tool_call_id: Some("call_1".to_string()), })), ..shell_context(env) @@ -1218,9 +1130,9 @@ mod tests { async fn shell_success_returns_ok_with_metadata_and_separate_streams() { let tool = make_shell_tool(); let env: Arc = mock_sandbox_with(ExecResult { - stdout: "hello".into(), - stderr: "a warning".into(), - exit_code: Some(0), + stdout: "hello".into(), + stderr: "a warning".into(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 10, }); @@ -1242,9 +1154,9 @@ mod tests { async fn shell_forwards_command_without_stream_redirection_wrapper() { let tool = make_shell_tool(); let env = mock_sandbox_with(ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 1, }); @@ -1270,12 +1182,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "sleep 1", "timeout_ms": 5000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1288,9 +1200,9 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "error".into(), - stderr: String::new(), - exit_code: Some(1), + stdout: "error".into(), + stderr: String::new(), + exit_code: Some(1), termination: CommandTermination::Exited, duration_ms: 10, }, @@ -1309,9 +1221,9 @@ mod tests { async fn shell_timeout_returns_error_with_partial_output() { let tool = make_shell_tool(); let env: Arc = mock_sandbox_with(ExecResult { - stdout: "partial".into(), - stderr: String::new(), - exit_code: None, + stdout: "partial".into(), + stderr: String::new(), + exit_code: None, termination: CommandTermination::TimedOut, duration_ms: 10000, }); @@ -1331,9 +1243,9 @@ mod tests { async fn shell_cancellation_returns_error_with_partial_output() { let tool = make_shell_tool(); let env: Arc = mock_sandbox_with(ExecResult { - stdout: "partial".into(), - stderr: String::new(), - exit_code: None, + stdout: "partial".into(), + stderr: String::new(), + exit_code: None, termination: CommandTermination::Cancelled, duration_ms: 42, }); @@ -1385,9 +1297,9 @@ mod tests { async fn shell_emits_process_event_with_typed_outcome_and_redacted_tails() { let tool = make_shell_tool(); let env: Arc = mock_sandbox_with(ExecResult { - stdout: "out".into(), - stderr: "boom key=AKIAYRWQG5EJLPZLBYNP".into(), - exit_code: Some(7), + stdout: "out".into(), + stderr: "boom key=AKIAYRWQG5EJLPZLBYNP".into(), + exit_code: Some(7), termination: CommandTermination::Exited, duration_ms: 12, }); @@ -1427,9 +1339,9 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "interleaved".into(), - stderr: String::new(), - exit_code: Some(0), + stdout: "interleaved".into(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 5, }, @@ -1545,12 +1457,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "echo $MY_KEY"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))), - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))), + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1593,12 +1505,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "echo $GITHUB_TOKEN"}), ToolContext { - env: env.clone(), - cancel: CancellationToken::new(), - tool_env_provider: Some(provider.clone()), - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env.clone(), + cancel: CancellationToken::new(), + tool_env_provider: Some(provider.clone()), + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1614,12 +1526,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "echo $GITHUB_TOKEN"}), ToolContext { - env: env.clone(), - cancel: CancellationToken::new(), - tool_env_provider: Some(provider), - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env.clone(), + cancel: CancellationToken::new(), + tool_env_provider: Some(provider), + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1669,15 +1581,18 @@ mod tests { ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: Some(Arc::new(FailingToolEnvProvider)), - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({"file_path": "/test.txt"}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: Some(Arc::new(FailingToolEnvProvider)), + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; assert_eq!(result.unwrap(), "1 | hello\n"); @@ -1688,15 +1603,18 @@ mod tests { let tool = make_shell_tool(); let env = Arc::new(MockSandbox::default()); let env_clone: Arc = env.clone(); - let _result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let _result = (tool.executor)( + serde_json::json!({"command": "echo hello"}), + ToolContext { + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; let captured = env.captured_env_vars.lock().unwrap().clone(); assert_eq!(captured, None); @@ -1707,9 +1625,9 @@ mod tests { let tool = make_web_fetch_tool(None); let env = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "fetched content".into(), - stderr: String::new(), - exit_code: Some(0), + stdout: "fetched content".into(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 100, }, @@ -1721,12 +1639,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))), - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))), + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1745,15 +1663,18 @@ mod tests { ], ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"pattern": "fn"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({"pattern": "fn"}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; let output = result.unwrap(); assert!(output.contains("src/main.rs:10:fn main()")); @@ -1767,15 +1688,18 @@ mod tests { glob_results: vec!["src/main.rs".into(), "src/lib.rs".into()], ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"pattern": "src/**/*.rs"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({"pattern": "src/**/*.rs"}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; let output = result.unwrap(); assert!(output.contains("src/main.rs")); @@ -1795,15 +1719,18 @@ mod tests { async fn web_search_missing_query_returns_error() { let tool = make_web_search_tool_with_api_key("fake-key".into()); let env: Arc = Arc::new(MockSandbox::default()); - let result = (tool.executor)(serde_json::json!({}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; let err = result.unwrap_err(); assert!( @@ -1818,6 +1745,7 @@ mod tests { let options = NativeToolOptions { secrets: ToolSecrets { brave_search_api_key: Some("fake-key".to_string()), + ..ToolSecrets::default() }, ..NativeToolOptions::default() }; @@ -1828,15 +1756,18 @@ mod tests { .get("web_search") .expect("web_search should be registered"); let env: Arc = Arc::new(MockSandbox::default()); - let result = (tool.executor)(serde_json::json!({}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) + let result = (tool.executor)( + serde_json::json!({}), + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) .await; let err = result.unwrap_err(); @@ -1846,37 +1777,14 @@ mod tests { ); } - #[test] - fn format_brave_results_formats_results() { - let body = serde_json::json!({ - "web": { - "results": [ - {"title": "Rust Lang", "url": "https://rust-lang.org", "description": "A systems language"}, - {"title": "Rust Book", "url": "https://doc.rust-lang.org/book", "description": "The Rust book"} - ] - } - }); - let output = format_brave_results(&body); - assert!(output.contains("1. Rust Lang")); - assert!(output.contains("https://rust-lang.org")); - assert!(output.contains("A systems language")); - assert!(output.contains("2. Rust Book")); - } - - #[test] - fn format_brave_results_no_results() { - let body = serde_json::json!({"web": {}}); - assert_eq!(format_brave_results(&body), "No results found."); - } - #[tokio::test] async fn web_fetch_builds_curl_command() { let tool = make_web_fetch_tool(None); let env = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

hello

".into(), - stderr: String::new(), - exit_code: Some(0), + stdout: "

hello

".into(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 100, }, @@ -1886,12 +1794,12 @@ mod tests { let result = (tool.executor)( serde_json::json!({"url": "https://example.com"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1952,12 +1860,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com", "timeout_ms": 15000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -1978,12 +1886,12 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com", "timeout_ms": 120_000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, + env: env_clone, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, agent_event_emitter: None, }, ) @@ -2002,9 +1910,9 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: large_content, - stderr: String::new(), - exit_code: Some(0), + stdout: large_content, + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 100, }, @@ -2033,9 +1941,9 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: "curl: (6) Could not resolve host".into(), - exit_code: Some(6), + stdout: String::new(), + stderr: "curl: (6) Could not resolve host".into(), + exit_code: Some(6), termination: CommandTermination::Exited, duration_ms: 100, }, @@ -2077,17 +1985,16 @@ mod tests { client, model_id: ModelHandle::ByName { provider: ProviderId::anthropic(), - model: "mock-model".to_string(), + model: "mock-model".to_string(), }, }; let tool = make_web_fetch_tool(Some(summarizer)); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Lots of content about Rust...

" - .into(), - stderr: String::new(), - exit_code: Some(0), + stdout: "

Lots of content about Rust...

".into(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 100, }, @@ -2118,11 +2025,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: - "

Rust is a systems programming language.

" - .into(), - stderr: String::new(), - exit_code: Some(0), + stdout: "

Rust is a systems programming language.

" + .into(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 100, }, @@ -2162,7 +2068,7 @@ mod tests { // "other_provider" is the default — it rejects all requests. let default_provider: Arc = Arc::new(MockErrorProvider { error: LlmError::Provider { - kind: ProviderErrorKind::NotFound, + kind: ProviderErrorKind::NotFound, detail: Box::new(ProviderErrorDetail::new( "model not found", "other_provider", @@ -2186,16 +2092,16 @@ mod tests { client, model_id: ModelHandle::ByName { provider: ProviderId::anthropic(), - model: "target-model".to_string(), + model: "target-model".to_string(), }, }; let tool = make_web_fetch_tool(Some(summarizer)); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Page content

".into(), - stderr: String::new(), - exit_code: Some(0), + stdout: "

Page content

".into(), + stderr: String::new(), + exit_code: Some(0), termination: CommandTermination::Exited, duration_ms: 100, }, diff --git a/lib/components/fabro-agent/src/web_search.rs b/lib/components/fabro-agent/src/web_search.rs new file mode 100644 index 000000000..9be79c811 --- /dev/null +++ b/lib/components/fabro-agent/src/web_search.rs @@ -0,0 +1,627 @@ +//! Built-in `web_search` backends. +//! +//! Agents always call the same tool. The HTTP backend is selected by +//! `[server.integrations.search].provider`. + +use std::fmt::Write; +use std::sync::OnceLock; +use std::time::Duration; + +use fabro_llm::types::ToolDefinition; +use fabro_types::settings::{SearchIntegrationSettings, SearchProvider, VeniceSearchEngine}; + +use crate::config::ToolSecrets; +use crate::tool_registry::{RegisteredTool, ToolSource}; +use crate::tools::{WEB_SEARCH_TOOL_NAME, required_str}; + +const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search"; +const VENICE_SEARCH_URL: &str = "https://api.venice.ai/api/v1/augment/search"; +const VENICE_QUERY_MAX_CHARS: usize = 400; +const VENICE_REQUEST_TIMEOUT: Duration = Duration::from_mins(1); +const DEFAULT_MAX_RESULTS: u64 = 5; +const MAX_RESULTS: u64 = 20; + +#[derive(Clone, Debug)] +pub(crate) enum SearchBackend { + Brave { + api_key: String, + search_url: String, + }, + Venice { + api_key: String, + engine: VeniceSearchEngine, + search_url: String, + }, +} + +impl SearchBackend { + #[must_use] + pub(crate) fn from_secrets(secrets: &ToolSecrets) -> Option { + match secrets.search.provider { + SearchProvider::Brave => secrets + .brave_search_api_key + .as_ref() + .map(|api_key| Self::brave(api_key.clone())), + SearchProvider::Venice => secrets + .venice_api_key + .as_ref() + .map(|api_key| Self::venice(api_key.clone(), secrets.search.venice_engine)), + } + } + + #[must_use] + pub(crate) fn brave(api_key: String) -> Self { + Self::Brave { + api_key, + search_url: BRAVE_SEARCH_URL.to_string(), + } + } + + #[must_use] + pub(crate) fn venice(api_key: String, engine: VeniceSearchEngine) -> Self { + Self::Venice { + api_key, + engine, + search_url: VENICE_SEARCH_URL.to_string(), + } + } + + #[must_use] + pub(crate) fn includes_engine_param(&self) -> bool { + matches!(self, Self::Venice { .. }) + } + + async fn search( + &self, + query: &str, + max_results: u64, + engine_override: Option, + ) -> Result { + match self { + Self::Brave { + api_key, + search_url, + } => search_brave(api_key, search_url, query, max_results).await, + Self::Venice { + api_key, + engine, + search_url, + } => { + if query.chars().count() > VENICE_QUERY_MAX_CHARS { + return Err(format!( + "query exceeds Venice Search maximum of {VENICE_QUERY_MAX_CHARS} characters" + )); + } + let engine = engine_override.unwrap_or(*engine); + search_venice(api_key, search_url, query, max_results, engine).await + } + } + } +} + +fn search_http_client() -> fabro_http::HttpClient { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT + .get_or_init(|| { + #[cfg(test)] + { + fabro_http::test_http_client().expect("Search HTTP client should build") + } + #[cfg(not(test))] + { + fabro_http::http_client().expect("Search HTTP client should build") + } + }) + .clone() +} + +async fn search_brave( + api_key: &str, + search_url: &str, + query: &str, + max_results: u64, +) -> Result { + let count = max_results.min(MAX_RESULTS); + let resp = search_http_client() + .get(search_url) + .header("X-Subscription-Token", api_key) + .header("Accept", "application/json") + .query(&[("q", query), ("count", &count.to_string())]) + .send() + .await + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!( + "Brave Search API returned status {}", + resp.status() + )); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + Ok(format_brave_results(&body)) +} + +async fn search_venice( + api_key: &str, + search_url: &str, + query: &str, + max_results: u64, + engine: VeniceSearchEngine, +) -> Result { + let limit = max_results.clamp(1, MAX_RESULTS); + let resp = search_http_client() + .post(search_url) + .timeout(VENICE_REQUEST_TIMEOUT) + .bearer_auth(api_key) + .header("Accept", "application/json") + .json(&serde_json::json!({ + "query": query, + "limit": limit, + "search_provider": engine.as_str(), + })) + .send() + .await + .map_err(|e| format!("HTTP request failed: {e}"))?; + + let status = resp.status(); + if !status.is_success() { + return Err(venice_status_error(status.as_u16(), &resp)); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + Ok(format_venice_results(&body)) +} + +fn venice_status_error(status: u16, resp: &fabro_http::Response) -> String { + let mut message = format!("Venice Search API returned status {status}"); + if status == 402 { + if let Some(balance) = header_str(resp, "x-venice-balance-usd") { + let _ = write!(message, " (balance USD {balance})"); + } else if let Some(balance) = header_str(resp, "x-venice-balance-diem") { + let _ = write!(message, " (balance DIEM {balance})"); + } + } + message +} + +fn header_str(resp: &fabro_http::Response, name: &str) -> Option { + resp.headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +fn format_brave_results(body: &serde_json::Value) -> String { + let results = body + .get("web") + .and_then(|w| w.get("results")) + .and_then(serde_json::Value::as_array); + format_hits(results.map(|results| { + results + .iter() + .map(|result| SearchHit { + title: json_str(result, "title"), + url: json_str(result, "url"), + description: json_str(result, "description"), + date: None, + }) + .collect() + })) +} + +fn format_venice_results(body: &serde_json::Value) -> String { + let results = body.get("results").and_then(serde_json::Value::as_array); + format_hits(results.map(|results| { + results + .iter() + .map(|result| SearchHit { + title: json_str(result, "title"), + url: json_str(result, "url"), + description: json_str(result, "content"), + date: optional_json_str(result, "date"), + }) + .collect() + })) +} + +struct SearchHit { + title: String, + url: String, + description: String, + date: Option, +} + +fn format_hits(hits: Option>) -> String { + let Some(hits) = hits.filter(|hits| !hits.is_empty()) else { + return "No results found.".to_string(); + }; + + let mut output = String::new(); + for (i, hit) in hits.iter().enumerate() { + let _ = write!( + output, + "{}. {}\n {}\n {}\n", + i + 1, + hit.title, + hit.url, + hit.description + ); + if let Some(date) = &hit.date { + let _ = writeln!(output, " {date}"); + } + output.push('\n'); + } + output +} + +fn json_str(value: &serde_json::Value, key: &str) -> String { + optional_json_str(value, key).unwrap_or_else(|| match key { + "title" => "(no title)".to_string(), + "url" => "(no url)".to_string(), + _ => String::new(), + }) +} + +fn optional_json_str(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +fn parse_engine_arg(args: &serde_json::Value) -> Result, String> { + let Some(value) = args.get("engine").and_then(serde_json::Value::as_str) else { + return Ok(None); + }; + value + .parse() + .map(Some) + .map_err(|_| format!("Invalid engine `{value}`; expected `brave` or `google`")) +} + +fn max_results_arg(args: &serde_json::Value) -> u64 { + args.get("max_results") + .and_then(serde_json::Value::as_u64) + .unwrap_or(DEFAULT_MAX_RESULTS) + .min(MAX_RESULTS) +} + +#[must_use] +pub(crate) fn make_web_search_tool(backend: SearchBackend) -> RegisteredTool { + let mut properties = serde_json::json!({ + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "description": "Maximum number of results (default 5, max 20)"} + }); + if backend.includes_engine_param() { + properties["engine"] = serde_json::json!({ + "type": "string", + "enum": ["brave", "google"], + "description": "Venice search engine. `brave` is ZDR (default); `google` is an anonymized proxy." + }); + } + + RegisteredTool { + definition: ToolDefinition { + name: WEB_SEARCH_TOOL_NAME.into(), + description: "Search the web when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": properties, + "required": ["query"] + }), + }, + executor: std::sync::Arc::new(move |args, _ctx| { + let backend = backend.clone(); + Box::pin(async move { + let query = required_str(&args, "query")?; + let engine = parse_engine_arg(&args)?; + backend + .search(query, max_results_arg(&args), engine) + .await + }) + }), + source: ToolSource::Native, + } +} + +#[cfg(test)] +#[must_use] +pub(crate) fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTool { + make_web_search_tool(SearchBackend::brave(api_key)) +} + +#[must_use] +pub fn search_settings_from_disk() -> SearchIntegrationSettings { + fabro_config::ServerSettingsBuilder::load_default() + .ok() + .map(|settings| settings.server.integrations.search) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use fabro_types::settings::{SearchIntegrationSettings, SearchProvider, VeniceSearchEngine}; + use httpmock::Method::{GET, POST}; + use httpmock::MockServer; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::config::ToolSecrets; + use crate::sandbox::Sandbox; + use crate::test_support::MockSandbox; + use crate::tool_registry::ToolContext; + + fn secrets(brave: Option<&str>, venice: Option<&str>, provider: SearchProvider) -> ToolSecrets { + ToolSecrets { + brave_search_api_key: brave.map(str::to_string), + venice_api_key: venice.map(str::to_string), + search: SearchIntegrationSettings { + provider, + venice_engine: VeniceSearchEngine::Brave, + }, + } + } + + async fn execute(tool: &RegisteredTool, args: serde_json::Value) -> Result { + let env: Arc = Arc::new(MockSandbox::default()); + (tool.executor)( + args, + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }, + ) + .await + } + + #[test] + fn from_secrets_registers_brave_by_default_when_brave_key_is_present() { + let backend = SearchBackend::from_secrets(&secrets( + Some("brave-key"), + Some("venice-key"), + SearchProvider::Brave, + )); + assert!(matches!(backend, Some(SearchBackend::Brave { .. }))); + } + + #[test] + fn from_secrets_omits_brave_when_key_missing() { + assert!( + SearchBackend::from_secrets(&secrets(None, Some("venice-key"), SearchProvider::Brave)) + .is_none() + ); + } + + #[test] + fn from_secrets_registers_venice_when_selected_and_key_present() { + let backend = SearchBackend::from_secrets(&secrets( + Some("brave-key"), + Some("venice-key"), + SearchProvider::Venice, + )); + assert!(matches!(backend, Some(SearchBackend::Venice { .. }))); + } + + #[test] + fn from_secrets_omits_venice_when_key_missing() { + assert!( + SearchBackend::from_secrets(&secrets(Some("brave-key"), None, SearchProvider::Venice)) + .is_none() + ); + } + + #[test] + fn format_brave_results_formats_results() { + let body = serde_json::json!({ + "web": { + "results": [ + {"title": "Rust Lang", "url": "https://rust-lang.org", "description": "A systems language"}, + {"title": "Rust Book", "url": "https://doc.rust-lang.org/book", "description": "The Rust book"} + ] + } + }); + let output = format_brave_results(&body); + assert!(output.contains("1. Rust Lang")); + assert!(output.contains("https://rust-lang.org")); + assert!(output.contains("A systems language")); + assert!(output.contains("2. Rust Book")); + } + + #[test] + fn format_brave_results_no_results() { + let body = serde_json::json!({"web": {}}); + assert_eq!(format_brave_results(&body), "No results found."); + } + + #[test] + fn format_venice_results_includes_date_when_present() { + let body = serde_json::json!({ + "query": "rust", + "results": [ + { + "title": "Rust Lang", + "url": "https://rust-lang.org", + "content": "A systems language", + "date": "2026-01-02" + } + ] + }); + let output = format_venice_results(&body); + assert!(output.contains("1. Rust Lang")); + assert!(output.contains("https://rust-lang.org")); + assert!(output.contains("A systems language")); + assert!(output.contains("2026-01-02")); + } + + #[test] + fn venice_schema_includes_engine_and_brave_schema_does_not() { + let brave = make_web_search_tool(SearchBackend::brave("key".into())); + let venice = make_web_search_tool(SearchBackend::venice( + "key".into(), + VeniceSearchEngine::Brave, + )); + assert!( + brave.definition.parameters["properties"] + .get("engine") + .is_none() + ); + assert!( + venice.definition.parameters["properties"] + .get("engine") + .is_some() + ); + } + + #[tokio::test] + async fn venice_search_posts_augment_search_and_maps_engine() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/augment/search") + .header("authorization", "Bearer venice-key") + .json_body(serde_json::json!({ + "query": "fabro", + "limit": 3, + "search_provider": "google" + })); + then.status(200).json_body(serde_json::json!({ + "query": "fabro", + "results": [{ + "title": "Fabro", + "url": "https://docs.fabro.sh", + "content": "Agent runtime", + "date": "2026-08-21" + }] + })); + }); + + let mut backend = SearchBackend::venice("venice-key".into(), VeniceSearchEngine::Brave); + if let SearchBackend::Venice { search_url, .. } = &mut backend { + *search_url = format!("{}/api/v1/augment/search", server.base_url()); + } + let tool = make_web_search_tool(backend); + let output = execute( + &tool, + serde_json::json!({ + "query": "fabro", + "max_results": 3, + "engine": "google" + }), + ) + .await + .expect("venice search should succeed"); + + mock.assert(); + assert!(output.contains("1. Fabro")); + assert!(output.contains("https://docs.fabro.sh")); + assert!(output.contains("Agent runtime")); + assert!(output.contains("2026-08-21")); + } + + #[tokio::test] + async fn venice_rejects_query_over_400_chars_before_http() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/api/v1/augment/search"); + then.status(200) + .json_body(serde_json::json!({"results": []})); + }); + + let mut backend = SearchBackend::venice("venice-key".into(), VeniceSearchEngine::Brave); + if let SearchBackend::Venice { search_url, .. } = &mut backend { + *search_url = format!("{}/api/v1/augment/search", server.base_url()); + } + let tool = make_web_search_tool(backend); + let query = "a".repeat(401); + let err = execute(&tool, serde_json::json!({ "query": query })) + .await + .expect_err("overlong query should fail before HTTP"); + + mock.assert_calls(0); + assert!(err.contains("400")); + } + + #[tokio::test] + async fn venice_maps_401_402_and_429_to_tool_errors() { + async fn assert_status(status: u16, header: Option<(&str, &str)>, expected: &str) { + let server = MockServer::start(); + let mock = match header { + Some((name, value)) => server.mock(|when, then| { + when.method(POST).path("/api/v1/augment/search"); + then.status(status).header(name, value).body("error"); + }), + None => server.mock(|when, then| { + when.method(POST).path("/api/v1/augment/search"); + then.status(status).body("error"); + }), + }; + let mut backend = SearchBackend::venice("venice-key".into(), VeniceSearchEngine::Brave); + if let SearchBackend::Venice { search_url, .. } = &mut backend { + *search_url = format!("{}/api/v1/augment/search", server.base_url()); + } + let tool = make_web_search_tool(backend); + let err = execute(&tool, serde_json::json!({ "query": "fabro" })) + .await + .expect_err("status should become a tool error"); + assert_eq!(err, expected); + mock.assert(); + } + + assert_status(401, None, "Venice Search API returned status 401").await; + assert_status( + 402, + Some(("x-venice-balance-usd", "0.12")), + "Venice Search API returned status 402 (balance USD 0.12)", + ) + .await; + assert_status(429, None, "Venice Search API returned status 429").await; + } + + #[tokio::test] + async fn brave_search_still_uses_get_and_subscription_token() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/res/v1/web/search") + .header("x-subscription-token", "brave-key") + .query_param("q", "rust") + .query_param("count", "5"); + then.status(200).json_body(serde_json::json!({ + "web": { + "results": [{ + "title": "Rust", + "url": "https://rust-lang.org", + "description": "A language" + }] + } + })); + }); + + let mut backend = SearchBackend::brave("brave-key".into()); + if let SearchBackend::Brave { search_url, .. } = &mut backend { + *search_url = format!("{}/res/v1/web/search", server.base_url()); + } + let tool = make_web_search_tool(backend); + let output = execute(&tool, serde_json::json!({ "query": "rust" })) + .await + .expect("brave search should succeed"); + mock.assert(); + assert!(output.contains("1. Rust")); + assert!(output.contains("A language")); + } +} diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 7f64a29fb..486526f07 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -26,22 +26,22 @@ type Provider = ProviderId; #[derive(Clone)] struct OpenAiTwinOptions { base_url: String, - api_key: String, + api_key: String, } fn summarizer_model_id(provider: &Provider) -> ModelHandle { match provider.as_str() { ProviderId::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => ModelHandle::ByName { provider: ProviderId::openai(), - model: "gpt-5.4-mini".to_string(), + model: "gpt-5.4-mini".to_string(), }, ProviderId::GEMINI => ModelHandle::ByName { provider: ProviderId::gemini(), - model: "gemini-3-flash-preview".to_string(), + model: "gemini-3-flash-preview".to_string(), }, ProviderId::ANTHROPIC => ModelHandle::ByName { provider: ProviderId::anthropic(), - model: "claude-haiku-4-5".to_string(), + model: "claude-haiku-4-5".to_string(), }, other => panic!("unexpected provider {other}"), } @@ -49,7 +49,7 @@ fn summarizer_model_id(provider: &Provider) -> ModelHandle { fn build_summarizer(provider: &Provider, client: &Client) -> WebFetchSummarizer { WebFetchSummarizer { - client: client.clone(), + client: client.clone(), model_id: summarizer_model_id(provider), } } @@ -170,12 +170,13 @@ fn make_openai_compatible_twin_session( // twin fixture so the profile can resolve the same OpenAI-compatible // codec that the manually registered adapter uses. let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { + settings.providers.insert( + provider.to_string(), + ProviderCatalogSettings { enabled: Some(true), ..ProviderCatalogSettings::default() - }); + }, + ); let catalog = Arc::new( Catalog::from_builtin_with_overrides(&settings) .expect("OpenAI-compatible twin catalog should build"), @@ -230,6 +231,7 @@ macro_rules! web_search_provider_test { "BRAVE_SEARCH_API_KEY must be set for web-search tests", ), ), + ..ToolSecrets::default() } ); }; diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 02be6fa2a..d7e44bf96 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -3921,6 +3921,7 @@ enabled = true // An invalid header value makes a correctly configured executor // fail locally before any request can leave the test process. brave_search_api_key: Some("\n".to_string()), + ..ToolSecrets::default() }); let node = Node::new("search"); let context = Context::new(); diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index 1c4711b7f..cdb8a6520 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -235,13 +235,11 @@ async fn build_registry( } async fn tool_secrets_from_configured_sources(vault: &Arc>) -> ToolSecrets { - let brave_search_api_key = vault - .read() - .await - .get(EnvVars::BRAVE_SEARCH_API_KEY) - .map(str::to_string); + let vault = vault.read().await; ToolSecrets { - brave_search_api_key, + brave_search_api_key: vault.get(EnvVars::BRAVE_SEARCH_API_KEY).map(str::to_string), + venice_api_key: vault.get(EnvVars::VENICE_API_KEY).map(str::to_string), + search: fabro_agent::search_settings_from_disk(), } } diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 0fb3cccd0..5a3ff06ae 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -324,6 +324,21 @@ fn main() { "fabro_types::settings::server::SlackIntegrationSettings", &[], ), + ( + "SearchIntegrationSettings", + "fabro_types::settings::server::SearchIntegrationSettings", + &[], + ), + ( + "SearchProvider", + "fabro_types::settings::server::SearchProvider", + &[], + ), + ( + "VeniceSearchEngine", + "fabro_types::settings::server::VeniceSearchEngine", + &[], + ), ( "IntegrationWebhooksSettings", "fabro_types::settings::server::IntegrationWebhooksSettings", @@ -676,8 +691,11 @@ fn main() { ("AskFabro", "fabro_types::AskFabro", &[]), ("Automation", "fabro_automation::Automation", &[]), ("AutomationRef", "fabro_types::AutomationRef", &[]), - ("AutomationTarget", "fabro_automation::AutomationTarget", &[ - ]), + ( + "AutomationTarget", + "fabro_automation::AutomationTarget", + &[], + ), ( "AutomationTrigger", "fabro_automation::AutomationTrigger", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index c53e682c7..97eb3c35b 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -28,11 +28,12 @@ pub mod types { pub use fabro_types::settings::run::{McpHttpProtocol, RunModelControls, RunModelSettings}; pub use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, - LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, - ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings, - ServerListenSettings, ServerLoggingSettings, ServerSandboxProviderSettings, - ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings, - ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, + LogDestination, ObjectStoreSettings, SearchIntegrationSettings, SearchProvider, + ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, + ServerAuthSettings, ServerIntegrationsSettings, ServerListenSettings, + ServerLoggingSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings, + ServerSandboxSettings, ServerSchedulerSettings, ServerSlateDbSettings, + ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, VeniceSearchEngine, WebhookStrategy, }; pub use fabro_types::settings::{McpTransport, ServerNamespace}; diff --git a/lib/foundation/fabro-config/src/layers/combine.rs b/lib/foundation/fabro-config/src/layers/combine.rs index aec393777..f3ef27232 100644 --- a/lib/foundation/fabro-config/src/layers/combine.rs +++ b/lib/foundation/fabro-config/src/layers/combine.rs @@ -7,8 +7,8 @@ use fabro_types::settings::run::{ ApprovalMode, EnvironmentNetworkMode, EnvironmentProvider, MergeStrategy, RunMode, }; use fabro_types::settings::server::{ - GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod, - WebhookStrategy, + GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, SearchProvider, + ServerAuthMethod, VeniceSearchEngine, WebhookStrategy, }; use fabro_types::settings::{Duration, InterpString, Size}; @@ -82,7 +82,9 @@ impl_combine_or_option!( GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, + SearchProvider, ServerAuthMethod, + VeniceSearchEngine, WebhookStrategy, LogFilter, AgentProfileKind, diff --git a/lib/foundation/fabro-config/src/layers/mod.rs b/lib/foundation/fabro-config/src/layers/mod.rs index c3fa1632c..253345227 100644 --- a/lib/foundation/fabro-config/src/layers/mod.rs +++ b/lib/foundation/fabro-config/src/layers/mod.rs @@ -39,8 +39,8 @@ pub use run::{ }; pub use server::{ GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, - ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, - ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + SearchIntegrationLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, + ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, diff --git a/lib/foundation/fabro-config/src/layers/server.rs b/lib/foundation/fabro-config/src/layers/server.rs index 351d4da68..7d4a96f5b 100644 --- a/lib/foundation/fabro-config/src/layers/server.rs +++ b/lib/foundation/fabro-config/src/layers/server.rs @@ -1,8 +1,8 @@ //! Sparse `[server]` settings layer definitions. use fabro_types::settings::server::{ - GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod, - WebhookStrategy, + GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, SearchProvider, + ServerAuthMethod, VeniceSearchEngine, WebhookStrategy, }; use fabro_types::settings::{Duration, InterpString}; use serde::{Deserialize, Serialize}; @@ -13,25 +13,25 @@ use super::LogFilter; #[serde(deny_unknown_fields)] pub struct ServerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub listen: Option, + pub listen: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, + pub api: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, + pub web: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, + pub auth: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, + pub sandbox: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage: Option, + pub storage: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, + pub artifacts: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub slatedb: Option, + pub slatedb: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub scheduler: Option, + pub scheduler: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub logging: Option, + pub logging: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub integrations: Option, } @@ -67,7 +67,7 @@ pub struct ServerWebLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, + pub url: Option, } /// `[server.auth]` — cohesive server auth surface. @@ -81,7 +81,7 @@ pub struct ServerAuthLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub methods: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, + pub github: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -103,9 +103,9 @@ pub struct ServerSandboxLayer { #[serde(deny_unknown_fields)] pub struct ServerSandboxProvidersLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, + pub local: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub docker: Option, + pub docker: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub daytona: Option, } @@ -132,11 +132,11 @@ pub struct ServerArtifactsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub prefix: Option, + pub prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, + pub local: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub s3: Option, + pub s3: Option, } /// `[server.slatedb]` — SlateDB bottomless storage plus tunables. @@ -144,17 +144,17 @@ pub struct ServerArtifactsLayer { #[serde(deny_unknown_fields)] pub struct ServerSlateDbLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, + pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub prefix: Option, + pub prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub flush_interval: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, + pub local: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub s3: Option, + pub s3: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk_cache: Option, + pub disk_cache: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -170,11 +170,11 @@ pub struct ObjectStoreLocalLayer { #[serde(deny_unknown_fields)] pub struct ObjectStoreS3Layer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub bucket: Option, + pub bucket: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, + pub region: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, + pub endpoint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub path_style: Option, } @@ -192,7 +192,7 @@ pub struct ServerSchedulerLayer { #[serde(deny_unknown_fields)] pub struct ServerLoggingLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub level: Option, + pub level: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub destination: Option, } @@ -205,7 +205,9 @@ pub struct ServerIntegrationsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub github: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, } /// `[server.integrations.github]` — GitHub App, credentials, and inbound @@ -214,17 +216,17 @@ pub struct ServerIntegrationsLayer { #[serde(deny_unknown_fields)] pub struct GithubIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, + pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, + pub strategy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub app_id: Option, + pub app_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub client_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub slug: Option, + pub slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub webhooks: Option, + pub webhooks: Option, } /// `[server.integrations.slack]` — Slack workspace credentials and defaults. @@ -232,11 +234,21 @@ pub struct GithubIntegrationLayer { #[serde(deny_unknown_fields)] pub struct SlackIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, + pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_channel: Option, } +/// `[server.integrations.search]` — backend for the built-in `web_search` tool. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct SearchIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub venice_engine: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct IntegrationWebhooksLayer { diff --git a/lib/foundation/fabro-config/src/lib.rs b/lib/foundation/fabro-config/src/lib.rs index f6097b9be..43bc1c5ab 100644 --- a/lib/foundation/fabro-config/src/lib.rs +++ b/lib/foundation/fabro-config/src/lib.rs @@ -53,11 +53,12 @@ pub use layers::{ RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, - RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, - ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, - ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, - ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, - ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, + RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, SearchIntegrationLayer, ServerApiLayer, + ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, + ServerLayer, ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, + ServerSandboxProviderLayer, ServerSandboxProvidersLayer, ServerSchedulerLayer, + ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, SlackIntegrationLayer, + StickyMap, StringOrSplice, WorkflowLayer, }; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; pub use parse::ParseError; diff --git a/lib/foundation/fabro-config/src/resolve/server.rs b/lib/foundation/fabro-config/src/resolve/server.rs index 39b41eaf9..df6f1b76b 100644 --- a/lib/foundation/fabro-config/src/resolve/server.rs +++ b/lib/foundation/fabro-config/src/resolve/server.rs @@ -2,12 +2,12 @@ use std::path::Path; use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, - ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, - ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings, - ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSandboxProviderSettings, - ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings, - ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, - WebhookStrategy, + ObjectStoreProvider, ObjectStoreSettings, SearchIntegrationSettings, ServerApiSettings, + ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, + ServerIntegrationsSettings, ServerListenSettings, ServerLoggingSettings, ServerNamespace, + ServerSandboxProviderSettings, ServerSandboxProvidersSettings, ServerSandboxSettings, + ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, + SlackIntegrationSettings, WebhookStrategy, }; use fabro_util::Home; @@ -51,7 +51,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se .expect("defaults.toml should provide server.scheduler.max_concurrent_runs"), }, logging: ServerLoggingSettings { - level: layer + level: layer .logging .as_ref() .and_then(|logging| logging.level.as_ref()) @@ -70,10 +70,10 @@ fn resolve_sandbox(layer: Option<&ServerSandboxLayer>) -> ServerSandboxSettings let providers = layer.and_then(|sandbox| sandbox.providers.as_ref()); ServerSandboxSettings { providers: ServerSandboxProvidersSettings { - local: resolve_sandbox_provider( + local: resolve_sandbox_provider( providers.and_then(|providers| providers.local.as_ref()), ), - docker: resolve_sandbox_provider( + docker: resolve_sandbox_provider( providers.and_then(|providers| providers.docker.as_ref()), ), daytona: resolve_sandbox_provider( @@ -150,7 +150,7 @@ fn resolve_auth( let methods = if let Some(mut methods) = layer.and_then(|auth| auth.methods.clone()) { if methods.is_empty() { errors.push(ResolveError::Invalid { - path: "server.auth.methods".to_string(), + path: "server.auth.methods".to_string(), reason: "must not be empty".to_string(), }); } @@ -169,7 +169,7 @@ fn resolve_auth( .unwrap_or_default(); if methods.contains(&ServerAuthMethod::Github) && github.allowed_usernames.is_empty() { errors.push(ResolveError::Invalid { - path: "server.auth.github.allowed_usernames".to_string(), + path: "server.auth.github.allowed_usernames".to_string(), reason: "must not be empty when github auth is enabled".to_string(), }); } @@ -198,7 +198,7 @@ fn validate_github_webhook_strategy( && github.app_id.is_none() { errors.push(ResolveError::Invalid { - path: "server.integrations.github.app_id".to_string(), + path: "server.integrations.github.app_id".to_string(), reason: "must be set when server.integrations.github.webhooks.strategy is configured" .to_string(), }); @@ -208,7 +208,7 @@ fn validate_github_webhook_strategy( && api_layer.and_then(|api| api.url.as_ref()).is_none() { errors.push(ResolveError::Invalid { - path: "server.api.url".to_string(), + path: "server.api.url".to_string(), reason: "must be set when server.integrations.github.webhooks.strategy = \"server_url\"" .to_string(), @@ -348,20 +348,20 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr ); warn_if_demoted_template("server.integrations.github.slug", github.slug.as_deref()); GithubIntegrationSettings { - enabled: github.enabled.unwrap_or(true), - strategy: github.strategy.unwrap_or_default(), - app_id: github.app_id.clone(), + enabled: github.enabled.unwrap_or(true), + strategy: github.strategy.unwrap_or_default(), + app_id: github.app_id.clone(), client_id: github.client_id.clone(), - slug: github.slug.clone(), - webhooks: github.webhooks.as_ref().map(resolve_github_webhooks), + slug: github.slug.clone(), + webhooks: github.webhooks.as_ref().map(resolve_github_webhooks), } }) .unwrap_or_default(), - slack: layer + slack: layer .and_then(|integrations| integrations.slack.as_ref()) .map_or( SlackIntegrationSettings { - enabled: false, + enabled: false, default_channel: None, }, |slack| { @@ -370,11 +370,18 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr slack.default_channel.as_deref(), ); SlackIntegrationSettings { - enabled: slack.enabled.unwrap_or(true), + enabled: slack.enabled.unwrap_or(true), default_channel: slack.default_channel.clone(), } }, ), + search: layer + .and_then(|integrations| integrations.search.as_ref()) + .map(|search| SearchIntegrationSettings { + provider: search.provider.unwrap_or_default(), + venice_engine: search.venice_engine.unwrap_or_default(), + }) + .unwrap_or_default(), } } diff --git a/lib/foundation/fabro-config/src/tests/resolve_server.rs b/lib/foundation/fabro-config/src/tests/resolve_server.rs index e4eecb353..0e058dd12 100644 --- a/lib/foundation/fabro-config/src/tests/resolve_server.rs +++ b/lib/foundation/fabro-config/src/tests/resolve_server.rs @@ -4,8 +4,8 @@ )] use fabro_types::settings::server::{ - GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod, - ServerListenSettings, ServerNamespace, + GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, SearchProvider, + ServerAuthMethod, ServerListenSettings, ServerNamespace, VeniceSearchEngine, }; use fabro_util::Home; use temp_env::with_var; @@ -127,6 +127,10 @@ fn resolved_server_integrations_disable_slack_when_config_is_absent() { "enabled": false, "default_channel": null, }, + "search": { + "provider": "brave", + "venice_engine": "brave", + }, }) ); } @@ -173,6 +177,43 @@ default_channel = "#releases" ); } +#[test] +fn resolve_search_defaults_to_brave_when_config_is_absent() { + let settings = resolve_server(&parse( + r" +_version = 1 +", + )); + + assert_eq!(settings.integrations.search.provider, SearchProvider::Brave); + assert_eq!( + settings.integrations.search.venice_engine, + VeniceSearchEngine::Brave + ); +} + +#[test] +fn resolve_search_provider_venice_and_google_engine() { + let settings = resolve_server(&parse( + r#" +_version = 1 + +[server.integrations.search] +provider = "venice" +venice_engine = "google" +"#, + )); + + assert_eq!( + settings.integrations.search.provider, + SearchProvider::Venice + ); + assert_eq!( + settings.integrations.search.venice_engine, + VeniceSearchEngine::Google + ); +} + #[test] fn resolve_slack_default_channel_keeps_template_token_literal() { // `server.integrations.slack.default_channel` is a plain literal now: a diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs index bb8c5d078..477f5c26e 100644 --- a/lib/foundation/fabro-static/src/env_vars.rs +++ b/lib/foundation/fabro-static/src/env_vars.rs @@ -47,6 +47,7 @@ impl EnvVars { pub const ANTHROPIC_BASE_URL: &'static str = "ANTHROPIC_BASE_URL"; pub const BEDROCK_API_KEY: &'static str = "BEDROCK_API_KEY"; pub const BRAVE_SEARCH_API_KEY: &'static str = "BRAVE_SEARCH_API_KEY"; + pub const VENICE_API_KEY: &'static str = "VENICE_API_KEY"; pub const CHATGPT_ACCOUNT_ID: &'static str = "CHATGPT_ACCOUNT_ID"; pub const DEEPSEEK_API_KEY: &'static str = "DEEPSEEK_API_KEY"; pub const FIREWORKS_API_KEY: &'static str = "FIREWORKS_API_KEY"; @@ -199,6 +200,7 @@ mod tests { EnvVars::AWS_BEARER_TOKEN_BEDROCK, EnvVars::BEDROCK_API_KEY, EnvVars::BRAVE_SEARCH_API_KEY, + EnvVars::VENICE_API_KEY, EnvVars::CHATGPT_ACCOUNT_ID, EnvVars::DEEPSEEK_API_KEY, EnvVars::FIREWORKS_API_KEY, diff --git a/lib/foundation/fabro-static/src/secret_registry.rs b/lib/foundation/fabro-static/src/secret_registry.rs index 22cf26177..4fb715b6f 100644 --- a/lib/foundation/fabro-static/src/secret_registry.rs +++ b/lib/foundation/fabro-static/src/secret_registry.rs @@ -19,6 +19,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[ EnvVars::AWS_BEARER_TOKEN_BEDROCK, EnvVars::BEDROCK_API_KEY, EnvVars::BRAVE_SEARCH_API_KEY, + EnvVars::VENICE_API_KEY, EnvVars::DEEPSEEK_API_KEY, EnvVars::FABRO_SLACK_APP_TOKEN, EnvVars::FABRO_SLACK_BOT_TOKEN, @@ -91,6 +92,7 @@ mod tests { EnvVars::FABRO_SLACK_BOT_TOKEN, EnvVars::DAYTONA_API_KEY, EnvVars::BRAVE_SEARCH_API_KEY, + EnvVars::VENICE_API_KEY, EnvVars::ANTHROPIC_API_KEY, EnvVars::AWS_BEARER_TOKEN_BEDROCK, EnvVars::BEDROCK_API_KEY, diff --git a/lib/foundation/fabro-types/src/settings/mod.rs b/lib/foundation/fabro-types/src/settings/mod.rs index 5bf91e4d9..d7694c6fa 100644 --- a/lib/foundation/fabro-types/src/settings/mod.rs +++ b/lib/foundation/fabro-types/src/settings/mod.rs @@ -46,10 +46,11 @@ pub use run::{ }; pub use server::{ GithubIntegrationSettings, IntegrationWebhooksSettings, LogDestination, ObjectStoreSettings, - ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, - ServerAuthSettings, ServerIntegrationsSettings, ServerListenSettings, ServerLoggingSettings, - ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, - ServerWebSettings, SlackIntegrationSettings, + SearchIntegrationSettings, SearchProvider, ServerApiSettings, ServerArtifactsSettings, + ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings, + ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, + ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, + VeniceSearchEngine, }; pub use size::{ParseSizeError, Size}; pub use workflow::WorkflowNamespace; diff --git a/lib/foundation/fabro-types/src/settings/server.rs b/lib/foundation/fabro-types/src/settings/server.rs index ef8da68b8..044c1776b 100644 --- a/lib/foundation/fabro-types/src/settings/server.rs +++ b/lib/foundation/fabro-types/src/settings/server.rs @@ -22,16 +22,16 @@ use super::duration::Duration; /// (tests). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerNamespace { - pub listen: ServerListenSettings, - pub api: ServerApiSettings, - pub web: ServerWebSettings, - pub auth: ServerAuthSettings, - pub sandbox: ServerSandboxSettings, - pub storage: ServerStorageSettings, - pub artifacts: ServerArtifactsSettings, - pub slatedb: ServerSlateDbSettings, - pub scheduler: ServerSchedulerSettings, - pub logging: ServerLoggingSettings, + pub listen: ServerListenSettings, + pub api: ServerApiSettings, + pub web: ServerWebSettings, + pub auth: ServerAuthSettings, + pub sandbox: ServerSandboxSettings, + pub storage: ServerStorageSettings, + pub artifacts: ServerArtifactsSettings, + pub slatedb: ServerSlateDbSettings, + pub scheduler: ServerSchedulerSettings, + pub logging: ServerLoggingSettings, pub integrations: ServerIntegrationsSettings, } @@ -43,16 +43,16 @@ impl ServerNamespace { #[must_use] pub fn test_default() -> Self { Self { - listen: ServerListenSettings::default(), - api: ServerApiSettings::default(), - web: ServerWebSettings::default(), - auth: ServerAuthSettings::default(), - sandbox: ServerSandboxSettings::default(), - storage: ServerStorageSettings::default(), - artifacts: ServerArtifactsSettings::default(), - slatedb: ServerSlateDbSettings::default(), - scheduler: ServerSchedulerSettings::default(), - logging: ServerLoggingSettings::default(), + listen: ServerListenSettings::default(), + api: ServerApiSettings::default(), + web: ServerWebSettings::default(), + auth: ServerAuthSettings::default(), + sandbox: ServerSandboxSettings::default(), + storage: ServerStorageSettings::default(), + artifacts: ServerArtifactsSettings::default(), + slatedb: ServerSlateDbSettings::default(), + scheduler: ServerSchedulerSettings::default(), + logging: ServerLoggingSettings::default(), integrations: ServerIntegrationsSettings::default(), } } @@ -89,13 +89,13 @@ pub struct ServerApiSettings { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerWebSettings { pub enabled: bool, - pub url: String, + pub url: String, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerAuthSettings { pub methods: Vec, - pub github: ServerAuthGithubSettings, + pub github: ServerAuthGithubSettings, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -117,8 +117,8 @@ pub struct ServerSandboxSettings { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerSandboxProvidersSettings { - pub local: ServerSandboxProviderSettings, - pub docker: ServerSandboxProviderSettings, + pub local: ServerSandboxProviderSettings, + pub docker: ServerSandboxProviderSettings, pub daytona: ServerSandboxProviderSettings, } @@ -158,28 +158,28 @@ pub struct ServerStorageSettings { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerArtifactsSettings { pub prefix: String, - pub store: ObjectStoreSettings, + pub store: ObjectStoreSettings, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerSlateDbSettings { - pub prefix: String, - pub store: ObjectStoreSettings, + pub prefix: String, + pub store: ObjectStoreSettings, #[serde( serialize_with = "serialize_std_duration", deserialize_with = "deserialize_std_duration" )] pub flush_interval: StdDuration, - pub disk_cache: bool, + pub disk_cache: bool, } impl Default for ServerSlateDbSettings { fn default() -> Self { Self { - prefix: String::new(), - store: ObjectStoreSettings::default(), + prefix: String::new(), + store: ObjectStoreSettings::default(), flush_interval: StdDuration::ZERO, - disk_cache: false, + disk_cache: false, } } } @@ -191,9 +191,9 @@ pub enum ObjectStoreSettings { root: String, }, S3 { - bucket: String, - region: String, - endpoint: Option, + bucket: String, + region: String, + endpoint: Option, path_style: bool, }, } @@ -233,7 +233,7 @@ pub enum LogDestination { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerLoggingSettings { - pub level: Option, + pub level: Option, #[serde(default)] pub destination: LogDestination, } @@ -241,34 +241,104 @@ pub struct ServerLoggingSettings { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerIntegrationsSettings { pub github: GithubIntegrationSettings, - pub slack: SlackIntegrationSettings, + pub slack: SlackIntegrationSettings, + pub search: SearchIntegrationSettings, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GithubIntegrationSettings { - pub enabled: bool, - pub strategy: GithubIntegrationStrategy, - pub app_id: Option, + pub enabled: bool, + pub strategy: GithubIntegrationStrategy, + pub app_id: Option, pub client_id: Option, - pub slug: Option, - pub webhooks: Option, + pub slug: Option, + pub webhooks: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SlackIntegrationSettings { - pub enabled: bool, + pub enabled: bool, pub default_channel: Option, } impl Default for SlackIntegrationSettings { fn default() -> Self { Self { - enabled: true, + enabled: true, default_channel: None, } } } +/// Backend used by the built-in `web_search` tool. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + strum::EnumString, + strum::IntoStaticStr, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum SearchProvider { + #[default] + Brave, + Venice, +} + +impl SearchProvider { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Brave => "brave", + Self::Venice => "venice", + } + } +} + +/// Venice `/augment/search` engine. `brave` is Firecrawl ZDR; `google` is an +/// anonymized proxy. Direct Brave Search remains [`SearchProvider::Brave`]. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + strum::EnumString, + strum::IntoStaticStr, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum VeniceSearchEngine { + #[default] + Brave, + Google, +} + +impl VeniceSearchEngine { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Brave => "brave", + Self::Google => "google", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SearchIntegrationSettings { + pub provider: SearchProvider, + pub venice_engine: VeniceSearchEngine, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct IntegrationWebhooksSettings { pub strategy: Option, diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 4f8d5f871..42ca0bf8f 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -441,6 +441,8 @@ models/saved-query.ts models/secret-list-response.ts models/secret-metadata.ts models/secret-type.ts +models/search-integration-settings.ts +models/search-provider.ts models/server-api-settings.ts models/server-artifacts-settings.ts models/server-auth-github-settings.ts @@ -532,6 +534,7 @@ models/user-response.ts models/validate-response.ts models/variable-list-response.ts models/variable.ts +models/venice-search-engine.ts models/vnc-preview-response.ts models/webhook-strategy.ts models/workflow-detail-response.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 04df237be..12d3fa017 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -410,6 +410,8 @@ export * from './saved-query'; export * from './secret-list-response'; export * from './secret-metadata'; export * from './secret-type'; +export * from './search-integration-settings'; +export * from './search-provider'; export * from './server-api-settings'; export * from './server-artifacts-settings'; export * from './server-auth-github-settings'; @@ -501,6 +503,7 @@ export * from './user-response'; export * from './validate-response'; export * from './variable'; export * from './variable-list-response'; +export * from './venice-search-engine'; export * from './vnc-preview-response'; export * from './webhook-strategy'; export * from './workflow-detail-response'; diff --git a/lib/packages/fabro-api-client/src/models/search-integration-settings.ts b/lib/packages/fabro-api-client/src/models/search-integration-settings.ts new file mode 100644 index 000000000..65a6902fe --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/search-integration-settings.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SearchProvider } from './search-provider'; +// May contain unused imports in some cases +// @ts-ignore +import type { VeniceSearchEngine } from './venice-search-engine'; + +export interface SearchIntegrationSettings { + 'provider': SearchProvider; + 'venice_engine': VeniceSearchEngine; +} diff --git a/lib/packages/fabro-api-client/src/models/search-provider.ts b/lib/packages/fabro-api-client/src/models/search-provider.ts new file mode 100644 index 000000000..552ccbe54 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/search-provider.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + + +export const SearchProvider = { + BRAVE: 'brave', + VENICE: 'venice' +} as const; + +export type SearchProvider = typeof SearchProvider[keyof typeof SearchProvider]; diff --git a/lib/packages/fabro-api-client/src/models/server-integrations-settings.ts b/lib/packages/fabro-api-client/src/models/server-integrations-settings.ts index 88b8fdd8d..3525f9c5a 100644 --- a/lib/packages/fabro-api-client/src/models/server-integrations-settings.ts +++ b/lib/packages/fabro-api-client/src/models/server-integrations-settings.ts @@ -18,9 +18,13 @@ import type { GithubIntegrationSettings } from './github-integration-settings'; // May contain unused imports in some cases // @ts-ignore +import type { SearchIntegrationSettings } from './search-integration-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { SlackIntegrationSettings } from './slack-integration-settings'; export interface ServerIntegrationsSettings { 'github': GithubIntegrationSettings; 'slack': SlackIntegrationSettings; + 'search': SearchIntegrationSettings; } diff --git a/lib/packages/fabro-api-client/src/models/venice-search-engine.ts b/lib/packages/fabro-api-client/src/models/venice-search-engine.ts new file mode 100644 index 000000000..29e7494cf --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/venice-search-engine.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + + +export const VeniceSearchEngine = { + BRAVE: 'brave', + GOOGLE: 'google' +} as const; + +export type VeniceSearchEngine = typeof VeniceSearchEngine[keyof typeof VeniceSearchEngine];