From 2c5a8680ad9709e7a32e2b4444f66933e928b2e9 Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Thu, 3 Sep 2026 16:23:57 +0300 Subject: [PATCH] fix(libsy): drop reasoning from task classifier history Signed-off-by: Eugen Nekhai --- crates/libsy/src/algorithms/llm_class.rs | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index 599e9f9e0..2b5f99ebc 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -173,6 +173,16 @@ impl ClassifierInput for TaskInput { Some(window) => trim_messages(&request.llm_request.messages, window), None => task_messages(&request.llm_request.messages), }; + // Reasoning is provider-private and not required to classify the task. Some + // upstreams also reject an unsigned reasoning item replayed without the + // opaque state it was issued with, so it cannot travel through a windowed + // classifier request as ordinary history. + for message in &mut messages { + message + .content + .retain(|block| !matches!(block, ContentBlock::Reasoning { .. })); + } + messages.retain(|message| !message.content.is_empty()); // Only the windowed path carries assistant turns and tool traffic for the judge // to be distracted by. The default path is user task messages only — the anchor // and the latest follow-up — so there is nothing there to outrank. @@ -1467,6 +1477,74 @@ mod tests { Ok(()) } + /// Reasoning is provider-private and some upstreams reject it replayed without the + /// opaque state it was issued with, so it must not reach a windowed classifier + /// request. Visible assistant text and complete tool pairs still do, and a turn + /// whose only content was reasoning is dropped rather than left behind empty. + #[test] + fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() { + let messages = vec![ + Message::text(Role::System, "client instructions"), + Message::text(Role::User, "initial task"), + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Reasoning { + text: "private chain of thought".to_string(), + signature: None, + details: Vec::new(), + }, + ContentBlock::Text { + text: "visible answer".to_string(), + }, + ], + }, + tool_call("call-1"), + tool_result("call-1"), + Message { + role: Role::Assistant, + content: vec![ContentBlock::Reasoning { + text: "reasoning-only turn".to_string(), + signature: None, + details: Vec::new(), + }], + }, + Message::text(Role::User, "follow-up"), + ]; + let request = Request { + llm_request: LlmRequest { + messages, + ..LlmRequest::default() + }, + raw_request: None, + metadata: None, + }; + + let built = TaskInput { + recent_turn_window: Some(10), + } + .build_messages(&State::default(), &request); + + assert!( + !built + .iter() + .flat_map(|message| &message.content) + .any(|block| matches!(block, ContentBlock::Reasoning { .. })), + "{built:?}" + ); + assert!( + built + .iter() + .any(|message| message.text_content("\n").as_deref() == Some("visible answer")) + ); + assert!(built.contains(&tool_call("call-1"))); + assert!(built.contains(&tool_result("call-1"))); + // Six of the seven fixtures survive — the reasoning-only turn is gone entirely + // rather than left behind empty — plus the trailing routing instruction. + assert_eq!(built.len(), 7); + assert!(built.iter().all(|message| !message.content.is_empty())); + } + #[test] fn the_default_path_is_left_unchanged() -> Result<()> { // No window means no conversation to be distracted by, so the default