1use std::collections::BTreeSet;
7use std::sync::Arc;
8
9use serde::Deserialize;
10use serde_json::Value;
11use switchyard_protocol::{
12 AggLlmResponse, ContentBlock, InstructionBlock, LlmRequest, Message, ModelId, OutputParams,
13 Request, Role, completion_text,
14};
15
16use super::util::prompts::drop_exact_replay;
17use super::util::robustness::{safe_client_error, safe_error_summary};
18use crate::core::algorithm::{Algorithm, Driver, RoutingOutcome};
19use crate::{LibsyError, Result};
20
21const ALGORITHM_NAME: &str = "system_prompt_judge";
22const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 64;
23const TRAILING_JUDGE_INSTRUCTION: &str =
24 "Choose a system-prompt action for the next assistant turn. Return only JSON.";
25
26#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct PromptInjectionAction {
29 pub id: String,
31 pub prompt: String,
33}
34
35impl PromptInjectionAction {
36 pub fn new(id: impl Into<String>, prompt: impl Into<String>) -> Result<Self> {
38 let id = id.into();
39 let prompt = prompt.into();
40 validate_action_id(&id)?;
41 if prompt.trim().is_empty() {
42 return Err(algorithm_error(format!(
43 "system prompt action {id:?} must not have an empty prompt"
44 )));
45 }
46 Ok(Self {
47 id,
48 prompt: prompt.trim().to_string(),
49 })
50 }
51
52 pub fn parse_database(source: &str) -> Result<Vec<Self>> {
64 let mut actions = Vec::new();
65 let mut current_id: Option<String> = None;
66 let mut current_prompt = String::new();
67 let mut seen = BTreeSet::new();
68
69 for line in source.lines() {
70 let trimmed = line.trim();
71 if let Some(id) = section_id(trimmed) {
72 flush_action(
73 &mut actions,
74 &mut seen,
75 current_id.take(),
76 &mut current_prompt,
77 )?;
78 current_id = Some(id.to_string());
79 continue;
80 }
81 if current_id.is_some() {
82 current_prompt.push_str(line);
83 current_prompt.push('\n');
84 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
85 return Err(algorithm_error(
86 "system prompt action DB content must appear under [action_id] headers",
87 ));
88 }
89 }
90 flush_action(&mut actions, &mut seen, current_id, &mut current_prompt)?;
91 if actions.is_empty() {
92 return Err(algorithm_error(
93 "system prompt action DB must contain at least one [action_id] section",
94 ));
95 }
96 Ok(actions)
97 }
98}
99
100#[derive(Clone, Debug)]
102pub struct SystemPromptJudgeConfig {
103 pub max_output_tokens: u64,
105}
106
107impl Default for SystemPromptJudgeConfig {
108 fn default() -> Self {
109 Self {
110 max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS,
111 }
112 }
113}
114
115pub struct SystemPromptJudge {
117 target: ModelId,
118 judge_target: ModelId,
119 actions: Vec<PromptInjectionAction>,
120 config: SystemPromptJudgeConfig,
121}
122
123impl SystemPromptJudge {
124 pub fn new(
126 target: ModelId,
127 judge_target: ModelId,
128 actions: Vec<PromptInjectionAction>,
129 config: SystemPromptJudgeConfig,
130 ) -> Result<Self> {
131 if actions.is_empty() {
132 return Err(algorithm_error(
133 "system_prompt_judge requires at least one action",
134 ));
135 }
136 if config.max_output_tokens == 0 {
137 return Err(algorithm_error("max_output_tokens must be at least 1"));
138 }
139 Ok(Self {
140 target,
141 judge_target,
142 actions,
143 config,
144 })
145 }
146
147 async fn selected_action(
148 &self,
149 driver: &Driver,
150 request: &Request,
151 ) -> Option<&PromptInjectionAction> {
152 let response = driver
153 .call_model(self.judge_request(request), vec![self.judge_target.clone()])
154 .await
155 .inspect_err(|error| {
156 tracing::warn!(
157 target: "libsy",
158 error = %safe_error_summary(error),
159 "system-prompt judge unavailable; routing without injection"
160 );
161 })
162 .ok()?;
163 let aggregate = response
164 .llm_response
165 .into_agg()
166 .await
167 .inspect_err(|error| {
168 tracing::warn!(
169 target: "libsy",
170 error = %safe_client_error(error),
171 "system-prompt judge response failed; routing without injection"
172 );
173 })
174 .ok()?;
175 let action = parse_verdict(&aggregate)
176 .inspect_err(|error| {
177 tracing::warn!(
178 target: "libsy",
179 error = %safe_error_summary(error),
180 "system-prompt judge verdict invalid; routing without injection"
181 );
182 })
183 .ok()?;
184 if action.eq_ignore_ascii_case("none") {
185 return None;
186 }
187 self.actions
188 .iter()
189 .find(|entry| entry.id == action)
190 .or_else(|| {
191 tracing::warn!(
192 target: "libsy",
193 action,
194 "system-prompt judge selected unknown action; routing without injection"
195 );
196 None
197 })
198 }
199
200 fn judge_request(&self, request: &Request) -> Request {
201 let mut messages = request.llm_request.messages.clone();
202 messages.push(Message::text(
203 Role::User,
204 TRAILING_JUDGE_INSTRUCTION.to_string(),
205 ));
206 Request {
207 llm_request: LlmRequest {
208 model: request.llm_request.model.clone(),
209 instructions: vec![InstructionBlock {
210 role: Role::System,
211 content: vec![ContentBlock::Text {
212 text: judge_prompt(&self.actions),
213 }],
214 }],
215 messages,
216 output: OutputParams {
217 max_output_tokens: Some(self.config.max_output_tokens),
218 response_format: Some(serde_json::json!({"type": "json_object"})),
219 },
220 ..LlmRequest::default()
221 },
222 raw_request: None,
223 metadata: request.metadata.clone(),
224 }
225 }
226}
227
228#[async_trait::async_trait]
229impl Algorithm for SystemPromptJudge {
230 fn name(&self) -> &str {
231 ALGORITHM_NAME
232 }
233
234 async fn route(
235 self: Arc<Self>,
236 driver: Driver,
237 mut request: Request,
238 ) -> Result<RoutingOutcome> {
239 if let Some(action) = self.selected_action(&driver, &request).await {
240 tracing::info!(
241 target: "libsy",
242 action = action.id,
243 selected_model = %self.target,
244 "system-prompt judge injecting action"
245 );
246 request.llm_request.instructions.insert(
247 0,
248 InstructionBlock {
249 role: Role::System,
250 content: vec![ContentBlock::Text {
251 text: action.prompt.clone(),
252 }],
253 },
254 );
255 drop_exact_replay(&mut request);
256 }
257 Ok(RoutingOutcome::route_to(
258 self.target.clone(),
259 Vec::new(),
260 request,
261 ))
262 }
263}
264
265#[derive(Deserialize)]
266struct JudgeVerdict {
267 action: String,
268}
269
270fn section_id(line: &str) -> Option<&str> {
271 line.strip_prefix('[')
272 .and_then(|rest| rest.strip_suffix(']'))
273 .map(str::trim)
274 .filter(|id| !id.is_empty())
275}
276
277fn flush_action(
278 actions: &mut Vec<PromptInjectionAction>,
279 seen: &mut BTreeSet<String>,
280 id: Option<String>,
281 prompt: &mut String,
282) -> Result<()> {
283 let Some(id) = id else {
284 return Ok(());
285 };
286 if !seen.insert(id.clone()) {
287 return Err(algorithm_error(format!(
288 "duplicate system prompt action id {id:?}"
289 )));
290 }
291 actions.push(PromptInjectionAction::new(id, std::mem::take(prompt))?);
292 Ok(())
293}
294
295fn validate_action_id(id: &str) -> Result<()> {
296 if id.eq_ignore_ascii_case("none") {
297 return Err(algorithm_error(
298 "system prompt action id 'none' is reserved",
299 ));
300 }
301 if id
302 .chars()
303 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
304 {
305 return Ok(());
306 }
307 Err(algorithm_error(format!(
308 "system prompt action id {id:?} may contain only ASCII letters, digits, '.', '_', or '-'"
309 )))
310}
311
312fn judge_prompt(actions: &[PromptInjectionAction]) -> String {
313 let mut prompt = String::from(
314 "You are a routing judge. Read the agent conversation and decide whether one hidden \
315 system-prompt action should be injected into the next assistant call.\n\
316 Select an action only when it clearly helps the next turn. Otherwise select none.\n\
317 Return exactly one JSON object: {\"action\":\"<id-or-none>\"}.\n\n\
318 Hidden action DB:\n",
319 );
320 for action in actions {
321 prompt.push_str("\n[");
322 prompt.push_str(&action.id);
323 prompt.push_str("]\n");
324 prompt.push_str(&action.prompt);
325 prompt.push('\n');
326 }
327 prompt
328}
329
330fn parse_verdict(response: &AggLlmResponse) -> Result<String> {
331 let completion = completion_text(response);
332 let text = strip_json_fence(completion.trim());
333 let verdict: JudgeVerdict =
334 serde_json::from_str(text).or_else(|_| parse_action_from_value(text))?;
335 let action = verdict.action.trim().to_string();
336 if action.is_empty() {
337 return Err(algorithm_error("system-prompt judge returned empty action"));
338 }
339 Ok(action)
340}
341
342fn parse_action_from_value(text: &str) -> Result<JudgeVerdict> {
343 let value: Value = serde_json::from_str(text).map_err(|error| {
344 algorithm_error(format!(
345 "system-prompt judge reply did not parse as JSON: {error}"
346 ))
347 })?;
348 let action = value
349 .get("action")
350 .and_then(Value::as_str)
351 .ok_or_else(|| algorithm_error("system-prompt judge JSON must contain string action"))?;
352 Ok(JudgeVerdict {
353 action: action.to_string(),
354 })
355}
356
357fn strip_json_fence(text: &str) -> &str {
358 let Some(rest) = text.strip_prefix("```") else {
359 return text;
360 };
361 let rest = rest.strip_prefix("json").unwrap_or(rest);
362 let rest = rest.trim_start_matches(['\n', '\r']);
363 rest.strip_suffix("```").map(str::trim).unwrap_or(rest)
364}
365
366fn algorithm_error(message: impl Into<String>) -> LibsyError {
367 LibsyError::AlgorithmError {
368 message: message.into(),
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 use parking_lot::Mutex;
377 use switchyard_protocol::{LlmResponse, Response, text_request, text_response};
378
379 use crate::core::testing::{reply, test_drive};
380
381 #[test]
382 fn parses_section_based_action_database() -> Result<()> {
383 let actions = PromptInjectionAction::parse_database(
384 r#"
385# comments before the first section are ignored
386[compile_failure]
387Focus on the exact compiler error.
388
389[stuck-loop]
390Stop repeating commands and make a new plan.
391"#,
392 )?;
393 assert_eq!(
394 actions,
395 vec![
396 PromptInjectionAction::new(
397 "compile_failure",
398 "Focus on the exact compiler error.",
399 )?,
400 PromptInjectionAction::new(
401 "stuck-loop",
402 "Stop repeating commands and make a new plan.",
403 )?,
404 ]
405 );
406 Ok(())
407 }
408
409 #[tokio::test]
410 async fn injects_the_prompt_selected_by_the_judge() -> Result<()> {
411 let actions = vec![PromptInjectionAction::new(
412 "compile_failure",
413 "Focus on compiler diagnostics before editing.",
414 )?];
415 let algorithm: Arc<dyn Algorithm> = Arc::new(SystemPromptJudge::new(
416 "target".into(),
417 "judge".into(),
418 actions,
419 SystemPromptJudgeConfig::default(),
420 )?);
421 let calls = Arc::new(Mutex::new(Vec::<(String, Request)>::new()));
422 let recorder = Arc::clone(&calls);
423 let mut request = Request {
424 llm_request: text_request(Some("route".to_string()), "nvcc failed"),
425 raw_request: None,
426 metadata: None,
427 };
428 request
429 .llm_request
430 .preservation
431 .requests
432 .insert("openai_chat".into(), serde_json::json!({"model":"route"}));
433
434 let (selected, _) = test_drive(
435 algorithm,
436 request,
437 move |model: ModelId, request: Request| {
438 let recorder = Arc::clone(&recorder);
439 async move {
440 recorder.lock().push((model.to_string(), request.clone()));
441 if model == "judge" {
442 Ok(Response {
443 llm_response: LlmResponse::Agg(text_response(
444 None,
445 r#"{"action":"compile_failure"}"#,
446 )),
447 metadata: None,
448 upstream_headers: Default::default(),
449 })
450 } else {
451 Ok(reply("ok"))
452 }
453 }
454 },
455 )
456 .await?;
457
458 assert_eq!(selected, "target");
459 let calls = calls.lock();
460 assert_eq!(
461 calls
462 .iter()
463 .map(|(model, _)| model.as_str())
464 .collect::<Vec<_>>(),
465 ["judge", "target"]
466 );
467 let judge_request = &calls[0].1;
468 let judge_prompt = judge_request
469 .llm_request
470 .instructions
471 .first()
472 .and_then(|instruction| instruction.content.first())
473 .and_then(|block| match block {
474 ContentBlock::Text { text } => Some(text.as_str()),
475 _ => None,
476 })
477 .unwrap_or_default();
478 assert!(judge_prompt.contains("[compile_failure]"));
479 assert!(judge_prompt.contains("Focus on compiler diagnostics before editing."));
480 assert_eq!(
481 judge_request
482 .llm_request
483 .messages
484 .first()
485 .and_then(|message| message.text_content("|")),
486 Some("nvcc failed".to_string())
487 );
488 let target_request = &calls[1].1;
489 let injected = target_request
490 .llm_request
491 .instructions
492 .first()
493 .and_then(|instruction| instruction.content.first())
494 .and_then(|block| match block {
495 ContentBlock::Text { text } => Some(text.as_str()),
496 _ => None,
497 });
498 assert_eq!(
499 injected,
500 Some("Focus on compiler diagnostics before editing.")
501 );
502 assert!(
503 target_request.llm_request.preservation.requests.is_empty(),
504 "mutated requests must not exact-replay the inbound body"
505 );
506 Ok(())
507 }
508
509 #[tokio::test]
510 async fn none_verdict_leaves_request_untouched() -> Result<()> {
511 let algorithm: Arc<dyn Algorithm> = Arc::new(SystemPromptJudge::new(
512 "target".into(),
513 "judge".into(),
514 vec![PromptInjectionAction::new(
515 "compile_failure",
516 "diagnose first",
517 )?],
518 SystemPromptJudgeConfig::default(),
519 )?);
520 let calls = Arc::new(Mutex::new(Vec::<(String, Request)>::new()));
521 let recorder = Arc::clone(&calls);
522
523 test_drive(
524 algorithm,
525 Request {
526 llm_request: text_request(Some("route".to_string()), "hello"),
527 raw_request: None,
528 metadata: None,
529 },
530 move |model: ModelId, request: Request| {
531 let recorder = Arc::clone(&recorder);
532 async move {
533 recorder.lock().push((model.to_string(), request));
534 if model == "judge" {
535 Ok(reply(r#"{"action":"none"}"#))
536 } else {
537 Ok(reply("ok"))
538 }
539 }
540 },
541 )
542 .await?;
543
544 let calls = calls.lock();
545 assert_eq!(calls[1].0, "target");
546 assert!(calls[1].1.llm_request.instructions.is_empty());
547 Ok(())
548 }
549
550 #[tokio::test]
551 async fn invalid_judge_reply_fails_open() -> Result<()> {
552 let algorithm: Arc<dyn Algorithm> = Arc::new(SystemPromptJudge::new(
553 "target".into(),
554 "judge".into(),
555 vec![PromptInjectionAction::new(
556 "compile_failure",
557 "diagnose first",
558 )?],
559 SystemPromptJudgeConfig::default(),
560 )?);
561
562 let (selected, response) = test_drive(
563 algorithm,
564 Request {
565 llm_request: text_request(Some("route".to_string()), "hello"),
566 raw_request: None,
567 metadata: None,
568 },
569 |model: ModelId, _request: Request| async move {
570 if model == "judge" {
571 Ok(reply("not json"))
572 } else {
573 Ok(reply("ok"))
574 }
575 },
576 )
577 .await?;
578
579 assert_eq!(selected, "target");
580 assert_eq!(
581 completion_text(response.llm_response.as_agg().unwrap()),
582 "ok"
583 );
584 Ok(())
585 }
586}