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 to a low watermark** — only when the payload exceeds
20//!    the budget are messages elided: unprotected `role: "tool"` /
21//!    `role: "assistant"` messages whose text, in either content shape, exceeds
22//!    [`TOOL_CONTENT_THRESHOLD_CHARS`] are replaced with
23//!    [`TRUNCATION_PLACEHOLDER`] **from oldest to newest**, until the estimated
24//!    savings reach a quantized target aimed at [`LOW_WATERMARK_PCT`] of the
25//!    budget. The freshest tool outputs — the ones the model most likely still
26//!    needs — are the last to be 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//! ## Why trim past the budget, and why the target is quantized
39//!
40//! This stage is stateless and clients resend the full raw history every turn,
41//! so an elision never persists: each request re-derives the elision set from
42//! scratch. Trimming to just under the budget therefore moves the elision
43//! frontier forward by ~one message on every turn of a long agentic session —
44//! and every move shifts the first byte at which the forwarded prompt differs
45//! from the previous turn's, breaking llama.cpp's common-prefix KV-cache match
46//! and forcing a near-full prompt re-prefill each turn.
47//!
48//! Aiming lower (a classic low watermark) is not enough by itself: a minimal
49//! elision set computed against *any* fixed threshold still grows at the same
50//! per-turn cadence. Instead the savings target is quantized to whole
51//! multiples of the watermark margin (budget − watermark, 25% of budget).
52//! Within one margin's worth of payload growth the target — and therefore the
53//! elision set — is identical across turns, so consecutive requests share
54//! their prompt prefix and llama-server only prefills the new tail. When
55//! growth crosses a margin boundary, several messages are elided at once and
56//! the cycle restarts: the post-trim payload lands at or below the watermark,
57//! sawtoothing within (50%, 75%] of budget, and prefix breaks happen once per
58//! ~25%-of-budget of growth instead of once per turn.
59//!
60//! ## The budget is the model's, and only the model's
61//!
62//! `limit_chars` is a **character** budget, derived from the model's context
63//! size in tokens via [`CHARS_PER_TOKEN_APPROX`]. There is no floor: a
64//! 4096-token model gets a ~16,000-character budget and a 262,144-token model
65//! gets a ~1,000,000-character one. Callers that know the *live* serving
66//! context and a better chars-per-token ratio (the proxy learns one per model
67//! from observed usage frames) pass their own number;
68//! [`ModelContext::context_budget_chars`] is the answer for everyone else.
69//!
70//! [`ModelContext::context_budget_chars`]: super::ModelContext::context_budget_chars
71
72use serde_json::Value;
73
74// =============================================================================
75// Constants
76// =============================================================================
77
78/// Maximum number of characters of text allowed in a single unprotected
79/// `role: "tool"` or `role: "assistant"` message, in either content shape,
80/// before it is eligible for replacement with [`TRUNCATION_PLACEHOLDER`].
81pub(crate) const TOOL_CONTENT_THRESHOLD_CHARS: usize = 2_000;
82
83/// Character-to-token conversion factor used to translate a model's **token**
84/// context size into the **character** budget [`truncate_history`] measures.
85///
86/// This is not an attempt at precise real-world tokenization — it deliberately
87/// matches the GitHub Copilot LLM Gateway extension's own
88/// `TOKEN_CONSTANTS.CHARS_PER_TOKEN = 4` (see its `tokenBudget.ts`), so that
89/// gglib's advertised context window and the extension's own char-to-token
90/// budget estimate agree on the same conversion factor. What matters is
91/// consistency between the two sides, not tokenizer accuracy.
92pub const CHARS_PER_TOKEN_APPROX: usize = 4;
93
94/// Number of trailing messages (by index) always preserved from truncation
95/// regardless of role or content size.
96///
97/// These are the immediate conversational context the model needs to respond
98/// coherently — sized to span several recent tool-call/result pairs so a live
99/// tool exchange is never half-elided.
100pub(crate) const PROTECTED_TAIL_COUNT: usize = 8;
101
102/// Low watermark the trim aims for, as a percentage of the request budget.
103///
104/// Once truncation is triggered (payload over `limit_chars`), the stage trims
105/// past the budget down toward this fraction of it, buying several turns of
106/// headroom in which follow-up requests need no new elisions — the property
107/// that keeps the forwarded prompt prefix stable for llama.cpp's KV cache.
108/// See the module docs for why the savings target is also quantized.
109pub(crate) const LOW_WATERMARK_PCT: usize = 75;
110
111/// Replacement string inserted in place of truncated message content.
112pub(crate) const TRUNCATION_PLACEHOLDER: &str = "[Raw tool output truncated by proxy to maintain context window. \
113     Rely on your previous observations.]";
114
115// =============================================================================
116// Report and error
117// =============================================================================
118
119/// Summary of what [`truncate_history`] did to a request body.
120///
121/// Callers record observability metrics from this rather than re-computing the
122/// same values. [`Default`] — every field zero — is the report for a request
123/// that was never measured at all, which is what a caller with no budget gets.
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct TruncationReport {
126    /// Serialized payload size in bytes before truncation.
127    pub payload_chars_before: usize,
128    /// Serialized payload size in bytes after truncation. Equal to
129    /// `payload_chars_before` when nothing was changed.
130    pub payload_chars_after: usize,
131    /// Number of messages whose `content` was replaced with
132    /// [`TRUNCATION_PLACEHOLDER`].
133    pub messages_truncated: usize,
134}
135
136impl TruncationReport {
137    /// The report for a body that came through untouched.
138    const fn unchanged(payload_chars: usize) -> Self {
139        Self {
140            payload_chars_before: payload_chars,
141            payload_chars_after: payload_chars,
142            messages_truncated: 0,
143        }
144    }
145}
146
147/// The request cannot be made to fit its context budget.
148///
149/// Surfaces map this to their own idiom — the proxy to HTTP 400
150/// `context_length_exceeded`, the in-process agent path to an error on the
151/// completion call.
152#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
153pub enum TruncationError {
154    /// Still over budget after every trimmable message was trimmed.
155    #[error(
156        "conversation is {payload_chars} characters after truncation, over the \
157         {limit_chars}-character context budget"
158    )]
159    ExceedsBudgetAfterTruncation {
160        /// Serialized payload size once trimming could do no more.
161        payload_chars: usize,
162        /// The budget it still exceeds.
163        limit_chars: usize,
164    },
165}
166
167// =============================================================================
168// The stage
169// =============================================================================
170
171/// Trim stale history in place so the request fits within `limit_chars`,
172/// aiming past the budget for the [`LOW_WATERMARK_PCT`] watermark once
173/// triggered.
174///
175/// `limit_chars` is the total payload character budget for this request. See
176/// the [module documentation](self) for the full algorithm.
177///
178/// # Errors
179///
180/// [`TruncationError::ExceedsBudgetAfterTruncation`] when the payload still
181/// exceeds the budget after every eligible message has been trimmed. `body` is
182/// left in its trimmed state; callers reject the request rather than forward it.
183pub fn truncate_history(
184    body: &mut Value,
185    limit_chars: usize,
186) -> Result<TruncationReport, TruncationError> {
187    let payload_chars_before = serialized_len(body);
188
189    // ── Budget gate ──────────────────────────────────────────────────────────
190    // While the whole payload fits there is nothing to do: leave it alone and
191    // keep the model's full history intact. This is the common case, and the
192    // reason truncation does not mutilate history pre-emptively.
193    if payload_chars_before <= limit_chars {
194        return Ok(TruncationReport::unchanged(payload_chars_before));
195    }
196
197    // Zero blast radius: a body this stage does not understand passes through
198    // rather than being rejected on a measurement it cannot act on.
199    let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
200        return Ok(TruncationReport::unchanged(payload_chars_before));
201    };
202
203    // ── Oldest-first trim ────────────────────────────────────────────────────
204    // Walk from the oldest message toward the newest, eliding eligible
205    // oversized content until the cumulative estimated savings reach the
206    // quantized watermark target, so the freshest tool outputs are the last to
207    // be sacrificed. The budget gate above guarantees the target is positive,
208    // so the loop never stops before considering the first eligible message.
209    // Exhausting the eligible messages short of the target is fine: the hard
210    // abort below fires only when the payload still exceeds the budget itself,
211    // not the watermark.
212    let total = messages.len();
213    let target_savings = target_savings_chars(payload_chars_before, limit_chars);
214    let mut messages_truncated = 0usize;
215    let mut saved = 0usize;
216
217    for (i, msg) in messages.iter_mut().enumerate() {
218        if saved >= target_savings {
219            break;
220        }
221        // Tail-protected messages and non-candidate roles (system, user) are
222        // skipped entirely.
223        if is_tail_protected(i, total) || !is_truncation_candidate(msg) {
224            continue;
225        }
226
227        // Either content shape is elided; `tool_calls` at any role is not.
228        let Some(reclaimed) = super::truncation_parts::elide(msg) else {
229            continue;
230        };
231        messages_truncated += 1;
232        saved = saved.saturating_add(reclaimed);
233    }
234
235    // ── Budget check ─────────────────────────────────────────────────────────
236    // Re-measure only when something actually changed; `saved` is an estimate
237    // and the hard abort deserves the real number.
238    let payload_chars_after = if messages_truncated == 0 {
239        payload_chars_before
240    } else {
241        serialized_len(body)
242    };
243
244    if payload_chars_after > limit_chars {
245        return Err(TruncationError::ExceedsBudgetAfterTruncation {
246            payload_chars: payload_chars_after,
247            limit_chars,
248        });
249    }
250
251    Ok(TruncationReport {
252        payload_chars_before,
253        payload_chars_after,
254        messages_truncated,
255    })
256}
257
258// =============================================================================
259// Helpers
260// =============================================================================
261
262/// The number of estimated characters truncation must reclaim from a payload
263/// of `payload_chars` under a budget of `limit_chars`.
264///
265/// The naive answer — `payload_chars - limit_chars`, just enough to fit —
266/// re-elides one more message on almost every turn of a growing conversation,
267/// because this stage is stateless and the client resends the full raw history
268/// each time. The target is therefore anchored at the [`LOW_WATERMARK_PCT`]
269/// watermark and rounded **up** to a whole multiple of the margin between
270/// watermark and budget: the result depends on the payload size only through
271/// that coarse bracket, so the elision set it induces stays identical across
272/// every turn within one margin (~25% of budget) of payload growth. See the
273/// module docs for the full KV-cache rationale.
274///
275/// Returns `0` when the payload is already at or below the watermark. The
276/// margin is clamped to at least one character so a degenerate `limit_chars`
277/// of `0` still yields a finite target (the whole payload).
278const fn target_savings_chars(payload_chars: usize, limit_chars: usize) -> usize {
279    let watermark = limit_chars.saturating_mul(LOW_WATERMARK_PCT) / 100;
280    let margin = match limit_chars - watermark {
281        0 => 1,
282        margin => margin,
283    };
284    let needed = payload_chars.saturating_sub(watermark);
285    needed.div_ceil(margin).saturating_mul(margin)
286}
287
288/// Byte length of `body` once serialized, without allocating a copy of it.
289///
290/// The budget is denominated in wire bytes, and a [`Value`] has none until it
291/// is serialized — but a 200 KB conversation does not need to be materialized
292/// twice just to be measured.
293fn serialized_len(body: &Value) -> usize {
294    let mut counter = CountingWriter::default();
295    // Serializing a `Value` cannot fail: it holds no non-string map keys and no
296    // non-finite numbers, and the sink never errors. Reporting zero on that
297    // unreachable branch degrades to "under budget", i.e. passthrough.
298    if serde_json::to_writer(&mut counter, body).is_err() {
299        return 0;
300    }
301    counter.0
302}
303
304/// An [`std::io::Write`] sink that keeps the byte count and discards the bytes.
305#[derive(Default)]
306struct CountingWriter(usize);
307
308impl std::io::Write for CountingWriter {
309    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
310        self.0 += buf.len();
311        Ok(buf.len())
312    }
313
314    fn flush(&mut self) -> std::io::Result<()> {
315        Ok(())
316    }
317}
318
319/// Returns `true` if the message at `index` (in a list of `total` messages)
320/// falls within the protected tail window and must not be truncated.
321#[inline]
322const fn is_tail_protected(index: usize, total: usize) -> bool {
323    index >= total.saturating_sub(PROTECTED_TAIL_COUNT)
324}
325
326/// Returns `true` if this message's role is eligible for content truncation.
327///
328/// Only `role: "tool"` and `role: "assistant"` are candidates. `role: "system"`
329/// and `role: "user"` are never truncated.
330#[inline]
331fn is_truncation_candidate(msg: &Value) -> bool {
332    matches!(
333        msg.get("role").and_then(Value::as_str).unwrap_or(""),
334        "tool" | "assistant"
335    )
336}
337
338#[cfg(test)]
339#[path = "truncation_tests.rs"]
340mod truncation_tests;