Merge pull request #776 from jesseproudman/feat/venice-search-provider

Add Venice as a web_search backend
This commit is contained in:
Bryan Helmkamp 2026-08-21 21:11:42 -04:00 committed by GitHub
commit 58f00c85c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 851 additions and 197 deletions

1
Cargo.lock generated
View file

@ -2300,6 +2300,7 @@ dependencies = [
"futures",
"glob",
"htmd",
"httpmock",
"insta",
"jsonschema",
"libc",

View file

@ -39,6 +39,7 @@ the vault:
- `FABRO_SLACK_BOT_TOKEN`
- `DAYTONA_API_KEY`
- `BRAVE_SEARCH_API_KEY`
- `VENICE_API_KEY`
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.

View file

@ -41,7 +41,7 @@ Add variables in **Service → Variables** as needed. The [Server Configuration]
| `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials |
Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
No `.env` file is auto-loaded inside the container; bootstrap variables come from Railway's environment.

View file

@ -38,7 +38,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
### Secrets
- **Keep API keys out of sandboxes.** The local sandbox strips environment variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`, but Docker and Daytona sandboxes provide stronger isolation — only explicitly configured variables are passed through.
- **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control.
- **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, Venice Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control.
- **Rotate the session secret.** The `SESSION_SECRET` environment variable encrypts web app sessions. Rotate it periodically and use a strong random value.
### Execution

View file

@ -108,7 +108,7 @@ Generate one with `openssl rand -hex 32`.
| `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials |
Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
Optional:

View file

@ -360,7 +360,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 +402,16 @@ 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-...
```
The built-in [`web_search`](/agents/tools#web_search) tool selects its backend from these credentials. It uses direct Brave Search when `BRAVE_SEARCH_API_KEY` exists. Otherwise it uses Venice Search when `VENICE_API_KEY` exists. When neither exists, the tool is not registered.
| 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; the preferred `web_search` backend when present |
| `VENICE_API_KEY` | Venice API key; used by the Venice LLM provider and by `web_search` when no Brave key exists |
### Server authentication

View file

@ -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.

View file

@ -145,7 +145,7 @@ The system prompt varies by LLM provider. Each provider has its own identity tex
<Accordion title="Example system prompt (Anthropic provider)">
This is the full system prompt sent to Claude as the LLM system message. The `<environment>` block is filled in at runtime.
Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present only when a [Brave Search API key](/integrations/brave-search) is configured; without one, both the tool and its guidance are omitted.
Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present when a [Brave Search API key](/integrations/brave-search) or [Venice API key](/integrations/venice-search) is configured. Without either key, both the tool and its guidance are omitted.
```
You are Claude, an AI coding assistant made by Anthropic. You help users with
@ -232,7 +232,7 @@ first). Use this for finding files rather than using shell find or ls
commands.
## web_search
Search the web using Brave Search. Returns titles, URLs, and descriptions.
Search the web. Returns titles, URLs, and descriptions.
## web_fetch
Fetch content from a URL and optionally summarize it. Pass a prompt to

View file

@ -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,18 @@ 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 or Venice Search. Fabro selects the backend automatically from the available credentials.
| 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) |
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 <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.
Fabro uses direct [Brave Search](/integrations/brave-search) when `BRAVE_SEARCH_API_KEY` is present. Otherwise it uses [Venice Search](/integrations/venice-search) when `VENICE_API_KEY` is present. If both credentials are present, Brave wins. Venice always uses its Brave search engine.
Runs read both keys from the server vault. Workers start from a cleared environment, so exporting a key in the server's shell has no effect. The standalone agent CLI reads the keys from the invoking shell instead.
The tool is registered when either credential is available. Once Fabro selects a backend, a failed call returns an error; it does not retry through the other backend. Results contain numbered titles, URLs, and descriptions. Venice includes `date` on a fourth line when present.
### web_fetch

View file

@ -1,5 +1,5 @@
---
title: "Additional GitHub repositories"
title: "Additional GitHub repositories and Venice search"
date: "2026-08-21"
---
@ -18,3 +18,9 @@ The run origin stays implicit, and Fabro mints one installation token scoped to
Every repository must share one owner and be reachable by the origin's GitHub App installation. Preflight resolves each repository's installation, mints the scoped token once, and probes every repository with `git ls-remote`, naming the exact repository when something is not accessible; run initialization enforces the same checks. A declared-but-inaccessible repository fails the run before its first stage.
Declaring additional repositories requires `contents = "read"` or `contents = "write"`. With `contents = "write"`, any stage can push to any declared repository — declare the smallest set and weakest permissions that work. See [Additional repositories](/integrations/github#additional-repositories) for details, including layering rules and `GH_TOKEN` precedence.
## Venice search backend for `web_search`
The built-in `web_search` tool now supports Venice as an automatic alternative to direct Brave Search. Fabro uses `BRAVE_SEARCH_API_KEY` when present. Otherwise it uses `VENICE_API_KEY` with Venice's Brave search engine. If neither key is present, the tool is not registered. Failed calls do not fall back between providers.
See [Venice Search](/integrations/venice-search) and [Brave Search](/integrations/brave-search).

View file

@ -166,7 +166,7 @@ Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatib
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values.
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional Brave-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Provider `billing_policy` defaults from `adapter` and controls usage-cost estimation. Use `openai`, `anthropic`, `gemini`, or `none`. Model rows may override it for models whose billing family differs from their provider's — for example, Claude models served through OpenRouter set `billing_policy = "anthropic"` so cache reads and writes price correctly.

View file

@ -103,7 +103,8 @@
"integrations/modal",
"integrations/fireworks",
"integrations/slack",
"integrations/brave-search"
"integrations/brave-search",
"integrations/venice-search"
]
},
{

View file

@ -3,7 +3,9 @@ title: "Brave Search"
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.
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.
Fabro selects the backend from the credentials in its vault. Direct Brave Search is preferred whenever `BRAVE_SEARCH_API_KEY` is present. When that key is absent, Fabro can use [Venice Search](/integrations/venice-search) with `VENICE_API_KEY` instead.
## Setup
@ -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 Brave key is missing but a Venice key exists, Fabro checks Venice instead. If neither key exists, 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.
The Fabro server reads this key from the vault only. It does not read `BRAVE_SEARCH_API_KEY` from process env or `server.env`.
@ -39,7 +41,7 @@ Agents call the `web_search` tool with a query string. Fabro sends the query to
The Rust book
```
If `BRAVE_SEARCH_API_KEY` is not configured in the vault, the tool returns an error explaining that the key is required. The agent can then fall back to other approaches.
If `BRAVE_SEARCH_API_KEY` is not configured, Fabro uses Venice when `VENICE_API_KEY` is available. If neither key is configured, the tool is not registered.
See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details.

View file

@ -0,0 +1,79 @@
---
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. Fabro uses [Venice Search](https://docs.venice.ai/api-reference/endpoint/augment/search) automatically when `VENICE_API_KEY` is available and a direct [Brave Search](/integrations/brave-search) key is not.
Venice Search reuses the same `VENICE_API_KEY` as 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-...
```
Fabro prefers direct Brave Search whenever `BRAVE_SEARCH_API_KEY` is also present. To select Venice, leave that key unset or remove it:
```bash
fabro secret rm BRAVE_SEARCH_API_KEY
```
2. Verify the key is working:
```bash
fabro doctor
```
The doctor output should show **Web Search** as `venice: configured and reachable`. If neither Venice nor Brave is configured, 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` with the Brave search engine 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 absent but `BRAVE_SEARCH_API_KEY` exists, Fabro uses direct Brave Search. If neither key exists, the tool is not registered. After selecting Venice, a failed call returns an error; Fabro does not retry through direct Brave Search.
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 <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
<Columns cols={2}>
<Card title="Tools" icon="wrench" href="/agents/tools#web_search">
Full `web_search` tool reference — parameters, output format, and error handling.
</Card>
<Card title="Brave Search" icon="globe" href="/integrations/brave-search">
Direct Brave Search backend, preferred whenever its key is configured.
</Card>
</Columns>

View file

@ -672,7 +672,7 @@ pub(crate) async fn run_diagnostics(
{ "name": "GitHub App", "status": "pass", "summary": "demo configured", "details": [], "remediation": null },
{ "name": "Docker Sandbox", "status": "pass", "summary": "disabled", "details": [{ "text": "server.sandbox.providers.docker.enabled = false", "warn": false }], "remediation": null },
{ "name": "Cloud Sandbox", "status": "warning", "summary": "not configured", "details": [], "remediation": "Set DAYTONA_API_KEY to enable cloud sandbox execution" },
{ "name": "Brave Search", "status": "warning", "summary": "not configured", "details": [], "remediation": "Set BRAVE_SEARCH_API_KEY to enable web search" }
{ "name": "Web Search", "status": "warning", "summary": "optional, not configured", "details": [], "remediation": "Set BRAVE_SEARCH_API_KEY or VENICE_API_KEY to enable web search" }
]
},
{

View file

@ -5,6 +5,7 @@ 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};
@ -19,6 +20,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;
@ -92,12 +94,12 @@ 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),
);
@ -106,7 +108,7 @@ pub async fn run_all(state: &AppState) -> DiagnosticsReport {
sections: vec![
CheckSection {
title: "Credentials".to_string(),
checks: vec![llm, github, docker_sandbox, cloud_sandbox, brave],
checks: vec![llm, github, docker_sandbox, cloud_sandbox, web_search],
},
CheckSection {
title: "Configuration".to_string(),
@ -755,25 +757,45 @@ fn check_storage_dir_path(path: &std::path::Path) -> CheckResult {
}
}
async fn check_brave_search(state: &AppState) -> CheckResult {
let api_key =
match diagnostic_secret(state, "Web Search (Brave)", EnvVars::BRAVE_SEARCH_API_KEY).await {
async fn check_web_search(state: &AppState) -> CheckResult {
let brave_api_key = match diagnostic_secret(
state,
WEB_SEARCH_CHECK_NAME,
EnvVars::BRAVE_SEARCH_API_KEY,
)
.await
{
Ok(value) => value,
Err(result) => return result,
};
if let Some(api_key) = brave_api_key {
return check_brave_search(api_key).await;
}
let venice_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 (Brave)".to_string(),
status: CheckStatus::Warning,
summary: "optional, not configured".to_string(),
details: Vec::new(),
remediation: Some(
"Run `fabro secret set BRAVE_SEARCH_API_KEY` to enable web search".to_string(),
),
};
};
if let Some(api_key) = venice_api_key {
return check_venice_search(api_key).await;
}
let http = match http_client_or_check("Web Search (Brave)", CheckStatus::Warning) {
CheckResult {
name: WEB_SEARCH_CHECK_NAME.to_string(),
status: CheckStatus::Warning,
summary: "optional, not configured".to_string(),
details: Vec::new(),
remediation: Some(
"Run `fabro secret set BRAVE_SEARCH_API_KEY` or `fabro secret set VENICE_API_KEY` to enable web search".to_string(),
),
}
}
const WEB_SEARCH_CHECK_NAME: &str = "Web Search";
async fn check_brave_search(api_key: String) -> CheckResult {
let http = match http_client_or_check(WEB_SEARCH_CHECK_NAME, CheckStatus::Warning) {
Ok(http) => http,
Err(result) => return result,
};
@ -787,36 +809,67 @@ async fn check_brave_search(state: &AppState) -> CheckResult {
})
.await;
match_web_search_probe(probe, "brave", "BRAVE_SEARCH_API_KEY")
}
async fn check_venice_search(api_key: String) -> CheckResult {
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,
"search_provider": "brave",
}))
.send()
.await
.map_err(anyhow::Error::new)
})
.await;
match_web_search_probe(probe, "venice", "VENICE_API_KEY")
}
fn match_web_search_probe(
probe: Result<anyhow::Result<Response>, Elapsed>,
provider: &str,
secret_name: &str,
) -> CheckResult {
match probe {
Ok(Ok(response)) if response.status().is_success() => CheckResult {
name: "Web Search (Brave)".to_string(),
name: WEB_SEARCH_CHECK_NAME.to_string(),
status: CheckStatus::Pass,
summary: "configured and reachable".to_string(),
summary: format!("{provider}: configured and reachable"),
details: Vec::new(),
remediation: None,
},
Ok(Ok(response)) => CheckResult {
name: "Web Search (Brave)".to_string(),
name: WEB_SEARCH_CHECK_NAME.to_string(),
status: CheckStatus::Warning,
summary: format!("HTTP {}", response.status()),
summary: format!("{provider}: HTTP {}", response.status()),
details: Vec::new(),
remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()),
remediation: Some(format!("Check {secret_name} and network connectivity")),
},
Ok(Err(err)) => CheckResult {
name: "Web Search (Brave)".to_string(),
name: WEB_SEARCH_CHECK_NAME.to_string(),
status: CheckStatus::Warning,
summary: "connectivity error".to_string(),
summary: format!("{provider}: connectivity error"),
details: vec![CheckDetail::new(format!("{err:#}"))],
remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()),
remediation: Some(format!("Check {secret_name} and network connectivity")),
},
Err(_) => CheckResult {
name: "Web Search (Brave)".to_string(),
name: WEB_SEARCH_CHECK_NAME.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()),
summary: format!("{provider}: timeout"),
details: vec![CheckDetail::new(format!(
"Web Search ({provider}) probe timed out"
))],
remediation: Some(format!("Check {secret_name} and network connectivity")),
},
}
}
@ -1195,23 +1248,76 @@ enabled = false
}
#[tokio::test]
async fn check_brave_search_ignores_env_backed_api_key() {
async fn check_web_search_ignores_env_backed_brave_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.remediation.as_deref(),
Some("Run `fabro secret set BRAVE_SEARCH_API_KEY` to enable web search")
Some(
"Run `fabro secret set BRAVE_SEARCH_API_KEY` or `fabro secret set VENICE_API_KEY` to enable web search"
)
);
}
#[tokio::test]
async fn check_web_search_ignores_env_backed_venice_api_key() {
let state = TestAppStateBuilder::new()
.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, "optional, not configured");
assert_eq!(
result.remediation.as_deref(),
Some(
"Run `fabro secret set BRAVE_SEARCH_API_KEY` or `fabro secret set VENICE_API_KEY` to enable web search"
)
);
}
#[tokio::test]
async fn check_web_search_prefers_brave_when_both_vault_keys_exist() {
let state = TestAppStateBuilder::new()
.vault_entries([
(EnvVars::BRAVE_SEARCH_API_KEY, "invalid\n"),
(EnvVars::VENICE_API_KEY, "invalid\n"),
])
.build();
let result = check_web_search(&state).await;
assert_eq!(result.name, "Web Search");
assert_eq!(result.status, CheckStatus::Warning);
assert_eq!(result.summary, "brave: connectivity error");
}
#[tokio::test]
async fn check_web_search_uses_venice_when_brave_vault_key_is_absent() {
let state = TestAppStateBuilder::new()
.vault_entries([(EnvVars::VENICE_API_KEY, "invalid\n")])
.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: connectivity error");
}
#[tokio::test]
async fn check_crypto_requires_github_client_secret_from_vault() {
let settings = fabro_config::ServerSettingsBuilder::from_toml(

View file

@ -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

View file

@ -39,11 +39,12 @@ use crate::{
#[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(),
}
}

View file

@ -103,6 +103,7 @@ impl ToolHookCallback for ToolApprovalAdapter {
#[derive(Clone, Default, PartialEq, Eq)]
pub struct ToolSecrets {
pub brave_search_api_key: Option<String>,
pub venice_api_key: Option<String>,
}
impl std::fmt::Debug for ToolSecrets {
@ -112,6 +113,7 @@ 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())
.finish()
}
}
@ -350,12 +352,15 @@ 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()),
};
let debug = format!("{secrets:?}");
assert!(debug.contains("brave_search_configured: true"));
assert!(debug.contains("venice_search_configured: true"));
assert!(!debug.contains("brave-secret-value"));
assert!(!debug.contains("venice-secret-value"));
}
#[test]

