返回 CodeWhale
context.rs
根目录 / crates / tui / src / core / engine / context.rs
1 //! Context budgeting and prompt-shaping helpers for the engine.
2 //!
3 //! These functions are shared by the streaming turn loop, capacity flow, and
4 //! engine session maintenance code. Keeping them here prevents the top-level
5 //! engine module from accumulating unrelated context-policy details.
6
7 use crate::compaction::estimate_tokens;
8 use crate::config::ApiProvider;
9 use crate::context_budget::ContextBudget;
10 use crate::error_taxonomy::ErrorCategory;
11 use crate::models::{Message, SystemPrompt};
12 pub(super) use crate::route_budget::effective_max_output_tokens_for_route;
13 #[cfg(test)]
14 pub(super) use crate::route_budget::{TURN_MAX_OUTPUT_TOKENS, effective_max_output_tokens};
15 use crate::tools::spec::ToolResult;
16 use codewhale_config::route::RouteLimits;
17 use serde_json::Value;
18 /// Keep this many most recent messages when emergency trimming is required.
19 pub(super) const MIN_RECENT_MESSAGES_TO_KEEP: usize = 4;
20 /// Allow a few emergency recovery attempts before failing the turn.
21 pub(super) const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2;
22 /// Hard cap for any tool output inserted into model context.
23 const TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS: usize = 12_000;
24 /// Soft cap for known noisy tools inserted into model context.
25 const TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS: usize = 2_000;
26 /// Snippet length kept when compacting tool output for model context.
27 const TOOL_RESULT_CONTEXT_SNIPPET_CHARS: usize = 900;
28 /// Hard cap for tool output inserted into a large-context model.
29 const LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS: usize = 48_000;
30 /// Soft cap for known noisy tools inserted into a large-context model.
31 const LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS: usize = 8_000;
32 /// Snippet length kept when compacting large-context noisy output.
33 const LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
34 /// Context window size at which tool output limits can be relaxed.
35 const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
36 /// Max chars to keep from metadata-provided output summaries.
37 const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320;
38
39 pub(super) const COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)";
40
41 #[derive(Debug, Clone, Copy)]
42 struct ToolResultContextLimits {
43 hard_limit_chars: usize,
44 noisy_soft_limit_chars: usize,
45 snippet_chars: usize,
46 }
47
48 pub(super) fn summarize_text(text: &str, limit: usize) -> String {
49 if text.chars().count() <= limit {
50 return text.to_string();
51 }
52 let take = limit.saturating_sub(3);
53 let mut out: String = text.chars().take(take).collect();
54 out.push_str("...");
55 out
56 }
57
58 fn summarize_text_head_tail(text: &str, limit: usize) -> String {
59 let total = text.chars().count();
60 if total <= limit {
61 return text.to_string();
62 }
63 if limit <= 20 {
64 return summarize_text(text, limit);
65 }
66
67 let marker = "\n\n[... output truncated for context ...]\n\n";
68 let marker_len = marker.chars().count();
69 if limit <= marker_len + 20 {
70 return summarize_text(text, limit);
71 }
72
73 let remaining = limit - marker_len;
74 let head_len = remaining.saturating_mul(2) / 3;
75 let tail_len = remaining.saturating_sub(head_len);
76 let head: String = text.chars().take(head_len).collect();
77 let tail_vec: Vec<char> = text.chars().rev().take(tail_len).collect();
78 let tail: String = tail_vec.into_iter().rev().collect();
79 format!("{head}{marker}{tail}")
80 }
81
82 fn tool_result_is_noisy(tool_name: &str) -> bool {
83 matches!(
84 tool_name,
85 "exec_shell"
86 | "exec_shell_wait"
87 | "exec_shell_interact"
88 | "exec_shell_cancel"
89 | "task_shell_start"
90 | "task_shell_wait"
91 | "run_tests"
92 | "run_verifiers"
93 | "task_gate_run"
94 | "multi_tool_use.parallel"
95 | "web_search"
96 )
97 }
98
99 fn tool_result_metadata_summary(metadata: Option<&serde_json::Value>) -> Option<String> {
100 let obj = metadata?.as_object()?;
101 for key in ["summary", "stdout_summary", "stderr_summary", "message"] {
102 if let Some(text) = obj.get(key).and_then(serde_json::Value::as_str) {
103 let trimmed = text.trim();
104 if !trimmed.is_empty() {
105 return Some(summarize_text(trimmed, TOOL_RESULT_METADATA_SUMMARY_CHARS));
106 }
107 }
108 }
109 None
110 }
111
112 fn summarize_subagent_status(status: &serde_json::Value) -> String {
113 if let Some(raw) = status.as_str() {
114 return raw.to_string();
115 }
116 if let Some(obj) = status.as_object()
117 && let Some((kind, value)) = obj.iter().next()
118 {
119 if let Some(reason) = value.as_str().filter(|s| !s.trim().is_empty()) {
120 return format!("{kind}({})", summarize_text(reason.trim(), 120));
121 }
122 return kind.to_string();
123 }
124 status.to_string()
125 }
126
127 fn summarize_subagent_snapshot(snapshot: &serde_json::Value, index: usize) -> String {
128 if let Some(inner) = snapshot.get("snapshot") {
129 return summarize_subagent_snapshot(inner, index);
130 }
131
132 let Some(obj) = snapshot.as_object() else {
133 return format!(
134 "- item {index}: {}",
135 summarize_text(&snapshot.to_string(), 240)
136 );
137 };
138
139 let agent_id = obj
140 .get("agent_id")
141 .and_then(serde_json::Value::as_str)
142 .unwrap_or("unknown");
143 let agent_type = obj
144 .get("agent_type")
145 .and_then(serde_json::Value::as_str)
146 .unwrap_or("agent");
147 let status = obj
148 .get("status")
149 .map(summarize_subagent_status)
150 .unwrap_or_else(|| "unknown".to_string());
151 let objective = obj
152 .get("assignment")
153 .and_then(|assignment| assignment.get("objective"))
154 .and_then(serde_json::Value::as_str)
155 .map(str::trim)
156 .filter(|s| !s.is_empty())
157 .map(|s| summarize_text(s, 220));
158 let result = obj
159 .get("result")
160 .and_then(serde_json::Value::as_str)
161 .map(str::trim)
162 .filter(|s| !s.is_empty())
163 .map(|s| summarize_text(s, 1_600));
164 let steps = obj.get("steps_taken").and_then(serde_json::Value::as_u64);
165 let duration_ms = obj.get("duration_ms").and_then(serde_json::Value::as_u64);
166
167 let mut lines = vec![format!("- {agent_id} ({agent_type}) status={status}")];
168 if let Some(objective) = objective {
169 lines.push(format!(" objective: {objective}"));
170 }
171 match result {
172 Some(result) => lines.push(format!(" result: {result}")),
173 None => lines.push(" result: not available yet".to_string()),
174 }
175 if steps.is_some() || duration_ms.is_some() {
176 let steps = steps
177 .map(|n| n.to_string())
178 .unwrap_or_else(|| "?".to_string());
179 let duration_ms = duration_ms
180 .map(|n| n.to_string())
181 .unwrap_or_else(|| "?".to_string());
182 lines.push(format!(" stats: steps={steps}, duration_ms={duration_ms}"));
183 }
184 lines.join("\n")
185 }
186
187 fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> {
188 if tool_name != "agent" {
189 return None;
190 }
191
192 let parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
193 let snapshots: Vec<&serde_json::Value> = match &parsed {
194 serde_json::Value::Array(items) => items.iter().collect(),
195 serde_json::Value::Object(_) => vec![&parsed],
196 _ => return None,
197 };
198
199 let mut out = String::from("[sub-agent result summarized for parent context]\n");
200 out.push_str(
201 "Child results are self-reports; verify side effects with `File` actions like `read` or `list` before claiming success.\n",
202 );
203 out.push_str("Use `handle_read` on `transcript_handle` for bounded transcript slices when the returned summary is not enough.\n");
204 for (idx, snapshot) in snapshots.iter().enumerate() {
205 if idx >= 8 {
206 out.push_str(&format!(
207 "- ... {} more sub-agent result(s) omitted from context summary\n",
208 snapshots.len().saturating_sub(idx)
209 ));
210 break;
211 }
212 out.push_str(&summarize_subagent_snapshot(snapshot, idx + 1));
213 out.push('\n');
214 }
215 Some(out.trim_end().to_string())
216 }
217
218 fn json_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
219 value
220 .get(key)
221 .and_then(Value::as_str)
222 .map(str::trim)
223 .filter(|s| !s.is_empty())
224 }
225
226 fn json_number_text(value: &Value, key: &str) -> Option<String> {
227 value
228 .get(key)
229 .and_then(|value| {
230 value
231 .as_i64()
232 .map(|n| n.to_string())
233 .or_else(|| value.as_u64().map(|n| n.to_string()))
234 })
235 .or_else(|| {
236 value
237 .get(key)
238 .and_then(Value::as_str)
239 .map(str::trim)
240 .filter(|s| !s.is_empty())
241 .map(ToString::to_string)
242 })
243 }
244
245 fn compact_run_tests_result_for_context(raw: &str) -> Option<String> {
246 let parsed: Value = serde_json::from_str(raw).ok()?;
247 let success = parsed.get("success")?.as_bool()?;
248 let exit_code = json_number_text(&parsed, "exit_code").unwrap_or_else(|| "?".to_string());
249 let command = json_text(&parsed, "command").unwrap_or("(unknown command)");
250 let stdout = json_text(&parsed, "stdout");
251 let stderr = json_text(&parsed, "stderr");
252 let stream_limit = if success { 500 } else { 1_000 };
253
254 let mut lines = vec![
255 "[run_tests result summarized for context]".to_string(),
256 format!(
257 "status: {}, exit_code: {exit_code}",
258 if success { "passed" } else { "failed" }
259 ),
260 format!("command: {}", summarize_text(command, 300)),
261 ];
262 if let Some(stderr) = stderr {
263 lines.push(format!(
264 "stderr: {}",
265 summarize_text_head_tail(stderr, stream_limit)
266 ));
267 }
268 if let Some(stdout) = stdout {
269 lines.push(format!(
270 "stdout: {}",
271 summarize_text_head_tail(stdout, stream_limit)
272 ));
273 }
274 Some(lines.join("\n"))
275 }
276
277 fn run_verifier_status_rank(status: Option<&str>) -> u8 {
278 match status.unwrap_or_default() {
279 "failed" | "timeout" => 0,
280 "skipped" => 1,
281 "passed" => 2,
282 _ => 3,
283 }
284 }
285
286 fn compact_run_verifiers_result_for_context(raw: &str) -> Option<String> {
287 let parsed: Value = serde_json::from_str(raw).ok()?;
288 let gates = parsed.get("gates")?.as_array()?;
289 let summary = json_text(&parsed, "summary")
290 .map(ToString::to_string)
291 .unwrap_or_else(|| {
292 let passed = json_number_text(&parsed, "passed").unwrap_or_else(|| "?".to_string());
293 let failed = json_number_text(&parsed, "failed").unwrap_or_else(|| "?".to_string());
294 let skipped = json_number_text(&parsed, "skipped").unwrap_or_else(|| "?".to_string());
295 format!("{passed} passed, {failed} failed, {skipped} skipped")
296 });
297
298 let mut ordered: Vec<&Value> = gates.iter().collect();
299 ordered.sort_by(|a, b| {
300 run_verifier_status_rank(json_text(a, "status"))
301 .cmp(&run_verifier_status_rank(json_text(b, "status")))
302 .then_with(|| json_text(a, "name").cmp(&json_text(b, "name")))
303 });
304
305 let mut lines = vec![
306 "[run_verifiers result summarized for context]".to_string(),
307 format!("summary: {summary}"),
308 ];
309 let profile = json_text(&parsed, "profile");
310 let level = json_text(&parsed, "level");
311 if profile.is_some() || level.is_some() {
312 lines.push(format!(
313 "selection: profile={}, level={}",
314 profile.unwrap_or("?"),
315 level.unwrap_or("?")
316 ));
317 }
318
319 for (idx, gate) in ordered.iter().enumerate() {
320 if idx >= 12 {
321 lines.push(format!(
322 "- ... {} more gate(s) omitted from context summary",
323 ordered.len().saturating_sub(idx)
324 ));
325 break;
326 }
327
328 let name = json_text(gate, "name").unwrap_or("gate");
329 let ecosystem = json_text(gate, "ecosystem").unwrap_or("unknown");
330 let status = json_text(gate, "status").unwrap_or("unknown");
331 let exit = json_number_text(gate, "exit_code")
332 .map(|code| format!(" exit={code}"))
333 .unwrap_or_default();
334 lines.push(format!("- {name} ({ecosystem}): {status}{exit}"));
335
336 if status != "passed" {
337 if let Some(command) = json_text(gate, "command") {
338 lines.push(format!(" command: {}", summarize_text(command, 240)));
339 }
340 if let Some(detail) = json_text(gate, "skipped_reason")
341 .or_else(|| json_text(gate, "stderr"))
342 .or_else(|| json_text(gate, "stdout"))
343 {
344 lines.push(format!(
345 " detail: {}",
346 summarize_text_head_tail(detail, 600)
347 ));
348 }
349 }
350 }
351
352 Some(lines.join("\n"))
353 }
354
355 fn compact_task_gate_run_result_for_context(raw: &str) -> Option<String> {
356 let parsed: Value = serde_json::from_str(raw).ok()?;
357 let gate = parsed.get("gate")?;
358 let gate_name = json_text(gate, "gate").unwrap_or("gate");
359 let status = json_text(gate, "status").unwrap_or("unknown");
360 let command = json_text(gate, "command").unwrap_or("(unknown command)");
361 let summary = json_text(gate, "summary")
362 .or_else(|| json_text(&parsed, "stderr_summary"))
363 .or_else(|| json_text(&parsed, "stdout_summary"));
364 let exit = json_number_text(gate, "exit_code")
365 .map(|code| format!(", exit_code: {code}"))
366 .unwrap_or_default();
367
368 let mut lines = vec![
369 "[task_gate_run result summarized for context]".to_string(),
370 format!("gate: {gate_name}, status: {status}{exit}"),
371 format!("command: {}", summarize_text(command, 300)),
372 ];
373 if let Some(summary) = summary {
374 lines.push(format!("summary: {}", summarize_text(summary, 800)));
375 }
376 if let Some(log_path) = json_text(gate, "log_path") {
377 lines.push(format!("log_path: {log_path}"));
378 }
379 Some(lines.join("\n"))
380 }
381
382 fn compact_structured_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> {
383 match tool_name {
384 "run_tests" => compact_run_tests_result_for_context(raw),
385 "run_verifiers" => compact_run_verifiers_result_for_context(raw),
386 // `tasks` is the unified durable-task tool (piagent phase B); its
387 // gate_run action emits the same gate payload as the legacy
388 // `task_gate_run` alias. The compactor returns None unless the
389 // content actually parses as a gate result, so non-gate `tasks`
390 // results fall through to the generic limits unchanged.
391 "task_gate_run" | "tasks" => compact_task_gate_run_result_for_context(raw),
392 _ => None,
393 }
394 }
395
396 fn tool_result_context_limits_for_window(context_window: u32) -> ToolResultContextLimits {
397 let is_large_context = context_window >= LARGE_CONTEXT_WINDOW_TOKENS;
398
399 if is_large_context {
400 ToolResultContextLimits {
401 hard_limit_chars: LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS,
402 noisy_soft_limit_chars: LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS,
403 snippet_chars: LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS,
404 }
405 } else {
406 ToolResultContextLimits {
407 hard_limit_chars: TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS,
408 noisy_soft_limit_chars: TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS,
409 snippet_chars: TOOL_RESULT_CONTEXT_SNIPPET_CHARS,
410 }
411 }
412 }
413
414 #[cfg(test)]
415 pub(crate) fn compact_tool_result_for_context(
416 model: &str,
417 tool_name: &str,
418 output: &ToolResult,
419 ) -> String {
420 compact_tool_result_for_route(ApiProvider::Deepseek, model, None, tool_name, output)
421 }
422
423 pub(crate) fn compact_tool_result_for_route(
424 provider: ApiProvider,
425 model: &str,
426 route_limits: Option<RouteLimits>,
427 tool_name: &str,
428 output: &ToolResult,
429 ) -> String {
430 let raw = output.content.trim();
431 if raw.is_empty() {
432 return String::new();
433 }
434
435 // A result already bounded by the adaptive evidence envelope is an
436 // honest, context-sized preview whose footer names the artifact path and
437 // a recovery instruction. Re-compacting it would strip that recovery
438 // contract and double-truncate the output, so pass it through unchanged.
439 if output
440 .metadata
441 .as_ref()
442 .and_then(|metadata| metadata.get("evidence_available"))
443 .and_then(serde_json::Value::as_bool)
444 .unwrap_or(false)
445 {
446 return raw.to_string();
447 }
448
449 // `registry_sync` is deliberately a complete model-side candidate set.
450 // Applying the generic 12K hard limit retains only the JSON head/tail and
451 // silently removes candidates from the middle, turning semantic matching
452 // back into an accidental position-based filter. The eligible local stdio
453 // catalog is bounded upstream by Registry pagination and environment/package
454 // filtering, so preserve it intact for the selection step.
455 if tool_name == "registry_sync" {
456 return raw.to_string();
457 }
458
459 if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) {
460 return summary;
461 }
462
463 if let Some(summary) = compact_structured_tool_result_for_context(tool_name, raw) {
464 return summary;
465 }
466
467 let context_window =
468 crate::route_budget::route_context_window_tokens(provider, model, route_limits);
469 let limits = tool_result_context_limits_for_window(context_window);
470 let raw_chars = raw.chars().count();
471 let should_compact = raw_chars > limits.hard_limit_chars
472 || (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars);
473 if !should_compact {
474 return raw.to_string();
475 }
476
477 let snippet = summarize_text_head_tail(raw, limits.snippet_chars);
478 let omitted = raw_chars.saturating_sub(snippet.chars().count());
479 let summary = tool_result_metadata_summary(output.metadata.as_ref());
480
481 if let Some(summary) = summary {
482 format!(
483 "[{tool_name} output compacted to protect context]\nSummary: {summary}\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)"
484 )
485 } else {
486 format!(
487 "[{tool_name} output compacted to protect context]\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)"
488 )
489 }
490 }
491
492 pub(super) fn extract_compaction_summary_prompt(
493 prompt: Option<SystemPrompt>,
494 ) -> Option<SystemPrompt> {
495 match prompt {
496 Some(SystemPrompt::Blocks(blocks)) => {
497 let summary_blocks: Vec<_> = blocks
498 .into_iter()
499 .filter(|block| block.text.contains(COMPACTION_SUMMARY_MARKER))
500 .collect();
501 if summary_blocks.is_empty() {
502 None
503 } else {
504 Some(SystemPrompt::Blocks(summary_blocks))
505 }
506 }
507 Some(SystemPrompt::Text(text)) => {
508 if text.contains(COMPACTION_SUMMARY_MARKER) {
509 Some(SystemPrompt::Text(text))
510 } else {
511 None
512 }
513 }
514 None => None,
515 }
516 }
517
518 #[allow(dead_code)] // exposed for future engine-side callers; current call path goes through compaction::estimate_input_tokens_conservative via token_estimate_cache.
519 fn estimate_text_tokens_conservative(text: &str) -> usize {
520 text.chars().count().div_ceil(3)
521 }
522
523 #[allow(dead_code)] // see estimate_text_tokens_conservative above
524 fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
525 match system {
526 Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
527 Some(SystemPrompt::Blocks(blocks)) => blocks
528 .iter()
529 .map(|block| estimate_text_tokens_conservative(&block.text))
530 .sum(),
531 None => 0,
532 }
533 }
534
535 #[allow(dead_code)] // see estimate_text_tokens_conservative above
536 pub(super) fn estimate_input_tokens_conservative(
537 messages: &[Message],
538 system: Option<&SystemPrompt>,
539 ) -> usize {
540 let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
541 let system_tokens = estimate_system_tokens_conservative(system);
542 let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
543 message_tokens
544 .saturating_add(system_tokens)
545 .saturating_add(framing_overhead)
546 }
547
548 /// Internal input-side token budget for a provider/model route:
549 /// `window - reserved_output - headroom`. Used by the preflight check,
550 /// emergency recovery, and capacity trimming to decide when to compact.
551 /// Unknown model ids fall back to the provider's conservative default instead
552 /// of disabling preflight; custom long-context deployments can still advertise
553 /// their window with a `-256k`/`-1024k` model suffix.
554 ///
555 /// The reserved-output term is window-dependent:
556 /// * `window >= 500K` (V4-class large-context) -> [`TURN_MAX_OUTPUT_TOKENS`]
557 /// (262K). Preserves the "leave room for interleaved thinking" contract.
558 /// * `window < 500K` (smaller / self-hosted, e.g. a 256K vLLM Qwen window)
559 /// -> [`effective_max_output_tokens`], i.e. what the API actually caps
560 /// output at. Reserving the full 262K here would compute
561 /// `256K - 262K - 1K`, which underflows `checked_sub` to `None` and
562 /// *silently disables every preflight and emergency recovery path* — the
563 /// session then runs until the provider hard-rejects on context length.
564 #[cfg(test)]
565 pub(super) fn context_input_budget_for_provider(
566 provider: ApiProvider,
567 model: &str,
568 ) -> Option<usize> {
569 context_input_budget_for_route(provider, model, None, 0)
570 }
571
572 /// Public so external callers (e.g. a host/bridge deriving its own compaction
573 /// trigger line) can reuse the *exact* same internal input-budget math — window
574 /// minus the window-dependent output reservation (`route_output_reservation_for_window`,
575 /// which encodes the ≥500K→262K vs smaller-window split) minus headroom —
576 /// instead of re-deriving those constants and silently drifting from the engine.
577 /// Pass `input_tokens = 0` to get the full emergency input budget for the route.
578 pub fn context_input_budget_for_route(
579 provider: ApiProvider,
580 model: &str,
581 route_limits: Option<RouteLimits>,
582 input_tokens: usize,
583 ) -> Option<usize> {
584 route_context_budget_for_route(provider, model, route_limits, input_tokens)
585 .and_then(|budget| usize::try_from(budget.available_input_tokens).ok())
586 }
587
588 #[cfg(test)]
589 pub(super) fn route_context_budget_for_provider(
590 provider: ApiProvider,
591 model: &str,
592 input_tokens: usize,
593 ) -> Option<ContextBudget> {
594 route_context_budget_for_route(provider, model, None, input_tokens)
595 }
596
597 pub(super) fn route_context_budget_for_route(
598 provider: ApiProvider,
599 model: &str,
600 route_limits: Option<RouteLimits>,
601 input_tokens: usize,
602 ) -> Option<ContextBudget> {
603 crate::route_budget::route_context_budget(provider, model, route_limits, input_tokens)
604 }
605
606 pub(super) fn is_context_length_error_message(message: &str) -> bool {
607 crate::error_taxonomy::classify_error_message(message) == ErrorCategory::InvalidInput
608 }
609
609 lines RUST