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//! These helpers mutate the normalized request. A codec asked to encode for the format the
7//! request arrived in replays the body captured at decode instead of reading that
8//! request — so an addition that leaves exact replay in place never reaches the
9//! model. This is not enforced: a future processor that mutates the request and
10//! forgets the call reintroduces SWITCH-1224, silently and without a failing
11//! test.
12
13use switchyard_protocol::{ContentBlock, InstructionBlock, Message, Request, Role};
14
15/// Appends `note` to the request as conversation text.
16///
17/// It joins the trailing user message when there is one, rather than opening a
18/// turn of its own: Anthropic rejects two consecutive user messages, and a
19/// `tool_result` must stay first within its message. A text block after it
20/// satisfies both and keeps the addition a cache-safe suffix. Any other trailing
21/// role — an empty conversation, or one ending on an assistant turn — takes a
22/// fresh user message.
23pub fn append_note(request: &mut Request, note: &str) {
24    match request.llm_request.messages.last_mut() {
25        Some(last) if last.role == Role::User => last.content.push(ContentBlock::Text {
26            text: note.to_string(),
27        }),
28        _ => request
29            .llm_request
30            .messages
31            .push(Message::text(Role::User, note)),
32    }
33    drop_exact_replay(request);
34}
35
36/// Gives up exact same-format replay for this turn.
37///
38/// A codec replays the preserved inbound body verbatim when the target format
39/// matches the source, which is what keeps a same-format hop lossless. That body
40/// predates anything added here, so leaving it in place would encode the request
41/// as it arrived and silently drop the addition. Dropping it sends the codec down
42/// its normal path, which encodes from the request itself.
43///
44/// Every stored body goes, not just the one for the inbound format: preservation
45/// also carries bodies embedded by earlier hops, and the addition is missing from
46/// all of them equally.
47///
48/// Call this from any new code that mutates the request. Nothing checks that you
49/// have.
50pub(crate) fn drop_exact_replay(request: &mut Request) {
51    request.llm_request.preservation.requests.clear();
52}
53
54/// Prepends a system prompt and disables exact replay so the edit reaches the provider.
55pub(crate) fn prepend_system_prompt(request: &mut Request, prompt: &str) {
56    request.llm_request.instructions.insert(
57        0,
58        InstructionBlock {
59            role: Role::System,
60            content: vec![ContentBlock::Text {
61                text: prompt.to_string(),
62            }],
63        },
64    );
65    drop_exact_replay(request);
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use switchyard_protocol::{LlmRequest, ToolResult, text_request};
72
73    const NOTE: &str = "recovering from an error";
74
75    /// Every test request carries the exact inbound body a codec keeps for
76    /// same-format replay, so each assertion below also says what happens to it.
77    fn request_with(messages: Vec<Message>) -> Request {
78        Request {
79            llm_request: LlmRequest {
80                messages,
81                preservation: preserved_body(),
82                ..LlmRequest::default()
83            },
84            raw_request: None,
85            metadata: None,
86        }
87    }
88
89    fn preserved_body() -> switchyard_protocol::PreservationMetadata {
90        let mut preservation = switchyard_protocol::PreservationMetadata::default();
91        preservation.requests.insert(
92            "openai_chat".into(),
93            serde_json::json!({"model": "weak", "messages": [{"role": "user", "content": "hi"}]}),
94        );
95        preservation
96    }
97
98    fn replays_exactly(request: &Request) -> bool {
99        !request.llm_request.preservation.requests.is_empty()
100    }
101
102    #[test]
103    fn a_note_joins_a_trailing_user_turn_after_its_tool_result() {
104        // The shape a coding-agent turn actually arrives in: the tool result
105        // leads the trailing user message, so the note has to follow it.
106        let tool_result = ContentBlock::ToolResult(ToolResult {
107            tool_call_id: "call_1".to_string(),
108            content: vec![ContentBlock::Text {
109                text: "exit 1".to_string(),
110            }],
111            is_error: Some(true),
112        });
113        let mut request = request_with(vec![Message {
114            role: Role::User,
115            content: vec![tool_result.clone()],
116        }]);
117
118        append_note(&mut request, NOTE);
119
120        let messages = &request.llm_request.messages;
121        assert_eq!(messages.len(), 1, "no second consecutive user turn");
122        assert_eq!(
123            messages[0].content,
124            vec![
125                tool_result,
126                ContentBlock::Text {
127                    text: NOTE.to_string()
128                }
129            ]
130        );
131    }
132
133    #[test]
134    fn a_note_opens_a_user_turn_after_an_assistant_turn() {
135        let mut request = request_with(vec![Message::text(Role::Assistant, "done")]);
136
137        append_note(&mut request, NOTE);
138
139        let messages = &request.llm_request.messages;
140        assert_eq!(messages.len(), 2);
141        assert_eq!(messages[1].role, Role::User);
142        assert_eq!(messages[1].text_content(""), Some(NOTE.to_string()));
143    }
144
145    #[test]
146    fn a_note_leaves_the_rest_of_the_conversation_untouched() {
147        let mut request = Request {
148            llm_request: LlmRequest {
149                preservation: preserved_body(),
150                ..text_request(Some("auto".to_string()), "fix the build")
151            },
152            raw_request: None,
153            metadata: None,
154        };
155
156        append_note(&mut request, NOTE);
157
158        let trail: Vec<String> = request
159            .llm_request
160            .messages
161            .iter()
162            .filter_map(|message| message.text_content("|"))
163            .collect();
164        assert_eq!(trail, vec![format!("fix the build|{NOTE}")]);
165        assert!(
166            !replays_exactly(&request),
167            "a same-format hop would replay the body captured before the note"
168        );
169    }
170}