This milestone is two halves that look unrelated and are not. Images are the
expensive half: one new content block, touched by serialisation, token counting,
compaction and two CLIs, and every one of those had to be taught something. Web search
and git are the cheap half: both arrived as files in examples/, with zero
lines added to src/. The milestone is worth an hour mostly because the
contrast is the whole argument for the extension formats
(ADR-0052,
ADR-0060).
flowchart TD
at["@photo.png, or read on an image"] --> spill["spill_image: bytes to blobs/"]
spill --> block["ImageBlock: ref, media_type, width, height"]
block --> tr["the transcript: a reference, never the bytes"]
tr --> gate{"does this model see?"}
gate -- "no" --> refuse["refused where the model is chosen"]
gate -- "yes" --> cost["image_tokens: cost by geometry"]
cost --> ser["the adapter reads the blob and base64s it"]
tr --> old["old enough to elide"]
old --> stub["one line naming the picture"]
Stage 1. The picture itself about 25 minutes
1One new block
core/message.py
Look for: ImageBlock images
ImageBlock has exactly four fields: ref,
media_type, width, height. Frozen and slotted,
like every other block in the vocabulary, and deliberately nothing more than that.
ref, media_type — what an adapter needs to build a
request to a provider
width, height — what image_tokens needs
to price the picture
The test for whether a field belongs here was narrow on purpose: does
serialisation or token counting actually read it? A filename, a caption, a checksum, a
source URL — all sounded useful, none of them had a real caller, so none of them made
the cut. This file is the frozen vocabulary the whole harness shares. A field added
"just in case" has to be carried by every adapter, every test and every stored session
forever after, and taking one away later means a migration.
Notice what's missing most of all: the bytes. An ImageBlock
is only a reference to a file under sessions/<id>/blobs/. So the
JSONL record of a session never contains base64 — which keeps a transcript
grep-able, and keeps a session with twenty screenshots in it a small file.
The second half of the change is Message.images, a convenience
property that reaches into both a message's own blocks and its tool results.
Everything downstream — counting, eliding, serialising — reads that one property
instead of walking the blocks itself.
Take with you: in a vocabulary everything else
depends on, the fields you leave out are the design.
2Where the bytes go
tools/spill.py
Look for: spill spill_image SIGNATURES
Read spill first — M5 wrote it for oversized tool output. It writes a
file under the session's blobs folder and hands back a path. spill_image
sits right beside it and shares the file-writing, but not the rule for when
to write:
- Text spills conditionally, only when it's too big,
and keeps a head and a tail so the model still sees something useful.
- An image spills always, whole — because half a PNG
isn't a smaller PNG, it's a broken one.
That split is the one judgement call worth arguing about on this page. Making the
existing spill function take a flag would have crammed both rules into
one function body, and the flag would have had to be threaded through every caller
that already existed.
Then read SIGNATURES, which is how a file of bytes becomes a picture
with known dimensions. There's no image library in edgar, and no new dependency was
added for this: PNG, JPEG, GIF and WebP each announce themselves in their first few
bytes, and each one carries its width and height a fixed distance into the file.
Fifteen lines of header-reading, against a dependency that would have doubled the
install size. A file whose header can't be read is still treated as an image — it
just gets width and height of zero, and stop 5 explains what
that costs.
Take with you: "reuse it" and "extract the shared
part" are different answers. Share the mechanism, not the policy.
3The model that cannot see
providers/quirks.py
· providers/routing.py
Look for: images check_images check_capabilities
images is just one more column in a table that already had several.
That's the whole point of quirks-as-data: a capability is a row, never a branch of
if statements buried in the loop. Read the table and you can say which
model can see pictures, without reading a line of code.
check_images is deliberately built the same shape as
check_capabilities, which M9 wrote for ROUTE-6, and it refuses at the
same moment: where the model is chosen, before a request is even built and before a
cent is spent. The alternative — send the image anyway and let the provider reject it
with a 400 — costs a round trip and hands you an error in the provider's words instead
of edgar's.
There are two different ways a picture can arrive, so there are two refusals:
flowchart TD
a["you attach @photo.png"] --> b{"can this model see?"}
b -- "no" --> c["refused up front
before the turn starts"]
b -- "yes" --> d["turn proceeds normally"]
t["a tool produces a picture
mid-turn"] --> e{"can this model see?"}
e -- "no" --> f["read answers with a sentence:
what the file is, that it can't look"]
e -- "yes" --> g["ImageBlock reaches the model"]
An attachment is checked before the turn even starts — follow the caller into
cli/oneshot.py and cli/repl.py and you'll see it fires on
the attachment, not on the whole turn. But a picture a tool produces
mid-turn can't be refused that way, because the model is already chosen by then. So
ToolContext carries an images flag instead, and
read on a picture answers a blind model with a plain sentence: here's
what the file is, and I can't look at it. That's not an error — the tool call still
worked, and the model gets to decide what to do next.
Take with you: when a new capability needs a refusal,
find the refusal the last one used and copy its shape and its timing.
4Three ways to say the same picture
providers/openai_compat.py
· providers/anthropic.py
· providers/fake.py
Look for: _parts _image encoded
This is where the bytes finally come back off disk. encoded lives in
providers/http.py because both real adapters need it and neither should
own it. It reads the blob, base64-encodes it, and raises a ProviderError
if the file is gone — rather than quietly sending an empty image. The base64 exists
only for the length of one HTTP request; it's never written to disk or stored.
Read the two provider shapes side by side and they turn out to want almost the same
thing, said differently:
- Chat Completions wants a content array with an
image_url part holding a data: URL.
- Anthropic wants a block with a
source table that
names the media type and the data separately.
Both adapters just translate. Neither one decides anything — the same rule as
every other line in these files.
The interesting case is a picture a tool produced. Anthropic can put an
image straight inside a tool_result block. The OpenAI shape has no room
for that, so openai_compat follows the tool results with one extra user
message carrying the pictures instead. Different bytes go out on the wire, but it's
the same conversation either way.
Finish on the fake provider — the one the whole test suite leans on. It can't
actually look at anything, so it just says what arrived: media type, dimensions and
reference. That one honest line is what lets a test assert an image survived a whole
turn, and a --resume, without any real provider ever being reachable.
Take with you: a fake that reports what it received
is worth more than a fake that pretends to understand it.
5What a picture costs
context/tokens.py
Look for: image_tokens _fit approx_message_tokens PIXELS_PER_TOKEN
Forty lines. No I/O, no clock, no config — just pure arithmetic over four integers.
That purity is the only reason a number this load-bearing can be trusted: compaction
decides when to run based on it, and a budget that undercounts images will silently
send requests that then get rejected for being too long.
The key idea: a picture costs by its geometry, never by its file size. A
4 MB photograph and a 40 KB screenshot of the same pixel dimensions cost exactly the
same, because the model resizes the image before it ever looks at it. Two rules cover
every provider edgar talks to:
- Anthropic — shrink to a 1568-pixel longest side, then charge
for the area over 750.
- The Chat Completions family — shrink to fit inside 2048, then
shrink the short side to 768, then charge a base price plus one price per
512-pixel tile.
_fit is the shrinking step both rules share, and it only ever shrinks
— it never enlarges a small image to make it cost more.
Read the first two lines of image_tokens closely. If a file's header
couldn't be parsed, its size was already recorded as zero back in stop 2. Rather than
guess at a picture nobody ever actually measured, the function charges for one tile's
worth. That's wrong, certainly — but wrong by a bounded amount, in a known direction.
That's exactly what you want from an estimate that gates something as important as
compaction.
Take with you: when the input might be unmeasurable,
decide what you charge for "unknown" before you actually need the answer.
6Eliding an old picture
context/compact.py
Look for: elide _stub ELIDED
This is the most dangerous file in the milestone, because of invariant 1: every
assistant message with tool calls must be followed by exactly one tool message whose
result ids match. Break that and every provider answers with an HTTP 400. Compaction
keeps that promise by only ever removing, stubbing or summarising a whole
unit — never trimming inside one.
So the change for images lives inside _stub, the same per-block
helper elide already runs over everything before the cut — not in a
second pass built just for pictures. That was the real judgement call. A separate
_elide_images function would have been easier to read on its own, but it
would also have been a second function that knows where a turn is allowed to be cut.
The whole value of the invariant is that exactly one function knows that.
def elide(block):
if block is old enough to cut:
if block is ImageBlock:
return TextBlock("[elided image: {media_type}, {width}x{height}, {ref}]")
if block is already a stub:
return block # idempotent: no-op on a second pass
return _stub(block) # text: summarised the usual way
return block # too recent, leave alone
An old picture becomes one line naming it — media type, dimensions and the
reference — so a human can re-attach it later, and the model stops paying to see it on
every single request. Nothing is actually lost: the bytes were never in the transcript
in the first place, only in the blobs folder, which elision never touches.
Two details keep this safe:
- A tool result carrying images is stubbed together with its text — the
whole unit is elided at once, never a stub still dragging its screenshots along
behind it.
- The operation is idempotent. An
ImageBlock that becomes a
TextBlock stays that way on a second pass, and a result that's already
a stub returns early instead of stubbing again.
The property suite runs elision twice over transcripts with an image on every other
tool result, and checks the pairing invariant still holds after both passes.
Take with you: when one function is the only thing
that knows an invariant, extending it is almost always cheaper than writing a second
function that has to know it too.
7Two ways in
context/attach.py
· tools/builtin/fs.py
Look for: attach _refuse Read ToolFinished
A picture can arrive two ways — a human attaches it, or a tool produces it — and
both end up at the exact same ImageBlock.
@photo.png extends M19's existing path instead of growing a second
one. Same token scan, same refusals in the same words (missing, a directory, a
credential file, outside the working directory) — and then exactly one new fork at
the end. Bytes that begin with a known image signature spill to disk and come back as
an ImageBlock; anything else is decoded as text, same as before. Compare
_refuse here with the version on the
working state page — it's unchanged. A file that's
neither text nor a recognised image is still refused, by name.
On the tool side, Read now answers a picture with the picture itself,
instead of the old "looks binary; not shown". And ToolFinished gained one
optional field carrying the reference, so something watching --events can
find the file on disk — while the actual bytes never pass through the event stream at
all. Same reason blob is already on that event for spilled text.
Take with you: a second way in should join the first
one as early as possible. Here that's one if, and everything downstream
never even learns there were two roads in.
Stage 2. The half that cost nothing about 20 minutes
Both stops below are folders, not modules. Nothing in src/ changed for
either, and that is the claim being tested: the extension formats froze at 1.0, and a
capability that fits them should need no code. Read them asking what edgar would have
had to grow if the formats were not there.
8Web search, as files
examples/tools/web_search.toml
· examples/skills/web-research/SKILL.md
Look for: the three commented variants, and the host that is not there.
Searching the web is really just an HTTP call with a key in a header — and M3's
HTTP-tool format already describes exactly that: a request template with the host
fixed, arguments filling only the path and query, and ${env:NAME} filling
headers at call time (then scrubbed from whatever comes back). So web search doesn't
need any new code at all. It's a TOML file.
The file ships three variants: Brave is active, Tavily and a self-hosted SearXNG
sit commented out right beside it. There's no default host — that's PRV-15 doing its
job. Who sees your search queries is exactly the kind of choice a harness must never
make quietly for you, and here the whole answer is in a file you can read in ten
seconds.
The skill is the half that actually matters. A search tool on its own just produces
a model that answers from snippets — and a snippet is an advertisement for a page, not
real evidence. The skill tells the model: search, then fetch the best two
or three pages, then answer citing the URLs. It also restates a rule the harness
already enforces elsewhere: a search result is untrusted output, so in
auto mode any shell or network call that follows it has to ask first.
This page's claim was actually tested, not just asserted: a subagent with none of
this milestone's context, given only the Cookbook and the extension guide and
forbidden from opening src/, produced the same host, header and
environment variable completely unaided.
Take with you: the tool is the easy half. The
instructions for using it well are the part that makes it worth having.
9git, as files
examples/tools/git-commit.toml
· examples/skills/git/SKILL.md
· examples/extensions/git-trail/hooks.toml
Look for: the message as one argument, and the two verbs that are missing.
git is just a CLI, and M3's command-tool format runs an argv template directly,
never through a shell. Four verbs, one file each: look at the tree, read the diff,
read the log, commit what's staged. Three are read_only; the fourth
writes, and goes through the permission guard like any other write.
Open git-commit.toml and look at one line: the whole commit message
is a single argv element. That matters because no shell ever sees it — so a message
containing newlines, semicolons and backticks is just message text, nothing more.
Every agent harness that shells out to git commit -m "..." the naive way
has this bug sitting latent inside it.
Then notice what's missing, because the absences are the design:
- No
git-add. Staging is where a human decides what actually goes
into the commit.
- No push, merge or tag. Those are calls only the user gets to make.
git-log takes a required count, not an optional one. An
argv element naming an absent argument gets dropped entirely — so an optional
--max-count would silently mean "the entire history", which is the
opposite of a sensible default.
The extension is the post_tool example, and it's observation only:
fired and forgotten, its output never reaching the model, matched to the one tool
worth recording. Its script has an interesting constraint — the event carries the
call's outcome, never the tool's actual output, so it has to read the finished commit
back out of git itself. That's the shape of every hook that isn't a
pre_tool hook.
Take with you: the argument for declarative extension
formats isn't that they're elegant. It's that this whole stop is a diff with no code
in it.
Sizes
Lines of code: blank lines and comments do not count, docstrings do.
just loc prints the current totals. Every file here already existed —
M20 added no module, and its second half added no code at all.
| File | Lines of code | What it is |
core/message.py | ~87 | the vocabulary, now with ImageBlock |
tools/spill.py | ~93 | bytes to blobs: text conditionally, images always |
context/tokens.py | ~41 | what text and pictures cost, purely |
context/compact.py | ~120 | the stages, and the one function that may cut |
context/attach.py | ~60 | @path: find, refuse, read or spill |
providers/quirks.py | ~168 | the table a capability is a column of |
Source: just loc, which counts with
tests/support/budget.py. Rounded; the tour's test fails if a figure drifts
more than 25 lines of code from the code.