| 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::config::ApiProvider; |
| 8 | use crate::context_budget::ContextBudget; |
| 9 | #[cfg(test)] |
| 10 | pub(super) use crate::route_budget::effective_max_output_tokens; |
| 11 | pub(super) use crate::route_budget::effective_max_output_tokens_for_route; |
| 12 | use crate::tools::spec::ToolResult; |
| 13 | use codewhale_config::route::RouteLimits; |
| 14 | use codewhale_models::SystemPrompt; |
| 15 | use serde_json::Value; |
| 16 | /// Allow a few emergency recovery attempts before failing the turn. |
| 17 | pub(super) const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2; |
| 18 | /// Hard cap for any tool output inserted into model context. |
| 19 | const TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS: usize = 12_000; |
| 20 | /// Soft cap for known noisy tools inserted into model context. |
| 21 | const TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS: usize = 2_000; |
| 22 | /// Snippet length kept when compacting tool output for model context. |
| 23 | const TOOL_RESULT_CONTEXT_SNIPPET_CHARS: usize = 900; |
| 24 | /// Hard cap for tool output inserted into a large-context model. |
| 25 | const LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS: usize = 48_000; |
| 26 | /// Soft cap for known noisy tools inserted into a large-context model. |
| 27 | const LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS: usize = 8_000; |
| 28 | /// Snippet length kept when compacting large-context noisy output. |
| 29 | const LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000; |
| 30 | /// Context window size at which tool output limits can be relaxed. |
| 31 | const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000; |
| 32 | /// Max chars to keep from metadata-provided output summaries. |
| 33 | const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320; |
| 34 | |
| 35 | #[cfg(test)] |
| 36 | pub(super) use crate::compaction::COMPACTION_SUMMARY_MARKER; |
| 37 | |
| 38 | #[derive(Debug, Clone, Copy)] |
| 39 | struct ToolResultContextLimits { |
| 40 | hard_limit_chars: usize, |
| 41 | noisy_soft_limit_chars: usize, |
| 42 | snippet_chars: usize, |
| 43 | } |
| 44 | |
| 45 | pub(super) fn summarize_text(text: &str, limit: usize) -> String { |
| 46 | if text.chars().count() <= limit { |
| 47 | return text.to_string(); |
| 48 | } |
| 49 | let take = limit.saturating_sub(3); |
| 50 | let mut out: String = text.chars().take(take).collect(); |
| 51 | out.push_str("..."); |
| 52 | out |
| 53 | } |
| 54 | |
| 55 | fn summarize_text_head_tail(text: &str, limit: usize) -> String { |
| 56 | let total = text.chars().count(); |
| 57 | if total <= limit { |
| 58 | return text.to_string(); |
| 59 | } |
| 60 | if limit <= 20 { |
| 61 | return summarize_text(text, limit); |
| 62 | } |
| 63 | |
| 64 | let marker = "\n\n[... output truncated for context ...]\n\n"; |
| 65 | let marker_len = marker.chars().count(); |
| 66 | if limit <= marker_len + 20 { |
| 67 | return summarize_text(text, limit); |
| 68 | } |
| 69 | |
| 70 | let remaining = limit - marker_len; |
| 71 | let head_len = remaining.saturating_mul(2) / 3; |
| 72 | let tail_len = remaining.saturating_sub(head_len); |
| 73 | let head: String = text.chars().take(head_len).collect(); |
| 74 | let tail_vec: Vec<char> = text.chars().rev().take(tail_len).collect(); |
| 75 | let tail: String = tail_vec.into_iter().rev().collect(); |
| 76 | format!("{head}{marker}{tail}") |
| 77 | } |
| 78 | |
| 79 | fn tool_result_is_noisy(tool_name: &str) -> bool { |
| 80 | matches!( |
| 81 | tool_name, |
| 82 | "exec_shell" |
| 83 | | "exec_shell_wait" |
| 84 | | "exec_shell_interact" |
| 85 | | "exec_shell_cancel" |
| 86 | | "task_shell_start" |
| 87 | | "task_shell_wait" |
| 88 | | "run_tests" |
| 89 | | "run_verifiers" |
| 90 | | "task_gate_run" |
| 91 | | "multi_tool_use.parallel" |
| 92 | | "Web" |
| 93 | | "web_search" |
| 94 | | "web.run" |
| 95 | | "fetch_url" |
| 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 | /// A payload is a sub-agent snapshot when it carries the identity/status shape |
| 188 | /// this summarizer knows how to render (`agent_id`/`agent_type`, optionally |
| 189 | /// wrapped in a `snapshot` field). |
| 190 | fn looks_like_subagent_snapshot(value: &serde_json::Value) -> bool { |
| 191 | let value = value.get("snapshot").unwrap_or(value); |
| 192 | value |
| 193 | .as_object() |
| 194 | .is_some_and(|obj| obj.contains_key("agent_id") || obj.contains_key("agent_type")) |
| 195 | } |
| 196 | |
| 197 | fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> { |
| 198 | if tool_name != "agent" { |
| 199 | return None; |
| 200 | } |
| 201 | |
| 202 | let parsed: serde_json::Value = serde_json::from_str(raw).ok()?; |
| 203 | let snapshots: Vec<&serde_json::Value> = match &parsed { |
| 204 | serde_json::Value::Array(items) => items.iter().collect(), |
| 205 | serde_json::Value::Object(_) => vec![&parsed], |
| 206 | _ => return None, |
| 207 | }; |
| 208 | |
| 209 | // Coordination envelopes (`wait`, `status`, `claim`, ...) carry typed |
| 210 | // fields the parent needs verbatim: `settled`, `still_running`, |
| 211 | // `timed_out`, `waited_ms`, `note`. Projecting them through the snapshot |
| 212 | // renderer replaced every one with `unknown (agent) status=unknown` and |
| 213 | // dropped the real payload. Summarize only snapshot-shaped results; let |
| 214 | // anything else fall through to the generic bounded path. |
| 215 | if snapshots.is_empty() |
| 216 | || !snapshots |
| 217 | .iter() |
| 218 | .all(|value| looks_like_subagent_snapshot(value)) |
| 219 | { |
| 220 | return None; |
| 221 | } |
| 222 | |
| 223 | let mut out = String::from("[sub-agent result summarized for parent context]\n"); |
| 224 | out.push_str( |
| 225 | "Child results are self-reports; verify side effects with `File` actions like `read` or `list` before claiming success.\n", |
| 226 | ); |
| 227 | out.push_str("Use `handle_read` on `transcript_handle` for bounded transcript slices when the returned summary is not enough.\n"); |
| 228 | for (idx, snapshot) in snapshots.iter().enumerate() { |
| 229 | if idx >= 8 { |
| 230 | out.push_str(&format!( |
| 231 | "- ... {} more sub-agent result(s) omitted from context summary\n", |
| 232 | snapshots.len().saturating_sub(idx) |
| 233 | )); |
| 234 | break; |
| 235 | } |
| 236 | out.push_str(&summarize_subagent_snapshot(snapshot, idx + 1)); |
| 237 | out.push('\n'); |
| 238 | } |
| 239 | Some(out.trim_end().to_string()) |
| 240 | } |
| 241 | |
| 242 | fn json_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { |
| 243 | value |
| 244 | .get(key) |
| 245 | .and_then(Value::as_str) |
| 246 | .map(str::trim) |
| 247 | .filter(|s| !s.is_empty()) |
| 248 | } |
| 249 | |
| 250 | fn json_number_text(value: &Value, key: &str) -> Option<String> { |
| 251 | value |
| 252 | .get(key) |
| 253 | .and_then(|value| { |
| 254 | value |
| 255 | .as_i64() |
| 256 | .map(|n| n.to_string()) |
| 257 | .or_else(|| value.as_u64().map(|n| n.to_string())) |
| 258 | }) |
| 259 | .or_else(|| { |
| 260 | value |
| 261 | .get(key) |
| 262 | .and_then(Value::as_str) |
| 263 | .map(str::trim) |
| 264 | .filter(|s| !s.is_empty()) |
| 265 | .map(ToString::to_string) |
| 266 | }) |
| 267 | } |
| 268 | |
| 269 | fn compact_run_tests_result_for_context(raw: &str) -> Option<String> { |
| 270 | let parsed: Value = serde_json::from_str(raw).ok()?; |
| 271 | let success = parsed.get("success")?.as_bool()?; |
| 272 | let exit_code = json_number_text(&parsed, "exit_code").unwrap_or_else(|| "?".to_string()); |
| 273 | let command = json_text(&parsed, "command").unwrap_or("(unknown command)"); |
| 274 | let stdout = json_text(&parsed, "stdout"); |
| 275 | let stderr = json_text(&parsed, "stderr"); |
| 276 | let stream_limit = if success { 500 } else { 1_000 }; |
| 277 | |
| 278 | let mut lines = vec![ |
| 279 | "[run_tests result summarized for context]".to_string(), |
| 280 | format!( |
| 281 | "status: {}, exit_code: {exit_code}", |
| 282 | if success { "passed" } else { "failed" } |
| 283 | ), |
| 284 | format!("command: {}", summarize_text(command, 300)), |
| 285 | ]; |
| 286 | if let Some(stderr) = stderr { |
| 287 | lines.push(format!( |
| 288 | "stderr: {}", |
| 289 | summarize_text_head_tail(stderr, stream_limit) |
| 290 | )); |
| 291 | } |
| 292 | if let Some(stdout) = stdout { |
| 293 | lines.push(format!( |
| 294 | "stdout: {}", |
| 295 | summarize_text_head_tail(stdout, stream_limit) |
| 296 | )); |
| 297 | } |
| 298 | Some(lines.join("\n")) |
| 299 | } |
| 300 | |
| 301 | fn run_verifier_status_rank(status: Option<&str>) -> u8 { |
| 302 | match status.unwrap_or_default() { |
| 303 | "failed" | "timeout" => 0, |
| 304 | "skipped" => 1, |
| 305 | "passed" => 2, |
| 306 | _ => 3, |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | fn compact_run_verifiers_result_for_context(raw: &str) -> Option<String> { |
| 311 | let parsed: Value = serde_json::from_str(raw).ok()?; |
| 312 | let gates = parsed.get("gates")?.as_array()?; |
| 313 | let summary = json_text(&parsed, "summary") |
| 314 | .map(ToString::to_string) |
| 315 | .unwrap_or_else(|| { |
| 316 | let passed = json_number_text(&parsed, "passed").unwrap_or_else(|| "?".to_string()); |
| 317 | let failed = json_number_text(&parsed, "failed").unwrap_or_else(|| "?".to_string()); |
| 318 | let skipped = json_number_text(&parsed, "skipped").unwrap_or_else(|| "?".to_string()); |
| 319 | format!("{passed} passed, {failed} failed, {skipped} skipped") |
| 320 | }); |
| 321 | |
| 322 | let mut ordered: Vec<&Value> = gates.iter().collect(); |
| 323 | ordered.sort_by(|a, b| { |
| 324 | run_verifier_status_rank(json_text(a, "status")) |
| 325 | .cmp(&run_verifier_status_rank(json_text(b, "status"))) |
| 326 | .then_with(|| json_text(a, "name").cmp(&json_text(b, "name"))) |
| 327 | }); |
| 328 | |
| 329 | let mut lines = vec![ |
| 330 | "[run_verifiers result summarized for context]".to_string(), |
| 331 | format!("summary: {summary}"), |
| 332 | ]; |
| 333 | let profile = json_text(&parsed, "profile"); |
| 334 | let level = json_text(&parsed, "level"); |
| 335 | if profile.is_some() || level.is_some() { |
| 336 | lines.push(format!( |
| 337 | "selection: profile={}, level={}", |
| 338 | profile.unwrap_or("?"), |
| 339 | level.unwrap_or("?") |
| 340 | )); |
| 341 | } |
| 342 | |
| 343 | for (idx, gate) in ordered.iter().enumerate() { |
| 344 | if idx >= 12 { |
| 345 | lines.push(format!( |
| 346 | "- ... {} more gate(s) omitted from context summary", |
| 347 | ordered.len().saturating_sub(idx) |
| 348 | )); |
| 349 | break; |
| 350 | } |
| 351 | |
| 352 | let name = json_text(gate, "name").unwrap_or("gate"); |
| 353 | let ecosystem = json_text(gate, "ecosystem").unwrap_or("unknown"); |
| 354 | let status = json_text(gate, "status").unwrap_or("unknown"); |
| 355 | let exit = json_number_text(gate, "exit_code") |
| 356 | .map(|code| format!(" exit={code}")) |
| 357 | .unwrap_or_default(); |
| 358 | lines.push(format!("- {name} ({ecosystem}): {status}{exit}")); |
| 359 | |
| 360 | if status != "passed" { |
| 361 | if let Some(command) = json_text(gate, "command") { |
| 362 | lines.push(format!(" command: {}", summarize_text(command, 240))); |
| 363 | } |
| 364 | if let Some(detail) = json_text(gate, "skipped_reason") |
| 365 | .or_else(|| json_text(gate, "stderr")) |
| 366 | .or_else(|| json_text(gate, "stdout")) |
| 367 | { |
| 368 | lines.push(format!( |
| 369 | " detail: {}", |
| 370 | summarize_text_head_tail(detail, 600) |
| 371 | )); |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | Some(lines.join("\n")) |
| 377 | } |
| 378 | |
| 379 | fn compact_task_gate_run_result_for_context(raw: &str) -> Option<String> { |
| 380 | let parsed: Value = serde_json::from_str(raw).ok()?; |
| 381 | let gate = parsed.get("gate")?; |
| 382 | let gate_name = json_text(gate, "gate").unwrap_or("gate"); |
| 383 | let status = json_text(gate, "status").unwrap_or("unknown"); |
| 384 | let command = json_text(gate, "command").unwrap_or("(unknown command)"); |
| 385 | let summary = json_text(gate, "summary") |
| 386 | .or_else(|| json_text(&parsed, "stderr_summary")) |
| 387 | .or_else(|| json_text(&parsed, "stdout_summary")); |
| 388 | let exit = json_number_text(gate, "exit_code") |
| 389 | .map(|code| format!(", exit_code: {code}")) |
| 390 | .unwrap_or_default(); |
| 391 | |
| 392 | let mut lines = vec![ |
| 393 | "[task_gate_run result summarized for context]".to_string(), |
| 394 | format!("gate: {gate_name}, status: {status}{exit}"), |
| 395 | format!("command: {}", summarize_text(command, 300)), |
| 396 | ]; |
| 397 | if let Some(summary) = summary { |
| 398 | lines.push(format!("summary: {}", summarize_text(summary, 800))); |
| 399 | } |
| 400 | if let Some(log_path) = json_text(gate, "log_path") { |
| 401 | lines.push(format!("log_path: {log_path}")); |
| 402 | } |
| 403 | Some(lines.join("\n")) |
| 404 | } |
| 405 | |
| 406 | fn compact_structured_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> { |
| 407 | match tool_name { |
| 408 | "run_tests" => compact_run_tests_result_for_context(raw), |
| 409 | "run_verifiers" => compact_run_verifiers_result_for_context(raw), |
| 410 | // `tasks` is the unified durable-task tool (piagent phase B); its |
| 411 | // gate_run action emits the same gate payload as the legacy |
| 412 | // `task_gate_run` alias. The compactor returns None unless the |
| 413 | // content actually parses as a gate result, so non-gate `tasks` |
| 414 | // results fall through to the generic limits unchanged. |
| 415 | "task_gate_run" | "tasks" => compact_task_gate_run_result_for_context(raw), |
| 416 | _ => None, |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | fn tool_result_context_limits_for_window(context_window: u32) -> ToolResultContextLimits { |
| 421 | let is_large_context = context_window >= LARGE_CONTEXT_WINDOW_TOKENS; |
| 422 | |
| 423 | let mut limits = if is_large_context { |
| 424 | ToolResultContextLimits { |
| 425 | hard_limit_chars: LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS, |
| 426 | noisy_soft_limit_chars: LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS, |
| 427 | snippet_chars: LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS, |
| 428 | } |
| 429 | } else { |
| 430 | ToolResultContextLimits { |
| 431 | hard_limit_chars: TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS, |
| 432 | noisy_soft_limit_chars: TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS, |
| 433 | snippet_chars: TOOL_RESULT_CONTEXT_SNIPPET_CHARS, |
| 434 | } |
| 435 | }; |
| 436 | if let Some(bytes) = |
| 437 | crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes() |
| 438 | { |
| 439 | // Opt-in long-context profiles may raise the model-visible budget. |
| 440 | // Never lower the compile-time floor; cap at 2 MiB (#5367). |
| 441 | let raised = bytes.clamp(limits.hard_limit_chars, 2 * 1024 * 1024); |
| 442 | limits.hard_limit_chars = raised; |
| 443 | limits.snippet_chars = (raised / 3).max(limits.snippet_chars); |
| 444 | limits.noisy_soft_limit_chars = limits.noisy_soft_limit_chars.max(raised / 6); |
| 445 | } |
| 446 | limits |
| 447 | } |
| 448 | |
| 449 | #[cfg(test)] |
| 450 | pub(crate) fn compact_tool_result_for_context( |
| 451 | model: &str, |
| 452 | tool_name: &str, |
| 453 | output: &ToolResult, |
| 454 | ) -> String { |
| 455 | compact_tool_result_for_route(ApiProvider::Deepseek, model, None, tool_name, output) |
| 456 | } |
| 457 | |
| 458 | pub(crate) fn compact_tool_result_for_route( |
| 459 | provider: ApiProvider, |
| 460 | model: &str, |
| 461 | route_limits: Option<RouteLimits>, |
| 462 | tool_name: &str, |
| 463 | output: &ToolResult, |
| 464 | ) -> String { |
| 465 | let raw = output.content.trim(); |
| 466 | if raw.is_empty() { |
| 467 | return String::new(); |
| 468 | } |
| 469 | |
| 470 | // A result already bounded by the adaptive evidence envelope is an |
| 471 | // honest, context-sized preview whose footer names the artifact path and |
| 472 | // a recovery instruction. Re-compacting it would strip that recovery |
| 473 | // contract and double-truncate the output, so pass it through unchanged. |
| 474 | if output |
| 475 | .metadata |
| 476 | .as_ref() |
| 477 | .and_then(|metadata| metadata.get("evidence_available")) |
| 478 | .and_then(serde_json::Value::as_bool) |
| 479 | .unwrap_or(false) |
| 480 | { |
| 481 | return raw.to_string(); |
| 482 | } |
| 483 | |
| 484 | // The `read` primitive already bounds itself to an explicit per-call byte |
| 485 | // budget and, when that budget truncates the file, ends with a footer |
| 486 | // naming the exact offset to continue from. Compacting it a second time |
| 487 | // would drop content the caller deliberately budgeted for *and* delete the |
| 488 | // continuation contract, leaving the model with a head/tail snippet and no |
| 489 | // way to page. A result that stayed inside its declared budget therefore |
| 490 | // passes through; one that somehow exceeded it still falls through to the |
| 491 | // ordinary limits below. |
| 492 | if output |
| 493 | .metadata |
| 494 | .as_ref() |
| 495 | .and_then(|metadata| metadata.get("read_budget_bytes")) |
| 496 | .and_then(serde_json::Value::as_u64) |
| 497 | .is_some_and(|budget| raw.len() as u64 <= budget) |
| 498 | { |
| 499 | return raw.to_string(); |
| 500 | } |
| 501 | |
| 502 | if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) { |
| 503 | return summary; |
| 504 | } |
| 505 | |
| 506 | if let Some(summary) = compact_structured_tool_result_for_context(tool_name, raw) { |
| 507 | return summary; |
| 508 | } |
| 509 | |
| 510 | let context_window = |
| 511 | crate::route_budget::route_context_window_tokens(provider, model, route_limits); |
| 512 | let limits = tool_result_context_limits_for_window(context_window); |
| 513 | let raw_chars = raw.chars().count(); |
| 514 | let should_compact = raw_chars > limits.hard_limit_chars |
| 515 | || (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars); |
| 516 | if !should_compact { |
| 517 | return raw.to_string(); |
| 518 | } |
| 519 | |
| 520 | let snippet = summarize_text_head_tail(raw, limits.snippet_chars); |
| 521 | let omitted = raw_chars.saturating_sub(snippet.chars().count()); |
| 522 | let summary = tool_result_metadata_summary(output.metadata.as_ref()); |
| 523 | |
| 524 | if let Some(summary) = summary { |
| 525 | format!( |
| 526 | "[{tool_name} output compacted to protect context]\nSummary: {summary}\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)" |
| 527 | ) |
| 528 | } else { |
| 529 | format!( |
| 530 | "[{tool_name} output compacted to protect context]\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)" |
| 531 | ) |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | pub(super) fn extract_compaction_summary_prompt( |
| 536 | prompt: Option<SystemPrompt>, |
| 537 | ) -> Option<SystemPrompt> { |
| 538 | crate::compaction::extract_compaction_summary(prompt.as_ref()) |
| 539 | } |
| 540 | |
| 541 | /// Internal input-side token budget for a provider/model route: |
| 542 | /// `window - reserved_output - headroom`. Used by the preflight check, |
| 543 | /// emergency recovery, and capacity trimming to decide when to compact. |
| 544 | /// Unknown model ids fall back to the provider's conservative default instead |
| 545 | /// of disabling preflight; custom long-context deployments can still advertise |
| 546 | /// their window with a `-256k`/`-1024k` model suffix. |
| 547 | /// |
| 548 | /// The reserved-output term is the route-effective request cap: exactly what |
| 549 | /// the API can receive after explicit overrides, compatibility/route ceilings, |
| 550 | /// and the route window are intersected. A second hidden reasoning reserve |
| 551 | /// would make preflight disagree with the wire request and can cause premature |
| 552 | /// compaction on otherwise valid large-window inputs. |
| 553 | #[cfg(test)] |
| 554 | pub(super) fn context_input_budget_for_provider( |
| 555 | provider: ApiProvider, |
| 556 | model: &str, |
| 557 | ) -> Option<usize> { |
| 558 | context_input_budget_for_route(provider, model, None, 0) |
| 559 | } |
| 560 | |
| 561 | /// Public so external callers (e.g. a host/bridge deriving its own compaction |
| 562 | /// trigger line) can reuse the *exact* same internal input-budget math — window |
| 563 | /// minus the route-effective output reservation |
| 564 | /// (`route_output_reservation`) minus headroom — |
| 565 | /// instead of re-deriving those constants and silently drifting from the engine. |
| 566 | /// Pass `input_tokens = 0` to get the full emergency input budget for the route. |
| 567 | pub fn context_input_budget_for_route( |
| 568 | provider: ApiProvider, |
| 569 | model: &str, |
| 570 | route_limits: Option<RouteLimits>, |
| 571 | input_tokens: usize, |
| 572 | ) -> Option<usize> { |
| 573 | route_context_budget_for_route(provider, model, route_limits, input_tokens) |
| 574 | .and_then(|budget| usize::try_from(budget.available_input_tokens).ok()) |
| 575 | } |
| 576 | |
| 577 | #[cfg(test)] |
| 578 | pub(super) fn route_context_budget_for_provider( |
| 579 | provider: ApiProvider, |
| 580 | model: &str, |
| 581 | input_tokens: usize, |
| 582 | ) -> Option<ContextBudget> { |
| 583 | route_context_budget_for_route(provider, model, None, input_tokens) |
| 584 | } |
| 585 | |
| 586 | pub(super) fn route_context_budget_for_route( |
| 587 | provider: ApiProvider, |
| 588 | model: &str, |
| 589 | route_limits: Option<RouteLimits>, |
| 590 | input_tokens: usize, |
| 591 | ) -> Option<ContextBudget> { |
| 592 | crate::route_budget::route_context_budget(provider, model, route_limits, input_tokens) |
| 593 | } |
| 594 | |
| 595 | pub(super) fn is_context_length_error_message(message: &str) -> bool { |
| 596 | // Only genuine context-length rejections may drive the bounded |
| 597 | // context-recovery retry. The broader `InvalidInput` bucket also holds |
| 598 | // wrong-model rejections ("Model not exist."), malformed requests, and |
| 599 | // truncated-output terminations, where re-sending a compacted history |
| 600 | // cannot help and would hide the real error. |
| 601 | let lower = message.to_lowercase(); |
| 602 | lower.contains("model output truncated") |
| 603 | || lower.contains("model response incomplete") |
| 604 | || lower.contains("maximum context length") |
| 605 | || lower.contains("context length") |
| 606 | || lower.contains("context_length") |
| 607 | || lower.contains("prompt is too long") |
| 608 | || lower.contains("context window") |
| 609 | || (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum")) |
| 610 | } |
| 611 | |
| 612 | pub(super) fn is_image_input_rejection_message(message: &str) -> bool { |
| 613 | let lower = message.to_lowercase(); |
| 614 | let image_signal = lower.contains("image_url") |
| 615 | || lower.contains("content.type") |
| 616 | || lower.contains("content type") |
| 617 | || lower.contains("does not support image") |
| 618 | || lower.contains("image input") |
| 619 | || lower.contains("unsupported modality") |
| 620 | || lower |
| 621 | .split(|character: char| !character.is_alphanumeric()) |
| 622 | .any(|term| term == "vision"); |
| 623 | let rejection_signal = lower.contains("400") |
| 624 | || lower.contains("invalid") |
| 625 | || lower.contains("unsupported") |
| 626 | || lower.contains("not support"); |
| 627 | image_signal && rejection_signal |
| 628 | } |
| 629 | |
| 630 | #[cfg(test)] |
| 631 | mod tests { |
| 632 | use super::is_image_input_rejection_message; |
| 633 | |
| 634 | #[test] |
| 635 | fn image_rejection_classifier_matches_provider_400s() { |
| 636 | assert!(is_image_input_rejection_message( |
| 637 | r#"request (400): {"error":{"code":"1214","message":"messages.content.type 参数非法, 取值范围 ['text']"}}"# |
| 638 | )); |
| 639 | assert!(is_image_input_rejection_message( |
| 640 | "Invalid content type. image_url is only supported by certain models." |
| 641 | )); |
| 642 | assert!(!is_image_input_rejection_message("Model not exist.")); |
| 643 | assert!(!is_image_input_rejection_message("invalid revision id")); |
| 644 | assert!(!is_image_input_rejection_message( |
| 645 | "This model's maximum context length is 131072 tokens." |
| 646 | )); |
| 647 | } |
| 648 | } |
| 649 |