From 18878ac1bb1e24d9aef951019672f94e615ff5ec Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Wed, 5 Aug 2026 18:48:08 +0000 Subject: [PATCH 1/2] fix(libsy): keep classifier history from orphaning a tool result Signed-off-by: Eric Liu --- crates/libsy/src/algorithms/llm_class.rs | 132 ++++++++++++++++++++++- 1 file changed, 128 insertions(+), 4 deletions(-) diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index b22a0166..2398ebc7 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Deserializer}; use serde_json::Value; -use switchyard_protocol::{Message, Role, SimpleDecision}; +use switchyard_protocol::{ContentBlock, Message, Role, SimpleDecision}; use super::fall_through::{DefaultTarget, FallThrough}; use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS; @@ -92,10 +92,41 @@ fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec usize { + let counted = tail.len().saturating_sub(recent_turn_window); + (0..=counted) + .rev() + .find(|start| !has_orphan_tool_result(&tail[*start..])) + .unwrap_or(counted) +} + +/// Whether `window` holds a tool result whose originating call is not also in `window`. +fn has_orphan_tool_result(window: &[&Message]) -> bool { + let blocks = || window.iter().flat_map(|message| &message.content); + let calls: BTreeSet<&str> = blocks() + .filter_map(|block| match block { + ContentBlock::ToolCall(call) => Some(call.id.as_str()), + _ => None, + }) + .collect(); + blocks().any(|block| match block { + ContentBlock::ToolResult(result) => !calls.contains(result.tool_call_id.as_str()), + _ => false, + }) +} + /// Keeps the opening task and the latest user follow-up when they differ. fn task_messages(messages: &[Message]) -> Vec { let mut user_messages = messages.iter().filter(|message| message.role == Role::User); @@ -919,8 +950,8 @@ mod tests { use super::*; use switchyard_protocol::{ - ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, completion_text, - text_request, text_response, + ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult, + completion_text, text_request, text_response, }; use crate::algorithms::util::llm_judge::Judge; @@ -1456,6 +1487,99 @@ mod tests { Ok(()) } + fn tool_call(id: &str) -> Message { + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolCall(ToolCall { + id: id.to_string(), + name: "search".to_string(), + arguments: Value::Null, + })], + } + } + + fn tool_result(id: &str) -> Message { + Message { + role: Role::Tool, + content: vec![ContentBlock::ToolResult(ToolResult { + tool_call_id: id.to_string(), + content: vec![ContentBlock::Text { + text: "tool output".to_string(), + }], + is_error: None, + })], + } + } + + /// Tool-call ids answered by a result that no kept call introduced. + fn orphan_tool_results(messages: &[Message]) -> Vec { + let blocks = |messages: &[Message]| { + messages + .iter() + .flat_map(|message| message.content.clone()) + .collect::>() + }; + let calls = blocks(messages) + .into_iter() + .filter_map(|block| match block { + ContentBlock::ToolCall(call) => Some(call.id), + _ => None, + }) + .collect::>(); + blocks(messages) + .into_iter() + .filter_map(|block| match block { + ContentBlock::ToolResult(result) if !calls.contains(&result.tool_call_id) => { + Some(result.tool_call_id) + } + _ => None, + }) + .collect() + } + + /// A count-based window can begin on a tool result, which leaves the call that + /// introduced its id outside the window and the classifier history invalid. + #[test] + fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() { + let messages = vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + Message::text(Role::Assistant, "old response"), + tool_call("call-1"), + tool_result("call-1"), + Message::text(Role::Assistant, "recent 1"), + Message::text(Role::User, "recent 2"), + Message::text(Role::Assistant, "recent 3"), + Message::text(Role::User, "recent 4"), + ]; + + // The five-message tail begins exactly on the tool result. + let kept = trim_messages(&messages, 5); + + assert_eq!(orphan_tool_results(&kept), Vec::::new()); + } + + /// Widening is only for tool pairs: a plain conversation keeps the window it asked for. + #[test] + fn trimming_without_tool_calls_keeps_only_the_window() { + let messages = vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + Message::text(Role::Assistant, "old response"), + Message::text(Role::User, "recent 1"), + Message::text(Role::Assistant, "recent 2"), + ]; + + let kept = trim_messages(&messages, 2); + + let contents = kept + .iter() + .filter_map(|message| message.text_content("\n")) + .collect::>(); + assert!(!contents.contains(&"old response".to_string())); + assert_eq!(kept.len(), 4); + } + #[test] fn capability_judge_builds_a_structured_request() -> Result<()> { let judge = capability_judge(None)?; From 8b34b91481527776df0795f4ee75d62d89737b0b Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Wed, 5 Aug 2026 20:16:12 +0000 Subject: [PATCH 2/2] fix(libsy): pair tool results in one newest-first scan of the window Signed-off-by: Eric Liu --- crates/libsy/src/algorithms/llm_class.rs | 159 ++++++++++++++--------- 1 file changed, 96 insertions(+), 63 deletions(-) diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index 2398ebc7..4c627b09 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -3,7 +3,7 @@ //! Judge-backed capability, escalation, and custom-policy routing. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::Arc; use async_trait::async_trait; @@ -99,32 +99,41 @@ fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec usize { let counted = tail.len().saturating_sub(recent_turn_window); - (0..=counted) - .rev() - .find(|start| !has_orphan_tool_result(&tail[*start..])) - .unwrap_or(counted) -} - -/// Whether `window` holds a tool result whose originating call is not also in `window`. -fn has_orphan_tool_result(window: &[&Message]) -> bool { - let blocks = || window.iter().flat_map(|message| &message.content); - let calls: BTreeSet<&str> = blocks() - .filter_map(|block| match block { - ContentBlock::ToolCall(call) => Some(call.id.as_str()), - _ => None, - }) - .collect(); - blocks().any(|block| match block { - ContentBlock::ToolResult(result) => !calls.contains(result.tool_call_id.as_str()), - _ => false, - }) + // An empty window holds no result to pair, and the loop below never visits its start. + if counted == tail.len() { + return counted; + } + let mut unpaired: HashSet<&str> = HashSet::new(); + for (start, message) in tail.iter().enumerate().rev() { + // Blocks reverse too, so a call answers a result only when it precedes it inside + // one message as well as across messages. + for block in message.content.iter().rev() { + match block { + ContentBlock::ToolResult(result) => { + unpaired.insert(result.tool_call_id.as_str()); + } + ContentBlock::ToolCall(call) => { + unpaired.remove(call.id.as_str()); + } + _ => {} + } + } + if start <= counted && unpaired.is_empty() { + return start; + } + } + counted } /// Keeps the opening task and the latest user follow-up when they differ. @@ -1511,32 +1520,6 @@ mod tests { } } - /// Tool-call ids answered by a result that no kept call introduced. - fn orphan_tool_results(messages: &[Message]) -> Vec { - let blocks = |messages: &[Message]| { - messages - .iter() - .flat_map(|message| message.content.clone()) - .collect::>() - }; - let calls = blocks(messages) - .into_iter() - .filter_map(|block| match block { - ContentBlock::ToolCall(call) => Some(call.id), - _ => None, - }) - .collect::>(); - blocks(messages) - .into_iter() - .filter_map(|block| match block { - ContentBlock::ToolResult(result) if !calls.contains(&result.tool_call_id) => { - Some(result.tool_call_id) - } - _ => None, - }) - .collect() - } - /// A count-based window can begin on a tool result, which leaves the call that /// introduced its id outside the window and the classifier history invalid. #[test] @@ -1556,28 +1539,78 @@ mod tests { // The five-message tail begins exactly on the tool result. let kept = trim_messages(&messages, 5); - assert_eq!(orphan_tool_results(&kept), Vec::::new()); + assert_eq!( + kept, + vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + tool_call("call-1"), + tool_result("call-1"), + Message::text(Role::Assistant, "recent 1"), + Message::text(Role::User, "recent 2"), + Message::text(Role::Assistant, "recent 3"), + Message::text(Role::User, "recent 4"), + ] + ); + } + + /// Ids repeat across a conversation, so a later call must not stand in for the one that + /// answers an earlier result. + #[test] + fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() { + let messages = vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + tool_call("x"), + tool_result("x"), + Message::text(Role::Assistant, "later"), + tool_call("x"), + tool_result("x"), + ]; + + // The four-message tail begins on the first result, whose own call sits one earlier. + let kept = trim_messages(&messages, 4); + + assert_eq!( + kept, + vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + tool_call("x"), + tool_result("x"), + Message::text(Role::Assistant, "later"), + tool_call("x"), + tool_result("x"), + ] + ); } - /// Widening is only for tool pairs: a plain conversation keeps the window it asked for. + /// A result whose call precedes the opening task can never be paired, because trimming + /// never reaches behind the task. The window must not widen hunting for it. #[test] - fn trimming_without_tool_calls_keeps_only_the_window() { + fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() { let messages = vec![ Message::text(Role::System, "client instructions"), + tool_call("orphan"), Message::text(Role::User, "initial task"), Message::text(Role::Assistant, "old response"), - Message::text(Role::User, "recent 1"), - Message::text(Role::Assistant, "recent 2"), + tool_result("orphan"), + Message::text(Role::Assistant, "recent 1"), + Message::text(Role::User, "recent 2"), ]; - let kept = trim_messages(&messages, 2); + let kept = trim_messages(&messages, 3); - let contents = kept - .iter() - .filter_map(|message| message.text_content("\n")) - .collect::>(); - assert!(!contents.contains(&"old response".to_string())); - assert_eq!(kept.len(), 4); + assert_eq!( + kept, + vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + tool_result("orphan"), + Message::text(Role::Assistant, "recent 1"), + Message::text(Role::User, "recent 2"), + ] + ); } #[test]