fix(media): drop oldest images on over-count instead of refusing #8

Merged
steve merged 3 commits from fix/image-overflow-drop-oldest into main 2026-06-28 22:43:20 +00:00
Owner

Problem (observed live)

media.Normalize refused (ErrUnsupported) when a request carried more images than the target's MaxImagesPerReq, on the theory that a failover chain would try a roomier target. In practice the chain's targets share the same cap. An agent loop that accumulates a preview image per iteration (mort's scaddy write_scad) blows past the cap, every target rejects — request carries 9 images, target allows at most 8 per request (ollama-cloud, cap 8) — and the whole run dies (all chain targets failed).

Fix

Over-count no longer fails. Normalize keeps the most-recent MaxImagesPerReq images and replaces each older one with a short text placeholder ([earlier image omitted to fit this model's per-request image limit]), preserving each message's turn structure and signalling the model that an image was elided. The most-recent images are the relevant ones in an iterative run.

  • Copy-on-write — the input request is never mutated.
  • MaxImagesPerReq stays the per-model knob (0 still = no image support).
  • SupportsImages / MIME / byte-budget / dimension normalization unchanged; the provider-side count check remains as a backstop.

Test

TestNormalizeTooManyImages_DropsOldest: 3 images across 2 messages, cap 2 → 2 kept (the most recent), 1 placeholder, no error, oldest dropped, input request unmutated. Full media + majordomo suites green (-race).

Note

The previous "refuse + let the chain try a roomier target" behavior was deliberate (honesty over silently changing the question). This trades that for keeping iterative agent runs alive, mitigated by (a) keeping the most-recent images and (b) the explicit placeholder so the model knows. Happy to gate it behind an opt-in policy instead if you'd prefer the old default preserved.

🤖 Generated with Claude Code

## Problem (observed live) `media.Normalize` **refused** (`ErrUnsupported`) when a request carried more images than the target's `MaxImagesPerReq`, on the theory that a failover chain would try a roomier target. In practice the chain's targets share the same cap. An agent loop that accumulates a preview image per iteration (mort's scaddy `write_scad`) blows past the cap, **every** target rejects — `request carries 9 images, target allows at most 8 per request` (ollama-cloud, cap 8) — and the whole run dies (`all chain targets failed`). ## Fix Over-count no longer fails. `Normalize` keeps the **most-recent `MaxImagesPerReq`** images and replaces each older one with a short text placeholder (`[earlier image omitted to fit this model's per-request image limit]`), preserving each message's turn structure and signalling the model that an image was elided. The most-recent images are the relevant ones in an iterative run. - Copy-on-write — the input request is never mutated. - `MaxImagesPerReq` stays the **per-model knob** (0 still = no image support). - `SupportsImages` / MIME / byte-budget / dimension normalization unchanged; the provider-side count check remains as a backstop. ## Test `TestNormalizeTooManyImages_DropsOldest`: 3 images across 2 messages, cap 2 → 2 kept (the most recent), 1 placeholder, no error, oldest dropped, input request unmutated. Full `media` + majordomo suites green (`-race`). ## Note The previous "refuse + let the chain try a roomier target" behavior was deliberate (honesty over silently changing the question). This trades that for keeping iterative agent runs alive, mitigated by (a) keeping the most-recent images and (b) the explicit placeholder so the model knows. Happy to gate it behind an opt-in policy instead if you'd prefer the old default preserved. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 1 commit 2026-06-28 21:38:51 +00:00
fix(media): drop oldest images on over-count instead of refusing the request
Adversarial Review (Gadfly) / review (pull_request) Has been cancelled
CI / Tidy (pull_request) Successful in 9m27s
CI / Build & Test (pull_request) Successful in 9m44s
d71aca4c3a
media.Normalize refused (ErrUnsupported) when a request carried more images than
the target's MaxImagesPerReq, on the theory that a failover chain would try a
roomier target. In practice the chain's targets share the same cap — an agent loop
that accumulates a preview image per iteration (e.g. scaddy's write_scad) blows
past the cap, EVERY target rejects ("9 images, target allows at most 8"), and the
run dies. Observed live on ollama-cloud (cap 8).

Now: over-count keeps the most-recent MaxImagesPerReq images and replaces each
older one with a short text placeholder ("[earlier image omitted to fit this
model's per-request image limit]"), preserving each message's turn structure and
telling the model an image was elided. The most-recent images are the relevant
ones in an iterative run. Copy-on-write; the input request is never mutated. The
per-model threshold stays configurable via Capabilities.MaxImagesPerReq (0 still
means no image support); SupportsImages / MIME / byte-budget / dimension behavior
is unchanged, and the provider-side count backstop remains.

Test: TestNormalizeTooManyImages_DropsOldest — 3 images, cap 2 → 2 kept (the most
recent), 1 placeholder, no error, oldest dropped, input unmutated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

🪰 Gadfly review — glm-5.2:cloud (ollama-cloud)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

  • Verified media/media.go:46-152: the only new data introduced into the request is the constant imageOverflowPlaceholder (line 110), a fixed string with no user-controlled/tainted content — no prompt-injection, template, SQL, command, or path vector. Dropped images are selected purely by count/order, not by any attacker-influenced key.
  • Remaining images still flow through normalizeImage (MIME sniff, byte budget, dimension caps) at lines 83-101, so the overflow path does not bypass any existing input validation on the retained image bytes.
  • MaxImagesPerReq == 0 still means "no image support" and is gated by SupportsImages() at line 58 (llm/capabilities.go:37), so the over-count drop path is only reachable for targets that explicitly support images — no text-only target silently accepts images.
  • No secret/credential leakage, SSRF, deserialization, or authz surface in this change. Copy-on-write is a correctness concern, not security, and is outside this lens.
🎯 Correctness — No material issues found

Verdict: No material issues found

I verified the correctness of the change through the following checks:

  • Drop math is exact. n = total - caps.MaxImagesPerReq when total > cap. dropOldestImages replaces the first n image parts front-to-back, leaving exactly total - n = cap images. Since n < total always holds (because total > cap), the loop never runs short of images to drop. Confirmed by reading media/media.go:70-71,116-143.

  • Oldest-first ordering is correct. Messages are iterated in slice order (oldest→newest in conversation history) and image parts within a message are iterated in order, so the oldest images are dropped and the most-recent MaxImagesPerReq survive. media/media.go:124-141.

  • MaxImagesPerReq == 0 is safe. The overflow branch is only reachable after caps.SupportsImages() passes (media/media.go:58), and SupportsImages() returns MaxImagesPerReq > 0 (llm/capabilities.go:37). So total - caps.MaxImagesPerReq is never evaluated with a zero cap in the overflow path; no negative-n or refuse-when-unsupported regression.

  • Copy-on-write is sound. dropOldestImages allocates a new Messages slice and only allocates new Parts for messages whose images are actually replaced; messages with no images (or past the drop point) share their Parts slice read-only. The subsequent normalizeImage loop (media/media.go:74-103) operates on the result of dropOldestImages and does its own copy-on-write for transforms. The input request is never mutated. Verified at media/media.go:116-143.

  • Provider-side backstop stays consistent. After dropping, the remaining image count equals cap, so the provider checks (images > caps.MaxImagesPerReq) in provider/anthropic/anthropic.go:219 and provider/openai/model.go:214 evaluate to cap > cap = false and pass. No double-rejection.

  • Partial-drop within a multi-image message is handled. When a message has several images and the drop count is exhausted mid-message, only the earlier images in that message become placeholders and the rest are kept, which is consistent with "keep most-recent." media/media.go:132-139.

No correctness bugs found.

🧹 Code cleanliness & maintainability — Minor issues

VERDICT: Minor issues

  • media/media.go:61-69 — The 9-line inline comment restates the package-level doc at media/media.go:12-17 almost verbatim (same "agent loop accumulating a preview image per iteration" rationale, same "0 = no image support" note). Two copies of the same rationale must now be kept in sync; if one drifts the other misleads. Trim the inline comment to 2-3 lines and let the package doc own the full justification.

  • media/media.go:116-143 vs 74-103dropOldestImages performs its own full copy-on-write (out.Messages = make(...); copy(...) at lines 121-122, plus per-message parts rebuild), and the main Normalize loop has a second, independent COW mechanism (copiedMessages/copiedParts flags at lines 75, 90-99). In the overflow path both fire: after req = dropOldestImages(...) (line 71), the first image transform re-allocates and re-copies out.Messages at lines 91-92 because copiedMessages is still false. The Messages slice is thus allocated and copied twice. The two COW idioms do the same job redundantly; consider having dropOldestImages set up out/copiedMessages state so the main loop skips its re-copy, or unify the strategy.

  • media/media.go:131-140 — The parts slice is rebuilt (make([]llm.Part, 0, len(...))) for every image-bearing message entered while dropped < n, including the boundary message where dropped reaches n partway through and the remaining parts are appended unchanged. For that message the allocation is unnecessary, and it reads as "always rebuild" rather than "rebuild only if something changed," inconsistent with the main loop's changed-gated copy. Low impact.

No dead code or naming problems found; hasImagePart and imageOverflowPlaceholder are reasonably named and scoped.

Performance — No material issues found

VERDICT: No material issues found

Through the performance lens, I verified the full Normalize flow (media/media.go:46-105) and dropOldestImages (media/media.go:116-143):

  • Decode work is correctly bounded by the cap. dropOldestImages replaces overflow images with cheap text placeholders before the main loop, so normalizeImage (the expensive decode/scale/encode path) is only ever called on the surviving MaxImagesPerReq images. With a large overflow (e.g. 1000 images, cap 8), only 8 images are decoded — this is actually better than the old behavior, which would have errored and killed the run anyway.
  • No new work on the non-overflow path. dropOldestImages is only called under the same total > caps.MaxImagesPerReq condition that previously returned an error. The fast paths (no images, images-already-fit) are unchanged.
  • No quadratic/unbounded behavior. All scanning is O(total parts), same order as the existing counting loop at lines 47-54. dropOldestImages breaks early once dropped >= n. No I/O or blocking calls introduced.

One negligible observation (not a material issue): on the overflow path, dropOldestImages allocates a fresh Messages slice and per-message Parts slices, and then the main normalize loop at lines 90-99 alloc

🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

Through the error-handling & edge-cases lens, I verified the following by reading the code:

  • dropOldestImages n-guard (media/media.go:117): n <= 0 returns req unchanged — safe. The only caller passes total - caps.MaxImagesPerReq, and since the call site is gated by total > caps.MaxImagesPerReq with SupportsImages() (which requires MaxImagesPerReq > 0, llm/capabilities.go:37) already checked, n is always strictly positive here. No negative/zero overflow path.

  • Copy-on-write correctness (media/media.go:120-141): A fresh Messages slice is allocated and the message structs are copied. New Parts slices are allocated only for messages containing image parts; messages without images keep their original (shared, unmutated) Parts. The inner loop only mutates the newly allocated parts slice, never the input. I confirmed llm.Message/llm.Part are plain value types (llm/content.go), so the shallow copy is sufficient — no aliasing of mutable state. The test's unmutation assertion (media_test.go) corroborates this.

  • Under-drop risk vs. provider backstop: total in Normalize and the drop count in dropOldestImages both use the same p.(llm.ImagePart) type assertion over the same message order, so exactly n images are dropped, leaving exactly MaxImagesPerReq. I checked the provider backstops (provider/anthropic/anthropic.go:219, provider/google/google.go:179, provider/ollama/wire.go:118) — all reject only count > MaxImagesPerReq (strictly greater), so an equal-count request passes. No mismatch that would cause the backstop to re-reject what Normalize just trimmed.

  • Nil/empty edge cases: req.Messages empty → total == 0 → early return at media.go:55. A message with nil PartshasImagePart returns false → skipped, no panic on range-over-nil.

  • No swallowed errors: The change removes an error return and replaces it with a non-erroring transform; this is the intended behavior change, not an accidental swallow. All other error paths in Normalize (unsupported target, normalizeImage failures) are unchanged and still propagated.

No material error-handling or edge-case issues in this change.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 5m 7s

<!-- gadfly-review:ollama:glm-5.2:cloud --> ### 🪰 Gadfly review — `glm-5.2:cloud` (ollama-cloud) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> VERDICT: No material issues found - Verified `media/media.go:46-152`: the only new data introduced into the request is the constant `imageOverflowPlaceholder` (line 110), a fixed string with no user-controlled/tainted content — no prompt-injection, template, SQL, command, or path vector. Dropped images are selected purely by count/order, not by any attacker-influenced key. - Remaining images still flow through `normalizeImage` (MIME sniff, byte budget, dimension caps) at lines 83-101, so the overflow path does not bypass any existing input validation on the retained image bytes. - `MaxImagesPerReq == 0` still means "no image support" and is gated by `SupportsImages()` at line 58 (`llm/capabilities.go:37`), so the over-count drop path is only reachable for targets that explicitly support images — no text-only target silently accepts images. - No secret/credential leakage, SSRF, deserialization, or authz surface in this change. Copy-on-write is a correctness concern, not security, and is outside this lens. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **Verdict: No material issues found** I verified the correctness of the change through the following checks: - **Drop math is exact.** `n = total - caps.MaxImagesPerReq` when `total > cap`. `dropOldestImages` replaces the first `n` image parts front-to-back, leaving exactly `total - n = cap` images. Since `n < total` always holds (because `total > cap`), the loop never runs short of images to drop. Confirmed by reading `media/media.go:70-71,116-143`. - **Oldest-first ordering is correct.** Messages are iterated in slice order (oldest→newest in conversation history) and image parts within a message are iterated in order, so the oldest images are dropped and the most-recent `MaxImagesPerReq` survive. `media/media.go:124-141`. - **`MaxImagesPerReq == 0` is safe.** The overflow branch is only reachable after `caps.SupportsImages()` passes (`media/media.go:58`), and `SupportsImages()` returns `MaxImagesPerReq > 0` (`llm/capabilities.go:37`). So `total - caps.MaxImagesPerReq` is never evaluated with a zero cap in the overflow path; no negative-`n` or refuse-when-unsupported regression. - **Copy-on-write is sound.** `dropOldestImages` allocates a new `Messages` slice and only allocates new `Parts` for messages whose images are actually replaced; messages with no images (or past the drop point) share their `Parts` slice read-only. The subsequent `normalizeImage` loop (`media/media.go:74-103`) operates on the result of `dropOldestImages` and does its own copy-on-write for transforms. The input request is never mutated. Verified at `media/media.go:116-143`. - **Provider-side backstop stays consistent.** After dropping, the remaining image count equals `cap`, so the provider checks (`images > caps.MaxImagesPerReq`) in `provider/anthropic/anthropic.go:219` and `provider/openai/model.go:214` evaluate to `cap > cap` = false and pass. No double-rejection. - **Partial-drop within a multi-image message is handled.** When a message has several images and the drop count is exhausted mid-message, only the earlier images in that message become placeholders and the rest are kept, which is consistent with "keep most-recent." `media/media.go:132-139`. No correctness bugs found. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> VERDICT: Minor issues - `media/media.go:61-69` — The 9-line inline comment restates the package-level doc at `media/media.go:12-17` almost verbatim (same "agent loop accumulating a preview image per iteration" rationale, same "0 = no image support" note). Two copies of the same rationale must now be kept in sync; if one drifts the other misleads. Trim the inline comment to 2-3 lines and let the package doc own the full justification. - `media/media.go:116-143` vs `74-103` — `dropOldestImages` performs its own full copy-on-write (`out.Messages = make(...); copy(...)` at lines 121-122, plus per-message `parts` rebuild), and the main `Normalize` loop has a *second*, independent COW mechanism (`copiedMessages`/`copiedParts` flags at lines 75, 90-99). In the overflow path both fire: after `req = dropOldestImages(...)` (line 71), the first image transform re-allocates and re-copies `out.Messages` at lines 91-92 because `copiedMessages` is still false. The Messages slice is thus allocated and copied twice. The two COW idioms do the same job redundantly; consider having `dropOldestImages` set up `out`/`copiedMessages` state so the main loop skips its re-copy, or unify the strategy. - `media/media.go:131-140` — The `parts` slice is rebuilt (`make([]llm.Part, 0, len(...))`) for every image-bearing message entered while `dropped < n`, including the boundary message where `dropped` reaches `n` partway through and the remaining parts are appended unchanged. For that message the allocation is unnecessary, and it reads as "always rebuild" rather than "rebuild only if something changed," inconsistent with the main loop's `changed`-gated copy. Low impact. No dead code or naming problems found; `hasImagePart` and `imageOverflowPlaceholder` are reasonably named and scoped. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** Through the performance lens, I verified the full `Normalize` flow (`media/media.go:46-105`) and `dropOldestImages` (`media/media.go:116-143`): - **Decode work is correctly bounded by the cap.** `dropOldestImages` replaces overflow images with cheap text placeholders *before* the main loop, so `normalizeImage` (the expensive decode/scale/encode path) is only ever called on the surviving `MaxImagesPerReq` images. With a large overflow (e.g. 1000 images, cap 8), only 8 images are decoded — this is actually better than the old behavior, which would have errored and killed the run anyway. - **No new work on the non-overflow path.** `dropOldestImages` is only called under the same `total > caps.MaxImagesPerReq` condition that previously returned an error. The fast paths (no images, images-already-fit) are unchanged. - **No quadratic/unbounded behavior.** All scanning is O(total parts), same order as the existing counting loop at lines 47-54. `dropOldestImages` breaks early once `dropped >= n`. No I/O or blocking calls introduced. One negligible observation (not a material issue): on the overflow path, `dropOldestImages` allocates a fresh `Messages` slice and per-message `Parts` slices, and then the main normalize loop at lines 90-99 alloc </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **VERDICT: No material issues found** Through the error-handling & edge-cases lens, I verified the following by reading the code: - **`dropOldestImages` n-guard** (`media/media.go:117`): `n <= 0` returns `req` unchanged — safe. The only caller passes `total - caps.MaxImagesPerReq`, and since the call site is gated by `total > caps.MaxImagesPerReq` with `SupportsImages()` (which requires `MaxImagesPerReq > 0`, `llm/capabilities.go:37`) already checked, `n` is always strictly positive here. No negative/zero overflow path. - **Copy-on-write correctness** (`media/media.go:120-141`): A fresh `Messages` slice is allocated and the message structs are copied. New `Parts` slices are allocated only for messages containing image parts; messages without images keep their original (shared, unmutated) `Parts`. The inner loop only mutates the newly allocated `parts` slice, never the input. I confirmed `llm.Message`/`llm.Part` are plain value types (`llm/content.go`), so the shallow copy is sufficient — no aliasing of mutable state. The test's unmutation assertion (`media_test.go`) corroborates this. - **Under-drop risk vs. provider backstop**: `total` in `Normalize` and the drop count in `dropOldestImages` both use the same `p.(llm.ImagePart)` type assertion over the same message order, so exactly `n` images are dropped, leaving exactly `MaxImagesPerReq`. I checked the provider backstops (`provider/anthropic/anthropic.go:219`, `provider/google/google.go:179`, `provider/ollama/wire.go:118`) — all reject only `count > MaxImagesPerReq` (strictly greater), so an equal-count request passes. No mismatch that would cause the backstop to re-reject what `Normalize` just trimmed. - **Nil/empty edge cases**: `req.Messages` empty → `total == 0` → early return at `media.go:55`. A message with nil `Parts` → `hasImagePart` returns false → skipped, no panic on range-over-nil. - **No swallowed errors**: The change removes an error return and replaces it with a non-erroring transform; this is the intended behavior change, not an accidental swallow. All other error paths in `Normalize` (unsupported target, `normalizeImage` failures) are unchanged and still propagated. No material error-handling or edge-case issues in this change. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 5m 7s</sub>

🪰 Gadfly review — minimax-m3:cloud (ollama-cloud)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

Verified the change against the checked-out code: media/media.go lines 46-152, media/media_test.go lines 152-197, llm/content.go (Part interface and constructors), llm/capabilities.go (SupportsImages), and the providers' SupportsImages call sites. The copy-on-write claim holds: dropOldestImages always allocates a new Messages slice and a new Parts slice for every message containing an ImagePart; untouched messages' Parts slices are shared but never mutated. The n parameter is bounded: callers compute n = total - caps.MaxImagesPerReq only after caps.SupportsImages() has confirmed MaxImagesPerReq > 0, so n ∈ [1, total-1], and the inner dropped < n guard guarantees termination.

VERDICT: No material issues found

Security review (🔒) of this change:

  • Injection / tainted input — None introduced. imageOverflowPlaceholder is a package-level const string; no caller-controlled bytes flow into it, and no new SQL/command/path/template sinks are touched.
  • Authn/authz — Not applicable; this is content-shape normalization upstream of provider calls.
  • SSRF / unsafe deserialization — Not in scope; no new network or decode surface. Image bytes still flow through the existing image.Decode/DecodeConfig paths unchanged.
  • Secret / credential leakage — None. Dropping the oldest images strictly removes bytes (potentially sensitive or prompt-injection-laden content) from the request sent to the provider; the new placeholder discloses nothing about what was elided.
  • Input validation / DoSn <= 0 early-returns; the loop is bounded by both dropped < n and the finite message/parts walks. caps.MaxImagesPerReq is guaranteed > 0 by the earlier !caps.SupportsImages() check at media/media.go:58, so n cannot exceed total - 1. No panic on empty messages.
  • Insecure defaultsMaxImagesPerReq = 0 still means "no image support" (the !caps.SupportsImages() branch is unchanged), and the provider-side count backstop mentioned in the PR description is untouched. The behavior change (silent drop vs. hard error) is a policy/UX choice, not a security regression — a constant string in place of an image does not widen the trust boundary.
🎯 Correctness — Minor issues

VERDICT: Minor issues

I read media/media.go (lines 1–152), media/media_test.go (lines 152–197), and confirmed the helper definitions in llm/message.go, llm/content.go, and llm/capabilities.go.

Findings (Correctness lens)

  • media/media_test.go:194 — Mutation assertion is a no-op for this case.
    The test claims to verify copy-on-write with:

    if len(req.Messages[0].Parts) != 2 {
        t.Errorf("input request was mutated: %+v", req.Messages[0].Parts)
    }
    

    In this test, req.Messages[0] starts as [a, b]. After the drop, the expected output for that slot is [placeholder, b] — also length 2. If dropOldestImages were buggy and mutated the input in place (replacing a with the placeholder text), the length would still be 2 and this assertion would pass silently. The check therefore cannot distinguish a correct CoW result from a mutation that happens to preserve part count.

    The two nearby assertions (bytes.Equal(im.Data, a.Data) for kept images, and the placeholders != 1 count) only inspect the output. There is no check that req.Messages[0].Parts[0] is still llm.ImagePart with Data == a.Data after Normalize returns.

    Suggested fix: assert that the first part of the first input message is still an ImagePart whose Data equals a.Data, e.g.

    if got, ok := req.Messages[0].Parts[0].(llm.ImagePart); !ok || !bytes.Equal(got.Data, a.Data) {
        t.Errorf("input mutated: oldest image was replaced in place, got %#v", req.Messages[0].Parts[0])
    }
    

    Confirmed by reading the test (lines 152–197) and the loop in dropOldestImages (media/media.go:116–143), which mutates out.Messages[mi].Parts in a way that, if done in place on the input, would leave len(req.Messages[0].Parts) == 2.

  • media/media_test.go:182-192 — "Oldest dropped" check is one-sided.
    The test asserts that the oldest image a's bytes are absent from the kept images, but does not positively assert that b's and c's bytes are present. A bug that produced a kept set of [b, b] (e.g. a duplicate rather than a different image) would pass all three existing checks: len(imgs) == 2, placeholders == 1, and no kept image equals a.Data. The PR description promises "the most-recent two … are kept" — the test should pin both ends.

    (Note: the draft's specific example — "a bug that kept a and c (and dropped b)" — is actually caught by line 189, since keeping a would put a.Data in the kept set. The valid core concern is that the kept set's identity is not positively asserted.)

    Suggested fix: pin the kept set explicitly, e.g. after collecting imgs:

    got := [][]byte{imgs[0].Data, imgs[1].Data}
    want := [][]byte{b.Data, c.Data}
    // sort or compare as a multiset
    

    Confirmed by reading the test (lines 168–192): only a.Data is checked; no assertion references b.Data or c.Data after the drop.

Verified as correct (no issue)

  • Algorithm: I traced dropOldestImages (media/media.go:116–143) on several message layouts (single message with many images; images spread across messages with intervening text-only turns; cap = 1, 2, ≥ total). The front-to-back single-pass with early-exit after n drops correctly yields "drop the n oldest images, keep the rest," and does not exceed MaxImagesPerReq in any layout I checked.
  • Copy-on-write (implementation): dropOldestImages allocates a fresh Messages slice (lines 121–122) and only reassigns out.Messages[mi].Parts (line 140), so the caller's req.Messages and per-message Parts slices are not touched. The subsequent normalizeImage loop in Normalize (media.go:74–103) operates on the rebinding req and preserves CoW for unchanged messages. Verified by reading both functions end-to-end.
  • n <= 0 / invariant on n: Because SupportsImages() (llm/capabilities.go:37) requires MaxImagesPerReq > 0, the total > caps.MaxImagesPerReq branch (media.go:70) only fires with 0 < n ≤ total. The n <= 0 guard is correct and sufficient.
  • Placeholder type: llm.Text(imageOverflowPlaceholder) returns llm.TextPart (llm/content.go:36), which is a valid Part — no interface-mismatch.
🧹 Code cleanliness & maintainability — Minor issues

VERDICT: Minor issues

  • media/media.go:145hasImagePart is a one-shot helper that doesn't earn a name. Confirmed: grep shows it's defined only at media/media.go:145 and called only from media/media.go:128 inside dropOldestImages. Its sole effect is to skip a make([]llm.Part, 0, len(...)) allocation for messages with no image part; the inner loop below it already produces a semantically identical result for that case (it would copy through unchanged). Inline the check or drop the early-continue entirely — naming a one-call internal helper makes future readers chase it.

  • media/media.go:116dropOldestImages misnames the operation. Confirmed against media/media.go:112-115: the doc comment says "replaces the n oldest image parts … with imageOverflowPlaceholder text, keeping the most-recent images and preserving every message's turn structure," and media/media.go:107-110 says the constant "replaces an image dropped to fit." The package doc at media/media.go:12-17 also says "replaced with a short text placeholder." The function name drop implies removal; the parts are preserved (count and turn structure intact, only the bytes change). Suggest renaming to replaceOldestImages (matches doc text) or elideOldestImages.

  • media/media_test.go:152TestNormalizeTooManyImages_DropsOldest breaks the file's naming convention. Confirmed by listing every Test function in media/media_test.go: this is the only test in the file using an underscore separator (lines 104, 118, 141, 199, 212, 231, 254, 273, 291, 330, 346, 368, 389, 410, 427, 462, 487, 527). Rename to TestNormalizeTooManyImagesDropsOldest for consistency.

  • media/media.go:70-104 — minor duplicated copy-on-write when overflow and a transform both apply. Confirmed: after req = dropOldestImages(req, ...) at line 71, req.Messages is already a fresh slice (see out.Messages = make(...) + copy(...) at media/media.go:121-122). Then at line 74 out := req aliases that slice header, and copiedMessages starts false. If any image subsequently transforms, the loop at media/media.go:90-94 does out.Messages = make(...) + copy(out.Messages, req.Messages) again — a redundant copy, since the original input is no longer reachable through req. Not buggy (the input stays unmutated either way), just wasted work. Worth knowing if the copy-on-write logic is ever refactored.

Performance — Minor issues

VERDICT: Minor issues

  • Redundant double-copy on overflow + normalizemedia/media.go:70-103. Confirmed by reading the file end-to-end. dropOldestImages (lines 116–143) unconditionally allocates a fresh Messages slice at line 121 and, for every message that carries an image, a fresh Parts slice at line 131. The normalization loop at lines 74–103 then begins with out := req (where req has already been reassigned to dropOldestImages's output on line 71) but its copiedMessages / copiedParts booleans default to false and have no knowledge that copies already exist. As soon as any surviving image's normalizeImage returns changed=true, lines 90–99 re-make/copy the Messages slice AND re-make/copy that message's Parts slice — wasted work on the overflow path. Output remains correct (the second copy is independent of req), but it is a real regression versus the pre-change behavior where overflow short-circuited to an error before the normalization loop ran. The path is cold (only when total > MaxImagesPerReq), so impact is bounded. Suggested fix: have dropOldestImages return a flag (or restructure so the normalization loop reads out.Messages directly) so the second copy is skipped when dropOldestImages already produced fresh slices.

  • Pre-existing count pass is unchangedmedia/media.go:47-54. Confirmed: the existing count pass walks every message/parts pair exactly once and is untouched by this diff; the new dropOldestImages adds another linear O(M·P) pass over the parts, so total work remains linear in the total number of parts — no quadratic behavior is introduced. No action.

🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

Through the error-handling & edge-cases lens, the change holds up. Verified by reading media/media.go and media/media_test.go:

  • n <= 0 guard at the top of dropOldestImages (media/media.go:117-119) absorbs both n == 0 and any negative n that could result from total - caps.MaxImagesPerReq (e.g., negative cap values). Safe.
  • SupportsImages() is checked before the overflow branch (media/media.go:58), so MaxImagesPerReq == 0 with images present still returns ErrUnsupported and never reaches dropOldestImages — no path drops all images and proceeds to encode an empty list.
  • total invariant preserved: n = total - caps.MaxImagesPerReq with the prior total > caps.MaxImagesPerReq check guarantees 1 <= n <= total, so the per-image loop can never underrun the count of available ImageParts.
  • Copy-on-write holds: out.Messages is freshly allocated and copy'd (media/media.go:121-122); every touched message gets a freshly allocated Parts slice (media/media.go:131). The later normalizeImage loop operates on the new out, not the input. The test at media/media_test.go:193-196 verifies the input is untouched.
  • No panics on boundary inputs: nil/empty Parts (no images) is skipped via hasImagePart; empty Messages is impossible to reach here because total == 0 returns early at media/media.go:55-57.
  • No swallowed errors: the function returns a new request and never errors; the only error paths in Normalize (undecodable bytes, byte-budget failure) are unchanged and still wrap llm.ErrUnsupported.
  • Off-by-one: the if dropped >= n { break } check is evaluated before processing each message and after every drop, so the exact n oldest images are replaced with text placeholders.

One non-blocking observation (not in my lens, so I'm not reporting it as a finding): the placeholder is a TextPart inserted mid-Parts slice; if any provider's parser assumes images are grouped contiguously or that text precedes images, it may now mis-tokenize a message. That's a provider-contract concern rather than an error-handling one.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 48s

<!-- gadfly-review:ollama:minimax-m3:cloud --> ### 🪰 Gadfly review — `minimax-m3:cloud` (ollama-cloud) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> Verified the change against the checked-out code: `media/media.go` lines 46-152, `media/media_test.go` lines 152-197, `llm/content.go` (Part interface and constructors), `llm/capabilities.go` (`SupportsImages`), and the providers' `SupportsImages` call sites. The copy-on-write claim holds: `dropOldestImages` always allocates a new `Messages` slice and a new `Parts` slice for every message containing an `ImagePart`; untouched messages' `Parts` slices are shared but never mutated. The `n` parameter is bounded: callers compute `n = total - caps.MaxImagesPerReq` only after `caps.SupportsImages()` has confirmed `MaxImagesPerReq > 0`, so `n ∈ [1, total-1]`, and the inner `dropped < n` guard guarantees termination. ## VERDICT: No material issues found Security review (🔒) of this change: - **Injection / tainted input** — None introduced. `imageOverflowPlaceholder` is a package-level `const` string; no caller-controlled bytes flow into it, and no new SQL/command/path/template sinks are touched. - **Authn/authz** — Not applicable; this is content-shape normalization upstream of provider calls. - **SSRF / unsafe deserialization** — Not in scope; no new network or decode surface. Image bytes still flow through the existing `image.Decode`/`DecodeConfig` paths unchanged. - **Secret / credential leakage** — None. Dropping the oldest images strictly *removes* bytes (potentially sensitive or prompt-injection-laden content) from the request sent to the provider; the new placeholder discloses nothing about what was elided. - **Input validation / DoS** — `n <= 0` early-returns; the loop is bounded by both `dropped < n` and the finite message/parts walks. `caps.MaxImagesPerReq` is guaranteed `> 0` by the earlier `!caps.SupportsImages()` check at `media/media.go:58`, so `n` cannot exceed `total - 1`. No panic on empty messages. - **Insecure defaults** — `MaxImagesPerReq = 0` still means "no image support" (the `!caps.SupportsImages()` branch is unchanged), and the provider-side count backstop mentioned in the PR description is untouched. The behavior change (silent drop vs. hard error) is a policy/UX choice, not a security regression — a constant string in place of an image does not widen the trust boundary. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> ## VERDICT: Minor issues I read `media/media.go` (lines 1–152), `media/media_test.go` (lines 152–197), and confirmed the helper definitions in `llm/message.go`, `llm/content.go`, and `llm/capabilities.go`. ### Findings (Correctness lens) - **`media/media_test.go:194` — Mutation assertion is a no-op for this case.** The test claims to verify copy-on-write with: ```go if len(req.Messages[0].Parts) != 2 { t.Errorf("input request was mutated: %+v", req.Messages[0].Parts) } ``` In this test, `req.Messages[0]` starts as `[a, b]`. After the drop, the *expected* output for that slot is `[placeholder, b]` — also length 2. If `dropOldestImages` were buggy and mutated the input in place (replacing `a` with the placeholder text), the length would still be 2 and this assertion would pass silently. The check therefore cannot distinguish a correct CoW result from a mutation that happens to preserve part count. The two nearby assertions (`bytes.Equal(im.Data, a.Data)` for kept images, and the `placeholders != 1` count) only inspect the *output*. There is no check that `req.Messages[0].Parts[0]` is still `llm.ImagePart` with `Data == a.Data` after `Normalize` returns. Suggested fix: assert that the first part of the first input message is still an `ImagePart` whose `Data` equals `a.Data`, e.g. ```go if got, ok := req.Messages[0].Parts[0].(llm.ImagePart); !ok || !bytes.Equal(got.Data, a.Data) { t.Errorf("input mutated: oldest image was replaced in place, got %#v", req.Messages[0].Parts[0]) } ``` Confirmed by reading the test (lines 152–197) and the loop in `dropOldestImages` (media/media.go:116–143), which mutates `out.Messages[mi].Parts` in a way that, if done in place on the input, would leave `len(req.Messages[0].Parts) == 2`. - **`media/media_test.go:182-192` — "Oldest dropped" check is one-sided.** The test asserts that the oldest image `a`'s bytes are absent from the kept images, but does not positively assert that `b`'s and `c`'s bytes are present. A bug that produced a kept set of `[b, b]` (e.g. a duplicate rather than a different image) would pass all three existing checks: `len(imgs) == 2`, `placeholders == 1`, and no kept image equals `a.Data`. The PR description promises "the most-recent two … are kept" — the test should pin both ends. (Note: the draft's specific example — "a bug that kept `a` and `c` (and dropped `b`)" — is actually caught by line 189, since keeping `a` would put `a.Data` in the kept set. The valid core concern is that the kept set's *identity* is not positively asserted.) Suggested fix: pin the kept set explicitly, e.g. after collecting `imgs`: ```go got := [][]byte{imgs[0].Data, imgs[1].Data} want := [][]byte{b.Data, c.Data} // sort or compare as a multiset ``` Confirmed by reading the test (lines 168–192): only `a.Data` is checked; no assertion references `b.Data` or `c.Data` after the drop. ### Verified as correct (no issue) - **Algorithm**: I traced `dropOldestImages` (media/media.go:116–143) on several message layouts (single message with many images; images spread across messages with intervening text-only turns; cap = 1, 2, ≥ total). The front-to-back single-pass with early-exit after `n` drops correctly yields "drop the `n` oldest images, keep the rest," and does not exceed `MaxImagesPerReq` in any layout I checked. - **Copy-on-write (implementation)**: `dropOldestImages` allocates a fresh `Messages` slice (lines 121–122) and only reassigns `out.Messages[mi].Parts` (line 140), so the caller's `req.Messages` and per-message `Parts` slices are not touched. The subsequent `normalizeImage` loop in `Normalize` (media.go:74–103) operates on the rebinding `req` and preserves CoW for unchanged messages. Verified by reading both functions end-to-end. - **`n <= 0` / invariant on `n`**: Because `SupportsImages()` (llm/capabilities.go:37) requires `MaxImagesPerReq > 0`, the `total > caps.MaxImagesPerReq` branch (media.go:70) only fires with `0 < n ≤ total`. The `n <= 0` guard is correct and sufficient. - **Placeholder type**: `llm.Text(imageOverflowPlaceholder)` returns `llm.TextPart` (llm/content.go:36), which is a valid `Part` — no interface-mismatch. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **VERDICT: Minor issues** - **`media/media.go:145` — `hasImagePart` is a one-shot helper that doesn't earn a name.** Confirmed: grep shows it's defined only at `media/media.go:145` and called only from `media/media.go:128` inside `dropOldestImages`. Its sole effect is to skip a `make([]llm.Part, 0, len(...))` allocation for messages with no image part; the inner loop below it already produces a semantically identical result for that case (it would copy through unchanged). Inline the check or drop the early-continue entirely — naming a one-call internal helper makes future readers chase it. - **`media/media.go:116` — `dropOldestImages` misnames the operation.** Confirmed against `media/media.go:112-115`: the doc comment says "replaces the n oldest image parts … with `imageOverflowPlaceholder` text, keeping the most-recent images and preserving every message's turn structure," and `media/media.go:107-110` says the constant "replaces an image dropped to fit." The package doc at `media/media.go:12-17` also says "replaced with a short text placeholder." The function name `drop` implies removal; the parts are preserved (count and turn structure intact, only the bytes change). Suggest renaming to `replaceOldestImages` (matches doc text) or `elideOldestImages`. - **`media/media_test.go:152` — `TestNormalizeTooManyImages_DropsOldest` breaks the file's naming convention.** Confirmed by listing every `Test` function in `media/media_test.go`: this is the only test in the file using an underscore separator (lines 104, 118, 141, 199, 212, 231, 254, 273, 291, 330, 346, 368, 389, 410, 427, 462, 487, 527). Rename to `TestNormalizeTooManyImagesDropsOldest` for consistency. - **`media/media.go:70-104` — minor duplicated copy-on-write when overflow and a transform both apply.** Confirmed: after `req = dropOldestImages(req, ...)` at line 71, `req.Messages` is already a fresh slice (see `out.Messages = make(...)` + `copy(...)` at `media/media.go:121-122`). Then at line 74 `out := req` aliases that slice header, and `copiedMessages` starts `false`. If any image subsequently transforms, the loop at `media/media.go:90-94` does `out.Messages = make(...)` + `copy(out.Messages, req.Messages)` again — a redundant copy, since the original input is no longer reachable through `req`. Not buggy (the input stays unmutated either way), just wasted work. Worth knowing if the copy-on-write logic is ever refactored. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> ## VERDICT: Minor issues - **Redundant double-copy on overflow + normalize** — `media/media.go:70-103`. Confirmed by reading the file end-to-end. `dropOldestImages` (lines 116–143) unconditionally allocates a fresh `Messages` slice at line 121 and, for every message that carries an image, a fresh `Parts` slice at line 131. The normalization loop at lines 74–103 then begins with `out := req` (where `req` has already been reassigned to `dropOldestImages`'s output on line 71) but its `copiedMessages` / `copiedParts` booleans default to `false` and have no knowledge that copies already exist. As soon as any surviving image's `normalizeImage` returns `changed=true`, lines 90–99 re-`make`/`copy` the `Messages` slice AND re-`make`/`copy` that message's `Parts` slice — wasted work on the overflow path. Output remains correct (the second copy is independent of `req`), but it is a real regression versus the pre-change behavior where overflow short-circuited to an error before the normalization loop ran. The path is cold (only when `total > MaxImagesPerReq`), so impact is bounded. Suggested fix: have `dropOldestImages` return a flag (or restructure so the normalization loop reads `out.Messages` directly) so the second copy is skipped when `dropOldestImages` already produced fresh slices. - **Pre-existing count pass is unchanged** — `media/media.go:47-54`. Confirmed: the existing count pass walks every message/parts pair exactly once and is untouched by this diff; the new `dropOldestImages` adds another linear O(M·P) pass over the parts, so total work remains linear in the total number of parts — no quadratic behavior is introduced. No action. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **VERDICT: No material issues found** Through the error-handling & edge-cases lens, the change holds up. Verified by reading `media/media.go` and `media/media_test.go`: - **`n <= 0` guard** at the top of `dropOldestImages` (`media/media.go:117-119`) absorbs both `n == 0` and any negative `n` that could result from `total - caps.MaxImagesPerReq` (e.g., negative cap values). Safe. - **`SupportsImages()` is checked before the overflow branch** (`media/media.go:58`), so `MaxImagesPerReq == 0` with images present still returns `ErrUnsupported` and never reaches `dropOldestImages` — no path drops *all* images and proceeds to encode an empty list. - **`total` invariant preserved**: `n = total - caps.MaxImagesPerReq` with the prior `total > caps.MaxImagesPerReq` check guarantees `1 <= n <= total`, so the per-image loop can never underrun the count of available `ImagePart`s. - **Copy-on-write** holds: `out.Messages` is freshly allocated and `copy`'d (`media/media.go:121-122`); every touched message gets a freshly allocated `Parts` slice (`media/media.go:131`). The later `normalizeImage` loop operates on the new `out`, not the input. The test at `media/media_test.go:193-196` verifies the input is untouched. - **No panics on boundary inputs**: nil/empty `Parts` (no images) is skipped via `hasImagePart`; empty `Messages` is impossible to reach here because `total == 0` returns early at `media/media.go:55-57`. - **No swallowed errors**: the function returns a new request and never errors; the only error paths in `Normalize` (undecodable bytes, byte-budget failure) are unchanged and still wrap `llm.ErrUnsupported`. - **Off-by-one**: the `if dropped >= n { break }` check is evaluated before processing each message and after every drop, so the exact `n` oldest images are replaced with text placeholders. One non-blocking observation (not in my lens, so I'm not reporting it as a finding): the placeholder is a `TextPart` inserted mid-`Parts` slice; if any provider's parser assumes images are grouped contiguously or that text precedes images, it may now mis-tokenize a message. That's a provider-contract concern rather than an error-handling one. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 48s</sub>

🪰 Gadfly review — glm-5.1:cloud (ollama-cloud)

Reviewing… this comment will update with findings and run time.

<!-- gadfly-review:ollama:glm-5.1:cloud --> ### 🪰 Gadfly review — `glm-5.1:cloud` (ollama-cloud) ⏳ Reviewing… this comment will update with findings and run time.

🪰 Gadfly — live review status

7/7 reviewers finished · updated 2026-06-28 22:06:26Z

claude-code/opus · claude-code — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

claude-code/opus:max · claude-code — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

deepseek-v4-pro:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — Minor issues
  • error-handling — No material issues found

glm-5.2:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

minimax-m3:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — Minor issues
  • error-handling — No material issues found

ragnaros/qwen3.6-27b · ragnaros — done

  • security — No material issues found
  • ⚠️ correctness — could not complete
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 7/7 reviewers finished · updated 2026-06-28 22:06:26Z #### `claude-code/opus` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `claude-code/opus:max` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `deepseek-v4-pro:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — Minor issues - ✅ **error-handling** — No material issues found #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `minimax-m3:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — Minor issues - ✅ **error-handling** — No material issues found #### `ragnaros/qwen3.6-27b` · ragnaros — ✅ done - ✅ **security** — No material issues found - ⚠️ **correctness** — could not complete - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>

🪰 Gadfly review — claude-code/opus (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

Reviewed through the 🔒 Security lens against the checked-out code (media/media.go:46-152, llm/content.go:14-39).

  • The new behavior replaces overflow ImageParts with a fixed compile-time constant (imageOverflowPlaceholder, media/media.go:110) via llm.Text(...). The placeholder text is not derived from any untrusted input, so it introduces no template/prompt-injection vector and no format-string risk (verified: llm.Text is a plain struct constructor, llm/content.go:36).
  • No tainted data flows into a new sink — dropped image bytes are simply discarded, never used to build a string, path, query, or request. No SSRF/path/command/SQL surface is touched.
  • No authn/authz, deserialization, or secret-handling code is involved. Copy-on-write (dropOldestImages, media/media.go:116-143) only allocates new slices; no shared-state corruption that could be security-relevant.
  • The remaining SupportsImages/MIME/byte-budget/dimension checks (media/media.go:58, normalizeImage) are unchanged, so no validation control was weakened — the provider-side count check still acts as a backstop per the PR description.

Nothing in the security lane is materially wrong here. (The behavioral question of whether silently dropping images is desirable is a correctness/design concern handled by another lens, not a security defect.)

🎯 Correctness — No material issues found

I've verified the change against the checked-out code. My correctness analysis:

  • Drop arithmetic (media.go:70-71, 116-143): n = total - MaxImagesPerReq, kept = total - n = MaxImagesPerReq. No off-by-one. Since n < total, there are always ≥ n images to replace, so it drops exactly n. Verified.
  • "Most-recent" semantics: dropOldestImages iterates messages low→high index and parts front→back, replacing the first n images. Chat history is oldest→newest, so the kept images are the newest — matches the stated intent. Verified.
  • 0 = no image support preserved: SupportsImages() is MaxImagesPerReq > 0 (llm/capabilities.go:37), so the cap-0 case still hits the text-only ErrUnsupported path before the overflow branch. Verified.
  • Copy-on-write: dropOldestImages allocates a fresh Messages slice and fresh Parts slices only for modified messages; unmodified messages share input slices but are never written. The downstream normalization loop (media.go:90-100) copies again before mutating. Input request is never mutated — confirmed by reading both copy sites and matched by the test's mutation assertion.
  • Type correctness: llm.Text(...) returns a valid Part (llm/content.go:36) appended into []llm.Part; test type-switches on llm.TextPart/llm.ImagePart, both real types. Test imports (bytes, errors, strings) all still used elsewhere — no unused-import compile break.

VERDICT: No material issues found

Through the correctness lens, the change is sound:

  • The kept/dropped counts re-derive exactly (kept == MaxImagesPerReq, no off-by-one), verified at media/media.go:70-71 and 116-143.
  • "Most-recent retained" is implemented correctly (front-to-back replacement keeps highest-index images).
  • The MaxImagesPerReq == 0 (text-only) path is preserved ahead of the overflow branch (llm/capabilities.go:37).
  • Copy-on-write holds end-to-end; the input request is not mutated.
  • The new test's assertions match the implemented behavior, and all test imports remain used (no compile break).

No magic constant beyond the count arithmetic, which checks out. Nothing material in my lane.

(Outside my lens, worth a glance by another reviewer: replacing an ImagePart inside an assistant-role turn with a text placeholder changes that turn's content shape — a provider/error-handling concern, not a correctness bug.)

🧹 Code cleanliness & maintainability — Minor issues

I've verified each finding against the actual source in media/media.go. All three are confirmed:

  • Finding 1 (split copy-on-write): dropOldestImages allocates out.Messages at lines 121-122; Normalize reassigns req at line 71, then the transform loop re-allocates out.Messages at lines 90-94 when any image changes — confirmed double allocation of the Messages slice in the overflow-and-transform case.
  • Finding 2 (name vs. behavior): lines 133-135 replace each image with llm.Text(...) rather than removing it — confirmed.
  • Finding 3 (triplicated image-type-assert loop): present at 48-54, 78-82, and 145-152 — confirmed.

VERDICT: Minor issues

The change is well-documented, the helpers are small and single-purpose, and it follows the file's existing copy-on-write convention.

Findings (code cleanliness lens):

  • Redundant / split copy-on-write of Messages in the overflow pathmedia/media.go:121-122 and media/media.go:90-94. dropOldestImages unconditionally allocates a fresh out.Messages slice and copies into it. Back in Normalize, req is reassigned to that result, and then the main transform loop (if !copiedMessages { out.Messages = make(...); copy(...) }) copies the same already-fresh slice a second time whenever any image also needs downscale/transcode. So in the overflow-and-transform case the Messages backing array is allocated twice. Low severity (small, conditional allocation), but it's the kind of split invariant that's easy to break later. A low-churn fix: have dropOldestImages signal that it already copied (e.g. return a bool, or have Normalize set copiedMessages = true after calling it) so the main loop reuses the copy.

  • dropOldestImages doesn't drop — it substitutesmedia/media.go:112-143. The name says "drop", but the function replaces each image with a placeholder TextPart (preserving turn structure). The doc comment clarifies, so this is cosmetic, but a name like elideOldestImages would match the behavior. Trivial.

  • Image-counting iteration is now duplicated three ways — the "range parts, type-assert llm.ImagePart" loop appears in Normalize's counter (media.go:48-54), hasImagePart (145-152), and the main transform loop (78-82). Two predate the PR and hasImagePart is a reasonable cheap pre-filter, so no refactor is warranted — noting only that a tiny func isImage(p llm.Part) bool would DRY it up if touched again. Trivial.

Nothing in this lens is blocking. The new code reads cleanly and TestNormalizeTooManyImages_DropsOldest is clear about intent.

Performance — No material issues found

Reviewing through the Performance lens. I've read the full media.go and the diff.

VERDICT: No material issues found

The change is performance-neutral to slightly beneficial under my lens, and I could not find a material efficiency regression.

Analysis (verified by reading media/media.go:46-152):

  • dropOldestImages runs before the decode loop (media.go:70-72, before line 76). Dropped images become text placeholders and are therefore never decoded/downscaled/re-encoded by normalizeImage. Since image decode+encode is by far the most expensive work in this package, dropping overflow images up front reduces CPU and the outbound payload size rather than adding cost. Net win.
  • No new quadratic / unbounded behavior. dropOldestImages is a single front-to-back pass that early-exits once n images are replaced (media.go:124-127). The pre-existing image-count scan (media.go:48-54) is unchanged. The per-Normalize cost remains O(messages × parts); the agent-loop accumulation cost is inherent to growing chat history, not introduced here.
  • Copy-on-write is bounded — only the Messages header slice plus the Parts slices of messages that actually contain a dropped image are reallocated (media.go:121-122, 131-140); image byte buffers are shared, not copied.

Trivial, non-material observations (flagging for completeness, not as issues — I would not block on these):

  • media.go:90-100: when overflow and a kept image needs transformation both occur, the Messages slice (and an affected message's Parts) get reallocated a second time — dropOldestImages already did the copy-on-write, then the main loop redoes it because it tracks its own copiedMessages/copiedParts flags. This is a duplicate O(len(messages)) allocation, negligible next to image decoding, and only on the overflow+transform path.
  • media.go:128: hasImagePart pre-scans a message's parts, then the rebuild loop scans them again — a double pass, but only across messages until n images are dropped. Micro-optimization, not worth changing.

Nothing here rises to a real performance regression. The overflow path is exercised rarely and does strictly less expensive work than the old error path would have on a retry chain.

🧯 Error handling & edge cases — No material issues found

I've verified the change against the actual code. Here's my review through the error-handling & edge-cases lens.


VERDICT: No material issues found

I traced every unhappy path the diff introduces and confirmed the edge cases are handled:

  • Zero / negative cap (the 0 = no image support contract). The dangerous case would be MaxImagesPerReq <= 0 reaching dropOldestImages(req, total-cap) and dropping every image (or computing a huge n). Verified it can't: Capabilities.SupportsImages() is MaxImagesPerReq > 0 (llm/capabilities.go:37), and Normalize returns the text-only ErrUnsupported at media/media.go:58 before the over-count branch. So 0 and negatives short-circuit cleanly.

  • n is always positive at the call site. The branch guard total > caps.MaxImagesPerReq (media/media.go:70) guarantees n = total - cap >= 1, and dropOldestImages additionally guards n <= 0 (media/media.go:117). No way to under/over-count. Kept count is exactly the cap.

  • Nil / empty parts. hasImagePart(nil) and the inner loop both range over nil/empty slices safely (media/media.go:128-139, 145-152); messages with no parts are skipped. No panic on empty collections.

  • Drop accounting & boundary. The outer dropped >= n break plus the inner dropped < n predicate correctly stop replacing once exactly n oldest images are gone, even when multiple images live in one message — later images in the same message are retained. No off-by-one.

  • No swallowed errors / no mutation. dropOldestImages is total (returns no error, nothing to swallow). Copy-on-write is real: out.Messages is freshly allocated, and only messages containing images get rebuilt Parts slices, so the caller's request is never mutated — confirmed both by reading the code and by the new test's len(req.Messages[0].Parts) != 2 assertion (media/media_test.go). The subsequent normalization loop's lazy re-copy of the already-fresh slice is redundant but harmless.

  • Interaction with downstream normalization / backstop. After the drop the remaining-image count equals the cap, so the provider-side <= count backstop still passes; per-image normalization runs unchanged on the survivors. No new failure path introduced.

Nothing in the error-handling lane is materially wrong. The diff replaces a hard refuse with a total, side-effect-free transformation, and the one genuinely risky edge (cap ≤ 0) is foreclosed upstream.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 1m 43s

<!-- gadfly-review:ollama:claude-code/opus --> ### 🪰 Gadfly review — `claude-code/opus` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> ## VERDICT: No material issues found Reviewed through the **🔒 Security** lens against the checked-out code (`media/media.go:46-152`, `llm/content.go:14-39`). - The new behavior replaces overflow `ImagePart`s with a **fixed compile-time constant** (`imageOverflowPlaceholder`, `media/media.go:110`) via `llm.Text(...)`. The placeholder text is not derived from any untrusted input, so it introduces no template/prompt-injection vector and no format-string risk (verified: `llm.Text` is a plain struct constructor, `llm/content.go:36`). - No tainted data flows into a new sink — dropped image bytes are simply discarded, never used to build a string, path, query, or request. No SSRF/path/command/SQL surface is touched. - No authn/authz, deserialization, or secret-handling code is involved. Copy-on-write (`dropOldestImages`, `media/media.go:116-143`) only allocates new slices; no shared-state corruption that could be security-relevant. - The remaining `SupportsImages`/MIME/byte-budget/dimension checks (`media/media.go:58`, `normalizeImage`) are unchanged, so no validation control was weakened — the provider-side count check still acts as a backstop per the PR description. Nothing in the security lane is materially wrong here. (The behavioral question of whether silently dropping images is desirable is a correctness/design concern handled by another lens, not a security defect.) </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> I've verified the change against the checked-out code. My correctness analysis: - **Drop arithmetic** (`media.go:70-71`, `116-143`): `n = total - MaxImagesPerReq`, kept = `total - n = MaxImagesPerReq`. No off-by-one. Since `n < total`, there are always ≥ `n` images to replace, so it drops exactly `n`. Verified. - **"Most-recent" semantics**: `dropOldestImages` iterates messages low→high index and parts front→back, replacing the first `n` images. Chat history is oldest→newest, so the kept images are the newest — matches the stated intent. Verified. - **0 = no image support preserved**: `SupportsImages()` is `MaxImagesPerReq > 0` (`llm/capabilities.go:37`), so the cap-0 case still hits the text-only `ErrUnsupported` path before the overflow branch. Verified. - **Copy-on-write**: `dropOldestImages` allocates a fresh `Messages` slice and fresh `Parts` slices only for modified messages; unmodified messages share input slices but are never written. The downstream normalization loop (`media.go:90-100`) copies again before mutating. Input request is never mutated — confirmed by reading both copy sites and matched by the test's mutation assertion. - **Type correctness**: `llm.Text(...)` returns a valid `Part` (`llm/content.go:36`) appended into `[]llm.Part`; test type-switches on `llm.TextPart`/`llm.ImagePart`, both real types. Test imports (`bytes`, `errors`, `strings`) all still used elsewhere — no unused-import compile break. --- VERDICT: **No material issues found** Through the correctness lens, the change is sound: - The kept/dropped counts re-derive exactly (`kept == MaxImagesPerReq`, no off-by-one), verified at `media/media.go:70-71` and `116-143`. - "Most-recent retained" is implemented correctly (front-to-back replacement keeps highest-index images). - The `MaxImagesPerReq == 0` (text-only) path is preserved ahead of the overflow branch (`llm/capabilities.go:37`). - Copy-on-write holds end-to-end; the input request is not mutated. - The new test's assertions match the implemented behavior, and all test imports remain used (no compile break). No magic constant beyond the count arithmetic, which checks out. Nothing material in my lane. (Outside my lens, worth a glance by another reviewer: replacing an `ImagePart` inside an *assistant*-role turn with a text placeholder changes that turn's content shape — a provider/error-handling concern, not a correctness bug.) </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> I've verified each finding against the actual source in `media/media.go`. All three are confirmed: - Finding 1 (split copy-on-write): `dropOldestImages` allocates `out.Messages` at lines 121-122; `Normalize` reassigns `req` at line 71, then the transform loop re-allocates `out.Messages` at lines 90-94 when any image changes — confirmed double allocation of the Messages slice in the overflow-and-transform case. - Finding 2 (name vs. behavior): lines 133-135 replace each image with `llm.Text(...)` rather than removing it — confirmed. - Finding 3 (triplicated image-type-assert loop): present at 48-54, 78-82, and 145-152 — confirmed. ## VERDICT: Minor issues The change is well-documented, the helpers are small and single-purpose, and it follows the file's existing copy-on-write convention. **Findings (code cleanliness lens):** - **Redundant / split copy-on-write of `Messages` in the overflow path** — `media/media.go:121-122` and `media/media.go:90-94`. `dropOldestImages` unconditionally allocates a fresh `out.Messages` slice and copies into it. Back in `Normalize`, `req` is reassigned to that result, and then the main transform loop (`if !copiedMessages { out.Messages = make(...); copy(...) }`) copies the *same already-fresh* slice a second time whenever any image also needs downscale/transcode. So in the overflow-and-transform case the `Messages` backing array is allocated twice. Low severity (small, conditional allocation), but it's the kind of split invariant that's easy to break later. A low-churn fix: have `dropOldestImages` signal that it already copied (e.g. return a bool, or have `Normalize` set `copiedMessages = true` after calling it) so the main loop reuses the copy. - **`dropOldestImages` doesn't drop — it substitutes** — `media/media.go:112-143`. The name says "drop", but the function replaces each image with a placeholder `TextPart` (preserving turn structure). The doc comment clarifies, so this is cosmetic, but a name like `elideOldestImages` would match the behavior. Trivial. - **Image-counting iteration is now duplicated three ways** — the "range parts, type-assert `llm.ImagePart`" loop appears in `Normalize`'s counter (`media.go:48-54`), `hasImagePart` (`145-152`), and the main transform loop (`78-82`). Two predate the PR and `hasImagePart` is a reasonable cheap pre-filter, so no refactor is warranted — noting only that a tiny `func isImage(p llm.Part) bool` would DRY it up if touched again. Trivial. Nothing in this lens is blocking. The new code reads cleanly and `TestNormalizeTooManyImages_DropsOldest` is clear about intent. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> Reviewing through the **Performance** lens. I've read the full `media.go` and the diff. ## VERDICT: No material issues found The change is performance-neutral to slightly **beneficial** under my lens, and I could not find a material efficiency regression. Analysis (verified by reading `media/media.go:46-152`): - **`dropOldestImages` runs before the decode loop** (`media.go:70-72`, before line 76). Dropped images become text placeholders and are therefore **never decoded/downscaled/re-encoded** by `normalizeImage`. Since image decode+encode is by far the most expensive work in this package, dropping overflow images up front *reduces* CPU and the outbound payload size rather than adding cost. Net win. - **No new quadratic / unbounded behavior.** `dropOldestImages` is a single front-to-back pass that early-exits once `n` images are replaced (`media.go:124-127`). The pre-existing image-count scan (`media.go:48-54`) is unchanged. The per-Normalize cost remains O(messages × parts); the agent-loop accumulation cost is inherent to growing chat history, not introduced here. - **Copy-on-write is bounded** — only the `Messages` header slice plus the Parts slices of messages that actually contain a dropped image are reallocated (`media.go:121-122`, `131-140`); image byte buffers are shared, not copied. Trivial, non-material observations (flagging for completeness, not as issues — I would not block on these): - `media.go:90-100`: when overflow *and* a kept image needs transformation both occur, the `Messages` slice (and an affected message's `Parts`) get reallocated a **second** time — `dropOldestImages` already did the copy-on-write, then the main loop redoes it because it tracks its own `copiedMessages`/`copiedParts` flags. This is a duplicate O(len(messages)) allocation, negligible next to image decoding, and only on the overflow+transform path. - `media.go:128`: `hasImagePart` pre-scans a message's parts, then the rebuild loop scans them again — a double pass, but only across messages until `n` images are dropped. Micro-optimization, not worth changing. Nothing here rises to a real performance regression. The overflow path is exercised rarely and does strictly less expensive work than the old error path would have on a retry chain. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> I've verified the change against the actual code. Here's my review through the error-handling & edge-cases lens. --- **VERDICT: No material issues found** I traced every unhappy path the diff introduces and confirmed the edge cases are handled: - **Zero / negative cap (the `0 = no image support` contract).** The dangerous case would be `MaxImagesPerReq <= 0` reaching `dropOldestImages(req, total-cap)` and dropping *every* image (or computing a huge `n`). Verified it can't: `Capabilities.SupportsImages()` is `MaxImagesPerReq > 0` (`llm/capabilities.go:37`), and `Normalize` returns the text-only `ErrUnsupported` at `media/media.go:58` before the over-count branch. So `0` and negatives short-circuit cleanly. ✅ - **`n` is always positive at the call site.** The branch guard `total > caps.MaxImagesPerReq` (`media/media.go:70`) guarantees `n = total - cap >= 1`, and `dropOldestImages` additionally guards `n <= 0` (`media/media.go:117`). No way to under/over-count. Kept count is exactly the cap. ✅ - **Nil / empty parts.** `hasImagePart(nil)` and the inner loop both range over nil/empty slices safely (`media/media.go:128-139, 145-152`); messages with no parts are skipped. No panic on empty collections. ✅ - **Drop accounting & boundary.** The outer `dropped >= n` break plus the inner `dropped < n` predicate correctly stop replacing once exactly `n` oldest images are gone, even when multiple images live in one message — later images in the same message are retained. No off-by-one. ✅ - **No swallowed errors / no mutation.** `dropOldestImages` is total (returns no error, nothing to swallow). Copy-on-write is real: `out.Messages` is freshly allocated, and only messages containing images get rebuilt `Parts` slices, so the caller's request is never mutated — confirmed both by reading the code and by the new test's `len(req.Messages[0].Parts) != 2` assertion (`media/media_test.go`). The subsequent normalization loop's lazy re-copy of the already-fresh slice is redundant but harmless. ✅ - **Interaction with downstream normalization / backstop.** After the drop the remaining-image count equals the cap, so the provider-side `<=` count backstop still passes; per-image normalization runs unchanged on the survivors. No new failure path introduced. ✅ Nothing in the error-handling lane is materially wrong. The diff replaces a hard refuse with a total, side-effect-free transformation, and the one genuinely risky edge (cap ≤ 0) is foreclosed upstream. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 1m 43s</sub>

🪰 Gadfly review — claude-code/sonnet (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

The changes are clean from a security perspective. Here's what I verified:

  • Placeholder injection: imageOverflowPlaceholder (media/media.go:110) is a hardcoded constant. No user-controlled data is interpolated into the text that gets injected into the model's context. No injection vector.

  • Integer arithmetic: total - caps.MaxImagesPerReq at line 71 is guarded by total > caps.MaxImagesPerReq at line 70, and caps.MaxImagesPerReq > 0 is guaranteed by the SupportsImages() guard at line 58 (verified: llm/capabilities.go:37SupportsImages() returns MaxImagesPerReq > 0). No underflow possible.

  • Copy-on-write: dropOldestImages allocates a fresh Messages slice and a fresh Parts slice for each affected message before substituting any parts. The original req argument is never mutated. Verified by reading lines 120–142. The subsequent normalization loop at lines 74–103 applies its own independent copy-on-write on top of the already-copied req, which is correct.

  • No new attack surface: Normalize takes caller-controlled caps and caller-controlled req — both already trusted inputs. The behavior change (drop-oldest instead of refuse) shifts load from error-path handling back into the same normalization pipeline, with no new parsing, no network access, no deserialization, and no credential handling.

🎯 Correctness — No material issues found

No material issues found

I traced through the full logic path in the checked-out code:

total counting and drop arithmetic (media.go:47–72)

total is counted before dropOldestImages is called; n = total - caps.MaxImagesPerReq is the exact surplus. After dropping n images, total - n == caps.MaxImagesPerReq images remain. Verified.

SupportsImages() guard ordering (media.go:58–72)

!caps.SupportsImages() (i.e. MaxImagesPerReq == 0) is checked first and returns ErrUnsupported. The overflow branch is only reached when images are supported, so n is always positive and caps.MaxImagesPerReq >= 1 when dropOldestImages is called. Verified by reading llm/capabilities.go:37.

dropOldestImages semantics (media.go:116–143)

Iterates messages front-to-back (oldest first), replaces the first n ImageParts with TextPart placeholders, then breaks. Within a mixed-content message the inner loop correctly counts only ImageParts and leaves TextParts in place. The n <= 0 early return is a sound defensive guard. Verified.

Copy-on-write chain (media.go:70–103)

After req = dropOldestImages(req, n), the outer normalization loop reads from the reassigned req (not the original). For messages where dropOldestImages replaced images, the Parts slice is already a new slice; the normalization loop then copies it again only if a further transformation is needed. No double-mutation hazard. Verified.

Test coverage (media_test.go:152–197)

TestNormalizeTooManyImages_DropsOldest checks: no error, exactly 2 images retained, exactly 1 placeholder, a's bytes absent from retained images, and the input req unmutated. The bytes.Equal on a.Data is reliable because gradient(2,2) vs. gradient(4,4) vs. gradient(8,8) produce structurally distinct PNG payloads. Verified.

🧹 Code cleanliness & maintainability — Minor issues

Both findings are confirmed against the actual code. Here is the corrected review:


Verdict: Minor issues


Finding 1 — Test name breaks the file's naming convention (media/media_test.go:152)

Every other top-level test in this file uses camelCase with no underscores: TestNormalizeFastPathNoImages, TestNormalizeFastPathFittingImages, TestNormalizeImagesUnsupported, TestNormalizeGarbageBytes, TestNormalizeCopyOnWrite, TestNormalizeInputNotMutated, etc. The new function TestNormalizeTooManyImages_DropsOldest introduces a _-separated suffix that stands out in go test -v output and is inconsistent with the rest of the file. A consistent name would be TestNormalizeOvercountDropsOldest.


Finding 2 — hasImagePart is a single-use helper that re-walks parts the inner loop already walks (media/media.go:128, 145–152)

hasImagePart is called once per message as a pre-scan guard inside dropOldestImages (line 128) to skip messages with no images before allocating a parts slice. The type assertion it performs is identical to the condition on line 133 inside the inner loop that immediately follows. Every image-bearing message therefore has its parts iterated twice — once by hasImagePart, once by the replace loop. Because dropOldestImages is only invoked when the image count already exceeds the cap and n is typically small, the cost is negligible; but the helper introduces a second maintenance point if llm.ImagePart ever becomes an interface rather than a concrete type.

A lazy-alloc inner loop would eliminate both the double scan and the helper:

var parts []llm.Part
for pi, p := range out.Messages[mi].Parts {
    if _, ok := p.(llm.ImagePart); ok && dropped < n {
        if parts == nil {
            parts = make([]llm.Part, pi, len(out.Messages[mi].Parts))
            copy(parts, out.Messages[mi].Parts[:pi])
        }
        dropped++
        parts = append(parts, llm.Text(imageOverflowPlaceholder))
        continue
    }
    if parts != nil {
        parts = append(parts, p)
    }
}
if parts != nil {
    out.Messages[mi].Parts = parts
}

Not blocking, but the current structure is worth a note.

Performance — No material issues found

No material issues found

Verified the new dropOldestImages path in full:

  • Double Messages-slice copy (overflow + image transforms): When both conditions hold — total > MaxImagesPerReq AND the surviving images also need normalization — the Messages slice is allocated twice: once in dropOldestImages (line 121) and again in the COW path of the main loop (line 91). That's O(N) in message count on a path already dominated by image decode/scale/encode work. Not material.

  • hasImagePart precheck (lines 128–130): Parts in a message with images are walked twice (once in hasImagePart, once in the replace loop). Given the alternative (allocating a parts slice for every message regardless), the precheck is net-beneficial. For 1–4 parts per message, the extra scan is negligible.

  • No unbounded growth, no quadratic behaviour: dropOldestImages is O(messages × parts), both of which are small and bounded. It's called at most once per Normalize call, which itself is called once per chain-target attempt, not inside a tight hot loop.

The performance profile of the overflow path is indistinguishable in practice from the pre-existing image-normalization path: both are dominated by image I/O (decode config, downscale, re-encode), not by slice bookkeeping.

🧯 Error handling & edge cases — No material issues found

No material issues found

I verified the following paths through the lens of error handling and edge cases:

dropOldestImages invariants (verified by reading media/media.go:116-143):

  • The n <= 0 guard at line 117 is safe; the call site at line 71 always passes total - caps.MaxImagesPerReq which is ≥ 1 because we only enter the branch when total > caps.MaxImagesPerReq and MaxImagesPerReq ≥ 1 (enforced by SupportsImages() at line 58).
  • n is always ≤ total - 1 (since MaxImagesPerReq ≥ 1), so there are always more images in req than we need to drop. dropped will reach n without the loop running out of images.
  • A message with all its images dropped ends up with only placeholder TextParts — still a well-formed message, turn structure preserved.
  • hasImagePart(nil) is safe (empty loop) for messages with nil Parts.

Copy-on-write soundness (verified by reading lines 120-143):

  • out.Messages is a new slice before any out.Messages[mi].Parts assignment; assigning out.Messages[mi].Parts = parts writes only into the new slice, leaving req.Messages[mi] untouched.
  • The test at line 194 confirms the original req.Messages[0].Parts length is still 2 after normalization.

Post-drop normalization loop (verified by reading lines 74-104):

  • After dropOldestImages returns, replaced image parts are TextPart. The inner loop at line 79 (if _, ok := part.(llm.ImagePart); !ok { continue }) correctly skips them, so placeholders are never passed to normalizeImage.
  • When a kept image needs normalization, copiedParts triggers a fresh copy(parts, req.Messages[mi].Parts) before any in-place write, capturing the post-drop parts (with placeholders intact) into the new slice.
  • Part index pi from for pi, part := range req.Messages[mi].Parts is stable because dropOldestImages replaces each dropped image with exactly one TextPart, so slice lengths are unchanged.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 21s

<!-- gadfly-review:ollama:claude-code/sonnet --> ### 🪰 Gadfly review — `claude-code/sonnet` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** The changes are clean from a security perspective. Here's what I verified: - **Placeholder injection**: `imageOverflowPlaceholder` (`media/media.go:110`) is a hardcoded constant. No user-controlled data is interpolated into the text that gets injected into the model's context. No injection vector. - **Integer arithmetic**: `total - caps.MaxImagesPerReq` at line 71 is guarded by `total > caps.MaxImagesPerReq` at line 70, and `caps.MaxImagesPerReq > 0` is guaranteed by the `SupportsImages()` guard at line 58 (verified: `llm/capabilities.go:37` — `SupportsImages()` returns `MaxImagesPerReq > 0`). No underflow possible. - **Copy-on-write**: `dropOldestImages` allocates a fresh `Messages` slice and a fresh `Parts` slice for each affected message before substituting any parts. The original `req` argument is never mutated. Verified by reading lines 120–142. The subsequent normalization loop at lines 74–103 applies its own independent copy-on-write on top of the already-copied `req`, which is correct. - **No new attack surface**: `Normalize` takes caller-controlled `caps` and caller-controlled `req` — both already trusted inputs. The behavior change (drop-oldest instead of refuse) shifts load from error-path handling back into the same normalization pipeline, with no new parsing, no network access, no deserialization, and no credential handling. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **No material issues found** I traced through the full logic path in the checked-out code: **`total` counting and drop arithmetic (media.go:47–72)** `total` is counted before `dropOldestImages` is called; `n = total - caps.MaxImagesPerReq` is the exact surplus. After dropping `n` images, `total - n == caps.MaxImagesPerReq` images remain. Verified. **`SupportsImages()` guard ordering (media.go:58–72)** `!caps.SupportsImages()` (i.e. `MaxImagesPerReq == 0`) is checked first and returns `ErrUnsupported`. The overflow branch is only reached when images are supported, so `n` is always positive and `caps.MaxImagesPerReq >= 1` when `dropOldestImages` is called. Verified by reading `llm/capabilities.go:37`. **`dropOldestImages` semantics (media.go:116–143)** Iterates messages front-to-back (oldest first), replaces the first `n` `ImagePart`s with `TextPart` placeholders, then breaks. Within a mixed-content message the inner loop correctly counts only `ImagePart`s and leaves `TextPart`s in place. The `n <= 0` early return is a sound defensive guard. Verified. **Copy-on-write chain (media.go:70–103)** After `req = dropOldestImages(req, n)`, the outer normalization loop reads from the reassigned `req` (not the original). For messages where `dropOldestImages` replaced images, the `Parts` slice is already a new slice; the normalization loop then copies it again only if a further transformation is needed. No double-mutation hazard. Verified. **Test coverage (media_test.go:152–197)** `TestNormalizeTooManyImages_DropsOldest` checks: no error, exactly 2 images retained, exactly 1 placeholder, `a`'s bytes absent from retained images, and the input `req` unmutated. The `bytes.Equal` on `a.Data` is reliable because `gradient(2,2)` vs. `gradient(4,4)` vs. `gradient(8,8)` produce structurally distinct PNG payloads. Verified. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Both findings are confirmed against the actual code. Here is the corrected review: --- **Verdict: Minor issues** --- ### Finding 1 — Test name breaks the file's naming convention (`media/media_test.go:152`) Every other top-level test in this file uses camelCase with no underscores: `TestNormalizeFastPathNoImages`, `TestNormalizeFastPathFittingImages`, `TestNormalizeImagesUnsupported`, `TestNormalizeGarbageBytes`, `TestNormalizeCopyOnWrite`, `TestNormalizeInputNotMutated`, etc. The new function `TestNormalizeTooManyImages_DropsOldest` introduces a `_`-separated suffix that stands out in `go test -v` output and is inconsistent with the rest of the file. A consistent name would be `TestNormalizeOvercountDropsOldest`. --- ### Finding 2 — `hasImagePart` is a single-use helper that re-walks parts the inner loop already walks (`media/media.go:128`, `145–152`) `hasImagePart` is called once per message as a pre-scan guard inside `dropOldestImages` (line 128) to skip messages with no images before allocating a `parts` slice. The type assertion it performs is identical to the condition on line 133 inside the inner loop that immediately follows. Every image-bearing message therefore has its parts iterated twice — once by `hasImagePart`, once by the replace loop. Because `dropOldestImages` is only invoked when the image count already exceeds the cap and `n` is typically small, the cost is negligible; but the helper introduces a second maintenance point if `llm.ImagePart` ever becomes an interface rather than a concrete type. A lazy-alloc inner loop would eliminate both the double scan and the helper: ```go var parts []llm.Part for pi, p := range out.Messages[mi].Parts { if _, ok := p.(llm.ImagePart); ok && dropped < n { if parts == nil { parts = make([]llm.Part, pi, len(out.Messages[mi].Parts)) copy(parts, out.Messages[mi].Parts[:pi]) } dropped++ parts = append(parts, llm.Text(imageOverflowPlaceholder)) continue } if parts != nil { parts = append(parts, p) } } if parts != nil { out.Messages[mi].Parts = parts } ``` Not blocking, but the current structure is worth a note. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found** Verified the new `dropOldestImages` path in full: - **Double Messages-slice copy (overflow + image transforms):** When both conditions hold — `total > MaxImagesPerReq` AND the surviving images also need normalization — the Messages slice is allocated twice: once in `dropOldestImages` (line 121) and again in the COW path of the main loop (line 91). That's O(N) in message count on a path already dominated by image decode/scale/encode work. Not material. - **`hasImagePart` precheck (lines 128–130):** Parts in a message with images are walked twice (once in `hasImagePart`, once in the replace loop). Given the alternative (allocating a `parts` slice for every message regardless), the precheck is net-beneficial. For 1–4 parts per message, the extra scan is negligible. - **No unbounded growth, no quadratic behaviour:** `dropOldestImages` is O(messages × parts), both of which are small and bounded. It's called at most once per `Normalize` call, which itself is called once per chain-target attempt, not inside a tight hot loop. The performance profile of the overflow path is indistinguishable in practice from the pre-existing image-normalization path: both are dominated by image I/O (decode config, downscale, re-encode), not by slice bookkeeping. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **No material issues found** I verified the following paths through the lens of error handling and edge cases: **`dropOldestImages` invariants** (verified by reading `media/media.go:116-143`): - The `n <= 0` guard at line 117 is safe; the call site at line 71 always passes `total - caps.MaxImagesPerReq` which is `≥ 1` because we only enter the branch when `total > caps.MaxImagesPerReq` and `MaxImagesPerReq ≥ 1` (enforced by `SupportsImages()` at line 58). - `n` is always `≤ total - 1` (since `MaxImagesPerReq ≥ 1`), so there are always more images in `req` than we need to drop. `dropped` will reach `n` without the loop running out of images. - A message with all its images dropped ends up with only placeholder `TextPart`s — still a well-formed message, turn structure preserved. - `hasImagePart(nil)` is safe (empty loop) for messages with nil Parts. **Copy-on-write soundness** (verified by reading lines 120-143): - `out.Messages` is a new slice before any `out.Messages[mi].Parts` assignment; assigning `out.Messages[mi].Parts = parts` writes only into the new slice, leaving `req.Messages[mi]` untouched. - The test at line 194 confirms the original `req.Messages[0].Parts` length is still 2 after normalization. **Post-drop normalization loop** (verified by reading lines 74-104): - After `dropOldestImages` returns, replaced image parts are `TextPart`. The inner loop at line 79 (`if _, ok := part.(llm.ImagePart); !ok { continue }`) correctly skips them, so placeholders are never passed to `normalizeImage`. - When a kept image needs normalization, `copiedParts` triggers a fresh `copy(parts, req.Messages[mi].Parts)` before any in-place write, capturing the post-drop parts (with placeholders intact) into the new slice. - Part index `pi` from `for pi, part := range req.Messages[mi].Parts` is stable because `dropOldestImages` replaces each dropped image with exactly one `TextPart`, so slice lengths are unchanged. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 21s</sub>

🪰 Gadfly review — deepseek-v4-pro:cloud (ollama-cloud)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

The change is a policy shift (drop-oldest vs. refuse) implemented through straightforward slice manipulation and a hardcoded constant placeholder string. From a security lens:

  • No injection vectors: The placeholder "[earlier image omitted to fit this model's per-request image limit]" is a compile-time constant, never derived from user input. It's inserted as a TextPart — plain conversation text with no path/command/template interpretation.
  • No authn/authz impact: Normalize is a pure media-transformation function with no access-control decisions.
  • No SSRF, no deserialization: dropOldestImages only does type-assertion switches on the sealed llm.Part interface (TextPart / ImagePart) and copies slices. No new parsing, no URL fetching.
  • No secret leakage: No credentials, tokens, or keys are touched.
  • No untrusted-data risk: Images that survive the drop still flow through the full normalizeImage pipeline (decode → downscale → re-encode), which already handles potentially malicious image bytes. Dropped images are discarded entirely — their bytes are never decoded or forwarded.
  • No input-validation gap: dropOldestImages is only reachable when MaxImagesPerReq > 0 (guarded by the SupportsImages() check at line 58) and total > MaxImagesPerReq, so n is always positive and bounded by the actual image count. The n <= 0 early-return is defensive but not strictly necessary given the call site.

The one design-level observation (not a security bug): silently dropping content changes what the model sees, which could be exploited by an adversary who intentionally floods a request with decoy images to displace earlier ones. But that's a functional/correctness concern (does the model still answer correctly?), not a security vulnerability — the model receives an explicit placeholder, and the attack surface (crafting many images to push others out) already existed in the "refuse" path (DoS via rejection). This is outside the security lens.

🎯 Correctness — No material issues found

Verdict: No material issues found

I traced through the full logic of dropOldestImages, its integration into Normalize, the copy-on-write interaction with the existing normalization loop, and the test coverage. The implementation correctly:

  • Drops the chronologically oldest n images (front-to-back across messages, then left-to-right within each message's parts)
  • Preserves message turn structure (replaces each dropped ImagePart with a TextPart placeholder, keeping part count identical)
  • Never mutates the input request (struct passed by value; new Messages slice allocated; new Parts slices for modified messages)
  • Leaves the remaining ImagePart instances intact for the downstream normalization loop, which correctly skips the placeholder TextPart instances via its ip, ok := part.(llm.ImagePart) guard
  • Handles edge cases: n <= 0 (no-op), n >= total can't happen because SupportsImages() gates out MaxImagesPerReq == 0 before this path, and total > caps.MaxImagesPerReq ensures n < total
  • The test verifies the oldest of 3 images is dropped, exactly 2 survive, exactly 1 placeholder appears, and the input is unmutated

The double-copy of Messages that can occur (once in dropOldestImages, again in the normalization loop when an image actually needs transformation) is wasteful but not a correctness issue — it's an allocation concern outside my lens.

🧹 Code cleanliness & maintainability — No material issues found

Verdict: No material issues found

The change is clean and well-structured through the 🧹 lens:

  • dropOldestImages is a focused, self-contained helper with clear copy-on-write semantics, a defensive n <= 0 guard, and a straightforward single-pass algorithm. The unexported hasImagePart helper is a minor extraction (used once, 6 lines) — a matter of taste, not a material problem.
  • The constant imageOverflowPlaceholder is properly named and documented, avoiding a magic string literal in the loop body.
  • Package doc and inline comments are updated consistently with the behavioral change; the old rationale is replaced rather than left as stale commentary.
  • The test follows existing naming conventions (TestNormalizeTooManyImages_DropsOldest), exercises the three key properties (correct count kept, placeholder inserted, input unmutated), and uses distinguishable image sizes so the "oldest dropped" assertion is meaningful.
  • No dead code, no duplication, no leaky abstractions. The function composes with the existing normalization loop without tangling — dropOldestImages handles its own copy, and the main loop's !ok { continue } naturally skips the inserted text placeholders.

One minor observation (not worth a finding on its own): hasImagePart causes a double-scan of parts for messages that contain images — once to decide whether to enter the block, then again in the inner loop. The same effect could be achieved by building the new parts slice lazily (only allocating when the first image-to-drop is encountered), but the current approach is clear and the slices are tiny; this is not a real maintainability concern.

Performance — Minor issues

Verdict: Minor issues

  • Double allocation of Messages slice when overflow + transform coincide (media/media.go:70–71 + media/media.go:90–93). dropOldestImages allocates a fresh out.Messages via make+copy (line 121–122). Back in Normalize, out := req (line 74) shares that slice. If any kept image later needs transformation (downscale/transcode), the normalization loop allocates out.Messages a second time (line 91), discarding the first. In practice the wasted allocation is dwarfed by the decode/scale/encode work that triggers it, and []llm.Message is a handful of words per entry, so the impact is negligible. Still, it's avoidable: dropOldestImages could return a flag indicating it already copied, letting Normalize skip the redundant allocation. (Verified by reading media/media.go:116–143 and media/media.go:74–104.)

No other material performance regressions: the new dropOldestImages path is O(messages + parts) — same order as the existing normalization loop — with no N+1 queries, no quadratic behavior, no unbounded growth, and no blocking calls on hot paths. The hasImagePart pre-scan before the inner loop is a constant-factor double-traversal of parts within a single message, which is immaterial given typical part counts.

🧯 Error handling & edge cases — No material issues found

Verdict: No material issues found

I traced every unhappy path through dropOldestImages and its integration into Normalize, verifying against the actual checked-out code. The error-handling and edge-case behavior is sound:

  • n <= 0 guard (media.go:117): returns early, no-op. The call site only passes n ≥ 1 (guarded by total > caps.MaxImagesPerReq at line 70), but the defensive check is correct.
  • MaxImagesPerReq == 0 with images present: caught earlier by SupportsImages() at line 58 — we never reach the overflow branch. Verified by reading llm/capabilities.go:37.
  • Empty/nil Parts slices: hasImagePart and range over nil both handle these safely — no panic. Verified by reading the loop at lines 128–140.
  • Messages with no images: hasImagePart returns false, message skipped, Parts slice left shared with original (correct copy-on-write). Verified.
  • Message with multiple images where only some need dropping: inner loop at lines 132–139 drops only until dropped >= n, keeps the rest. Correct.
  • Placeholder parts are TextPart, not ImagePart: the subsequent normalization loop at lines 78–82 skips them via the .(llm.ImagePart) type assertion. They won't be fed to normalizeImage. Verified by reading llm/content.go:36 (Text() returns TextPart).
  • Normalization failure on a kept image: normalizeImage returns an error, Normalize returns llm.Request{}, err — the partially-built output (with placeholders) is discarded. No leaked state.
  • Input immutability: dropOldestImages takes req by value, creates a new Messages slice, and only replaces Parts slices for messages where images were actually dropped. The test at line 193–196 confirms the original request's Parts count is unchanged. Verified by reading the function at lines 116–143.
  • total computed before mutation: total is counted from the original req (lines 47–53), then dropOldestImages is called with the original req (line 71), then req is shadowed. No inconsistency between the count and the drop target.

No ignored errors, no missing cleanup, no panics on bad/empty input, no off-by-one in the drop count, no nil-pointer hazards. The double-allocation of Messages/Parts when both dropOldestImages and the normalization loop copy is a minor performance waste, not an error-handling concern.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 6m 7s

<!-- gadfly-review:ollama:deepseek-v4-pro:cloud --> ### 🪰 Gadfly review — `deepseek-v4-pro:cloud` (ollama-cloud) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** The change is a policy shift (drop-oldest vs. refuse) implemented through straightforward slice manipulation and a hardcoded constant placeholder string. From a security lens: - **No injection vectors**: The placeholder `"[earlier image omitted to fit this model's per-request image limit]"` is a compile-time constant, never derived from user input. It's inserted as a `TextPart` — plain conversation text with no path/command/template interpretation. - **No authn/authz impact**: `Normalize` is a pure media-transformation function with no access-control decisions. - **No SSRF, no deserialization**: `dropOldestImages` only does type-assertion switches on the sealed `llm.Part` interface (`TextPart` / `ImagePart`) and copies slices. No new parsing, no URL fetching. - **No secret leakage**: No credentials, tokens, or keys are touched. - **No untrusted-data risk**: Images that survive the drop still flow through the full `normalizeImage` pipeline (decode → downscale → re-encode), which already handles potentially malicious image bytes. Dropped images are discarded entirely — their bytes are never decoded or forwarded. - **No input-validation gap**: `dropOldestImages` is only reachable when `MaxImagesPerReq > 0` (guarded by the `SupportsImages()` check at line 58) and `total > MaxImagesPerReq`, so `n` is always positive and bounded by the actual image count. The `n <= 0` early-return is defensive but not strictly necessary given the call site. The one design-level observation (not a security bug): silently dropping content changes what the model sees, which could be exploited by an adversary who intentionally floods a request with decoy images to displace earlier ones. But that's a functional/correctness concern (does the model still answer correctly?), not a security vulnerability — the model receives an explicit placeholder, and the attack surface (crafting many images to push others out) already existed in the "refuse" path (DoS via rejection). This is outside the security lens. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> ## Verdict: No material issues found I traced through the full logic of `dropOldestImages`, its integration into `Normalize`, the copy-on-write interaction with the existing normalization loop, and the test coverage. The implementation correctly: - Drops the chronologically oldest `n` images (front-to-back across messages, then left-to-right within each message's parts) - Preserves message turn structure (replaces each dropped `ImagePart` with a `TextPart` placeholder, keeping part count identical) - Never mutates the input request (struct passed by value; new `Messages` slice allocated; new `Parts` slices for modified messages) - Leaves the remaining `ImagePart` instances intact for the downstream normalization loop, which correctly skips the placeholder `TextPart` instances via its `ip, ok := part.(llm.ImagePart)` guard - Handles edge cases: `n <= 0` (no-op), `n >= total` can't happen because `SupportsImages()` gates out `MaxImagesPerReq == 0` before this path, and `total > caps.MaxImagesPerReq` ensures `n < total` - The test verifies the oldest of 3 images is dropped, exactly 2 survive, exactly 1 placeholder appears, and the input is unmutated The double-copy of `Messages` that can occur (once in `dropOldestImages`, again in the normalization loop when an image actually needs transformation) is wasteful but not a correctness issue — it's an allocation concern outside my lens. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> ## Verdict: No material issues found The change is clean and well-structured through the 🧹 lens: - **`dropOldestImages`** is a focused, self-contained helper with clear copy-on-write semantics, a defensive `n <= 0` guard, and a straightforward single-pass algorithm. The unexported `hasImagePart` helper is a minor extraction (used once, 6 lines) — a matter of taste, not a material problem. - **The constant `imageOverflowPlaceholder`** is properly named and documented, avoiding a magic string literal in the loop body. - **Package doc and inline comments** are updated consistently with the behavioral change; the old rationale is replaced rather than left as stale commentary. - **The test** follows existing naming conventions (`TestNormalizeTooManyImages_DropsOldest`), exercises the three key properties (correct count kept, placeholder inserted, input unmutated), and uses distinguishable image sizes so the "oldest dropped" assertion is meaningful. - **No dead code, no duplication, no leaky abstractions.** The function composes with the existing normalization loop without tangling — `dropOldestImages` handles its own copy, and the main loop's `!ok { continue }` naturally skips the inserted text placeholders. One minor observation (not worth a finding on its own): `hasImagePart` causes a double-scan of parts for messages that contain images — once to decide whether to enter the block, then again in the inner loop. The same effect could be achieved by building the new parts slice lazily (only allocating when the first image-to-drop is encountered), but the current approach is clear and the slices are tiny; this is not a real maintainability concern. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> ## Verdict: Minor issues - **Double allocation of `Messages` slice when overflow + transform coincide** (`media/media.go:70–71` + `media/media.go:90–93`). `dropOldestImages` allocates a fresh `out.Messages` via `make`+`copy` (line 121–122). Back in `Normalize`, `out := req` (line 74) shares that slice. If any *kept* image later needs transformation (downscale/transcode), the normalization loop allocates `out.Messages` a second time (line 91), discarding the first. In practice the wasted allocation is dwarfed by the decode/scale/encode work that triggers it, and `[]llm.Message` is a handful of words per entry, so the impact is negligible. Still, it's avoidable: `dropOldestImages` could return a flag indicating it already copied, letting `Normalize` skip the redundant allocation. (Verified by reading `media/media.go:116–143` and `media/media.go:74–104`.) No other material performance regressions: the new `dropOldestImages` path is O(messages + parts) — same order as the existing normalization loop — with no N+1 queries, no quadratic behavior, no unbounded growth, and no blocking calls on hot paths. The `hasImagePart` pre-scan before the inner loop is a constant-factor double-traversal of parts within a single message, which is immaterial given typical part counts. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> ## Verdict: No material issues found I traced every unhappy path through `dropOldestImages` and its integration into `Normalize`, verifying against the actual checked-out code. The error-handling and edge-case behavior is sound: - **`n <= 0` guard** (`media.go:117`): returns early, no-op. The call site only passes `n ≥ 1` (guarded by `total > caps.MaxImagesPerReq` at line 70), but the defensive check is correct. - **`MaxImagesPerReq == 0` with images present**: caught earlier by `SupportsImages()` at line 58 — we never reach the overflow branch. Verified by reading `llm/capabilities.go:37`. - **Empty/nil `Parts` slices**: `hasImagePart` and `range` over nil both handle these safely — no panic. Verified by reading the loop at lines 128–140. - **Messages with no images**: `hasImagePart` returns false, message skipped, `Parts` slice left shared with original (correct copy-on-write). Verified. - **Message with multiple images where only some need dropping**: inner loop at lines 132–139 drops only until `dropped >= n`, keeps the rest. Correct. - **Placeholder parts are `TextPart`, not `ImagePart`**: the subsequent normalization loop at lines 78–82 skips them via the `.(llm.ImagePart)` type assertion. They won't be fed to `normalizeImage`. Verified by reading `llm/content.go:36` (`Text()` returns `TextPart`). - **Normalization failure on a *kept* image**: `normalizeImage` returns an error, `Normalize` returns `llm.Request{}, err` — the partially-built output (with placeholders) is discarded. No leaked state. - **Input immutability**: `dropOldestImages` takes `req` by value, creates a new `Messages` slice, and only replaces `Parts` slices for messages where images were actually dropped. The test at line 193–196 confirms the original request's `Parts` count is unchanged. Verified by reading the function at lines 116–143. - **`total` computed before mutation**: `total` is counted from the original `req` (lines 47–53), then `dropOldestImages` is called with the original `req` (line 71), then `req` is shadowed. No inconsistency between the count and the drop target. No ignored errors, no missing cleanup, no panics on bad/empty input, no off-by-one in the drop count, no nil-pointer hazards. The double-allocation of `Messages`/`Parts` when both `dropOldestImages` and the normalization loop copy is a minor performance waste, not an error-handling concern. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 6m 7s</sub>

🪰 Gadfly review — claude-code/opus:max (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

I reviewed this change strictly through the 🔒 Security lens, reading the full implementation in media/media.go:46-152 and the relevant llm type definitions (llm/content.go).

Findings:

  • No injection surface introduced. The only text inserted into the request is imageOverflowPlaceholder (media/media.go:110), a compile-time constant. No untrusted/tainted data (image bytes, MIME, or any caller-supplied string) is reflected into the placeholder or any other sink. Verified the placeholder is const and that dropOldestImages only ever appends llm.Text(imageOverflowPlaceholder) — never anything derived from the dropped image's contents (media/media.go:131-138).
  • No tainted-data handling change. Dropped ImageParts are discarded, not decoded, parsed, or echoed. The remaining (most-recent) images still flow through the existing unchanged normalization pipeline (normalizeImage, byte/MIME/dimension checks) at media/media.go:74-104, so no validation is bypassed for the images that survive.
  • No auth/SSRF/deserialization/secret exposure. This is a purely in-memory, copy-on-write transform of an already-assembled request; it opens no network paths, reads no credentials, and deserializes nothing new.

One note adjacent to (but still within) the lens: switching from "refuse" to "silently drop oldest images + placeholder" means any upstream consumer that relied on ErrUnsupported as a signal that content was being altered no longer gets it. The PR documents the provider-side count check remains as a backstop, and I confirmed nothing in this diff weakens an authorization or input-validation control — so this is a behavior/contract concern (other lenses' territory), not a security defect.

Nothing in my lane is materially wrong here.

🎯 Correctness — No material issues found

VERDICT: No material issues found

I reviewed the drop-oldest logic through the correctness lens, verifying against the checked-out code (media/media.go, llm/capabilities.go, llm/content.go).

Checks performed and why they hold:

  • No "drop all images" when MaxImagesPerReq == 0. dropOldestImages is called with n = total - caps.MaxImagesPerReq. I confirmed SupportsImages() is MaxImagesPerReq > 0 (llm/capabilities.go:37) and the !caps.SupportsImages() guard (media.go:58) returns before line 70. So the overflow branch only runs with cap ≥ 1, dropping total-cap and keeping ≥ 1 image. No path drops every image.

  • "Most recent kept" is correct, no off-by-one. With total=9, cap=8, n=1; iterating messages front-to-back (oldest→newest) drops the first n ImageParts and keeps the trailing cap. The inner dropped < n guard plus the outer dropped >= n break correctly handle a single message holding more than cap images (drops the leading ones, keeps the trailing ones within that message). Resulting kept count is exactly cap, which satisfies the provider backstop's > check.

  • Copy-on-write preserved; original request untouched. dropOldestImages allocates a fresh out.Messages and fresh Parts slices only for messages it edits; the caller's req.Messages slice and its parts are never written. The subsequent main-loop COW operates on the post-drop req, so indices stay consistent (the part count changes happen before the normalize loop reads them). Verified the test's mutation assertion (req.Messages[0].Parts length) is a genuine guard.

  • Counting consistency. total is counted over ImageParts across all messages (media.go:48-54); replacing dropped images with TextPart placeholders means the post-drop image count is deterministically cap. No risk of under/over-dropping.

  • Compiles. bytes is already imported in media_test.go:4; llm.Text, llm.Image, llm.ImagePart, llm.TextPart all exist as used.

Non-correctness observation (not flagging as a finding): when several images in one message are dropped, each becomes a separate identical placeholder TextPart, so a message can carry repeated placeholder lines — verbose but behaviorally correct.

The change does what it claims; I found no logic, unit, boundary, or magic-number error in my lens.

🧹 Code cleanliness & maintainability — Minor issues

Both findings check out against the code. Confirmed: the test file's errors/bytes/strings imports all remain in use (lines 144, 189, 176, etc.), so the test-rewrite does not break compilation. On finding 1, only the package doc (12–17) and the inline comment (61–69) actually restate the same rationale; the dropOldestImages doc comment (112–115) describes mechanics, not the chain-exhaustion justification — so it's a duplication, not a triplication. Finding 2's double Messages-slice copy is verified by tracing line 71 → 121 → 90–93.


VERDICT: Minor issues

Reviewed through the code-cleanliness & maintainability lens. The change is small, well-named, and follows the existing copy-on-write idiom in media.go. Type assertions match patterns used elsewhere; test imports all still used (errors/bytes/strings at media_test.go:144,189,176 etc.). Two minor maintainability points:

  • Duplicated rationale comment (media/media.go:12-17 and 61-69). The same justification — a hard refuse exhausts a chain whose targets share the same cap, the agent loop accumulating a preview image per iteration, MaxImagesPerReq stays the per-model knob — is spelled out in full in both the package doc and the inline branch comment. (The dropOldestImages doc at 112-115 covers mechanics, not this rationale.) Two copies that must move together is a drift risk. Low-churn fix: keep the policy statement in the package doc and trim the inline comment at 61-69 to one line (e.g. // Over-count: keep most-recent MaxImagesPerReq, elide older ones — see dropOldestImages.).

  • Redundant Messages-slice copy in the overflow path (media/media.go:71116-122, then 90-94). When over-count and some image also needs transform, dropOldestImages allocates+copies out.Messages once (line 121), then the normalize loop allocates+copies it a second time (lines 90-93) because copiedMessages starts false on the reassigned req. Two COW mechanisms with different idioms (make(0,len)+append vs make(len)+copy+index) now coexist for the same "substitute parts in a message" job. Functionally correct; just a structural redundancy worth a conscious choice rather than overlooked.

Neither blocks merge. The naming nit that dropOldestImages replaces (not literally drops) images is acceptable since the doc comment and PR make the placeholder behavior explicit.

Performance — No material issues found

I've reviewed the actual checked-out code. Let me trace the performance characteristics of the new overflow path.

Analysis through the Performance lens:

The change adds dropOldestImages (media.go:116-143), invoked only on the overflow branch (media.go:70-72). Tracing the cost:

  • dropOldestImages copies the Messages slice header (shallow, O(#messages)) and, for each message until n images are replaced, scans its parts twice — once in hasImagePart (line 128) and once in the rebuild loop (line 132). Two linear passes over part headers, bounded by message/part counts that are inherently small. No quadratic behavior, no per-image data copies — placeholders are a shared constant string.
  • The replaced images become Text parts, so the subsequent normalizeImage loop (media.go:76-103) skips them (the part.(llm.ImagePart) assertion fails). The overflow path therefore does less expensive decode/downscale/re-encode work than before, not more.
  • Minor: on the overflow-and-transform path the Messages slice header is allocated twice (once in dropOldestImages, once in the copy-on-write at media.go:91). This is a shallow header copy on a cold path (only when a target both overflows and needs image transformation) — not material.
  • hasImagePart + the rebuild loop double-scan parts, but only for messages on the cold overflow path. Merging them would be a micro-optimization not worth the readability cost.

No N+1, no unbounded growth, no new work in a hot loop, no avoidable large allocations. The per-target recompute matches the pre-existing per-target total count, so there's no chain-level regression.


VERDICT: No material issues found

Nothing in the Performance lens is materially wrong. The overflow handling is linear in message/part count, runs only on the cold overflow branch, and actually reduces downstream image-processing work by converting dropped images into trivial text placeholders before the decode/encode pass. Verified by reading media/media.go:46-152 and confirming the placeholder parts are skipped by the normalizeImage loop's type assertion.

🧯 Error handling & edge cases — No material issues found

I've verified the implementation against the checked-out code. Let me trace the unhappy paths and edge cases carefully.

Verification performed:

  • MaxImagesPerReq == 0 (no negative/zero math): SupportsImages() is MaxImagesPerReq > 0 (llm/capabilities.go:37). So line 58 returns the text-only error before the overflow path. By line 70, MaxImagesPerReq >= 1, and total > cap guarantees n = total - cap >= 1. No zero/negative drop count reaches dropOldestImages, and it guards n <= 0 defensively anyway (media.go:117).
  • n larger than available images: Can't occur (n < total always), but even if it did the loop simply replaces every image and stops — no panic, no index error.
  • Empty collections / nil parts: total == 0 returns early (line 55). A message with no/empty parts hits hasImagePart → false and is skipped (media.go:128). A message whose parts are all images becomes all-placeholders, never an empty Parts slice.
  • Copy-on-write / input mutation: Traced the aliasing. dropOldestImages allocates a fresh Messages slice and a fresh Parts slice only for image-bearing messages; no-image messages share the original Parts but are never written (the second loop only writes on ImagePart matches). In Normalize, copiedMessages always triggers before copiedParts, so writes land in freshly-copied slices — the original R0 and intermediate R1 are never mutated. Matches the test's mutation assertion.
  • Swallowed errors / cleanup: dropOldestImages is pure and cannot fail; nothing to swallow. If a kept image later fails normalizeImage, Normalize still returns the wrapped ErrUnsupported (line 85) and the dropped-image request is discarded — correct.
  • Off-by-one on which images survive: Front-to-back, replacing exactly the first n; the dropped < n guard stops mid-message and appends the rest, so the most-recent cap images are retained. Boundary is correct.

VERDICT: No material issues found

Through the error-handling & edge-cases lens, the new overflow path is sound:

  • All the unhappy paths I could construct (zero/negative drop count, over-drop, empty/nil parts, all-image messages) are either structurally impossible here or handled without panic.
  • The n <= 0 guard in dropOldestImages is correct defensive coding even though the one caller can't reach it.
  • Copy-on-write is correctly preserved on the new code path — no input mutation, verified by tracing the slice aliasing (and corroborated by the test's len(req.Messages[0].Parts) != 2 check).
  • No errors are ignored or swallowed; the function is total (cannot error), and the surrounding normalizeImage error handling is unchanged.

The drop-oldest semantics (silently eliding images rather than refusing) is a deliberate, author-acknowledged behavior trade-off — that belongs to the correctness/design lens, not error handling, so I won't restate it here.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 4s

<!-- gadfly-review:ollama:claude-code/opus:max --> ### 🪰 Gadfly review — `claude-code/opus:max` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **VERDICT: No material issues found** I reviewed this change strictly through the 🔒 Security lens, reading the full implementation in `media/media.go:46-152` and the relevant `llm` type definitions (`llm/content.go`). Findings: - **No injection surface introduced.** The only text inserted into the request is `imageOverflowPlaceholder` (`media/media.go:110`), a compile-time constant. No untrusted/tainted data (image bytes, MIME, or any caller-supplied string) is reflected into the placeholder or any other sink. Verified the placeholder is `const` and that `dropOldestImages` only ever appends `llm.Text(imageOverflowPlaceholder)` — never anything derived from the dropped image's contents (`media/media.go:131-138`). - **No tainted-data handling change.** Dropped `ImagePart`s are discarded, not decoded, parsed, or echoed. The remaining (most-recent) images still flow through the existing unchanged normalization pipeline (`normalizeImage`, byte/MIME/dimension checks) at `media/media.go:74-104`, so no validation is bypassed for the images that survive. - **No auth/SSRF/deserialization/secret exposure.** This is a purely in-memory, copy-on-write transform of an already-assembled request; it opens no network paths, reads no credentials, and deserializes nothing new. One note adjacent to (but still within) the lens: switching from "refuse" to "silently drop oldest images + placeholder" means any upstream consumer that relied on `ErrUnsupported` as a signal that content was being altered no longer gets it. The PR documents the provider-side count check remains as a backstop, and I confirmed nothing in this diff weakens an authorization or input-validation control — so this is a behavior/contract concern (other lenses' territory), not a security defect. Nothing in my lane is materially wrong here. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **VERDICT: No material issues found** I reviewed the drop-oldest logic through the correctness lens, verifying against the checked-out code (`media/media.go`, `llm/capabilities.go`, `llm/content.go`). Checks performed and why they hold: - **No "drop all images" when `MaxImagesPerReq == 0`.** `dropOldestImages` is called with `n = total - caps.MaxImagesPerReq`. I confirmed `SupportsImages()` is `MaxImagesPerReq > 0` (`llm/capabilities.go:37`) and the `!caps.SupportsImages()` guard (`media.go:58`) returns *before* line 70. So the overflow branch only runs with cap ≥ 1, dropping `total-cap` and keeping ≥ 1 image. No path drops every image. - **"Most recent kept" is correct, no off-by-one.** With `total=9, cap=8`, `n=1`; iterating messages front-to-back (oldest→newest) drops the first `n` `ImagePart`s and keeps the trailing `cap`. The inner `dropped < n` guard plus the outer `dropped >= n` break correctly handle a single message holding more than `cap` images (drops the leading ones, keeps the trailing ones within that message). Resulting kept count is exactly `cap`, which satisfies the provider backstop's `>` check. - **Copy-on-write preserved; original request untouched.** `dropOldestImages` allocates a fresh `out.Messages` and fresh `Parts` slices only for messages it edits; the caller's `req.Messages` slice and its parts are never written. The subsequent main-loop COW operates on the post-drop `req`, so indices stay consistent (the part count changes happen before the normalize loop reads them). Verified the test's mutation assertion (`req.Messages[0].Parts` length) is a genuine guard. - **Counting consistency.** `total` is counted over `ImagePart`s across all messages (`media.go:48-54`); replacing dropped images with `TextPart` placeholders means the post-drop image count is deterministically `cap`. No risk of under/over-dropping. - **Compiles.** `bytes` is already imported in `media_test.go:4`; `llm.Text`, `llm.Image`, `llm.ImagePart`, `llm.TextPart` all exist as used. Non-correctness observation (not flagging as a finding): when several images in one message are dropped, each becomes a separate identical placeholder `TextPart`, so a message can carry repeated placeholder lines — verbose but behaviorally correct. The change does what it claims; I found no logic, unit, boundary, or magic-number error in my lens. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Both findings check out against the code. Confirmed: the test file's `errors`/`bytes`/`strings` imports all remain in use (lines 144, 189, 176, etc.), so the test-rewrite does not break compilation. On finding 1, only the package doc (12–17) and the inline comment (61–69) actually restate the same rationale; the `dropOldestImages` doc comment (112–115) describes mechanics, not the chain-exhaustion justification — so it's a duplication, not a triplication. Finding 2's double Messages-slice copy is verified by tracing line 71 → 121 → 90–93. --- VERDICT: Minor issues Reviewed through the code-cleanliness & maintainability lens. The change is small, well-named, and follows the existing copy-on-write idiom in `media.go`. Type assertions match patterns used elsewhere; test imports all still used (`errors`/`bytes`/`strings` at media_test.go:144,189,176 etc.). Two minor maintainability points: - **Duplicated rationale comment** (`media/media.go:12-17` and `61-69`). The same justification — a hard refuse exhausts a chain whose targets share the same cap, the agent loop accumulating a preview image per iteration, MaxImagesPerReq stays the per-model knob — is spelled out in full in both the package doc and the inline branch comment. (The `dropOldestImages` doc at 112-115 covers mechanics, not this rationale.) Two copies that must move together is a drift risk. Low-churn fix: keep the policy statement in the package doc and trim the inline comment at 61-69 to one line (e.g. `// Over-count: keep most-recent MaxImagesPerReq, elide older ones — see dropOldestImages.`). - **Redundant Messages-slice copy in the overflow path** (`media/media.go:71` → `116-122`, then `90-94`). When over-count *and* some image also needs transform, `dropOldestImages` allocates+copies `out.Messages` once (line 121), then the normalize loop allocates+copies it a second time (lines 90-93) because `copiedMessages` starts false on the reassigned `req`. Two COW mechanisms with different idioms (`make(0,len)`+append vs `make(len)`+copy+index) now coexist for the same "substitute parts in a message" job. Functionally correct; just a structural redundancy worth a conscious choice rather than overlooked. Neither blocks merge. The naming nit that `dropOldestImages` *replaces* (not literally drops) images is acceptable since the doc comment and PR make the placeholder behavior explicit. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> I've reviewed the actual checked-out code. Let me trace the performance characteristics of the new overflow path. **Analysis through the Performance lens:** The change adds `dropOldestImages` (media.go:116-143), invoked only on the overflow branch (media.go:70-72). Tracing the cost: - `dropOldestImages` copies the `Messages` slice header (shallow, O(#messages)) and, for each message until `n` images are replaced, scans its parts twice — once in `hasImagePart` (line 128) and once in the rebuild loop (line 132). Two linear passes over part headers, bounded by message/part counts that are inherently small. No quadratic behavior, no per-image data copies — placeholders are a shared constant string. - The replaced images become `Text` parts, so the subsequent `normalizeImage` loop (media.go:76-103) **skips** them (the `part.(llm.ImagePart)` assertion fails). The overflow path therefore does *less* expensive decode/downscale/re-encode work than before, not more. - Minor: on the overflow-and-transform path the `Messages` slice header is allocated twice (once in `dropOldestImages`, once in the copy-on-write at media.go:91). This is a shallow header copy on a cold path (only when a target both overflows and needs image transformation) — not material. - `hasImagePart` + the rebuild loop double-scan parts, but only for messages on the cold overflow path. Merging them would be a micro-optimization not worth the readability cost. No N+1, no unbounded growth, no new work in a hot loop, no avoidable large allocations. The per-target recompute matches the pre-existing per-target `total` count, so there's no chain-level regression. --- **VERDICT: No material issues found** Nothing in the Performance lens is materially wrong. The overflow handling is linear in message/part count, runs only on the cold overflow branch, and actually *reduces* downstream image-processing work by converting dropped images into trivial text placeholders before the decode/encode pass. Verified by reading `media/media.go:46-152` and confirming the placeholder parts are skipped by the `normalizeImage` loop's type assertion. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> I've verified the implementation against the checked-out code. Let me trace the unhappy paths and edge cases carefully. **Verification performed:** - **`MaxImagesPerReq == 0` (no negative/zero math):** `SupportsImages()` is `MaxImagesPerReq > 0` (`llm/capabilities.go:37`). So line 58 returns the text-only error before the overflow path. By line 70, `MaxImagesPerReq >= 1`, and `total > cap` guarantees `n = total - cap >= 1`. No zero/negative drop count reaches `dropOldestImages`, and it guards `n <= 0` defensively anyway (`media.go:117`). ✅ - **`n` larger than available images:** Can't occur (`n < total` always), but even if it did the loop simply replaces every image and stops — no panic, no index error. ✅ - **Empty collections / nil parts:** `total == 0` returns early (line 55). A message with no/empty parts hits `hasImagePart` → false and is skipped (`media.go:128`). A message whose parts are all images becomes all-placeholders, never an empty `Parts` slice. ✅ - **Copy-on-write / input mutation:** Traced the aliasing. `dropOldestImages` allocates a fresh `Messages` slice and a fresh `Parts` slice only for image-bearing messages; no-image messages share the original `Parts` but are never written (the second loop only writes on `ImagePart` matches). In `Normalize`, `copiedMessages` always triggers before `copiedParts`, so writes land in freshly-copied slices — the original `R0` and intermediate `R1` are never mutated. Matches the test's mutation assertion. ✅ - **Swallowed errors / cleanup:** `dropOldestImages` is pure and cannot fail; nothing to swallow. If a *kept* image later fails `normalizeImage`, `Normalize` still returns the wrapped `ErrUnsupported` (line 85) and the dropped-image request is discarded — correct. ✅ - **Off-by-one on which images survive:** Front-to-back, replacing exactly the first `n`; the `dropped < n` guard stops mid-message and appends the rest, so the most-recent `cap` images are retained. Boundary is correct. ✅ --- **VERDICT: No material issues found** Through the error-handling & edge-cases lens, the new overflow path is sound: - All the unhappy paths I could construct (zero/negative drop count, over-drop, empty/nil parts, all-image messages) are either structurally impossible here or handled without panic. - The `n <= 0` guard in `dropOldestImages` is correct defensive coding even though the one caller can't reach it. - Copy-on-write is correctly preserved on the new code path — no input mutation, verified by tracing the slice aliasing (and corroborated by the test's `len(req.Messages[0].Parts) != 2` check). - No errors are ignored or swallowed; the function is total (cannot error), and the surrounding `normalizeImage` error handling is unchanged. The drop-oldest *semantics* (silently eliding images rather than refusing) is a deliberate, author-acknowledged behavior trade-off — that belongs to the correctness/design lens, not error handling, so I won't restate it here. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 4s</sub>

🪰 Gadfly review — ragnaros/qwen3.6-27b (ragnaros)

Verdict: Minor issues · ⚠️ 1/5 lens(es) errored — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found (security lens).

The change replaces a hard-refusal on image over-count with a drop-oldest-and-replace-with-placeholder strategy. Reviewed through the security lens:

  • No injection vectors: The placeholder text ("[earlier image omitted to fit this model's per-request image limit]") is a hardcoded constant, not derived from user input. It cannot be manipulated for prompt injection, template injection, or any other injection attack.
  • No new deserialization or network paths: ImagePart.Data holds raw bytes (not URLs), and the new code only iterates, counts, and replaces parts — no decoding, no fetches, no external calls. The package comment explicitly states "majordomo does not download remote content."
  • Copy-on-write preserves caller data: dropOldestImages creates a new Messages slice (line 121) and new Parts slices for affected messages (line 131), so the original req is not mutated. Verified against the checked-out code.
  • Guarded by existing checks: The caps.SupportsImages() gate (line 58, which checks MaxImagesPerReq > 0) runs before the overflow logic. A zero cap correctly rejects with ErrUnsupported rather than silently processing.
  • n <= 0 early return (line 117) prevents any pathological negative-count behavior.
  • No authn/authz changes, no credential handling, no SSRF surface.
🎯 Correctness⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 5: context deadline exceeded

🧹 Code cleanliness & maintainability — Minor issues

Minor issues

  • media/media.go:71–103 — Dual copy-on-write paths don't compose. When a request both overflows and needs per-image transforms, dropOldestImages eagerly allocates a fresh Messages slice (lines 121–122), then the normalization loop in Normalize independently checks copiedMessages (still false) and allocates/copies out.Messages again (lines 90–93). The messages slice is allocated and copied twice with no sharing. Confirmed by reading lines 71, 74, and 90–93.

  • media/media_test.go:176 — Test matches placeholder by substring rather than constant. strings.Contains(v.Text, "omitted") is fragile: renaming imageOverflowPlaceholder would silently make this test pass for the wrong reason. Should reference the constant directly: v.Text == imageOverflowPlaceholder. Confirmed by reading media_test.go:170–180.

Performance — No material issues found

Verdict: No material issues found

Through the Performance lens, the changes are clean:

  • Algorithmic complexity remains O(n) across the request structure (messages × parts). The new dropOldestImages function does one linear pass with early exit (break at line 125), and hasImagePart is O(parts) per message but only invoked on messages before the drop quota is filled.
  • No unbounded growth or N+1 patterns. The function operates on a single in-memory request and returns promptly.
  • Allocation pattern is acceptable. dropOldestImages allocates a new Messages slice and per-message Parts slices only for affected messages (copy-on-write). The main normalization loop below it also uses copy-on-write, so worst-case when both overflow and image normalization hit is two rounds of allocation — not material for the described use case (agent loops with ~10 images).
  • Minor note (unverified whether material): The initial count loop (lines 47–54) + dropOldestImages + main normalization loop (lines 76–102) means three full passes over the request on overflow. For very large requests this is a constant-factor overhead, but still O(n) — not quadratic or otherwise pathological. If requests routinely carry hundreds of messages, consider folding the count into dropOldestImages or merging it with the normalization pass. For the stated use case, this is negligible.
🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found (error handling & edge cases lens)

Verified by reading media/media.go (full file), llm/capabilities.go, and llm/content.go:

  • MaxImagesPerReq == 0 path: Correctly guarded by caps.SupportsImages() at line 58 (MaxImagesPerReq > 0), which returns ErrUnsupported before the overflow branch is reached. Confirmed via grep on SupportsImages() definition.
  • n <= 0 guard in dropOldestImages: Line 117 handles this cleanly, returning req unchanged.
  • Integer underflow of total - caps.MaxImagesPerReq: Cannot happen — the subtraction only executes under total > caps.MaxImagesPerReq, so n >= 1.
  • Insufficient images to drop: n = total - caps.MaxImagesPerReq and we've established caps.MaxImagesPerReq >= 1, so n < total. The loop will always find enough images.
  • Nil/empty Parts slices: hasImagePart returns false for nil or empty slices (range over nil is a no-op in Go), so such messages are safely skipped.
  • Nil Messages: total would be 0 (range over nil is a no-op), triggering the early return at line 55.
  • Copy-on-write correctness: dropOldestImages creates a new Messages slice via make + copy, and replaces Parts for affected messages. The original request's slices are not mutated. The subsequent normalization loop in Normalize operates on the already-copied req (reassigned at line 71), so the caller's original is preserved.

One minor observation (not a bug): the test's mutation check at media/media_test.go:194 only verifies len(req.Messages[0].Parts) != 2, which is weak — a true in-place mutation would also yield length 2 (one image replaced by one placeholder). A stronger check would verify the first part is still an ImagePart. This doesn't affect production code correctness.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 27m 14s

<!-- gadfly-review:ollama:ragnaros/qwen3.6-27b --> ### 🪰 Gadfly review — `ragnaros/qwen3.6-27b` (ragnaros) **Verdict: Minor issues · ⚠️ 1/5 lens(es) errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **VERDICT: No material issues found (security lens).** The change replaces a hard-refusal on image over-count with a drop-oldest-and-replace-with-placeholder strategy. Reviewed through the security lens: - **No injection vectors**: The placeholder text (`"[earlier image omitted to fit this model's per-request image limit]"`) is a hardcoded constant, not derived from user input. It cannot be manipulated for prompt injection, template injection, or any other injection attack. - **No new deserialization or network paths**: `ImagePart.Data` holds raw bytes (not URLs), and the new code only iterates, counts, and replaces parts — no decoding, no fetches, no external calls. The package comment explicitly states "majordomo does not download remote content." - **Copy-on-write preserves caller data**: `dropOldestImages` creates a new `Messages` slice (line 121) and new `Parts` slices for affected messages (line 131), so the original `req` is not mutated. Verified against the checked-out code. - **Guarded by existing checks**: The `caps.SupportsImages()` gate (line 58, which checks `MaxImagesPerReq > 0`) runs before the overflow logic. A zero cap correctly rejects with `ErrUnsupported` rather than silently processing. - **`n <= 0` early return** (line 117) prevents any pathological negative-count behavior. - **No authn/authz changes**, no credential handling, no SSRF surface. </details> <details><summary><b>🎯 Correctness</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 5: context deadline exceeded </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Minor issues - **`media/media.go:71–103` — Dual copy-on-write paths don't compose.** When a request both overflows and needs per-image transforms, `dropOldestImages` eagerly allocates a fresh `Messages` slice (lines 121–122), then the normalization loop in `Normalize` independently checks `copiedMessages` (still `false`) and allocates/copies `out.Messages` again (lines 90–93). The messages slice is allocated and copied twice with no sharing. Confirmed by reading lines 71, 74, and 90–93. - **`media/media_test.go:176` — Test matches placeholder by substring rather than constant.** `strings.Contains(v.Text, "omitted")` is fragile: renaming `imageOverflowPlaceholder` would silently make this test pass for the wrong reason. Should reference the constant directly: `v.Text == imageOverflowPlaceholder`. Confirmed by reading `media_test.go:170–180`. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **Verdict: No material issues found** Through the Performance lens, the changes are clean: - **Algorithmic complexity remains O(n)** across the request structure (messages × parts). The new `dropOldestImages` function does one linear pass with early exit (`break` at line 125), and `hasImagePart` is O(parts) per message but only invoked on messages before the drop quota is filled. - **No unbounded growth or N+1 patterns.** The function operates on a single in-memory request and returns promptly. - **Allocation pattern is acceptable.** `dropOldestImages` allocates a new `Messages` slice and per-message `Parts` slices only for affected messages (copy-on-write). The main normalization loop below it also uses copy-on-write, so worst-case when both overflow and image normalization hit is two rounds of allocation — not material for the described use case (agent loops with ~10 images). - **Minor note (unverified whether material):** The initial count loop (lines 47–54) + `dropOldestImages` + main normalization loop (lines 76–102) means three full passes over the request on overflow. For very large requests this is a constant-factor overhead, but still O(n) — not quadratic or otherwise pathological. If requests routinely carry hundreds of messages, consider folding the count into `dropOldestImages` or merging it with the normalization pass. For the stated use case, this is negligible. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **VERDICT: No material issues found** (error handling & edge cases lens) Verified by reading `media/media.go` (full file), `llm/capabilities.go`, and `llm/content.go`: - **`MaxImagesPerReq == 0` path**: Correctly guarded by `caps.SupportsImages()` at line 58 (`MaxImagesPerReq > 0`), which returns `ErrUnsupported` before the overflow branch is reached. Confirmed via `grep` on `SupportsImages()` definition. - **`n <= 0` guard in `dropOldestImages`**: Line 117 handles this cleanly, returning `req` unchanged. - **Integer underflow of `total - caps.MaxImagesPerReq`**: Cannot happen — the subtraction only executes under `total > caps.MaxImagesPerReq`, so `n >= 1`. - **Insufficient images to drop**: `n = total - caps.MaxImagesPerReq` and we've established `caps.MaxImagesPerReq >= 1`, so `n < total`. The loop will always find enough images. - **Nil/empty `Parts` slices**: `hasImagePart` returns `false` for nil or empty slices (range over nil is a no-op in Go), so such messages are safely skipped. - **Nil `Messages`**: `total` would be 0 (range over nil is a no-op), triggering the early return at line 55. - **Copy-on-write correctness**: `dropOldestImages` creates a new `Messages` slice via `make` + `copy`, and replaces `Parts` for affected messages. The original request's slices are not mutated. The subsequent normalization loop in `Normalize` operates on the already-copied `req` (reassigned at line 71), so the caller's original is preserved. One minor observation (not a bug): the test's mutation check at `media/media_test.go:194` only verifies `len(req.Messages[0].Parts) != 2`, which is weak — a true in-place mutation would also yield length 2 (one image replaced by one placeholder). A stronger check would verify the first part is still an `ImagePart`. This doesn't affect production code correctness. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 27m 14s</sub>
steve added 1 commit 2026-06-28 22:06:20 +00:00
media: address gadfly review — single-pass elide, drop helpers, stronger test
CI / Tidy (pull_request) Successful in 9m27s
CI / Build & Test (pull_request) Successful in 9m41s
52bb910f4d
Review fixes (no behavior change):
- Fold the over-cap elide INTO the existing copy-on-write normalize pass: one
  loop now replaces the first toElide (oldest) images with the placeholder and
  size-normalizes the rest, so the Messages slice is copied at most once (the
  prior dropOldestImages + the normalize loop double-copied when overflow and a
  transform both applied — the dominant review finding, 5 models).
- Remove dropOldestImages (the name implied removal; it substituted) and the
  one-shot hasImagePart helper — both subsumed by the single pass.
- Trim the 9-line inline comment that restated the package doc.
- Test: rename TestNormalizeTooManyImages_DropsOldest → TestNormalizeOverCount
  (file convention) and assert the EXACT survivors ([b, c], in order) + a
  content-based non-mutation check (first input part is still image a, which a
  len check wouldn't catch).

Build + media + majordomo suites green (-race).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve added 1 commit 2026-06-28 22:33:02 +00:00
test(media): match the overflow placeholder by const, not substring (gadfly #8)
CI / Tidy (pull_request) Successful in 9m25s
CI / Build & Test (pull_request) Successful in 9m49s
70b7aebd86
ragnaros/qwen3.6-27b noted TestNormalizeOverCount matched 'omitted' by substring;
the test is in-package, so assert == imageOverflowPlaceholder instead — robust to
wording changes. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve merged commit 8b924700fb into main 2026-06-28 22:43:20 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/majordomo#8