Tech9/20/2026·6 min read

The Image That Vanished in Five Wires at Once

The catalog said the model could see images, so the app sent one, and the relay dropped it silently. Two places computed one intersection by hand, one line apart.

Four relays, one boolean

Our agent stack has a catalog that says what each model can do. It has four relays that carry a request to a model. A request goes down exactly one of them, chosen by the provider, and which provider is in play is decided per call.

The catalog said images were supported. The client believed it, read pixels off the device, and sent an image block. The relay took the block, and there was no image at the far end.

What the user saw was the model's first line of thinking, followed by its answer: no image was attached, and the upload probably failed. What the diagnostics showed was the opposite. The image read had succeeded, reported succeeded, and the bytes were in memory. There was no error on the device, no error on the server, and no error in the logs. The image had been read correctly, sent correctly, and dropped correctly.

The drop was in the relay. The native relay passes bytes through. The translating relay converts from one vendor's message format to another's, and it converts text, tool calls and usage. It does not convert images, because the destination format has nowhere to put them. When the incoming image block met a converter with no arm for it, the arm it took was the one that produces nothing.

What was wrong was a single intersection, computed twice

The correctness of the catalog was not a matter of taste. It had a definition: a capability a client is told about is the intersection of what the model declares and what the line it will actually use can carry. And that intersection was being computed in two places, by hand, in the same file, one line apart.

One function took the catalog's declared capabilities and intersected them with the line. The next function, immediately after, read the same catalog row and handed it to the client untouched. So the row carried two different answers to the same question, and the second one was the one the client saw.

Neither function was wrong on its own. The first was a correct intersection that nothing consulted. The second was a pass-through that nothing questioned. What was missing was the observation that they were two answers to one question, which is the kind of thing that survives a long time when it is not written down anywhere.

The structural part of the story is why it had to be on the server. The line a request will use depends on the provider, and the provider is deliberately not sent to the client. The client therefore cannot compute the intersection. It is not permitted to. Only the server holds both halves of the input to the question.

The second shape of the same bug

There is a second instance in the same change, and it is the more instructive one, because it is the same mistake with the signs reversed.

A capability bit had been dropped in one adapter's result mapping. A result field carrying images was being swallowed by a struct-literal conversion, and the match that fed it was exhaustive: every arm present, none missing. Nothing was wrong with the code as written. The image had never reached that provider at all, because the capability it belonged to had already been lost a layer earlier, and by the time this code ran there was nothing left to lose.

The lesson is that completeness of a match is not completeness of a path. A switch with every arm present proves you handled every case that reached it. It says nothing about whether the case reached it at all. The bit that decided whether this provider ever saw an image was set somewhere else entirely.

There is a corollary about metrics. For a while the count of image blocks in a request was read as zero on that relay, because it only counted images under content, and this provider carried images in a result instead. So the leak was invisible in the one number that should have shown it. When the count is computed from the shape you expect rather than the shape you send, the metric protects nothing.

Four transports, four answers

The fix was a closed table, one row per relay, one field per capability bit, and the intersection computed from that table instead of assembled by hand per call site. There are four relays and they genuinely differ.

The two native relays pass through. The translating relay keeps tools and loses both images and hosted search, and that loss is not incidental: the vendor's hosted search is expressed as a tool, and the translation turns it into a tool declaration that nothing on the other side will ever execute, so the model calls it again and again. The OpenAI-compatible adapter keeps images and loses tools, because its request type has an image part and no tools field at all.

A single boolean cannot be correct for four relays. There is no value of "supports vision" that is true for all four and true only when the wire can carry it. So the answer is four rows, and the argument for each cell lives next to that cell rather than in a document.

Why the compiler will not save you, and what does

Here is the part worth internalising, because it is the kind of thing that gets rediscovered.

Adding a fourth capability bit breaks nothing. Both directions are silent. A composite literal in Go may omit a field and take its zero value, so a new bit reads as false on the relays that do support it, and a capability that genuinely works vanishes from every client without a single error. And the function that publishes the catalog writes each field out by name, so omitting one there reads as false too. The code deliberately does not copy the model through and fill in the gaps, because that pass-through would publish a capability nobody checked, which is the same failure in the other direction.

The two failures look identical from outside and produce opposite symptoms. A missing field is a capability that disappears. A pass-through is a capability that appears where it does not work. And the fix for the second one, which is to copy the model through, is the fix that introduces the first. Neither one fails to compile.

What catches them is a test that pins the field count of the capability struct, and the comment in the source that it exists to lead you back to the table. It fails when the shape changes, which is exactly when the author is adding a bit and is therefore in the one place where an explanation can be useful.

The second guard is on the other side of the boundary and is not optional. The relay's own mapping must keep rejecting a block type it cannot translate, and rejecting must be a named refusal rather than a silent skip. The wire types on the two sides are strings and enums written by different teams, and no amount of care makes them a bijection, so the default arm has to say no.

Where the answers live now

Three places, each with one job.

The intersection itself is a function on the server, on the read path that lists what a scene publishes. It walks the four conditions in order and returns both halves together: the dialect to publish, and the reason when it publishes none. It returns both because for a long time it returned one, and returning one meant a condition added to it alone produced an answer with no explanation, which is the one shape no consumer could render.

The per-line capability claims are the closed table, with each cell justified next to the cell. The claims are not derived from the code that does the translation; they are asserted, and the test that pins the field count is what keeps the assertion honest.

The refusal is in the translation layer, in its default arm, and it names the block it could not carry rather than dropping it.

Takeaways

A capability a client is told about is the intersection of what the model declares and what the line it will actually use can carry. That is the definition of the word, not a nicety, and we shipped a catalog that violated it by one field for longer than anyone noticed. The failure was silent in both directions: the read succeeded, the send succeeded, the bytes arrived, and the block was dropped in a converter that has no arm for it.

Compute that intersection once, on the side that holds both halves, and publish only the result. The side that cannot compute it is the side that must not be told the answer, and in our architecture the choice of relay is decided per call and never sent to the client, so this is a structural property rather than a choice.

A per-line answer is not a boolean. Four relays, four rows, and a cell that is false for one line and true for another is not an inconsistency, it is the fact about the world being represented honestly. The table is closed on purpose; new capabilities go through the native relays and do not open the translating one.

Know which compiler error you would get if a change broke this, because you would not get one. Adding a field to a capability struct compiles in both directions, and so does copying a model through without intersecting it. One loses a working feature and the other promises an undeliverable one, and a test that pins the shape of the struct is the only thing standing between you and either.

Keep reading