The Loop Is Simple. The Harness Is Not.
The loop is still call, tool, result, repeat. The design work is the function that turns a tool result into the string the model actually reads: truncating, dropping, summarizing, writing the payload to a file, delegating the read to a sub-agent, and choosing which tools to load. One measured harness cut 3,827 of 11,189 tool results while the largest prompt used 5.75% of the window. Cut on records, return pointers, and keep budgets in code.
The Loop Is Simple. The Harness Is Not.
The simple loop is still the whole agent. Call the model, run the tool, push the result back, repeat. That picture is right, and it is where most writeups stop. Stopping there is how a harness throws away the evidence and then blames the model.
The line that changes between a demo and a system is this one:
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(result),
});
In the loop post, content is the tool output. In anything that has to survive a real tool, that string is a decision. Some code chose how much to keep, what to delete, whether to summarize, whether to write the payload to a file and hand back a path, and which tools were even offered on the turn that produced the call. The model never sees the tool. It sees whatever that function let through.
That function is the harness. The agents lesson draws the same split: every message starts or ends at the harness, and the model and the tool never talk. The questions I keep running into while building Meso, an agent runtime, all sit in that gap. The loop is settled. The delivery policy is not.
Where the evidence actually goes
Sehgal, Panahandeh, and Ezzati-Jivan measured this on an agent that reads Linux kernel traces and names the container behind an injected fault. The paper is Context Is a Pipeline, Not a Window. Across 720 runs the harness handled 11,189 tool results. The single largest prompt was 23k tokens against a 400k window, 5.75% of it. The window was not the constraint. 3,827 of those results were cut before the model saw them, and every cut result became broken JSON.
The cut was a prefix slice: serialize, keep the first N characters, drop the rest. JSON lists keys in a fixed order, so a slice does not shorten a result evenly. It deletes whatever sits past the cut, the whole field, every time. One field reached the model in 4.7% of 742 calls. A second field, in the same payloads, reached it in 99.3%. A process that starts during the trace shows up in the early list, the one that survives. A container that was already running shows up in the late list, the one the slice deletes.
They replayed the shipped 6,000-character cap on a production trace of 33,370 characters, 5.6 times that budget. The slice delivered 30 of 118 early-list records and 0 of 128 late-list records, inside a payload that no longer parsed.
A controlled run put the cut back by itself, with the agent, tools, task, and model held fixed. On the reversed-key arm, the spawned culprit was still named in 15 of 15 runs, against 14 of 15 repaired. The authors call that gap noise at this sample size. What moved was the work: median tool calls from 53 to 76, computations from 18 to 36, yield (findings recorded divided by computations run) from 0.61 to 0.31. Whether the cut causes the wider accuracy split in the field data is still open. A character slice deletes by position, and an almost empty window will not tell you.
They also read the cutting code in seven framework releases. Two cut by position and ignore structure. langchain-core 1.6.12 slices serialized tool output to 500 characters and keeps the front. smolagents 1.26.0 deletes the middle of any observation over 20,000 characters. Three do not cut tool output at all, which only pushes the loss into the window or an API error. One cuts only its own tracing data. One drops whole lines and says so. The safe pattern exists. It is not the default.
Cut on records, not characters
This is the prefix slice:
function deliverSliced(result: unknown, budget = 6000): string {
const raw = JSON.stringify(result);
if (raw.length <= budget) return raw;
return raw.slice(0, budget);
}
slice does not know it is holding JSON. The model gets a string that starts like an object and ends mid-field, and it will often keep going, because a broken tool result still looks like evidence. An error you can retry. A half object you reason from.
If the payload is a list of records, keep whole records and say what you left out:
function deliverRows(rows: Record<string, unknown>[], budget = 6000): string {
const kept: Record<string, unknown>[] = [];
for (const row of rows) {
const next = JSON.stringify({
rows: [...kept, row],
total: rows.length,
omitted: rows.length - (kept.length + 1),
});
if (next.length > budget) break;
kept.push(row);
}
return JSON.stringify({
rows: kept,
total: rows.length,
omitted: rows.length - kept.length,
});
}
Valid JSON, a total, an omission count. The model can ask for the next page. It cannot treat the first page as the whole trace. Re-serializing on each row keeps the counts inside the same budget, so the wrapper cannot lie about its own size.
Order is part of the policy. Sort by the field you care about and the surviving prefix is a choice. Leave it to key order and a late field dies for being late. Their reversal test is that fact in the data: flip the keys and a different list vanishes, same content underneath.
Silence is a policy too. omitted: 0 and a missing message are different facts. In that field data, 98 of 554 workers handed nothing on at the transfer step, and silence reads as “nothing found”. “I looked” has to be a value.
Offload the payload, return a pointer
A row cap still fails when one record is a log or a data export. Keep the bytes where they can be read back, and put a pointer in the context.
LangChain’s note on retrieval names three ways this goes wrong: search that fills the window before it finds anything, one result big enough to fill the window in a single call, and irrelevant or stale text the model then builds on. For the oversized case, their Deep Agents harness offloads a tool result over about 20,000 tokens to a file under /large_tool_results/, and the agent gets the path plus a preview of the first and last few lines. read_file pages after that, 100 lines by default. glob and grep are supposed to run before a read.
That is not the slice the paper found in langchain-core 1.6.12. Same organization, two policies, and the one that runs is the one you configured. A writeup about offloading does not disable a 500-character cut in the library you actually import.
import { writeFile } from "node:fs/promises";
async function deliverOrFile(
callId: string,
result: unknown,
budget = 8000
): Promise<string> {
const raw = JSON.stringify(result, null, 2);
if (raw.length <= budget) return raw;
const path = `/tmp/tool-results/${callId}.json`;
await writeFile(path, raw);
return JSON.stringify({
offloaded: true,
path,
bytes: raw.length,
preview: raw.slice(0, 400),
});
}
The preview may be a broken slice. It is labeled as a preview, and the path holds the object. The bug is slicing and calling the slice the result. The loop’s push then becomes content: await deliverOrFile(call.id, result).
This is the same shape as OpenClaw’s memory. Durable text lives in files. The window holds a handle, plus what you loaded this turn. The memory flush is a harness hook on the step just before summarization: a silent turn writes what should survive to memory/YYYY-MM-DD.md, the reply is suppressed, and only then does compaction summarize the older messages. Compaction is lossy on purpose. The flush is the refusal to let the summary be the only copy. The seedling that compares this file flush with a memory MCP server is Two ways to give an agent memory.
Drew Breunig’s survey of the toolbox puts offloading next to five other moves harnesses keep reimplementing: bring in what is relevant, pick a tool loadout, quarantine work in a sub-agent that starts with a blank history, prune turns, and summarize. Early compaction, he notes, often strips tool results. Fine, if the original is a file. Fatal, if the original only lived in the message list.
LangChain’s “retrieve, offload, delegate” is the quarantine in practice. Search writes each hit to a file and returns paths. A sub-agent reads one file. The main loop sees conclusions, so an off-topic article stays out. The price is the handoff. A summary with no source cannot be checked, and an empty return cannot be told from “the file was too big and I stopped”. Require findings, sources, or an explicit empty. Those 98 silent workers are what an optional handoff produces.
Load the tools this turn needs
The tool list is context too. Every schema you attach is text the model attends to. Breunig calls the control for this tool loadout, and describes an enterprise agent that loaded 600 skills for every engineer at startup and then got worse. The fix was a filter.
MCP
makes the over-attach easy, because each server arrives as a handful of tools and the default is to mount all of them. A tag filter is crude. That is the point. It fails where you can see it.
type Tool = { name: string; tags: string[]; schema: unknown };
function loadout(task: string, tools: Tool[], cap = 8): Tool[] {
const hay = task.toLowerCase();
const picked = tools.filter(t => t.tags.some(tag => hay.includes(tag)));
if (picked.length) return picked;
return tools.slice(0, cap);
}
The tags and the cap are yours. Eight is a stand-in, not a measured constant. LangChain’s skills do the softer version of the same limit: a name and a short description up front, and the full instructions only when the task needs them.
Code owns the shape
Breunig’s distinction is the one I keep. Choose what is deterministic, and what the model is allowed to drive. The name moved, from prompt engineering to context engineering to harness engineering, as the assembly of context moved into code. The choice did not.
Code owns the budget, the record boundary, the omission count, the file written before any summary, the flush before compaction, and which tools are eligible. The model owns the judgments inside that: which page to read, whether the preview is enough, whether to delegate again, which loaded tool to use. If the model also picks the budget, or can skip saying a field was dropped, you get a confident answer from a broken object.
Retrieval
sits one step earlier. Offloading a mangled parse gives the model a pointer to garbage. The harness decides what is addressable. Parsing and search decide what is worth addressing.
The keyword-plus-vector version used by that RAG layer and by OpenClaw’s memory index is written up in Hybrid search keeps showing up.
The loop will push a sliced string, an empty handoff, or every schema you own, and the model will answer. That is how a prompt can use 5.75% of the window while a third of the tool results are already gone. A short map of the notes this sits between is From loop to harness.
Building Meso keeps putting me back here. Not a new loop. Delivery, omission, and which decisions are allowed to be probabilistic. I would rather count what each boundary drops than wrap the while in another abstraction.
ciao