Too Big to Read: How TruvaG3 Fits a Megabyte of Results Back Into the Prompt

Ask an AI agent to dig through a system's logs and metrics and one tool call can return a megabyte of JSON, another a few hundred log streams. That result does not just get stored — it has to go back into an LLM, twice: once for the agent to decide what to do next, and once to write the final answer. Put the raw payload in a prompt and you can blow the context window or, worse, quietly degrade the model's reasoning. This is TruvaG3's[1] answer: result compaction — three layers, two prompt paths, and one invariant that makes throwing data away safe. It runs automatically, and everything below was checked against the source.

Neelabh Tripathi · July 20, 2026

Contents

The result that doesn't fit

This whole article is built on one real run, so let's start there. TruvaG3[1] is an open-source framework for building LLM agents — programs that answer a request by writing a plan, calling tools to gather data, and then using an LLM to turn what came back into a final answer. One of its bundled example agents[4], a DevOps assistant[5], was asked a broad, data-hungry question — "Analyze the cluster and the pods health. Tell me if there are any issues in the services by checking the logs and other data points that may reveal the details. I want the analysis from the last 6 hours of data." To answer it, the agent wrote a plan and made 28 tool calls (it calls each one a step), grouped into 4 rounds of plan-then-run (each round is a phase), using three tools: devops-tool (which runs kubectl, the Kubernetes command-line tool), devops-observability-tool (which fetches logs from Loki, a log database), and prometheus-query-tool (which reads metrics).

The results those tools returned were not small. Together they came to 2.71 MB, and the single biggest — one kubectl dump — was 855 KB on its own; a dozen of the twenty-eight were large enough to need compaction. None of this is unusual — a single broad question routinely returns results this large.

Here is the problem. A step's result is not the end of the line — the plan is not finished. That result has to be read back by an LLM so the run can continue: the agent needs to see what happened in order to plan its next steps, and, at the very end, an LLM call has to read the results to write the final answer (this last call is the one TruvaG3 calls synthesis). Either way, a megabyte of kubectl output has to fit into a prompt — and a prompt has two ceilings, not one.

The first ceiling is hard and obvious: exceed the model's context window and the call simply fails. The second is softer, and it is the one that actually causes the trouble. Reasoning accuracy degrades well before the context is full — the framework's own effective-prompts research[6] shows accuracy on a controlled task falling from roughly 0.92 to 0.68 as inputs grow into the low thousands of tokens. A prompt stuffed with raw payload is not just risky; it is a prompt that reasons worse. The model has to find the two log lines that matter among the two hundred it was handed, and every irrelevant kilobyte makes that harder.

Result compaction is the framework's answer: shrink each result to fit a budget while preserving the signal the model actually needs — and do it automatically, so the people writing agents never have to think about it. Any agent connected to an LLM gets it out of the box; there is nothing to opt into. The payoff, in this run: those 2.71 MB of raw results — roughly 812,000 tokens if you fed them into a prompt whole — were compacted into a synthesis prompt of just 25,090 tokens, and 87% of all the token-reading happened on a cheap "fast" model, leaving the expensive reasoning model to see only the compacted result. Here is the entire run, drawn by the registry viewer — a tool bundled with TruvaG3 that renders each run as an inspectable graph:

