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 = match &self.marker_template {
162            Some(template) => {
163                let orig_str = original_bytes.to_string();
164                let start_str = offset_start.to_string();
165                let end_str = offset_end.to_string();
166                let ref_str = spill.spill_ref().map_or("", SpillRef::as_str);
167
168                template
169                    .replace(MarkerPlaceholder::TOKEN_OMITTED_BYTES, &omitted_str)
170                    .replace(MarkerPlaceholder::TOKEN_ORIGINAL_BYTES, &orig_str)
171                    .replace(MarkerPlaceholder::TOKEN_OFFSET_START, &start_str)
172                    .replace(MarkerPlaceholder::TOKEN_OFFSET_END, &end_str)
173                    .replace(MarkerPlaceholder::TOKEN_SPILL_REF, ref_str)
174                    .replace(MarkerPlaceholder::DEPRECATED_COUNT, &omitted_str)
175                    .replace(MarkerPlaceholder::DEPRECATED_BYTES, &omitted_str)
176                    .replace(MarkerPlaceholder::DEPRECATED_N, &omitted_str)
177            }
178            None => match spill {
179                SpillDisposition::Spilled(handle) => format!(
180                    "\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"
181                ),
182                SpillDisposition::Failed(error) => format!(
183                    "\n[TRUNCATED: {omitted_str} bytes omitted (bytes {offset_start}..{offset_end} of {original_bytes}).{notice}]\n",
184                    notice = spill_failure_notice(error)
185                ),
186                SpillDisposition::NotConfigured => {
187                    format!("\n[TRUNCATED: {omitted_str} bytes omitted]\n")
188                }
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        match Self::passthrough(input, self.max_bytes) {
335            Some(outcome) => Ok(outcome),
336            None => match self.resolve_spill(ctx, input) {
337                SpillDisposition::Failed(error) => Err(error),
338                spill => Ok(self.truncate_oversized_auto(input, &spill)),
339            },
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        match &self.spill_sink {
360            Some(sink) => match sink.spill(ctx, input) {
361                Ok(handle) => SpillDisposition::Spilled(handle),
362                Err(error) => SpillDisposition::Failed(error),
363            },
364            None => SpillDisposition::NotConfigured,
365        }
366    }
367
368    fn resolve_spill_logged(&self, ctx: &SpillContext<'_>, input: &str) -> SpillDisposition {
369        let spill = self.resolve_spill(ctx, input);
370        if let Some(error) = spill.error() {
371            tracing::error!(
372                error = %error,
373                tool = ctx.tool_name,
374                "spill sink failed; omitted bytes are unrecoverable and the truncation marker will say so"
375            );
376        }
377        spill
378    }
379
380    /// Dispatch oversized truncation while preserving quarantine envelopes and
381    /// sanitization invariants if `input` was already quarantined or sanitized.
382    fn truncate_oversized_auto<'a>(
383        &self,
384        input: &'a str,
385        spill: &SpillDisposition,
386    ) -> TruncationOutcome<'a> {
387        if let Some(inner) = unquarantine_tool_output(input) {
388            return self.truncate_and_wrap_quarantine(inner, input.len(), OPEN_FRAME.len(), spill);
389        }
390        if !md_tmpl_core::has_untrusted_breakout(input, TOOL_OUTPUT_QUARANTINE_TAG) {
391            return self.truncate_oversized_sanitized(input, spill);
392        }
393        self.truncate_oversized(input, spill)
394    }
395
396    /// Truncate `inner` and wrap the result in `<tool_output_quarantine>\n...\n</tool_output_quarantine>`,
397    /// guaranteeing that the quarantine envelope is never broken even under tiny `max_bytes`.
398    fn truncate_and_wrap_quarantine<'a>(
399        &self,
400        inner: &str,
401        outer_original_len: usize,
402        offset_bias: usize,
403        spill: &SpillDisposition,
404    ) -> TruncationOutcome<'a> {
405        let inner_len = inner.len();
406        let spill_ref = spill.spill_ref().cloned();
407        let inner_budget = self.max_bytes.saturating_sub(FRAME_OVERHEAD);
408
409        if inner_budget < MINIMAL_TRUNCATION_MARKER.len() {
410            let notice = match spill {
411                SpillDisposition::Failed(_) => COMPACT_TRUNCATION_MARKER_SPILL_FAILED,
412                SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
413                    COMPACT_TRUNCATION_MARKER
414                }
415            };
416            let mut head_end = inner_budget.saturating_sub(notice.len()).min(inner_len);
417            while head_end > 0 && !inner.is_char_boundary(head_end) {
418                head_end -= 1;
419            }
420            let mut body = String::with_capacity(head_end + notice.len());
421            body.push_str(&inner[..head_end]);
422            body.push_str(notice);
423            let sanitized_body =
424                md_tmpl_core::sanitize_untrusted_str(&body, TOOL_OUTPUT_QUARANTINE_TAG);
425            let final_body = if head_end > 0 && sanitized_body.len() > inner_budget {
426                notice
427            } else {
428                sanitized_body.as_ref()
429            };
430            return TruncationOutcome {
431                text: Cow::Owned(format!("{OPEN_FRAME}{final_body}{CLOSE_FRAME}")),
432                original_len: outer_original_len,
433                omitted: Some(offset_bias..(offset_bias + inner_len)),
434                spill_ref,
435            };
436        }
437
438        let inner_policy = Self {
439            max_bytes: inner_budget,
440            head_ratio: self.head_ratio,
441            alignment: self.alignment,
442            marker_template: self.marker_template.clone(),
443            spill_sink: None,
444        };
445        let inner_outcome = inner_policy.truncate_oversized_sanitized(inner, spill);
446        let omitted = inner_outcome
447            .omitted
448            .map(|r| (r.start + offset_bias)..(r.end + offset_bias))
449            .or(Some(offset_bias..(offset_bias + inner_len)));
450
451        TruncationOutcome {
452            text: Cow::Owned(format!("{OPEN_FRAME}{}{CLOSE_FRAME}", inner_outcome.text)),
453            original_len: outer_original_len,
454            omitted,
455            spill_ref,
456        }
457    }
458
459    /// Perform middle-out elision on `input` while neutralizing any control tokens
460    /// or breakout tags in the surviving head, marker, tail, or across their splice
461    /// boundary, converging `head_end` and `tail_start` so the sanitized string fits
462    /// within `self.max_bytes`.
463    fn truncate_oversized_sanitized<'a>(
464        &self,
465        input: &'a str,
466        spill: &SpillDisposition,
467    ) -> TruncationOutcome<'a> {
468        let total_bytes = input.len();
469        let spill_ref = spill.spill_ref().cloned();
470
471        if self.max_bytes == 0 {
472            return TruncationOutcome {
473                text: Cow::Borrowed(""),
474                original_len: total_bytes,
475                omitted: Some(0..total_bytes),
476                spill_ref,
477            };
478        }
479
480        let est_omitted = total_bytes.saturating_sub(self.max_bytes);
481        let sample_marker =
482            self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
483        let sample_marker_sanitized =
484            md_tmpl_core::sanitize_untrusted_str(&sample_marker, TOOL_OUTPUT_QUARANTINE_TAG);
485        let marker_len = sample_marker_sanitized.len();
486
487        if self.max_bytes <= marker_len {
488            return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
489        }
490
491        let content_budget = self.max_bytes.saturating_sub(marker_len);
492        let (mut head_end, mut tail_start) =
493            self.compute_initial_boundaries(input, total_bytes, content_budget);
494
495        if head_end >= tail_start {
496            let sanitized_full =
497                md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
498            if sanitized_full.len() <= self.max_bytes {
499                return TruncationOutcome {
500                    text: sanitized_full,
501                    original_len: total_bytes,
502                    omitted: None,
503                    spill_ref,
504                };
505            }
506            let mut mid = total_bytes / 2;
507            while mid > 0 && !input.is_char_boundary(mid) {
508                mid -= 1;
509            }
510            head_end = mid;
511            tail_start = mid;
512        }
513
514        let (mut head_end, mut tail_start, mut marker, _) =
515            self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
516
517        loop {
518            let mut candidate =
519                String::with_capacity(head_end + marker.len() + (total_bytes - tail_start));
520            candidate.push_str(&input[..head_end]);
521            candidate.push_str(&marker);
522            candidate.push_str(&input[tail_start..]);
523
524            let sanitized =
525                md_tmpl_core::sanitize_untrusted_str(&candidate, TOOL_OUTPUT_QUARANTINE_TAG);
526            if sanitized.len() <= self.max_bytes {
527                return TruncationOutcome {
528                    text: Cow::Owned(sanitized.into_owned()),
529                    original_len: total_bytes,
530                    omitted: Some(head_end..tail_start),
531                    spill_ref,
532                };
533            }
534
535            if head_end == 0 && tail_start >= total_bytes {
536                return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
537            }
538
539            if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
540            {
541                head_end = self.shrink_head_boundary(input, head_end);
542            } else if tail_start < total_bytes {
543                tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
544            } else {
545                return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
546            }
547
548            let omitted_bytes = tail_start.saturating_sub(head_end);
549            marker = self.format_marker_detailed(
550                omitted_bytes,
551                total_bytes,
552                head_end,
553                tail_start,
554                spill,
555            );
556        }
557    }
558
559    /// Perform middle-out elision on input already known to exceed `max_bytes`,
560    /// with the spill attempt already resolved.
561    fn truncate_oversized<'a>(
562        &self,
563        input: &'a str,
564        spill: &SpillDisposition,
565    ) -> TruncationOutcome<'a> {
566        let total_bytes = input.len();
567        let spill_ref = spill.spill_ref().cloned();
568
569        if self.max_bytes == 0 {
570            return TruncationOutcome {
571                text: Cow::Borrowed(""),
572                original_len: total_bytes,
573                omitted: Some(0..total_bytes),
574                spill_ref,
575            };
576        }
577
578        // Estimate marker length using approximate omitted byte count.
579        let est_omitted = total_bytes.saturating_sub(self.max_bytes);
580        let sample_marker =
581            self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
582        let marker_len = sample_marker.len();
583
584        if self.max_bytes <= marker_len {
585            return self.truncate_with_fallback_marker(input, spill, spill_ref);
586        }
587
588        let content_budget = self.max_bytes.saturating_sub(marker_len);
589        let (head_end, tail_start) =
590            self.compute_initial_boundaries(input, total_bytes, content_budget);
591
592        // If boundaries overlap or touch, no truncation is necessary
593        if head_end >= tail_start {
594            return TruncationOutcome {
595                text: Cow::Borrowed(input),
596                original_len: total_bytes,
597                omitted: None,
598                spill_ref,
599            };
600        }
601
602        let (head_end, tail_start, marker, current_total) =
603            self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
604
605        let mut output = String::with_capacity(current_total);
606        output.push_str(&input[..head_end]);
607        output.push_str(&marker);
608        output.push_str(&input[tail_start..]);
609
610        if output.len() > self.max_bytes {
611            let mut cut = self.max_bytes;
612            while cut > 0 && !output.is_char_boundary(cut) {
613                cut -= 1;
614            }
615            output.truncate(cut);
616        }
617
618        TruncationOutcome {
619            text: Cow::Owned(output),
620            original_len: total_bytes,
621            omitted: Some(head_end..tail_start),
622            spill_ref,
623        }
624    }
625
626    /// Choose the most informative marker that fits inside `max_bytes`.
627    fn fallback_marker(spill: &SpillDisposition, max_bytes: usize) -> String {
628        if let SpillDisposition::Spilled(handle) = spill {
629            let with_ref = format!("\n[TRUNCATED: bytes omitted; ref=\"{handle}\"]\n");
630            if with_ref.len() <= max_bytes {
631                return with_ref;
632            }
633        }
634        match spill {
635            SpillDisposition::Failed(_) => MINIMAL_TRUNCATION_MARKER_SPILL_FAILED.into(),
636            SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
637                MINIMAL_TRUNCATION_MARKER.into()
638            }
639        }
640    }
641
642    fn truncate_with_fallback_marker_sanitized<'a>(
643        &self,
644        input: &str,
645        spill: &SpillDisposition,
646        spill_ref: Option<SpillRef>,
647    ) -> TruncationOutcome<'a> {
648        let total_bytes = input.len();
649        let raw_marker = Self::fallback_marker(spill, self.max_bytes);
650        let marker = md_tmpl_core::sanitize_untrusted_str(&raw_marker, TOOL_OUTPUT_QUARANTINE_TAG);
651
652        if marker.len() >= self.max_bytes {
653            let mut cut = self.max_bytes;
654            while cut > 0 && !marker.is_char_boundary(cut) {
655                cut -= 1;
656            }
657            let slice = &marker[..cut];
658            let clean = md_tmpl_core::sanitize_untrusted_str(slice, TOOL_OUTPUT_QUARANTINE_TAG);
659            return TruncationOutcome {
660                text: Cow::Owned(clean.into_owned()),
661                original_len: total_bytes,
662                omitted: Some(0..total_bytes),
663                spill_ref,
664            };
665        }
666
667        let mut head_end = self.max_bytes - marker.len();
668        while head_end > 0 && !input.is_char_boundary(head_end) {
669            head_end -= 1;
670        }
671
672        loop {
673            let mut text = String::with_capacity(head_end + marker.len());
674            text.push_str(&input[..head_end]);
675            text.push_str(&marker);
676            let sanitized = md_tmpl_core::sanitize_untrusted_str(&text, TOOL_OUTPUT_QUARANTINE_TAG);
677            if sanitized.len() <= self.max_bytes {
678                return TruncationOutcome {
679                    text: Cow::Owned(sanitized.into_owned()),
680                    original_len: total_bytes,
681                    omitted: Some(head_end..total_bytes),
682                    spill_ref,
683                };
684            }
685            if head_end == 0 {
686                let mut cut = self.max_bytes.min(marker.len());
687                while cut > 0 && !marker.is_char_boundary(cut) {
688                    cut -= 1;
689                }
690                return TruncationOutcome {
691                    text: Cow::Owned(marker[..cut].into()),
692                    original_len: total_bytes,
693                    omitted: Some(0..total_bytes),
694                    spill_ref,
695                };
696            }
697            head_end -= 1;
698            while head_end > 0 && !input.is_char_boundary(head_end) {
699                head_end -= 1;
700            }
701        }
702    }
703
704    /// Truncate when `max_bytes` cannot hold the fully informative marker.
705    ///
706    /// Content is sacrificed before the marker is, so the reader can always tell
707    /// that bytes are missing; a budget too small even for the minimal marker
708    /// yields a truncated marker rather than a bare, deceptively complete-looking
709    /// slice of the input.
710    fn truncate_with_fallback_marker<'a>(
711        &self,
712        input: &str,
713        spill: &SpillDisposition,
714        spill_ref: Option<SpillRef>,
715    ) -> TruncationOutcome<'a> {
716        let total_bytes = input.len();
717        let marker = Self::fallback_marker(spill, self.max_bytes);
718
719        if marker.len() >= self.max_bytes {
720            let mut cut = self.max_bytes;
721            while cut > 0 && !marker.is_char_boundary(cut) {
722                cut -= 1;
723            }
724            return TruncationOutcome {
725                text: Cow::Owned(marker[..cut].into()),
726                original_len: total_bytes,
727                omitted: Some(0..total_bytes),
728                spill_ref,
729            };
730        }
731
732        let mut head_end = self.max_bytes - marker.len();
733        while head_end > 0 && !input.is_char_boundary(head_end) {
734            head_end -= 1;
735        }
736
737        let mut text = String::with_capacity(head_end + marker.len());
738        text.push_str(&input[..head_end]);
739        text.push_str(&marker);
740
741        TruncationOutcome {
742            text: Cow::Owned(text),
743            original_len: total_bytes,
744            omitted: Some(head_end..total_bytes),
745            spill_ref,
746        }
747    }
748
749    fn compute_initial_boundaries(
750        &self,
751        input: &str,
752        total_bytes: usize,
753        content_budget: usize,
754    ) -> (usize, usize) {
755        let head_budget = self.head_ratio.compute_head_budget(content_budget);
756        let tail_budget = content_budget.saturating_sub(head_budget);
757
758        // Floor to valid char boundary for head
759        let mut head_end = head_budget.min(total_bytes);
760        while head_end > 0 && !input.is_char_boundary(head_end) {
761            head_end -= 1;
762        }
763
764        // Ceil to valid char boundary for tail
765        let tail_target = total_bytes.saturating_sub(tail_budget);
766        let mut tail_start = tail_target.min(total_bytes);
767        while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
768            tail_start += 1;
769        }
770
771        // Apply line boundary alignment if configured
772        if self.alignment == TruncationAlignment::LineBoundary {
773            let head_scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
774            if let Some(rel_nl) = input[head_scan_start..head_end].rfind('\n') {
775                head_end = head_scan_start + rel_nl + 1;
776            }
777
778            let tail_scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
779            if let Some(rel_nl) = input[tail_start..tail_scan_end].find('\n') {
780                tail_start = tail_start + rel_nl + 1;
781            }
782        }
783
784        (head_end, tail_start)
785    }
786
787    fn shrink_head_boundary(&self, input: &str, mut head_end: usize) -> usize {
788        if self.alignment == TruncationAlignment::LineBoundary {
789            let scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
790            if let Some(rel_nl) = input[scan_start..head_end.saturating_sub(1)].rfind('\n') {
791                return scan_start + rel_nl + 1;
792            }
793        }
794        head_end = head_end.saturating_sub(1);
795        while head_end > 0 && !input.is_char_boundary(head_end) {
796            head_end -= 1;
797        }
798        head_end
799    }
800
801    fn expand_tail_boundary(
802        &self,
803        input: &str,
804        total_bytes: usize,
805        mut tail_start: usize,
806    ) -> usize {
807        if self.alignment == TruncationAlignment::LineBoundary {
808            let scan_start = tail_start.saturating_add(1).min(total_bytes);
809            let scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
810            if scan_start < scan_end
811                && let Some(rel_nl) = input[scan_start..scan_end].find('\n')
812            {
813                return scan_start + rel_nl + 1;
814            }
815        }
816        tail_start = tail_start.saturating_add(1);
817        while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
818            tail_start += 1;
819        }
820        tail_start
821    }
822
823    fn converge_boundaries(
824        &self,
825        input: &str,
826        total_bytes: usize,
827        mut head_end: usize,
828        mut tail_start: usize,
829        spill: &SpillDisposition,
830    ) -> (usize, usize, String, usize) {
831        let mut omitted_bytes = tail_start.saturating_sub(head_end);
832        let mut marker =
833            self.format_marker_detailed(omitted_bytes, total_bytes, head_end, tail_start, spill);
834        let mut current_total = head_end + marker.len() + (total_bytes - tail_start);
835
836        while current_total > self.max_bytes && (head_end > 0 || tail_start < total_bytes) {
837            if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
838            {
839                head_end = self.shrink_head_boundary(input, head_end);
840            } else if tail_start < total_bytes {
841                tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
842            } else {
843                break;
844            }
845
846            omitted_bytes = tail_start.saturating_sub(head_end);
847            marker = self.format_marker_detailed(
848                omitted_bytes,
849                total_bytes,
850                head_end,
851                tail_start,
852                spill,
853            );
854            current_total = head_end + marker.len() + (total_bytes - tail_start);
855        }
856
857        (head_end, tail_start, marker, current_total)
858    }
859}
860
861/// Helper function to perform middle-out truncation on a string slice.
862#[must_use]
863pub fn truncate_middle<'a>(input: &'a str, policy: &TruncationPolicy) -> Cow<'a, str> {
864    policy.truncate(input)
865}
866
867/// Detailed middle-out truncation returning metadata and recall handle.
868///
869/// # Errors
870///
871/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
872pub fn truncate_middle_detailed<'a>(
873    input: &'a str,
874    policy: &TruncationPolicy,
875) -> Result<TruncationOutcome<'a>, SpillError> {
876    policy.truncate_detailed(input)
877}
878
879/// Detailed middle-out truncation with [`SpillContext`].
880///
881/// # Errors
882///
883/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
884pub fn truncate_middle_detailed_with_context<'a>(
885    input: &'a str,
886    policy: &TruncationPolicy,
887    ctx: &SpillContext<'_>,
888) -> Result<TruncationOutcome<'a>, SpillError> {
889    policy.truncate_detailed_with_context(input, ctx)
890}
891
892/// Helper function to truncate a [`ToolOutput`] in-place according to `policy`.
893pub fn truncate_output(output: &mut ToolOutput, policy: &TruncationPolicy) {
894    output.content = policy.truncate(&output.content).into_owned();
895}
896
897/// Detailed in-place truncation of [`ToolOutput`].
898///
899/// # Errors
900///
901/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
902pub fn truncate_output_detailed(
903    output: &mut ToolOutput,
904    policy: &TruncationPolicy,
905) -> Result<TruncationOutcome<'static>, SpillError> {
906    truncate_output_detailed_with_context(output, policy, &SpillContext::ANONYMOUS)
907}
908
909/// Detailed in-place truncation of [`ToolOutput`] with [`SpillContext`].
910///
911/// # Errors
912///
913/// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
914pub fn truncate_output_detailed_with_context(
915    output: &mut ToolOutput,
916    policy: &TruncationPolicy,
917    ctx: &SpillContext<'_>,
918) -> Result<TruncationOutcome<'static>, SpillError> {
919    let outcome = policy.truncate_detailed_with_context(&output.content, ctx)?;
920    let text = outcome.text.into_owned();
921    let original_len = outcome.original_len;
922    let omitted = outcome.omitted;
923    let spill_ref = outcome.spill_ref;
924    output.content.clone_from(&text);
925    Ok(TruncationOutcome {
926        text: Cow::Owned(text),
927        original_len,
928        omitted,
929        spill_ref,
930    })
931}