Skip to main content

llm_tool/types/truncation/
policy.rs

1//! Truncation policy definition and core middle-out execution logic.
2
3use alloc::{
4    borrow::Cow,
5    format,
6    string::{String, ToString},
7    sync::Arc,
8};
9
10use super::{
11    alignment::{MAX_LINE_SCAN_WINDOW, TruncationAlignment},
12    outcome::TruncationOutcome,
13    placeholder::MarkerPlaceholder,
14    ratio::HeadRatio,
15    sink::{OutputSpillSink, SpillContext, SpillDisposition, SpillError, SpillRef},
16};
17use crate::types::{
18    ToolOutput,
19    quarantine::{
20        CLOSE_FRAME, FRAME_OVERHEAD, OPEN_FRAME, TOOL_OUTPUT_QUARANTINE_TAG,
21        unquarantine_tool_output,
22    },
23};
24
25/// Prefix of the in-band warning emitted when a configured [`OutputSpillSink`] fails.
26pub const SPILL_FAILURE_NOTICE_PREFIX: &str = " WARNING: full output could NOT be retained (";
27
28/// Suffix of the in-band warning emitted when a configured [`OutputSpillSink`] fails.
29pub const SPILL_FAILURE_NOTICE_SUFFIX: &str = "); the omitted bytes are unrecoverable.";
30
31/// Marker used when `max_bytes` is too small to hold the fully informative marker.
32///
33/// Truncation is never allowed to be silent, so content is sacrificed before the
34/// notice is: a reader must always be able to tell that bytes are missing.
35pub const MINIMAL_TRUNCATION_MARKER: &str = "\n[TRUNCATED: bytes omitted]\n";
36
37/// Minimal marker variant used when a configured [`OutputSpillSink`] failed.
38pub const MINIMAL_TRUNCATION_MARKER_SPILL_FAILED: &str =
39    "\n[TRUNCATED: bytes omitted; NOT retained]\n";
40
41/// Compact single-line truncation notice used inside `<tool_output_quarantine>` when
42/// the inner budget is smaller than [`MINIMAL_TRUNCATION_MARKER`].
43const COMPACT_TRUNCATION_MARKER: &str = "[TRUNCATED: bytes omitted]";
44
45/// Compact single-line spill-failed truncation notice used inside `<tool_output_quarantine>`.
46const COMPACT_TRUNCATION_MARKER_SPILL_FAILED: &str = "[TRUNCATED: bytes omitted; NOT retained]";
47
48/// Render the human- and model-readable notice describing a failed spill.
49fn spill_failure_notice(error: &SpillError) -> String {
50    format!("{SPILL_FAILURE_NOTICE_PREFIX}{error}{SPILL_FAILURE_NOTICE_SUFFIX}")
51}
52
53/// Configuration policy for middle-out string truncation.
54#[derive(Debug, Clone)]
55pub struct TruncationPolicy {
56    /// Maximum allowed bytes in the truncated output.
57    pub max_bytes: usize,
58    /// Ratio of available content budget assigned to the head portion.
59    /// Default is 50/50 balance between head and tail.
60    pub head_ratio: HeadRatio,
61    /// Alignment boundary strategy.
62    pub alignment: TruncationAlignment,
63    /// Optional custom marker template.
64    pub marker_template: Option<String>,
65    /// Optional spill sink for retaining full un-truncated observations.
66    pub spill_sink: Option<Arc<dyn OutputSpillSink>>,
67}
68
69impl PartialEq for TruncationPolicy {
70    fn eq(&self, other: &Self) -> bool {
71        self.max_bytes == other.max_bytes
72            && self.head_ratio == other.head_ratio
73            && self.alignment == other.alignment
74            && self.marker_template == other.marker_template
75            && match (&self.spill_sink, &other.spill_sink) {
76                (None, None) => true,
77                (Some(a), Some(b)) => Arc::ptr_eq(a, b),
78                _ => false,
79            }
80    }
81}
82
83impl Eq for TruncationPolicy {}
84
85impl TruncationPolicy {
86    /// Create a new truncation policy with a specified `max_bytes` ceiling,
87    /// balanced 50/50 head-tail ratio, and character boundary alignment.
88    #[must_use]
89    pub const fn new(max_bytes: usize) -> Self {
90        Self {
91            max_bytes,
92            head_ratio: HeadRatio::BALANCED,
93            alignment: TruncationAlignment::CharBoundary,
94            marker_template: None,
95            spill_sink: None,
96        }
97    }
98
99    /// Set the head budget ratio.
100    #[must_use]
101    pub fn with_head_ratio(mut self, ratio: impl Into<HeadRatio>) -> Self {
102        self.head_ratio = ratio.into();
103        self
104    }
105
106    /// Set the boundary alignment strategy.
107    #[must_use]
108    pub const fn with_alignment(mut self, alignment: TruncationAlignment) -> Self {
109        self.alignment = alignment;
110        self
111    }
112
113    /// Set a custom marker template.
114    ///
115    /// Placeholders `{omitted_bytes}`, `{original_bytes}`, `{offset_start}`,
116    /// `{offset_end}`, and `{ref}` will be substituted. Legacy aliases `{count}`,
117    /// `{bytes}`, and `<N>` are also supported.
118    #[must_use]
119    pub fn with_marker(mut self, marker: impl Into<String>) -> Self {
120        self.marker_template = Some(marker.into());
121        self
122    }
123
124    /// Set a spill sink for retaining un-truncated observations.
125    #[must_use]
126    pub fn with_spill_sink(mut self, sink: Arc<dyn OutputSpillSink>) -> Self {
127        self.spill_sink = Some(sink);
128        self
129    }
130
131    /// Format the marker string for the given omitted bytes without sink reference.
132    ///
133    /// Preserved for backwards compatibility with existing callers.
134    #[must_use]
135    pub fn format_marker(&self, omitted_bytes: usize) -> String {
136        self.format_marker_detailed(
137            omitted_bytes,
138            omitted_bytes,
139            0,
140            omitted_bytes,
141            &SpillDisposition::NotConfigured,
142        )
143    }
144
145    /// Format marker with full metadata substitutions including offsets and spill disposition.
146    ///
147    /// When `spill` is [`SpillDisposition::Failed`], the rendered marker always carries
148    /// an in-band warning that the omitted bytes are unrecoverable — including for custom
149    /// templates that do not mention the failure themselves — so retention loss can never
150    /// pass unnoticed by the model reading the output.
151    #[must_use]
152    pub fn format_marker_detailed(
153        &self,
154        omitted_bytes: usize,
155        original_bytes: usize,
156        offset_start: usize,
157        offset_end: usize,
158        spill: &SpillDisposition,
159    ) -> String {
160        let omitted_str = omitted_bytes.to_string();
161        let mut marker = self.marker_template.as_ref().map_or_else(
162            || match spill {
163                SpillDisposition::Spilled(handle) => format!(
164                    "\n[TRUNCATED: {omitted_str} bytes omitted (bytes {offset_start}..{offset_end} of {original_bytes}). Full output retained as ref=\"{handle}\" — inspect with read_observation(ref, pattern, offset, length)]\n"
165                ),
166                SpillDisposition::Failed(error) => format!(
167                    "\n[TRUNCATED: {omitted_str} bytes omitted (bytes {offset_start}..{offset_end} of {original_bytes}).{notice}]\n",
168                    notice = spill_failure_notice(error)
169                ),
170                SpillDisposition::NotConfigured => {
171                    format!("\n[TRUNCATED: {omitted_str} bytes omitted]\n")
172                }
173            },
174            |template| {
175                let orig_str = original_bytes.to_string();
176                let start_str = offset_start.to_string();
177                let end_str = offset_end.to_string();
178                let ref_str = spill.spill_ref().map_or("", SpillRef::as_str);
179
180                template
181                    .replace(MarkerPlaceholder::TOKEN_OMITTED_BYTES, &omitted_str)
182                    .replace(MarkerPlaceholder::TOKEN_ORIGINAL_BYTES, &orig_str)
183                    .replace(MarkerPlaceholder::TOKEN_OFFSET_START, &start_str)
184                    .replace(MarkerPlaceholder::TOKEN_OFFSET_END, &end_str)
185                    .replace(MarkerPlaceholder::TOKEN_SPILL_REF, ref_str)
186                    .replace(MarkerPlaceholder::DEPRECATED_COUNT, &omitted_str)
187                    .replace(MarkerPlaceholder::DEPRECATED_BYTES, &omitted_str)
188                    .replace(MarkerPlaceholder::DEPRECATED_N, &omitted_str)
189            },
190        );
191
192        // A custom template cannot be trusted to mention the failure, so append it.
193        if let Some(error) = spill.error()
194            && self.marker_template.is_some()
195        {
196            marker.push_str(&spill_failure_notice(error));
197        }
198
199        marker
200    }
201
202    /// Truncate `input` using this policy, returning a borrowed `Cow` when no truncation
203    /// is needed, or an owned string when middle-out truncation occurs.
204    ///
205    /// If `input` is a verified `<tool_output_quarantine>` envelope, the outer XML
206    /// quarantine tags are preserved and only the inner payload is truncated. If `input`
207    /// was already sanitized (contains no raw control tokens or breakout tags), any
208    /// split token formed across the head/marker/tail cut boundary is neutralized.
209    ///
210    /// If a configured [`OutputSpillSink`] fails, this method does **not** fail: the
211    /// error is logged and rendered into the truncation marker, so the loss of the
212    /// omitted bytes is visible to both the operator and the model. Callers that need
213    /// to handle spill failure programmatically should use
214    /// [`TruncationPolicy::truncate_detailed`], which returns the [`SpillError`].
215    #[must_use]
216    pub fn truncate<'a>(&self, input: &'a str) -> Cow<'a, str> {
217        self.truncate_lossy_with_context(input, &SpillContext::ANONYMOUS)
218            .text
219    }
220
221    /// Sanitize and truncate `input` in a single pass, neutralizing all known LLM
222    /// control tokens ([`DELIMITER_RULES`](crate::DELIMITER_RULES)) and breakout tags
223    /// (including any split tokens formed across the head/marker/tail cut boundary)
224    /// while ensuring the final sanitized result fits within `self.max_bytes`.
225    #[must_use]
226    pub fn truncate_sanitized<'a>(&self, input: &'a str) -> Cow<'a, str> {
227        self.truncate_sanitized_lossy_with_context(input, &SpillContext::ANONYMOUS)
228            .text
229    }
230
231    /// Quarantine and truncate `input`, neutralizing all known LLM control tokens and
232    /// wrapping the output in `<tool_output_quarantine>` tags while fitting the total
233    /// envelope inside `self.max_bytes` (when `self.max_bytes >= 70`).
234    #[must_use]
235    pub fn truncate_quarantined(&self, input: &str) -> String {
236        self.truncate_quarantined_lossy_with_context(input, &SpillContext::ANONYMOUS)
237            .text
238            .into_owned()
239    }
240
241    /// Infallible truncation that degrades visibly — rather than failing — when a
242    /// configured [`OutputSpillSink`] returns an error.
243    #[must_use]
244    pub fn truncate_lossy_with_context<'a>(
245        &self,
246        input: &'a str,
247        ctx: &SpillContext<'_>,
248    ) -> TruncationOutcome<'a> {
249        if let Some(outcome) = Self::passthrough(input, self.max_bytes) {
250            return outcome;
251        }
252
253        let spill = self.resolve_spill_logged(ctx, input);
254        self.truncate_oversized_auto(input, &spill)
255    }
256
257    /// Infallible sanitize + truncate with caller-provided [`SpillContext`].
258    #[must_use]
259    pub fn truncate_sanitized_lossy_with_context<'a>(
260        &self,
261        input: &'a str,
262        ctx: &SpillContext<'_>,
263    ) -> TruncationOutcome<'a> {
264        if unquarantine_tool_output(input).is_some() {
265            return self.truncate_lossy_with_context(input, ctx);
266        }
267
268        if input.len() <= self.max_bytes {
269            let sanitized = md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
270            if sanitized.len() <= self.max_bytes {
271                return TruncationOutcome {
272                    text: sanitized,
273                    original_len: input.len(),
274                    omitted: None,
275                    spill_ref: None,
276                };
277            }
278        }
279
280        let spill = self.resolve_spill_logged(ctx, input);
281        self.truncate_oversized_sanitized(input, &spill)
282    }
283
284    /// Infallible quarantine + truncate with caller-provided [`SpillContext`].
285    #[must_use]
286    pub fn truncate_quarantined_lossy_with_context<'a>(
287        &self,
288        input: &'a str,
289        ctx: &SpillContext<'_>,
290    ) -> TruncationOutcome<'a> {
291        if unquarantine_tool_output(input).is_some() {
292            return self.truncate_lossy_with_context(input, ctx);
293        }
294
295        let inner_budget = self.max_bytes.saturating_sub(FRAME_OVERHEAD);
296        if input.len() <= inner_budget && self.max_bytes >= FRAME_OVERHEAD {
297            let sanitized = md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
298            if sanitized.len() <= inner_budget {
299                return TruncationOutcome {
300                    text: Cow::Owned(format!("{OPEN_FRAME}{sanitized}{CLOSE_FRAME}")),
301                    original_len: input.len(),
302                    omitted: None,
303                    spill_ref: None,
304                };
305            }
306        }
307
308        let spill = self.resolve_spill_logged(ctx, input);
309        self.truncate_and_wrap_quarantine(input, input.len(), 0, &spill)
310    }
311
312    /// Truncate `input` using an anonymous spill context, returning a detailed [`TruncationOutcome`].
313    ///
314    /// # Errors
315    ///
316    /// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
317    pub fn truncate_detailed<'a>(
318        &self,
319        input: &'a str,
320    ) -> Result<TruncationOutcome<'a>, SpillError> {
321        self.truncate_detailed_with_context(input, &SpillContext::ANONYMOUS)
322    }
323
324    /// Truncate `input` with caller-provided [`SpillContext`], returning a detailed [`TruncationOutcome`].
325    ///
326    /// # Errors
327    ///
328    /// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
329    pub fn truncate_detailed_with_context<'a>(
330        &self,
331        input: &'a str,
332        ctx: &SpillContext<'_>,
333    ) -> Result<TruncationOutcome<'a>, SpillError> {
334        Self::passthrough(input, self.max_bytes).map_or_else(
335            || match self.resolve_spill(ctx, input) {
336                SpillDisposition::Failed(error) => Err(error),
337                spill => Ok(self.truncate_oversized_auto(input, &spill)),
338            },
339            Ok,
340        )
341    }
342
343    /// Return the untouched input when it already fits within `max_bytes`.
344    ///
345    /// Checked before any spill is attempted: content that is not truncated loses
346    /// nothing, so it must not consume observation-store capacity.
347    fn passthrough(input: &str, max_bytes: usize) -> Option<TruncationOutcome<'_>> {
348        let total_bytes = input.len();
349        (total_bytes <= max_bytes).then_some(TruncationOutcome {
350            text: Cow::Borrowed(input),
351            original_len: total_bytes,
352            omitted: None,
353            spill_ref: None,
354        })
355    }
356
357    /// Attempt to retain the full output, classifying the result.
358    fn resolve_spill(&self, ctx: &SpillContext<'_>, input: &str) -> SpillDisposition {
359        self.spill_sink
360            .as_ref()
361            .map_or(SpillDisposition::NotConfigured, |sink| {
362                match sink.spill(ctx, input) {
363                    Ok(handle) => SpillDisposition::Spilled(handle),
364                    Err(error) => SpillDisposition::Failed(error),
365                }
366            })
367    }
368
369    fn resolve_spill_logged(&self, ctx: &SpillContext<'_>, input: &str) -> SpillDisposition {
370        let spill = self.resolve_spill(ctx, input);
371        if let Some(error) = spill.error() {
372            tracing::error!(
373                error = %error,
374                tool = ctx.tool_name,
375                "spill sink failed; omitted bytes are unrecoverable and the truncation marker will say so"
376            );
377        }
378        spill
379    }
380
381    /// Dispatch oversized truncation while preserving quarantine envelopes and
382    /// sanitization invariants if `input` was already quarantined or sanitized.
383    fn truncate_oversized_auto<'a>(
384        &self,
385        input: &'a str,
386        spill: &SpillDisposition,
387    ) -> TruncationOutcome<'a> {
388        if let Some(inner) = unquarantine_tool_output(input) {
389            return self.truncate_and_wrap_quarantine(inner, input.len(), OPEN_FRAME.len(), spill);
390        }
391        if !md_tmpl_core::has_untrusted_breakout(input, TOOL_OUTPUT_QUARANTINE_TAG) {
392            return self.truncate_oversized_sanitized(input, spill);
393        }
394        self.truncate_oversized(input, spill)
395    }
396
397    /// Truncate `inner` and wrap the result in `<tool_output_quarantine>\n...\n</tool_output_quarantine>`,
398    /// guaranteeing that the quarantine envelope is never broken even under tiny `max_bytes`.
399    fn truncate_and_wrap_quarantine<'a>(
400        &self,
401        inner: &str,
402        outer_original_len: usize,
403        offset_bias: usize,
404        spill: &SpillDisposition,
405    ) -> TruncationOutcome<'a> {
406        let inner_len = inner.len();
407        let spill_ref = spill.spill_ref().cloned();
408        let inner_budget = self.max_bytes.saturating_sub(FRAME_OVERHEAD);
409
410        if inner_budget < MINIMAL_TRUNCATION_MARKER.len() {
411            let notice = match spill {
412                SpillDisposition::Failed(_) => COMPACT_TRUNCATION_MARKER_SPILL_FAILED,
413                SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
414                    COMPACT_TRUNCATION_MARKER
415                }
416            };
417            let mut head_end = inner_budget.saturating_sub(notice.len()).min(inner_len);
418            while head_end > 0 && !inner.is_char_boundary(head_end) {
419                head_end -= 1;
420            }
421            let mut body = String::with_capacity(head_end + notice.len());
422            body.push_str(&inner[..head_end]);
423            body.push_str(notice);
424            let sanitized_body =
425                md_tmpl_core::sanitize_untrusted_str(&body, TOOL_OUTPUT_QUARANTINE_TAG);
426            let final_body = if head_end > 0 && sanitized_body.len() > inner_budget {
427                notice
428            } else {
429                sanitized_body.as_ref()
430            };
431            return TruncationOutcome {
432                text: Cow::Owned(format!("{OPEN_FRAME}{final_body}{CLOSE_FRAME}")),
433                original_len: outer_original_len,
434                omitted: Some(offset_bias..(offset_bias + inner_len)),
435                spill_ref,
436            };
437        }
438
439        let inner_policy = Self {
440            max_bytes: inner_budget,
441            head_ratio: self.head_ratio,
442            alignment: self.alignment,
443            marker_template: self.marker_template.clone(),
444            spill_sink: None,
445        };
446        let inner_outcome = inner_policy.truncate_oversized_sanitized(inner, spill);
447        let omitted = inner_outcome
448            .omitted
449            .map(|r| (r.start + offset_bias)..(r.end + offset_bias))
450            .or(Some(offset_bias..(offset_bias + inner_len)));
451
452        TruncationOutcome {
453            text: Cow::Owned(format!("{OPEN_FRAME}{}{CLOSE_FRAME}", inner_outcome.text)),
454            original_len: outer_original_len,
455            omitted,
456            spill_ref,
457        }
458    }
459
460    /// Perform middle-out elision on `input` while neutralizing any control tokens
461    /// or breakout tags in the surviving head, marker, tail, or across their splice
462    /// boundary, converging `head_end` and `tail_start` so the sanitized string fits
463    /// within `self.max_bytes`.
464    fn truncate_oversized_sanitized<'a>(
465        &self,
466        input: &'a str,
467        spill: &SpillDisposition,
468    ) -> TruncationOutcome<'a> {
469        let total_bytes = input.len();
470        let spill_ref = spill.spill_ref().cloned();
471
472        if self.max_bytes == 0 {
473            return TruncationOutcome {
474                text: Cow::Borrowed(""),
475                original_len: total_bytes,
476                omitted: Some(0..total_bytes),
477                spill_ref,
478            };
479        }
480
481        let est_omitted = total_bytes.saturating_sub(self.max_bytes);
482        let sample_marker =
483            self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
484        let sample_marker_sanitized =
485            md_tmpl_core::sanitize_untrusted_str(&sample_marker, TOOL_OUTPUT_QUARANTINE_TAG);
486        let marker_len = sample_marker_sanitized.len();
487
488        if self.max_bytes <= marker_len {
489            return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
490        }
491
492        let content_budget = self.max_bytes.saturating_sub(marker_len);
493        let (mut head_end, mut tail_start) =
494            self.compute_initial_boundaries(input, total_bytes, content_budget);
495
496        if head_end >= tail_start {
497            let sanitized_full =
498                md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
499            if sanitized_full.len() <= self.max_bytes {
500                return TruncationOutcome {
501                    text: sanitized_full,
502                    original_len: total_bytes,
503                    omitted: None,
504                    spill_ref,
505                };
506            }
507            let mut mid = total_bytes / 2;
508            while mid > 0 && !input.is_char_boundary(mid) {
509                mid -= 1;
510            }
511            head_end = mid;
512            tail_start = mid;
513        }
514
515        let (mut head_end, mut tail_start, mut marker, _) =
516            self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
517
518        loop {
519            let mut candidate =
520                String::with_capacity(head_end + marker.len() + (total_bytes - tail_start));
521            candidate.push_str(&input[..head_end]);
522            candidate.push_str(&marker);
523            candidate.push_str(&input[tail_start..]);
524
525            let sanitized =
526                md_tmpl_core::sanitize_untrusted_str(&candidate, TOOL_OUTPUT_QUARANTINE_TAG);
527            if sanitized.len() <= self.max_bytes {
528                return TruncationOutcome {
529                    text: Cow::Owned(sanitized.into_owned()),
530                    original_len: total_bytes,
531                    omitted: Some(head_end..tail_start),
532                    spill_ref,
533                };
534            }
535
536            if head_end == 0 && tail_start >= total_bytes {
537                return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
538            }
539
540            if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
541            {
542                head_end = self.shrink_head_boundary(input, head_end);
543            } else if tail_start < total_bytes {
544                tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
545            } else {
546                return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
547            }
548
549            let omitted_bytes = tail_start.saturating_sub(head_end);
550            marker = self.format_marker_detailed(
551                omitted_bytes,
552                total_bytes,
553                head_end,
554                tail_start,
555                spill,
556            );
557        }
558    }
559
560    /// Perform middle-out elision on input already known to exceed `max_bytes`,
561    /// with the spill attempt already resolved.
562    fn truncate_oversized<'a>(
563        &self,
564        input: &'a str,
565        spill: &SpillDisposition,
566    ) -> TruncationOutcome<'a> {
567        let total_bytes = input.len();
568        let spill_ref = spill.spill_ref().cloned();
569
570        if self.max_bytes == 0 {
571            return TruncationOutcome {
572                text: Cow::Borrowed(""),
573                original_len: total_bytes,
574                omitted: Some(0..total_bytes),
575                spill_ref,
576            };
577        }
578
579        // Estimate marker length using approximate omitted byte count.
580        let est_omitted = total_bytes.saturating_sub(self.max_bytes);
581        let sample_marker =
582            self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
583        let marker_len = sample_marker.len();
584
585        if self.max_bytes <= marker_len {
586            return self.truncate_with_fallback_marker(input, spill, spill_ref);
587        }
588
589        let content_budget = self.max_bytes.saturating_sub(marker_len);
590        let (head_end, tail_start) =
591            self.compute_initial_boundaries(input, total_bytes, content_budget);
592
593        // If boundaries overlap or touch, no truncation is necessary
594        if head_end >= tail_start {
595            return TruncationOutcome {
596                text: Cow::Borrowed(input),
597                original_len: total_bytes,
598                omitted: None,
599                spill_ref,
600            };
601        }
602
603        let (head_end, tail_start, marker, current_total) =
604            self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
605
606        let mut output = String::with_capacity(current_total);
607        output.push_str(&input[..head_end]);
608        output.push_str(&marker);
609        output.push_str(&input[tail_start..]);
610
611        if output.len() > self.max_bytes {
612            let mut cut = self.max_bytes;
613            while cut > 0 && !output.is_char_boundary(cut) {
614                cut -= 1;
615            }
616            output.truncate(cut);
617        }
618
619        TruncationOutcome {
620            text: Cow::Owned(output),
621            original_len: total_bytes,
622            omitted: Some(head_end..tail_start),
623            spill_ref,
624        }
625    }
626
627    /// Choose the most informative marker that fits inside `max_bytes`.
628    fn fallback_marker(spill: &SpillDisposition, max_bytes: usize) -> String {
629        if let SpillDisposition::Spilled(handle) = spill {
630            let with_ref = format!("\n[TRUNCATED: bytes omitted; ref=\"{handle}\"]\n");
631            if with_ref.len() <= max_bytes {
632                return with_ref;
633            }
634        }
635        match spill {
636            SpillDisposition::Failed(_) => MINIMAL_TRUNCATION_MARKER_SPILL_FAILED.into(),
637            SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
638                MINIMAL_TRUNCATION_MARKER.into()
639            }
640        }
641    }
642
643    fn truncate_with_fallback_marker_sanitized<'a>(
644        &self,
645        input: &str,
646        spill: &SpillDisposition,
647        spill_ref: Option<SpillRef>,
648    ) -> TruncationOutcome<'a> {
649        let total_bytes = input.len();
650        let raw_marker = Self::fallback_marker(spill, self.max_bytes);
651        let marker = md_tmpl_core::sanitize_untrusted_str(&raw_marker, TOOL_OUTPUT_QUARANTINE_TAG);
652
653        if marker.len() >= self.max_bytes {
654            let mut cut = self.max_bytes;
655            while cut > 0 && !marker.is_char_boundary(cut) {
656                cut -= 1;
657            }
658            let slice = &marker[..cut];
659            let clean = md_tmpl_core::sanitize_untrusted_str(slice, TOOL_OUTPUT_QUARANTINE_TAG);
660            return TruncationOutcome {
661                text: Cow::Owned(clean.into_owned()),
662                original_len: total_bytes,
663                omitted: Some(0..total_bytes),
664                spill_ref,
665            };
666        }
667
668        let mut head_end = self.max_bytes - marker.len();
669        while head_end > 0 && !input.is_char_boundary(head_end) {
670            head_end -= 1;
671        }
672
673        loop {
674            let mut text = String::with_capacity(head_end + marker.len());
675            text.push_str(&input[..head_end]);
676            text.push_str(&marker);
677            let sanitized = md_tmpl_core::sanitize_untrusted_str(&text, TOOL_OUTPUT_QUARANTINE_TAG);
678            if sanitized.len() <= self.max_bytes {
679                return TruncationOutcome {
680                    text: Cow::Owned(sanitized.into_owned()),
681                    original_len: total_bytes,
682                    omitted: Some(head_end..total_bytes),
683                    spill_ref,
684                };
685            }
686            if head_end == 0 {
687                let mut cut = self.max_bytes.min(marker.len());
688                while cut > 0 && !marker.is_char_boundary(cut) {
689                    cut -= 1;
690                }
691                return TruncationOutcome {
692                    text: Cow::Owned(marker[..cut].into()),
693                    original_len: total_bytes,
694                    omitted: Some(0..total_bytes),
695                    spill_ref,
696                };
697            }
698            head_end -= 1;
699            while head_end > 0 && !input.is_char_boundary(head_end) {
700                head_end -= 1;
701            }
702        }
703    }
704
705    /// Truncate when `max_bytes` cannot hold the fully informative marker.
706    ///
707    /// Content is sacrificed before the marker is, so the reader can always tell
708    /// that bytes are missing; a budget too small even for the minimal marker
709    /// yields a truncated marker rather than a bare, deceptively complete-looking
710    /// slice of the input.
711    fn truncate_with_fallback_marker<'a>(
712        &self,
713        input: &str,
714        spill: &SpillDisposition,
715        spill_ref: Option<SpillRef>,
716    ) -> TruncationOutcome<'a> {
717        let total_bytes = input.len();
718        let marker = Self::fallback_marker(spill, self.max_bytes);
719
720        if marker.len() >= self.max_bytes {
721            let mut cut = self.max_bytes;
722            while cut > 0 && !marker.is_char_boundary(cut) {
723                cut -= 1;
724            }
725            return TruncationOutcome {
726                text: Cow::Owned(marker[..cut].into()),
727                original_len: total_bytes,
728                omitted: Some(0..total_bytes),
729                spill_ref,
730            };
731        }
732
733        let mut head_end = self.max_bytes - marker.len();
734        while head_end > 0 && !input.is_char_boundary(head_end) {
735            head_end -= 1;
736        }
737
738        let mut text = String::with_capacity(head_end + marker.len());
739        text.push_str(&input[..head_end]);
740        text.push_str(&marker);
741
742        TruncationOutcome {
743            text: Cow::Owned(text),
744            original_len: total_bytes,
745            omitted: Some(head_end..total_bytes),
746            spill_ref,
747        }
748    }
749
750    fn compute_initial_boundaries(
751        &self,
752        input: &str,
753        total_bytes: usize,
754        content_budget: usize,
755    ) -> (usize, usize) {
756        let head_budget = self.head_ratio.compute_head_budget(content_budget);
757        let tail_budget = content_budget.saturating_sub(head_budget);
758
759        // Floor to valid char boundary for head
760        let mut head_end = head_budget.min(total_bytes);
761        while head_end > 0 && !input.is_char_boundary(head_end) {
762            head_end -= 1;
763        }
764
765        // Ceil to valid char boundary for tail
766        let tail_target = total_bytes.saturating_sub(tail_budget);
767        let mut tail_start = tail_target.min(total_bytes);
768        while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
769            tail_start += 1;
770        }
771
772        // Apply line boundary alignment if configured
773        if self.alignment == TruncationAlignment::LineBoundary {
774            let head_scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
775            if let Some(rel_nl) = input[head_scan_start..head_end].rfind('\n') {
776                head_end = head_scan_start + rel_nl + 1;
777            }
778
779            let tail_scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
780            if let Some(rel_nl) = input[tail_start..tail_scan_end].find('\n') {
781                tail_start = tail_start + rel_nl + 1;
782            }
783        }
784
785        (head_end, tail_start)
786    }
787
788    fn shrink_head_boundary(&self, input: &str, mut head_end: usize) -> usize {
789        if self.alignment == TruncationAlignment::LineBoundary {
790            let scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
791            if let Some(rel_nl) = input[scan_start..head_end.saturating_sub(1)].rfind('\n') {
792                return scan_start + rel_nl + 1;
793            }
794        }
795        head_end = head_end.saturating_sub(1);
796        while head_end > 0 && !input.is_char_boundary(head_end) {
797            head_end -= 1;
798        }
799        head_end
800    }
801
802    fn expand_tail_boundary(
803        &self,
804        input: &str,
805        total_bytes: usize,
806        mut tail_start: usize,
807    ) -> usize {
808        if self.alignment == TruncationAlignment::LineBoundary {
809            let scan_start = tail_start.saturating_add(1).min(total_bytes);
810            let scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
811            if scan_start < scan_end
812                && let Some(rel_nl) = input[scan_start..scan_end].find('\n')
813            {
814                return scan_start + rel_nl + 1;
815            }
816        }
817        tail_start = tail_start.saturating_add(1);
818        while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
819            tail_start += 1;
820        }
821        tail_start
822    }
823
824    fn converge_boundaries(
825        &self,
826        input: &str,
827        total_bytes: usize,
828        mut head_end: usize,
829        mut tail_start: usize,
830        spill: &SpillDisposition,
831    ) -> (usize, usize, String, usize) {
832        let mut omitted_bytes = tail_start.saturating_sub(head_end);
833        let mut marker =
834            self.format_marker_detailed(omitted_bytes, total_bytes, head_end, tail_start, spill);
835        let mut current_total = head_end + marker.len() + (total_bytes - tail_start);
836
837        while current_total > self.max_bytes && (head_end > 0 || tail_start < total_bytes) {
838            if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
839            {
840                head_end = self.shrink_head_boundary(input, head_end);
841            } else if tail_start < total_bytes {
842                tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
843            } else {
844                break;
845            }
846
847            omitted_bytes = tail_start.saturating_sub(head_end);
848            marker = self.format_marker_detailed(
849                omitted_bytes,
850                total_bytes,
851                head_end,
852                tail_start,
853                spill,
854            );
855            current_total = head_end + marker.len() + (total_bytes - tail_start);
856        }
857
858        (head_end, tail_start, marker, current_total)
859    }
860}
861
862/// Helper function to perform middle-out truncation on a string slice.
863#[must_use]
864pub fn truncate_middle<'a>(input: &'a str, policy: &TruncationPolicy) -> Cow<'a, str> {
865    policy.truncate(input)
866}
867
868/// Detailed middle-out truncation returning metadata and recall handle.
869///
870/// # Errors
871///
872/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
873pub fn truncate_middle_detailed<'a>(
874    input: &'a str,
875    policy: &TruncationPolicy,
876) -> Result<TruncationOutcome<'a>, SpillError> {
877    policy.truncate_detailed(input)
878}
879
880/// Detailed middle-out truncation with [`SpillContext`].
881///
882/// # Errors
883///
884/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
885pub fn truncate_middle_detailed_with_context<'a>(
886    input: &'a str,
887    policy: &TruncationPolicy,
888    ctx: &SpillContext<'_>,
889) -> Result<TruncationOutcome<'a>, SpillError> {
890    policy.truncate_detailed_with_context(input, ctx)
891}
892
893/// Helper function to truncate a [`ToolOutput`] in-place according to `policy`.
894pub fn truncate_output(output: &mut ToolOutput, policy: &TruncationPolicy) {
895    output.content = policy.truncate(&output.content).into_owned();
896}
897
898/// Detailed in-place truncation of [`ToolOutput`].
899///
900/// # Errors
901///
902/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
903pub fn truncate_output_detailed(
904    output: &mut ToolOutput,
905    policy: &TruncationPolicy,
906) -> Result<TruncationOutcome<'static>, SpillError> {
907    truncate_output_detailed_with_context(output, policy, &SpillContext::ANONYMOUS)
908}
909
910/// Detailed in-place truncation of [`ToolOutput`] with [`SpillContext`].
911///
912/// # Errors
913///
914/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
915pub fn truncate_output_detailed_with_context(
916    output: &mut ToolOutput,
917    policy: &TruncationPolicy,
918    ctx: &SpillContext<'_>,
919) -> Result<TruncationOutcome<'static>, SpillError> {
920    let outcome = policy.truncate_detailed_with_context(&output.content, ctx)?;
921    let text = outcome.text.into_owned();
922    let original_len = outcome.original_len;
923    let omitted = outcome.omitted;
924    let spill_ref = outcome.spill_ref;
925    output.content.clone_from(&text);
926    Ok(TruncationOutcome {
927        text: Cow::Owned(text),
928        original_len,
929        omitted,
930        spill_ref,
931    })
932}