Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 35 additions & 33 deletions apis/src/anthropic/web_search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use serde::{Deserialize, de::IgnoredAny};
use serde_json::{Value, json};

use crate::web_search::{
SearchClient, SearchContextSize, SearchOutcome, SearchResult, WebSearchFilterConfig, build_config,
SEARCH_UNAVAILABLE, SearchClient, SearchContextSize, SearchOutcome, WebSearchFilterConfig, build_config,
format_search_results,
};

Expand Down Expand Up @@ -159,8 +159,6 @@ struct ResponseEnvelope<'a> {
/// api_key: ${WEB_SEARCH_API_KEY}
/// default_context_size: medium
/// timeout_ms: 10000
/// provider_failure_mode: closed
/// status_on_error: 502
/// max_body_bytes: 67108864
/// ```
///
Expand Down Expand Up @@ -222,21 +220,14 @@ impl AnthropicWebSearchFilter {
}))
}

/// Execute one pending call and map provider failure policy to Messages semantics.
async fn execute_pending_search(&self, pending: &PendingSearch) -> Result<Vec<SearchResult>, Rejection> {
let outcome = self
.search_client
/// Execute one pending call, returning the provider outcome.
///
/// A provider failure never rejects the Messages response: the caller
/// appends a truthful `is_error` tool result so the loop can continue.
async fn execute_pending_search(&self, pending: &PendingSearch) -> SearchOutcome {
self.search_client
.search(&pending.query, Some(self.default_context_size))
.await;
match outcome {
SearchOutcome::Results(results) => Ok(results),
SearchOutcome::Skipped => Ok(Vec::new()),
SearchOutcome::Rejected { status } => Err(anthropic_rejection(
status,
"api_error",
"web search provider unavailable",
)),
}
.await
}

/// Execute a retained search and replace the IRR request body.
Expand Down Expand Up @@ -281,13 +272,8 @@ impl AnthropicWebSearchFilter {
"messages must be an array for web search re-entry",
)));
}
let results = match self.execute_pending_search(&pending).await {
Ok(results) => results,
Err(rejection) => {
return Ok(FilterAction::Reject(rejection));
},
};
if let Err(rejection) = append_search_turns(&mut request, assistant_content, pending, &results) {
let outcome = self.execute_pending_search(&pending).await;
if let Err(rejection) = append_search_turns(&mut request, assistant_content, pending, &outcome) {
return Ok(FilterAction::Reject(rejection));
}
let rebuilt = serde_json::to_vec(&request)
Expand Down Expand Up @@ -524,7 +510,7 @@ fn append_search_turns(
request: &mut Value,
assistant_content: Vec<Value>,
pending: PendingSearch,
results: &[SearchResult],
outcome: &SearchOutcome,
) -> Result<(), Rejection> {
let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) else {
return Err(anthropic_rejection(
Expand All @@ -533,19 +519,12 @@ fn append_search_turns(
"messages must be an array for web search re-entry",
));
};
let content = if results.is_empty() {
"No search results found.".to_owned()
} else {
format_search_results(results)
};
let PendingSearch { id, query: _ } = pending;
let mut assistant_turn = serde_json::Map::new();
assistant_turn.insert("role".to_owned(), Value::String("assistant".to_owned()));
assistant_turn.insert("content".to_owned(), Value::Array(assistant_content));
messages.push(Value::Object(assistant_turn));
messages.push(json!({"role":"user","content":[{
"type":"tool_result","tool_use_id":id,"content":content
}]}));
messages.push(build_tool_result_turn(&id, outcome));
if request.get("tool_choice").is_some()
&& let Some(object) = request.as_object_mut()
{
Expand All @@ -554,6 +533,29 @@ fn append_search_turns(
Ok(())
}

/// Build the user turn carrying the search tool result.
///
/// A provider failure yields a truthful `is_error` result carrying the bounded
/// [`SEARCH_UNAVAILABLE`] message so the loop continues; a successful empty
/// search reports `No search results found.` without `is_error`.
fn build_tool_result_turn(tool_use_id: &str, outcome: &SearchOutcome) -> Value {
match outcome {
SearchOutcome::Results(results) => {
let content = if results.is_empty() {
"No search results found.".to_owned()
} else {
format_search_results(results)
};
json!({"role":"user","content":[{
"type":"tool_result","tool_use_id":tool_use_id,"content":content
}]})
},
SearchOutcome::Failed => json!({"role":"user","content":[{
"type":"tool_result","tool_use_id":tool_use_id,"content":SEARCH_UNAVAILABLE,"is_error":true
}]}),
}
}

/// Publish the loop decision for the IRR transition table.
fn set_action(ctx: &mut HttpFilterContext<'_>, action: &'static str) -> Result<(), FilterError> {
ctx.filter_results
Expand Down
68 changes: 46 additions & 22 deletions apis/src/anthropic/web_search/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,12 @@ default_context_size: medium
AnthropicWebSearchFilter::from_config(&config).unwrap()
}

fn test_filter_impl_with_base_url(base_url: &str, provider_failure_mode: &str) -> AnthropicWebSearchFilter {
fn test_filter_impl_with_base_url(base_url: &str) -> AnthropicWebSearchFilter {
let config = serde_yaml::from_str(&format!(
r#"
provider: you
api_key: test-key
default_context_size: medium
provider_failure_mode: {provider_failure_mode}
base_url: "{base_url}"
allow_private_base_url: true
"#,
Expand Down Expand Up @@ -130,6 +129,10 @@ fn valid_you_body() -> String {
.to_string()
}

fn empty_you_body() -> String {
json!({"results": {"web": [], "news": []}}).to_string()
}

#[test]
fn search_stub_reads_full_content_length_body() {
let search = start_you_search_stub(200, valid_you_body());
Expand Down Expand Up @@ -508,12 +511,12 @@ async fn initial_request_body_is_not_mutated() {
#[tokio::test]
async fn pending_search_executes_and_appends_tool_result() {
let search = start_you_search_stub(200, valid_you_body());
let filter = test_filter_impl_with_base_url(search.base_url(), "closed");
let filter = test_filter_impl_with_base_url(search.base_url());
let pending = pending_search("potato");

let results = filter.execute_pending_search(&pending).await.unwrap();
let outcome = filter.execute_pending_search(&pending).await;
let mut rebuilt = base_request();
append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &results).unwrap();
append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &outcome).unwrap();

assert_eq!(rebuilt["model"], "openai/gpt-oss-20b");
assert_eq!(rebuilt["system"], "Answer with sources.");
Expand All @@ -533,6 +536,10 @@ async fn pending_search_executes_and_appends_tool_result() {
.unwrap()
.contains("Potato - Wikipedia")
);
assert!(
messages[messages.len() - 1]["content"][0].get("is_error").is_none(),
"a successful search must not mark the tool result as an error"
);
assert_eq!(search.last_json()["query"], "potato");
assert!(
search
Expand All @@ -543,34 +550,51 @@ async fn pending_search_executes_and_appends_tool_result() {
}

#[tokio::test]
async fn closed_provider_failure_returns_anthropic_error() {
async fn provider_failure_appends_is_error_tool_result() {
let search = start_you_search_stub(503, "unavailable".to_owned());
let filter = test_filter_impl_with_base_url(search.base_url(), "closed");
let filter = test_filter_impl_with_base_url(search.base_url());
let pending = pending_search("potato");

let result = filter.execute_pending_search(&pending).await;
let outcome = filter.execute_pending_search(&pending).await;
assert!(
matches!(&outcome, SearchOutcome::Failed),
"a provider 5xx must map to a failed outcome, got {outcome:?}"
);

let Err(rejection) = result else {
panic!("expected rejection");
};
assert_eq!(rejection.status, 502);
assert!(String::from_utf8_lossy(rejection.body.as_ref().unwrap()).contains("api_error"));
let mut rebuilt = base_request();
append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &outcome).unwrap();

let result_block = &rebuilt["messages"].as_array().unwrap().last().unwrap()["content"][0];
assert_eq!(result_block["type"], "tool_result");
assert_eq!(result_block["tool_use_id"], "toolu_search_1");
assert_eq!(result_block["content"], "Web search unavailable.");
assert_eq!(
result_block["is_error"], true,
"a failed search must mark the tool result with is_error"
);
}

#[tokio::test]
async fn open_provider_failure_appends_no_results_tool_result() {
let search = start_you_search_stub(503, "unavailable".to_owned());
let filter = test_filter_impl_with_base_url(search.base_url(), "open");
async fn empty_results_appends_no_results_tool_result() {
let search = start_you_search_stub(200, empty_you_body());
let filter = test_filter_impl_with_base_url(search.base_url());
let pending = pending_search("potato");

let results = filter.execute_pending_search(&pending).await.unwrap();
let outcome = filter.execute_pending_search(&pending).await;
assert!(
matches!(&outcome, SearchOutcome::Results(results) if results.is_empty()),
"a successful empty search must be a zero-result outcome, got {outcome:?}"
);

let mut rebuilt = base_request();
append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &results).unwrap();
append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &outcome).unwrap();

let content = rebuilt["messages"].as_array().unwrap().last().unwrap()["content"][0]["content"]
.as_str()
.unwrap();
assert_eq!(content, "No search results found.");
let result_block = &rebuilt["messages"].as_array().unwrap().last().unwrap()["content"][0];
assert_eq!(result_block["content"], "No search results found.");
assert!(
result_block.get("is_error").is_none(),
"a successful empty search must not mark the tool result as an error"
);
}

#[test]
Expand Down
Loading
Loading