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;