Skip to main content

switchyard_libsy/algorithms/
system_prompt_judge.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Judge-selected system prompt injection from a hidden action database.
5
6use 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/// One hidden action the judge may select for system prompt injection.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct PromptInjectionAction {
29    /// Stable action id the judge returns.
30    pub id: String,
31    /// System prompt inserted when this action is selected.
32    pub prompt: String,
33}
34
35impl PromptInjectionAction {
36    /// Creates one validated action entry.
37    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    /// Parses a text action DB.
53    ///
54    /// The format is intentionally small:
55    ///
56    /// ```text
57    /// [action_id]
58    /// System prompt text to inject.
59    ///
60    /// [other_action]
61    /// Another system prompt.
62    /// ```
63    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/// Runtime knobs for [`SystemPromptJudge`].
101#[derive(Clone, Debug)]
102pub struct SystemPromptJudgeConfig {
103    /// Most completion tokens the judge verdict may use.
104    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
115/// Passthrough routing with a judge-selected hidden system prompt.
116pub struct SystemPromptJudge {
117    target: ModelId,
118    judge_target: ModelId,
119    actions: Vec<PromptInjectionAction>,
120    config: SystemPromptJudgeConfig,
121}
122
123impl SystemPromptJudge {
124    /// Creates a prompt-injection route.
125    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}