From 37499afeaadb03796ce38d40c1e017b6a84ecc3b Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Mon, 3 Aug 2026 19:36:46 +0000 Subject: [PATCH 1/3] fix(libsy): redact upstream error content from judge warning logs Signed-off-by: Giedrius Burachas --- crates/libsy/src/algorithms/util.rs | 1 + crates/libsy/src/algorithms/util/llm_judge.rs | 27 ++- .../libsy/src/algorithms/util/robustness.rs | 163 ++++++++++++++++++ 3 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 crates/libsy/src/algorithms/util/robustness.rs diff --git a/crates/libsy/src/algorithms/util.rs b/crates/libsy/src/algorithms/util.rs index 66b5ed982..b53e1303f 100644 --- a/crates/libsy/src/algorithms/util.rs +++ b/crates/libsy/src/algorithms/util.rs @@ -6,6 +6,7 @@ pub(crate) mod classifier_contract; pub mod escalation; pub(crate) mod llm_judge; pub(crate) mod prompts; +pub(crate) mod robustness; pub(crate) mod stage; pub mod subagent; pub(crate) mod target_selector; diff --git a/crates/libsy/src/algorithms/util/llm_judge.rs b/crates/libsy/src/algorithms/util/llm_judge.rs index 464544e24..2223e0b41 100644 --- a/crates/libsy/src/algorithms/util/llm_judge.rs +++ b/crates/libsy/src/algorithms/util/llm_judge.rs @@ -16,6 +16,8 @@ use switchyard_protocol::{ AggLlmResponse, InstructionBlock, LlmRequest, Message, OutputParams, Role, completion_text, }; +use super::robustness::{safe_client_error, safe_error_summary}; + use super::classifier_contract::ClassifierContract; use crate::core::algorithm::{Driver, LlmTarget}; use crate::core::classifier::{Classification, Classifier}; @@ -235,23 +237,40 @@ where ), ) .await - .inspect_err(|error| report_fail_open(judge_model, error, libsy_error_reason(error))) + .inspect_err(|error| { + report_fail_open( + judge_model, + safe_error_summary(error), + libsy_error_reason(error), + ) + }) .ok()?; let aggregate = response .llm_response .into_agg() .await - .inspect_err(|error| report_fail_open(judge_model, error, client_error_reason(error))) + .inspect_err(|error| { + report_fail_open( + judge_model, + safe_client_error(error), + client_error_reason(error), + ) + }) .ok()?; self.judge .parse(&aggregate) - .inspect_err(|error| report_fail_open(judge_model, error, "parse_error")) + .inspect_err(|error| { + report_fail_open(judge_model, safe_error_summary(error), "parse_error") + }) .ok() } } /// Logs and counts a judge failure with a bounded label that excludes message content. -fn report_fail_open(judge_model: &str, error: &dyn std::fmt::Display, reason: &'static str) { +/// `error` must already be redacted: `LlmClientError::UpstreamHttp`'s `Display` interpolates the +/// raw upstream body, which can quote the conversation back. Callers pass a +/// `robustness::safe_*` summary rather than the error itself. +fn report_fail_open(judge_model: &str, error: String, reason: &'static str) { tracing::warn!( target: "libsy", judge_model, diff --git a/crates/libsy/src/algorithms/util/robustness.rs b/crates/libsy/src/algorithms/util/robustness.rs new file mode 100644 index 000000000..9e6b1776c --- /dev/null +++ b/crates/libsy/src/algorithms/util/robustness.rs @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hardening helpers shared by classifier-style inner-LLM callers. +//! +//! Consulting a model to make a routing decision fails in ways the routed call does +//! not, and those failures are logged on a path that runs for every turn. The +//! natural `Display` of an error is not safe there: an upstream error body is echoed +//! verbatim, and both bodies and decode failures can embed the conversation or the +//! model's reply. [`safe_error_summary`] is the redaction those log sites use. + +use switchyard_protocol::LlmClientError; + +use crate::LibsyError; + +/// A loggable description of `error` that carries no conversation or response content. +/// +/// Each case contributes only vetted operational detail — the failure's class, the +/// target or model it concerns, and a status code where one exists. Messages libsy +/// builds itself from static strings are reported in full; anything sourced from +/// outside the process is reduced to its class. +/// +/// The match is deliberately exhaustive rather than defaulting to `to_string()`, so a +/// new [`LibsyError`] variant cannot start leaking content by omission. +pub(crate) fn safe_error_summary(error: &LibsyError) -> String { + match error { + LibsyError::ClientCall { target, source } => { + format!( + "client call to target {target:?} failed: {}", + safe_client_error(source) + ) + } + // Built by libsy from static text plus a serde position, so safe verbatim. + LibsyError::AlgorithmError { message } => message.clone(), + // A task panic message is arbitrary text from wherever the panic was raised, + // so only the fact of the failure is reported. + LibsyError::AlgorithmTask { .. } => "algorithm task failed".to_string(), + LibsyError::Driver(_) => "algorithm driver failed".to_string(), + // The operation is a static label, but the boxed source comes from a user + // extension and is unvetted. + LibsyError::External { operation, .. } => format!("{operation} failed"), + // The remaining variants render only structural detail — configured target + // names and fixed strings — and carry nothing request-derived. + LibsyError::TargetNotFound { .. } + | LibsyError::NoTargets + | LibsyError::MissingFinalResponse + | LibsyError::AllTargetsExcluded => error.to_string(), + } +} + +/// The client half of [`safe_error_summary`]. +/// +/// `UpstreamHttp` is the sharp edge: its `Display` interpolates the raw upstream +/// body, which routinely quotes the request back. Boxed transport, decode, and FFI +/// sources are reduced for the same reason. +pub(crate) fn safe_client_error(error: &LlmClientError) -> String { + match error { + LlmClientError::UpstreamHttp { status, .. } => format!("upstream HTTP {status}"), + LlmClientError::ContextWindowExceeded { model, .. } => { + format!("context window exceeded for model {model}") + } + LlmClientError::Timeout { .. } => "upstream request timed out".to_string(), + LlmClientError::Transport { .. } => "upstream transport error".to_string(), + LlmClientError::InvalidResponse { .. } => "invalid upstream response".to_string(), + LlmClientError::Ffi { .. } => "foreign function interface error".to_string(), + LlmClientError::InvalidRequest { .. } => "invalid request".to_string(), + LlmClientError::RequestTranslation(_) => "request translation failed".to_string(), + LlmClientError::RequestEncoding(_) => "outbound request encoding failed".to_string(), + LlmClientError::ResponseTranslation(_) => "response translation failed".to_string(), + // Raised by the client from its own configuration, not from a request. + LlmClientError::Configuration { message } => { + format!("client configuration error: {message}") + } + // `General` and any future variant are unvetted by construction. + _ => "client call failed".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The conversation text a leaking log line would expose. + const SECRET: &str = "patient name is Jane Doe"; + + #[test] + fn an_upstream_body_never_reaches_the_summary() { + let error = LibsyError::ClientCall { + target: "weak".to_string(), + source: LlmClientError::UpstreamHttp { + status: 400, + body: format!(r#"{{"error":{{"message":"bad request: {SECRET}"}}}}"#), + }, + }; + + let summary = safe_error_summary(&error); + + // The status and target survive; the body does not. + assert!(summary.contains("upstream HTTP 400"), "{summary}"); + assert!(summary.contains("weak"), "{summary}"); + assert!(!summary.contains(SECRET), "{summary}"); + // Guard against the redaction silently regressing to `Display`. + assert!(error.to_string().contains(SECRET)); + } + + #[test] + fn boxed_sources_are_reduced_to_their_class() { + for source in [ + LlmClientError::Transport { + source: std::io::Error::other(SECRET).into(), + }, + LlmClientError::InvalidResponse { + source: std::io::Error::other(SECRET).into(), + }, + LlmClientError::Timeout { + source: std::io::Error::other(SECRET).into(), + }, + ] { + let summary = safe_client_error(&source); + assert!(!summary.contains(SECRET), "{summary}"); + assert!(!summary.is_empty()); + } + } + + #[test] + fn context_overflow_keeps_the_model_but_not_the_message() { + let summary = safe_client_error(&LlmClientError::ContextWindowExceeded { + model: "weak".to_string(), + message: SECRET.to_string(), + }); + assert!(summary.contains("weak"), "{summary}"); + assert!(!summary.contains(SECRET), "{summary}"); + } + + #[test] + fn libsy_authored_messages_are_reported_in_full() { + // The parse path builds this from static text plus a serde position; it is + // the one message operators need verbatim to diagnose a bad judge reply. + let message = "judge reply did not parse as Verdict: expected value at line 1 column 1"; + let summary = safe_error_summary(&LibsyError::AlgorithmError { + message: message.to_string(), + }); + assert_eq!(summary, message); + } + + #[test] + fn an_extension_failure_keeps_its_label_but_not_its_source() { + let error = LibsyError::external("loading extension", std::io::Error::other(SECRET)); + + let summary = safe_error_summary(&error); + + assert_eq!(summary, "loading extension failed"); + assert!(error.to_string().contains(SECRET)); + } + + #[test] + fn structural_variants_keep_their_detail() { + let summary = safe_error_summary(&LibsyError::TargetNotFound { + target: "strong".to_string(), + }); + assert!(summary.contains("strong"), "{summary}"); + } +} From 25718355b14bc60352123a16f41922996cfe60bc Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Tue, 4 Aug 2026 18:36:03 +0000 Subject: [PATCH 2/3] feat(libsy): bound judge consultations with a configurable deadline Signed-off-by: Giedrius Burachas --- crates/libsy/src/algorithms/llm_class.rs | 15 +- crates/libsy/src/algorithms/util/llm_judge.rs | 132 ++++++++++++++---- crates/switchyard-py/src/libsy_bindings.rs | 5 +- crates/switchyard-server/src/config.rs | 16 +++ docs/reference/toml_schema.md | 1 + 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index be0536df7..c69b66263 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; use serde::{Deserialize, Deserializer}; @@ -244,6 +245,13 @@ pub struct TaskClassifierConfig { pub contract: ClassifierContractConfig, /// Maximum completion tokens available to the classifier verdict. pub max_output_tokens: u64, + /// Bounds one judge consultation, in milliseconds. + /// + /// The judge call sits in front of the routed call, so a stalled judge stalls the + /// turn. On expiry the judge counts as unavailable and routing falls back exactly + /// as it does for any other judge failure. `None` (the default) leaves it + /// unbounded, preserving existing behaviour. + pub judge_deadline_ms: Option, } /// Flat serialized shape that maps prompt settings into the runtime contract. @@ -263,6 +271,8 @@ struct TaskClassifierConfigWire { prompt: Option, #[serde(default = "default_judge_max_output_tokens")] max_output_tokens: u64, + #[serde(default)] + judge_deadline_ms: Option, } impl<'de> Deserialize<'de> for TaskClassifierConfig { @@ -283,6 +293,7 @@ impl<'de> Deserialize<'de> for TaskClassifierConfig { recent_turn_window: wire.recent_turn_window, contract, max_output_tokens: wire.max_output_tokens, + judge_deadline_ms: wire.judge_deadline_ms, }) } } @@ -301,6 +312,7 @@ impl Default for TaskClassifierConfig { recent_turn_window: None, contract: ClassifierContractConfig::default(), max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS, + judge_deadline_ms: None, } } } @@ -694,7 +706,8 @@ impl LlmTaskClassifier { }, contract, SerdeDecoder::new(), - JudgeRuntimeConfig::new(config.max_output_tokens)?, + JudgeRuntimeConfig::new(config.max_output_tokens)? + .with_deadline(config.judge_deadline_ms.map(Duration::from_millis))?, ), judge_target.clone(), TaskClassifierPolicy::new( diff --git a/crates/libsy/src/algorithms/util/llm_judge.rs b/crates/libsy/src/algorithms/util/llm_judge.rs index 2223e0b41..099b5246b 100644 --- a/crates/libsy/src/algorithms/util/llm_judge.rs +++ b/crates/libsy/src/algorithms/util/llm_judge.rs @@ -8,6 +8,7 @@ //! the route. use std::marker::PhantomData; +use std::time::Duration; use async_trait::async_trait; use serde::de::DeserializeOwned; @@ -95,6 +96,8 @@ impl VerdictDecoder for JsonSchemaDecoder { /// Runtime limits shared by structured classifier judges. pub(crate) struct JudgeRuntimeConfig { max_output_tokens: u64, + /// Bounds one consultation. `None` leaves it unbounded. + deadline: Option, } impl JudgeRuntimeConfig { @@ -104,7 +107,26 @@ impl JudgeRuntimeConfig { message: "max_output_tokens must be at least 1".to_string(), }); } - Ok(Self { max_output_tokens }) + Ok(Self { + max_output_tokens, + deadline: None, + }) + } + + /// Bounds how long one consultation may take before the judge counts as + /// unavailable. + /// + /// The judge call sits in front of the routed call, so a stalled judge stalls the + /// turn behind it with no bound of its own. Expiry folds into the same fail-open + /// path as any other judge failure, so the turn is still served. + pub(crate) fn with_deadline(mut self, deadline: Option) -> Result { + if deadline.is_some_and(|d| d.is_zero()) { + return Err(LibsyError::AlgorithmError { + message: "judge_deadline_ms must be greater than 0".to_string(), + }); + } + self.deadline = deadline; + Ok(self) } } @@ -142,6 +164,10 @@ where I: ClassifierInput, D: VerdictDecoder, { + fn deadline(&self) -> Option { + self.runtime.deadline + } + type Verdict = D::Verdict; fn build_request(&self, state: &State, request: &Request) -> Request { @@ -180,6 +206,13 @@ pub trait Judge: Send + Sync { fn parse(&self, response: &AggLlmResponse) -> Result { parse_json_verdict(response) } + + /// Bounds one consultation, when the judge is configured with a deadline. + /// + /// Defaulted so a judge that does not care about liveness need not implement it. + fn deadline(&self) -> Option { + None + } } /// Converts a parsed verdict, or an unavailable verdict, into a routing classification. @@ -227,36 +260,57 @@ where ) -> Option { let judge_model = self.target.semantic_name.as_str(); - let response = driver - .call_model( - self.judge.build_request(state, request), - Decision::new( - self.target.semantic_name.to_string(), - Some("llm judge consultation".to_string()), - false, - ), - ) - .await - .inspect_err(|error| { - report_fail_open( - judge_model, - safe_error_summary(error), - libsy_error_reason(error), + // The whole consultation is bounded, not just the HTTP call: a judge that + // returns its headers promptly and then stalls mid-stream would otherwise hold + // the turn open just as long. + let consult = async { + let response = driver + .call_model( + self.judge.build_request(state, request), + Decision::new( + self.target.semantic_name.to_string(), + Some("llm judge consultation".to_string()), + false, + ), ) - }) - .ok()?; - let aggregate = response - .llm_response - .into_agg() - .await - .inspect_err(|error| { - report_fail_open( - judge_model, - safe_client_error(error), - client_error_reason(error), - ) - }) - .ok()?; + .await + .inspect_err(|error| { + report_fail_open( + judge_model, + safe_error_summary(error), + libsy_error_reason(error), + ) + }) + .ok()?; + response + .llm_response + .into_agg() + .await + .inspect_err(|error| { + report_fail_open( + judge_model, + safe_client_error(error), + client_error_reason(error), + ) + }) + .ok() + }; + + let aggregate = match self.judge.deadline() { + Some(deadline) => match tokio::time::timeout(deadline, consult).await { + Ok(aggregate) => aggregate, + Err(_) => { + // Libsy-authored text with a configured duration: no upstream content. + report_fail_open( + judge_model, + format!("judge deadline of {} ms exceeded", deadline.as_millis()), + "deadline_exceeded", + ); + None + } + }, + None => consult.await, + }?; self.judge .parse(&aggregate) .inspect_err(|error| { @@ -354,6 +408,24 @@ fn strip_json_fence(text: &str) -> &str { #[cfg(test)] mod tests { + use std::time::Duration; + + #[test] + fn a_zero_judge_deadline_is_rejected() { + let runtime = JudgeRuntimeConfig::new(4096).expect("valid token budget"); + assert!(runtime.with_deadline(Some(Duration::ZERO)).is_err()); + + // A positive deadline, and no deadline at all, both configure cleanly. + let runtime = JudgeRuntimeConfig::new(4096).expect("valid token budget"); + assert!( + runtime + .with_deadline(Some(Duration::from_millis(50))) + .is_ok() + ); + let runtime = JudgeRuntimeConfig::new(4096).expect("valid token budget"); + assert!(runtime.with_deadline(None).is_ok()); + } + use super::*; use futures::StreamExt; diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index afc647fd7..5ee0189db 100644 --- a/crates/switchyard-py/src/libsy_bindings.rs +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -136,7 +136,8 @@ impl PyTaskClassifierConfig { message_hash_fallback=false, recent_turn_window=None, max_output_tokens=4096, - prompt=None + prompt=None, + judge_deadline_ms=None ))] #[allow(clippy::too_many_arguments)] fn new( @@ -147,6 +148,7 @@ impl PyTaskClassifierConfig { recent_turn_window: Option, max_output_tokens: u64, prompt: Option, + judge_deadline_ms: Option, ) -> Self { let mut contract = ClassifierContractConfig::default(); if let Some(prompt) = prompt { @@ -161,6 +163,7 @@ impl PyTaskClassifierConfig { recent_turn_window, contract, max_output_tokens, + judge_deadline_ms, }, } } diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index b644d4a04..b04a72929 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -305,6 +305,8 @@ struct CapabilityClassifierRouteConfig { recent_turn_window: Option, prompt: Option, max_output_tokens: u64, + /// Bounds one judge consultation, in milliseconds. Omit to leave it unbounded. + judge_deadline_ms: Option, } #[derive(Debug)] @@ -392,6 +394,13 @@ enum RouteConfig { prompt: Option, #[serde(default = "default_classifier_max_output_tokens")] max_output_tokens: u64, + /// Bounds one judge consultation, in milliseconds. + /// + /// The judge call sits in front of the routed call, so a stalled judge stalls + /// the turn. On expiry the judge counts as unavailable and the route falls back + /// as it does for any other judge failure. Omit to leave it unbounded. + #[serde(default)] + judge_deadline_ms: Option, #[serde(default)] escalation: Option, #[serde(default)] @@ -452,6 +461,9 @@ struct StageClassifierConfig { prompt: Option, #[serde(default = "default_classifier_max_output_tokens")] max_output_tokens: u64, + /// Bounds one judge consultation, in milliseconds. Omit to leave it unbounded. + #[serde(default)] + judge_deadline_ms: Option, } impl StageClassifierConfig { @@ -464,6 +476,7 @@ impl StageClassifierConfig { recent_turn_window: self.recent_turn_window, contract: classifier_contract(self.prompt.as_deref()), max_output_tokens: self.max_output_tokens, + judge_deadline_ms: self.judge_deadline_ms, } } } @@ -589,6 +602,7 @@ impl RouteConfig { recent_turn_window, prompt, max_output_tokens, + judge_deadline_ms, escalation, targets, default_target, @@ -646,6 +660,7 @@ impl RouteConfig { recent_turn_window: *recent_turn_window, prompt: prompt.clone(), max_output_tokens: *max_output_tokens, + judge_deadline_ms: *judge_deadline_ms, }, )) } @@ -858,6 +873,7 @@ fn build_algorithm( recent_turn_window: config.recent_turn_window, contract: classifier_contract(config.prompt.as_deref()), max_output_tokens: config.max_output_tokens, + judge_deadline_ms: config.judge_deadline_ms, }; LlmTaskClassifier::new(LlmClassifierConfig::Capability { judge_target: classifier, diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index c42d0560f..a360b738f 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -118,6 +118,7 @@ Runs one of three judge-backed modes: `capability`, `escalation`, or `custom`. | `mode` | No | `capability` | Classifier behavior. Set it explicitly for new configurations. | | `classifier_target` | Yes | — | Target the judge is called through. Not a routing destination. | | `max_output_tokens` | No | `4096` | Maximum completion tokens for the judge verdict. Must be at least `1`. | +| `judge_deadline_ms` | No | unset | Bounds one judge consultation, in milliseconds. On expiry the judge counts as unavailable and the route falls back as it does for any other judge failure. | Capability mode classifies before serving. See [LLM Classifier Routing](../routing_algorithms/llm_classifier_routing.md). From 68803ecf72cb101fa86d35fcfc0ef9c0d5c0ce01 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Mon, 3 Aug 2026 20:09:15 +0000 Subject: [PATCH 3/3] fix(libsy): reinforce the routing task after windowed conversation content Signed-off-by: Giedrius Burachas --- crates/libsy/src/algorithms/llm_class.rs | 67 +++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index c69b66263..e2f4ace59 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -33,6 +33,17 @@ const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/sch /// Telemetry label for this algorithm's spans, metrics, and logs. const ALGORITHM_NAME: &str = "llm_task_classifier"; +/// Restates the task after the conversation the judge is asked to route. +/// +/// The rubric leads the request, which works while the payload is a single short +/// message. Once `recent_turn_window` includes real conversation, those instructions +/// sit far from the generation point and the judge sometimes *answers* the +/// conversation instead of classifying it — the reply then fails to parse, the +/// verdict is unavailable, and the turn silently falls back. Repeating the task at +/// the end is what reliably prevents that. +const END_POSITION_REINFORCEMENT: &str = + "Route the conversation above. Output ONLY the routing JSON object, nothing else."; + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct TaskClassifierVerdict { @@ -156,10 +167,20 @@ impl ClassifierInput for TaskInput { fn build_messages(&self, _state: &State, request: &Request) -> Vec { // The default preserves the whole-task anchor and latest user update. A // configured window widens that to the surrounding conversation. - match self.recent_turn_window { + let mut messages = match self.recent_turn_window { Some(window) => trim_messages(&request.llm_request.messages, window), None => task_messages(&request.llm_request.messages), + }; + // 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. + if self.recent_turn_window.is_some() { + messages.push(Message::text( + Role::User, + END_POSITION_REINFORCEMENT.to_string(), + )); } + messages } } @@ -1615,6 +1636,50 @@ mod tests { ); } + #[test] + fn a_window_reinforces_the_task_after_the_conversation() -> Result<()> { + let contents = judged_contents(2)?; + + // Last, not merely present: the reinforcement only works end-positioned, + // after the conversation content it is meant to outrank. + assert_eq!( + contents.last().map(String::as_str), + Some(END_POSITION_REINFORCEMENT) + ); + Ok(()) + } + + #[test] + fn the_single_message_path_is_left_unreinforced() -> Result<()> { + // No window means no conversation to be distracted by, so the default + // request shape stays exactly as it was. + let judge = capability_judge(None)?; + let request = Request { + llm_request: LlmRequest { + messages: vec![Message::text(Role::User, "the task")], + ..LlmRequest::default() + }, + raw_request: None, + metadata: None, + }; + + let built = judge.build_request(&State::default(), &request); + + // The task message alone; the rubric reaches the judge as an instruction block + // rather than a message, so nothing was dropped by it not being counted here. + assert_eq!(built.llm_request.messages.len(), 1); + assert!(!built.llm_request.instructions.is_empty()); + assert!( + !built + .llm_request + .messages + .iter() + .filter_map(|message| message.text_content("\n")) + .any(|text| text.contains(END_POSITION_REINFORCEMENT)) + ); + Ok(()) + } + #[test] fn capability_judge_builds_a_structured_request() -> Result<()> { let judge = capability_judge(None)?;