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) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
288 config.validate()?;
289 let contract = build_contract(contract_config, phase.is_some())?;
290 Ok(JudgeClassifier::new(
291 StructuredJudge::new(
292 EscalationInput { config, phase },
293 contract,
294 SerdeDecoder::new(),
295 JudgeRuntimeConfig::new(max_output_tokens)?,
296 ),
297 EscalationPolicy { phase },
298 )
299 .with_evidence(escalation_evidence))
300}
301
302fn build_contract(
303 contract_config: &ClassifierContractConfig,
304 phase_aware: bool,
305) -> Result<ClassifierContract> {
306 let prompt = contract_config.prompt().unwrap_or(PROMPT_TEMPLATE);
307 validate_prompt(prompt)?;
308 let phase_aware_config = phase_aware.then(|| {
309 contract_config.clone().with_prompt(format!(
310 "{}\n\n{}",
311 prompt.trim_end(),
312 DEESCALATION_PROMPT.trim()
313 ))
314 });
315 let contract_config = phase_aware_config.as_ref().unwrap_or(contract_config);
316 ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
317}
318
319pub(crate) fn conversation_turn(request: &Request) -> usize {
328 request
329 .llm_request
330 .messages
331 .iter()
332 .filter(|message| message.role == Role::Assistant)
333 .count()
334}
335
336fn truncate_middle(text: &str, limit: usize) -> String {
341 let chars: Vec<char> = text.chars().collect();
342 if chars.len() <= limit {
343 return text.to_string();
344 }
345 let keep = limit
346 .saturating_sub(TRIM_MARKER.chars().count())
347 .max(20)
348 .min(chars.len());
349 let head = keep * 2 / 3;
350 let tail = keep - head;
351 let mut out: String = chars[..head].iter().collect();
352 out.push_str(TRIM_MARKER);
353 out.extend(chars[chars.len() - tail..].iter());
354 out
355}
356
357fn summarize_for_judge(
367 instructions: &[InstructionBlock],
368 messages: &[Message],
369 turn: usize,
370 phase: Option<EvaluationPhase>,
371 config: &EscalationJudgeConfig,
372) -> String {
373 let mut instruction_anchors: Vec<String> = Vec::new();
374 let mut anchors: Vec<String> = Vec::new();
375 let mut window: Vec<String> = Vec::new();
376 let mut assistant_seen = false;
377
378 for instruction in instructions {
379 let mut parts = Vec::new();
380 collect_text(&instruction.content, &mut parts, &[]);
381 instruction_anchors.push(format!(
382 "[{}] {}",
383 role_label(instruction.role),
384 truncate_middle(&parts.join(" "), SYSTEM_CHARS)
385 ));
386 }
387
388 for message in messages {
389 let text = message_text(message);
390 match message.role {
391 Role::System | Role::Developer => instruction_anchors.push(format!(
392 "[{}] {}",
393 role_label(message.role),
394 truncate_middle(&text, SYSTEM_CHARS)
395 )),
396 Role::User if !assistant_seen => {
398 anchors.push(format!(
399 "[user (task)] {}",
400 truncate_middle(&text, TASK_CHARS)
401 ));
402 }
403 role => {
404 if role == Role::Assistant {
405 assistant_seen = true;
406 }
407 window.push(format!(
408 "[{}] {}",
409 role_label(role),
410 truncate_middle(&text, config.window_message_chars)
411 ));
412 }
413 }
414 }
415
416 if window.len() > config.recent_turn_window {
417 window.drain(..window.len() - config.recent_turn_window);
418 }
419
420 let assemble = |instructions: Option<&str>, window: &[String]| {
421 let header = format!(
422 "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
423 window.len(),
424 messages.len(),
425 );
426 phase
427 .map(|phase| format!("Routing phase: {}", phase.marker()))
428 .into_iter()
429 .chain(std::iter::once(header))
430 .chain(instructions.map(str::to_owned))
431 .chain(anchors.iter().cloned())
432 .chain(window.iter().cloned())
433 .collect::<Vec<_>>()
434 .join("\n")
435 };
436
437 let reserved = assemble(None, &window[window.len().saturating_sub(1)..])
438 .chars()
439 .count();
440 let instruction_budget = MAX_REQUEST_CHARS.saturating_sub(reserved + 1);
441 let instruction_text = truncate_middle(&instruction_anchors.join("\n"), instruction_budget)
443 .chars()
444 .take(instruction_budget)
445 .collect::<String>();
446 let instructions = (!instruction_text.is_empty()).then_some(instruction_text.as_str());
447 let mut text = assemble(instructions, &window);
448 while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
449 window.remove(0);
450 text = assemble(instructions, &window);
451 }
452 if text.chars().count() > MAX_REQUEST_CHARS {
453 let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
454 text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
455 }
456 text
457}
458
459fn role_label(role: Role) -> &'static str {
461 match role {
462 Role::System => "system",
463 Role::Developer => "developer",
464 Role::User => "user",
465 Role::Assistant => "assistant",
466 Role::Tool => "tool",
467 }
468}
469
470#[cfg(test)]
475pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
476 use switchyard_protocol::{LlmRequest, Metadata};
477
478 let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
479 for attempt in 1..turn {
480 messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
481 messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
482 }
483 Request {
484 llm_request: LlmRequest {
485 model: Some("auto".to_string()),
486 messages,
487 ..LlmRequest::default()
488 },
489 raw_request: None,
490 metadata: session_id.map(|id| Metadata {
491 session_id: Some(id.to_string()),
492 ..Metadata::default()
493 }),
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use switchyard_protocol::{ContentBlock, Message, Role};
500
501 use super::*;
502 use crate::algorithms::util::llm_judge::Judge;
503
504 fn escalation_judge(
505 max_output_tokens: u64,
506 phase: Option<EvaluationPhase>,
507 contract_config: &ClassifierContractConfig,
508 ) -> Result<EscalationJudge> {
509 Ok(StructuredJudge::new(
510 EscalationInput {
511 config: EscalationJudgeConfig::default(),
512 phase,
513 },
514 build_contract(contract_config, phase.is_some())?,
515 SerdeDecoder::new(),
516 JudgeRuntimeConfig::new(max_output_tokens)?,
517 ))
518 }
519
520 #[test]
521 fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
522 let judge = escalation_judge(
523 super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
524 None,
525 &ClassifierContractConfig::default(),
526 )?;
527
528 let mut judged = request_at_turn(None, 4);
530 judged.llm_request.instructions = [
531 (Role::System, "system constraint"),
532 (Role::Developer, "developer constraint"),
533 ]
534 .into_iter()
535 .map(|(role, text)| InstructionBlock {
536 role,
537 content: Message::text(role, text).content,
538 })
539 .collect();
540 judged
541 .llm_request
542 .messages
543 .push(Message::text(Role::Assistant, "this turn's reply"));
544 let built = judge.build_request(&State::default(), &judged);
545
546 assert_eq!(built.llm_request.instructions.len(), 1);
548 assert_eq!(built.llm_request.instructions[0].role, Role::System);
549 assert_eq!(
550 built.llm_request.instructions[0].content.as_slice(),
551 &[ContentBlock::Text {
552 text: PROMPT_TEMPLATE.to_string()
553 }]
554 );
555 assert_eq!(built.llm_request.messages.len(), 1);
556 assert_eq!(built.llm_request.messages[0].role, Role::User);
557 let summary = built.llm_request.messages[0]
558 .text_content("")
559 .expect("summary");
560 assert!(summary.contains("Conversation turn 4"));
561 assert!(summary.contains(
562 "[system] system constraint\n[developer] developer constraint\n[user (task)] What is 2+2?"
563 ));
564 assert!(summary.contains("[assistant] this turn's reply"));
565 assert!(!summary.contains("Routing phase:"));
566 assert_eq!(
568 built.llm_request.output.max_output_tokens,
569 Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
570 );
571 assert!(built.llm_request.output.response_format.is_some());
572
573 judged.llm_request.instructions.extend(vec![
574 InstructionBlock {
575 role: Role::Developer,
576 content: Message::text(Role::Developer, "x".repeat(SYSTEM_CHARS)).content,
577 };
578 MAX_REQUEST_CHARS / SYSTEM_CHARS + 1
579 ]);
580 let built = judge.build_request(&State::default(), &judged);
581 let summary = built.llm_request.messages[0]
582 .text_content("")
583 .expect("summary");
584 assert!(summary.chars().count() <= MAX_REQUEST_CHARS);
585 assert!(summary.contains(TRIM_MARKER));
586 assert!(summary.contains("[system] system constraint"));
587 assert!(summary.contains("[developer] developer constraint"));
588 assert!(summary.contains("[user (task)] What is 2+2?"));
589 assert!(summary.contains("[assistant] this turn's reply"));
590 Ok(())
591 }
592
593 #[test]
594 fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
595 let judge = escalation_judge(512, None, &ClassifierContractConfig::default())?;
596
597 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
598
599 assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
600 Ok(())
601 }
602
603 #[test]
604 fn conversation_turn_counts_assistant_replies() {
605 for turn in [1, 5] {
608 let mut judged = request_at_turn(None, turn);
609 judged
610 .llm_request
611 .messages
612 .push(Message::text(Role::Assistant, "this turn's reply"));
613 assert_eq!(conversation_turn(&judged), turn);
614 }
615 }
616
617 #[test]
618 fn truncate_middle_keeps_head_and_tail() {
619 let text = "a".repeat(40) + &"z".repeat(40);
620 let trimmed = truncate_middle(&text, 50);
621 assert!(trimmed.chars().count() <= 50, "{trimmed}");
622 assert!(trimmed.starts_with('a'));
623 assert!(trimmed.ends_with('z'));
624 assert!(trimmed.contains("[trimmed]"));
625
626 assert_eq!(truncate_middle("short", 50), "short");
628 }
629
630 #[test]
631 fn deescalation_settings_must_be_valid() {
632 let zero = EscalationJudgeConfig {
633 deescalation: Some(DeescalationConfig {
634 strong_min_calls: 0,
635 strong_max_calls: None,
636 confirmations: 2,
637 weak_cooldown_calls: 0,
638 }),
639 ..EscalationJudgeConfig::default()
640 };
641 assert!(
642 zero.validate()
643 .is_err_and(|error| error.to_string().contains("at least 1"))
644 );
645
646 let inverted = EscalationJudgeConfig {
647 deescalation: Some(DeescalationConfig {
648 strong_min_calls: 4,
649 strong_max_calls: Some(3),
650 confirmations: 2,
651 weak_cooldown_calls: 0,
652 }),
653 ..EscalationJudgeConfig::default()
654 };
655 assert!(
656 inverted
657 .validate()
658 .is_err_and(|error| error.to_string().contains("at least deescalation"))
659 );
660 }
661
662 #[test]
663 fn deescalation_contract_marks_both_routing_phases() -> Result<()> {
664 let contract = ClassifierContractConfig::default().with_prompt("Custom trajectory rubric.");
665 for (phase, marker) in [
666 (EvaluationPhase::Efficient, "EFFICIENT_EVALUATION"),
667 (EvaluationPhase::Strong, "STRONG_EVALUATION"),
668 ] {
669 let judge = escalation_judge(512, Some(phase), &contract)?;
670 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
671 let system_prompt = built.llm_request.instructions[0].content.iter().find_map(
672 |content| match content {
673 ContentBlock::Text { text } => Some(text.as_str()),
674 _ => None,
675 },
676 );
677 assert!(system_prompt.is_some_and(|prompt| {
678 prompt.starts_with("Custom trajectory rubric.")
679 && prompt.contains("EFFICIENT_EVALUATION")
680 && prompt.contains("STRONG_EVALUATION")
681 }));
682 assert!(
683 built.llm_request.messages[0]
684 .text_content("")
685 .is_some_and(|summary| summary.starts_with(&format!(
686 "Routing phase: {marker}"
687 )))
688 );
689 }
690 Ok(())
691 }
692
693 #[test]
694 fn summary_keeps_anchors_and_the_recent_window() {
695 let mut messages = vec![
696 Message::text(Role::System, "you are a coding agent"),
697 Message::text(Role::User, "fix the failing test"),
698 ];
699 for i in 0..10 {
700 messages.push(Message::text(Role::Assistant, format!("step {i}")));
701 }
702 let config = EscalationJudgeConfig {
703 recent_turn_window: 3,
704 ..EscalationJudgeConfig::default()
705 };
706
707 let summary = summarize_for_judge(&[], &messages, 11, None, &config);
708
709 assert!(
710 summary.contains("[system] you are a coding agent"),
711 "{summary}"
712 );
713 assert!(
714 summary.contains("[user (task)] fix the failing test"),
715 "{summary}"
716 );
717 assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
718 assert!(summary.contains("step 9"), "{summary}");
720 assert!(summary.contains("step 7"), "{summary}");
721 assert!(!summary.contains("step 6"), "{summary}");
722 }
723
724 #[test]
725 fn summary_anchors_every_user_message_before_the_first_reply() {
726 let mut messages = vec![
729 Message::text(
730 Role::Developer,
731 "<skills_instructions>...</skills_instructions>",
732 ),
733 Message::text(
734 Role::User,
735 "<environment_context><cwd>/app</cwd></environment_context>",
736 ),
737 Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
738 ];
739 for i in 0..40 {
740 messages.push(Message::text(Role::Assistant, format!("step {i}")));
741 messages.push(Message::text(Role::User, format!("later user note {i}")));
742 }
743 let config = EscalationJudgeConfig {
744 recent_turn_window: 3,
745 ..EscalationJudgeConfig::default()
746 };
747
748 let summary = summarize_for_judge(&[], &messages, 40, None, &config);
749
750 assert!(
751 summary.contains("[user (task)] <environment_context>"),
752 "{summary}"
753 );
754 assert!(
755 summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
756 "{summary}"
757 );
758 assert!(
760 !summary.contains("[user (task)] later user note"),
761 "{summary}"
762 );
763 assert!(summary.contains("[user] later user note 39"), "{summary}");
764 assert!(!summary.contains("later user note 0\n"), "{summary}");
765 }
766
767 #[test]
768 fn summary_drops_oldest_window_lines_under_the_char_cap() {
769 let mut messages = vec![
773 Message::text(Role::System, "framing"),
774 Message::text(Role::User, "task"),
775 ];
776 for i in 0..20 {
777 messages.push(Message::text(
778 Role::Assistant,
779 format!("{i} {}", "x".repeat(2_000)),
780 ));
781 }
782 let config = EscalationJudgeConfig {
783 window_message_chars: 2_000,
784 ..EscalationJudgeConfig::default()
785 };
786
787 let summary = summarize_for_judge(&[], &messages, 21, None, &config);
788
789 assert!(
790 summary.chars().count() <= MAX_REQUEST_CHARS,
791 "{}",
792 summary.chars().count()
793 );
794 assert!(summary.contains("[system] framing"), "{summary}");
796 assert!(summary.contains("[user (task)] task"), "{summary}");
797 assert!(summary.contains("19 xxx"), "{summary}");
798 assert!(!summary.contains("0 xxx"), "{summary}");
799 }
800}