The registry-viewer Execution DAG for the run. A header reads devops-chat-agent, ID orch-1784568113494162491, 28 steps, Total 378.50s, Success, 30 LLM calls, 4 Phases. Below, a vertical graph flows top-to-bottom: the agent node, a History Prepare hook, a Tier Select and Planning node, then Phase 1 fanning out to devops-tool and prometheus-query-tool and devops-observability-tool nodes, a Phase 1→2 boundary, then Phase 2, Phase 3 and Phase 4 each preceded by a Tier Select and Phase Plan (continuation) node and fanning out to tool nodes. Many of the devops-observability-tool and devops-tool nodes carry a scissors label like '594.1KB→2.3KB' and are wired to purple Distill nodes. The final phase feeds an Event Summary node, then a Synthesis (Stream) node, then Complete.
Figure 1. The whole run as a reasoning graph — request orch-1784568113494162491[9]. Four phases of tool calls flow into a final Synthesis (Stream). The purple Distill nodes are result compaction happening: each one is a fast-model call shrinking an oversized tool result (the ✂ 594.1KB→2.3KB labels) before it reaches a prompt. One result — a 855 KB devops-tool dump in the last phase — fans out into a whole row of Distill nodes; that is map-reduce, which we get to below. Expand the image and the labels blur — but the colors do not, and the key below decodes them.
How to read the graph above
Green — tool step. The agent runs a tool (kubectl, Loki, Prometheus) to fetch data. This is the only place the big results are produced.
Purple — Distill. A compaction call: an oversized tool result being shrunk before it can enter a prompt. These are what this article is about. (The single purple node at the very top is the agent itself.)
Blue — orchestration LLM call. The model choosing which tools to use, writing the plan, and doing the final synthesis.
Amber — lifecycle hook. Memory preparation before planning, and a summary after the answer is written.
Teal dashes — phase boundary. The divider between one round of plan-then-run and the next. This run has four.
Read top to bottom, the shape is the run: prepare (amber) → plan (blue) → a fan of green tool steps, each big result paired with a purple Distill → a phase boundary, then the next round, four times over, ending in the blue synthesis.
A legible close-up of one phase (Phase 3) in the registry-viewer Execution DAG. A Tier Select node and a Phase Plan node sit above a fan of tool nodes: three green devops-observability-tool nodes labelled with scissors and sizes 98.4KB to 2.1KB, 97.7KB to 2.4KB and 112.5KB to 1.6KB, three green prometheus-query-tool nodes with no size label, and a green devops-observability-tool node labelled 199.2KB to 2.1KB. Each labelled tool node is wired down to a purple Distill node. A dashed Phase 3 to 4 boundary sits below.
Figure 2. One phase, close enough to read — the same graph as Figure 1, zoomed into Phase 3[11]. Now the labels are legible: four devops-observability-tool results (✂ 98.4KB→2.1KB, 97.7KB→2.4KB, 112.5KB→1.6KB, and 199.2KB→2.1KB), each green tool node wired to a purple Distill node that shrank it before synthesis. The small prometheus-query-tool results were under the 16 KB threshold, so they have no Distill node at all. Below, the dashed Phase 3 → 4 boundary starts the next round.

Where synthesis sits: the life of a query

That run has a shape, and it is worth seeing before we open up the compaction itself — because the word "synthesis" only makes sense once you know where it sits. A TruvaG3 agent answers a request as a short lifecycle, and nearly every stage is an LLM call. The planner picks tools and writes a plan; the executor runs the steps, and this is the one place the large data is born — a tool returns a result. Completed results are kept whole, in memory. Then, while the plan still has phases to go, the planner re-reads those results and plans the next one (continuation, or "replan"); when the plan is finished, one last AI phase — synthesis — turns the results into the answer.

LLM / AI phase tool / data step compaction hand-off User query ① PLAN · LLM pick tools, write the plan ordered steps ② EXECUTE STEPS · tools ◆ tools run — the data is born here results up to ~855 KB each in the sample run every result stored STEP RESULTS · full, in memory nothing is deleted — {{step-N.field}} resolves here compaction changes only what a PROMPT sees Layer 3 · digest while more phases Layer 2 · distill when plan done ③ CONTINUATION · LLM · replan re-read completed steps as digests, plan the next phase ④ SYNTHESIS · LLM · terminal read results as distilled summaries, compose the answer — the last AI phase ↺ next phase Final answer → user
Figure 3. The lifecycle as produce once, read twice. Purple stages are LLM calls; the teal stages are where tools run and where results are held. Everything is produced in one place — EXECUTE — and read back by two AI phases through compaction: the continuation planner mid-run via structure-only digests (Layer 3, left), and synthesis — the terminal phase — via LLM distillation (Layer 2, right). The two readers want different things from the same results, which is why they get different compaction. The replan loop runs once per phase; in the run above it turned four times before synthesis fired.

The picture makes the two facts this whole article rests on visible at a glance. First, the data is born in exactly one place — step execution — and everything after it is a consumer. Second, of those consumers, exactly two are AI phases that must read whole step results back into a prompt: the continuation planner between phases (the "replan" path) and the synthesizer at the very end (the "terminal" path). Those two hand-offs are what the rest of this piece is about. The other LLM calls in the run — choosing which tools to use, writing the plan, and micro-resolution (filling in a step's inputs from an earlier step's result) — either produce only small text or read a single result through the same deterministic floor, so they don't drive the design.

Two paths back into the model

The key to the whole design is a distinction the guide makes up front: a result is compacted differently depending on where it is headed. There are exactly two prompt paths for a step result, and they want completely different things from it.

The first is synthesis — the final-answer prompt. Here the model is writing prose for the user, so it wants meaning: of these two hundred log lines, which ones actually bear on the question? The second is continuation planning — the prompt used between phases when the planner re-reads the completed steps to decide what to do next. This is the "replan" path, and it is subtly different: the planner does not need the values in a result at all. It needs the result's shape — enough to reference a field in the next step and to avoid re-issuing work it has already done.

