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 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 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 #[test]
244 fn auth_required_covers_all_consolidated_needles() {
245 for s in [
246 "authentication_failed: token expired",
248 "authentication failed mid-request",
250 "the request is unauthenticated",
252 "tool authentication required to continue",
254 "auth required for this endpoint",
256 "client is not authenticated yet",
258 "got 401 from /auth endpoint",
260 "/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 #[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 #[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}