| 1 | //! Shared route-sized inline output with recoverable session spillover. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | |
| 5 | use crate::tools::spec::{ToolContext, ToolError}; |
| 6 | |
| 7 | #[derive(Debug)] |
| 8 | pub(crate) struct OverflowArtifact { |
| 9 | pub(crate) session_id: String, |
| 10 | pub(crate) absolute_path: PathBuf, |
| 11 | pub(crate) relative_path: PathBuf, |
| 12 | pub(crate) byte_size: u64, |
| 13 | pub(crate) preview: String, |
| 14 | } |
| 15 | |
| 16 | #[derive(Debug)] |
| 17 | pub(crate) struct BoundedText { |
| 18 | pub(crate) content: String, |
| 19 | pub(crate) artifact: Option<OverflowArtifact>, |
| 20 | } |
| 21 | |
| 22 | pub(crate) fn inline_char_budget(context: &ToolContext) -> usize { |
| 23 | context |
| 24 | .route_context_window |
| 25 | .map(|tokens| { |
| 26 | let chars = u64::from(tokens).saturating_mul(4).saturating_mul(3) / 100; |
| 27 | usize::try_from(chars).unwrap_or(100_000) |
| 28 | }) |
| 29 | .unwrap_or(100_000) |
| 30 | .clamp(1, 100_000) |
| 31 | } |
| 32 | |
| 33 | pub(crate) fn bound_text<F>( |
| 34 | content: String, |
| 35 | context: &ToolContext, |
| 36 | artifact_id: F, |
| 37 | subject: &str, |
| 38 | ) -> Result<BoundedText, ToolError> |
| 39 | where |
| 40 | F: FnOnce(&str) -> String, |
| 41 | { |
| 42 | let budget = inline_char_budget(context); |
| 43 | if content.chars().count() <= budget { |
| 44 | return Ok(BoundedText { |
| 45 | content, |
| 46 | artifact: None, |
| 47 | }); |
| 48 | } |
| 49 | |
| 50 | let artifact_id = artifact_id(&content); |
| 51 | let (absolute_path, relative_path) = |
| 52 | crate::artifacts::write_session_artifact(&context.state_namespace, &artifact_id, &content) |
| 53 | .map_err(|error| { |
| 54 | ToolError::execution_failed(format!( |
| 55 | "failed to preserve {subject} content artifact: {error}" |
| 56 | )) |
| 57 | })?; |
| 58 | let relative = crate::artifacts::format_artifact_relative_path(&relative_path); |
| 59 | let mut head = content |
| 60 | .chars() |
| 61 | .take(budget.saturating_sub(256)) |
| 62 | .collect::<String>(); |
| 63 | let mut footer = overflow_footer(subject, &relative, head.len(), content.len()); |
| 64 | let allowed_head = budget.saturating_sub(footer.chars().count()); |
| 65 | head = content.chars().take(allowed_head).collect(); |
| 66 | footer = overflow_footer(subject, &relative, head.len(), content.len()); |
| 67 | while !head.is_empty() && head.chars().count() + footer.chars().count() > budget { |
| 68 | head.pop(); |
| 69 | footer = overflow_footer(subject, &relative, head.len(), content.len()); |
| 70 | } |
| 71 | let preview = content.chars().take(200).collect(); |
| 72 | |
| 73 | Ok(BoundedText { |
| 74 | content: format!("{head}{footer}"), |
| 75 | artifact: Some(OverflowArtifact { |
| 76 | session_id: context.state_namespace.clone(), |
| 77 | absolute_path, |
| 78 | relative_path, |
| 79 | byte_size: content.len() as u64, |
| 80 | preview, |
| 81 | }), |
| 82 | }) |
| 83 | } |
| 84 | |
| 85 | fn overflow_footer(subject: &str, relative: &str, head_bytes: usize, total_bytes: usize) -> String { |
| 86 | format!( |
| 87 | "\n\n[Content overflow: first {head_bytes} of {total_bytes} bytes shown; full {subject} saved to {relative}. Recovery: call retrieve_tool_result with ref={relative}.]" |
| 88 | ) |
| 89 | } |
| 90 |