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::{Category, ContentBlock, InstructionBlock, Message, Role};
13
14use super::classifier_contract::{ClassifierContract, ClassifierContractConfig, validate_prompt};
15use super::llm_judge::{
16    ClassifierInput, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig, SerdeDecoder,
17    StructuredJudge,
18};
19use crate::core::algorithm::Driver;
20use crate::core::classifier::{Classification, Score};
21use crate::core::state::State;
22use crate::{LibsyError, Result};
23use switchyard_protocol::Request;
24
25const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
26const DEESCALATION_PROMPT: &str = include_str!("../../prompts/escalation/deescalation.md");
27const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
28
29/// Separator marking where [`truncate_middle`] dropped a message's interior.
30const TRIM_MARKER: &str = " ...[trimmed] ";
31
32/// Suffix marking a transcript cut off by [`MAX_REQUEST_CHARS`].
33const TRUNCATION_SUFFIX: &str = "...<truncated>";
34
35/// Per-message cap for system and developer anchors, which carry no trajectory signal but
36/// which coding-agent harnesses make very large.
37const SYSTEM_CHARS: usize = 1_000;
38
39/// Per-message cap for task-framing user messages — every user message that precedes the first
40/// assistant reply. Coding-agent harnesses often send environment boilerplate as the first user
41/// message and the task itself as the second, so anchoring only the first would pin the
42/// boilerplate and let the task scroll out of the window. Feature specifications run to several
43/// thousand characters, so this gets the widest anchor budget.
44const TASK_CHARS: usize = 4_000;
45
46/// Backstop on the assembled transcript; the per-message caps normally bind first.
47const MAX_REQUEST_CHARS: usize = 18_000;
48
49/// Optional policy for returning an escalated session to the efficient tier.
50#[derive(Clone, Copy, Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct DeescalationConfig {
53    /// Minimum number of capable-tier turns before the judge may release the session.
54    pub strong_min_calls: u32,
55    /// Optional hard limit on capable-tier turns before a forced return.
56    #[serde(default)]
57    pub strong_max_calls: Option<u32>,
58    /// Consecutive judge declines required to return to the efficient tier.
59    pub confirmations: u32,
60    /// Efficient calls served without judging after a hard-limit return.
61    #[serde(default)]
62    pub weak_cooldown_calls: u32,
63}
64
65impl DeescalationConfig {
66    fn validate(&self) -> Result<()> {
67        let reject = |message: &str| {
68            Err(LibsyError::AlgorithmError {
69                message: message.to_string(),
70            })
71        };
72        if self.strong_min_calls == 0 {
73            return reject("deescalation.strong_min_calls must be at least 1");
74        }
75        if self.confirmations == 0 {
76            return reject("deescalation.confirmations must be at least 1");
77        }
78        if self
79            .strong_max_calls
80            .is_some_and(|strong_max_calls| strong_max_calls < self.strong_min_calls)
81        {
82            return reject(
83                "deescalation.strong_max_calls must be at least deescalation.strong_min_calls",
84            );
85        }
86        Ok(())
87    }
88}
89
90/// The tuning surface for the trajectory judge.
91///
92/// The routing settings retain their benchmarked defaults. Everything else is a fixed invariant
93/// (the constants above).
94#[derive(Clone, Debug, Deserialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct EscalationJudgeConfig {
97    /// Consecutive escalate verdicts required before a turn moves to the capable tier, which
98    /// is also the turn that latches the session. Any decline clears the streak.
99    /// `1` escalates on the first verdict; the router's main cost dial.
100    /// `2` or higher needs a session id, since the streak is retained per session.
101    pub confirmations: u32,
102    /// Trailing messages shown on top of the anchors. A loop longer than this is invisible.
103    pub recent_turn_window: usize,
104    /// Per-message cap inside the trailing window.
105    pub window_message_chars: usize,
106    /// De-escalation policy. `None` preserves permanent latching to the capable tier.
107    pub deescalation: Option<DeescalationConfig>,
108}
109
110impl EscalationJudgeConfig {
111    /// Rejects settings that would leave the judge with nothing useful to read.
112    fn validate(&self) -> Result<()> {
113        let reject = |message: String| Err(LibsyError::AlgorithmError { message });
114        if self.confirmations == 0 {
115            return reject("confirmations must be at least 1".to_string());
116        }
117        if self.recent_turn_window == 0 {
118            return reject("recent_turn_window must be at least 1".to_string());
119        }
120        if self.window_message_chars < 50 {
121            return reject(format!(
122                "window_message_chars must be at least 50, got {}",
123                self.window_message_chars
124            ));
125        }
126        if let Some(deescalation) = self.deescalation {
127            deescalation.validate()?;
128        }
129        Ok(())
130    }
131}
132
133impl Default for EscalationJudgeConfig {
134    fn default() -> Self {
135        Self {
136            confirmations: 2,
137            recent_turn_window: 28,
138            window_message_chars: 500,
139            deescalation: None,
140        }
141    }
142}
143
144/// Router-controlled phase attached to judge input when de-escalation is enabled.
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub(crate) enum EvaluationPhase {
147    Efficient,
148    Strong,
149}
150
151impl EvaluationPhase {
152    fn marker(self) -> &'static str {
153        match self {
154            Self::Efficient => "EFFICIENT_EVALUATION",
155            Self::Strong => "STRONG_EVALUATION",
156        }
157    }
158}
159
160/// The judge's verdict. The schema also requires a `reason`, which makes the judge state its
161/// case and measurably sharpens the verdict. Routing reads only the boolean; the reason is
162/// kept solely so an operator can see why the judge held or escalated when the
163/// `switchyard_libsy::algorithms::util::escalation` target is enabled at `debug`.
164#[derive(Deserialize)]
165pub(crate) struct EscalationVerdict {
166    escalate: bool,
167    #[serde(default)]
168    reason: String,
169}
170
171/// Builds the condensed trajectory presented to the escalation judge.
172pub(crate) struct EscalationInput {
173    config: EscalationJudgeConfig,
174    phase: Option<EvaluationPhase>,
175}
176
177impl ClassifierInput for EscalationInput {
178    fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
179        let summary = summarize_for_judge(
180            &request.llm_request.instructions,
181            &request.llm_request.messages,
182            conversation_turn(request),
183            self.phase,
184            &self.config,
185        );
186        vec![Message::text(Role::User, summary)]
187    }
188}
189
190/// Structured trajectory judge with a typed escalation verdict.
191pub(crate) type EscalationJudge = StructuredJudge<EscalationInput, SerdeDecoder<EscalationVerdict>>;
192
193/// Maps the judge's verdict to a classification. A verdict names the tier to serve — capable
194/// on escalate, efficient on decline — so the caller reads it straight off the winning score.
195/// [`Classification::Ambiguous`] carries the unavailable case, which names no tier: both a
196/// decline and an outage stay efficient, but only a decline is evidence, so only a decline
197/// clears the streak.
198pub(crate) struct EscalationPolicy {
199    phase: Option<EvaluationPhase>,
200}
201
202impl JudgePolicy for EscalationPolicy {
203    type Verdict = EscalationVerdict;
204
205    fn to_classification(
206        &self,
207        verdict: Option<&EscalationVerdict>,
208        driver: &Driver,
209    ) -> Result<Classification> {
210        if let Some(verdict) = verdict {
211            tracing::debug!(
212                escalate = verdict.escalate,
213                reason = %verdict.reason,
214                "escalation judge verdict"
215            );
216        }
217        match verdict {
218            Some(verdict) if verdict.escalate => Ok(Classification::Scores(vec![Score {
219                target: driver.first_model_for(&Category::Capable)?.clone(),
220                confidence: 1.0,
221                category: Some(Category::Capable),
222            }])),
223            Some(_) => Ok(Classification::Scores(vec![Score {
224                target: driver.first_model_for(&Category::Efficient)?.clone(),
225                confidence: 1.0,
226                category: Some(Category::Efficient),
227            }])),
228            None => Ok(Classification::Ambiguous(Vec::new())),
229        }
230    }
231}
232
233/// Maps present verdicts to phase-aware evidence; absent verdicts add nothing.
234fn escalation_evidence(
235    policy: &EscalationPolicy,
236    verdict: Option<&EscalationVerdict>,
237) -> Option<Value> {
238    verdict.map(|verdict| {
239        let verdict = match (policy.phase, verdict.escalate) {
240            (Some(EvaluationPhase::Strong), true) => "retain",
241            (Some(EvaluationPhase::Strong), false) => "deescalate",
242            (_, true) => "escalate",
243            (_, false) => "continue",
244        };
245        serde_json::json!({
246            "source": "escalation",
247            "verdict": verdict,
248        })
249    })
250}
251
252/// Builds the trajectory judge, scoring the runtime capable category when it escalates.
253///
254/// Loads the packaged prompt and schema, so an unusable asset or an unusable `config` value
255/// fails here rather than on the first request.
256pub(crate) fn build_judge(
257    contract_config: &ClassifierContractConfig,
258    config: EscalationJudgeConfig,
259    phase: Option<EvaluationPhase>,
260    max_output_tokens: u64,
261) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
262    config.validate()?;
263    let contract = build_contract(contract_config, phase.is_some())?;
264    Ok(JudgeClassifier::new(
265        StructuredJudge::new(
266            EscalationInput { config, phase },
267            contract,
268            SerdeDecoder::new(),
269            JudgeRuntimeConfig::new(max_output_tokens)?,
270        ),
271        EscalationPolicy { phase },
272    )
273    .with_evidence(escalation_evidence))
274}
275
276fn build_contract(
277    contract_config: &ClassifierContractConfig,
278    phase_aware: bool,
279) -> Result<ClassifierContract> {
280    let prompt = contract_config.prompt().unwrap_or(PROMPT_TEMPLATE);
281    validate_prompt(prompt)?;
282    let phase_aware_config = phase_aware.then(|| {
283        contract_config.clone().with_prompt(format!(
284            "{}\n\n{}",
285            prompt.trim_end(),
286            DEESCALATION_PROMPT.trim()
287        ))
288    });
289    let contract_config = phase_aware_config.as_ref().unwrap_or(contract_config);
290    ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
291}
292
293/// The 1-indexed model invocation the transcript ends on: one per assistant reply.
294///
295/// The judge reads the turn *including* the reply it is judging, so the newest assistant
296/// message is this turn — no `+ 1`. Counting the caller's request instead would report the
297/// turn after the one under judgement.
298///
299/// Messages are already normalized by `switchyard-protocol`, so this needs no
300/// per-format branching.
301pub(crate) fn conversation_turn(request: &Request) -> usize {
302    request
303        .llm_request
304        .messages
305        .iter()
306        .filter(|message| message.role == Role::Assistant)
307        .count()
308}
309
310/// Flattens a message to plain text, tool calls and tool results included.
311///
312/// [`Message::text_content`] is deliberately not used here: it keeps only text and refusal
313/// blocks, which would erase exactly the repeated-command signal the judge's loop detection
314/// relies on.
315fn message_text(message: &Message) -> String {
316    let mut parts = Vec::new();
317    collect_text(&message.content, &mut parts);
318    parts.join(" ")
319}
320
321/// Appends the judge-relevant text of each block, descending into tool results.
322fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
323    for block in content {
324        match block {
325            ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
326                parts.push(text.clone());
327            }
328            ContentBlock::ToolCall(call) => {
329                parts.push(format!("tool_call {}({})", call.name, call.arguments));
330            }
331            ContentBlock::ToolResult(result) => collect_text(&result.content, parts),
332            _ => {}
333        }
334    }
335}
336
337/// Keeps the head and tail of `text` within `limit` characters.
338///
339/// The head gets two thirds of the surviving budget: for a trajectory judge the command or
340/// error signature that opens a message carries more signal than its trailing output.
341fn truncate_middle(text: &str, limit: usize) -> String {
342    let chars: Vec<char> = text.chars().collect();
343    if chars.len() <= limit {
344        return text.to_string();
345    }
346    let keep = limit
347        .saturating_sub(TRIM_MARKER.chars().count())
348        .max(20)
349        .min(chars.len());
350    let head = keep * 2 / 3;
351    let tail = keep - head;
352    let mut out: String = chars[..head].iter().collect();
353    out.push_str(TRIM_MARKER);
354    out.extend(chars[chars.len() - tail..].iter());
355    out
356}
357
358/// Renders a compact role-labelled transcript for the judge.
359///
360/// Task-framing user messages are capped individually. System/developer instructions share
361/// the remaining budget after reserving space for task framing and the newest window entry.
362/// The trailing window carries recent activity. A coverage header states how much history is
363/// not shown, so the judge can reason about pace rather than assuming it sees everything.
364///
365/// When the assembled text still exceeds `max_request_chars`, the oldest window lines go
366/// first: for a trajectory judge the newest evidence is strictly the most valuable.
367fn summarize_for_judge(
368    instructions: &[InstructionBlock],
369    messages: &[Message],
370    turn: usize,
371    phase: Option<EvaluationPhase>,
372    config: &EscalationJudgeConfig,
373) -> String {
374    let mut instruction_anchors: Vec<String> = Vec::new();
375    let mut anchors: Vec<String> = Vec::new();
376    let mut window: Vec<String> = Vec::new();
377    let mut assistant_seen = false;
378
379    for instruction in instructions {
380        let mut parts = Vec::new();
381        collect_text(&instruction.content, &mut parts);
382        instruction_anchors.push(format!(
383            "[{}] {}",
384            role_label(instruction.role),
385            truncate_middle(&parts.join(" "), SYSTEM_CHARS)
386        ));
387    }
388
389    for message in messages {
390        let text = message_text(message);
391        match message.role {
392            Role::System | Role::Developer => instruction_anchors.push(format!(
393                "[{}] {}",
394                role_label(message.role),
395                truncate_middle(&text, SYSTEM_CHARS)
396            )),
397            // Everything the user said before the agent first replied is task framing.
398            Role::User if !assistant_seen => {
399                anchors.push(format!(
400                    "[user (task)] {}",
401                    truncate_middle(&text, TASK_CHARS)
402                ));
403            }
404            role => {
405                if role == Role::Assistant {
406                    assistant_seen = true;
407                }
408                window.push(format!(
409                    "[{}] {}",
410                    role_label(role),
411                    truncate_middle(&text, config.window_message_chars)
412                ));
413            }
414        }
415    }
416
417    if window.len() > config.recent_turn_window {
418        window.drain(..window.len() - config.recent_turn_window);
419    }
420
421    let assemble = |instructions: Option<&str>, window: &[String]| {
422        let header = format!(
423            "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
424            window.len(),
425            messages.len(),
426        );
427        phase
428            .map(|phase| format!("Routing phase: {}", phase.marker()))
429            .into_iter()
430            .chain(std::iter::once(header))
431            .chain(instructions.map(str::to_owned))
432            .chain(anchors.iter().cloned())
433            .chain(window.iter().cloned())
434            .collect::<Vec<_>>()
435            .join("\n")
436    };
437
438    let reserved = assemble(None, &window[window.len().saturating_sub(1)..])
439        .chars()
440        .count();
441    let instruction_budget = MAX_REQUEST_CHARS.saturating_sub(reserved + 1);
442    // The remaining budget may be smaller than truncate_middle's minimum retained span.
443    let instruction_text = truncate_middle(&instruction_anchors.join("\n"), instruction_budget)
444        .chars()
445        .take(instruction_budget)
446        .collect::<String>();
447    let instructions = (!instruction_text.is_empty()).then_some(instruction_text.as_str());
448    let mut text = assemble(instructions, &window);
449    while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
450        window.remove(0);
451        text = assemble(instructions, &window);
452    }
453    if text.chars().count() > MAX_REQUEST_CHARS {
454        let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
455        text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
456    }
457    text
458}
459
460/// The transcript label for a role.
461fn role_label(role: Role) -> &'static str {
462    match role {
463        Role::System => "system",
464        Role::Developer => "developer",
465        Role::User => "user",
466        Role::Assistant => "assistant",
467        Role::Tool => "tool",
468    }
469}
470
471/// A request whose conversation sits at `turn`: `turn - 1` prior assistant replies, each
472/// answered by a further user message.
473///
474/// Shared with the assembled router's tests, which drive the same conversation shape.
475#[cfg(test)]
476pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
477    use switchyard_protocol::{LlmRequest, Metadata};
478
479    let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
480    for attempt in 1..turn {
481        messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
482        messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
483    }
484    Request {
485        llm_request: LlmRequest {
486            model: Some("auto".to_string()),
487            messages,
488            ..LlmRequest::default()
489        },
490        raw_request: None,
491        metadata: session_id.map(|id| Metadata {
492            session_id: Some(id.to_string()),
493            ..Metadata::default()
494        }),
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use serde_json::json;
501    use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
502
503    use super::*;
504    use crate::algorithms::util::llm_judge::Judge;
505
506    fn escalation_judge(
507        max_output_tokens: u64,
508        phase: Option<EvaluationPhase>,
509        contract_config: &ClassifierContractConfig,
510    ) -> Result<EscalationJudge> {
511        Ok(StructuredJudge::new(
512            EscalationInput {
513                config: EscalationJudgeConfig::default(),
514                phase,
515            },
516            build_contract(contract_config, phase.is_some())?,
517            SerdeDecoder::new(),
518            JudgeRuntimeConfig::new(max_output_tokens)?,
519        ))
520    }
521
522    #[test]
523    fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
524        let judge = escalation_judge(
525            super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
526            None,
527            &ClassifierContractConfig::default(),
528        )?;
529
530        // As the classifier calls it: the turn's reply is already on the transcript.
531        let mut judged = request_at_turn(None, 4);
532        judged.llm_request.instructions = [
533            (Role::System, "system constraint"),
534            (Role::Developer, "developer constraint"),
535        ]
536        .into_iter()
537        .map(|(role, text)| InstructionBlock {
538            role,
539            content: Message::text(role, text).content,
540        })
541        .collect();
542        judged
543            .llm_request
544            .messages
545            .push(Message::text(Role::Assistant, "this turn's reply"));
546        let built = judge.build_request(&State::default(), &judged);
547
548        // Rubric in instructions, condensed trajectory as the sole user message.
549        assert_eq!(built.llm_request.instructions.len(), 1);
550        assert_eq!(built.llm_request.instructions[0].role, Role::System);
551        assert_eq!(
552            built.llm_request.instructions[0].content.as_slice(),
553            &[ContentBlock::Text {
554                text: PROMPT_TEMPLATE.to_string()
555            }]
556        );
557        assert_eq!(built.llm_request.messages.len(), 1);
558        assert_eq!(built.llm_request.messages[0].role, Role::User);
559        let summary = built.llm_request.messages[0]
560            .text_content("")
561            .expect("summary");
562        assert!(summary.contains("Conversation turn 4"));
563        assert!(summary.contains(
564            "[system] system constraint\n[developer] developer constraint\n[user (task)] What is 2+2?"
565        ));
566        assert!(summary.contains("[assistant] this turn's reply"));
567        assert!(!summary.contains("Routing phase:"));
568        // Bounded output, so a reasoning judge cannot run away mid-verdict.
569        assert_eq!(
570            built.llm_request.output.max_output_tokens,
571            Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
572        );
573        assert!(built.llm_request.output.response_format.is_some());
574
575        judged.llm_request.instructions.extend(vec![
576            InstructionBlock {
577                role: Role::Developer,
578                content: Message::text(Role::Developer, "x".repeat(SYSTEM_CHARS)).content,
579            };
580            MAX_REQUEST_CHARS / SYSTEM_CHARS + 1
581        ]);
582        let built = judge.build_request(&State::default(), &judged);
583        let summary = built.llm_request.messages[0]
584            .text_content("")
585            .expect("summary");
586        assert!(summary.chars().count() <= MAX_REQUEST_CHARS);
587        assert!(summary.contains(TRIM_MARKER));
588        assert!(summary.contains("[system] system constraint"));
589        assert!(summary.contains("[developer] developer constraint"));
590        assert!(summary.contains("[user (task)] What is 2+2?"));
591        assert!(summary.contains("[assistant] this turn's reply"));
592        Ok(())
593    }
594
595    #[test]
596    fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
597        let judge = escalation_judge(512, None, &ClassifierContractConfig::default())?;
598
599        let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
600
601        assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
602        Ok(())
603    }
604
605    #[test]
606    fn conversation_turn_counts_assistant_replies() {
607        // The judge is handed the transcript with this turn's reply already appended, which
608        // is the shape asserted here: a request entering turn N plus its reply *is* turn N.
609        for turn in [1, 5] {
610            let mut judged = request_at_turn(None, turn);
611            judged
612                .llm_request
613                .messages
614                .push(Message::text(Role::Assistant, "this turn's reply"));
615            assert_eq!(conversation_turn(&judged), turn);
616        }
617    }
618
619    #[test]
620    fn message_text_keeps_tool_calls_and_results() {
621        let call = Message {
622            role: Role::Assistant,
623            content: vec![
624                ContentBlock::Text {
625                    text: "running it".to_string(),
626                },
627                ContentBlock::ToolCall(ToolCall {
628                    id: "call-1".to_string(),
629                    name: "bash".to_string(),
630                    arguments: json!({"cmd": "ls"}),
631                }),
632            ],
633        };
634        let text = message_text(&call);
635        assert!(text.contains("running it"), "{text}");
636        assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
637
638        let result = Message {
639            role: Role::Tool,
640            content: vec![ContentBlock::ToolResult(ToolResult {
641                tool_call_id: "call-1".to_string(),
642                content: vec![ContentBlock::Text {
643                    text: "no such file".to_string(),
644                }],
645                is_error: Some(true),
646            })],
647        };
648        assert_eq!(message_text(&result), "no such file");
649    }
650
651    #[test]
652    fn truncate_middle_keeps_head_and_tail() {
653        let text = "a".repeat(40) + &"z".repeat(40);
654        let trimmed = truncate_middle(&text, 50);
655        assert!(trimmed.chars().count() <= 50, "{trimmed}");
656        assert!(trimmed.starts_with('a'));
657        assert!(trimmed.ends_with('z'));
658        assert!(trimmed.contains("[trimmed]"));
659
660        // Under the limit the text is returned untouched.
661        assert_eq!(truncate_middle("short", 50), "short");
662    }
663
664    #[test]
665    fn deescalation_settings_must_be_valid() {
666        let zero = EscalationJudgeConfig {
667            deescalation: Some(DeescalationConfig {
668                strong_min_calls: 0,
669                strong_max_calls: None,
670                confirmations: 2,
671                weak_cooldown_calls: 0,
672            }),
673            ..EscalationJudgeConfig::default()
674        };
675        assert!(
676            zero.validate()
677                .is_err_and(|error| error.to_string().contains("at least 1"))
678        );
679
680        let inverted = EscalationJudgeConfig {
681            deescalation: Some(DeescalationConfig {
682                strong_min_calls: 4,
683                strong_max_calls: Some(3),
684                confirmations: 2,
685                weak_cooldown_calls: 0,
686            }),
687            ..EscalationJudgeConfig::default()
688        };
689        assert!(
690            inverted
691                .validate()
692                .is_err_and(|error| error.to_string().contains("at least deescalation"))
693        );
694    }
695
696    #[test]
697    fn deescalation_contract_marks_both_routing_phases() -> Result<()> {
698        let contract = ClassifierContractConfig::default().with_prompt("Custom trajectory rubric.");
699        for (phase, marker) in [
700            (EvaluationPhase::Efficient, "EFFICIENT_EVALUATION"),
701            (EvaluationPhase::Strong, "STRONG_EVALUATION"),
702        ] {
703            let judge = escalation_judge(512, Some(phase), &contract)?;
704            let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
705            let system_prompt = built.llm_request.instructions[0].content.iter().find_map(
706                |content| match content {
707                    ContentBlock::Text { text } => Some(text.as_str()),
708                    _ => None,
709                },
710            );
711            assert!(system_prompt.is_some_and(|prompt| {
712                prompt.starts_with("Custom trajectory rubric.")
713                    && prompt.contains("EFFICIENT_EVALUATION")
714                    && prompt.contains("STRONG_EVALUATION")
715            }));
716            assert!(
717                built.llm_request.messages[0]
718                    .text_content("")
719                    .is_some_and(|summary| summary.starts_with(&format!(
720                        "Routing phase: {marker}"
721                    )))
722            );
723        }
724        Ok(())
725    }
726
727    #[test]
728    fn summary_keeps_anchors_and_the_recent_window() {
729        let mut messages = vec![
730            Message::text(Role::System, "you are a coding agent"),
731            Message::text(Role::User, "fix the failing test"),
732        ];
733        for i in 0..10 {
734            messages.push(Message::text(Role::Assistant, format!("step {i}")));
735        }
736        let config = EscalationJudgeConfig {
737            recent_turn_window: 3,
738            ..EscalationJudgeConfig::default()
739        };
740
741        let summary = summarize_for_judge(&[], &messages, 11, None, &config);
742
743        assert!(
744            summary.contains("[system] you are a coding agent"),
745            "{summary}"
746        );
747        assert!(
748            summary.contains("[user (task)] fix the failing test"),
749            "{summary}"
750        );
751        assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
752        // Only the newest window entries survive.
753        assert!(summary.contains("step 9"), "{summary}");
754        assert!(summary.contains("step 7"), "{summary}");
755        assert!(!summary.contains("step 6"), "{summary}");
756    }
757
758    #[test]
759    fn summary_anchors_every_user_message_before_the_first_reply() {
760        // Codex sends environment boilerplate as the first user message and the task as the
761        // second. Both are framing; the task must stay visible after the window has moved on.
762        let mut messages = vec![
763            Message::text(
764                Role::Developer,
765                "<skills_instructions>...</skills_instructions>",
766            ),
767            Message::text(
768                Role::User,
769                "<environment_context><cwd>/app</cwd></environment_context>",
770            ),
771            Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
772        ];
773        for i in 0..40 {
774            messages.push(Message::text(Role::Assistant, format!("step {i}")));
775            messages.push(Message::text(Role::User, format!("later user note {i}")));
776        }
777        let config = EscalationJudgeConfig {
778            recent_turn_window: 3,
779            ..EscalationJudgeConfig::default()
780        };
781
782        let summary = summarize_for_judge(&[], &messages, 40, None, &config);
783
784        assert!(
785            summary.contains("[user (task)] <environment_context>"),
786            "{summary}"
787        );
788        assert!(
789            summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
790            "{summary}"
791        );
792        // User messages after the first reply are ordinary window entries, not anchors.
793        assert!(
794            !summary.contains("[user (task)] later user note"),
795            "{summary}"
796        );
797        assert!(summary.contains("[user] later user note 39"), "{summary}");
798        assert!(!summary.contains("later user note 0\n"), "{summary}");
799    }
800
801    #[test]
802    fn summary_drops_oldest_window_lines_under_the_char_cap() {
803        // MAX_REQUEST_CHARS is a backstop, not a dial: at default settings the window caps
804        // bind first (28 x 500 plus anchors sits under it), so reaching it takes an unusually
805        // wide per-message cap. That is the point — it only fires on pathological input.
806        let mut messages = vec![
807            Message::text(Role::System, "framing"),
808            Message::text(Role::User, "task"),
809        ];
810        for i in 0..20 {
811            messages.push(Message::text(
812                Role::Assistant,
813                format!("{i} {}", "x".repeat(2_000)),
814            ));
815        }
816        let config = EscalationJudgeConfig {
817            window_message_chars: 2_000,
818            ..EscalationJudgeConfig::default()
819        };
820
821        let summary = summarize_for_judge(&[], &messages, 21, None, &config);
822
823        assert!(
824            summary.chars().count() <= MAX_REQUEST_CHARS,
825            "{}",
826            summary.chars().count()
827        );
828        // Anchors are never dropped, and the newest activity outlives the oldest.
829        assert!(summary.contains("[system] framing"), "{summary}");
830        assert!(summary.contains("[user (task)] task"), "{summary}");
831        assert!(summary.contains("19 xxx"), "{summary}");
832        assert!(!summary.contains("0 xxx"), "{summary}");
833    }
834}