runtime: extract image-stripping into a registered MessageTransform#2573
Merged
dgageot merged 3 commits intodocker:mainfrom Apr 28, 2026
Merged
Conversation
…mode The transform was calling agent.Model() which re-randomizes alloy picks and ignores per-tool overrides — it could end up consulting modalities for a different model than the one the loop was actually about to call. Pass the resolved modelID through hooks.Input.ModelID instead.
gtardif
approved these changes
Apr 28, 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.
Summary
Extracts the inline
stripImageContentcall fromrunStreamLoopinto a registered, runtime-private message-transform mechanism that opens the door to a family of message-mutating builtins (PII redactors, secret scrubbers, prompt-prefix injectors, …).Changes
New mechanism —
MessageTransform(in-process before_llm_call rewrites)MessageTransformtype andWithMessageTransform("name", fn)option inpkg/runtime/transforms.go.before_llm_callgate — a hook that wants to abort the call should target the gate, not a transform.hooks.Input.AgentName.First built-in transform —
strip_unsupported_modalitiespkg/runtime/strip_modalities.gohostsBuiltinStripUnsupportedModalities, the transform body, and thestripImageContenthelper (moved fromstreaming.go).if m != nil && len(m.Modalities.Input) > 0 && !slices.Contains(...)block inrunStreamLoopis gone. The loop now callsexecuteBeforeLLMCallHooks(gate) followed byapplyBeforeLLMCallTransforms(rewrite) — so a transform failure cannot waste the gate's allow verdict.Correctness fix — alloy mode + per-tool model override
ModelIDfield onhooks.Input, populated byrunStreamLoopwith the model the loop actually picked (post per-tool override, post alloy-mode random selection).in.ModelIDinstead of callingagent.Model()again — which would re-randomize the alloy pick or miss a per-tool override and consult the wrong modalities.TestStripUnsupportedModalitiesTransform_UsesInputModelID, which uses an ID-keyed model store to prove the lookup keys offModelIDrather than the agent.ModelIDis now also surfaced to user-authoredbefore_llm_callhooks for free.What's preserved
All previous user-facing behavior:
add_date/add_environment_info/add_prompt_files/cache_responsebuiltins are untouched.hooks.Inputfield additions are backward-compatible (omitemptyJSON tags; existing handlers ignore unknown fields).What's not preserved (intentional)
The original PR briefly experimented with auto-injecting transforms as
{type: builtin, command: name}entries into agent hook configs (with a no-opBuiltinFuncshim and dedup logic). This was simplified away because users couldn't actually control transforms through YAML — auto-injection always won — so the YAML coupling was internal plumbing for a control surface that didn't exist. The simplification dropped ~340 net lines without losing any user-facing capability.Why this matters
The payoff isn't in code we deleted today (the strip is the only candidate currently inline). The payoff is shrinking the diff for future message-rewriting features:
WithMessageTransform("redact_pii", fn). 0 lines in the run loop.Without this mechanism, each of those would have grown a new branch in
runStreamLoop. With it, the loop's pre-LLM-call section stays at three logical lines: get gate verdict, run transforms, call model.Validation
mise lint✓ (golangci-lint run: 0 issues, internallintchecker: no offenses,go mod tidy --diff: clean)mise test✓ (full suite passes)ModelID, registration-order chain semantics, fail-soft contract, end-to-end strip viaRunStream, end-to-end transform-error survival, input validation, alloy / per-tool override correctness.Commits
extract strip_unsupported_modalities into a registered before_llm_call transformsimplify message transforms: drop the YAML auto-injection plumbingfix strip transform reading wrong model in alloy / per-tool override modeAssisted-By: docker-agent