one step's result up to ~1 MB Layer 2 · distillation (LLM) Layer 3 · digest (no LLM) SYNTHESIS — the final answer wants meaning: which lines answer the user? distilled to ~4 KB on a fast model CONTINUATION — the next plan wants shape: which fields can I reference? digested to a ~600 B skeleton Either way, the full untouched result stays in step memory — compaction only changes what a prompt sees.
Figure 4. The same result, two destinations, two mechanisms. Synthesis gets an LLM-selected summary; continuation planning gets a structure-complete skeleton. The split is the whole design — because "the final answer" and "the next decision" need different things, they get different forms of compaction.

Three mechanisms serve those two paths, and it is worth naming them before we walk each one. Result trimming (the StructuralTrimmer) is a deterministic, LLM-free floor. Result distillation (the LLMDistiller) is the LLM-first primary path for synthesis. Continuation digests are the structure-only skeletons for the planning path. The guide lays them out as a grid, and it maps cleanly onto the code:

LayerRuns onLLM?What it produces
Result Trimming (StructuralTrimmer)synthesis floor; resolution-source and tool-input pathsNoa size-bounded subset — a valid JSON body and a [trimmed …] note, or plain-text truncation
Result Distillation (LLMDistiller)the synthesis promptYes (fast model)a query-relevant summary
Continuation Digeststhe continuation planning promptNo (rarely yes)structure-complete JSON skeletons

The invariant that makes forgetting safe

Before any of the shrinking, one property has to be true, or the whole scheme is reckless: compaction only ever changes what the LLM sees in a given prompt. The complete, untouched result stays in memory: the orchestrator — the component that runs the plan-and-execute loop — keeps every step's full result. This is not a detail; it is the foundation the whole design rests on.

Here is why it matters. A TruvaG3 plan wires steps together with templates — a later step can say {{step-9.response.data.streams}} to feed the entire result of step 9 into a tool call. Those templates resolve against the full value at execution time, out of memory, not out of whatever summary a prompt happened to show. So the planner can look at a six-hundred-byte skeleton that merely tells it "there's a data.streams array of 200 entries," write a step that references it, and the executor will bind all 200 entries at run time. The summary is a view for reasoning; it is never a replacement for the data. That is exactly what makes lossy compaction safe: you are never deleting anything the run might still need — you are only deciding how many prompt tokens to spend showing it to a model.

On top of that invariant sit three design principles that recur in every layer of the code:

Layer 1: the structural floor

The bottom layer is the StructuralTrimmer — deterministic, LLM-free, and therefore fast, free, and incapable of failing. It never calls a model, which is precisely why it can serve as the fail-open floor for everything above it. When distillation is switched off, or there is no AIClient, or an LLM call times out, this is what runs.

What it does is whole-unit structural selection to fit a byte budget. It inventories the result's fields and array items, then keeps whole units in breadth-first order — shallowest first, and largest-first within a depth — descending into a unit only when it is too big to keep whole. A field that fits is kept intact rather than picked apart. Units that do not fit are dropped and counted (FieldsDropped). In the code, this is a sort by depth ascending, then by size descending; the comment calls it the "whole-unit guard," and it is what keeps the output structurally coherent instead of a scattering of disconnected fragments.

Two details are worth pinning down because they are easy to get wrong. First, a top-level array is filled from its head, but a nested array is first capped to its first 500 items (maxArrayInventoryItems = 500) and then run through the same whole-unit sort — so its kept items are not a strict head sample. Second, the primary cut is not a relevance ranking. Relevance scoring exists in the trimmer, but it only drives a secondary backfill pass and key ordering; picking the query-relevant content is distillation's job, one layer up. The trimmer preserves shape, not meaning.

On the JSON path, the kept payload is valid JSON, but the trimmer appends a short plain-text annotation after it — so the returned string is a JSON body followed by a note, not a pure-JSON document. A non-JSON input (logs, plain text) skips structure entirely and is reduced as plain text — keyword-scored sentence selection, not a blind head cut — with the same kind of annotation. And there is a safety floor beneath the floor: if a trim keeps under 5% of the source (degenerateKeptRatio = 0.05), the note escalates to [severely reduced … treat anything NOT shown as UNKNOWN], because at that point the sample is no longer representative and the synthesizer must not read absence into it.

The trimmer runs in several places, each with its own budget. The two that matter most for synthesis are a 16 KB per-result cap and a 32 KB total-prompt cap. One default is deliberately surprising:

BudgetDefaultPurpose
Per-result cap16 KBceiling per result when several share one synthesis prompt
Total prompt32 KBall results combined in one synthesis prompt
Micro-resolution source64 KBsource data fed to parameter resolution / semantic retry
Agent input (tool→tool)0 — no capone tool's output feeding another tool's input

