Repository navigation
perf(middleware): stop checkpointing view_image base64 payloads - #5014
Merged
WillemJiang merged 1 commit intoAug 27, 2026
Merged
Conversation
ViewImageMiddleware injected the viewed-image message from before_model and removed it again from after_model. before_model, model, and after_model are separate graph nodes, so every view_image turn cost two extra nodes and two state writes, and up to 20MB of base64 sat in two checkpoints for the duration of the model call. A run interrupted in that window (user cancel, restart) stranded the payload in history for good. Inject from wrap_model_call instead, so the message lives only in ModelRequest.messages and is never returned as a state update: - before_model/after_model (and the async pair) are replaced by wrap_model_call/awrap_model_call; _remove_image_context_messages and its RemoveMessage bookkeeping go with them. The async hook keeps the existing asyncio.to_thread offload for the file read and base64 encode. - _should_inject_image_message gates on request.messages rather than state, so the decision is made against what the model will actually see. - _inject sweeps this middleware's own message out of the request before rebuilding it. Dropping after_model also drops the cleanup it did on every call, so without the sweep a payload stranded by an older interrupted run would ride along in every later request for the life of the thread. Matching requires both the reserved id prefix and the server-owned marker, and Gateway strips that marker from client input, so a user message is never dropped. Chain position is unchanged, and wrap_model_call nests first-registered outermost, so TokenBudgetMiddleware still sees the image message and enforces the input budget against it. Checkpoint rows that already hold a stranded payload keep it on disk. It is inert -- never sent to a provider, and strip_data_url_image_blocks keeps it off the wire -- and reclaiming it would mean keeping the node this change removes. tests/test_view_image_middleware.py is rewritten around the new hook (43 tests): sync/async at unit and graph level, the stranded sweep, and the client-message protection. Docs: middleware chain entry 23, Vision Support, the middleware-execution-flow hook matrix and diagrams, and the strip_data_url_image_blocks docstring.
hyeonsang010716
marked this pull request as ready for review
August 25, 2026 07:11
WillemJiang
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
ViewImageMiddlewarefeeds viewed images to the model by injecting a hiddenHumanMessagecarrying their base64 payloads. It did that frombefore_modeland undid it fromafter_model.before_model,model, andafter_modelare separate graph nodes, so every turn that follows aview_imagecall paid for two extra nodes and two state writes, and up to 20 MB of base64 sat in two checkpoints for the duration of the model call. The payload is transient context that nothing downstream reads back — it exists only to be handed to the provider on one call.The window also had a failure mode: a run interrupted between the two hooks (user cancel, restart, timeout) left the payload checkpointed with no cleanup pass ever coming, so it stayed in the thread's history.
What changed
ViewImageMiddlewareinjects fromwrap_model_call/awrap_model_callinstead of thebefore_model/after_modelpair. The message lives only inModelRequest.messagesand is never returned as a state update, so no checkpoint carries it._remove_image_context_messagesand itsRemoveMessagebookkeeping are gone with the hooks; the async path keeps the existingasyncio.to_thread()offload for the file read and base64 encode.request.messagesrather than state, so the gate judges the messages the model will actually receive._injectsweeps this middleware's own message out of the request before rebuilding it. Droppingafter_modelalso drops the cleanup it performed on every call, so without the sweep a payload stranded by an older interrupted run would keep riding along in every later request for the life of the thread. The sweep requires both the reserved id prefix and the server-owned marker, and Gateway strips that marker from client input, so a user-authored message can never be dropped.wrap_model_callnests first-registered-outermost, soTokenBudgetMiddlewarestill sees the image message and enforces the input budget against it;SummarizationMiddlewareisbefore_model-only and did not see it before either.AGENTS.mdVision Support, themiddleware-execution-flow.mdhook matrix and diagrams, and thestrip_data_url_image_blocksdocstring.Not addressed, deliberately: checkpoint rows that already hold a stranded payload keep it on disk. It is inert — never sent to a provider, and
strip_data_url_image_blockskeeps it off the wire — and reclaiming it would mean keeping theafter_modelnode this change removes.Surface area
frontend/backend/applanggraph.json, or prompt changedocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.jsonOn the default-behavior box: the image context message no longer appears in checkpointed
messages, and therefore no longer appears invaluesstream frames. It was alreadyhide_from_uiand already stripped from history responses bystrip_data_url_image_blocks, so no UI or IM surface changes — but the persisted data shape does, which is why the box is checked.Validation
cd backend && make lintAll checks passed!/1228 files already formattedcd backend && make test12307 passed, 78 skippeduv run pytest tests/test_view_image_middleware.py -q43 passeduv run ruff check/ruff format --checkon the three changed Python files3 files already formattedgit diff --checktests/test_view_image_middleware.pyis rewritten around the new hook: sync and async at both the unit and graph level, the stranded-payload sweep, and the protection that stops a client-supplied message wearing the reserved id prefix from being swept.Two behavioural claims in "What changed" were verified against the real graph rather than assumed:
graph.ainvokedispatches toawrap_model_call(so the 20 MB read stays off the event loop), and a middleware registered afterViewImageMiddlewaresees the injected message while one registered before it does not.AI assistance
Tool(s) used: Claude Code
How you used it: I forked the repo because I spotted that
ViewImageMiddlewarestill used abefore_model/after_modelpair to inject and then remove the image payload — wasteful per turn, and it strands the payload if a run dies mid-call. I decided the direction (move it intowrap_model_callso it never enters state) and had Claude Code implement it, along with the tests and doc updates.I pushed back over several rounds and asked it to re-verify its own claims rather than accepting the first answer; that caught a real regression — the old
after_modelalso swept payloads stranded by earlier interrupted runs, and the first version silently dropped that cleanup — which is why_injectnow sweeps the request. It also verified the ordering and async-dispatch claims against a real graph instead of assuming them.I reviewed the final diff myself before opening this.