Multimodal input: analyze_file, MCP binary capture, and a fixture server (#513) - #563
Conversation
…#513) RockBot has been text-only end to end. Every model it talks to can see, but nothing in the framework could put a non-text byte in front of one — issue #513 found this the hard way, chunking 167K characters of textual image representation into working memory while a vision-capable model never saw an image. The obvious fix — let a tool return an image and forward it — cannot work. On OpenAI-compatible APIs, which is every provider RockBot talks to, tool-role messages accept text only, so bytes can enter a conversation solely as content parts on a user message. So analyze_file runs the look-up as its own LLM call: a path and a prompt go in, prose comes out, and bytes never touch the agent's context, the message bus, or the context budget. - LlmTierConfig.SupportsImageInput — opt-in per tier. A blind model rejects image content with an opaque provider error deep in the stack, so the capability is declared rather than guessed from a model id. - analyze_file is registered only when some tier declares it, and the file-tools skill guide documents it under the same predicate, so an agent is never taught a capability its deployment lacks. - Requested tier is substituted for a seeing one when it cannot see. ILlmClient retries a failed Low/High call on Balanced, so a vision request sent to a blind tier would fail twice and report the less informative second error. - LlmTierOptions is now registered in DI by the agent, after the compat fixups, so consumers see the same resolved tiers the chat clients were built from. design/multimodal-input.md records the full gap inventory (five distinct text-only chokepoints), the wire-format constraint above, and the sequencing for the remaining work: generic binary capture in the MCP bridge, then inbound user attachments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
|
Verified live on the reference deployment (agent 0.14.43 built from this branch, all three tiers flagged Vision support, measured rather than assumed. A throwaway in-cluster probe pod (
All three on the Azure Foundry Registration gate works: End to end. A 240×120 PNG was written to the shared volume: three bars, left to right red (h=60), green (h=95), blue (h=40). Asked through the CLI, the agent chose the tool and the High tier on its own: Final reply: "It shows 3 bars. Left to right, the colours are red, green, blue. By height, blue is shortest, red is in the middle, and green is tallest." Correct on count, on left-to-right colour order, and on the height ordering — which is only recoverable by actually looking at the image. Test file removed from the shared volume afterwards. Includes a version bump to 0.14.43. |
The vision flag was reachable only through the agent.extraEnv escape hatch, which is the wrong shape for a per-tier model property: it sits in the ConfigMap away from the endpoint/modelId/reasoningEffort keys it belongs with, and an operator reading values.yaml has no way to discover it exists. secrets.llm.<tier>.supportsImageInput now renders LLM__<Tier>__SupportsImageInput alongside the other per-tier keys, following the reasoningEffort pattern — emitted only when true, so a release that does not set it is byte-identical to before. balancedModels entries deliberately take no flag of their own: the app reads SupportsImageInput per tier, not per fallback model, so a flag there would be silently inert. values.yaml says so, and says the consequence — if balanced declares vision, every model in the fallback chain must have it. Verified on the reference deployment: keys render into the Secret and no longer into the ConfigMap, the agent picks them up from the new source after a restart (the chart has no checksum annotation, so LLM config changes still need one), and analyze_file registers with all three tiers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
|
Added It follows the secrets:
llm:
high:
modelId: gpt-5.5
supportsImageInput: true
Verified on the reference deployment: the three keys now render into the Secret and no longer into the ConfigMap, the unrelated Worth noting for whoever changes this later: the chart has no checksum annotation on the Secret, so the Helm upgrade alone left the running pod on its old env — LLM config changes still need an explicit |
The attachment gateway only helps servers that implement RockBot's convention. Most don't. Issue #513 arrived with the consequence: the official Gitea server returns a repository PNG through its ordinary get_file_contents tool as base64 inside JSON, which lands in context as ~167K characters of unusable text, gets chunked into working memory, and still never reaches the model as an image. BinaryResponseCapture runs on every MCP response, for every server, manifest or not — the servers with no attachments block are exactly the ones it exists for. Two rules, deliberately unequal in what they ask of an operator: - Typed image/audio content blocks are captured with no configuration at all. MCP has already labelled them, so nothing is being guessed. - Base64 inside a JSON response is captured only where a manifest rule names the fields. Sniffing for "a field that looks like base64" is exactly the fragile heuristic the attachment design rejected, and Gitea's shape needs no server change — only a description of the response it already sends. The load-bearing detail is deciding what is actually binary. A repository server returns a README and a PNG through the same tool and the same field, so capturing on the presence of base64 alone would take away text the model could simply have read. Name or MIME decides when there is one (SVG deliberately excluded: an image by MIME, text by nature); otherwise the bytes do, via a NUL byte or a strict UTF-8 decode failure. Capture never fails a tool call. A bad rule, an unwritable volume, or a payload that isn't what the rule claimed logs and passes the original response through. That is the opposite of the outbound gateway's stance and deliberately so: outbound, a failure means the model's file never got attached and silence would be a lie; inbound, capture is an optimisation on a response already complete. The response object survives a rewrite — only the content field is removed, so sha/url/whatever else the server sent stay alongside the added path descriptor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Smoke-testing capture against a live repository MCP server turned up a failure mode the design didn't anticipate. Its get_file_contents returns a 345 KB PNG not as base64 but as UTF-8-decoded text: the leading 0x89 arrives as U+FFFD, the bytes are destroyed at the source, and the response reaches the agent as 1,366,356 characters that chunk into 22 working-memory entries and 22 embedding calls. Capture already declined this correctly — the content field is not valid base64, so it passed the response through untouched, which is the right answer to "can you save these bytes?" But it is the wrong answer to what the agent then does with them: read mojibake, conclude nothing, and often retry the same call. So under a declared rule, a content field that fails base64 decoding is now checked for the signature of lossy binary-to-text decoding — eight or more U+FFFD in at least a kilobyte, thresholds set so a document with a couple of encoding glitches is still a document. When it matches, the field is dropped and replaced with a note saying the bytes were corrupted, that retrying returns the same corrupted text, and to fetch the file by a route that preserves bytes. The rest of the response — name, sha, size, url — survives, and is usually enough to do exactly that. Nothing is lost by dropping it: the bytes were already unrecoverable when they arrived. This only fires for tools an operator has declared a capture rule for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
|
Smoke-tested capture against a live MCP server, and it found a failure mode the design didn't anticipate — now fixed (fc76780). Baseline
1.37 million characters, 22 working-memory chunks, 22 embedding calls. The issue reported 167K; this is eight times worse. What the smoke test foundCapture didn't fire, and was right not to. Reading the raw payload out of working memory rather than trusting the agent's summary: That is not base64. This server decodes file bytes as UTF-8 before returning them, so the PNG's leading Worth noting the issue's Gitea case is genuinely base64 (167K chars matches base64 of a ~125 KB image), so this is a second, distinct shape — one only a live test was going to surface. The fixUnder a declared rule, a content field that fails base64 decoding is now checked for the signature of lossy binary-to-text decoding: eight or more U+FFFD in at least a kilobyte, thresholds set so a document with a couple of encoding glitches is still a document. On a match the field is dropped and replaced with a note; Nothing is lost — the bytes were already unrecoverable when they arrived. After (0.14.45, same call)1,366,356 chars and 22 chunks → 588 chars and none. The agent reported the note back accurately and did not retry. Regression checkSame tool, same capture rule, What this did not exerciseNo server in that fleet returns base64 file content or typed |
The bridge's binary capture and analyze_file were covered by unit tests, but no server in a real deployment returns the shapes they exist for — the ones RockBot talks to either write files to disk or corrupt their bytes. McpServer.BinaryFixture returns each shape on demand: typed image and audio blocks, a repository server's base64-in-JSON for both an image and a text file, binary mangled into text, and plain text as the control. Payloads are generated in code, so the repo carries no binary blobs and — more usefully — the image's content is documented, so a vision model's description can be checked against a known answer. Pointing a live agent at it immediately found two real bugs, both of which the unit tests had encoded as assumptions rather than observations: 1. ImageContentBlock.Data is typed ReadOnlyMemory<byte>, but in SDK 1.4.0 it carries the wire field verbatim — base64 text. Capture wrote Data.ToArray() straight to disk, so a 783-byte PNG landed as 1044 bytes of "iVBORw0KGgo…" under a .png name, and the vision model rejected it as invalid_image_format. McpBinaryPayload now reads either convention; McpToolExecutor.MapContentBlocks was double-encoding the same field and is fixed alongside it. 2. The fixture's own get_image was unusable for the same reason in reverse: raw PNG bytes handed to Data serialise as mojibake and the client rejects the block outright. The shim and the reason are documented where they are used. Verified end to end against a live agent, every shape: typed image captured as a valid 783-byte PNG and correctly described by analyze_file (matching the fixture's own stated answer), audio captured at its exact size, text preserved beside a captured image, base64 image captured while base64 text stayed readable in the response, and the mangled case collapsing 218,751 characters to a 482-character explanation. The fixture deployment lives outside the Helm chart deliberately — it is applied for a test and deleted afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
|
Added Bug 1: content-block payloads are base64 text, not bytes
Capture was writing 1044 is exactly the base64 length of 783 bytes, which is what gave it away. Bug 2: the same thing in reverse, in the fixtureHanding raw PNG bytes to The fixture now writes base64-as-bytes, with the reason documented where it's used. Verified against the wire JSON rather than inferred. The fixture server
End to end, unprompted by me — the agent chose the tools itself:
The payloads are generated in code, so the repo carries no binary blobs — but the real reason is that the image's content is documented, so a vision model's answer is checked against a known one rather than against an impression of a photo. Fixtures are also served over plain HTTP ( One case needed the fixture itself fixed rather than the code: an 783-byte flat-colour PNG mangles to ~700 characters, below the guard's 1 KB floor, so the mangled fixture now uses an incompressible noise image at a realistic size. Cluster left cleanFixture deployment deleted, |
Binary capture names saved files from the `name` field of an MCP server's response — a remote party's input. That turns AttachmentStorage's leaf-only sanitising from tidiness into a security property, and nothing was holding it in place: a refactor that "simplified" it into a Path.Combine would let a hostile server write anywhere the agent can reach. Behaviour is unchanged; the tests cover traversal names, absolute paths, subdirectories, the empty-name fallback, collision handling, and the read-side rejection of paths outside the base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Both were written from reading the shape of the code rather than its detail, and checking them while filing the follow-up issues showed they were wrong: - EstimateMessageChars does not score a DataContent at zero. It has a flat `_ => 50` fallback, so a 1.8 MB image counts as 50 characters — still ~35,000x under, and still the reason the trim logic cannot be trusted with images, but the number matters to anyone deciding how to fix it (#564). - Inbound user attachments do not need a schema migration. Adding an optional Attachments property to ConversationTurn is additive, which the policy in schema-migrations.md absorbs silently, and the conversation store is not enrolled in schema migrations at all — only memory, skills, feedback and wisp are. That makes (D) meaningfully smaller than the doc claimed (#565). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
The version trail on this branch (0.14.43 through 0.14.47) was an artefact of
needing a fresh image tag for each round of live testing, not a considered
sequence. Collapsing it into one deliberate number, and a minor rather than a
patch, because a patch release should not carry what this does:
- Binary capture is on by default for every MCP server, with no opt-in. A
consumer whose server returns typed image or audio blocks now receives a
{path, name, size, mime} descriptor where it previously received the block.
- McpToolExecutor.MapContentBlocks stops double-encoding those payloads, so
ToolContentBlock.Data changes shape for the same consumers.
- New public surface across three packages: LlmTierConfig.SupportsImageInput,
FileSystemOptions.AnalyzeFileMaxBytes/AnalyzeFileMimeTypes, AttachmentManifest
.Capture with its config types, and BinaryResponseCapture itself.
- A new chart value, secrets.llm.<tier>.supportsImageInput.
- A capability the framework did not have at all: multimodal input.
Someone pinned to 0.14.x taking a patch upgrade should not find their tool
results a different shape. That is what the minor is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Chart.yaml has sat at 0.10.21 while the app moved to 0.14.x — the two were in step when that number was set, so this is drift rather than a deliberate split. This PR adds a chart value (secrets.llm.<tier>.supportsImageInput), which is exactly the kind of change a chart version is supposed to signal, and shipping that under a version last touched four minor releases ago tells an operator nothing. Both version and appVersion move to 0.15.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj
Steps (A), (B) and (C) of the multimodal plan in #513: binary content now reaches the shared volume instead of the model's context, and a model that can actually see it can be asked about it.
The problem
RockBot has been text-only end to end. Every model it talks to can see, but nothing in the framework could put a non-text byte in front of one. #513 found this from the outside: an agent asked an MCP server for an image, got 167K characters of textual representation back, chunked it into working memory, and never showed the model an image.
Five separate chokepoints, all verified in the tree and inventoried in the design doc. The one that shapes this PR:
Images cannot ride in a tool result.
RegistryToolFunctionalready maps MCP image blocks toDataContent, which looks like a working path. It isn't. The text-based tool-calling path reduces the result withresult?.ToString(), so aList<AIContent>becomes a bare type name. And on the FICC path, where it survives, every provider is reached throughOpenAIClient(...).AsIChatClient()— and the OpenAI Chat Completions wire format accepts only text in tool-role messages. ADataContentthere is JSON-serialised into a data-URI string: full base64 token cost, no image.On OpenAI-compatible APIs, bytes can only enter as content parts on a user message.
What this adds
analyze_file(path, prompt, tier)— a side call rather than a richer tool result, which sidesteps the constraint entirely:A path goes in, prose comes out. Bytes never touch the agent's context, the message bus, or the context budget — which is why the byte-blind
EstimateMessageCharsgap can wait for the inbound-attachment work.LlmTierConfig.SupportsImageInputis the second half. It is opt-in per tier because a blind model rejects image content with an opaque provider error deep in the stack; making the operator say "this model can see" is one config line and removes the whole class of failure.Notable decisions
VisionTiers.Fromso the two cannot disagree whichever hosted service starts first.ILlmClientretries a failed Low/High call on Balanced. Sending a vision request to a blind tier would fail twice and surface the less informative second error.LlmTierOptionsis now registered in DI by the agent, after the backward-compat fixups, so anything reading it sees the same resolved tiers the chat clients were built from. Consumers that do not register it get ananalyze_filethat never registers — the dependency is optional and its absence reads the same as "no tier declares vision".RockBot.Tools.FileSystem, which already referencesRockBot.Host(soILlmClientis in scope), already ownsSafeResolvePath, and already defaults its base path to/rockbot/shared— so the MCP attachment gateway's output directory is already inside its reachable scope.Enabling it
{ "LLM": { "High": { "ModelId": "openai/gpt-5.5", "SupportsImageInput": true } } }Or
LLM__High__SupportsImageInput=true. Nothing changes for a deployment that sets neither.(C) Binary capture in the MCP bridge
analyze_fileonly helps once bytes are a file on the shared volume, and the attachment gateway only gets them there for servers that implement RockBot's convention. Most don't — including the one in the issue.BinaryResponseCaptureruns on every MCP response, for every server, manifest or not:image/audiocontent blocks are captured with no configuration. MCP has already labelled them, so nothing is guessed. Bytes go to the attachments directory, the block becomes{path, name, size, mime, note}, other blocks are untouched.{ "attachments": { "capture": { "rules": [ { "tools": ["get_file_contents"], "contentField": "content", "nameField": "name", "encodingField": "encoding" } ] } } }The load-bearing detail is deciding what is actually binary. A repository server returns a README and a PNG through the same tool and the same field, so capturing on the presence of base64 alone would take away text the model could simply have read. Name or MIME decides when there is one — SVG deliberately excluded, an image by MIME and text by nature — and otherwise the bytes do, via a NUL byte or a strict UTF-8 decode failure.
Capture never fails a tool call: a bad rule, an unwritable volume, or a payload that isn't what the rule claimed logs and passes the server's original response through. That is the opposite of the outbound gateway's stance, deliberately — outbound, a failure means the model's file never got attached and silence would be a lie; inbound, capture is an optimisation on a response that is already complete.
The response object survives the rewrite. Only the content field is removed, so
sha,url, and whatever else the server sent stay alongside the added path descriptor.Content-block payloads were being read wrong
Found by pointing a live agent at the fixture server below, and it affects any MCP server that returns typed image or audio blocks — which is to say it was broken before this PR too, just invisibly, because nothing in the reference deployment returns that shape.
ImageContentBlock.Datais typedReadOnlyMemory<byte>, which reads like "the file's bytes". In SDK 1.4.0 it is not: it carries the wire field verbatim, and the wire field is base64 text. So capture wroteData.ToArray()straight to disk and a 783-byte PNG landed as 1044 bytes ofiVBORw0KGgo…under a.pngname, which the vision model then rejected asinvalid_image_format. The same field was being double-encoded inMcpToolExecutor.MapContentBlocks(Convert.ToBase64Stringover already-base64 bytes) — pre-existing, fixed alongside.McpBinaryPayloadreads either convention, so an SDK version that starts storing decoded bytes needs no change.The fixture server
McpServer.BinaryFixturereturns each binary shape on demand — typed image and audio blocks, a repository server's base64-in-JSON for both an image and a text file, binary mangled into text, and plain text as the control. It exists because none of those shapes occur in a real deployment, which is how both bugs in this PR stayed hidden behind unit tests that had encoded assumptions rather than observations.Payloads are generated in code: no binary blobs in the repo, and — the actual point — the image's content is documented, so a vision model's description is checked against a known answer instead of an impression of a photo. The fixtures are also served over plain HTTP (
/fixtures/chart.png) for when a live result and a model's account of it disagree.It sits outside the Helm chart deliberately (
deploy/k8s/mcp-binary-fixture.yaml, applied for a test and deleted afterwards) and isIsPackable=false. Seesrc/McpServer.BinaryFixture/README.md.Not in this PR
design/multimodal-input.mdsequences what remains, now filed:attach_image:UserMessage.Attachments, an upload control,--attach, and the loop injecting image parts onto the user message. Also carries the one-wordClientCapabilityPresets.Blazorfix (it omitsImageAttachment, so the agent is never told it may attach images to Blazor replies even though the client renders them).DataContentas 50 characters, so images are effectively invisible to the watermark trim. Unreachable today, because nothing puts image parts into the agent's own message list; Blazor and CLI cannot send files to the agent #565 is what makes it reachable, so it should land first.Two claims in the design doc were corrected while filing those:
EstimateMessageCharsscores non-text parts at a flat 50 rather than zero, and inbound attachments need no schema migration — adding an optional property toConversationTurnis additive, and the conversation store is not enrolled in schema migrations at all. Both had been written from the shape of the code rather than its detail.Version: 0.15.0, not a patch
Two things here are behavioural changes for an existing consumer, and a patch release should not carry them:
{path, name, size, mime}descriptor where it previously received the block.McpToolExecutor.MapContentBlocksstops double-encoding those payloads, soToolContentBlock.Datachanges shape for the same consumers.Plus new public surface across three packages (
LlmTierConfig.SupportsImageInput,FileSystemOptions.AnalyzeFileMaxBytes/AnalyzeFileMimeTypes,AttachmentManifest.Captureand its config types,BinaryResponseCapture), a new chart value, and a capability the framework did not have at all.The 0.14.43–0.14.47 trail on the branch was an artefact of needing a fresh image tag per round of live testing; it is collapsed into this one number.
Testing
48 new tests.
analyze_file(15): containment rejection, missing file, unrecognised extension, allowlist enforcement in both directions, the size limit, tier honouring and substitution, multimodal content shape (prompt text and bytes both arrive intact), empty response, and provider exceptions. Capture (19): image and audio blocks, mixed blocks, text-only responses left alone, the disable switch, error results, the Gitea shape end to end, a README through the same field left in the response, sniffing fallback in both directions, wrong encoding, malformed base64, unmatched tools, no rules, MIME override, storage failure, and binary a server mangled into text (dropped with an explanation rather than flooding context). Payload decoding (7): base64-text and raw-byte payloads both read correctly, no double-encoding, round trip. Storage containment (7): traversal names, absolute paths, subdirectories, empty-name fallback, collisions, and read-side rejection — pinned because capture now names files from a remote server's response. Full suite green: 3198 passed, 0 failed. Capture was also smoke-tested against a live MCP server, and againstMcpServer.BinaryFixture(added here) which returns every shape on demand — see the comments. Those two runs are what turned up the mangled-binary case and the base64-payload bug respectively.Closes #513
🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf8a2Z1v9dY3YjjM42zJEj