That last row is the surprising one, and it is a design statement. When one tool's output feeds another tool's input, the downstream tool gets the full upstream result by default — no cap. The reasoning is fidelity-first: a tool consuming data programmatically usually needs all of it, unlike a model consuming it to reason. Compaction is for prompts, not for pipelines. (You can set a cap or supply a custom processor if you specifically want to bound inter-tool payloads — but you have to ask for it.)

Layer 2: distillation, the synthesis path

Structural trimming preserves shape, but for the final answer you usually want meaning. That is what distillation does, and it is the primary compaction path for the synthesis prompt. It is on by default; it kicks in when a result exceeds the 16 KB DistillThreshold and the agent is connected to an LLM. When it runs, it is a two-stage pipeline.

Stage 1 — structural pre-filter. The StructuralTrimmer from Layer 1 reduces the raw result to the 128 KB PreFilterBudget, so the model is never handed the entire multi-megabyte payload. Stage 2 — LLM distill. A fast model — Haiku, gpt-4.1-mini, or gemini-flash-lite, reached through the portable fast alias — summarizes the pre-filtered content down to roughly the 4 KB TargetSize, conditioned on the user's original query and the step's task so it keeps what is relevant to both.

1 KB 10 KB 100 KB 1 MB 525 KB → map-reduce raw kubectl -o json ≈ 1 MB Stage 1 · structural pre-filter · 128 KB Stage 2 · LLM-distilled summary · 4 KB
Figure 5. The synthesis reduction, on a log scale. A megabyte of raw JSON is pre-filtered to 128 KB deterministically, then distilled by a fast model to about 4 KB. The dashed marker at 525 KB is the boundary where a single distill call is no longer enough — the subject of the next section.

The distiller's system prompt is what makes the 4 KB trustworthy. It does not tell the model "summarize this." It tells the model it is a selector, not a paraphraser: first decide whether the data is evidence (records, log lines, rows, search hits) or narrative (prose). For evidence, copy the matching units verbatim — exact text, identifiers, primary keys, timestamps, numeric values, byte for byte. Only narrative gets paraphrased, and even then identifiers and numbers are preserved exactly. If nothing is relevant, the model must emit a specific sentinel (No matching entries found) rather than inventing an answer, and it may state that something is absent only after inspecting the whole input.

You can watch that play out on a real result from the run[9]. Step-16 asked for error logs from two services and got back 97.7 KB of nested, escaped Loki JSON — this is the shape the fast model had to read (excerpt):

{"data":{"query":"{service_name=~\"arxiv-tool|clinical-trials-tool\"} |~ \"(?i)error|warn|…\"",
"source":"loki","streams":[{"entries":[
  {"line":"{\"component\":\"framework/core\",\"key\":\"truvag3:services:clinical-trials-tool\",
   \"level\":\"WARN\",\"message\":\"Service not found for health update\",
   \"service\":\"clinical-trials-tool\",\"timestamp\":\"2026-07-20T17:18:17Z\"}"},
  …(50-plus more entries like it — 97.7 KB in all)…
]}]}}

The fast model returned 2.4 KB — and, crucially, it did not paraphrase. It pulled out the matching WARN lines and copied them verbatim, exact timestamps and full JSON intact (compare the first line here with the input above), then added a health summary and one line of judgement:

# Error/Warning Logs: arxiv-tool & clinical-trials-tool (Last 6 Hours)

## WARN Logs (Service Health Update Issues)
**clinical-trials-tool:**
- 2026-07-20T17:18:17Z: `{"component":"framework/core","key":"truvag3:services:clinical-trials-tool","level":"WARN","message":"Service not found for health update","service":"clinical-trials-tool","service_id":"clinical-trials-tool","timestamp":"2026-07-20T17:18:17Z"}`
- 2026-07-20T16:43:15Z: `{…}`
- 2026-07-20T16:27:19Z: `{…}`
**arxiv-tool:**
- 2026-07-20T17:18:24Z: `{…}`   (…and 2 more)

…(then a "Health Status Summary" section — success rates, uptime, failure counts per service)…

## Analysis
Both services show recurring WARN "Service not found for health update" at regular intervals
(~15 min apart). However, heartbeat summaries indicate 100% success rates with zero failures,
suggesting the warnings relate to health-check registration rather than operational failures.

The other half of that contract is knowing when there is nothing to report. Step-13 went hunting for OOMKilled, CrashLoopBackOff, and pod-eviction events across every service. The 39 KB of logs that came back were only advisory debug chatter — recommendations about monitoring, not actual incidents — so rather than invent a failure, the model said exactly that (its full output, 0.6 KB):

No matching entries found

