I was staring at a 400 Bad Request that made no sense.
The payload was under the byte limit. It was valid JSON when I sent it. But the downstream service — the thing that stores agent session state so a Claude Code session can resume where it left off — kept rejecting it. Same error, every retry, no matter how many times I re-sent.
This was the storage layer for my agent fleet. Each autonomous Claude Code session carries a context payload: the full conversation history, every tool output, every file read, plus the metadata the session needs to pick up mid-task without losing its train of thought. As sessions run longer, that payload grows. Eventually it crosses the size cap the storage service enforces before it’ll accept the write.
The fix looked like one line:
const trimmed = payload.slice(0, MAX_BYTES);
Chop the bytes at the limit and move on. Works fine on prose. It is a catastrophe on JSON, and I walked straight into it.
Slicing a structured context blob at an arbitrary byte boundary gives you something like {"messages":[{"role":"assistant","content":"Here is the implementa — a string cut in half, no closing quote, no closing brace. The storage service tried to parse that, choked, and returned a hard 400. Deterministically. Every single time.
Here’s the part that actually hurt: the malformed payload got stored anyway.
I’d assumed a 400 meant the service rejected the write. It didn’t. It stored the broken bytes and then returned the error. So the retry logic kicked in, re-sent the same truncated blob, and got the same 400. The record was now permanently poisoned — a one-time size overflow had become an infinite failure loop. Every subsequent attempt by that agent session to resume or sync hit the same wall. The session was bricked.
The root cause is simple in hindsight: byte length and structural validity have nothing to do with each other. A raw byte slice doesn’t know about string boundaries, doesn’t know about JSON nesting, doesn’t know where it’s safe to cut. You cannot safely shorten structured data you haven’t parsed, because you have no idea where the safe cut points are.
I’d been treating the payload as a bag of bytes. It’s a tree.
The correct approach has three steps. Parse the payload into a real in-memory object — JSON.parse for JSON, a real parser for whatever format you’re holding, never a regex, never a raw string slice. Shrink by walking the tree and truncating only the long string leaves: the assistant turns, the tool output blobs, the long reasoning traces. Leave numbers, booleans, message IDs, file paths, and timestamps untouched — mangling an ID or a path corrupts meaning while saving almost no bytes. Re-serialize back to valid JSON. The output is always well-formed because it was built from a parsed object, not from a pair of scissors.
One guard rail matters more than any other: if the parse step fails, do nothing. Leave the original untouched. A verbose-but-valid payload always beats a short-but-broken one. Compaction is an optimization; validity is a requirement. You never sacrifice a requirement to satisfy an optimization.
Recursive shrink in Node (agent context edition) give me the detail
The pattern below truncates only string leaves and preserves all structure. Use a real parser — JSON.parse for JSON, js-yaml for YAML context blobs, fast-xml-parser for XML — never a regex, never a raw slice.
function shrink(node, maxLen = 200) {
if (typeof node === "string")
return node.length > maxLen ? node.slice(0, maxLen) + "…" : node;
if (Array.isArray(node)) return node.map((n) => shrink(n, maxLen));
if (node && typeof node === "object")
return Object.fromEntries(
Object.entries(node).map(([k, v]) => [k, shrink(v, maxLen)]),
);
return node; // numbers, booleans, null — untouched
}
export function safeTrim(raw, maxLen) {
try {
return JSON.stringify(shrink(JSON.parse(raw), maxLen));
} catch {
return raw; // unparseable: never corrupt it further
}
}Test it: assert JSON.parse(safeTrim(x)) never throws, for any input — including already-broken x. If you’re shrinking agent conversation arrays specifically, you can also prefer dropping the oldest messages wholesale (pop from the front) over truncating strings mid-thought; both need a parse step first.
The rule applies anywhere you need to compact structured data to fit a budget: a model context window, a queue message, a config blob. Always parse to shrink to re-serialize, never raw-slice. And if you can’t parse it, you can’t safely shorten it — so don’t try.