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.
69fn 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 { request, decision } = event else {
118            return Ok(());
119        };
120        let Some(prompt) = self.prompts.get(decision.selected_model_id()) else {
121            return Ok(());
122        };
123        // Ahead of the client's own instructions, so this framing is what the
124        // model reads first.
125        request.llm_request.instructions.insert(
126            0,
127            InstructionBlock {
128                role: Role::System,
129                content: vec![ContentBlock::Text {
130                    text: prompt.to_string(),
131                }],
132            },
133        );
134        drop_exact_replay(request);
135        Ok(())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use switchyard_protocol::{Decision, LlmRequest, ToolResult, text_request};
143
144    const NOTE: &str = "recovering from an error";
145    const STRONG_PROMPT: &str = "diagnose before you edit";
146    const WEAK_PROMPT: &str = "follow the settled plan";
147
148    /// Every test request carries the exact inbound body a codec keeps for
149    /// same-format replay, so each assertion below also says what happens to it.
150    fn request_with(messages: Vec<Message>) -> Request {
151        Request {
152            llm_request: LlmRequest {
153                messages,
154                preservation: preserved_body(),
155                ..LlmRequest::default()
156            },
157            raw_request: None,
158            metadata: None,
159        }
160    }
161
162    fn preserved_body() -> switchyard_protocol::PreservationMetadata {
163        let mut preservation = switchyard_protocol::PreservationMetadata::default();
164        preservation.requests.insert(
165            "openai_chat".into(),
166            serde_json::json!({"model": "weak", "messages": [{"role": "user", "content": "hi"}]}),
167        );
168        preservation
169    }
170
171    fn replays_exactly(request: &Request) -> bool {
172        !request.llm_request.preservation.requests.is_empty()
173    }
174
175    #[test]
176    fn a_note_joins_a_trailing_user_turn_after_its_tool_result() {
177        // The shape a coding-agent turn actually arrives in: the tool result
178        // leads the trailing user message, so the note has to follow it.
179        let tool_result = ContentBlock::ToolResult(ToolResult {
180            tool_call_id: "call_1".to_string(),
181            content: vec![ContentBlock::Text {
182                text: "exit 1".to_string(),
183            }],
184            is_error: Some(true),
185        });
186        let mut request = request_with(vec![Message {
187            role: Role::User,
188            content: vec![tool_result.clone()],
189        }]);
190
191        append_note(&mut request, NOTE);
192
193        let messages = &request.llm_request.messages;
194        assert_eq!(messages.len(), 1, "no second consecutive user turn");
195        assert_eq!(
196            messages[0].content,
197            vec![
198                tool_result,
199                ContentBlock::Text {
200                    text: NOTE.to_string()
201                }
202            ]
203        );
204    }
205
206    #[test]
207    fn a_note_opens_a_user_turn_after_an_assistant_turn() {
208        let mut request = request_with(vec![Message::text(Role::Assistant, "done")]);
209
210        append_note(&mut request, NOTE);
211
212        let messages = &request.llm_request.messages;
213        assert_eq!(messages.len(), 2);
214        assert_eq!(messages[1].role, Role::User);
215        assert_eq!(messages[1].text_content(""), Some(NOTE.to_string()));
216    }
217
218    #[test]
219    fn a_note_leaves_the_rest_of_the_conversation_untouched() {
220        let mut request = Request {
221            llm_request: LlmRequest {
222                preservation: preserved_body(),
223                ..text_request(Some("auto".to_string()), "fix the build")
224            },
225            raw_request: None,
226            metadata: None,
227        };
228
229        append_note(&mut request, NOTE);
230
231        let trail: Vec<String> = request
232            .llm_request
233            .messages
234            .iter()
235            .filter_map(|message| message.text_content("|"))
236            .collect();
237        assert_eq!(trail, vec![format!("fix the build|{NOTE}")]);
238        assert!(
239            !replays_exactly(&request),
240            "a same-format hop would replay the body captured before the note"
241        );
242    }
243
244    /// The instruction text the request carries.
245    fn instructions(request: &Request) -> Vec<String> {
246        request
247            .llm_request
248            .instructions
249            .iter()
250            .filter_map(|block| {
251                block.content.iter().find_map(|content| match content {
252                    ContentBlock::Text { text } => Some(text.clone()),
253                    _ => None,
254                })
255            })
256            .collect()
257    }
258
259    /// Runs one outbound request routed to `target` through `processor`.
260    async fn run(processor: &SystemPromptProcessor, target: &'static str) -> Result<Request> {
261        let mut request = Request {
262            llm_request: LlmRequest {
263                preservation: preserved_body(),
264                ..LlmRequest::default()
265            },
266            ..Request::default()
267        };
268        let decision = Decision::new(target, None, true);
269        processor
270            .process(
271                &mut (),
272                Event::Decision {
273                    request: &mut request,
274                    decision: &decision,
275                },
276            )
277            .await?;
278        Ok(request)
279    }
280
281    fn prompts() -> TargetPrompts {
282        TargetPrompts::default()
283            .with("strong", STRONG_PROMPT)
284            .with("weak", WEAK_PROMPT)
285    }
286
287    #[tokio::test]
288    async fn each_target_gets_its_own_prompt() -> Result<()> {
289        let processor = SystemPromptProcessor::new(prompts());
290        for (target, expected) in [("strong", STRONG_PROMPT), ("weak", WEAK_PROMPT)] {
291            let request = run(&processor, target).await?;
292            assert_eq!(instructions(&request), vec![expected]);
293            assert!(
294                !replays_exactly(&request),
295                "{target}: a same-format hop would replay the body captured before the prompt"
296            );
297        }
298        Ok(())
299    }
300
301    #[tokio::test]
302    async fn an_unconfigured_target_is_left_untouched() -> Result<()> {
303        // One target's prompt must not leak onto another, whatever ran before.
304        let processor =
305            SystemPromptProcessor::new(TargetPrompts::default().with("strong", STRONG_PROMPT));
306        assert_eq!(
307            instructions(&run(&processor, "strong").await?),
308            vec![STRONG_PROMPT]
309        );
310        let untouched = run(&processor, "weak").await?;
311        assert!(instructions(&untouched).is_empty());
312        assert!(
313            replays_exactly(&untouched),
314            "an untouched request must keep its lossless same-format replay"
315        );
316        Ok(())
317    }
318
319    #[tokio::test]
320    async fn the_prompt_leads_the_client_instructions() -> Result<()> {
321        let processor = SystemPromptProcessor::new(prompts());
322        let mut request = Request::default();
323        request.llm_request.instructions.push(InstructionBlock {
324            role: Role::System,
325            content: vec![ContentBlock::Text {
326                text: "you are a coding agent".to_string(),
327            }],
328        });
329        let decision = Decision::new("strong", None, true);
330
331        processor
332            .process(
333                &mut (),
334                Event::Decision {
335                    request: &mut request,
336                    decision: &decision,
337                },
338            )
339            .await?;
340
341        assert_eq!(
342            instructions(&request),
343            vec![STRONG_PROMPT, "you are a coding agent"]
344        );
345        Ok(())
346    }
347
348    #[tokio::test]
349    async fn the_inbound_request_is_left_alone() -> Result<()> {
350        // The inbound hook runs before the cascade has picked anything.
351        let processor = SystemPromptProcessor::new(prompts());
352        let mut request = Request::default();
353        processor
354            .process(&mut (), Event::Request(&mut request))
355            .await?;
356        assert!(instructions(&request).is_empty());
357        Ok(())
358    }
359}