The log query executed against the `truvag3-examples` namespace with the regex pattern
`(?i)OOMKilled|CrashLoopBackOff|ImagePullBackOff|evict|killed|oom|out of memory` returned only
DEBUG-level AI response logs from the devops-chat-agent service containing observability
recommendations and patterns. These are advisory logs about how to monitor for such conditions,
not actual incident records showing OOMKilled, CrashLoopBackOff, ImagePullBackOff, or pod
eviction events.

The data contains no direct evidence of these pod failure conditions occurring in the last 6
hours across the services queried.

Those two outputs are the whole trust argument in miniature: when the evidence is there it is copied, not summarized away; when it is not, the model reports the absence instead of hallucinating an incident. Both of these — not the 97.7 KB and 39 KB behind them — are what the synthesizer, and the final answer, were built from.

One more piece of cleverness lives in the budget allocator. When several results share one synthesis prompt, the allocator is distillation-aware: a result that will be distilled is budgeted at its post-distill footprint (~4 KB), not its raw megabyte. Without this, one large result would grab the whole budget and starve the others; with it, the allocator knows that result is about to shrink and sizes it accordingly. And if there is no AIClient at all, distillation is simply skipped and you get the structural floor at the synthesis budgets — a safe, trimmed result, never a failure.

When one call isn't enough: map-reduce

Here is the problem the 128 KB pre-filter creates. If you pre-filter a 1 MB result down to 128 KB and then hand that to the model, you have thrown away seven-eighths of the source before the LLM ever looked at it — and the one log line that proves the incident might have been sitting at byte 500,000, in the part you discarded. Pre-filtering is fine when the result is merely large. It is dangerous when the result is huge and the finding could be anywhere in it.

So when a result's estimated token count exceeds ModelContextTokens — 150,000 tokens, which, at the framework's estimate of ~3.5 bytes per token, is about 525 KB — distillation switches to map-reduce, the classic split-into-chunks, summarize-each, then-combine pattern, run over the full result:

  1. Split the whole result into chunks of at most 128 KB, preferring structural boundaries: top-level JSON array elements first, then the elements of the dominant array inside a JSON object, then newline-delimited records, and only as a last resort a raw byte split.
  2. Map — distill each chunk concurrently (up to MapConcurrency, default 8) on the fast tier.
  3. Reduce — combine the per-chunk extracts into one final summary with a last fast-model call.
full result · 855 KB — step-28's kubectl dump (chunked in full — never pre-filtered away) split into chunks ≤ 128 KB → 7 chunks, all distilled in one concurrent wave (MapConcurrency = 8) distill chunk 1/7 distill chunk 2/7 distill chunk 3/7 distill chunk 4/7 distill chunk 5/7 distill chunk 6/7 distill chunk 7/7 reduce → one summary 3.7 KB The whole 855 KB reached an LLM across 7 chunks — 100% coverage. A positional truncation would miss anything past the first 128 KB.
Figure 6. Map-reduce over step-28's 855 KB result from the sample run. Seven chunks distilled in parallel on the fast tier — one wave, under the concurrency cap of 8 — then one reduce call combined them into a 3.7 KB summary at 100% source coverage. Because the whole result is chunked rather than pre-filtered, nothing is thrown away before the model reads it. (Here the chunker fell back to a byte split — the kubectl JSON had no clean array boundary to cut on — which stays byte-lossless even though it can tear a record mid-JSON.)

The chunker proves its value on the awkward shapes. When a result is an object wrapped around a dominant array — {"data":{"result":[…]},"status":…,"stats":…}, the shape a Loki or Prometheus response takes — it does not just chunk the array and drop the wrapper. Since a recent hardening pass, each grouped chunk replicates the wrapper and sibling fields, so the surrounding context (status, stats) reaches every chunk's model call rather than being dropped with everything outside the array. An array element too big for one chunk is handled as one or more standalone chunks — split recursively, never truncated. And when every element is oversized and split out, the wrapper still gets emitted once with a sentinel marker in place of the array — so an emptied array is never misread by the model as an authoritative "zero records."

A zoomed view of the final phase in the registry-viewer Execution DAG. A Phase Plan node fans out to several tool nodes: four devops-observability-tool nodes labelled with scissors and sizes 98.4KB→2.3KB, 193.2KB→1.3KB, 189.2KB→1.7KB and 192.6KB→1.2KB, two small prometheus-query-tool nodes with no compaction, and one devops-tool node labelled 854.5KB→3.7KB. Each devops-observability-tool node wires to a single purple Distill node, while the 854.5KB devops-tool node wires to a whole row of eight Distill nodes on the right. Below, an Event Summary node feeds a Synthesis (Stream) node and then Complete.
Figure 7. The map-reduce from Figure 6, in the registry viewer[11]. Step-28's devops-tool result (854.5KB→3.7KB) fans out into the row of Distill nodes on the right — the seven chunk extracts and one reduce. The other big results in the same phase — the 98–193 KB devops-observability-tool results — each took the ordinary single-call distill path, one Distill node apiece (✂ 193.2KB→1.3KB, and so on).