View file

@ -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::{

View file

@ -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(

View file

@ -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,
@ -114,8 +115,8 @@ pub(crate) fn make_bash_tool(options: &NativeToolOptions) -> RegisteredTool {
}
#[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 mut tool = web_search::make_web_search_tool(backend);
tool.definition = definition(
NativeTool::WebSearch,
"Search the web when current external information is needed. Returns result titles, URLs, \
@ -536,9 +537,16 @@ mod tests {
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_search_tool(SearchBackend::brave("key".to_string())),
&["query"],
&["query"],
);
assert_schema(
&make_web_search_tool(SearchBackend::venice("key".to_string())),
&["query"],
&["query"],
);
let todo_runtime = Arc::new(TodoRuntime::new());
assert_schema(

View file

@ -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.

View file

@ -70,7 +70,7 @@ Search file contents with regex. Use glob_filter to narrow results.
Find files by name pattern.
{% if inputs.has_web_search %}## web_search
Search the web using Brave Search. Returns titles, URLs, and descriptions.
Search the web. Returns titles, URLs, and descriptions.
{% endif %}## web_fetch
Fetch content from a URL and optionally summarize it. Pass a prompt to extract specific information instead of returning the full page. URLs must start with http:// or https://.

View file

@ -78,7 +78,7 @@ Search file contents with regex. Use glob_filter to narrow results.
Find files by name pattern.
## web_search
Search the web using Brave Search. Returns titles, URLs, and descriptions.
Search the web. Returns titles, URLs, and descriptions.
## web_fetch
Fetch content from a URL and optionally summarize it. Pass a prompt to extract specific information instead of returning the full page. URLs must start with http:// or https://.

View file

@ -68,7 +68,7 @@ Search file contents with regex. Use glob_filter to narrow results.
Find files by name pattern.
## web_search
Search the web using Brave Search. Returns titles, URLs, and descriptions.
Search the web. Returns titles, URLs, and descriptions.
## web_fetch
Fetch content from a URL and optionally summarize it. Pass a prompt to extract specific information instead of returning the full page. URLs must start with http:// or https://.

View file

@ -14,6 +14,7 @@ use crate::config::NativeToolOptions;
use crate::sandbox::{ExecStreamingResult, GrepOptions};
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use crate::types::AgentEvent;
use crate::web_search::{SearchBackend, make_web_search_tool};
const MAX_WEB_FETCH_BYTES: usize = 100 * 1024;
const MAX_READ_MANY_FILES_CONCURRENCY: usize = 8;
@ -49,14 +50,14 @@ fn html_to_markdown(text: &str) -> String {
converter.convert(text).unwrap_or_else(|_| text.to_string())
}
/// Name of the Brave-backed web search tool. Profiles look this up in their own
/// registry to decide whether to advertise web search in the system prompt, so
/// availability and prompt guidance cannot drift apart.
/// Name of the credential-backed web search tool. Profiles look this up in
/// their own registry to decide whether to advertise web search in the system
/// prompt, so availability and prompt guidance cannot drift apart.
pub const WEB_SEARCH_TOOL_NAME: &str = "web_search";
/// Registers the core tools shared by all provider profiles: `read_file`,
/// `write_file`, `shell`, `grep`, `glob`, and `web_fetch`. `web_search` is
/// included when a Brave Search API key is configured.
/// included when a Brave or Venice Search API key is configured.
///
/// The shell tool captures its default and max timeouts from `options`.
pub fn register_core_tools(
@ -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 a search provider credential 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));
}
}
@ -610,102 +611,6 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool {
}
}
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<fabro_http::HttpClient> = 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,
}
}
#[must_use]
pub(crate) fn make_web_fetch_tool(summarizer: Option<WebFetchSummarizer>) -> RegisteredTool {
RegisteredTool {
@ -827,6 +732,7 @@ mod tests {
use crate::tool_registry::ToolContext;
use crate::truncation;
use crate::types::SessionEvent;
use crate::web_search::make_web_search_tool_with_api_key;
#[test]
fn core_tool_descriptions_include_actionable_guidance() {
@ -1818,6 +1724,7 @@ mod tests {
let options = NativeToolOptions {
secrets: ToolSecrets {
brave_search_api_key: Some("fake-key".to_string()),
..ToolSecrets::default()
},
..NativeToolOptions::default()
};
@ -1846,29 +1753,6 @@ 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);

View file

@ -0,0 +1,545 @@
//! Built-in `web_search` backends.
//!
//! Agents always call the same tool. Brave is preferred when its credential
//! is present; otherwise Venice is used when its credential is present.
use std::fmt::Write;
use std::sync::OnceLock;
use std::time::Duration;
use fabro_llm::types::ToolDefinition;
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,
search_url: String,
},
}
impl SearchBackend {
#[must_use]
pub(crate) fn from_secrets(secrets: &ToolSecrets) -> Option<Self> {
match (
secrets.brave_search_api_key.as_ref(),
secrets.venice_api_key.as_ref(),
) {
(Some(api_key), _) => Some(Self::brave(api_key.clone())),
(None, Some(api_key)) => Some(Self::venice(api_key.clone())),
(None, None) => None,
}
}
#[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) -> Self {
Self::Venice {
api_key,
search_url: VENICE_SEARCH_URL.to_string(),
}
}
async fn search(&self, query: &str, max_results: u64) -> Result<String, String> {
match self {
Self::Brave {
api_key,
search_url,
} => search_brave(api_key, search_url, query, max_results).await,
Self::Venice {
api_key,
search_url,
} => {
if query.chars().count() > VENICE_QUERY_MAX_CHARS {
return Err(format!(
"query exceeds Venice Search maximum of {VENICE_QUERY_MAX_CHARS} characters"
));
}
search_venice(api_key, search_url, query, max_results).await
}
}
}
}
fn search_http_client() -> fabro_http::HttpClient {
static CLIENT: OnceLock<fabro_http::HttpClient> = 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<String, String> {
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,
) -> Result<String, String> {
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": "brave",
}))
.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<String> {
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<String>,
}
fn format_hits(hits: Option<Vec<SearchHit>>) -> 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<String> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
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 {
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": {
"query": {"type": "string", "description": "Search query"},
"max_results": {"type": "integer", "description": "Maximum number of results (default 5, max 20)"}
},
"required": ["query"]
}),
},
executor: std::sync::Arc::new(move |args, _ctx| {
let backend = backend.clone();
Box::pin(async move {
let query = required_str(&args, "query")?;
backend.search(query, max_results_arg(&args)).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))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
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>) -> ToolSecrets {
ToolSecrets {
brave_search_api_key: brave.map(str::to_string),
venice_api_key: venice.map(str::to_string),
}
}
async fn execute(tool: &RegisteredTool, args: serde_json::Value) -> Result<String, String> {
let env: Arc<dyn Sandbox> = 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_prefers_brave_when_both_keys_are_present() {
let backend = SearchBackend::from_secrets(&secrets(Some("brave-key"), Some("venice-key")));
assert!(matches!(backend, Some(SearchBackend::Brave { .. })));
}
#[test]
fn from_secrets_registers_brave_when_only_brave_key_is_present() {
let backend = SearchBackend::from_secrets(&secrets(Some("brave-key"), None));
assert!(matches!(backend, Some(SearchBackend::Brave { .. })));
}
#[test]
fn from_secrets_registers_venice_when_only_venice_key_is_present() {
let backend = SearchBackend::from_secrets(&secrets(None, Some("venice-key")));
assert!(matches!(backend, Some(SearchBackend::Venice { .. })));
}
#[test]
fn from_secrets_omits_search_when_both_keys_are_missing() {
assert!(SearchBackend::from_secrets(&secrets(None, None)).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 brave_and_venice_use_the_same_tool_schema() {
let brave = make_web_search_tool(SearchBackend::brave("key".into()));
let venice = make_web_search_tool(SearchBackend::venice("key".into()));
assert_eq!(brave.definition.parameters, venice.definition.parameters);
}
#[tokio::test]
async fn venice_search_posts_augment_search_with_brave_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": "brave"
}));
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());
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
}),
)
.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());
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());
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"));
}
}

View file

@ -230,6 +230,7 @@ macro_rules! web_search_provider_test {
"BRAVE_SEARCH_API_KEY must be set for web-search tests",
),
),
..ToolSecrets::default()
}
);
};

View file

@ -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();

View file

@ -312,13 +312,10 @@ async fn build_registry(
}
async fn tool_secrets_from_configured_sources(vault: &Arc<AsyncRwLock<Vault>>) -> 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),
}
}

View file

@ -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,

View file

@ -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,