Skip to main content

vtcode_core/core/agent/
refusal.rs

1//! Provider refusal handling shared by the interactive turn loop and the
2//! headless runner.
3//!
4//! A refusal (`FinishReason::Refusal`) is terminal for the prompt that caused
5//! it: resending the same prompt is refused again, and any partial output was
6//! cut off by the provider. Both loops stop on a refusal and report the notice
7//! built here, so the wording and the facts it states stay identical across
8//! the TUI, `exec` JSON output, and subagent results.
9
10use serde_json::Value;
11
12use crate::llm::provider::{FinishReason, LLMResponse};
13
14/// Leading text of every refusal notice. Gates that must never treat a
15/// refusal as a recoverable block (auto-continue, recovery turns) match it via
16/// [`is_refusal_notice`].
17pub const REFUSAL_NOTICE_PREFIX: &str = "The model declined this request";
18
19/// Upper bound, in characters, on explanation text copied from the response.
20const MAX_EXPLANATION_CHARS: usize = 400;
21
22const NEXT_STEP_SENTENCE: &str = "The request was not retried; rephrase it or switch models.";
23
24/// Whether `response` ended with a provider refusal.
25pub fn is_refusal(response: &LLMResponse) -> bool {
26    matches!(response.finish_reason, FinishReason::Refusal)
27}
28
29/// Whether `text` is a notice produced by [`refusal_reason`].
30pub fn is_refusal_notice(text: &str) -> bool {
31    text.trim_start().starts_with(REFUSAL_NOTICE_PREFIX)
32}
33
34/// User-facing explanation for a provider refusal.
35///
36/// Anthropic reports the refusal category and explanation in a
37/// `stop_details` reasoning detail, and reports server-side fallback activity
38/// as a `fallback` reasoning detail, `fallback_message` usage iterations, or a
39/// `recommended_model` when the fallback could not run. Other providers only
40/// report the finish reason, sometimes with a refusal message in the content;
41/// that content (trimmed and bounded) is the explanation when no
42/// `stop_details` explanation exists.
43pub 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
97/// States fallback activity only when the response shows fallbacks were
98/// carried or attempted; a refusal without fallback signals says nothing.
99fn 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    // Collapse whitespace so a multi-paragraph refusal message reads as one
118    // sentence inside the notice.
119    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}