1use alloc::{borrow::ToOwned, string::String};
8
9use crate::types::ToolOutput;
10
11pub const TOOL_OUTPUT_QUARANTINE_TAG: &str = "tool_output_quarantine";
13
14pub const TOOL_OUTPUT_QUARANTINE_OPEN: &str = "<tool_output_quarantine>";
16
17pub const TOOL_OUTPUT_QUARANTINE_CLOSE: &str = "</tool_output_quarantine>";
19
20const OPEN_FRAME: &str = "<tool_output_quarantine>\n";
22
23const CLOSE_FRAME: &str = "\n</tool_output_quarantine>";
25
26pub const DELIMITER_RULES: &[(&str, &str)] = &[
28 ("<|im_start|>", "<|im_start|>"),
29 ("<|im_end|>", "<|im_end|>"),
30 ("<|endoftext|>", "<|endoftext|>"),
31 ("<|start_header_id|>", "<|start_header_id|>"),
32 ("<|end_header_id|>", "<|end_header_id|>"),
33 ("<|eot_id|>", "<|eot_id|>"),
34 ("<tool_call>", "<tool_call>"),
35 ("</tool_call>", "</tool_call>"),
36 ("<tool_response>", "<tool_response>"),
37 ("</tool_response>", "</tool_response>"),
38 ("[INST]", "[INST]"),
39 ("[/INST]", "[/INST]"),
40 ("<<SYS>>", "<<SYS>>"),
41 ("<</SYS>>", "<</SYS>>"),
42 ("<start_of_turn>", "<start_of_turn>"),
43 ("<end_of_turn>", "<end_of_turn>"),
44 ("<think>", "<think>"),
45 ("</think>", "</think>"),
46 ("<untrusted_tool_output>", "<untrusted_tool_output>"),
47 ("</untrusted_tool_output>", "</untrusted_tool_output>"),
48 ("<untrusted_content>", "<untrusted_content>"),
49 ("</untrusted_content>", "</untrusted_content>"),
50 ("<tool_output_quarantine>", "<tool_output_quarantine>"),
51 (
52 "</tool_output_quarantine>",
53 "</tool_output_quarantine>",
54 ),
55 ("<|begin▁of▁sentence|>", "<|begin▁of▁sentence|>"),
56 ("<|end▁of▁sentence|>", "<|end▁of▁sentence|>"),
57 ("<|User|>", "<|User|>"),
58 ("<|Assistant|>", "<|Assistant|>"),
59 ("<|tool▁calls▁begin|>", "<|tool▁calls▁begin|>"),
60 ("<|user|>", "<|user|>"),
61 ("<|assistant|>", "<|assistant|>"),
62 ("<|system|>", "<|system|>"),
63 ("<|end|>", "<|end|>"),
64 ("<|START_OF_TURN_TOKEN|>", "<|START_OF_TURN_TOKEN|>"),
65 ("<|END_OF_TURN_TOKEN|>", "<|END_OF_TURN_TOKEN|>"),
66 ("[TOOL_CALLS]", "[TOOL_CALLS]"),
67 ("[AVAILABLE_TOOLS]", "[AVAILABLE_TOOLS]"),
68 ("[/TOOL_CALLS]", "[/TOOL_CALLS]"),
69 ("[/AVAILABLE_TOOLS]", "[/AVAILABLE_TOOLS]"),
70 ("\n\nHuman:", "\n\nHuman:"),
71 ("\n\nAssistant:", "\n\nAssistant:"),
72 ("<role>", "<role>"),
73 ("</role>", "</role>"),
74 ("<|role_end|>", "<|role_end|>"),
75 ("<|channel|>", "<|channel|>"),
76 ("<|message|>", "<|message|>"),
77];
78
79const fn is_ncname_continue_byte(b: u8) -> bool {
80 b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'
81}
82
83fn match_tag_prefix(bytes: &[u8], i: usize, tag_bytes: &[u8]) -> Option<usize> {
87 let mut pos = i + 1;
88 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
89 pos += 1;
90 }
91 if pos < bytes.len() && bytes[pos] == b'/' {
92 pos += 1;
93 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
94 pos += 1;
95 }
96 }
97 let after_tag = pos.checked_add(tag_bytes.len())?;
98 if after_tag <= bytes.len()
99 && bytes[pos..after_tag].eq_ignore_ascii_case(tag_bytes)
100 && !(after_tag < bytes.len() && is_ncname_continue_byte(bytes[after_tag]))
101 {
102 Some(after_tag)
103 } else {
104 None
105 }
106}
107
108fn has_quarantine_tag_breakout(s: &str) -> bool {
112 let bytes = s.as_bytes();
113 let tag_bytes = TOOL_OUTPUT_QUARANTINE_TAG.as_bytes();
114 for i in 0..bytes.len() {
115 if bytes[i] == b'<' && match_tag_prefix(bytes, i, tag_bytes).is_some() {
116 return true;
117 }
118 }
119 false
120}
121
122fn sanitize_quarantine_tag_breakouts(s: &str) -> String {
124 let bytes = s.as_bytes();
125 let tag_bytes = TOOL_OUTPUT_QUARANTINE_TAG.as_bytes();
126 let mut out = String::with_capacity(s.len());
127 let mut i = 0;
128 while i < bytes.len() {
129 if bytes[i] == b'<'
130 && let Some(after_tag) = match_tag_prefix(bytes, i, tag_bytes)
131 {
132 let mut j = after_tag;
133 while j < bytes.len() && bytes[j] != b'>' && bytes[j] != b'<' {
134 j += 1;
135 }
136 if j < bytes.len() && bytes[j] == b'>' {
137 out.push_str("<");
138 out.push_str(&s[i + 1..j]);
139 out.push_str(">");
140 i = j + 1;
141 continue;
142 }
143 out.push_str("<");
144 i += 1;
145 continue;
146 }
147 let ch = s[i..].chars().next().expect("valid utf-8 character");
148 out.push(ch);
149 i += ch.len_utf8();
150 }
151 out
152}
153
154#[must_use]
156pub fn neutralize_delimiters(raw: &str) -> String {
157 let mut out = raw.to_owned();
158 for &(token, replacement) in DELIMITER_RULES {
159 if out.contains(token) {
160 out = out.replace(token, replacement);
161 }
162 }
163 if has_quarantine_tag_breakout(&out) {
164 out = sanitize_quarantine_tag_breakouts(&out);
165 }
166 out
167}
168
169#[must_use]
178pub fn is_quarantined_output(raw: &str) -> bool {
179 let Some(rest) = raw.strip_prefix(OPEN_FRAME) else {
180 return false;
181 };
182 let Some(inner) = rest.strip_suffix(CLOSE_FRAME) else {
183 return false;
184 };
185 if DELIMITER_RULES
186 .iter()
187 .any(|&(token, _)| inner.contains(token))
188 {
189 return false;
190 }
191 !has_quarantine_tag_breakout(inner)
192}
193
194#[must_use]
197pub fn unquarantine_tool_output(raw: &str) -> Option<&str> {
198 if !is_quarantined_output(raw) {
199 return None;
200 }
201 raw.strip_prefix(OPEN_FRAME)
202 .and_then(|rest| rest.strip_suffix(CLOSE_FRAME))
203}
204
205#[must_use]
211pub fn quarantine_tool_output(raw: &str) -> String {
212 if is_quarantined_output(raw) {
213 return raw.to_owned();
214 }
215 let sanitized = neutralize_delimiters(raw);
216 let mut buf = String::with_capacity(OPEN_FRAME.len() + sanitized.len() + CLOSE_FRAME.len());
217 buf.push_str(OPEN_FRAME);
218 buf.push_str(&sanitized);
219 buf.push_str(CLOSE_FRAME);
220 buf
221}
222
223impl ToolOutput {
224 #[must_use]
229 pub fn quarantined(mut self) -> Self {
230 self.content = quarantine_tool_output(&self.content);
231 self
232 }
233
234 #[must_use]
237 pub fn is_quarantined(&self) -> bool {
238 is_quarantined_output(&self.content)
239 }
240}