← All posts

Byte-Slicing a Claude Agent's Context Payload Poisoned Every Retry

When compacting agent conversation context to fit a model token budget, a raw byte slice produced broken JSON that got stored and re-sent forever. Parse, shrink string leaves, re-serialize.

  • claude-code
  • agentic-ai
  • ai-agents
  • json
  • reliability
  • engineering

I run a fleet of Claude Code agents — autonomous sessions handling coding tasks across several Anthropic accounts. Each agent carries a structured context payload: the conversation history, tool outputs, and metadata that let it resume where it left off. As sessions grow, that payload grows too, and eventually it crosses the size limit a downstream service enforces before storage.

The fix looked trivial. If the payload is too big, trim it before sending:

const trimmed = payload.slice(0, MAX_BYTES);

This works on plain prose. It is a disaster on JSON.

Slicing an agent’s context payload at an arbitrary byte boundary leaves you with something like {"messages":[{"role":"assistant","content":"Here is the implementa — an unterminated string and no closing brace. The downstream service parsed it, failed, and returned a hard 400. Deterministically. Every time.

The part that actually hurt: the malformed payload got stored. This wasn’t a transient error that a retry would clear. The retry re-sent the same broken bytes and got the same 400. The record was permanently poisoned — a one-time size problem turned into an infinite failure loop. Every time that agent session tried to resume or sync, it hit the wall again.

The root cause is that byte length and structural validity are unrelated. A byte slice respects neither string boundaries nor JSON nesting. You can’t safely shorten structured data you haven’t parsed, because you have no idea where the safe cut points are.

The correct approach for compacting agent context has three steps. Parse the payload into a real object. Shrink — walk the tree and truncate 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 path corrupts meaning while saving almost no bytes. Re-serialize back to valid JSON. The output is always well-formed because you built it from a parsed object.

One guard rail that matters most: if the parse step fails, do nothing. Leave the original untouched. A verbose-but-valid payload is always better than a short-but-broken one. Compaction is an optimization; validity is a requirement. 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 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.