That is not a hypothetical — it is step-28 of the sample run. The 855 KB kubectl dump cleared the ~525 KB context threshold, so the distiller chunked it into seven segments, ran all seven as concurrent fast-model calls, and reduced them into a single 3.7 KB summary with the whole result represented (source_coverage_ratio = 1.0, content_lost = false). That is eight cheap fast-model calls to read most of a megabyte — so the expensive reasoning model never had to.

What a shrunk result admits it dropped

A compaction system that quietly discards content is a liability, because the model downstream cannot tell a trimmed result from a complete one — and will happily conclude "there are no errors" when what it means is "there are no errors in the 4 KB I was shown." TruvaG3's answer is a hard rule: whenever any layer deterministically drops content, it appends a single-line note and sets an authoritative content_lost flag in the trim metadata. Every note starts with a [ so it is machine-parseable and strippable, and every note tells the downstream model the same thing — treat what is omitted as UNKNOWN, not absent.

That flag is the one signal downstream honesty keys on. Not byte ratios — re-serialization can shrink bytes with nothing lost, and escape-unwrapping can grow bytes while content was dropped — but the explicit content_lost bit, which every lossy operation is required to set. The framework even folds any specific loss flag into it defensively, so a report can never claim "verified lossless" while also flagging partial coverage. Here are the notes you can actually see in a result:

NoteEmitted byWhat it means
[trimmed: …]Structural trimWhole fields, items, or sentences were dropped to fit the budget.
[severely reduced: …]Structural trimUnder 5% of the source survived — too little to be representative.
[partial source: ~N% …]DistillationStage-1 pre-filter dropped content before the LLM (the 128–525 KB band).
[partial: N of M segments analyzed …]Map-reduceOnly N of M chunks completed — a deadline or a failed chunk call.
[findings truncated: …]Map-reduce reduceThe combined extracts were truncated to fit the budget.
[reduced without model analysis: …]Synthesis floorA byte-truncation fallback ran and no LLM analyzed the dropped tail.

There is a nice piece of discipline hiding in what is not on that list. The distiller's system prompt also asks the model to end an over-budget summary with its own [truncated: …] line — but that model-emitted form is deliberately not registered with the framework's annotation stripper. The reason: real tools emit their own [truncated: …] trailers, and if the framework stripped that pattern, it would silently delete a tool's own truncation signal, presenting visibly truncated source as complete. The authoritative, framework-guaranteed disclosure is the [partial source: …] note, not the model's best-effort one. It is the kind of distinction you only make after the alternative has caused a real problem.

Layer 3: digests, the planning path

Now the other prompt path — the one that fires during replanning. Between phases, the continuation planner re-reads the completed steps to decide what comes next. It would be wasteful to distill each one (an LLM call per step, on a path that runs every phase), and destructive to truncate them (which mangles JSON). So completed-step results are rendered as structure-complete JSON digests instead.

A digest is a skeleton of the result that is always valid JSON. An object keeps all its keys up to a 50-key cap; wider "map-shaped" objects are sampled to sorted keys plus a sentinel. Arrays are head-sampled to 3 elements plus a length sentinel. Long strings — anything over 200 characters — are shortened to a placeholder like "…(16384 chars)". A 519 KB Loki result collapses to a roughly 600-byte skeleton that still tells the planner everything it needs to know about the shape of what came back:

{
  "status": "success",
  "data": {
    "resultType": "streams",
    "streams": [
      { "stream": { "pod": "tool-abc", "level": "error" },
        "values": [
          ["1721000000000000000", "…(16384 chars)"],
          ["1721000000000000001", "…(12000 chars)"],
          ["1721000000000000002", "…(8192 chars)"],
          "…197 more of 200"
        ]
      },
      { "stream": { "pod": "tool-def", "level": "warn" },
        "values": [["1721000000000000003", "…(4096 chars)"]]
      },
      { "stream": { "pod": "tool-ghi", "level": "info" },
        "values": [["1721000000000000004", "…(2048 chars)"]]
      },
      "…197 more of 200"
    ]
  },
  "stats": {
    "ingester": { "compressedBytes": 123456 },
    "summary": { "totalLines": 400 }
  }
}

