use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{
CODE_SEARCH_MAX_MAX_TOKENS, CodeSearchArgs, RecencyFilter, WEB_SEARCH_MAX_RESULTS,
WebSearchArgs,
},
contract::{metadata_key as meta, tool_name},
};
use crate::{agent::cancellation::AgentCancellation, output::redact_sensitive_text};
use chrono::{Duration as ChronoDuration, Utc};
use reqwest::{StatusCode, blocking::Client};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{io::Read, time::Duration};
const EXA_BASE_URL: &str = "https://api.exa.ai";
const EXA_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const EXA_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
#[cfg(test)]
const EXA_TEST_CONNECT_TIMEOUT: Duration = Duration::from_millis(100);
#[cfg(test)]
const EXA_TEST_REQUEST_TIMEOUT: Duration = Duration::from_millis(250);
const EXA_ERROR_BODY_MAX_BYTES: u64 = 2_000;
const EXA_RESPONSE_MAX_BYTES: usize = 2 * 1024 * 1024;
const TOOL_OUTPUT_MAX_BYTES: usize = 48 * 1024;
const WEB_INLINE_SOURCE_MAX_CHARS: usize = 2_000;
const WEB_INLINE_QUERY_MAX_CHARS: usize = 8_000;
const CODE_SEARCH_RESULTS: u64 = 5;
const TRUNCATION_MARKER: &str = "\n[truncated]";
#[derive(Debug, Clone)]
pub(super) struct ExaClient {
client: Client,
base_url: String,
api_key: String,
}
#[derive(Debug, Clone, Serialize)]
struct ExaSearchRequest {
query: String,
#[serde(rename = "type")]
search_type: &'static str,
#[serde(rename = "numResults")]
num_results: u64,
#[serde(rename = "includeDomains", skip_serializing_if = "Vec::is_empty")]
include_domains: Vec<String>,
#[serde(rename = "excludeDomains", skip_serializing_if = "Vec::is_empty")]
exclude_domains: Vec<String>,
#[serde(rename = "startPublishedDate", skip_serializing_if = "Option::is_none")]
start_published_date: Option<String>,
contents: ExaContents,
}
#[derive(Debug, Clone, Serialize)]
struct ExaContents {
text: ExaTextRequest,
highlights: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
enum ExaTextRequest {
Enabled(bool),
Limited {
#[serde(rename = "maxCharacters")]
max_characters: usize,
},
}
#[derive(Debug, Clone, Deserialize)]
pub(super) struct ExaSearchResponse {
results: Vec<ExaResult>,
}
#[derive(Debug, Clone, Deserialize)]
pub(super) struct ExaResult {
title: Option<String>,
url: Option<String>,
#[serde(rename = "publishedDate")]
published_date: Option<String>,
author: Option<String>,
text: Option<String>,
highlights: Option<Vec<String>>,
#[serde(rename = "highlightScores")]
_highlight_scores: Option<Vec<f64>>,
}
#[derive(Debug, Clone)]
struct ExaToolError {
message: String,
timeout: bool,
rate_limited: bool,
}
impl ExaToolError {
fn new(message: impl Into<String>) -> Self {
Self {
message: redact_sensitive_text(&message.into()),
timeout: false,
rate_limited: false,
}
}
fn timeout(message: impl Into<String>) -> Self {
Self {
message: redact_sensitive_text(&message.into()),
timeout: true,
rate_limited: false,
}
}
fn status(status: StatusCode, body: String) -> Self {
Self {
message: format!(
"EXA API error {status}: {}",
redact_sensitive_text(body.trim())
),
timeout: false,
rate_limited: status == StatusCode::TOO_MANY_REQUESTS,
}
}
}
type ExaResultValue<T> = Result<T, ExaToolError>;
impl ExaClient {
fn from_env() -> ExaResultValue<Self> {
let api_key = std::env::var("EXA_API_KEY")
.ok()
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
.ok_or_else(|| {
ExaToolError::new("EXA_API_KEY is required for EXA-backed search tools; set/export EXA_API_KEY in the process environment")
})?;
Self::new(
EXA_BASE_URL,
api_key,
EXA_CONNECT_TIMEOUT,
EXA_REQUEST_TIMEOUT,
)
}
fn new(
base_url: &str,
api_key: String,
connect_timeout: Duration,
request_timeout: Duration,
) -> ExaResultValue<Self> {
let client = Client::builder()
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.user_agent(format!("magi-code/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| ExaToolError::new(format!("EXA HTTP client setup failed: {error}")))?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
})
}
#[cfg(test)]
pub(super) fn for_test(base_url: &str) -> Self {
Self::new(
base_url,
"test-exa-key".to_string(),
EXA_TEST_CONNECT_TIMEOUT,
EXA_TEST_REQUEST_TIMEOUT,
)
.unwrap()
}
fn search(&self, request: &ExaSearchRequest) -> ExaResultValue<ExaSearchResponse> {
let url = format!("{}/search", self.base_url);
let response = self
.client
.post(url)
.header("x-api-key", &self.api_key)
.json(request)
.send()
.map_err(|error| {
if error.is_timeout() {
ExaToolError::timeout("EXA request timed out")
} else {
ExaToolError::new(format!("EXA network error: {error}"))
}
})?;
let status = response.status();
if !status.is_success() {
let body = read_error_body(response);
return Err(ExaToolError::status(status, body));
}
if response
.content_length()
.is_some_and(|length| length > EXA_RESPONSE_MAX_BYTES as u64)
{
return Err(ExaToolError::new(format!(
"EXA response exceeded {EXA_RESPONSE_MAX_BYTES} byte limit"
)));
}
let mut bytes = Vec::new();
response
.take((EXA_RESPONSE_MAX_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| ExaToolError::new(format!("EXA response read failed: {error}")))?;
if bytes.len() > EXA_RESPONSE_MAX_BYTES {
return Err(ExaToolError::new(format!(
"EXA response exceeded {EXA_RESPONSE_MAX_BYTES} byte limit"
)));
}
serde_json::from_slice::<ExaSearchResponse>(&bytes)
.map_err(|error| ExaToolError::new(format!("EXA returned malformed JSON: {error}")))
}
}
fn read_error_body(mut response: reqwest::blocking::Response) -> String {
let mut body = String::new();
let read_result = response
.by_ref()
.take(EXA_ERROR_BODY_MAX_BYTES + 1)
.read_to_string(&mut body);
if let Err(error) = read_result {
body.push_str(&format!("<failed to read error body: {error}>"));
}
if body.len() > EXA_ERROR_BODY_MAX_BYTES as usize {
truncate_to_char_boundary(&body, EXA_ERROR_BODY_MAX_BYTES as usize).to_string()
} else {
body
}
}
impl ToolRuntime {
pub(super) fn web_search(
&self,
args: WebSearchArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let client = match ExaClient::from_env() {
Ok(client) => client,
Err(error) => return Ok(search_error_result(tool_name::WEB_SEARCH, error, None)),
};
Ok(self.web_search_with_client_cancellable(args, &client, cancellation))
}
pub(super) fn code_search(
&self,
args: CodeSearchArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
cancellation.check()?;
let client = match ExaClient::from_env() {
Ok(client) => client,
Err(error) => return Ok(search_error_result(tool_name::CODE_SEARCH, error, None)),
};
Ok(self.code_search_with_client_cancellable(args, &client, cancellation))
}
#[cfg(test)]
pub(super) fn web_search_with_client(
&self,
args: WebSearchArgs,
client: &ExaClient,
) -> ToolResult {
self.web_search_with_client_cancellable(args, client, &AgentCancellation::default())
}
pub(super) fn web_search_with_client_cancellable(
&self,
args: WebSearchArgs,
client: &ExaClient,
cancellation: &AgentCancellation,
) -> ToolResult {
let queries = args.normalized_queries();
let mut sections = Vec::new();
let mut total_results = 0usize;
let mut truncated = false;
let mut failures = Vec::new();
let include_content = args.include_content();
for query in &queries {
if let Err(error) = cancellation.check() {
return cancellation_result(tool_name::WEB_SEARCH, error);
}
let request = web_request(query, &args);
match client.search(&request) {
Ok(response) => {
if let Err(error) = cancellation.check() {
return cancellation_result(tool_name::WEB_SEARCH, error);
}
total_results += response.results.len();
let (section, section_truncated) = format_web_query_section(
query,
response.results.as_slice(),
include_content,
);
truncated |= section_truncated;
sections.push(section);
}
Err(error) => {
failures.push(error.clone());
sections.push(format!("# {query}\n\nEXA search failed: {}", error.message));
}
}
}
if failures.len() == queries.len() {
let error = aggregate_search_errors(failures);
return search_error_result(tool_name::WEB_SEARCH, error, Some(queries.len()));
}
let mut content = sections.join("\n\n");
truncated |= enforce_output_cap(&mut content);
content = redact_sensitive_text(&content);
truncated |= enforce_output_cap(&mut content);
ToolResult {
tool_name: tool_name::WEB_SEARCH.to_string(),
success: true,
content,
metadata: json!({
(meta::PROVIDER): "exa",
(meta::QUERY_COUNT): queries.len(),
(meta::TOTAL_RESULTS): total_results,
(meta::INCLUDE_CONTENT): include_content,
(meta::TRUNCATED): truncated,
(meta::TIMEOUT): failures.iter().any(|failure| failure.timeout),
(meta::RATE_LIMITED): failures.iter().any(|failure| failure.rate_limited),
}),
display: ToolResultDisplay::default(),
}
}
#[cfg(test)]
pub(super) fn code_search_with_client(
&self,
args: CodeSearchArgs,
client: &ExaClient,
) -> ToolResult {
self.code_search_with_client_cancellable(args, client, &AgentCancellation::default())
}
pub(super) fn code_search_with_client_cancellable(
&self,
args: CodeSearchArgs,
client: &ExaClient,
cancellation: &AgentCancellation,
) -> ToolResult {
if let Err(error) = cancellation.check() {
return cancellation_result(tool_name::CODE_SEARCH, error);
}
let max_tokens = args.max_tokens();
let char_budget = (max_tokens * 4).clamp(4_000, CODE_SEARCH_MAX_MAX_TOKENS * 4) as usize;
let request = ExaSearchRequest {
query: format!("{} official documentation code examples GitHub", args.query),
search_type: "auto",
num_results: CODE_SEARCH_RESULTS,
include_domains: Vec::new(),
exclude_domains: Vec::new(),
start_published_date: None,
contents: ExaContents {
text: ExaTextRequest::Limited {
max_characters: char_budget,
},
highlights: true,
},
};
let response = match client.search(&request) {
Ok(response) => response,
Err(error) => return search_error_result(tool_name::CODE_SEARCH, error, None),
};
if let Err(error) = cancellation.check() {
return cancellation_result(tool_name::CODE_SEARCH, error);
}
let (mut content, mut truncated) =
format_code_search_output(&args.query, &response.results, char_budget);
truncated |= enforce_output_cap(&mut content);
content = redact_sensitive_text(&content);
truncated |= enforce_output_cap(&mut content);
ToolResult {
tool_name: tool_name::CODE_SEARCH.to_string(),
success: true,
content,
metadata: json!({
(meta::PROVIDER): "exa",
(meta::QUERY): args.query,
(meta::MAX_TOKENS): max_tokens,
(meta::RESULTS): response.results.len(),
(meta::CHAR_BUDGET): char_budget,
(meta::TRUNCATED): truncated,
}),
display: ToolResultDisplay::default(),
}
}
}
fn web_request(query: &str, args: &WebSearchArgs) -> ExaSearchRequest {
let (include_domains, exclude_domains) =
map_domains(args.domain_filter.as_deref().unwrap_or(&[]));
ExaSearchRequest {
query: query.to_string(),
search_type: "auto",
num_results: args.num_results().min(WEB_SEARCH_MAX_RESULTS),
include_domains,
exclude_domains,
start_published_date: args.recency_filter.map(recency_start_date),
contents: ExaContents {
text: if args.include_content() {
ExaTextRequest::Enabled(true)
} else {
ExaTextRequest::Limited {
max_characters: 3000,
}
},
highlights: true,
},
}
}
fn map_domains(domains: &[String]) -> (Vec<String>, Vec<String>) {
let mut include_domains = Vec::new();
let mut exclude_domains = Vec::new();
for domain in domains {
if let Some(excluded) = domain.strip_prefix('-') {
exclude_domains.push(excluded.to_string());
} else {
include_domains.push(domain.to_string());
}
}
(include_domains, exclude_domains)
}
fn recency_start_date(filter: RecencyFilter) -> String {
let days = match filter {
RecencyFilter::Day => 1,
RecencyFilter::Week => 7,
RecencyFilter::Month => 30,
RecencyFilter::Year => 365,
};
(Utc::now().date_naive() - ChronoDuration::days(days)).to_string()
}
fn format_web_query_section(
query: &str,
results: &[ExaResult],
include_content: bool,
) -> (String, bool) {
let mut section = format!("# {query}\n");
if results.is_empty() {
section.push_str("\nNo EXA results returned.");
return (section, false);
}
if let Some(answer) = synthesized_context(results, 1_200) {
section.push('\n');
section.push_str(&answer);
section.push('\n');
}
section.push_str("\n## Sources\n");
for (index, result) in results.iter().enumerate() {
let title = result.title.as_deref().unwrap_or("Untitled source");
let url = result.url.as_deref().unwrap_or("<no url>");
section.push_str(&format!("{}. {}\n URL: {}\n", index + 1, title, url));
if let Some(date) = result.published_date.as_deref() {
section.push_str(&format!(" Published: {date}\n"));
}
if let Some(author) = result.author.as_deref() {
section.push_str(&format!(" Author: {author}\n"));
}
if let Some(snippet) = snippet(result) {
section.push_str(&format!(" Snippet: {snippet}\n"));
}
}
let mut truncated = false;
if include_content {
let mut used = 0usize;
section.push_str("\n## Inline content\n");
for (index, result) in results.iter().enumerate() {
let Some(text) = result
.text
.as_deref()
.filter(|text| !text.trim().is_empty())
else {
continue;
};
if used >= WEB_INLINE_QUERY_MAX_CHARS {
truncated = true;
break;
}
let remaining = WEB_INLINE_QUERY_MAX_CHARS - used;
let cap = WEB_INLINE_SOURCE_MAX_CHARS.min(remaining);
let clipped = truncate_text(text.trim(), cap, &mut truncated);
used += clipped.len();
section.push_str(&format!(
"\n### Source {} content\n{}\n",
index + 1,
clipped
));
}
}
(section, truncated)
}
fn format_code_search_output(
query: &str,
results: &[ExaResult],
char_budget: usize,
) -> (String, bool) {
let mut output = format!("# Code search: {query}\n\n## Search results\n");
if results.is_empty() {
output.push_str("No EXA results returned.\n");
return (output, false);
}
for (index, result) in results.iter().enumerate() {
let title = result.title.as_deref().unwrap_or("Untitled source");
let url = result.url.as_deref().unwrap_or("<no url>");
output.push_str(&format!("{}. {}\n URL: {}\n", index + 1, title, url));
if let Some(snippet) = snippet(result) {
output.push_str(&format!(" Snippet: {snippet}\n"));
}
}
output.push_str("\n## Context\n");
let mut remaining = char_budget;
let mut truncated = false;
for (index, result) in results.iter().enumerate() {
let Some(text) = result_context_text(result) else {
continue;
};
if remaining == 0 {
truncated = true;
break;
}
let clipped = truncate_text(&text, remaining, &mut truncated);
remaining = remaining.saturating_sub(clipped.len());
output.push_str(&format!("\n### Source {}\n{}\n", index + 1, clipped));
}
(output, truncated)
}
fn synthesized_context(results: &[ExaResult], limit: usize) -> Option<String> {
let mut text = String::new();
let mut truncated = false;
for result in results {
if let Some(snippet) = snippet(result) {
if !text.is_empty() {
text.push(' ');
}
text.push_str(&snippet);
}
}
if text.trim().is_empty() {
return None;
}
Some(truncate_text(text.trim(), limit, &mut truncated))
}
fn snippet(result: &ExaResult) -> Option<String> {
result
.highlights
.as_ref()
.and_then(|highlights| {
let joined = highlights
.iter()
.map(|highlight| highlight.trim())
.filter(|highlight| !highlight.is_empty())
.collect::<Vec<_>>()
.join(" ");
(!joined.is_empty()).then_some(joined)
})
.or_else(|| {
result
.text
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
.map(|text| text.chars().take(240).collect::<String>())
})
}
fn result_context_text(result: &ExaResult) -> Option<String> {
let mut parts = Vec::new();
if let Some(highlights) = result.highlights.as_ref() {
let joined = highlights
.iter()
.map(|highlight| highlight.trim())
.filter(|highlight| !highlight.is_empty())
.collect::<Vec<_>>()
.join("\n");
if !joined.is_empty() {
parts.push(joined);
}
}
if let Some(text) = result
.text
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
parts.push(text.to_string());
}
(!parts.is_empty()).then(|| parts.join("\n\n"))
}
fn truncate_text(text: &str, max_bytes: usize, truncated: &mut bool) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
*truncated = true;
truncate_with_marker(text, max_bytes).to_string()
}
fn enforce_output_cap(content: &mut String) -> bool {
if content.len() <= TOOL_OUTPUT_MAX_BYTES {
return false;
}
*content = truncate_with_marker(content, TOOL_OUTPUT_MAX_BYTES).to_string();
true
}
fn truncate_with_marker(text: &str, max_bytes: usize) -> std::borrow::Cow<'_, str> {
if text.len() <= max_bytes {
return std::borrow::Cow::Borrowed(text);
}
if max_bytes <= TRUNCATION_MARKER.len() {
return std::borrow::Cow::Owned(
truncate_to_char_boundary(TRUNCATION_MARKER, max_bytes).to_string(),
);
}
let text_budget = max_bytes - TRUNCATION_MARKER.len();
let clipped = truncate_to_char_boundary(text, text_budget);
std::borrow::Cow::Owned(format!("{clipped}{TRUNCATION_MARKER}"))
}
fn truncate_to_char_boundary(text: &str, max_bytes: usize) -> &str {
if text.len() <= max_bytes {
return text;
}
let mut end = max_bytes;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
fn aggregate_search_errors(failures: Vec<ExaToolError>) -> ExaToolError {
let timeout = failures.iter().any(|failure| failure.timeout);
let rate_limited = failures.iter().any(|failure| failure.rate_limited);
let mut error = failures
.into_iter()
.next()
.unwrap_or_else(|| ExaToolError::new("EXA search failed"));
error.timeout = timeout;
error.rate_limited = rate_limited;
error
}
fn cancellation_result(tool_name_value: &str, error: anyhow::Error) -> ToolResult {
ToolResult {
tool_name: tool_name_value.to_string(),
success: false,
content: error.to_string(),
metadata: json!({}),
display: ToolResultDisplay::default(),
}
}
fn search_error_result(
tool_name_value: &str,
error: ExaToolError,
query_count: Option<usize>,
) -> ToolResult {
let mut metadata = json!({
(meta::PROVIDER): "exa",
(meta::TRUNCATED): false,
(meta::TIMEOUT): error.timeout,
(meta::RATE_LIMITED): error.rate_limited,
});
if let Some(query_count) = query_count {
metadata[meta::QUERY_COUNT] = json!(query_count);
}
ToolResult {
tool_name: tool_name_value.to_string(),
success: false,
content: error.message,
metadata,
display: ToolResultDisplay::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::{
io::{Read, Write},
net::TcpListener,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
};
fn fake_server(
response: &'static str,
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
fake_server_with_delay(response, Duration::ZERO)
}
fn fake_server_with_delay(
response: &'static str,
delay: Duration,
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0_u8; 16384];
let n = stream.read(&mut buf).unwrap();
let request = String::from_utf8_lossy(&buf[..n]).to_string();
tx.send(request).unwrap();
if !delay.is_zero() {
thread::sleep(delay);
}
let _ = stream.write_all(response.as_bytes());
});
(format!("http://{address}"), rx, handle)
}
fn fake_server_responses(
responses: Vec<String>,
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0_u8; 16384];
let n = stream.read(&mut buf).unwrap();
tx.send(String::from_utf8_lossy(&buf[..n]).to_string())
.unwrap();
let _ = stream.write_all(response.as_bytes());
}
});
(format!("http://{address}"), rx, handle)
}
fn http_ok(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
}
#[test]
fn missing_env_var_reports_exa_api_key_without_secret() {
let env = crate::test_support::env::env_lock();
let _saved_env = env.save("EXA_API_KEY");
env.remove_var("EXA_API_KEY");
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let result = runtime.dispatch("web_search", json!({"queries":["rust"]}));
assert!(!result.success);
assert!(result.content.contains("EXA_API_KEY"));
assert!(!result.content.contains("sk-"));
}
#[test]
fn web_search_success_posts_search_request_and_formats_sources() {
let body = r#"{"results":[{"title":"Rust","url":"https://www.rust-lang.org","publishedDate":"2026-01-01","author":"Rust Team","text":"Rust language text","highlights":["safe systems"]}]}"#;
let response = http_ok(body);
let (base, rx, handle) = fake_server(Box::leak(response.into_boxed_str()));
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args =
serde_json::from_value::<WebSearchArgs>(json!({"queries":[" rust "],"numResults":3}))
.unwrap()
.validate()
.unwrap();
let result = runtime.web_search_with_client(args, &client);
handle.join().unwrap();
let request = rx.recv().unwrap();
assert!(request.starts_with("POST /search HTTP/1.1"), "{request}");
assert!(request.contains("x-api-key"), "{request}");
assert!(
request
.to_ascii_lowercase()
.contains("content-type: application/json"),
"{request}"
);
assert!(request.contains("\"query\":\"rust\""), "{request}");
assert!(request.contains("\"numResults\":3"), "{request}");
assert!(result.success, "{}", result.content);
assert!(result.content.contains("# rust"));
assert!(result.content.contains("Rust"));
assert!(result.content.contains("https://www.rust-lang.org"));
assert_eq!(result.metadata[meta::TOTAL_RESULTS], 1);
}
#[test]
fn web_search_maps_recency_domains_and_truncates_inline_content() {
let long = "x".repeat(WEB_INLINE_SOURCE_MAX_CHARS + 50);
let body = format!(
r#"{{"results":[{{"title":"One","url":"https://example.com","text":"{long}"}}]}}"#
);
let response = http_ok(&body);
let (base, rx, handle) = fake_server(Box::leak(response.into_boxed_str()));
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<WebSearchArgs>(json!({
"queries":["rust"],
"includeContent": true,
"recencyFilter":"week",
"domainFilter":["example.com", "-old.example.com"]
}))
.unwrap()
.validate()
.unwrap();
let result = runtime.web_search_with_client(args, &client);
handle.join().unwrap();
let request = rx.recv().unwrap();
assert!(
request.contains("\"includeDomains\":[\"example.com\"]"),
"{request}"
);
assert!(
request.contains("\"excludeDomains\":[\"old.example.com\"]"),
"{request}"
);
assert!(request.contains("startPublishedDate"), "{request}");
assert!(request.contains("\"text\":true"), "{request}");
assert!(result.content.contains("## Inline content"));
assert!(result.content.contains("[truncated]"));
assert_eq!(result.metadata[meta::TRUNCATED], true);
}
#[test]
fn web_search_cancellation_after_query_response_aborts_batch() {
let response = http_ok(
r#"{"results":[{"title":"One","url":"https://one.example","highlights":["first"]}]}"#,
);
let cancel = Arc::new(AtomicBool::new(false));
let cancellation = AgentCancellation::new(Arc::clone(&cancel));
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0_u8; 16384];
let n = stream.read(&mut buf).unwrap();
tx.send(String::from_utf8_lossy(&buf[..n]).to_string())
.unwrap();
cancel.store(true, Ordering::SeqCst);
let _ = stream.write_all(response.as_bytes());
});
let client = ExaClient::for_test(&format!("http://{address}"));
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<WebSearchArgs>(json!({"queries":["one", "two"]}))
.unwrap()
.validate()
.unwrap();
let result = runtime.web_search_with_client_cancellable(args, &client, &cancellation);
handle.join().unwrap();
let first_request = rx.recv().unwrap();
assert!(
first_request.contains("\"query\":\"one\""),
"{first_request}"
);
assert!(!result.success);
assert_eq!(result.content, "prompt canceled");
assert_eq!(rx.try_recv(), Err(mpsc::TryRecvError::Disconnected));
}
#[test]
fn oversized_success_response_is_rejected_before_json_parse() {
let oversized = "{".repeat(EXA_RESPONSE_MAX_BYTES + 1);
let response = http_ok(&oversized);
let (base, _rx, handle) = fake_server(Box::leak(response.into_boxed_str()));
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<CodeSearchArgs>(json!({"query":"rust"}))
.unwrap()
.validate()
.unwrap();
let result = runtime.code_search_with_client(args, &client);
handle.join().unwrap();
assert!(!result.success);
assert!(result.content.contains("exceeded"), "{}", result.content);
assert!(
!result.content.contains("malformed JSON"),
"{}",
result.content
);
}
#[test]
fn aggregate_search_errors_preserves_first_message_and_any_flags() {
let error = aggregate_search_errors(vec![
ExaToolError::timeout("first timeout"),
ExaToolError::status(StatusCode::TOO_MANY_REQUESTS, "rate limited".to_string()),
]);
assert_eq!(error.message, "first timeout");
assert!(error.timeout);
assert!(error.rate_limited);
}
#[test]
fn web_search_all_failed_metadata_rate_limited_from_any_error() {
let first = "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nfirst".to_string();
let second = "HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\nContent-Length: 6\r\n\r\nsecond".to_string();
let (base, _rx, handle) = fake_server_responses(vec![first, second]);
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<WebSearchArgs>(json!({"queries":["one", "two"]}))
.unwrap()
.validate()
.unwrap();
let result = runtime.web_search_with_client(args, &client);
handle.join().unwrap();
assert!(!result.success);
assert_eq!(result.metadata[meta::QUERY_COUNT], 2);
assert_eq!(result.metadata[meta::RATE_LIMITED], true);
}
#[test]
fn web_search_multi_query_keeps_sections_distinct() {
let first = http_ok(
r#"{"results":[{"title":"One","url":"https://one.example","highlights":["first"]}]}"#,
);
let second = http_ok(
r#"{"results":[{"title":"Two","url":"https://two.example","highlights":["second"]}]}"#,
);
let (base, rx, handle) = fake_server_responses(vec![first, second]);
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<WebSearchArgs>(json!({"queries":["one", "two"]}))
.unwrap()
.validate()
.unwrap();
let result = runtime.web_search_with_client(args, &client);
handle.join().unwrap();
let first_request = rx.recv().unwrap();
let second_request = rx.recv().unwrap();
assert!(
first_request.contains("\"query\":\"one\""),
"{first_request}"
);
assert!(
second_request.contains("\"query\":\"two\""),
"{second_request}"
);
assert!(result.success, "{}", result.content);
assert!(result.content.contains("# one"));
assert!(result.content.contains("# two"));
assert_eq!(result.metadata[meta::QUERY_COUNT], 2);
assert_eq!(result.metadata[meta::TOTAL_RESULTS], 2);
}
#[test]
fn web_search_trims_query_before_request() {
let response = http_ok(
r#"{"results":[{"title":"Rust","url":"https://rust.example","highlights":["safe"]}]}"#,
);
let (base, rx, handle) = fake_server(response.leak());
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<WebSearchArgs>(json!({
"queries":[" rust "]
}))
.unwrap()
.validate()
.unwrap();
assert_eq!(args.normalized_queries(), vec!["rust".to_string()]);
let result = runtime.web_search_with_client(args, &client);
handle.join().unwrap();
let request = rx.recv().unwrap();
assert!(request.contains("\"query\":\"rust\""), "{request}");
assert!(!request.contains("\"query\":\"\""), "{request}");
assert!(result.success, "{}", result.content);
}
#[test]
fn truncation_markers_fit_within_documented_caps() {
let mut truncated = false;
let clipped = truncate_text(
&"x".repeat(WEB_INLINE_SOURCE_MAX_CHARS + 50),
WEB_INLINE_SOURCE_MAX_CHARS,
&mut truncated,
);
assert!(truncated);
assert!(clipped.ends_with(TRUNCATION_MARKER));
assert!(clipped.len() <= WEB_INLINE_SOURCE_MAX_CHARS);
let mut output = "x".repeat(TOOL_OUTPUT_MAX_BYTES + 50);
assert!(enforce_output_cap(&mut output));
assert!(output.ends_with(TRUNCATION_MARKER));
assert!(output.len() <= TOOL_OUTPUT_MAX_BYTES);
}
#[test]
fn code_search_enriches_query_and_formats_context() {
let body = r#"{"results":[{"title":"Reqwest docs","url":"https://docs.rs/reqwest","text":"Client::builder().timeout(Duration::from_secs(20))","highlights":["blocking client timeout example"]}]}"#;
let response = http_ok(body);
let (base, rx, handle) = fake_server(Box::leak(response.into_boxed_str()));
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<CodeSearchArgs>(
json!({"query":"reqwest timeout","maxTokens":1000}),
)
.unwrap()
.validate()
.unwrap();
let result = runtime.code_search_with_client(args, &client);
handle.join().unwrap();
let request = rx.recv().unwrap();
assert!(
request.contains("reqwest timeout official documentation code examples GitHub"),
"{request}"
);
assert!(result.success, "{}", result.content);
assert!(result.content.contains("# Code search: reqwest timeout"));
assert!(result.content.contains("## Context"));
assert!(result.content.contains("https://docs.rs/reqwest"));
}
#[test]
fn http_status_errors_are_redacted_and_rate_limited() {
let body = "EXA_API_KEY=sk-statusSecret123456 Bearer abcdefghijk";
let response = format!(
"HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let (base, _rx, handle) = fake_server(Box::leak(response.into_boxed_str()));
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<CodeSearchArgs>(json!({"query":"rust"}))
.unwrap()
.validate()
.unwrap();
let result = runtime.code_search_with_client(args, &client);
handle.join().unwrap();
assert!(!result.success);
assert!(result.content.contains("429"));
assert!(result.content.contains("<redacted>"));
assert!(!result.content.contains("sk-statusSecret"));
assert_eq!(result.metadata[meta::RATE_LIMITED], true);
}
#[test]
fn malformed_json_and_timeout_return_tool_failures() {
let response = http_ok("not-json");
let (base, _rx, handle) = fake_server(Box::leak(response.into_boxed_str()));
let client = ExaClient::for_test(&base);
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let args = serde_json::from_value::<CodeSearchArgs>(json!({"query":"rust"}))
.unwrap()
.validate()
.unwrap();
let result = runtime.code_search_with_client(args, &client);
handle.join().unwrap();
assert!(!result.success);
assert!(result.content.contains("malformed JSON"));
let response = http_ok(r#"{"results":[]}"#);
let (base, _rx, handle) =
fake_server_with_delay(Box::leak(response.into_boxed_str()), Duration::from_secs(1));
let client = ExaClient::for_test(&base);
let args = serde_json::from_value::<CodeSearchArgs>(json!({"query":"rust"}))
.unwrap()
.validate()
.unwrap();
let result = runtime.code_search_with_client(args, &client);
let _ = handle.join();
assert!(!result.success);
assert_eq!(result.metadata[meta::TIMEOUT], true);
}
}