1use serde::Deserialize;
11use serde_json::Value;
12use switchyard_protocol::{ContentBlock, Message, ModelId, Role};
13
14use super::classifier_contract::{ClassifierContract, ClassifierContractConfig};
15use super::llm_judge::{
16 ClassifierInput, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig, SerdeDecoder,
17 StructuredJudge,
18};
19use crate::core::classifier::{Classification, Score};
20use crate::core::state::State;
21use crate::{LibsyError, Result};
22use switchyard_protocol::Request;
23
24const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
25const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
26
27const TRIM_MARKER: &str = " ...[trimmed] ";
29
30const TRUNCATION_SUFFIX: &str = "...<truncated>";
32
33const SYSTEM_CHARS: usize = 1_000;
36
37const TASK_CHARS: usize = 4_000;
43
44const MAX_REQUEST_CHARS: usize = 18_000;
46
47#[derive(Clone, Debug, Deserialize)]
52#[serde(default, deny_unknown_fields)]
53pub struct EscalationJudgeConfig {
54 pub confirmations: u32,
59 pub recent_turn_window: usize,
61 pub window_message_chars: usize,
63 pub handoff_note: Option<String>,
69}
70
71impl EscalationJudgeConfig {
72 fn validate(&self) -> Result<()> {
74 let reject = |message: String| Err(LibsyError::AlgorithmError { message });
75 if self.confirmations == 0 {
76 return reject("confirmations must be at least 1".to_string());
77 }
78 if self.recent_turn_window == 0 {
79 return reject("recent_turn_window must be at least 1".to_string());
80 }
81 if self.window_message_chars < 50 {
82 return reject(format!(
83 "window_message_chars must be at least 50, got {}",
84 self.window_message_chars
85 ));
86 }
87 if self
88 .handoff_note
89 .as_deref()
90 .is_some_and(|note| note.trim().is_empty())
91 {
92 return reject("handoff_note must not be blank".to_string());
93 }
94 Ok(())
95 }
96}
97
98impl Default for EscalationJudgeConfig {
99 fn default() -> Self {
100 Self {
101 confirmations: 2,
102 recent_turn_window: 28,
103 window_message_chars: 500,
104 handoff_note: None,
105 }
106 }
107}
108
109#[derive(Deserialize)]
114pub(crate) struct EscalationVerdict {
115 escalate: bool,
116 #[serde(default)]
117 reason: String,
118}
119
120pub(crate) struct EscalationInput {
122 config: EscalationJudgeConfig,
123}
124
125impl ClassifierInput for EscalationInput {
126 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
127 let messages = &request.llm_request.messages;
128 let summary = summarize_for_judge(messages, conversation_turn(request), &self.config);
129 vec![Message::text(Role::User, summary)]
130 }
131}
132
133pub(crate) type EscalationJudge = StructuredJudge<EscalationInput, SerdeDecoder<EscalationVerdict>>;
135
136pub(crate) struct EscalationPolicy {
142 capable: ModelId,
143 efficient: ModelId,
144}
145
146impl JudgePolicy for EscalationPolicy {
147 type Verdict = EscalationVerdict;
148
149 fn to_classification(&self, verdict: Option<&EscalationVerdict>) -> Classification {
150 if let Some(verdict) = verdict {
151 tracing::debug!(
152 escalate = verdict.escalate,
153 reason = %verdict.reason,
154 "escalation judge verdict"
155 );
156 }
157 match verdict {
158 Some(verdict) if verdict.escalate => Classification::Scores(vec![Score {
159 target: self.capable.clone(),
160 confidence: 1.0,
161 }]),
162 Some(_) => Classification::Scores(vec![Score {
163 target: self.efficient.clone(),
164 confidence: 1.0,
165 }]),
166 None => Classification::Ambiguous(Vec::new()),
167 }
168 }
169}
170
171fn escalation_evidence(
173 _policy: &EscalationPolicy,
174 verdict: Option<&EscalationVerdict>,
175) -> Option<Value> {
176 verdict.map(|verdict| {
177 serde_json::json!({
178 "source": "escalation",
179 "verdict": if verdict.escalate { "escalate" } else { "continue" },
180 })
181 })
182}
183
184pub(crate) fn build_judge(
189 judge_target: ModelId,
190 capable: ModelId,
191 efficient: ModelId,
192 contract_config: &ClassifierContractConfig,
193 config: EscalationJudgeConfig,
194 max_output_tokens: u64,
195) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
196 config.validate()?;
197 let contract =
198 ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)?;
199 Ok(JudgeClassifier::new(
200 StructuredJudge::new(
201 EscalationInput { config },
202 contract,
203 SerdeDecoder::new(),
204 JudgeRuntimeConfig::new(max_output_tokens)?,
205 ),
206 judge_target,
207 EscalationPolicy { capable, efficient },
208 )
209 .with_evidence(escalation_evidence))
210}
211
212pub(crate) fn conversation_turn(request: &Request) -> usize {
221 request
222 .llm_request
223 .messages
224 .iter()
225 .filter(|message| message.role == Role::Assistant)
226 .count()
227}
228
229fn message_text(message: &Message) -> String {
235 let mut parts = Vec::new();
236 collect_text(&message.content, &mut parts);
237 parts.join(" ")
238}
239
240fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
242 for block in content {
243 match block {
244 ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
245 parts.push(text.clone());
246 }
247 ContentBlock::ToolCall(call) => {
248 parts.push(format!("tool_call {}({})", call.name, call.arguments));
249 }
250 ContentBlock::ToolResult(result) => collect_text(&result.content, parts),
251 _ => {}
252 }
253 }
254}
255
256fn truncate_middle(text: &str, limit: usize) -> String {
261 let chars: Vec<char> = text.chars().collect();
262 if chars.len() <= limit {
263 return text.to_string();
264 }
265 let keep = limit
266 .saturating_sub(TRIM_MARKER.chars().count())
267 .max(20)
268 .min(chars.len());
269 let head = keep * 2 / 3;
270 let tail = keep - head;
271 let mut out: String = chars[..head].iter().collect();
272 out.push_str(TRIM_MARKER);
273 out.extend(chars[chars.len() - tail..].iter());
274 out
275}
276
277fn summarize_for_judge(
287 messages: &[Message],
288 turn: usize,
289 config: &EscalationJudgeConfig,
290) -> String {
291 let mut anchors: Vec<String> = Vec::new();
292 let mut window: Vec<String> = Vec::new();
293 let mut assistant_seen = false;
294
295 for message in messages {
296 let text = message_text(message);
297 match message.role {
298 Role::System | Role::Developer => anchors.push(format!(
299 "[{}] {}",
300 role_label(message.role),
301 truncate_middle(&text, SYSTEM_CHARS)
302 )),
303 Role::User if !assistant_seen => {
305 anchors.push(format!(
306 "[user (task)] {}",
307 truncate_middle(&text, TASK_CHARS)
308 ));
309 }
310 role => {
311 if role == Role::Assistant {
312 assistant_seen = true;
313 }
314 window.push(format!(
315 "[{}] {}",
316 role_label(role),
317 truncate_middle(&text, config.window_message_chars)
318 ));
319 }
320 }
321 }
322
323 if window.len() > config.recent_turn_window {
324 window.drain(..window.len() - config.recent_turn_window);
325 }
326
327 let assemble = |window: &[String]| {
328 let header = format!(
329 "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
330 window.len(),
331 messages.len(),
332 );
333 std::iter::once(header)
334 .chain(anchors.iter().cloned())
335 .chain(window.iter().cloned())
336 .collect::<Vec<_>>()
337 .join("\n")
338 };
339
340 let mut text = assemble(&window);
341 while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
342 window.remove(0);
343 text = assemble(&window);
344 }
345 if text.chars().count() > MAX_REQUEST_CHARS {
346 let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
347 text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
348 }
349 text
350}
351
352fn role_label(role: Role) -> &'static str {
354 match role {
355 Role::System => "system",
356 Role::Developer => "developer",
357 Role::User => "user",
358 Role::Assistant => "assistant",
359 Role::Tool => "tool",
360 }
361}
362
363#[cfg(test)]
368pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
369 use switchyard_protocol::{LlmRequest, Metadata};
370
371 let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
372 for attempt in 1..turn {
373 messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
374 messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
375 }
376 Request {
377 llm_request: LlmRequest {
378 model: Some("auto".to_string()),
379 messages,
380 ..LlmRequest::default()
381 },
382 raw_request: None,
383 metadata: session_id.map(|id| Metadata {
384 session_id: Some(id.to_string()),
385 ..Metadata::default()
386 }),
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use serde_json::json;
393 use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
394
395 use super::*;
396 use crate::algorithms::util::llm_judge::Judge;
397
398 fn escalation_judge(max_output_tokens: u64) -> Result<EscalationJudge> {
399 Ok(StructuredJudge::new(
400 EscalationInput {
401 config: EscalationJudgeConfig::default(),
402 },
403 ClassifierContract::from_config(
404 &ClassifierContractConfig::default(),
405 PROMPT_TEMPLATE,
406 SCHEMA_TEMPLATE,
407 )?,
408 SerdeDecoder::new(),
409 JudgeRuntimeConfig::new(max_output_tokens)?,
410 ))
411 }
412
413 #[test]
414 fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
415 let judge = escalation_judge(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?;
416
417 let mut judged = request_at_turn(None, 4);
419 judged
420 .llm_request
421 .messages
422 .push(Message::text(Role::Assistant, "this turn's reply"));
423 let built = judge.build_request(&State::default(), &judged);
424
425 assert_eq!(built.llm_request.instructions.len(), 1);
427 assert_eq!(built.llm_request.instructions[0].role, Role::System);
428 assert_eq!(built.llm_request.messages.len(), 1);
429 assert_eq!(built.llm_request.messages[0].role, Role::User);
430 assert!(
431 built.llm_request.messages[0]
432 .text_content("")
433 .is_some_and(|text| text.contains("Conversation turn 4"))
434 );
435 assert_eq!(
437 built.llm_request.output.max_output_tokens,
438 Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
439 );
440 assert!(built.llm_request.output.response_format.is_some());
441 Ok(())
442 }
443
444 #[test]
445 fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
446 let judge = escalation_judge(512)?;
447
448 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
449
450 assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
451 Ok(())
452 }
453
454 #[test]
455 fn conversation_turn_counts_assistant_replies() {
456 for turn in [1, 5] {
459 let mut judged = request_at_turn(None, turn);
460 judged
461 .llm_request
462 .messages
463 .push(Message::text(Role::Assistant, "this turn's reply"));
464 assert_eq!(conversation_turn(&judged), turn);
465 }
466 }
467
468 #[test]
469 fn message_text_keeps_tool_calls_and_results() {
470 let call = Message {
471 role: Role::Assistant,
472 content: vec![
473 ContentBlock::Text {
474 text: "running it".to_string(),
475 },
476 ContentBlock::ToolCall(ToolCall {
477 id: "call-1".to_string(),
478 name: "bash".to_string(),
479 arguments: json!({"cmd": "ls"}),
480 }),
481 ],
482 };
483 let text = message_text(&call);
484 assert!(text.contains("running it"), "{text}");
485 assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
486
487 let result = Message {
488 role: Role::Tool,
489 content: vec![ContentBlock::ToolResult(ToolResult {
490 tool_call_id: "call-1".to_string(),
491 content: vec![ContentBlock::Text {
492 text: "no such file".to_string(),
493 }],
494 is_error: Some(true),
495 })],
496 };
497 assert_eq!(message_text(&result), "no such file");
498 }
499
500 #[test]
501 fn truncate_middle_keeps_head_and_tail() {
502 let text = "a".repeat(40) + &"z".repeat(40);
503 let trimmed = truncate_middle(&text, 50);
504 assert!(trimmed.chars().count() <= 50, "{trimmed}");
505 assert!(trimmed.starts_with('a'));
506 assert!(trimmed.ends_with('z'));
507 assert!(trimmed.contains("[trimmed]"));
508
509 assert_eq!(truncate_middle("short", 50), "short");
511 }
512
513 #[test]
514 fn summary_keeps_anchors_and_the_recent_window() {
515 let mut messages = vec![
516 Message::text(Role::System, "you are a coding agent"),
517 Message::text(Role::User, "fix the failing test"),
518 ];
519 for i in 0..10 {
520 messages.push(Message::text(Role::Assistant, format!("step {i}")));
521 }
522 let config = EscalationJudgeConfig {
523 recent_turn_window: 3,
524 ..EscalationJudgeConfig::default()
525 };
526
527 let summary = summarize_for_judge(&messages, 11, &config);
528
529 assert!(
530 summary.contains("[system] you are a coding agent"),
531 "{summary}"
532 );
533 assert!(
534 summary.contains("[user (task)] fix the failing test"),
535 "{summary}"
536 );
537 assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
538 assert!(summary.contains("step 9"), "{summary}");
540 assert!(summary.contains("step 7"), "{summary}");
541 assert!(!summary.contains("step 6"), "{summary}");
542 }
543
544 #[test]
545 fn summary_anchors_every_user_message_before_the_first_reply() {
546 let mut messages = vec![
549 Message::text(
550 Role::Developer,
551 "<skills_instructions>...</skills_instructions>",
552 ),
553 Message::text(
554 Role::User,
555 "<environment_context><cwd>/app</cwd></environment_context>",
556 ),
557 Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
558 ];
559 for i in 0..40 {
560 messages.push(Message::text(Role::Assistant, format!("step {i}")));
561 messages.push(Message::text(Role::User, format!("later user note {i}")));
562 }
563 let config = EscalationJudgeConfig {
564 recent_turn_window: 3,
565 ..EscalationJudgeConfig::default()
566 };
567
568 let summary = summarize_for_judge(&messages, 40, &config);
569
570 assert!(
571 summary.contains("[user (task)] <environment_context>"),
572 "{summary}"
573 );
574 assert!(
575 summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
576 "{summary}"
577 );
578 assert!(
580 !summary.contains("[user (task)] later user note"),
581 "{summary}"
582 );
583 assert!(summary.contains("[user] later user note 39"), "{summary}");
584 assert!(!summary.contains("later user note 0\n"), "{summary}");
585 }
586
587 #[test]
588 fn summary_drops_oldest_window_lines_under_the_char_cap() {
589 let mut messages = vec![
593 Message::text(Role::System, "framing"),
594 Message::text(Role::User, "task"),
595 ];
596 for i in 0..20 {
597 messages.push(Message::text(
598 Role::Assistant,
599 format!("{i} {}", "x".repeat(2_000)),
600 ));
601 }
602 let config = EscalationJudgeConfig {
603 window_message_chars: 2_000,
604 ..EscalationJudgeConfig::default()
605 };
606
607 let summary = summarize_for_judge(&messages, 21, &config);
608
609 assert!(
610 summary.chars().count() <= MAX_REQUEST_CHARS,
611 "{}",
612 summary.chars().count()
613 );
614 assert!(summary.contains("[system] framing"), "{summary}");
616 assert!(summary.contains("[user (task)] task"), "{summary}");
617 assert!(summary.contains("19 xxx"), "{summary}");
618 assert!(!summary.contains("0 xxx"), "{summary}");
619 }
620}