1use serde::Deserialize;
11use switchyard_protocol::{ContentBlock, LlmRequest, Message, OutputParams, Role};
12
13use super::classifier_contract::{ClassifierContract, ClassifierContractConfig};
14use super::llm_judge::{Judge, JudgeClassifier, JudgePolicy};
15use crate::core::algorithm::LlmTarget;
16use crate::core::classifier::{Classification, Score};
17use crate::core::state::State;
18use crate::{LibsyError, Result};
19use switchyard_protocol::Request;
20
21const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
22const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
23
24const TRIM_MARKER: &str = " ...[trimmed] ";
26
27const TRUNCATION_SUFFIX: &str = "...<truncated>";
29
30const SYSTEM_CHARS: usize = 1_000;
33
34const FIRST_USER_CHARS: usize = 2_000;
36
37const MAX_REQUEST_CHARS: usize = 18_000;
39
40#[derive(Clone, Debug, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct EscalationJudgeConfig {
47 pub confirmations: u32,
52 pub recent_turn_window: usize,
54 pub window_message_chars: usize,
56}
57
58impl EscalationJudgeConfig {
59 fn validate(&self) -> Result<()> {
61 let reject = |message: String| Err(LibsyError::AlgorithmError { message });
62 if self.confirmations == 0 {
63 return reject("confirmations must be at least 1".to_string());
64 }
65 if self.recent_turn_window == 0 {
66 return reject("recent_turn_window must be at least 1".to_string());
67 }
68 if self.window_message_chars < 50 {
69 return reject(format!(
70 "window_message_chars must be at least 50, got {}",
71 self.window_message_chars
72 ));
73 }
74 Ok(())
75 }
76}
77
78impl Default for EscalationJudgeConfig {
79 fn default() -> Self {
80 Self {
81 confirmations: 2,
82 recent_turn_window: 28,
83 window_message_chars: 500,
84 }
85 }
86}
87
88#[derive(Deserialize)]
92pub(crate) struct EscalationVerdict {
93 escalate: bool,
94}
95
96pub(crate) struct EscalationJudge {
98 contract: ClassifierContract,
99 max_output_tokens: u64,
100 config: EscalationJudgeConfig,
101}
102
103impl Judge for EscalationJudge {
104 type Verdict = EscalationVerdict;
105
106 fn build_request(&self, _state: &State, request: &Request) -> Request {
109 let messages = &request.llm_request.messages;
110 let summary = summarize_for_judge(messages, conversation_turn(request), &self.config);
111 Request {
112 llm_request: LlmRequest {
113 model: request.llm_request.model.clone(),
114 messages: vec![
115 Message::text(Role::System, self.contract.system_prompt().to_string()),
116 Message::text(Role::User, summary),
117 ],
118 output: OutputParams {
119 response_format: Some(self.contract.response_format().clone()),
120 max_output_tokens: Some(self.max_output_tokens),
121 },
122 ..LlmRequest::default()
123 },
124 raw_request: None,
125 metadata: request.metadata.clone(),
126 }
127 }
128}
129
130pub(crate) struct EscalationPolicy {
136 capable: String,
137 efficient: String,
138}
139
140impl JudgePolicy for EscalationPolicy {
141 type Verdict = EscalationVerdict;
142
143 fn to_classification(&self, verdict: Option<&EscalationVerdict>) -> Classification {
144 match verdict {
145 Some(verdict) if verdict.escalate => Classification::Scores(vec![Score {
146 target: self.capable.clone(),
147 confidence: 1.0,
148 }]),
149 Some(_) => Classification::Scores(vec![Score {
150 target: self.efficient.clone(),
151 confidence: 1.0,
152 }]),
153 None => Classification::Ambiguous(Vec::new()),
154 }
155 }
156}
157
158pub(crate) fn build_judge(
163 judge_target: LlmTarget,
164 capable: String,
165 efficient: String,
166 contract_config: &ClassifierContractConfig,
167 config: EscalationJudgeConfig,
168 max_output_tokens: u64,
169) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
170 config.validate()?;
171 if max_output_tokens == 0 {
172 return Err(LibsyError::AlgorithmError {
173 message: "max_output_tokens must be at least 1".to_string(),
174 });
175 }
176 let contract =
177 ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)?;
178 Ok(JudgeClassifier::new(
179 EscalationJudge {
180 contract,
181 max_output_tokens,
182 config,
183 },
184 judge_target,
185 EscalationPolicy { capable, efficient },
186 ))
187}
188
189pub(crate) fn conversation_turn(request: &Request) -> usize {
198 request
199 .llm_request
200 .messages
201 .iter()
202 .filter(|message| message.role == Role::Assistant)
203 .count()
204}
205
206fn message_text(message: &Message) -> String {
212 let mut parts = Vec::new();
213 collect_text(&message.content, &mut parts);
214 parts.join(" ")
215}
216
217fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
219 for block in content {
220 match block {
221 ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
222 parts.push(text.clone());
223 }
224 ContentBlock::ToolCall(call) => {
225 parts.push(format!("tool_call {}({})", call.name, call.arguments));
226 }
227 ContentBlock::ToolResult(result) => collect_text(&result.content, parts),
228 _ => {}
229 }
230 }
231}
232
233fn truncate_middle(text: &str, limit: usize) -> String {
238 let chars: Vec<char> = text.chars().collect();
239 if chars.len() <= limit {
240 return text.to_string();
241 }
242 let keep = limit
243 .saturating_sub(TRIM_MARKER.chars().count())
244 .max(20)
245 .min(chars.len());
246 let head = keep * 2 / 3;
247 let tail = keep - head;
248 let mut out: String = chars[..head].iter().collect();
249 out.push_str(TRIM_MARKER);
250 out.extend(chars[chars.len() - tail..].iter());
251 out
252}
253
254fn summarize_for_judge(
264 messages: &[Message],
265 turn: usize,
266 config: &EscalationJudgeConfig,
267) -> String {
268 let mut anchors: Vec<String> = Vec::new();
269 let mut window: Vec<String> = Vec::new();
270 let mut first_user_seen = false;
271
272 for message in messages {
273 let text = message_text(message);
274 match message.role {
275 Role::System | Role::Developer => anchors.push(format!(
276 "[{}] {}",
277 role_label(message.role),
278 truncate_middle(&text, SYSTEM_CHARS)
279 )),
280 Role::User if !first_user_seen => {
281 first_user_seen = true;
282 anchors.push(format!(
283 "[user (task)] {}",
284 truncate_middle(&text, FIRST_USER_CHARS)
285 ));
286 }
287 role => window.push(format!(
288 "[{}] {}",
289 role_label(role),
290 truncate_middle(&text, config.window_message_chars)
291 )),
292 }
293 }
294
295 if window.len() > config.recent_turn_window {
296 window.drain(..window.len() - config.recent_turn_window);
297 }
298
299 let assemble = |window: &[String]| {
300 let header = format!(
301 "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
302 window.len(),
303 messages.len(),
304 );
305 std::iter::once(header)
306 .chain(anchors.iter().cloned())
307 .chain(window.iter().cloned())
308 .collect::<Vec<_>>()
309 .join("\n")
310 };
311
312 let mut text = assemble(&window);
313 while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
314 window.remove(0);
315 text = assemble(&window);
316 }
317 if text.chars().count() > MAX_REQUEST_CHARS {
318 let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
319 text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
320 }
321 text
322}
323
324fn role_label(role: Role) -> &'static str {
326 match role {
327 Role::System => "system",
328 Role::Developer => "developer",
329 Role::User => "user",
330 Role::Assistant => "assistant",
331 Role::Tool => "tool",
332 }
333}
334
335#[cfg(test)]
340pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
341 use switchyard_protocol::Metadata;
342
343 let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
344 for attempt in 1..turn {
345 messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
346 messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
347 }
348 Request {
349 llm_request: LlmRequest {
350 model: Some("auto".to_string()),
351 messages,
352 ..LlmRequest::default()
353 },
354 raw_request: None,
355 metadata: session_id.map(|id| Metadata {
356 session_id: Some(id.to_string()),
357 ..Metadata::default()
358 }),
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use serde_json::json;
365 use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
366
367 use super::*;
368
369 #[test]
370 fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
371 let judge = EscalationJudge {
372 contract: ClassifierContract::from_config(
373 &ClassifierContractConfig::default(),
374 PROMPT_TEMPLATE,
375 SCHEMA_TEMPLATE,
376 )?,
377 max_output_tokens: super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
378 config: EscalationJudgeConfig::default(),
379 };
380
381 let mut judged = request_at_turn(None, 4);
383 judged
384 .llm_request
385 .messages
386 .push(Message::text(Role::Assistant, "this turn's reply"));
387 let built = judge.build_request(&State::default(), &judged);
388
389 assert_eq!(built.llm_request.messages.len(), 2);
391 assert_eq!(built.llm_request.messages[0].role, Role::System);
392 assert_eq!(built.llm_request.messages[1].role, Role::User);
393 assert!(
394 built.llm_request.messages[1]
395 .text_content("")
396 .is_some_and(|text| text.contains("Conversation turn 4"))
397 );
398 assert_eq!(
400 built.llm_request.output.max_output_tokens,
401 Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
402 );
403 assert!(built.llm_request.output.response_format.is_some());
404 Ok(())
405 }
406
407 #[test]
408 fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
409 let judge = EscalationJudge {
410 contract: ClassifierContract::from_config(
411 &ClassifierContractConfig::default(),
412 PROMPT_TEMPLATE,
413 SCHEMA_TEMPLATE,
414 )?,
415 max_output_tokens: 512,
416 config: EscalationJudgeConfig::default(),
417 };
418
419 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
420
421 assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
422 Ok(())
423 }
424
425 #[test]
426 fn conversation_turn_counts_assistant_replies() {
427 for turn in [1, 5] {
430 let mut judged = request_at_turn(None, turn);
431 judged
432 .llm_request
433 .messages
434 .push(Message::text(Role::Assistant, "this turn's reply"));
435 assert_eq!(conversation_turn(&judged), turn);
436 }
437 }
438
439 #[test]
440 fn message_text_keeps_tool_calls_and_results() {
441 let call = Message {
442 role: Role::Assistant,
443 content: vec![
444 ContentBlock::Text {
445 text: "running it".to_string(),
446 },
447 ContentBlock::ToolCall(ToolCall {
448 id: "call-1".to_string(),
449 name: "bash".to_string(),
450 arguments: json!({"cmd": "ls"}),
451 }),
452 ],
453 };
454 let text = message_text(&call);
455 assert!(text.contains("running it"), "{text}");
456 assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
457
458 let result = Message {
459 role: Role::Tool,
460 content: vec![ContentBlock::ToolResult(ToolResult {
461 tool_call_id: "call-1".to_string(),
462 content: vec![ContentBlock::Text {
463 text: "no such file".to_string(),
464 }],
465 is_error: Some(true),
466 })],
467 };
468 assert_eq!(message_text(&result), "no such file");
469 }
470
471 #[test]
472 fn truncate_middle_keeps_head_and_tail() {
473 let text = "a".repeat(40) + &"z".repeat(40);
474 let trimmed = truncate_middle(&text, 50);
475 assert!(trimmed.chars().count() <= 50, "{trimmed}");
476 assert!(trimmed.starts_with('a'));
477 assert!(trimmed.ends_with('z'));
478 assert!(trimmed.contains("[trimmed]"));
479
480 assert_eq!(truncate_middle("short", 50), "short");
482 }
483
484 #[test]
485 fn summary_keeps_anchors_and_the_recent_window() {
486 let mut messages = vec![
487 Message::text(Role::System, "you are a coding agent"),
488 Message::text(Role::User, "fix the failing test"),
489 ];
490 for i in 0..10 {
491 messages.push(Message::text(Role::Assistant, format!("step {i}")));
492 }
493 let config = EscalationJudgeConfig {
494 recent_turn_window: 3,
495 ..EscalationJudgeConfig::default()
496 };
497
498 let summary = summarize_for_judge(&messages, 11, &config);
499
500 assert!(
501 summary.contains("[system] you are a coding agent"),
502 "{summary}"
503 );
504 assert!(
505 summary.contains("[user (task)] fix the failing test"),
506 "{summary}"
507 );
508 assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
509 assert!(summary.contains("step 9"), "{summary}");
511 assert!(summary.contains("step 7"), "{summary}");
512 assert!(!summary.contains("step 6"), "{summary}");
513 }
514
515 #[test]
516 fn summary_drops_oldest_window_lines_under_the_char_cap() {
517 let mut messages = vec![
521 Message::text(Role::System, "framing"),
522 Message::text(Role::User, "task"),
523 ];
524 for i in 0..20 {
525 messages.push(Message::text(
526 Role::Assistant,
527 format!("{i} {}", "x".repeat(2_000)),
528 ));
529 }
530 let config = EscalationJudgeConfig {
531 window_message_chars: 2_000,
532 ..EscalationJudgeConfig::default()
533 };
534
535 let summary = summarize_for_judge(&messages, 21, &config);
536
537 assert!(
538 summary.chars().count() <= MAX_REQUEST_CHARS,
539 "{}",
540 summary.chars().count()
541 );
542 assert!(summary.contains("[system] framing"), "{summary}");
544 assert!(summary.contains("[user (task)] task"), "{summary}");
545 assert!(summary.contains("19 xxx"), "{summary}");
546 assert!(!summary.contains("0 xxx"), "{summary}");
547 }
548}