switchyard_libsy/algorithms/util/
prompts.rs1use std::collections::BTreeMap;
27
28use async_trait::async_trait;
29use switchyard_protocol::{ContentBlock, InstructionBlock, Message, 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
55fn 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<String, String>,
78}
79
80impl TargetPrompts {
81 pub fn with(mut self, target: impl Into<String>, prompt: impl Into<String>) -> Self {
83 self.by_target.insert(target.into(), prompt.into());
84 self
85 }
86
87 pub fn get(&self, target: &str) -> 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 { request, decision } = event else {
118 return Ok(());
119 };
120 let Some(prompt) = self.prompts.get(decision.selected_model()) else {
121 return Ok(());
122 };
123 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::{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 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 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 struct RoutedTo(&'static str);
246 impl switchyard_protocol::Decision for RoutedTo {
247 fn selected_model(&self) -> &str {
248 self.0
249 }
250 fn reasoning(&self) -> Option<&str> {
251 None
252 }
253 fn as_any(&self) -> &dyn std::any::Any {
254 self
255 }
256 }
257
258 fn instructions(request: &Request) -> Vec<String> {
260 request
261 .llm_request
262 .instructions
263 .iter()
264 .filter_map(|block| {
265 block.content.iter().find_map(|content| match content {
266 ContentBlock::Text { text } => Some(text.clone()),
267 _ => None,
268 })
269 })
270 .collect()
271 }
272
273 async fn run(processor: &SystemPromptProcessor, target: &'static str) -> Result<Request> {
275 let mut request = Request {
276 llm_request: LlmRequest {
277 preservation: preserved_body(),
278 ..LlmRequest::default()
279 },
280 ..Request::default()
281 };
282 processor
283 .process(
284 &mut (),
285 Event::Decision {
286 request: &mut request,
287 decision: &RoutedTo(target),
288 },
289 )
290 .await?;
291 Ok(request)
292 }
293
294 fn prompts() -> TargetPrompts {
295 TargetPrompts::default()
296 .with("strong", STRONG_PROMPT)
297 .with("weak", WEAK_PROMPT)
298 }
299
300 #[tokio::test]
301 async fn each_target_gets_its_own_prompt() -> Result<()> {
302 let processor = SystemPromptProcessor::new(prompts());
303 for (target, expected) in [("strong", STRONG_PROMPT), ("weak", WEAK_PROMPT)] {
304 let request = run(&processor, target).await?;
305 assert_eq!(instructions(&request), vec![expected]);
306 assert!(
307 !replays_exactly(&request),
308 "{target}: a same-format hop would replay the body captured before the prompt"
309 );
310 }
311 Ok(())
312 }
313
314 #[tokio::test]
315 async fn an_unconfigured_target_is_left_untouched() -> Result<()> {
316 let processor =
318 SystemPromptProcessor::new(TargetPrompts::default().with("strong", STRONG_PROMPT));
319 assert_eq!(
320 instructions(&run(&processor, "strong").await?),
321 vec![STRONG_PROMPT]
322 );
323 let untouched = run(&processor, "weak").await?;
324 assert!(instructions(&untouched).is_empty());
325 assert!(
326 replays_exactly(&untouched),
327 "an untouched request must keep its lossless same-format replay"
328 );
329 Ok(())
330 }
331
332 #[tokio::test]
333 async fn the_prompt_leads_the_client_instructions() -> Result<()> {
334 let processor = SystemPromptProcessor::new(prompts());
335 let mut request = Request::default();
336 request.llm_request.instructions.push(InstructionBlock {
337 role: Role::System,
338 content: vec![ContentBlock::Text {
339 text: "you are a coding agent".to_string(),
340 }],
341 });
342
343 processor
344 .process(
345 &mut (),
346 Event::Decision {
347 request: &mut request,
348 decision: &RoutedTo("strong"),
349 },
350 )
351 .await?;
352
353 assert_eq!(
354 instructions(&request),
355 vec![STRONG_PROMPT, "you are a coding agent"]
356 );
357 Ok(())
358 }
359
360 #[tokio::test]
361 async fn the_inbound_request_is_left_alone() -> Result<()> {
362 let processor = SystemPromptProcessor::new(prompts());
364 let mut request = Request::default();
365 processor
366 .process(&mut (), Event::Request(&mut request))
367 .await?;
368 assert!(instructions(&request).is_empty());
369 Ok(())
370 }
371}