This is the addressability idea, and it is deliberate: digests are lossy on values but complete on structure. The planner never needs the 200 log streams — it needs to know they exist, and where. It writes a plan that references {{step-9.response.data.streams}}, and, per the invariant from earlier, the executor binds the entire 200-entry array from memory at run time. The planner reasoned over 600 bytes; the next step will act on the full half-megabyte.

The budget for this section is a greedy recency-fill, and it is a soft target (32 KB) that drives eviction rather than a hard cap. The rules encode what the planner most needs to see:

Not every result is JSON, of course — logs, markdown, and CSV have no structure to digest. Those get a plain floor preview (sized by a 10,000-character cap), and a capped number of the newest non-JSON steps (default 8; set 0 to disable) may escalate to a separate continuation distiller for a fast-model summary that replaces the floor preview. This is the "rarely yes" from the table at the top: on an all-JSON workload it almost never fires, and when it does, it is fail-open — an empty summary just leaves the floor preview in place.

Cache, deadlines, and what it costs

Distillation makes real LLM calls, so it has a real cost — and the design spends carefully. The reading is done on the fast tier, several times cheaper per token than the reasoning model (roughly 3× on a Sonnet→Haiku stack, more against a frontier reasoning model). A run that distills several megabytes does the bulk of that reading cheaply and reserves the expensive model for the actual answer — in the sample run, distillation accounted for 683,956 of the 784,965 input tokens the whole request read (87%), all of it on the fast tier, while the reasoning-tier synthesis prompt was just 25,090 tokens. Map-reduce chunks run concurrently, so wall-clock time is the slowest wave plus the reduce, not the sum of all chunks. And below the threshold, nothing happens at all — a small result takes a cheap structural pass, or passes through raw if it already fits.

Two more mechanisms are worth naming, because both are strictly fail-open. The first is caching, which is opt-in: there is no cache unless you supply one (a core.DigestCache, such as one backed by Redis), so out of the box every distillation recomputes. When a cache is supplied, matching inputs — the result, the instruction, the user query, and the budget, plus a configuration salt covering the prompt version, model, target size, and routing knobs — reuse a prior distillation for a 5-minute TTL, turning scheduled and repetitive runs into cache hits. Crucially, a failed or partial output is never cached: a deadline cut, or a map-reduce run in which some chunks failed, is marked non-cacheable so a transient error cannot be served for the whole TTL. A nil or erroring cache simply recomputes — it never fails.

The second is the deadline: each compaction is bounded by a 45-second wall-clock limit, which should be set below your gateway timeout. On a timeout, the single-call path falls back to the structural floor, and the map-reduce path returns the chunks that finished plus the honest [partial: …] disclosure. The synthesis hot path is never blocked past the deadline — it degrades to a smaller, labeled result instead. This is the fail-open principle made concrete: every knob that could slow the request down has a defined, disclosed way to give up gracefully.

All of it is observable. Every distillation emits telemetry you can watch in your monitoring stack — trace events (result_distill.mapreduce_route, stage1_complete, stage2_complete, mapreduce_complete, cache_hit, llm_failed) and metrics (triggered, duration_ms, failed, mapreduce, cache_hit/miss) — and every processed step carries a result_trim record with the authoritative content_lost flag and the coverage numbers. Turn on TruvaG3's LLM-debug mode and each distill call is captured in full — prompt, response, tokens — including the individual map-reduce chunk i/N calls, shown next to the planning and synthesis calls in the registry viewer. The companion observability walkthrough[8] shows exactly what those surfaces look like on a real run.

The whole decision, in one picture

Put the layers back together and every result a step produces runs through one decision, determined entirely by where it is going next:

result produced by a step
│
├─ used as INPUT to another tool ──────────▶ pass through RAW (fidelity-first;
│                                             cap only if MaxAgentInputBytes > 0)
│
├─ used as SOURCE for param resolution ────▶ StructuralTrimmer to 64 KB
│                                             (deterministic — never the LLM distiller)
│
├─ headed to the SYNTHESIS prompt ─────────▶ ≤ 16 KB?  structural trim to the budget
│                                             > 16 KB + AIClient?  distill (fast model)
│                                                 └─ > model context (~525 KB)?  map-reduce
│                                             no AIClient?  StructuralTrimmer floor
│
└─ headed to the CONTINUATION prompt ──────▶ valid JSON?  structure-complete digest
                                              non-JSON?  floor preview (+ rare escalation)
                                              whole section under a soft 32 KB budget
                                              (newest-first; failed + newest always kept)

