vtcode_core/core/agent/
refusal.rs1use serde_json::Value;
11
12use crate::llm::provider::{FinishReason, LLMResponse};
13
14pub const REFUSAL_NOTICE_PREFIX: &str = "The model declined this request";
18
19const MAX_EXPLANATION_CHARS: usize = 400;
21
22const NEXT_STEP_SENTENCE: &str = "The request was not retried; rephrase it or switch models.";
23
24pub fn is_refusal(response: &LLMResponse) -> bool {
26 matches!(response.finish_reason, FinishReason::Refusal)
27}
28
29pub fn is_refusal_notice(text: &str) -> bool {
31 text.trim_start().starts_with(REFUSAL_NOTICE_PREFIX)
32}
33
34pub fn refusal_reason(response: &LLMResponse) -> String {
44 let details: Vec<Value> = response
45 .reasoning_details
46 .as_deref()
47 .unwrap_or_default()
48 .iter()
49 .filter_map(|detail| serde_json::from_str::<Value>(detail).ok())
50 .collect();
51 let stop_details = details.iter().find(|detail| detail_type(detail) == Some("stop_details"));
52 let stop_field = |name: &str| {
53 stop_details
54 .and_then(|detail| detail.get(name))
55 .and_then(Value::as_str)
56 .map(str::trim)
57 .filter(|value| !value.is_empty())
58 };
59
60 let mut reason = match stop_field("category") {
61 Some(category) => format!("{REFUSAL_NOTICE_PREFIX} (category: {category})"),
62 None => REFUSAL_NOTICE_PREFIX.to_string(),
63 };
64
65 let explanation = stop_field("explanation").map(str::to_string).or_else(|| {
66 response
67 .content
68 .as_deref()
69 .map(str::trim)
70 .filter(|content| !content.is_empty())
71 .map(bounded_explanation)
72 });
73 match explanation {
74 Some(explanation) => {
75 reason.push_str(": ");
76 reason.push_str(&vtcode_commons::formatting::lowercase_leading_word(&explanation));
77 if !ends_with_terminal_punctuation(&reason) {
78 reason.push('.');
79 }
80 }
81 None => reason.push('.'),
82 }
83
84 if let Some(sentence) = fallback_sentence(response, &details, stop_field("recommended_model")) {
85 reason.push(' ');
86 reason.push_str(&sentence);
87 }
88 reason.push(' ');
89 reason.push_str(NEXT_STEP_SENTENCE);
90 reason
91}
92
93fn detail_type(detail: &Value) -> Option<&str> {
94 detail.get("type").and_then(Value::as_str)
95}
96
97fn fallback_sentence(response: &LLMResponse, details: &[Value], recommended_model: Option<&str>) -> Option<String> {
100 let fallback_served = details.iter().any(|detail| detail_type(detail) == Some("fallback"))
101 || response
102 .usage
103 .as_ref()
104 .and_then(|usage| usage.iterations.as_deref())
105 .is_some_and(|iterations| {
106 iterations
107 .iter()
108 .any(|iteration| detail_type(iteration) == Some("fallback_message"))
109 });
110 if fallback_served {
111 return Some("The configured fallback model also declined.".to_string());
112 }
113 recommended_model.map(|model| format!("A fallback to `{model}` was recommended but could not run."))
114}
115
116fn bounded_explanation(content: &str) -> String {
117 let collapsed = content.split_whitespace().collect::<Vec<_>>().join(" ");
120 vtcode_commons::formatting::truncate_within(&collapsed, MAX_EXPLANATION_CHARS, "...")
121}
122
123fn ends_with_terminal_punctuation(text: &str) -> bool {
124 text.trim_end().chars().last().is_some_and(|c| matches!(c, '.' | '!' | '?')) || text.trim_end().ends_with("...")
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::llm::provider::Usage;
131 use serde_json::json;
132
133 fn refusal(content: Option<&str>, details: Vec<Value>) -> LLMResponse {
134 LLMResponse {
135 content: content.map(str::to_string),
136 finish_reason: FinishReason::Refusal,
137 reasoning_details: (!details.is_empty())
138 .then(|| details.into_iter().map(|detail| detail.to_string()).collect()),
139 ..LLMResponse::default()
140 }
141 }
142
143 #[test]
144 fn reports_category_and_explanation_without_fallback_claim() {
145 let response = refusal(
146 None,
147 vec![
148 json!({"type": "stop_details", "category": "cyber", "explanation": "This looks like malware development."}),
149 ],
150 );
151 let reason = refusal_reason(&response);
152 assert_eq!(
153 reason,
154 "The model declined this request (category: cyber): this looks like malware development. \
155 The request was not retried; rephrase it or switch models."
156 );
157 assert!(!reason.contains(".."));
158 assert!(!reason.contains("fallback"));
159 assert!(is_refusal_notice(&reason));
160 }
161
162 #[test]
163 fn mentions_fallback_only_when_a_fallback_model_ran() {
164 let mut response = refusal(None, vec![json!({"type": "stop_details", "category": "cyber"})]);
165 response.usage = Some(Usage {
166 iterations: Some(vec![
167 json!({"type": "message", "input_tokens": 10, "output_tokens": 0}),
168 json!({"type": "fallback_message", "input_tokens": 10, "output_tokens": 0}),
169 ]),
170 ..Usage::default()
171 });
172 assert_eq!(
173 refusal_reason(&response),
174 "The model declined this request (category: cyber). The configured fallback model also declined. \
175 The request was not retried; rephrase it or switch models."
176 );
177
178 let served = refusal(
179 None,
180 vec![
181 json!({"type": "fallback", "from": {"model": "a"}, "to": {"model": "b"}}),
182 json!({"type": "stop_details", "category": "bio"}),
183 ],
184 );
185 assert!(refusal_reason(&served).contains("The configured fallback model also declined."));
186 }
187
188 #[test]
189 fn reports_recommended_model_that_could_not_run() {
190 let response = refusal(
191 None,
192 vec![json!({"type": "stop_details", "category": "cyber", "recommended_model": "claude-opus-4-8"})],
193 );
194 assert_eq!(
195 refusal_reason(&response),
196 "The model declined this request (category: cyber). A fallback to `claude-opus-4-8` was recommended \
197 but could not run. The request was not retried; rephrase it or switch models."
198 );
199 }
200
201 #[test]
202 fn uses_bounded_content_when_stop_details_are_absent() {
203 let response = refusal(Some(" I can't help with that.\n\nPlease ask something else. "), Vec::new());
204 assert_eq!(
205 refusal_reason(&response),
206 "The model declined this request: I can't help with that. Please ask something else. \
207 The request was not retried; rephrase it or switch models."
208 );
209
210 let long = "Sorry ".repeat(200);
211 let bounded = refusal_reason(&refusal(Some(&long), Vec::new()));
212 assert!(bounded.contains("sorry Sorry"));
213 assert!(bounded.contains("..."));
214 assert!(!bounded.contains("...."));
215 assert!(bounded.chars().count() < MAX_EXPLANATION_CHARS + 150);
216 }
217
218 #[test]
219 fn stop_details_explanation_takes_precedence_over_content() {
220 let response = refusal(
221 Some("partial answer that was cut off"),
222 vec![json!({"type": "stop_details", "category": "cyber", "explanation": "Policy violation"})],
223 );
224 let reason = refusal_reason(&response);
225 assert!(reason.contains(": policy violation."));
226 assert!(!reason.contains("partial answer"));
227 }
228
229 #[test]
230 fn plain_refusal_has_no_category_or_fallback() {
231 assert_eq!(
232 refusal_reason(&refusal(None, Vec::new())),
233 "The model declined this request. The request was not retried; rephrase it or switch models."
234 );
235 assert!(is_refusal(&refusal(None, Vec::new())));
236 assert!(!is_refusal(&LLMResponse::default()));
237 }
238
239 #[test]
240 fn keeps_case_of_acronyms_after_colon() {
241 let response = refusal(Some("CSAM content is not allowed"), Vec::new());
242 assert!(refusal_reason(&response).contains(": CSAM content is not allowed."));
243 }
244}