Skip to main content

switchyard_libsy/algorithms/util/
prompts.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Adding text to a request on its way to the model it was routed to.
5//!
6//! Two shapes, both target-agnostic — any algorithm routing between named
7//! targets can use them, and neither writes anything back into the caller's
8//! conversation:
9//!
10//! * [`append_note`] — a one-off note in the conversation itself, for telling
11//!   the model something about *this* turn.
12//! * [`SystemPromptProcessor`] — standing instructions per target, applied on
13//!   every turn that target serves.
14//!
15//! Which text, and when, is the caller's policy; this module only knows how to
16//! place it so the provider accepts it and the prompt cache survives.
17//!
18//! **Anything added here must call [`drop_exact_replay`].** Both shapes above
19//! mutate the normalized request, and a codec asked to encode for the format the
20//! request arrived in replays the body captured at decode instead of reading that
21//! request — so an addition that leaves exact replay in place never reaches the
22//! model. This is not enforced: a future processor that mutates the request and
23//! forgets the call reintroduces SWITCH-1224, silently and without a failing
24//! test.
25
26use std::collections::BTreeMap;
27
28use async_trait::async_trait;
29use switchyard_protocol::{ContentBlock, InstructionBlock, Message, ModelId, Request, Role};
30
31use crate::Result;
32use crate::core::processor::{Event, Processor};
33
34/// Appends `note` to the request as conversation text.
35///
36/// It joins the trailing user message when there is one, rather than opening a
37/// turn of its own: Anthropic rejects two consecutive user messages, and a
38/// `tool_result` must stay first within its message. A text block after it
39/// satisfies both and keeps the addition a cache-safe suffix. Any other trailing
40/// role — an empty conversation, or one ending on an assistant turn — takes a
41/// fresh user message.
42pub fn append_note(request: &mut Request, note: &str) {
43    match request.llm_request.messages.last_mut() {
44        Some(last) if last.role == Role::User => last.content.push(ContentBlock::Text {
45            text: note.to_string(),
46        }),
47        _ => request
48            .llm_request
49            .messages
50            .push(Message::text(Role::User, note)),
51    }
52    drop_exact_replay(request);
53}
54
55/// Gives up exact same-format replay for this turn.
56///
57/// A codec replays the preserved inbound body verbatim when the target format
58/// matches the source, which is what keeps a same-format hop lossless. That body
59/// predates anything added here, so leaving it in place would encode the request
60/// as it arrived and silently drop the addition. Dropping it sends the codec down
61/// its normal path, which encodes from the request itself.
62///
63/// Every stored body goes, not just the one for the inbound format: preservation
64/// also carries bodies embedded by earlier hops, and the addition is missing from
65/// all of them equally.
66///
67/// Call this from any new code that mutates the request. Nothing checks that you
68/// have.
69pub(crate) fn drop_exact_replay(request: &mut Request) {
70    request.llm_request.preservation.requests.clear();
71}
72
73/// System prompts keyed by routing target. A target left unset is routed
74/// untouched.
75#[derive(Clone, Debug, Default)]
76pub struct TargetPrompts {
77    by_target: BTreeMap<ModelId, String>,
78}
79
80impl TargetPrompts {
81    /// Hand `target` this prompt on every turn it serves.
82    pub fn with(mut self, target: impl Into<ModelId>, prompt: impl Into<String>) -> Self {
83        self.by_target.insert(target.into(), prompt.into());
84        self
85    }
86
87    /// The prompt configured for `target`, if any.
88    pub fn get(&self, target: &ModelId) -> Option<&str> {
89        self.by_target.get(target).map(String::as_str)
90    }
91
92    /// Whether any target has a prompt, so a caller can skip wiring the
93    /// processor when none does.
94    pub fn is_empty(&self) -> bool {
95        self.by_target.is_empty()
96    }
97}
98
99/// Prepends the routed target's system prompt to the outbound request.
100pub struct SystemPromptProcessor {
101    prompts: TargetPrompts,
102}
103
104impl SystemPromptProcessor {
105    /// Hand each target the prompt configured for it.
106    pub fn new(prompts: TargetPrompts) -> Self {
107        Self { prompts }
108    }
109}
110
111#[async_trait]
112impl<S: Send> Processor<S> for SystemPromptProcessor {
113    async fn process(&self, _state: &mut S, event: Event<'_>) -> Result<()> {
114        // The decision event carries both the routing outcome and the outbound request,
115        // so the target is read straight off it — whichever classifier picked it, and
116        // with nothing kept between turns.
117        let Event::Decision {
118            request,
119            selected_model_id,
120        } = event
121        else {
122            return Ok(());
123        };
124        let Some(prompt) = self.prompts.get(selected_model_id) else {
125            return Ok(());
126        };
127        // Ahead of the client's own instructions, so this framing is what the
128        // model reads first.
129        request.llm_request.instructions.insert(
130            0,
131            InstructionBlock {
132                role: Role::System,
133                content: vec![ContentBlock::Text {
134                    text: prompt.to_string(),
135                }],
136            },
137        );
138        drop_exact_replay(request);
139        Ok(())
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use switchyard_protocol::{LlmRequest, ModelId, ToolResult, text_request};
147
148    const NOTE: &str = "recovering from an error";
149    const STRONG_PROMPT: &str = "diagnose before you edit";
150    const WEAK_PROMPT: &str = "follow the settled plan";
151
152    /// Every test request carries the exact inbound body a codec keeps for
153    /// same-format replay, so each assertion below also says what happens to it.
154    fn request_with(messages: Vec<Message>) -> Request {
155        Request {
156            llm_request: LlmRequest {
157                messages,
158                preservation: preserved_body(),
159                ..LlmRequest::default()
160            },
161            raw_request: None,
162            metadata: None,
163        }
164    }
165
166    fn preserved_body() -> switchyard_protocol::PreservationMetadata {
167        let mut preservation = switchyard_protocol::PreservationMetadata::default();
168        preservation.requests.insert(
169            "openai_chat".into(),
170            serde_json::json!({"model": "weak", "messages": [{"role": "user", "content": "hi"}]}),
171        );
172        preservation
173    }
174
175    fn replays_exactly(request: &Request) -> bool {
176        !request.llm_request.preservation.requests.is_empty()
177    }
178
179    #[test]
180    fn a_note_joins_a_trailing_user_turn_after_its_tool_result() {
181        // The shape a coding-agent turn actually arrives in: the tool result
182        // leads the trailing user message, so the note has to follow it.
183        let tool_result = ContentBlock::ToolResult(ToolResult {
184            tool_call_id: "call_1".to_string(),
185            content: vec![ContentBlock::Text {
186                text: "exit 1".to_string(),
187            }],
188            is_error: Some(true),
189        });
190        let mut request = request_with(vec![Message {
191            role: Role::User,
192            content: vec![tool_result.clone()],
193        }]);
194
195        append_note(&mut request, NOTE);
196
197        let messages = &request.llm_request.messages;
198        assert_eq!(messages.len(), 1, "no second consecutive user turn");
199        assert_eq!(
200            messages[0].content,
201            vec![
202                tool_result,
203                ContentBlock::Text {
204                    text: NOTE.to_string()
205                }
206            ]
207        );
208    }
209
210    #[test]
211    fn a_note_opens_a_user_turn_after_an_assistant_turn() {
212        let mut request = request_with(vec![Message::text(Role::Assistant, "done")]);
213
214        append_note(&mut request, NOTE);
215
216        let messages = &request.llm_request.messages;
217        assert_eq!(messages.len(), 2);
218        assert_eq!(messages[1].role, Role::User);
219        assert_eq!(messages[1].text_content(""), Some(NOTE.to_string()));
220    }
221
222    #[test]
223    fn a_note_leaves_the_rest_of_the_conversation_untouched() {
224        let mut request = Request {
225            llm_request: LlmRequest {
226                preservation: preserved_body(),
227                ..text_request(Some("auto".to_string()), "fix the build")
228            },
229            raw_request: None,
230            metadata: None,
231        };
232
233        append_note(&mut request, NOTE);
234
235        let trail: Vec<String> = request
236            .llm_request
237            .messages
238            .iter()
239            .filter_map(|message| message.text_content("|"))
240            .collect();
241        assert_eq!(trail, vec![format!("fix the build|{NOTE}")]);
242        assert!(
243            !replays_exactly(&request),
244            "a same-format hop would replay the body captured before the note"
245        );
246    }
247
248    /// The instruction text the request carries.
249    fn instructions(request: &Request) -> Vec<String> {
250        request
251            .llm_request
252            .instructions
253            .iter()
254            .filter_map(|block| {
255                block.content.iter().find_map(|content| match content {
256                    ContentBlock::Text { text } => Some(text.clone()),
257                    _ => None,
258                })
259            })
260            .collect()
261    }
262
263    /// Runs one outbound request routed to `target` through `processor`.
264    async fn run(processor: &SystemPromptProcessor, target: &'static str) -> Result<Request> {
265        let mut request = Request {
266            llm_request: LlmRequest {
267                preservation: preserved_body(),
268                ..LlmRequest::default()
269            },
270            ..Request::default()
271        };
272        let selected_model_id = ModelId::from(target);
273        processor
274            .process(
275                &mut (),
276                Event::Decision {
277                    request: &mut request,
278                    selected_model_id: &selected_model_id,
279                },
280            )
281            .await?;
282        Ok(request)
283    }
284
285    fn prompts() -> TargetPrompts {
286        TargetPrompts::default()
287            .with("strong", STRONG_PROMPT)
288            .with("weak", WEAK_PROMPT)
289    }
290
291    #[tokio::test]
292    async fn each_target_gets_its_own_prompt() -> Result<()> {
293        let processor = SystemPromptProcessor::new(prompts());
294        for (target, expected) in [("strong", STRONG_PROMPT), ("weak", WEAK_PROMPT)] {
295            let request = run(&processor, target).await?;
296            assert_eq!(instructions(&request), vec![expected]);
297            assert!(
298                !replays_exactly(&request),
299                "{target}: a same-format hop would replay the body captured before the prompt"
300            );
301        }
302        Ok(())
303    }
304
305    #[tokio::test]
306    async fn an_unconfigured_target_is_left_untouched() -> Result<()> {
307        // One target's prompt must not leak onto another, whatever ran before.
308        let processor =
309            SystemPromptProcessor::new(TargetPrompts::default().with("strong", STRONG_PROMPT));
310        assert_eq!(
311            instructions(&run(&processor, "strong").await?),
312            vec![STRONG_PROMPT]
313        );
314        let untouched = run(&processor, "weak").await?;
315        assert!(instructions(&untouched).is_empty());
316        assert!(
317            replays_exactly(&untouched),
318            "an untouched request must keep its lossless same-format replay"
319        );
320        Ok(())
321    }
322
323    #[tokio::test]
324    async fn the_prompt_leads_the_client_instructions() -> Result<()> {
325        let processor = SystemPromptProcessor::new(prompts());
326        let mut request = Request::default();
327        request.llm_request.instructions.push(InstructionBlock {
328            role: Role::System,
329            content: vec![ContentBlock::Text {
330                text: "you are a coding agent".to_string(),
331            }],
332        });
333        let selected_model_id = ModelId::from("strong");
334
335        processor
336            .process(
337                &mut (),
338                Event::Decision {
339                    request: &mut request,
340                    selected_model_id: &selected_model_id,
341                },
342            )
343            .await?;
344
345        assert_eq!(
346            instructions(&request),
347            vec![STRONG_PROMPT, "you are a coding agent"]
348        );
349        Ok(())
350    }
351
352    #[tokio::test]
353    async fn the_inbound_request_is_left_alone() -> Result<()> {
354        // The inbound hook runs before the cascade has picked anything.
355        let processor = SystemPromptProcessor::new(prompts());
356        let mut request = Request::default();
357        processor
358            .process(&mut (), Event::Request(&mut request))
359            .await?;
360        assert!(instructions(&request).is_empty());
361        Ok(())
362    }
363}