DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Context Is Not "More Context"

An iteration that touched two files was burning ~350,000 tokens - not because the task was big, but because whoever executed it went looking around the repository first. What fixed it was not more context. It was writing down which context, once, and forbidding the rest.


👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. Most of what I write about comes out of carving a live PHP monolith into Go services. This series is about a smaller, adjacent problem: how I hand work to an executor - human or model - so the result comes back correct without me re-explaining the codebase every time. Running notes live on my GitHub: github.com/brilliant-almazov.

These are my working habits, not a methodology. They're shaped by one specific codebase with very rigid conventions. Take the parts that transfer.


The number that started this

I was running a stage of work split into small iterations. Each iteration was supposed to be tiny: connect one file to an existing core, move its test, run the package's tests, done. Two files touched, maybe three.

Each of those iterations cost roughly 350,000 tokens.

I assumed the tasks were bigger than I thought. They weren't. I went back and looked at what was actually being read, and the answer was boring: the repository. Before writing a line, the executor was hunting for the type it had to embed, then for a similar file in a neighbouring package to copy the shape from, then for the error sentinel names, then for the test helper. Every single time. Nine-tenths of the spend was reconnaissance, and none of it landed in the diff.

The reflex fix is to give more: "here's the architecture doc, here's the design, here's the other domain, go." That makes it worse. Reconnaissance you pay for once is a cost. Reconnaissance you hand over as background is a cost and a hazard - the more neighbouring code is in view, the higher the chance the wrong neighbour becomes the template.


Two different things get called "context"

Separating these was the whole fix:

  • Task context - what has to be done and how it will be checked. This must be complete. Anything missing here gets invented.
  • Codebase context - how things are built around here. This must be minimal. Exactly the files named in the task, nothing else.

Almost every bad task I've written failed by getting these backwards: vague about the task, generous about the codebase. "Connect the reads to the shared core, see how the neighbouring domain does it" is exactly that shape. It says nothing checkable, and it delegates a search.

Two columns contrasting task context, which must be complete, with codebase context, which must be minimal - each row marked plus for required or minus for forbidden

TASK CONTEXT — must be complete       CODEBASE CONTEXT — must be minimal
─────────────────────────────────     ─────────────────────────────────
files to create (exact paths)         only the files named, by path
files to change (exact paths)         no tree search, no grep
signatures it plugs into              no neighbouring packages
values: errors, env, tables           no discussion history
the acceptance command                no "how it's usually done"
Enter fullscreen mode Exit fullscreen mode

What the executor must know

Five things, and they're all boring to write down:

  1. Exact paths of every file to create and every file to change - a closed list. A file not on the list is not touched and not read.
  2. Full signatures of whatever it plugs into: name, type parameters, methods, package, import alias. Not "the reader type" - the actual declaration.
  3. The existing primitives it must reuse, by path, each marked do not write your own copy. This is the single highest-value line in any task I write, because the default failure of a fast executor is to write a fresh, locally-reasonable version of something that already exists twenty metres away.
  4. Values, as values: error sentinel names, env var names, queue names, metric names, table names. Not "the usual naming" - the strings.
  5. The acceptance command, runnable with no substitution: the exact test invocation, scoped to the package being touched.

What the executor must not know

Neighbouring iterations. The discussion that led to the design. Packages outside the list. "How we generally do things."

Every one of those is a real cost multiplied by the number of executors, and every one is a chance to copy the wrong template. A fact written into the task is paid for once. The same fact discovered by the executor is paid for on every executor - and it isn't even the same fact twice, because two searches don't land in the same place.


Where the spend actually goes

After the split I started sorting the waste. Roughly in order of size:

Source What happens Share
Reconnaissance types, paths and signatures get re-discovered; neighbouring packages get read largest
Full re-runs every iteration runs the whole test suite and the linter over the whole repo large
Rework an iteration fails acceptance and gets rewritten large
Guard trips a copy of an existing generic gets written, a check blocks it, the cause gets hunted medium
Duplicated rules the shared style block is copy-pasted into every file of the set and rides along in every context medium
Re-reading a file that was just written gets read back to "verify" small but constant
source              what happens                                   share
──────────────────  ─────────────────────────────────────────────  ───────
reconnaissance      types, paths, signatures re-discovered         largest
full re-runs        whole suite + linter over the whole repo       large
rework              an iteration fails acceptance, written twice   large
guard trips         a copy of an existing generic gets blocked     medium
duplicated rules    the style block pasted into every file         medium
re-reading          a just-written file is read back to verify     small
Enter fullscreen mode Exit fullscreen mode

