diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index ef56b2706..59bec719c 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -2652,7 +2652,9 @@ mod tests { {"type": "reasoning", "encrypted_content": ""}, {"type": "reasoning"}, {"type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{}"}, - {"type": "function_call_output", "call_id": "call_1", "output": "ok"} + {"type": "function_call_output", "call_id": "call_1", "output": "ok"}, + {"type": "web_search_call", "id": "ws_1", "status": "completed", "action": {"type": "search", "query": "switchyard"}}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "found it"}]} ]); for store in [Some(true), Some(false), None] { for is_drop in [false, true] { @@ -2711,6 +2713,10 @@ mod tests { } } expected.extend([history[6].clone(), history[7].clone()]); + if !is_drop { + expected.push(history[8].clone()); + } + expected.push(history[9].clone()); assert_eq!( body["input"], json!(expected), diff --git a/crates/libsy-llm-client/src/responses_reasoning.rs b/crates/libsy-llm-client/src/responses_reasoning.rs index f5ba00d15..b4e345cfb 100644 --- a/crates/libsy-llm-client/src/responses_reasoning.rs +++ b/crates/libsy-llm-client/src/responses_reasoning.rs @@ -15,7 +15,8 @@ pub enum ResponsesReasoningPolicy { /// This is the default for every Responses model, regardless of its URL. #[default] PreserveEncrypted, - /// Drop all reasoning items while preserving messages and tool-call history. + /// Drop all reasoning items and replayed hosted web searches while preserving + /// messages and tool-call history. /// /// Use this for local Responses-compatible servers that cannot consume /// another provider's encrypted reasoning representation. @@ -37,6 +38,14 @@ impl ResponsesReasoningPolicy { let Some(object) = item.as_object_mut() else { return true; }; + // A hosted provider's server-side web search is replayed as a `web_search_call` item, + // which a local server cannot run or type (llama.cpp answers 400 "Cannot determine type + // of 'item'"). Its findings are in the assistant message that follows it. + if self == Self::Drop + && object.get("type").and_then(Value::as_str) == Some("web_search_call") + { + return false; + } if object.get("type").and_then(Value::as_str) != Some("reasoning") { return true; } diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index a691c0d32..749a38bb7 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -66,7 +66,11 @@ non-empty `encrypted_content` or a stored reasoning `id` are retained with empty `content`. ID-only reasoning is removed when the request sets `store: false`. Switchyard's translated Anthropic reasoning is always removed. Reasoning without either is removed. Set `responses_reasoning = "drop"` explicitly for a backend -that cannot consume provider reasoning. Both modes keep messages, tool +that cannot consume provider reasoning. `"drop"` also removes replayed +`web_search_call` items: a conversation that ran a hosted web search replays +them, and a local server such as llama.cpp rejects them with 400 +`Cannot determine type of 'item'`. The search results stay in the assistant +message that follows. Both modes keep messages, tool calls and tool results. The setting is rejected on other client formats, even when no target uses the client. It is not inferred from the model name or URL.