switchyard_libsy/algorithms/util/
prompts.rs1use 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
34pub 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
55pub(crate) fn drop_exact_replay(request: &mut Request) {
70 request.llm_request.preservation.requests.clear();
71}
72
73#[derive(Clone, Debug, Default)]
76pub struct TargetPrompts {
77 by_target: BTreeMap<ModelId, String>,
78}
79
80impl TargetPrompts {
81 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 pub fn get(&self, target: &ModelId) -> Option<&str> {
89 self.by_target.get(target).map(String::as_str)
90 }
91
92 pub fn is_empty(&self) -> bool {
95 self.by_target.is_empty()
96 }
97}
98
99pub struct SystemPromptProcessor {
101 prompts: TargetPrompts,
102}
103
104impl SystemPromptProcessor {
105 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 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 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 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 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 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 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 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 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}