| 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 |