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