Skip to main content

switchyard_libsy/algorithms/util/
escalation.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Trajectory-judge components for the escalation router — the judge, its verdict policy, and
5//! the transcript condenser they read.
6//!
7//! [`build_judge`] is the whole surface; the confirmation policy that consumes its verdicts
8//! lives with the assembled algorithm in [`crate::algorithms::escalation`].
9
10use serde::Deserialize;
11use switchyard_protocol::{ContentBlock, LlmRequest, Message, OutputParams, Role};
12
13use super::classifier_contract::{ClassifierContract, ClassifierContractConfig};
14use super::llm_judge::{Judge, JudgeClassifier, JudgePolicy};
15use crate::core::algorithm::LlmTarget;
16use crate::core::classifier::{Classification, Score};
17use crate::core::state::State;
18use crate::{LibsyError, Result};
19use switchyard_protocol::Request;
20
21const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
22const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
23
24/// Separator marking where [`truncate_middle`] dropped a message's interior.
25const TRIM_MARKER: &str = " ...[trimmed] ";
26
27/// Suffix marking a transcript cut off by [`MAX_REQUEST_CHARS`].
28const TRUNCATION_SUFFIX: &str = "...<truncated>";
29
30/// Per-message cap for system and developer anchors, which carry no trajectory signal but
31/// which coding-agent harnesses make very large.
32const SYSTEM_CHARS: usize = 1_000;
33
34/// Cap for the first user message — the task statement, so it gets the widest anchor budget.
35const FIRST_USER_CHARS: usize = 2_000;
36
37/// Backstop on the assembled transcript; the per-message caps normally bind first.
38const MAX_REQUEST_CHARS: usize = 18_000;
39
40/// The tuning surface for the trajectory judge.
41///
42/// The routing settings retain their benchmarked defaults. Everything else is a fixed invariant
43/// (the constants above).
44#[derive(Clone, Debug, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct EscalationJudgeConfig {
47    /// Consecutive escalate verdicts required before a turn moves to the capable tier, which
48    /// is also the turn that latches the session. Any decline clears the streak.
49    /// `1` escalates on the first verdict; the router's main cost dial.
50    /// `2` or higher needs a session id, since the streak is retained per session.
51    pub confirmations: u32,
52    /// Trailing messages shown on top of the anchors. A loop longer than this is invisible.
53    pub recent_turn_window: usize,
54    /// Per-message cap inside the trailing window.
55    pub window_message_chars: usize,
56}
57
58impl EscalationJudgeConfig {
59    /// Rejects settings that would leave the judge with nothing useful to read.
60    fn validate(&self) -> Result<()> {
61        let reject = |message: String| Err(LibsyError::AlgorithmError { message });
62        if self.confirmations == 0 {
63            return reject("confirmations must be at least 1".to_string());
64        }
65        if self.recent_turn_window == 0 {
66            return reject("recent_turn_window must be at least 1".to_string());
67        }
68        if self.window_message_chars < 50 {
69            return reject(format!(
70                "window_message_chars must be at least 50, got {}",
71                self.window_message_chars
72            ));
73        }
74        Ok(())
75    }
76}
77
78impl Default for EscalationJudgeConfig {
79    fn default() -> Self {
80        Self {
81            confirmations: 2,
82            recent_turn_window: 28,
83            window_message_chars: 500,
84        }
85    }
86}
87
88/// The judge's verdict. The schema also requires a `reason`, which makes the judge state its
89/// case and measurably sharpens the verdict — routing reads only the boolean, so it is
90/// deserialized away rather than carried.
91#[derive(Deserialize)]
92pub(crate) struct EscalationVerdict {
93    escalate: bool,
94}
95
96/// Builds the judge request from the conversation so far.
97pub(crate) struct EscalationJudge {
98    contract: ClassifierContract,
99    max_output_tokens: u64,
100    config: EscalationJudgeConfig,
101}
102
103impl Judge for EscalationJudge {
104    type Verdict = EscalationVerdict;
105
106    /// Renders the rubric as the system message and the condensed trajectory as the user
107    /// message. `state` is unused: the judge reads only the live request.
108    fn build_request(&self, _state: &State, request: &Request) -> Request {
109        let messages = &request.llm_request.messages;
110        let summary = summarize_for_judge(messages, conversation_turn(request), &self.config);
111        Request {
112            llm_request: LlmRequest {
113                model: request.llm_request.model.clone(),
114                messages: vec![
115                    Message::text(Role::System, self.contract.system_prompt().to_string()),
116                    Message::text(Role::User, summary),
117                ],
118                output: OutputParams {
119                    response_format: Some(self.contract.response_format().clone()),
120                    max_output_tokens: Some(self.max_output_tokens),
121                },
122                ..LlmRequest::default()
123            },
124            raw_request: None,
125            metadata: request.metadata.clone(),
126        }
127    }
128}
129
130/// Maps the judge's verdict to a classification. A verdict names the tier to serve — capable
131/// on escalate, efficient on decline — so the caller reads it straight off the winning score.
132/// [`Classification::Ambiguous`] carries the unavailable case, which names no tier: both a
133/// decline and an outage stay efficient, but only a decline is evidence, so only a decline
134/// clears the streak.
135pub(crate) struct EscalationPolicy {
136    capable: String,
137    efficient: String,
138}
139
140impl JudgePolicy for EscalationPolicy {
141    type Verdict = EscalationVerdict;
142
143    fn to_classification(&self, verdict: Option<&EscalationVerdict>) -> Classification {
144        match verdict {
145            Some(verdict) if verdict.escalate => Classification::Scores(vec![Score {
146                target: self.capable.clone(),
147                confidence: 1.0,
148            }]),
149            Some(_) => Classification::Scores(vec![Score {
150                target: self.efficient.clone(),
151                confidence: 1.0,
152            }]),
153            None => Classification::Ambiguous(Vec::new()),
154        }
155    }
156}
157
158/// Builds the trajectory judge over `judge_target`, scoring `capable` when it escalates.
159///
160/// Loads the packaged prompt and schema, so an unusable asset or an unusable `config` value
161/// fails here rather than on the first request.
162pub(crate) fn build_judge(
163    judge_target: LlmTarget,
164    capable: String,
165    efficient: String,
166    contract_config: &ClassifierContractConfig,
167    config: EscalationJudgeConfig,
168    max_output_tokens: u64,
169) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
170    config.validate()?;
171    if max_output_tokens == 0 {
172        return Err(LibsyError::AlgorithmError {
173            message: "max_output_tokens must be at least 1".to_string(),
174        });
175    }
176    let contract =
177        ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)?;
178    Ok(JudgeClassifier::new(
179        EscalationJudge {
180            contract,
181            max_output_tokens,
182            config,
183        },
184        judge_target,
185        EscalationPolicy { capable, efficient },
186    ))
187}
188
189/// The 1-indexed model invocation the transcript ends on: one per assistant reply.
190///
191/// The judge reads the turn *including* the reply it is judging, so the newest assistant
192/// message is this turn — no `+ 1`. Counting the caller's request instead would report the
193/// turn after the one under judgement.
194///
195/// Messages are already normalized by `switchyard-protocol`, so this needs no
196/// per-format branching.
197pub(crate) fn conversation_turn(request: &Request) -> usize {
198    request
199        .llm_request
200        .messages
201        .iter()
202        .filter(|message| message.role == Role::Assistant)
203        .count()
204}
205
206/// Flattens a message to plain text, tool calls and tool results included.
207///
208/// [`Message::text_content`] is deliberately not used here: it keeps only text and refusal
209/// blocks, which would erase exactly the repeated-command signal the judge's loop detection
210/// relies on.
211fn message_text(message: &Message) -> String {
212    let mut parts = Vec::new();
213    collect_text(&message.content, &mut parts);
214    parts.join(" ")
215}
216
217/// Appends the judge-relevant text of each block, descending into tool results.
218fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
219    for block in content {
220        match block {
221            ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
222                parts.push(text.clone());
223            }
224            ContentBlock::ToolCall(call) => {
225                parts.push(format!("tool_call {}({})", call.name, call.arguments));
226            }
227            ContentBlock::ToolResult(result) => collect_text(&result.content, parts),
228            _ => {}
229        }
230    }
231}
232
233/// Keeps the head and tail of `text` within `limit` characters.
234///
235/// The head gets two thirds of the surviving budget: for a trajectory judge the command or
236/// error signature that opens a message carries more signal than its trailing output.
237fn truncate_middle(text: &str, limit: usize) -> String {
238    let chars: Vec<char> = text.chars().collect();
239    if chars.len() <= limit {
240        return text.to_string();
241    }
242    let keep = limit
243        .saturating_sub(TRIM_MARKER.chars().count())
244        .max(20)
245        .min(chars.len());
246    let head = keep * 2 / 3;
247    let tail = keep - head;
248    let mut out: String = chars[..head].iter().collect();
249    out.push_str(TRIM_MARKER);
250    out.extend(chars[chars.len() - tail..].iter());
251    out
252}
253
254/// Renders a compact role-labelled transcript for the judge.
255///
256/// The framing anchors — system/developer messages and the first user message, where agent
257/// harnesses put the task statement — are kept unconditionally and capped individually. The
258/// trailing window carries recent activity. A coverage header states how much history is not
259/// shown, so the judge can reason about pace rather than assuming it sees everything.
260///
261/// When the assembled text still exceeds `max_request_chars`, the oldest window lines go
262/// first: for a trajectory judge the newest evidence is strictly the most valuable.
263fn summarize_for_judge(
264    messages: &[Message],
265    turn: usize,
266    config: &EscalationJudgeConfig,
267) -> String {
268    let mut anchors: Vec<String> = Vec::new();
269    let mut window: Vec<String> = Vec::new();
270    let mut first_user_seen = false;
271
272    for message in messages {
273        let text = message_text(message);
274        match message.role {
275            Role::System | Role::Developer => anchors.push(format!(
276                "[{}] {}",
277                role_label(message.role),
278                truncate_middle(&text, SYSTEM_CHARS)
279            )),
280            Role::User if !first_user_seen => {
281                first_user_seen = true;
282                anchors.push(format!(
283                    "[user (task)] {}",
284                    truncate_middle(&text, FIRST_USER_CHARS)
285                ));
286            }
287            role => window.push(format!(
288                "[{}] {}",
289                role_label(role),
290                truncate_middle(&text, config.window_message_chars)
291            )),
292        }
293    }
294
295    if window.len() > config.recent_turn_window {
296        window.drain(..window.len() - config.recent_turn_window);
297    }
298
299    let assemble = |window: &[String]| {
300        let header = format!(
301            "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
302            window.len(),
303            messages.len(),
304        );
305        std::iter::once(header)
306            .chain(anchors.iter().cloned())
307            .chain(window.iter().cloned())
308            .collect::<Vec<_>>()
309            .join("\n")
310    };
311
312    let mut text = assemble(&window);
313    while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
314        window.remove(0);
315        text = assemble(&window);
316    }
317    if text.chars().count() > MAX_REQUEST_CHARS {
318        let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
319        text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
320    }
321    text
322}
323
324/// The transcript label for a role.
325fn role_label(role: Role) -> &'static str {
326    match role {
327        Role::System => "system",
328        Role::Developer => "developer",
329        Role::User => "user",
330        Role::Assistant => "assistant",
331        Role::Tool => "tool",
332    }
333}
334
335/// A request whose conversation sits at `turn`: `turn - 1` prior assistant replies, each
336/// answered by a further user message.
337///
338/// Shared with the assembled router's tests, which drive the same conversation shape.
339#[cfg(test)]
340pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
341    use switchyard_protocol::Metadata;
342
343    let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
344    for attempt in 1..turn {
345        messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
346        messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
347    }
348    Request {
349        llm_request: LlmRequest {
350            model: Some("auto".to_string()),
351            messages,
352            ..LlmRequest::default()
353        },
354        raw_request: None,
355        metadata: session_id.map(|id| Metadata {
356            session_id: Some(id.to_string()),
357            ..Metadata::default()
358        }),
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use serde_json::json;
365    use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
366
367    use super::*;
368
369    #[test]
370    fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
371        let judge = EscalationJudge {
372            contract: ClassifierContract::from_config(
373                &ClassifierContractConfig::default(),
374                PROMPT_TEMPLATE,
375                SCHEMA_TEMPLATE,
376            )?,
377            max_output_tokens: super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
378            config: EscalationJudgeConfig::default(),
379        };
380
381        // As the classifier calls it: the turn's reply is already on the transcript.
382        let mut judged = request_at_turn(None, 4);
383        judged
384            .llm_request
385            .messages
386            .push(Message::text(Role::Assistant, "this turn's reply"));
387        let built = judge.build_request(&State::default(), &judged);
388
389        // Two messages: the rubric as system, the condensed trajectory as user.
390        assert_eq!(built.llm_request.messages.len(), 2);
391        assert_eq!(built.llm_request.messages[0].role, Role::System);
392        assert_eq!(built.llm_request.messages[1].role, Role::User);
393        assert!(
394            built.llm_request.messages[1]
395                .text_content("")
396                .is_some_and(|text| text.contains("Conversation turn 4"))
397        );
398        // Bounded output, so a reasoning judge cannot run away mid-verdict.
399        assert_eq!(
400            built.llm_request.output.max_output_tokens,
401            Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
402        );
403        assert!(built.llm_request.output.response_format.is_some());
404        Ok(())
405    }
406
407    #[test]
408    fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
409        let judge = EscalationJudge {
410            contract: ClassifierContract::from_config(
411                &ClassifierContractConfig::default(),
412                PROMPT_TEMPLATE,
413                SCHEMA_TEMPLATE,
414            )?,
415            max_output_tokens: 512,
416            config: EscalationJudgeConfig::default(),
417        };
418
419        let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
420
421        assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
422        Ok(())
423    }
424
425    #[test]
426    fn conversation_turn_counts_assistant_replies() {
427        // The judge is handed the transcript with this turn's reply already appended, which
428        // is the shape asserted here: a request entering turn N plus its reply *is* turn N.
429        for turn in [1, 5] {
430            let mut judged = request_at_turn(None, turn);
431            judged
432                .llm_request
433                .messages
434                .push(Message::text(Role::Assistant, "this turn's reply"));
435            assert_eq!(conversation_turn(&judged), turn);
436        }
437    }
438
439    #[test]
440    fn message_text_keeps_tool_calls_and_results() {
441        let call = Message {
442            role: Role::Assistant,
443            content: vec![
444                ContentBlock::Text {
445                    text: "running it".to_string(),
446                },
447                ContentBlock::ToolCall(ToolCall {
448                    id: "call-1".to_string(),
449                    name: "bash".to_string(),
450                    arguments: json!({"cmd": "ls"}),
451                }),
452            ],
453        };
454        let text = message_text(&call);
455        assert!(text.contains("running it"), "{text}");
456        assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
457
458        let result = Message {
459            role: Role::Tool,
460            content: vec![ContentBlock::ToolResult(ToolResult {
461                tool_call_id: "call-1".to_string(),
462                content: vec![ContentBlock::Text {
463                    text: "no such file".to_string(),
464                }],
465                is_error: Some(true),
466            })],
467        };
468        assert_eq!(message_text(&result), "no such file");
469    }
470
471    #[test]
472    fn truncate_middle_keeps_head_and_tail() {
473        let text = "a".repeat(40) + &"z".repeat(40);
474        let trimmed = truncate_middle(&text, 50);
475        assert!(trimmed.chars().count() <= 50, "{trimmed}");
476        assert!(trimmed.starts_with('a'));
477        assert!(trimmed.ends_with('z'));
478        assert!(trimmed.contains("[trimmed]"));
479
480        // Under the limit the text is returned untouched.
481        assert_eq!(truncate_middle("short", 50), "short");
482    }
483
484    #[test]
485    fn summary_keeps_anchors_and_the_recent_window() {
486        let mut messages = vec![
487            Message::text(Role::System, "you are a coding agent"),
488            Message::text(Role::User, "fix the failing test"),
489        ];
490        for i in 0..10 {
491            messages.push(Message::text(Role::Assistant, format!("step {i}")));
492        }
493        let config = EscalationJudgeConfig {
494            recent_turn_window: 3,
495            ..EscalationJudgeConfig::default()
496        };
497
498        let summary = summarize_for_judge(&messages, 11, &config);
499
500        assert!(
501            summary.contains("[system] you are a coding agent"),
502            "{summary}"
503        );
504        assert!(
505            summary.contains("[user (task)] fix the failing test"),
506            "{summary}"
507        );
508        assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
509        // Only the newest window entries survive.
510        assert!(summary.contains("step 9"), "{summary}");
511        assert!(summary.contains("step 7"), "{summary}");
512        assert!(!summary.contains("step 6"), "{summary}");
513    }
514
515    #[test]
516    fn summary_drops_oldest_window_lines_under_the_char_cap() {
517        // MAX_REQUEST_CHARS is a backstop, not a dial: at default settings the window caps
518        // bind first (28 x 500 plus anchors sits under it), so reaching it takes an unusually
519        // wide per-message cap. That is the point — it only fires on pathological input.
520        let mut messages = vec![
521            Message::text(Role::System, "framing"),
522            Message::text(Role::User, "task"),
523        ];
524        for i in 0..20 {
525            messages.push(Message::text(
526                Role::Assistant,
527                format!("{i} {}", "x".repeat(2_000)),
528            ));
529        }
530        let config = EscalationJudgeConfig {
531            window_message_chars: 2_000,
532            ..EscalationJudgeConfig::default()
533        };
534
535        let summary = summarize_for_judge(&messages, 21, &config);
536
537        assert!(
538            summary.chars().count() <= MAX_REQUEST_CHARS,
539            "{}",
540            summary.chars().count()
541        );
542        // Anchors are never dropped, and the newest activity outlives the oldest.
543        assert!(summary.contains("[system] framing"), "{summary}");
544        assert!(summary.contains("[user (task)] task"), "{summary}");
545        assert!(summary.contains("19 xxx"), "{summary}");
546        assert!(!summary.contains("0 xxx"), "{summary}");
547    }
548}