Skip to main content

gglib_core/request_pipeline/
truncation.rs

1//! Stage 3: history truncation.
2//!
3//! ## Problem
4//!
5//! Client-side context compaction can be broken for custom `OpenAI`-compatible
6//! endpoints. Each tool-call result is permanently embedded in the chat history
7//! by the client, so the prompt balloons past the model's context window after
8//! several tool-heavy turns and the model falls into repetition or logic loops.
9//!
10//! ## Defence
11//!
12//! [`truncate_history`] is a stateless pass over the request body:
13//!
14//! 1. **Budget gate** — if the serialized payload already fits within
15//!    `limit_chars` the body is left **completely untouched**. No history is
16//!    elided while there is room, so the model keeps maximum context on every
17//!    turn that does not actually need trimming.
18//!
19//! 2. **Oldest-first trim** — only when the payload exceeds the budget are
20//!    messages elided, and then only as many as necessary: unprotected
21//!    `role: "tool"` / `role: "assistant"` messages whose `content` string
22//!    exceeds [`TOOL_CONTENT_THRESHOLD_CHARS`] are replaced with
23//!    [`TRUNCATION_PLACEHOLDER`] **from oldest to newest**, stopping as soon as
24//!    the running payload estimate drops back under budget. The freshest tool
25//!    outputs — the ones the model most likely still needs — are the last to be
26//!    sacrificed.
27//!
28//! 3. **Hard abort** — if the payload still exceeds the budget after every
29//!    eligible message has been trimmed (an enormous protected system prompt,
30//!    say), [`TruncationError`] is returned rather than forwarding a prompt
31//!    that would fail at the model. Each surface maps that to its own idiom.
32//!
33//! 4. **Protected set** — `role: "system"` messages and the last
34//!    [`PROTECTED_TAIL_COUNT`] messages by index (the immediate conversational
35//!    context, spanning several recent tool-call/result pairs) are never
36//!    modified. Neither is `tool_calls`, at any role.
37//!
38//! ## The budget is the model's, and only the model's
39//!
40//! `limit_chars` is a **character** budget, derived from the model's context
41//! size in tokens via [`CHARS_PER_TOKEN_APPROX`]. There is no floor: a
42//! 4096-token model gets a ~16,000-character budget and a 262,144-token model
43//! gets a ~1,000,000-character one. Callers that know the *live* serving
44//! context and a better chars-per-token ratio (the proxy learns one per model
45//! from observed usage frames) pass their own number;
46//! [`ModelContext::context_budget_chars`] is the answer for everyone else.
47//!
48//! [`ModelContext::context_budget_chars`]: super::ModelContext::context_budget_chars
49
50use serde_json::Value;
51
52// =============================================================================
53// Constants
54// =============================================================================
55
56/// Maximum number of characters allowed in a single unprotected `role: "tool"`
57/// or `role: "assistant"` message `content` string before it is eligible for
58/// replacement with [`TRUNCATION_PLACEHOLDER`].
59pub const TOOL_CONTENT_THRESHOLD_CHARS: usize = 2_000;
60
61/// Character-to-token conversion factor used to translate a model's **token**
62/// context size into the **character** budget [`truncate_history`] measures.
63///
64/// This is not an attempt at precise real-world tokenization — it deliberately
65/// matches the GitHub Copilot LLM Gateway extension's own
66/// `TOKEN_CONSTANTS.CHARS_PER_TOKEN = 4` (see its `tokenBudget.ts`), so that
67/// gglib's advertised context window and the extension's own char-to-token
68/// budget estimate agree on the same conversion factor. What matters is
69/// consistency between the two sides, not tokenizer accuracy.
70pub const CHARS_PER_TOKEN_APPROX: usize = 4;
71
72/// Number of trailing messages (by index) always preserved from truncation
73/// regardless of role or content size.
74///
75/// These are the immediate conversational context the model needs to respond
76/// coherently — sized to span several recent tool-call/result pairs so a live
77/// tool exchange is never half-elided.
78pub const PROTECTED_TAIL_COUNT: usize = 8;
79
80/// Replacement string inserted in place of truncated message content.
81pub const TRUNCATION_PLACEHOLDER: &str = "[Raw tool output truncated by proxy to maintain context window. \
82     Rely on your previous observations.]";
83
84// =============================================================================
85// Report and error
86// =============================================================================
87
88/// Summary of what [`truncate_history`] did to a request body.
89///
90/// Callers record observability metrics from this rather than re-computing the
91/// same values. [`Default`] — every field zero — is the report for a request
92/// that was never measured at all, which is what a caller with no budget gets.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct TruncationReport {
95    /// Serialized payload size in bytes before truncation.
96    pub payload_chars_before: usize,
97    /// Serialized payload size in bytes after truncation. Equal to
98    /// `payload_chars_before` when nothing was changed.
99    pub payload_chars_after: usize,
100    /// Number of messages whose `content` was replaced with
101    /// [`TRUNCATION_PLACEHOLDER`].
102    pub messages_truncated: usize,
103}
104
105impl TruncationReport {
106    /// The report for a body that came through untouched.
107    const fn unchanged(payload_chars: usize) -> Self {
108        Self {
109            payload_chars_before: payload_chars,
110            payload_chars_after: payload_chars,
111            messages_truncated: 0,
112        }
113    }
114}
115
116/// The request cannot be made to fit its context budget.
117///
118/// Surfaces map this to their own idiom — the proxy to HTTP 400
119/// `context_length_exceeded`, the in-process agent path to an error on the
120/// completion call.
121#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
122pub enum TruncationError {
123    /// Still over budget after every trimmable message was trimmed.
124    #[error(
125        "conversation is {payload_chars} characters after truncation, over the \
126         {limit_chars}-character context budget"
127    )]
128    ExceedsBudgetAfterTruncation {
129        /// Serialized payload size once trimming could do no more.
130        payload_chars: usize,
131        /// The budget it still exceeds.
132        limit_chars: usize,
133    },
134}
135
136// =============================================================================
137// The stage
138// =============================================================================
139
140/// Trim stale history in place so the request fits within `limit_chars`.
141///
142/// `limit_chars` is the total payload character budget for this request. See
143/// the [module documentation](self) for the full algorithm.
144///
145/// # Errors
146///
147/// [`TruncationError::ExceedsBudgetAfterTruncation`] when the payload still
148/// exceeds the budget after every eligible message has been trimmed. `body` is
149/// left in its trimmed state; callers reject the request rather than forward it.
150pub fn truncate_history(
151    body: &mut Value,
152    limit_chars: usize,
153) -> Result<TruncationReport, TruncationError> {
154    let payload_chars_before = serialized_len(body);
155
156    // ── Budget gate ──────────────────────────────────────────────────────────
157    // While the whole payload fits there is nothing to do: leave it alone and
158    // keep the model's full history intact. This is the common case, and the
159    // reason truncation does not mutilate history pre-emptively.
160    if payload_chars_before <= limit_chars {
161        return Ok(TruncationReport::unchanged(payload_chars_before));
162    }
163
164    // Zero blast radius: a body this stage does not understand passes through
165    // rather than being rejected on a measurement it cannot act on.
166    let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
167        return Ok(TruncationReport::unchanged(payload_chars_before));
168    };
169
170    // ── Oldest-first trim ────────────────────────────────────────────────────
171    // Walk from the oldest message toward the newest, eliding eligible
172    // oversized content only until the running payload estimate drops back
173    // under budget. `running` tracks the approximate payload size as each
174    // replacement shrinks it, so we stop at the minimum necessary and leave the
175    // freshest tool outputs intact.
176    let total = messages.len();
177    let placeholder_len = TRUNCATION_PLACEHOLDER.len();
178    let mut messages_truncated = 0usize;
179    let mut running = payload_chars_before;
180
181    for (i, msg) in messages.iter_mut().enumerate() {
182        if running <= limit_chars {
183            break;
184        }
185        // Tail-protected messages and non-candidate roles (system, user) are
186        // skipped entirely.
187        if is_tail_protected(i, total) || !is_truncation_candidate(msg) {
188            continue;
189        }
190
191        // Only string-form content is replaced. Array-form content (multi-part
192        // messages) is left untouched, as is `tool_calls` at any role.
193        let Some(content_len) = msg
194            .get("content")
195            .and_then(Value::as_str)
196            .map(str::len)
197            .filter(|len| *len > TOOL_CONTENT_THRESHOLD_CHARS)
198        else {
199            continue;
200        };
201
202        msg["content"] = Value::String(TRUNCATION_PLACEHOLDER.to_owned());
203        messages_truncated += 1;
204        // Each replacement reclaims (content_len - placeholder_len) chars.
205        running = running.saturating_sub(content_len.saturating_sub(placeholder_len));
206    }
207
208    // ── Budget check ─────────────────────────────────────────────────────────
209    // Re-measure only when something actually changed; `running` is an estimate
210    // and the hard abort deserves the real number.
211    let payload_chars_after = if messages_truncated == 0 {
212        payload_chars_before
213    } else {
214        serialized_len(body)
215    };
216
217    if payload_chars_after > limit_chars {
218        return Err(TruncationError::ExceedsBudgetAfterTruncation {
219            payload_chars: payload_chars_after,
220            limit_chars,
221        });
222    }
223
224    Ok(TruncationReport {
225        payload_chars_before,
226        payload_chars_after,
227        messages_truncated,
228    })
229}
230
231// =============================================================================
232// Helpers
233// =============================================================================
234
235/// Byte length of `body` once serialized, without allocating a copy of it.
236///
237/// The budget is denominated in wire bytes, and a [`Value`] has none until it
238/// is serialized — but a 200 KB conversation does not need to be materialized
239/// twice just to be measured.
240fn serialized_len(body: &Value) -> usize {
241    let mut counter = CountingWriter::default();
242    // Serializing a `Value` cannot fail: it holds no non-string map keys and no
243    // non-finite numbers, and the sink never errors. Reporting zero on that
244    // unreachable branch degrades to "under budget", i.e. passthrough.
245    if serde_json::to_writer(&mut counter, body).is_err() {
246        return 0;
247    }
248    counter.0
249}
250
251/// An [`std::io::Write`] sink that keeps the byte count and discards the bytes.
252#[derive(Default)]
253struct CountingWriter(usize);
254
255impl std::io::Write for CountingWriter {
256    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
257        self.0 += buf.len();
258        Ok(buf.len())
259    }
260
261    fn flush(&mut self) -> std::io::Result<()> {
262        Ok(())
263    }
264}
265
266/// Returns `true` if the message at `index` (in a list of `total` messages)
267/// falls within the protected tail window and must not be truncated.
268#[inline]
269const fn is_tail_protected(index: usize, total: usize) -> bool {
270    index >= total.saturating_sub(PROTECTED_TAIL_COUNT)
271}
272
273/// Returns `true` if this message's role is eligible for content truncation.
274///
275/// Only `role: "tool"` and `role: "assistant"` are candidates. `role: "system"`
276/// and `role: "user"` are never truncated.
277#[inline]
278fn is_truncation_candidate(msg: &Value) -> bool {
279    matches!(
280        msg.get("role").and_then(Value::as_str).unwrap_or(""),
281        "tool" | "assistant"
282    )
283}
284
285#[cfg(test)]
286#[path = "truncation_tests.rs"]
287mod truncation_tests;