Skip to main content

forge_agent/translate/
error_handling.rs

1pub use forge_primitives::TurnErrorClass;
2
3pub fn classify_turn_error(input: &str) -> TurnErrorClass {
4    let lower = input.to_ascii_lowercase();
5    if looks_like_plan_limit_error_lower(&lower) {
6        TurnErrorClass::PlanLimit
7    } else if looks_like_auth_required_error_lower(&lower) {
8        TurnErrorClass::AuthRequired
9    } else if looks_like_internal_error_lower(&lower) {
10        TurnErrorClass::Internal
11    } else {
12        TurnErrorClass::Other
13    }
14}
15
16pub fn looks_like_internal_error(input: &str) -> bool {
17    looks_like_internal_error_lower(&input.to_ascii_lowercase())
18}
19
20pub fn summarize_internal_error(input: &str) -> String {
21    if let Some(summary) = summarize_permission_schema_error(input) {
22        return truncate_for_log(&summary);
23    }
24    if let Some(msg) = extract_xml_tag_value(input, "message") {
25        return truncate_for_log(msg);
26    }
27    if let Some(msg) = extract_json_string_field(input, "message") {
28        return truncate_for_log(&msg);
29    }
30    // #143 item 1: extended fallback chain. The wrapped error
31    // payloads sometimes carry their useful text under a different
32    // field name. The previous chain (`<message>` -> `"message"` ->
33    // first-non-blank line) returned `""` when the wire shape used
34    // any of the variants below.
35    //
36    // - `assistant_error` - wire-shape from the agent SDK adapter for
37    //   errors that surface inside an assistant turn body.
38    // - `detail` / `description` - common JSON shapes for error
39    //   responses that don't follow the `"message"` convention
40    //   (Anthropic's "rate_limit_error" body uses `message` so it
41    //   hits the prior branch; other endpoints differ).
42    // - `error.message` / `error.type` - nested error objects.
43    // - `body` - the truncated HTTP body suffix oauth_usage and
44    //   similar loggers stuff verbatim onto wrapped error strings.
45    //
46    // Each extractor returns None when the field isn't present so
47    // the chain falls through to the original first-non-blank-line
48    // fallback when no structured signal is recoverable.
49    for field in ["assistant_error", "detail", "description", "type", "body"] {
50        if let Some(msg) = extract_json_string_field(input, field)
51            && !msg.trim().is_empty()
52        {
53            return truncate_for_log(&msg);
54        }
55    }
56    let fallback = input.lines().find(|line| !line.trim().is_empty()).unwrap_or(input);
57    truncate_for_log(fallback.trim())
58}
59
60fn looks_like_plan_limit_error_lower(lower: &str) -> bool {
61    [
62        "rate limit",
63        "rate-limit",
64        "max turns",
65        "max turn",
66        "max budget",
67        "quota",
68        "plan limit",
69        "plan-limit",
70        "429",
71        "too many requests",
72        "usage limit",
73        "insufficient quota",
74    ]
75    .iter()
76    .any(|needle| lower.contains(needle))
77}
78
79pub fn looks_like_auth_required_error_lower(lower: &str) -> bool {
80    let any = [
81        "/login",
82        "auth required",
83        "authentication failed",
84        "authentication_failed",
85        "authentication required",
86        "please log in",
87        "login required",
88        "not authenticated",
89        "unauthenticated",
90        "unauthorized",
91    ]
92    .iter()
93    .any(|needle| lower.contains(needle));
94    // The 401+auth conjunction catches HTTP-shape errors that don't
95    // include any of the literal substrings above (e.g. "request
96    // returned 401 from auth gateway").
97    any || (lower.contains("401") && lower.contains("auth"))
98}
99
100fn looks_like_internal_error_lower(lower: &str) -> bool {
101    has_internal_error_keywords(lower)
102        || looks_like_json_rpc_error_shape(lower)
103        || looks_like_xml_error_shape(lower)
104}
105
106fn has_internal_error_keywords(lower: &str) -> bool {
107    [
108        "internal error",
109        "agent sdk",
110        "claude-agent-sdk",
111        "adapter",
112        "bridge",
113        "json-rpc",
114        "rpc",
115        "protocol error",
116        "transport",
117        "handshake failed",
118        "session creation failed",
119        "connection closed",
120        "event channel closed",
121        "tool permission request failed",
122        "zoderror",
123        "invalid_union",
124        "bridge command failed",
125        "agent stream failed",
126        "agent initialization failed",
127    ]
128    .iter()
129    .any(|needle| lower.contains(needle))
130}
131
132fn looks_like_json_rpc_error_shape(lower: &str) -> bool {
133    (lower.contains("\"jsonrpc\"") && lower.contains("\"error\""))
134        || lower.contains("\"code\":-32603")
135        || lower.contains("\"code\": -32603")
136}
137
138fn looks_like_xml_error_shape(lower: &str) -> bool {
139    let has_error_node = lower.contains("<error") || lower.contains("<fault");
140    let has_detail_node = lower.contains("<message>") || lower.contains("<code>");
141    has_error_node && has_detail_node
142}
143
144fn summarize_permission_schema_error(input: &str) -> Option<String> {
145    let lower = input.to_ascii_lowercase();
146    if !lower.contains("tool permission request failed") {
147        return None;
148    }
149
150    let detail = if let Some(msg) = extract_json_string_field(input, "message") {
151        msg
152    } else {
153        input.lines().find(|line| !line.trim().is_empty()).unwrap_or(input).trim().to_owned()
154    };
155
156    Some(format!("Tool permission request failed: {detail}"))
157}
158
159pub fn truncate_for_log(input: &str) -> String {
160    const LIMIT: usize = 240;
161    let mut out = String::new();
162    for (i, ch) in input.chars().enumerate() {
163        if i >= LIMIT {
164            out.push_str("...");
165            break;
166        }
167        out.push(ch);
168    }
169    out.replace('\n', "\\n")
170}
171
172pub fn extract_xml_tag_value<'a>(input: &'a str, tag: &str) -> Option<&'a str> {
173    let lower = input.to_ascii_lowercase();
174    let open = format!("<{tag}>");
175    let close = format!("</{tag}>");
176    let start = lower.find(&open)? + open.len();
177    let end = start + lower[start..].find(&close)?;
178    let value = input[start..end].trim();
179    (!value.is_empty()).then_some(value)
180}
181
182fn extract_json_string_field(input: &str, field: &str) -> Option<String> {
183    let needle = format!("\"{field}\"");
184    let start = input.find(&needle)? + needle.len();
185    let rest = input[start..].trim_start();
186    let colon_idx = rest.find(':')?;
187    let mut chars = rest[colon_idx + 1..].trim_start().chars();
188    if chars.next()? != '"' {
189        return None;
190    }
191
192    let mut escaped = false;
193    let mut out = String::new();
194    for ch in chars {
195        if escaped {
196            let mapped = match ch {
197                'n' => '\n',
198                'r' => '\r',
199                't' => '\t',
200                '"' => '"',
201                '\\' => '\\',
202                _ => ch,
203            };
204            out.push(mapped);
205            escaped = false;
206            continue;
207        }
208        match ch {
209            '\\' => escaped = true,
210            '"' => return Some(out),
211            _ => out.push(ch),
212        }
213    }
214    None
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{
220        TurnErrorClass, classify_turn_error, looks_like_internal_error, summarize_internal_error,
221    };
222
223    #[test]
224    fn classifies_plan_limit_errors() {
225        assert_eq!(classify_turn_error("HTTP 429 Too Many Requests"), TurnErrorClass::PlanLimit);
226        assert_eq!(
227            classify_turn_error("turn failed: max budget exceeded"),
228            TurnErrorClass::PlanLimit
229        );
230    }
231
232    #[test]
233    fn classifies_auth_required_errors() {
234        assert_eq!(
235            classify_turn_error("authentication failed: please log in"),
236            TurnErrorClass::AuthRequired
237        );
238    }
239
240    /// Locks in the full needle list. A future dedup pass that
241    /// drops any of these substrings would silently regress
242    /// auth-required classification - assert the matrix end-to-end.
243    #[test]
244    fn auth_required_covers_all_consolidated_needles() {
245        for s in [
246            // Underscore form (typical CLI emit).
247            "authentication_failed: token expired",
248            // Space form of the same needle.
249            "authentication failed mid-request",
250            // Bare "unauthenticated" form.
251            "the request is unauthenticated",
252            // Substring "authentication required".
253            "tool authentication required to continue",
254            // Bare "auth required" form (shorter than "authentication required").
255            "auth required for this endpoint",
256            // "not authenticated" sentence form.
257            "client is not authenticated yet",
258            // Conjunctive 401 + auth check (HTTP-shape error).
259            "got 401 from /auth endpoint",
260            // Pre-existing needles (regression-protection).
261            "/login to continue",
262            "please log in",
263            "login required",
264            "unauthorized",
265        ] {
266            assert_eq!(
267                classify_turn_error(s),
268                TurnErrorClass::AuthRequired,
269                "expected AuthRequired for {s:?}"
270            );
271        }
272    }
273
274    #[test]
275    fn classifies_internal_errors() {
276        assert_eq!(
277            classify_turn_error(
278                r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal rpc fault"}}"#
279            ),
280            TurnErrorClass::Internal
281        );
282        assert!(looks_like_internal_error(
283            "<error><code>-32603</code><message>Adapter process crashed</message></error>"
284        ));
285    }
286
287    #[test]
288    fn classifies_other_errors() {
289        assert_eq!(classify_turn_error("turn failed: timeout"), TurnErrorClass::Other);
290    }
291
292    #[test]
293    fn summarize_prefers_permission_schema_error_message() {
294        let payload = "Tool permission request failed: ZodError: [{\"message\":\"Invalid input: expected record, received undefined\"}]";
295        assert_eq!(
296            summarize_internal_error(payload),
297            "Tool permission request failed: Invalid input: expected record, received undefined"
298        );
299    }
300
301    /// #143 item 1: the wrapped error payloads sometimes use field
302    /// names other than "message". Extended fallback chain now
303    /// reaches `assistant_error`, `detail`, `description`, `type`,
304    /// `body` so error_preview no longer comes back empty when
305    /// the wire shape avoids the canonical "message" field.
306    #[test]
307    fn summarize_falls_through_to_assistant_error_field() {
308        let payload = r#"{"jsonrpc":"2.0","error":{"assistant_error":"model context exceeded"}}"#;
309        assert_eq!(summarize_internal_error(payload), "model context exceeded");
310    }
311
312    #[test]
313    fn summarize_falls_through_to_detail_field() {
314        let payload =
315            r#"{"jsonrpc":"2.0","error":{"code":-32603,"detail":"upstream stream closed"}}"#;
316        assert_eq!(summarize_internal_error(payload), "upstream stream closed");
317    }
318
319    #[test]
320    fn summarize_falls_through_to_body_field() {
321        let payload =
322            r#"{"jsonrpc":"2.0","error":{"code":-32603,"body":"HTTP/2.0 502 Bad Gateway"}}"#;
323        assert_eq!(summarize_internal_error(payload), "HTTP/2.0 502 Bad Gateway");
324    }
325
326    /// `"message"` field wins over the fallback fields - the
327    /// pre-existing extractor runs first so this test pins
328    /// precedence ordering.
329    #[test]
330    fn summarize_message_field_wins_over_fallbacks() {
331        let payload = r#"{"error":{"message":"primary","detail":"secondary"}}"#;
332        assert_eq!(summarize_internal_error(payload), "primary");
333    }
334}