Table of where the token spend goes, from reconnaissance as the largest share down to reading files back as the smallest

Two of those are worth calling out because they look like diligence:

Full re-runs. An iteration that touches one package does not need the whole suite. It needs go test ./internal/<package>/... -race -count=1. The full run - formatting, vet, linter, everything with -race, structure checks - happens once, by me, at the end of the stage. Running it per iteration feels responsible and costs a fortune.

Re-reading. Writing a file and then reading it back to check it landed. The edit tool reports its own failures; reading back is pure spend.


The mechanism: a facts section

So the control document for a stage carries a section called Facts, and its job is to make search unnecessary:

  • exact package paths, and the path of every file that will be created or changed;
  • full signatures of the types and interfaces the iterations plug into, with import aliases;
  • the list of existing generics and cross-cutting primitives that must be reused, each with path and signature and the note do not write a copy;
  • names of error sentinels, env vars, queues, metrics and tables - as values;
  • the acceptance command, one line, no substitution.

I fill it in before the first iteration runs. That is the one place where reading around the repository is allowed, and it's me doing it. When an executor asks a clarifying question, I don't answer in chat - I add the fact to the section. Answering in chat fixes one executor; adding the fact fixes every executor after it.

The other half of the same rule: the shared style block lives in exactly one document. Iterations reference it in one line instead of repeating it. When a rule changes, it changes in one file. I learned that one the hard way, having pasted the same block into fifteen files and then needing to change it.


The same task, two ways

Anonymised, but this is the real difference in shape.

Before - three lines, and every one of them delegates a search:

Move the entity reads onto the shared read core.
Look at how the neighbouring domain does it and follow the same pattern.
Run the tests.
Enter fullscreen mode Exit fullscreen mode

After - the same work, nothing left to find:

Files (closed list):
  change: internal/repository/order/methods/list.go
  change: internal/repository/order/methods/list_test.go

Plug into (do not write a copy):
  internal/repository/rows.Reader[In, Out]
    fields:  executor, statement, scanner
    method:  Query(ctx context.Context, in In) (Out, error)
    import:  rows "<module>/internal/repository/rows"

Values:
  sentinel on empty result: order.ErrNotFound
  wrap format:              "list orders: %w"

Read only these files: internal/repository/order/methods/list.go,
                       internal/repository/order/methods/list_test.go

Acceptance: go test ./internal/repository/order/... -race -count=1
Enter fullscreen mode Exit fullscreen mode

The second version is longer to write and shorter to run. It is also checkable: I can tell whether it was followed without reading the diff carefully, because the file list and the acceptance command are both facts.

BEFORE                                  AFTER
"move the reads onto the shared core"   closed list: 2 files + 1 signature
        │                                        │
        ├──▶ pkg a                               │        pkg a
        ├──▶ pkg b                               │        pkg b
        ├──▶ pkg order                           └──────▶ pkg order
        ├──▶ pkg d                                        pkg d
        ├──▶ pkg e                                        pkg e
        └──▶ pkg f                                        pkg f
  every executor pays the search again    the fact is written once
Enter fullscreen mode Exit fullscreen mode

The same task written two ways: the vague version fans out to six packages the executor has to search, the closed-list version reaches one named package and leaves the rest untouched


What it costs

Writing tasks this way is not free, and I'd rather say so than pretend.

Someone has to collect the facts, and that someone is me, by reading the code. On a stage of thirteen iterations that's an hour of my time before anything starts. On a stage of forty it's more. If the work is a one-off fix in a file I already have open, the whole apparatus is pure overhead - I just do it.

It also only works where the conventions are rigid enough to be written down. In this codebase the layout is fixed to the point of being boring: one method per file, reads and writes in separate layers, a hard file-size limit, no comments in code. That rigidity is what makes a task specifiable in a page. In a codebase where every module has its own shape, there's nothing stable to write down, and the executor genuinely does have to look around - which is a statement about the codebase, not about the executor.


The part that generalises

The uncomfortable thing is that none of this is specific to models. A task with exact paths, named signatures, forbidden shortcuts and one runnable acceptance command is a good task for a person too. The reason it shows up now is scale: a human executor absorbs a vague task and quietly does the reconnaissance for free, once, and remembers it. A fresh executor does it every time and hands you the bill.

Speed only helps when correctness is visible without running the thing. That's the discipline that gets amplified - and the lack of it is what gets exposed.


Working with agents - Part 1.

Next: how far a task gets split before it stops being splittable - and the tell that says an iteration is still too big.

Top comments (0)