Lesson 6: Hands-On: Wiring Context Management onto the Harness
Learning goals:
- Wire token usage tracking onto a stop_reason-driven harness loop: accumulate with
response.usage, decide whether context is approaching the window limit
- Turn Lesson 4's
compact() into a threshold-triggered mechanism: set the trigger ratio, think through how messages and the usage counter should reset after triggering
- Wire structured notes into this compaction flow so
NOTES.md gets read back whenever a new window restarts, and run a task that exceeds single-window capacity
Prerequisites: You've finished Lesson 4 on compaction and notes, Lesson 5 on subagent isolation, and you have the harness loop from course 7 of this series, "Agent Harness Fundamentals: Loops and Control," within reach | Prev: Lesson 5 <<
First, See It Running
The first five lessons were all principles: why context is a finite resource, how compaction works, how notes work, how subagents isolate. This lesson welds the first two — compaction and notes — into the harness loop you wrote in course 7 of this series. First see what it looks like running, then we'll unpack the code.
Below is a real execution log (using a stub client to simulate multi-turn model responses so a long task fits in a few lines of logs; the stub client implementation appears at the end of this lesson). The task is that familiar scenario from Lesson 4: fixing a concurrency race bug in an order service. To trigger compaction within a few turns, the demo deliberately sets a tiny context window:
Read through line by line: the first three calls push tokensUsed from 400 to 1050 to 1850; after the third round's tool results are appended to history, the accumulated value crosses the set threshold, so the harness doesn't wait for the window to actually blow up — it proactively fires a compaction call (call 4; stop_reason no longer matters because this response never enters the main loop, its output is used directly to restart messages); tokensUsed resets to zero; the next two rounds count fresh in the new window until the model wraps up. The entire process has only one user-visible artifact — that final reply; compaction and notes happen offstage. The rest of this lesson is building the code behind that log, line by line.
I. Wire Token Usage Tracking onto the Loop
Step one is straightforward: know how many tokens you've used so far, because that's the prerequisite for deciding whether to compact. You already used this field when you wrote the budget valve (valve 2) in course 7 of this series — response.usage carries input_tokens and output_tokens for this call, and each time you get a response you add them to an accumulator:
Course 7's TOKEN_BUDGET valve used this accumulated value for one thing: stop when you hit the ceiling. This lesson does something else: compact proactively at a much earlier ratio and keep working, rather than stopping. Both use the same accumulator; what happens after the trigger is completely different — one brakes, the other takes a breath.
How big is the window itself? That's a constant you define in your own engineering judgment:
There's no standard answer for what COMPACT_RATIO should be — it's an engineering judgment, not a spec clause. Set it too high and by the time you realize "time to compact," the window might be so tight you can't even send the next request; set it too low and compaction will interrupt the task earlier and more often than it should, wasting model calls. As a rule of thumb, leaving about 30% headroom (i.e., a threshold of 0.7) usually works; the specific number should be tuned based on your actual model's window size and the volume of single-turn tool outputs.
II. Threshold-Triggered Compaction: Wire Lesson 4's compact() Into the Loop
With the mechanism set, the next step is wiring it into the loop body. Recall Lesson 4's conclusion: compaction doesn't cram a summary back into the old conversation and keep squeezing — it restarts a new window with the summary, abandoning the old messages entirely1. Wired into the loop, that means replacing messages wholesale at the right moment:
Three positions determine whether this code is correct:
- Where the check sits: immediately after this round's tool results are appended to
messages, before the next client.messages.create. Too early (checking before appending) misses the tool outputs just produced; too late (appending after checking) sends already-over-threshold content for an extra request.
- After compaction,
messages is replaced wholesale, not appended to: the return value of compact() is assigned directly to messages, and the old array with its dozens of tool round-trips is discarded — that's the boundary between "restart" and "keep piling onto the old conversation."
tokensUsed must reset to zero: the new window starts from a summary, so usage should count from this summary onward, not keep carrying the old window's accumulated value. Missing this step is a common trap; this lesson's exercises will diagnose it specifically.
compact() itself reuses the Lesson 4 implementation; COMPACT_INSTRUCTION and the triage principles (preserve architectural decisions, unresolved bugs, and key implementation details; discard redundant tool outputs) stay the same1. The next section adds one new capability to it: on restart, read not just the summary but also NOTES.md.
III. Structured Notes as Fallback: NOTES.md Gets Read Back During Compaction
Compaction is passive and after-the-fact — it summarizes "what's left in the window at the moment of trigger." Lesson 4 already explained that notes are active, write-as-you-go insurance: the agent writes decisions and problems to NOTES.md outside the window the moment they happen1. The way to wire the two together is straightforward: when a new window restarts, in addition to reading the summary, also read NOTES.md back in — so even if this round's summary triage made a mistake, the notes still have an independent backup.
First, give the agent a tool for writing notes:
update_notes's input is the complete note content to save, and the implementation writes it wholesale — this is the simplest semantics: the agent maintains one complete note corpus and every update means "this is the current state," with no incremental merging to handle. Wire a requirement into the system prompt: "Whenever you make an important decision, discover a new problem, or complete a phase, call update_notes to update notes before continuing," just as in Lesson 4.
Then comes this lesson's new step: after compact() generates the summary, it also reads NOTES.md into the restart message:
When the new window wakes up, it has two pieces of material: the model's own summary, and the notes the agent wrote by hand. The former may lose detail due to summary triage; the latter is lossless — that's Lesson 4's "the more diligent the notes, the lighter the consequences of compaction losing something" in code form.
IV. Put It Together: A Task That Exceeds Single-Window Capacity
Three components — usage tracking, threshold-triggered compaction, reading and writing NOTES.md — wired into the same runAgent produce the complete code behind the opening log:
This is the complete source of the opening log: three tool calls push tokensUsed from 400 to 1050 to 1850, crossing the 2000 * 0.7 = 1400 threshold line; compact() is called, messages is replaced wholesale, counter resets to zero; then two more rounds in the new window, and the model wraps up. Only one compaction happened during the entire run, but if the task continued and hit the threshold again, the same logic would trigger a second, third time — shouldCompact doesn't care which window this is, it only looks at the current window's usage. That's what "running a task that exceeds single-window capacity" means: the task's total length is not bounded by any one window's capacity, only bounded by "one continuous uninterrupted inference."
To run a full verification, hook up a stub client that simulates multi-turn model responses (swap it for new Anthropic() in real calls; the runAgent code doesn't change a character):
The value of verifying with such a stub client is that it nails down "will the model call tools this turn, how many tokens did it use" as known quantities, so whether compaction triggers at which turn, whether tokensUsed resets to zero, whether NOTES.md gets written then read back — everything can be checked with assertions instead of squinting at real call outputs and guessing.
Sense of Proportion: Not Every Task Needs This Machinery
After wiring all this up, it's easy to develop a false impression: from now on, writing agents should default to including usage tracking, threshold compaction, and structured notes as a package. Return to the sense of proportion established in Lesson 2: consider adding complexity only when it can demonstrably improve outcomes2. For tasks that finish within a dozen turns, compaction and notes are both superfluous components — start from the bare loop plus basic control valves from course 7 of this series, and only add this layer when you actually hit the window limit or see "new window doesn't know what old window did" amnesia symptoms.
At this point, everything this course taught from Lesson 1 to Lesson 6 — attention budget, altitude of system prompts, just-in-time retrieval, compaction and notes, subagent isolation — converges on the same takeaway: what the model should see each turn is always an engineering judgment you keep revisiting, not a one-time configuration you set and forget.
Recap
- Wiring usage tracking onto the harness just needs an accumulator: each time you get a response, add
response.usage.input_tokens + response.usage.output_tokens; it shares the same data with course 7's TOKEN_BUDGET valve, but the action after trigger differs — the budget valve stops when topped out; this lesson's threshold compacts and keeps working.
- Threshold-triggered compaction wires Lesson 4's
compact() into the loop body: check sits at "this round's tool results fully appended, before next request goes out"; after trigger messages is replaced wholesale with the compaction result — it's a restart, not an append1; tokensUsed must reset in sync, otherwise you fall into a storm of repeated compactions.
- Notes and compaction wire together in this lesson:
update_notes tool writes as you go — that's persisting notes outside the context window1 — and compact() reads NOTES.md back into the restart message in addition to generating the summary; summary may lose content due to triage, notes are read back as a lossless copy. "Read once only at critical moments like window restart, don't cram into every turn's system prompt" is this lesson's engineering tradeoff based on the attention budget principle (every new token depletes that budget1).
- One real end-to-end verification shows: three tool calls push usage from 400 to 1850, cross the threshold to trigger one compaction, counter resets, then two more rounds to wrap up — the task's total length is no longer bounded by single-window capacity, only bounded by "one continuous uninterrupted inference."
- Don't treat this machinery as default configuration: only add it when extra complexity can demonstrably improve outcomes2; for tasks that finish in a few turns, the bare loop plus basic control valves from course 7 of this series is enough.