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 fresh-evidence verdicts in the same category required before a turn moves to
98    /// the capable tier, which is also the turn that latches the session. Any decline or stale
99    /// evidence clears the streak.
100    /// `1` escalates on the first verdict; the router's main cost dial.
101    /// `2` or higher needs a session id, since the streak is retained per session.
102    pub confirmations: u32,
103    /// Trailing messages shown on top of the anchors. A loop longer than this is invisible.
104    pub recent_turn_window: usize,
105    /// Per-message cap inside the trailing window.
106    pub window_message_chars: usize,
107    /// De-escalation policy. `None` preserves permanent latching to the capable tier.
108    pub deescalation: Option<DeescalationConfig>,
109}
110
111impl EscalationJudgeConfig {
112    /// Rejects settings that would leave the judge with nothing useful to read.
113    fn validate(&self) -> Result<()> {
114        let reject = |message: String| Err(LibsyError::AlgorithmError { message });
115        if self.confirmations == 0 {
116            return reject("confirmations must be at least 1".to_string());
117        }
118        if self.recent_turn_window == 0 {
119            return reject("recent_turn_window must be at least 1".to_string());
120        }
121        if self.window_message_chars < 50 {
122            return reject(format!(
123                "window_message_chars must be at least 50, got {}",
124                self.window_message_chars
125            ));
126        }
127        if let Some(deescalation) = self.deescalation {
128            deescalation.validate()?;
129        }
130        Ok(())
131    }
132}
133
134impl Default for EscalationJudgeConfig {
135    fn default() -> Self {
136        Self {
137            confirmations: 2,
138            recent_turn_window: 28,
139            window_message_chars: 500,
140            deescalation: None,
141        }
142    }
143}
144
145/// Router-controlled phase attached to judge input when de-escalation is enabled.
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub(crate) enum EvaluationPhase {
148    Efficient,
149    Strong,
150}
151
152impl EvaluationPhase {
153    fn marker(self) -> &'static str {
154        match self {
155            Self::Efficient => "EFFICIENT_EVALUATION",
156            Self::Strong => "STRONG_EVALUATION",
157        }
158    }
159}
160
161/// Bounded trouble pattern used to correlate escalation confirmations across turns.
162#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
163#[serde(rename_all = "snake_case")]
164pub(crate) enum EscalationCategory {
165    None,
166    Repetition,
167    FalseProgress,
168    Drift,
169    Desperation,
170    CapabilityGap,
171}
172
173impl EscalationCategory {
174    /// Stable state and telemetry label.
175    pub(crate) const fn label(self) -> &'static str {
176        match self {
177            Self::None => "none",
178            Self::Repetition => "repetition",
179            Self::FalseProgress => "false_progress",
180            Self::Drift => "drift",
181            Self::Desperation => "desperation",
182            Self::CapabilityGap => "capability_gap",
183        }
184    }
185}
186
187/// The judge's typed verdict, including the evidence needed to confirm a stable pattern.
188#[derive(Deserialize)]
189pub(crate) struct EscalationVerdict {
190    pub(crate) escalate: bool,
191    pub(crate) category: EscalationCategory,
192    pub(crate) new_evidence: bool,
193    pub(crate) reason: String,
194}
195
196/// Builds the condensed trajectory presented to the escalation judge.
197pub(crate) struct EscalationInput {
198    config: EscalationJudgeConfig,
199    phase: Option<EvaluationPhase>,
200}
201
202impl ClassifierInput for EscalationInput {
203    fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
204        let summary = summarize_for_judge(
205            &request.llm_request.instructions,
206            &request.llm_request.messages,
207            conversation_turn(request),
208            self.phase,
209            &self.config,
210        );
211        vec![Message::text(Role::User, summary)]
212    }
213}
214
215/// Structured trajectory judge with a typed escalation verdict.
216pub(crate) type EscalationJudge = StructuredJudge<EscalationInput, SerdeDecoder<EscalationVerdict>>;
217
218/// Maps the judge's verdict to a classification. A verdict names the tier to serve — capable
219/// on escalate, efficient on decline — so the caller reads it straight off the winning score.
220/// [`Classification::Ambiguous`] carries the unavailable case, which names no tier: both a
221/// decline and an outage stay efficient, but only a decline is evidence, so only a decline
222/// clears the streak.
223pub(crate) struct EscalationPolicy {
224    phase: Option<EvaluationPhase>,
225}
226
227impl JudgePolicy for EscalationPolicy {
228    type Verdict = EscalationVerdict;
229
230    fn to_classification(
231        &self,
232        verdict: Option<&EscalationVerdict>,
233        driver: &Driver,
234    ) -> Result<Classification> {
235        if let Some(verdict) = verdict {
236            tracing::debug!(
237                escalate = verdict.escalate,
238                reason = %verdict.reason,
239                "escalation judge verdict"
240            );
241        }
242        match verdict {
243            Some(verdict) if verdict.escalate => Ok(Classification::Scores(vec![Score {
244                target: driver.first_model_for(&Category::Capable)?.clone(),
245                confidence: 1.0,
246                category: Some(Category::Capable),
247            }])),
248            Some(_) => Ok(Classification::Scores(vec![Score {
249                target: driver.first_model_for(&Category::Efficient)?.clone(),
250                confidence: 1.0,
251                category: Some(Category::Efficient),
252            }])),
253            None => Ok(Classification::Ambiguous(Vec::new())),
254        }
255    }
256}
257
258/// Maps present verdicts to phase-aware evidence; absent verdicts add nothing.
259fn escalation_evidence(
260    policy: &EscalationPolicy,
261    verdict: Option<&EscalationVerdict>,
262) -> Option<Value> {
263    verdict.map(|verdict| {
264        let verdict = match (policy.phase, verdict.escalate) {
265            (Some(EvaluationPhase::Strong), true) => "retain",
266            (Some(EvaluationPhase::Strong), false) => "deescalate",
267            (_, true) => "escalate",
268            (_, false) => "continue",
269        };
270        serde_json::json!({
271            "source": "escalation",
272            "verdict": verdict,
273        })
274    })
275}
276
277/// Builds the trajectory judge, scoring the runtime capable category when it escalates.
278///
279/// Loads the packaged prompt and schema, so an unusable asset or an unusable `config` value
280/// fails here rather than on the first request.
281pub(crate) fn build_judge(
282    contract_config: &ClassifierContractConfig,
283    config: EscalationJudgeConfig,
284    phase: Option<EvaluationPhase>,
285    max_output_tokens: u64,
286) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
287    config.validate()?;
288    let contract = build_contract(contract_config, phase.is_some())?;
289    Ok(JudgeClassifier::new(
290        StructuredJudge::new(
291            EscalationInput { config, phase },
292            contract,
293            SerdeDecoder::new(),
294            JudgeRuntimeConfig::new(max_output_tokens)?,
295        ),
296        EscalationPolicy { phase },
297    )
298    .with_evidence(escalation_evidence))
299}
300
301fn build_contract(
302    contract_config: &ClassifierContractConfig,
303    phase_aware: bool,
304) -> Result<ClassifierContract> {
305    let prompt = contract_config.prompt().unwrap_or(PROMPT_TEMPLATE);
306    validate_prompt(prompt)?;
307    let phase_aware_config = phase_aware.then(|| {
308        contract_config.clone().with_prompt(format!(
309            "{}\n\n{}",
310            prompt.trim_end(),
311            DEESCALATION_PROMPT.trim()
312        ))
313    });
314    let contract_config = phase_aware_config.as_ref().unwrap_or(contract_config);
315    ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
316}
317
318/// The 1-indexed model invocation the transcript ends on: one per assistant reply.
319///
320/// The judge reads the turn *including* the reply it is judging, so the newest assistant
321/// message is this turn — no `+ 1`. Counting the caller's request instead would report the
322/// turn after the one under judgement.
323///
324/// Messages are already normalized by `switchyard-protocol`, so this needs no
325/// per-format branching.
326pub(crate) fn conversation_turn(request: &Request) -> usize {
327    request
328        .llm_request
329        .messages
330        .iter()
331        .filter(|message| message.role == Role::Assistant)
332        .count()
333}
334
335/// Flattens a message to plain text, tool calls and tool results included.
336///
337/// [`Message::text_content`] is deliberately not used here: it keeps only text and refusal
338/// blocks, which would erase exactly the repeated-command signal the judge's loop detection
339/// relies on.
340fn message_text(message: &Message) -> String {
341    let mut parts = Vec::new();
342    let terminus_commands = if message.role == Role::Assistant {
343        message
344            .content
345            .iter()
346            .filter_map(|block| match block {
347                ContentBlock::ToolCall(call) if call.name == "bash_command" => call
348                    .arguments
349                    .get("keystrokes")
350                    .and_then(|value| value.as_str()),
351                _ => None,
352            })
353            .collect::<Vec<_>>()
354    } else {
355        Vec::new()
356    };
357    collect_text(&message.content, &mut parts, &terminus_commands);
358    parts.join(" ")
359}
360
361/// Removes a Terminus command batch when a structured bash call carries the same action.
362///
363/// The model-facing request remains untouched. Only the judge's plain-text view is normalized,
364/// so one action cannot look like two attempts while the agent still sees its native history.
365fn without_duplicated_terminus_commands(text: &str, tool_commands: &[&str]) -> String {
366    let mut normalized_text = String::with_capacity(text.len());
367    let mut unmatched_tool_commands = tool_commands.to_vec();
368    let mut copied_through = 0;
369    let mut scan_from = 0;
370
371    while let Some(relative_start) = text[scan_from..].find('{') {
372        let start = scan_from + relative_start;
373
374        let mut values =
375            serde_json::Deserializer::from_str(&text[start..]).into_iter::<serde_json::Value>();
376        let Some(Ok(mut value)) = values.next() else {
377            scan_from = start + 1;
378            continue;
379        };
380        let end = start + values.byte_offset();
381        let Some(commands) = value
382            .get("commands")
383            .and_then(|commands| commands.as_array())
384        else {
385            scan_from = start + 1;
386            continue;
387        };
388        let Some(command_batch) = commands
389            .iter()
390            .map(|command| command.get("keystrokes").and_then(|value| value.as_str()))
391            .collect::<Option<Vec<_>>>()
392        else {
393            scan_from = start + 1;
394            continue;
395        };
396        let mut remaining_tool_commands = unmatched_tool_commands.clone();
397        let fully_encoded = command_batch.iter().all(|command| {
398            let Some(index) = remaining_tool_commands
399                .iter()
400                .position(|candidate| candidate == command)
401            else {
402                return false;
403            };
404            remaining_tool_commands.swap_remove(index);
405            true
406        });
407        if command_batch.is_empty() || !fully_encoded {
408            scan_from = start + 1;
409            continue;
410        }
411
412        value["commands"] = serde_json::Value::Array(Vec::new());
413        let Ok(normalized) = serde_json::to_string(&value) else {
414            scan_from = start + 1;
415            continue;
416        };
417        normalized_text.push_str(&text[copied_through..start]);
418        normalized_text.push_str(&normalized);
419        copied_through = end;
420        scan_from = end;
421        unmatched_tool_commands = remaining_tool_commands;
422    }
423    normalized_text.push_str(&text[copied_through..]);
424    normalized_text
425}
426
427/// Appends the judge-relevant text of each block, descending into tool results.
428fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>, tool_commands: &[&str]) {
429    for block in content {
430        match block {
431            ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
432                parts.push(without_duplicated_terminus_commands(text, tool_commands));
433            }
434            ContentBlock::ToolCall(call) => {
435                parts.push(format!("tool_call {}({})", call.name, call.arguments));
436            }
437            ContentBlock::ToolResult(result) => collect_text(&result.content, parts, &[]),
438            _ => {}
439        }
440    }
441}
442
443/// Keeps the head and tail of `text` within `limit` characters.
444///
445/// The head gets two thirds of the surviving budget: for a trajectory judge the command or
446/// error signature that opens a message carries more signal than its trailing output.
447fn truncate_middle(text: &str, limit: usize) -> String {
448    let chars: Vec<char> = text.chars().collect();
449    if chars.len() <= limit {
450        return text.to_string();
451    }
452    let keep = limit
453        .saturating_sub(TRIM_MARKER.chars().count())
454        .max(20)
455        .min(chars.len());
456    let head = keep * 2 / 3;
457    let tail = keep - head;
458    let mut out: String = chars[..head].iter().collect();
459    out.push_str(TRIM_MARKER);
460    out.extend(chars[chars.len() - tail..].iter());
461    out
462}
463
464/// Renders a compact role-labelled transcript for the judge.
465///
466/// Task-framing user messages are capped individually. System/developer instructions share
467/// the remaining budget after reserving space for task framing and the newest window entry.
468/// The trailing window carries recent activity. A coverage header states how much history is
469/// not shown, so the judge can reason about pace rather than assuming it sees everything.
470///
471/// When the assembled text still exceeds `max_request_chars`, the oldest window lines go
472/// first: for a trajectory judge the newest evidence is strictly the most valuable.
473fn summarize_for_judge(
474    instructions: &[InstructionBlock],
475    messages: &[Message],
476    turn: usize,
477    phase: Option<EvaluationPhase>,
478    config: &EscalationJudgeConfig,
479) -> String {
480    let mut instruction_anchors: Vec<String> = Vec::new();
481    let mut anchors: Vec<String> = Vec::new();
482    let mut window: Vec<String> = Vec::new();
483    let mut assistant_seen = false;
484
485    for instruction in instructions {
486        let mut parts = Vec::new();
487        collect_text(&instruction.content, &mut parts, &[]);
488        instruction_anchors.push(format!(
489            "[{}] {}",
490            role_label(instruction.role),
491            truncate_middle(&parts.join(" "), SYSTEM_CHARS)
492        ));
493    }
494
495    for message in messages {
496        let text = message_text(message);
497        match message.role {
498            Role::System | Role::Developer => instruction_anchors.push(format!(
499                "[{}] {}",
500                role_label(message.role),
501                truncate_middle(&text, SYSTEM_CHARS)
502            )),
503            // Everything the user said before the agent first replied is task framing.
504            Role::User if !assistant_seen => {
505                anchors.push(format!(
506                    "[user (task)] {}",
507                    truncate_middle(&text, TASK_CHARS)
508                ));
509            }
510            role => {
511                if role == Role::Assistant {
512                    assistant_seen = true;
513                }
514                window.push(format!(
515                    "[{}] {}",
516                    role_label(role),
517                    truncate_middle(&text, config.window_message_chars)
518                ));
519            }
520        }
521    }
522
523    if window.len() > config.recent_turn_window {
524        window.drain(..window.len() - config.recent_turn_window);
525    }
526
527    let assemble = |instructions: Option<&str>, window: &[String]| {
528        let header = format!(
529            "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
530            window.len(),
531            messages.len(),
532        );
533        phase
534            .map(|phase| format!("Routing phase: {}", phase.marker()))
535            .into_iter()
536            .chain(std::iter::once(header))
537            .chain(instructions.map(str::to_owned))
538            .chain(anchors.iter().cloned())
539            .chain(window.iter().cloned())
540            .collect::<Vec<_>>()
541            .join("\n")
542    };
543
544    let reserved = assemble(None, &window[window.len().saturating_sub(1)..])
545        .chars()
546        .count();
547    let instruction_budget = MAX_REQUEST_CHARS.saturating_sub(reserved + 1);
548    // The remaining budget may be smaller than truncate_middle's minimum retained span.
549    let instruction_text = truncate_middle(&instruction_anchors.join("\n"), instruction_budget)
550        .chars()
551        .take(instruction_budget)
552        .collect::<String>();
553    let instructions = (!instruction_text.is_empty()).then_some(instruction_text.as_str());
554    let mut text = assemble(instructions, &window);
555    while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
556        window.remove(0);
557        text = assemble(instructions, &window);
558    }
559    if text.chars().count() > MAX_REQUEST_CHARS {
560        let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
561        text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
562    }
563    text
564}
565
566/// The transcript label for a role.
567fn role_label(role: Role) -> &'static str {
568    match role {
569        Role::System => "system",
570        Role::Developer => "developer",
571        Role::User => "user",
572        Role::Assistant => "assistant",
573        Role::Tool => "tool",
574    }
575}
576
577/// A request whose conversation sits at `turn`: `turn - 1` prior assistant replies, each
578/// answered by a further user message.
579///
580/// Shared with the assembled router's tests, which drive the same conversation shape.
581#[cfg(test)]
582pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
583    use switchyard_protocol::{LlmRequest, Metadata};
584
585    let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
586    for attempt in 1..turn {
587        messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
588        messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
589    }
590    Request {
591        llm_request: LlmRequest {
592            model: Some("auto".to_string()),
593            messages,
594            ..LlmRequest::default()
595        },
596        raw_request: None,
597        metadata: session_id.map(|id| Metadata {
598            session_id: Some(id.to_string()),
599            ..Metadata::default()
600        }),
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use serde_json::json;
607    use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
608
609    use super::*;
610    use crate::algorithms::util::llm_judge::Judge;
611
612    fn escalation_judge(
613        max_output_tokens: u64,
614        phase: Option<EvaluationPhase>,
615        contract_config: &ClassifierContractConfig,
616    ) -> Result<EscalationJudge> {
617        Ok(StructuredJudge::new(
618            EscalationInput {
619                config: EscalationJudgeConfig::default(),
620                phase,
621            },
622            build_contract(contract_config, phase.is_some())?,
623            SerdeDecoder::new(),
624            JudgeRuntimeConfig::new(max_output_tokens)?,
625        ))
626    }
627
628    #[test]
629    fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
630        let judge = escalation_judge(
631            super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
632            None,
633            &ClassifierContractConfig::default(),
634        )?;
635
636        // As the classifier calls it: the turn's reply is already on the transcript.
637        let mut judged = request_at_turn(None, 4);
638        judged.llm_request.instructions = [
639            (Role::System, "system constraint"),
640            (Role::Developer, "developer constraint"),
641        ]
642        .into_iter()
643        .map(|(role, text)| InstructionBlock {
644            role,
645            content: Message::text(role, text).content,
646        })
647        .collect();
648        judged
649            .llm_request
650            .messages
651            .push(Message::text(Role::Assistant, "this turn's reply"));
652        let built = judge.build_request(&State::default(), &judged);
653
654        // Rubric in instructions, condensed trajectory as the sole user message.
655        assert_eq!(built.llm_request.instructions.len(), 1);
656        assert_eq!(built.llm_request.instructions[0].role, Role::System);
657        assert_eq!(
658            built.llm_request.instructions[0].content.as_slice(),
659            &[ContentBlock::Text {
660                text: PROMPT_TEMPLATE.to_string()
661            }]
662        );
663        assert_eq!(built.llm_request.messages.len(), 1);
664        assert_eq!(built.llm_request.messages[0].role, Role::User);
665        let summary = built.llm_request.messages[0]
666            .text_content("")
667            .expect("summary");
668        assert!(summary.contains("Conversation turn 4"));
669        assert!(summary.contains(
670            "[system] system constraint\n[developer] developer constraint\n[user (task)] What is 2+2?"
671        ));
672        assert!(summary.contains("[assistant] this turn's reply"));
673        assert!(!summary.contains("Routing phase:"));
674        // Bounded output, so a reasoning judge cannot run away mid-verdict.
675        assert_eq!(
676            built.llm_request.output.max_output_tokens,
677            Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
678        );
679        assert!(built.llm_request.output.response_format.is_some());
680
681        judged.llm_request.instructions.extend(vec![
682            InstructionBlock {
683                role: Role::Developer,
684                content: Message::text(Role::Developer, "x".repeat(SYSTEM_CHARS)).content,
685            };
686            MAX_REQUEST_CHARS / SYSTEM_CHARS + 1
687        ]);
688        let built = judge.build_request(&State::default(), &judged);
689        let summary = built.llm_request.messages[0]
690            .text_content("")
691            .expect("summary");
692        assert!(summary.chars().count() <= MAX_REQUEST_CHARS);
693        assert!(summary.contains(TRIM_MARKER));
694        assert!(summary.contains("[system] system constraint"));
695        assert!(summary.contains("[developer] developer constraint"));
696        assert!(summary.contains("[user (task)] What is 2+2?"));
697        assert!(summary.contains("[assistant] this turn's reply"));
698        Ok(())
699    }
700
701    #[test]
702    fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
703        let judge = escalation_judge(512, None, &ClassifierContractConfig::default())?;
704
705        let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
706
707        assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
708        Ok(())
709    }
710
711    #[test]
712    fn conversation_turn_counts_assistant_replies() {
713        // The judge is handed the transcript with this turn's reply already appended, which
714        // is the shape asserted here: a request entering turn N plus its reply *is* turn N.
715        for turn in [1, 5] {
716            let mut judged = request_at_turn(None, turn);
717            judged
718                .llm_request
719                .messages
720                .push(Message::text(Role::Assistant, "this turn's reply"));
721            assert_eq!(conversation_turn(&judged), turn);
722        }
723    }
724
725    #[test]
726    fn message_text_keeps_tool_calls_and_results() {
727        let call = Message {
728            role: Role::Assistant,
729            content: vec![
730                ContentBlock::Text {
731                    text: "running it".to_string(),
732                },
733                ContentBlock::ToolCall(ToolCall {
734                    id: "call-1".to_string(),
735                    name: "bash".to_string(),
736                    arguments: json!({"cmd": "ls"}),
737                }),
738            ],
739        };
740        let text = message_text(&call);
741        assert!(text.contains("running it"), "{text}");
742        assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
743
744        let result = Message {
745            role: Role::Tool,
746            content: vec![ContentBlock::ToolResult(ToolResult {
747                tool_call_id: "call-1".to_string(),
748                content: vec![ContentBlock::Text {
749                    text: "no such file".to_string(),
750                }],
751                is_error: Some(true),
752            })],
753        };
754        assert_eq!(message_text(&result), "no such file");
755    }
756
757    /// A raw command batch fully mirrored by structured tool calls is emptied in the judge view.
758    #[test]
759    fn message_text_deduplicates_terminus_commands_for_the_judge() {
760        let first_command = "grep -n bug app.py\n";
761        let second_command = "sed -n '1,80p' app.py\n";
762        let message = Message {
763            role: Role::Assistant,
764            content: vec![
765                ContentBlock::Text {
766                    text: format!(
767                        "Before\n```json\n{}\n```\nAfter",
768                        json!({
769                            "analysis": "inspect the reported file",
770                            "commands": [
771                                {"keystrokes": first_command, "duration": 0.1},
772                                {"keystrokes": second_command, "duration": 0.1},
773                            ],
774                        })
775                    ),
776                },
777                ContentBlock::ToolCall(ToolCall {
778                    id: "call-1".to_string(),
779                    name: "bash_command".to_string(),
780                    arguments: json!({"keystrokes": first_command, "duration": 0.1}),
781                }),
782                ContentBlock::ToolCall(ToolCall {
783                    id: "call-2".to_string(),
784                    name: "bash_command".to_string(),
785                    arguments: json!({"keystrokes": second_command, "duration": 0.1}),
786                }),
787            ],
788        };
789
790        let text = message_text(&message);
791
792        assert!(text.contains("inspect the reported file"), "{text}");
793        assert!(text.contains("Before"), "{text}");
794        assert!(text.contains("After"), "{text}");
795        assert!(text.contains(r#""commands":[]"#), "{text}");
796        assert_eq!(text.matches("grep -n bug app.py").count(), 1, "{text}");
797        assert_eq!(text.matches("sed -n '1,80p' app.py").count(), 1, "{text}");
798        assert_eq!(text.matches("tool_call bash_command(").count(), 2, "{text}");
799    }
800
801    /// Each structured tool call can absorb only one rendered batch, so a repeated batch stays.
802    #[test]
803    fn message_text_deduplicates_multiple_batches_once_per_tool_call() {
804        let first_command = "grep -n bug app.py\n";
805        let second_command = "sed -n '1,80p' app.py\n";
806        let batch = |command| {
807            json!({
808                "analysis": "inspect",
809                "commands": [{"keystrokes": command}],
810            })
811            .to_string()
812        };
813        let message = Message {
814            role: Role::Assistant,
815            content: vec![
816                ContentBlock::Text {
817                    text: format!(
818                        "First {} second {} repeated {}",
819                        batch(first_command),
820                        batch(second_command),
821                        batch(first_command)
822                    ),
823                },
824                ContentBlock::ToolCall(ToolCall {
825                    id: "call-1".to_string(),
826                    name: "bash_command".to_string(),
827                    arguments: json!({"keystrokes": first_command}),
828                }),
829                ContentBlock::ToolCall(ToolCall {
830                    id: "call-2".to_string(),
831                    name: "bash_command".to_string(),
832                    arguments: json!({"keystrokes": second_command}),
833                }),
834            ],
835        };
836
837        let text = message_text(&message);
838
839        assert_eq!(text.matches(r#""commands":[]"#).count(), 2, "{text}");
840        assert_eq!(text.matches("grep -n bug app.py").count(), 2, "{text}");
841        assert_eq!(text.matches("sed -n '1,80p' app.py").count(), 1, "{text}");
842    }
843
844    /// A rendered batch stays when no structured tool call carries the same command.
845    #[test]
846    fn message_text_keeps_terminus_commands_without_matching_tool_call() {
847        let command = "grep -n bug app.py\n";
848        let text = json!({
849            "analysis": "inspect the reported file",
850            "commands": [{"keystrokes": command}],
851        })
852        .to_string();
853        let without_tool_call = Message {
854            role: Role::Assistant,
855            content: vec![ContentBlock::Text { text: text.clone() }],
856        };
857        let mismatched_tool_call = Message {
858            role: Role::Assistant,
859            content: vec![
860                ContentBlock::Text { text },
861                ContentBlock::ToolCall(ToolCall {
862                    id: "call-1".to_string(),
863                    name: "bash_command".to_string(),
864                    arguments: json!({"keystrokes": "sed -n '1,20p' app.py\n", "duration": 0.1}),
865                }),
866            ],
867        };
868
869        assert_eq!(
870            message_text(&without_tool_call)
871                .matches("grep -n bug app.py")
872                .count(),
873            1
874        );
875        assert_eq!(
876            message_text(&mismatched_tool_call)
877                .matches("grep -n bug app.py")
878                .count(),
879            1
880        );
881    }
882
883    /// A batch stays intact when only some of its commands have matching tool calls.
884    #[test]
885    fn message_text_keeps_a_partially_encoded_terminus_batch() {
886        let first_command = "grep -n bug app.py\n";
887        let second_command = "sed -n '1,80p' app.py\n";
888        let message = Message {
889            role: Role::Assistant,
890            content: vec![
891                ContentBlock::Text {
892                    text: json!({
893                        "commands": [
894                            {"keystrokes": first_command},
895                            {"keystrokes": second_command},
896                        ],
897                    })
898                    .to_string(),
899                },
900                ContentBlock::ToolCall(ToolCall {
901                    id: "call-1".to_string(),
902                    name: "bash_command".to_string(),
903                    arguments: json!({"keystrokes": first_command}),
904                }),
905            ],
906        };
907
908        let text = message_text(&message);
909
910        assert_eq!(text.matches("grep -n bug app.py").count(), 2, "{text}");
911        assert_eq!(text.matches("sed -n '1,80p' app.py").count(), 1, "{text}");
912        assert!(!text.contains(r#""commands":[]"#), "{text}");
913    }
914
915    /// Duplicate commands in one batch need one tool call each before the batch is removed.
916    #[test]
917    fn message_text_keeps_duplicate_commands_without_one_tool_call_each() {
918        let command = "grep -n bug app.py\n";
919        let message = Message {
920            role: Role::Assistant,
921            content: vec![
922                ContentBlock::Text {
923                    text: json!({
924                        "commands": [
925                            {"keystrokes": command},
926                            {"keystrokes": command},
927                        ],
928                    })
929                    .to_string(),
930                },
931                ContentBlock::ToolCall(ToolCall {
932                    id: "call-1".to_string(),
933                    name: "bash_command".to_string(),
934                    arguments: json!({"keystrokes": command}),
935                }),
936            ],
937        };
938
939        let text = message_text(&message);
940
941        assert_eq!(text.matches("grep -n bug app.py").count(), 3, "{text}");
942        assert!(!text.contains(r#""commands":[]"#), "{text}");
943    }
944
945    #[test]
946    fn truncate_middle_keeps_head_and_tail() {
947        let text = "a".repeat(40) + &"z".repeat(40);
948        let trimmed = truncate_middle(&text, 50);
949        assert!(trimmed.chars().count() <= 50, "{trimmed}");
950        assert!(trimmed.starts_with('a'));
951        assert!(trimmed.ends_with('z'));
952        assert!(trimmed.contains("[trimmed]"));
953
954        // Under the limit the text is returned untouched.
955        assert_eq!(truncate_middle("short", 50), "short");
956    }
957
958    #[test]
959    fn deescalation_settings_must_be_valid() {
960        let zero = EscalationJudgeConfig {
961            deescalation: Some(DeescalationConfig {
962                strong_min_calls: 0,
963                strong_max_calls: None,
964                confirmations: 2,
965                weak_cooldown_calls: 0,
966            }),
967            ..EscalationJudgeConfig::default()
968        };
969        assert!(
970            zero.validate()
971                .is_err_and(|error| error.to_string().contains("at least 1"))
972        );
973
974        let inverted = EscalationJudgeConfig {
975            deescalation: Some(DeescalationConfig {
976                strong_min_calls: 4,
977                strong_max_calls: Some(3),
978                confirmations: 2,
979                weak_cooldown_calls: 0,
980            }),
981            ..EscalationJudgeConfig::default()
982        };
983        assert!(
984            inverted
985                .validate()
986                .is_err_and(|error| error.to_string().contains("at least deescalation"))
987        );
988    }
989
990    #[test]
991    fn deescalation_contract_marks_both_routing_phases() -> Result<()> {
992        let contract = ClassifierContractConfig::default().with_prompt("Custom trajectory rubric.");
993        for (phase, marker) in [
994            (EvaluationPhase::Efficient, "EFFICIENT_EVALUATION"),
995            (EvaluationPhase::Strong, "STRONG_EVALUATION"),
996        ] {
997            let judge = escalation_judge(512, Some(phase), &contract)?;
998            let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
999            let system_prompt = built.llm_request.instructions[0].content.iter().find_map(
1000                |content| match content {
1001                    ContentBlock::Text { text } => Some(text.as_str()),
1002                    _ => None,
1003                },
1004            );
1005            assert!(system_prompt.is_some_and(|prompt| {
1006                prompt.starts_with("Custom trajectory rubric.")
1007                    && prompt.contains("EFFICIENT_EVALUATION")
1008                    && prompt.contains("STRONG_EVALUATION")
1009            }));
1010            assert!(
1011                built.llm_request.messages[0]
1012                    .text_content("")
1013                    .is_some_and(|summary| summary.starts_with(&format!(
1014                        "Routing phase: {marker}"
1015                    )))
1016            );
1017        }
1018        Ok(())
1019    }
1020
1021    #[test]
1022    fn summary_keeps_anchors_and_the_recent_window() {
1023        let mut messages = vec![
1024            Message::text(Role::System, "you are a coding agent"),
1025            Message::text(Role::User, "fix the failing test"),
1026        ];
1027        for i in 0..10 {
1028            messages.push(Message::text(Role::Assistant, format!("step {i}")));
1029        }
1030        let config = EscalationJudgeConfig {
1031            recent_turn_window: 3,
1032            ..EscalationJudgeConfig::default()
1033        };
1034
1035        let summary = summarize_for_judge(&[], &messages, 11, None, &config);
1036
1037        assert!(
1038            summary.contains("[system] you are a coding agent"),
1039            "{summary}"
1040        );
1041        assert!(
1042            summary.contains("[user (task)] fix the failing test"),
1043            "{summary}"
1044        );
1045        assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
1046        // Only the newest window entries survive.
1047        assert!(summary.contains("step 9"), "{summary}");
1048        assert!(summary.contains("step 7"), "{summary}");
1049        assert!(!summary.contains("step 6"), "{summary}");
1050    }
1051
1052    #[test]
1053    fn summary_anchors_every_user_message_before_the_first_reply() {
1054        // Codex sends environment boilerplate as the first user message and the task as the
1055        // second. Both are framing; the task must stay visible after the window has moved on.
1056        let mut messages = vec![
1057            Message::text(
1058                Role::Developer,
1059                "<skills_instructions>...</skills_instructions>",
1060            ),
1061            Message::text(
1062                Role::User,
1063                "<environment_context><cwd>/app</cwd></environment_context>",
1064            ),
1065            Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
1066        ];
1067        for i in 0..40 {
1068            messages.push(Message::text(Role::Assistant, format!("step {i}")));
1069            messages.push(Message::text(Role::User, format!("later user note {i}")));
1070        }
1071        let config = EscalationJudgeConfig {
1072            recent_turn_window: 3,
1073            ..EscalationJudgeConfig::default()
1074        };
1075
1076        let summary = summarize_for_judge(&[], &messages, 40, None, &config);
1077
1078        assert!(
1079            summary.contains("[user (task)] <environment_context>"),
1080            "{summary}"
1081        );
1082        assert!(
1083            summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
1084            "{summary}"
1085        );
1086        // User messages after the first reply are ordinary window entries, not anchors.
1087        assert!(
1088            !summary.contains("[user (task)] later user note"),
1089            "{summary}"
1090        );
1091        assert!(summary.contains("[user] later user note 39"), "{summary}");
1092        assert!(!summary.contains("later user note 0\n"), "{summary}");
1093    }
1094
1095    #[test]
1096    fn summary_drops_oldest_window_lines_under_the_char_cap() {
1097        // MAX_REQUEST_CHARS is a backstop, not a dial: at default settings the window caps
1098        // bind first (28 x 500 plus anchors sits under it), so reaching it takes an unusually
1099        // wide per-message cap. That is the point — it only fires on pathological input.
1100        let mut messages = vec![
1101            Message::text(Role::System, "framing"),
1102            Message::text(Role::User, "task"),
1103        ];
1104        for i in 0..20 {
1105            messages.push(Message::text(
1106                Role::Assistant,
1107                format!("{i} {}", "x".repeat(2_000)),
1108            ));
1109        }
1110        let config = EscalationJudgeConfig {
1111            window_message_chars: 2_000,
1112            ..EscalationJudgeConfig::default()
1113        };
1114
1115        let summary = summarize_for_judge(&[], &messages, 21, None, &config);
1116
1117        assert!(
1118            summary.chars().count() <= MAX_REQUEST_CHARS,
1119            "{}",
1120            summary.chars().count()
1121        );
1122        // Anchors are never dropped, and the newest activity outlives the oldest.
1123        assert!(summary.contains("[system] framing"), "{summary}");
1124        assert!(summary.contains("[user (task)] task"), "{summary}");
1125        assert!(summary.contains("19 xxx"), "{summary}");
1126        assert!(!summary.contains("0 xxx"), "{summary}");
1127    }
1128}