Read it top to bottom, and the philosophy is visible in the branching. A tool-to-tool handoff gets the raw truth, because a program needs all of it. A resolution prompt gets a deterministic trim, because it drives its own LLM call and must not be pre-summarized. The final answer gets an LLM that reads for meaning, escalating to map-reduce only when the result is too big to read in one pass. And the planner gets a skeleton, because it is choosing the next move, not writing the answer. Four destinations, four treatments — and behind all of them, the same guarantee that the full result is still sitting in memory, untouched.

Conclusion

The uncomfortable truth about agentic systems is that the interesting results are big, and the model that has to reason over them has a small window and a shorter attention span. You cannot simply put a megabyte of kubectl output into a prompt and hope it works. You will either exceed the context window outright, or — long before that — quietly degrade the model's reasoning. TruvaG3 treats that as a first-class problem and solves it in the place it belongs — automatically, inside the orchestrator, before a single oversized result ever reaches a prompt.

What makes the solution more than a truncation hack is the shape of it. The split by destination means the planner gets structure and the synthesizer gets meaning, each exactly what it needs. The invariant that the full result never leaves memory means every summary is a safe view of the data, never a permanent deletion. The fail-open discipline means no compaction step can ever be the reason a request errors. And the honesty contract — every loss disclosed, every omission flagged UNKNOWN rather than absent — means the model downstream is never quietly misled about what it is and isn't seeing. A megabyte goes in; a few kilobytes come out — the ones that actually matter — and the request finishes cleanly. That is what result compaction is for.

References

  1. TruvaG3 — repository. The open-source reference implementation of the microagents framework and the source of every threshold and code path described here. https://github.com/truvaagents/truva-g3
  2. TruvaG3 — Result Compaction Guide. The companion conceptual reference: three layers, two prompt paths, the full configuration surface, tuning scenarios, and troubleshooting. docs/orchestration/RESULT_COMPACTION_GUIDE.md
  3. TruvaG3 — orchestration/ package. The code that is the source of truth for this article: result_processor.go (trim metadata, budget allocation, continuation digests), result_distiller.go (the two-stage LLM distiller), result_mapreduce.go (chunking and map-reduce), structural_trimmer.go (the deterministic floor), and synthesizer.go (the synthesis prompt). https://github.com/truvaagents/truva-g3/tree/main/orchestration
  4. TruvaG3 — examples/ directory. The bundled tools and agents, including the DevOps example agents and tools whose oversized results motivate compaction. https://github.com/truvaagents/truva-g3/tree/main/examples
  5. TruvaG3 — examples/devops-chat-agent/. The data-heavy agent used as the running example. Paired with devops-tool and devops-observability-tool, a single deep-analysis query fans out into the megabyte-scale kubectl and Loki results this article compacts. https://github.com/truvaagents/truva-g3/tree/main/examples/devops-chat-agent
  6. TruvaG3 — Effective Prompts Guide. The framework's prompt-engineering reference, including the "bloat threshold" research behind keeping prompts lean — the measured drop in reasoning accuracy as inputs grow into the low thousands of tokens. docs/building/EFFECTIVE_PROMPTS_GUIDE.md
  7. TruvaG3 — Conversation History Guide. The adjacent, separate context-management layer that compacts long chat histories across turns. It uses different inputs and knobs, and it composes without overlapping result compaction. docs/memory-and-chat/CONVERSATION_HISTORY_GUIDE.md
  8. TruvaG3 — Observability Deep Dive (companion article). One request followed through Jaeger traces, correlated Loki logs, and the registry-viewer Execution DAG, where the distillation spans and LLM-debug records described here become visible. https://truvag3.dev/blogs/truvag3-observability-deep-dive.html
  9. TruvaG3 — execution DAG for this run (JSON). The complete execution record for request orch-1784568113494162491: the plan, all 28 step results, per-step trim metadata (original bytes, distilled bytes, coverage, chunk strategy), and all 30 LLM interactions with their prompts and token counts. Every number in this article is taken from it. https://assets.truvag3.dev/blogs/result-compaction/execution-dag.json
  10. TruvaG3 — raw Jaeger trace for this run (JSON). The distributed trace for the same request, including the result_distill.* span events (map-reduce routing, stage-1/stage-2 completion, per-chunk calls) referenced throughout. https://assets.truvag3.dev/blogs/result-compaction/jaeger-trace.json
  11. TruvaG3 — registry-viewer screenshots for this run (PNG). The Execution DAG shown in Figures 1, 2 and 7 — the full graph, a legible Phase-3 close-up, and the final-phase map-reduce fan-out — captured from the registry viewer at registry.localhost. registry-execution-dag.png · registry-dag-phase3.png · registry-dag-mapreduce.png

Cite as: Tripathi, N. (2026). Too Big to Read: How TruvaG3 Fits a Megabyte of Results Back Into the Prompt. https://truvag3.dev/blogs/truvag3-result-compaction