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//! Router guidance uses the shared request edits so preserved provider content stays intact.
5
6use switchyard_protocol::Request;
7
8/// This helper appends temporary guidance and updates preserved provider bodies.
9pub fn append_note(request: &mut Request, note: &str) {
10    switchyard_translation::append_note(&mut request.llm_request, note);
11}
12
13/// This helper clears preserved bodies and the note count after other conversation edits.
14/// Subsequent encoding uses the edited request instead of replaying stale provider bodies.
15pub(crate) fn drop_exact_replay(request: &mut Request) {
16    request.llm_request.preservation.requests.clear();
17    request.llm_request.temporary_note_count = 0;
18}
19
20#[cfg(test)]
21mod tests {
22    use super::*;
23    use serde_json::json;
24    use switchyard_protocol::WireFormat;
25    use switchyard_translation::{TranslationEngine, TranslationPolicy};
26
27    // Native bodies retain provider fields and tool argument text that decoding may not preserve.
28    #[test]
29    fn edits_preserve_native_prefixes_and_provider_fields() -> Result<(), Box<dyn std::error::Error>>
30    {
31        let engine = TranslationEngine::default();
32        let policy = TranslationPolicy::default();
33        let fixtures = [
34            (
35                WireFormat::OpenAiChat,
36                json!({
37                    "model":"auto", "messages":[
38                        {"role":"developer","content":[{"type":"text","text":"Keep this instruction.","vendor":"keep"}]},
39                        {"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"run","arguments":"{ \"cmd\": \"test\" }"}}]},
40                        {"role":"tool","tool_call_id":"call_1","content":"exit 1","vendor":"keep"}
41                    ], "metadata":{"caller":"keep"}, "temperature":0.2
42                }),
43            ),
44            (
45                WireFormat::OpenAiResponses,
46                json!({
47                    "model":"auto", "instructions":"Keep this instruction.", "input":[
48                        {"type":"message","role":"developer","content":[{"type":"input_text","text":"Keep this too."}]},
49                        {"type":"function_call","call_id":"call_1","name":"run","arguments":"{ \"cmd\": \"test\" }"},
50                        {"type":"function_call_output","call_id":"call_1","output":"exit 1"}
51                    ], "metadata":{"caller":"keep"}, "store":false
52                }),
53            ),
54            (
55                WireFormat::AnthropicMessages,
56                json!({
57                    "model":"auto", "max_tokens":32,
58                    "system":[{"type":"text","text":"Keep this instruction.","cache_control":{"type":"ephemeral","ttl":"1h"}}],
59                    "messages":[
60                        {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"run","input":{"cmd":"test"}}]},
61                        {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"exit 1","is_error":true,"cache_control":{"type":"ephemeral"}}]}
62                    ], "metadata":{"user_id":"keep"}
63                }),
64            ),
65        ];
66        for (format, original) in fixtures {
67            let mut request = Request {
68                llm_request: engine.decode_request(format, &original, &policy)?.request,
69                ..Default::default()
70            };
71            append_note(&mut request, "Reassess the failure.");
72            let encoded = engine
73                .encode_request(format, &request.llm_request, &policy)?
74                .body;
75            let mut expected = original.clone();
76            match format {
77                WireFormat::OpenAiChat => expected["messages"].as_array_mut().ok_or("messages")?.push(json!({"role":"user","content":[{"type":"text","text":"Reassess the failure."}]})),
78                WireFormat::OpenAiResponses => expected["input"].as_array_mut().ok_or("input")?.push(json!({"role":"user","content":[{"type":"input_text","text":"Reassess the failure."}]})),
79                WireFormat::AnthropicMessages => expected["messages"][1]["content"].as_array_mut().ok_or("content")?.push(json!({"type":"text","text":"Reassess the failure."})),
80            }
81            assert_eq!(encoded, expected, "{format}: only the note may change");
82            switchyard_translation::prepend_system_prompt(
83                &mut request.llm_request,
84                "Use the capable tier.",
85            );
86            let edited = engine
87                .encode_request(format, &request.llm_request, &policy)?
88                .body;
89            // The system prompt edit must retain the earlier note and all conversation and tool items.
90            let key = if format == WireFormat::OpenAiResponses {
91                "input"
92            } else {
93                "messages"
94            };
95            if format == WireFormat::OpenAiChat {
96                assert_eq!(
97                    edited[key].as_array().ok_or("messages")?[1..],
98                    encoded[key].as_array().ok_or("messages")?[..]
99                );
100            } else {
101                assert_eq!(edited[key], encoded[key]);
102            }
103            assert_eq!(edited["metadata"], original["metadata"]);
104        }
105        Ok(())
106    }
107
108    #[test]
109    fn string_inputs_and_assistant_endings_keep_the_original_message()
110    -> Result<(), Box<dyn std::error::Error>> {
111        let engine = TranslationEngine::default();
112        let policy = TranslationPolicy::default();
113        for (format, body, key) in [
114            (
115                WireFormat::OpenAiChat,
116                json!({"model":"m","messages":[{"role":"assistant","content":"done"}]}),
117                "messages",
118            ),
119            (
120                WireFormat::AnthropicMessages,
121                json!({"model":"m","max_tokens":8,"messages":[{"role":"user","content":"question"},{"role":"assistant","content":"done"}]}),
122                "messages",
123            ),
124            (
125                WireFormat::OpenAiResponses,
126                json!({"model":"m","input":"question"}),
127                "input",
128            ),
129        ] {
130            let mut request = Request {
131                llm_request: engine.decode_request(format, &body, &policy)?.request,
132                ..Default::default()
133            };
134            append_note(&mut request, "note one");
135            append_note(&mut request, "note two");
136            let encoded = engine
137                .encode_request(format, &request.llm_request, &policy)?
138                .body;
139            let items = encoded[key].as_array().ok_or("items")?;
140            if body[key].is_array() {
141                let original = body[key].as_array().ok_or("original")?;
142                assert_eq!(&items[..original.len()], original);
143            } else {
144                assert_eq!(items[0], json!({"role":"user","content":"question"}));
145            }
146        }
147        Ok(())
148    }
149    // Unsupported preserved bodies must not cause encoding to omit the note.
150    #[test]
151    fn invalid_preservation_falls_back_without_losing_the_note()
152    -> Result<(), Box<dyn std::error::Error>> {
153        let engine = TranslationEngine::default();
154        let policy = TranslationPolicy::default();
155        let source = json!({"model":"m","messages":[{"role":"user","content":"original task"}]});
156        let mut request = Request {
157            llm_request: engine
158                .decode_request(WireFormat::OpenAiChat, &source, &policy)?
159                .request,
160            ..Default::default()
161        };
162        request
163            .llm_request
164            .preservation
165            .requests
166            .insert("custom".into(), json!({"messages":"unsupported"}));
167        request.llm_request.preservation.requests.insert(
168            WireFormat::OpenAiChat.into(),
169            json!({"model":"m","messages":"unsupported"}),
170        );
171        append_note(&mut request, "reassess the failure");
172        assert!(request.llm_request.preservation.requests.is_empty());
173        for target in [
174            WireFormat::OpenAiChat,
175            WireFormat::OpenAiResponses,
176            WireFormat::AnthropicMessages,
177        ] {
178            let body = engine
179                .encode_request(target, &request.llm_request, &policy)?
180                .body;
181            let decoded = engine.decode_request(target, &body, &policy)?.request;
182            let text: Vec<_> = decoded
183                .messages
184                .iter()
185                .filter_map(|message| message.text_content(""))
186                .collect();
187            assert_eq!(
188                text,
189                vec!["original task", "reassess the failure"],
190                "{target}"
191            );
192        }
193        Ok(())
194    }
195}