switchyard_libsy/algorithms/util/
prompts.rs1use std::collections::BTreeMap;
19
20use async_trait::async_trait;
21use switchyard_protocol::{ContentBlock, InstructionBlock, Message, Request, Role};
22
23use crate::Result;
24use crate::core::processor::{Event, Processor};
25
26pub fn append_note(request: &mut Request, note: &str) {
35 match request.llm_request.messages.last_mut() {
36 Some(last) if last.role == Role::User => last.content.push(ContentBlock::Text {
37 text: note.to_string(),
38 }),
39 _ => request
40 .llm_request
41 .messages
42 .push(Message::text(Role::User, note)),
43 }
44}
45
46#[derive(Clone, Debug, Default)]
49pub struct TargetPrompts {
50 by_target: BTreeMap<String, String>,
51}
52
53impl TargetPrompts {
54 pub fn with(mut self, target: impl Into<String>, prompt: impl Into<String>) -> Self {
56 self.by_target.insert(target.into(), prompt.into());
57 self
58 }
59
60 pub fn get(&self, target: &str) -> Option<&str> {
62 self.by_target.get(target).map(String::as_str)
63 }
64
65 pub fn is_empty(&self) -> bool {
68 self.by_target.is_empty()
69 }
70}
71
72pub struct SystemPromptProcessor {
74 prompts: TargetPrompts,
75}
76
77impl SystemPromptProcessor {
78 pub fn new(prompts: TargetPrompts) -> Self {
80 Self { prompts }
81 }
82}
83
84#[async_trait]
85impl<S: Send> Processor<S> for SystemPromptProcessor {
86 async fn process(&self, _state: &mut S, event: Event<'_>) -> Result<()> {
87 let Event::Decision { request, decision } = event else {
91 return Ok(());
92 };
93 let Some(prompt) = self.prompts.get(decision.selected_model()) else {
94 return Ok(());
95 };
96 request.llm_request.instructions.insert(
99 0,
100 InstructionBlock {
101 role: Role::System,
102 content: vec![ContentBlock::Text {
103 text: prompt.to_string(),
104 }],
105 },
106 );
107 Ok(())
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::text::text_request;
115 use switchyard_protocol::{LlmRequest, ToolResult};
116
117 const NOTE: &str = "recovering from an error";
118 const STRONG_PROMPT: &str = "diagnose before you edit";
119 const WEAK_PROMPT: &str = "follow the settled plan";
120
121 fn request_with(messages: Vec<Message>) -> Request {
122 Request {
123 llm_request: LlmRequest {
124 messages,
125 ..LlmRequest::default()
126 },
127 raw_request: None,
128 metadata: None,
129 }
130 }
131
132 #[test]
133 fn a_note_joins_a_trailing_user_turn_after_its_tool_result() {
134 let tool_result = ContentBlock::ToolResult(ToolResult {
137 tool_call_id: "call_1".to_string(),
138 content: vec![ContentBlock::Text {
139 text: "exit 1".to_string(),
140 }],
141 is_error: Some(true),
142 });
143 let mut request = request_with(vec![Message {
144 role: Role::User,
145 content: vec![tool_result.clone()],
146 }]);
147
148 append_note(&mut request, NOTE);
149
150 let messages = &request.llm_request.messages;
151 assert_eq!(messages.len(), 1, "no second consecutive user turn");
152 assert_eq!(
153 messages[0].content,
154 vec![
155 tool_result,
156 ContentBlock::Text {
157 text: NOTE.to_string()
158 }
159 ]
160 );
161 }
162
163 #[test]
164 fn a_note_opens_a_user_turn_after_an_assistant_turn() {
165 let mut request = request_with(vec![Message::text(Role::Assistant, "done")]);
166
167 append_note(&mut request, NOTE);
168
169 let messages = &request.llm_request.messages;
170 assert_eq!(messages.len(), 2);
171 assert_eq!(messages[1].role, Role::User);
172 assert_eq!(messages[1].text_content(""), Some(NOTE.to_string()));
173 }
174
175 #[test]
176 fn a_note_leaves_the_rest_of_the_conversation_untouched() {
177 let mut request = Request {
178 llm_request: text_request(Some("auto".to_string()), "fix the build"),
179 raw_request: None,
180 metadata: None,
181 };
182
183 append_note(&mut request, NOTE);
184
185 let trail: Vec<String> = request
186 .llm_request
187 .messages
188 .iter()
189 .filter_map(|message| message.text_content("|"))
190 .collect();
191 assert_eq!(trail, vec![format!("fix the build|{NOTE}")]);
192 }
193
194 struct RoutedTo(&'static str);
196 impl switchyard_protocol::Decision for RoutedTo {
197 fn selected_model(&self) -> &str {
198 self.0
199 }
200 fn reasoning(&self) -> Option<&str> {
201 None
202 }
203 fn as_any(&self) -> &dyn std::any::Any {
204 self
205 }
206 }
207
208 fn instructions(request: &Request) -> Vec<String> {
210 request
211 .llm_request
212 .instructions
213 .iter()
214 .filter_map(|block| {
215 block.content.iter().find_map(|content| match content {
216 ContentBlock::Text { text } => Some(text.clone()),
217 _ => None,
218 })
219 })
220 .collect()
221 }
222
223 async fn run(processor: &SystemPromptProcessor, target: &'static str) -> Result<Request> {
225 let mut request = Request::default();
226 processor
227 .process(
228 &mut (),
229 Event::Decision {
230 request: &mut request,
231 decision: &RoutedTo(target),
232 },
233 )
234 .await?;
235 Ok(request)
236 }
237
238 fn prompts() -> TargetPrompts {
239 TargetPrompts::default()
240 .with("strong", STRONG_PROMPT)
241 .with("weak", WEAK_PROMPT)
242 }
243
244 #[tokio::test]
245 async fn each_target_gets_its_own_prompt() -> Result<()> {
246 let processor = SystemPromptProcessor::new(prompts());
247 for (target, expected) in [("strong", STRONG_PROMPT), ("weak", WEAK_PROMPT)] {
248 assert_eq!(
249 instructions(&run(&processor, target).await?),
250 vec![expected]
251 );
252 }
253 Ok(())
254 }
255
256 #[tokio::test]
257 async fn an_unconfigured_target_is_left_untouched() -> Result<()> {
258 let processor =
260 SystemPromptProcessor::new(TargetPrompts::default().with("strong", STRONG_PROMPT));
261 assert_eq!(
262 instructions(&run(&processor, "strong").await?),
263 vec![STRONG_PROMPT]
264 );
265 assert!(instructions(&run(&processor, "weak").await?).is_empty());
266 Ok(())
267 }
268
269 #[tokio::test]
270 async fn the_prompt_leads_the_client_instructions() -> Result<()> {
271 let processor = SystemPromptProcessor::new(prompts());
272 let mut request = Request::default();
273 request.llm_request.instructions.push(InstructionBlock {
274 role: Role::System,
275 content: vec![ContentBlock::Text {
276 text: "you are a coding agent".to_string(),
277 }],
278 });
279
280 processor
281 .process(
282 &mut (),
283 Event::Decision {
284 request: &mut request,
285 decision: &RoutedTo("strong"),
286 },
287 )
288 .await?;
289
290 assert_eq!(
291 instructions(&request),
292 vec![STRONG_PROMPT, "you are a coding agent"]
293 );
294 Ok(())
295 }
296
297 #[tokio::test]
298 async fn the_inbound_request_is_left_alone() -> Result<()> {
299 let processor = SystemPromptProcessor::new(prompts());
301 let mut request = Request::default();
302 processor
303 .process(&mut (), Event::Request(&mut request))
304 .await?;
305 assert!(instructions(&request).is_empty());
306 Ok(())
307 }
308}