1use serde::Deserialize;
11use serde_json::Value;
12use switchyard_protocol::{Category, 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 super::transcript::{collect_text, message_text};
20use crate::core::algorithm::Driver;
21use crate::core::classifier::{Classification, Score};
22use crate::core::state::State;
23use crate::{LibsyError, Result};
24use switchyard_protocol::Request;
25
26const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
27const DEESCALATION_PROMPT: &str = include_str!("../../prompts/escalation/deescalation.md");
28const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
29
30const TRIM_MARKER: &str = " ...[trimmed] ";
32
33const TRUNCATION_SUFFIX: &str = "...<truncated>";
35
36const SYSTEM_CHARS: usize = 1_000;
39
40const TASK_CHARS: usize = 4_000;
46
47const MAX_REQUEST_CHARS: usize = 18_000;
49
50#[derive(Clone, Copy, Debug, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct DeescalationConfig {
54 pub strong_min_calls: u32,
56 #[serde(default)]
58 pub strong_max_calls: Option<u32>,
59 pub confirmations: u32,
61 #[serde(default)]
63 pub weak_cooldown_calls: u32,
64}
65
66impl DeescalationConfig {
67 fn validate(&self) -> Result<()> {
68 let reject = |message: &str| {
69 Err(LibsyError::AlgorithmError {
70 message: message.to_string(),
71 })
72 };
73 if self.strong_min_calls == 0 {
74 return reject("deescalation.strong_min_calls must be at least 1");
75 }
76 if self.confirmations == 0 {
77 return reject("deescalation.confirmations must be at least 1");
78 }
79 if self
80 .strong_max_calls
81 .is_some_and(|strong_max_calls| strong_max_calls < self.strong_min_calls)
82 {
83 return reject(
84 "deescalation.strong_max_calls must be at least deescalation.strong_min_calls",
85 );
86 }
87 Ok(())
88 }
89}
90
91#[derive(Clone, Debug, Deserialize)]
96#[serde(default, deny_unknown_fields)]
97pub struct EscalationJudgeConfig {
98 pub confirmations: u32,
104 pub recent_turn_window: usize,
106 pub window_message_chars: usize,
108 pub deescalation: Option<DeescalationConfig>,
110}
111
112impl EscalationJudgeConfig {
113 fn validate(&self) -> Result<()> {
115 let reject = |message: String| Err(LibsyError::AlgorithmError { message });
116 if self.confirmations == 0 {
117 return reject("confirmations must be at least 1".to_string());
118 }
119 if self.recent_turn_window == 0 {
120 return reject("recent_turn_window must be at least 1".to_string());
121 }
122 if self.window_message_chars < 50 {
123 return reject(format!(
124 "window_message_chars must be at least 50, got {}",
125 self.window_message_chars
126 ));
127 }
128 if let Some(deescalation) = self.deescalation {
129 deescalation.validate()?;
130 }
131 Ok(())
132 }
133}
134
135impl Default for EscalationJudgeConfig {
136 fn default() -> Self {
137 Self {
138 confirmations: 2,
139 recent_turn_window: 28,
140 window_message_chars: 500,
141 deescalation: None,
142 }
143 }
144}
145
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub(crate) enum EvaluationPhase {
149 Efficient,
150 Strong,
151}
152
153impl EvaluationPhase {
154 fn marker(self) -> &'static str {
155 match self {
156 Self::Efficient => "EFFICIENT_EVALUATION",
157 Self::Strong => "STRONG_EVALUATION",
158 }
159 }
160}
161
162#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
164#[serde(rename_all = "snake_case")]
165pub(crate) enum EscalationCategory {
166 None,
167 Repetition,
168 FalseProgress,
169 Drift,
170 Desperation,
171 CapabilityGap,
172}
173
174impl EscalationCategory {
175 pub(crate) const fn label(self) -> &'static str {
177 match self {
178 Self::None => "none",
179 Self::Repetition => "repetition",
180 Self::FalseProgress => "false_progress",
181 Self::Drift => "drift",
182 Self::Desperation => "desperation",
183 Self::CapabilityGap => "capability_gap",
184 }
185 }
186}
187
188#[derive(Deserialize)]
190pub(crate) struct EscalationVerdict {
191 pub(crate) escalate: bool,
192 pub(crate) category: EscalationCategory,
193 pub(crate) new_evidence: bool,
194 pub(crate) reason: String,
195}
196
197pub(crate) struct EscalationInput {
199 config: EscalationJudgeConfig,
200 phase: Option<EvaluationPhase>,
201}
202
203impl ClassifierInput for EscalationInput {
204 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
205 let summary = summarize_for_judge(
206 &request.llm_request.instructions,
207 &request.llm_request.messages,
208 conversation_turn(request),
209 self.phase,
210 &self.config,
211 );
212 vec![Message::text(Role::User, summary)]
213 }
214}
215
216pub(crate) type EscalationJudge = StructuredJudge<EscalationInput, SerdeDecoder<EscalationVerdict>>;
218
219pub(crate) struct EscalationPolicy {
225 phase: Option<EvaluationPhase>,
226}
227
228impl JudgePolicy for EscalationPolicy {
229 type Verdict = EscalationVerdict;
230
231 fn to_classification(
232 &self,
233 verdict: Option<&EscalationVerdict>,
234 driver: &Driver,
235 ) -> Result<Classification> {
236 if let Some(verdict) = verdict {
237 tracing::debug!(
238 escalate = verdict.escalate,
239 reason = %verdict.reason,
240 "escalation judge verdict"
241 );
242 }
243 match verdict {
244 Some(verdict) if verdict.escalate => Ok(Classification::Scores(vec![Score {
245 target: driver.first_model_for(&Category::Capable)?.clone(),
246 confidence: 1.0,
247 category: Some(Category::Capable),
248 }])),
249 Some(_) => Ok(Classification::Scores(vec![Score {
250 target: driver.first_model_for(&Category::Efficient)?.clone(),
251 confidence: 1.0,
252 category: Some(Category::Efficient),
253 }])),
254 None => Ok(Classification::Ambiguous(Vec::new())),
255 }
256 }
257}
258
259fn escalation_evidence(
261 policy: &EscalationPolicy,
262 verdict: Option<&EscalationVerdict>,
263) -> Option<Value> {
264 verdict.map(|verdict| {
265 let verdict = match (policy.phase, verdict.escalate) {
266 (Some(EvaluationPhase::Strong), true) => "retain",
267 (Some(EvaluationPhase::Strong), false) => "deescalate",
268 (_, true) => "escalate",
269 (_, false) => "continue",
270 };
271 serde_json::json!({
272 "source": "escalation",
273 "verdict": verdict,
274 })
275 })
276}
277
278pub(crate) fn build_judge(
283 contract_config: &ClassifierContractConfig,
284 config: EscalationJudgeConfig,
285 phase: Option<EvaluationPhase>,
286 max_output_tokens: u64,
287 judge_deadline_ms: Option<u64>,
288) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
289 config.validate()?;
290 let contract = build_contract(contract_config, phase.is_some())?;
291 Ok(JudgeClassifier::new(
292 StructuredJudge::new(
293 EscalationInput { config, phase },
294 contract,
295 SerdeDecoder::new(),
296 JudgeRuntimeConfig::new(max_output_tokens)?.with_deadline_ms(judge_deadline_ms)?,
297 ),
298 EscalationPolicy { phase },
299 )
300 .with_evidence(escalation_evidence))
301}
302
303fn build_contract(
304 contract_config: &ClassifierContractConfig,
305 phase_aware: bool,
306) -> Result<ClassifierContract> {
307 let prompt = contract_config.prompt().unwrap_or(PROMPT_TEMPLATE);
308 validate_prompt(prompt)?;
309 let phase_aware_config = phase_aware.then(|| {
310 contract_config.clone().with_prompt(format!(
311 "{}\n\n{}",
312 prompt.trim_end(),
313 DEESCALATION_PROMPT.trim()
314 ))
315 });
316 let contract_config = phase_aware_config.as_ref().unwrap_or(contract_config);
317 ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
318}
319
320pub(crate) fn conversation_turn(request: &Request) -> usize {
329 request
330 .llm_request
331 .messages
332 .iter()
333 .filter(|message| message.role == Role::Assistant)
334 .count()
335}
336
337fn 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
358fn 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 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 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
460fn 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#[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 switchyard_protocol::{ContentBlock, Message, Role};
501
502 use super::*;
503 use crate::algorithms::util::llm_judge::Judge;
504
505 fn escalation_judge(
506 max_output_tokens: u64,
507 phase: Option<EvaluationPhase>,
508 contract_config: &ClassifierContractConfig,
509 ) -> Result<EscalationJudge> {
510 Ok(StructuredJudge::new(
511 EscalationInput {
512 config: EscalationJudgeConfig::default(),
513 phase,
514 },
515 build_contract(contract_config, phase.is_some())?,
516 SerdeDecoder::new(),
517 JudgeRuntimeConfig::new(max_output_tokens)?,
518 ))
519 }
520
521 #[test]
522 fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
523 let judge = escalation_judge(
524 super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
525 None,
526 &ClassifierContractConfig::default(),
527 )?;
528
529 let mut judged = request_at_turn(None, 4);
531 judged.llm_request.instructions = [
532 (Role::System, "system constraint"),
533 (Role::Developer, "developer constraint"),
534 ]
535 .into_iter()
536 .map(|(role, text)| InstructionBlock {
537 role,
538 content: Message::text(role, text).content,
539 })
540 .collect();
541 judged
542 .llm_request
543 .messages
544 .push(Message::text(Role::Assistant, "this turn's reply"));
545 let built = judge.build_request(&State::default(), &judged);
546
547 assert_eq!(built.llm_request.instructions.len(), 1);
549 assert_eq!(built.llm_request.instructions[0].role, Role::System);
550 assert_eq!(
551 built.llm_request.instructions[0].content.as_slice(),
552 &[ContentBlock::Text {
553 text: PROMPT_TEMPLATE.to_string()
554 }]
555 );
556 assert_eq!(built.llm_request.messages.len(), 1);
557 assert_eq!(built.llm_request.messages[0].role, Role::User);
558 let summary = built.llm_request.messages[0]
559 .text_content("")
560 .expect("summary");
561 assert!(summary.contains("Conversation turn 4"));
562 assert!(summary.contains(
563 "[system] system constraint\n[developer] developer constraint\n[user (task)] What is 2+2?"
564 ));
565 assert!(summary.contains("[assistant] this turn's reply"));
566 assert!(!summary.contains("Routing phase:"));
567 assert_eq!(
569 built.llm_request.output.max_output_tokens,
570 Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
571 );
572 assert!(built.llm_request.output.response_format.is_some());
573
574 judged.llm_request.instructions.extend(vec![
575 InstructionBlock {
576 role: Role::Developer,
577 content: Message::text(Role::Developer, "x".repeat(SYSTEM_CHARS)).content,
578 };
579 MAX_REQUEST_CHARS / SYSTEM_CHARS + 1
580 ]);
581 let built = judge.build_request(&State::default(), &judged);
582 let summary = built.llm_request.messages[0]
583 .text_content("")
584 .expect("summary");
585 assert!(summary.chars().count() <= MAX_REQUEST_CHARS);
586 assert!(summary.contains(TRIM_MARKER));
587 assert!(summary.contains("[system] system constraint"));
588 assert!(summary.contains("[developer] developer constraint"));
589 assert!(summary.contains("[user (task)] What is 2+2?"));
590 assert!(summary.contains("[assistant] this turn's reply"));
591 Ok(())
592 }
593
594 #[test]
595 fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
596 let judge = escalation_judge(512, None, &ClassifierContractConfig::default())?;
597
598 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
599
600 assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
601 Ok(())
602 }
603
604 #[test]
605 fn conversation_turn_counts_assistant_replies() {
606 for turn in [1, 5] {
609 let mut judged = request_at_turn(None, turn);
610 judged
611 .llm_request
612 .messages
613 .push(Message::text(Role::Assistant, "this turn's reply"));
614 assert_eq!(conversation_turn(&judged), turn);
615 }
616 }
617
618 #[test]
619 fn truncate_middle_keeps_head_and_tail() {
620 let text = "a".repeat(40) + &"z".repeat(40);
621 let trimmed = truncate_middle(&text, 50);
622 assert!(trimmed.chars().count() <= 50, "{trimmed}");
623 assert!(trimmed.starts_with('a'));
624 assert!(trimmed.ends_with('z'));
625 assert!(trimmed.contains("[trimmed]"));
626
627 assert_eq!(truncate_middle("short", 50), "short");
629 }
630
631 #[test]
632 fn deescalation_settings_must_be_valid() {
633 let zero = EscalationJudgeConfig {
634 deescalation: Some(DeescalationConfig {
635 strong_min_calls: 0,
636 strong_max_calls: None,
637 confirmations: 2,
638 weak_cooldown_calls: 0,
639 }),
640 ..EscalationJudgeConfig::default()
641 };
642 assert!(
643 zero.validate()
644 .is_err_and(|error| error.to_string().contains("at least 1"))
645 );
646
647 let inverted = EscalationJudgeConfig {
648 deescalation: Some(DeescalationConfig {
649 strong_min_calls: 4,
650 strong_max_calls: Some(3),
651 confirmations: 2,
652 weak_cooldown_calls: 0,
653 }),
654 ..EscalationJudgeConfig::default()
655 };
656 assert!(
657 inverted
658 .validate()
659 .is_err_and(|error| error.to_string().contains("at least deescalation"))
660 );
661 }
662
663 #[test]
664 fn deescalation_contract_marks_both_routing_phases() -> Result<()> {
665 let contract = ClassifierContractConfig::default().with_prompt("Custom trajectory rubric.");
666 for (phase, marker) in [
667 (EvaluationPhase::Efficient, "EFFICIENT_EVALUATION"),
668 (EvaluationPhase::Strong, "STRONG_EVALUATION"),
669 ] {
670 let judge = escalation_judge(512, Some(phase), &contract)?;
671 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
672 let system_prompt = built.llm_request.instructions[0].content.iter().find_map(
673 |content| match content {
674 ContentBlock::Text { text } => Some(text.as_str()),
675 _ => None,
676 },
677 );
678 assert!(system_prompt.is_some_and(|prompt| {
679 prompt.starts_with("Custom trajectory rubric.")
680 && prompt.contains("EFFICIENT_EVALUATION")
681 && prompt.contains("STRONG_EVALUATION")
682 }));
683 assert!(
684 built.llm_request.messages[0]
685 .text_content("")
686 .is_some_and(|summary| summary.starts_with(&format!(
687 "Routing phase: {marker}"
688 )))
689 );
690 }
691 Ok(())
692 }
693
694 #[test]
695 fn summary_keeps_anchors_and_the_recent_window() {
696 let mut messages = vec![
697 Message::text(Role::System, "you are a coding agent"),
698 Message::text(Role::User, "fix the failing test"),
699 ];
700 for i in 0..10 {
701 messages.push(Message::text(Role::Assistant, format!("step {i}")));
702 }
703 let config = EscalationJudgeConfig {
704 recent_turn_window: 3,
705 ..EscalationJudgeConfig::default()
706 };
707
708 let summary = summarize_for_judge(&[], &messages, 11, None, &config);
709
710 assert!(
711 summary.contains("[system] you are a coding agent"),
712 "{summary}"
713 );
714 assert!(
715 summary.contains("[user (task)] fix the failing test"),
716 "{summary}"
717 );
718 assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
719 assert!(summary.contains("step 9"), "{summary}");
721 assert!(summary.contains("step 7"), "{summary}");
722 assert!(!summary.contains("step 6"), "{summary}");
723 }
724
725 #[test]
726 fn summary_anchors_every_user_message_before_the_first_reply() {
727 let mut messages = vec![
730 Message::text(
731 Role::Developer,
732 "<skills_instructions>...</skills_instructions>",
733 ),
734 Message::text(
735 Role::User,
736 "<environment_context><cwd>/app</cwd></environment_context>",
737 ),
738 Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
739 ];
740 for i in 0..40 {
741 messages.push(Message::text(Role::Assistant, format!("step {i}")));
742 messages.push(Message::text(Role::User, format!("later user note {i}")));
743 }
744 let config = EscalationJudgeConfig {
745 recent_turn_window: 3,
746 ..EscalationJudgeConfig::default()
747 };
748
749 let summary = summarize_for_judge(&[], &messages, 40, None, &config);
750
751 assert!(
752 summary.contains("[user (task)] <environment_context>"),
753 "{summary}"
754 );
755 assert!(
756 summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
757 "{summary}"
758 );
759 assert!(
761 !summary.contains("[user (task)] later user note"),
762 "{summary}"
763 );
764 assert!(summary.contains("[user] later user note 39"), "{summary}");
765 assert!(!summary.contains("later user note 0\n"), "{summary}");
766 }
767
768 #[test]
769 fn summary_drops_oldest_window_lines_under_the_char_cap() {
770 let mut messages = vec![
774 Message::text(Role::System, "framing"),
775 Message::text(Role::User, "task"),
776 ];
777 for i in 0..20 {
778 messages.push(Message::text(
779 Role::Assistant,
780 format!("{i} {}", "x".repeat(2_000)),
781 ));
782 }
783 let config = EscalationJudgeConfig {
784 window_message_chars: 2_000,
785 ..EscalationJudgeConfig::default()
786 };
787
788 let summary = summarize_for_judge(&[], &messages, 21, None, &config);
789
790 assert!(
791 summary.chars().count() <= MAX_REQUEST_CHARS,
792 "{}",
793 summary.chars().count()
794 );
795 assert!(summary.contains("[system] framing"), "{summary}");
797 assert!(summary.contains("[user (task)] task"), "{summary}");
798 assert!(summary.contains("19 xxx"), "{summary}");
799 assert!(!summary.contains("0 xxx"), "{summary}");
800 }
801}