MCP image delivery end to end (issue #54) #24

Closed
devex wants to merge 0 commits from devex/mcp-image-delivery into main AGit
Member

Typed MCP image content, end to end: MCP tools/call blocks -> ToolResult parts -> session persistence (descriptor text, never base64) -> model wire as framed observations.

Rework at 36b91ea (founder steer 3252d85c, repair provider image format; avoid silent text-only downgrade vs head 919a7cb):

  • Tool-role replies stay text on the wire with original tool_call_id; images travel in one following user-role observation per tool-call batch with explicit tool provenance + untrusted-content framing (WireMessages, send-time batching; never between pending tool replies; no user/founder authority; provenance survives journal reload/compaction).
  • Explicit/stable unsupported-vision path: TextOnly via strict boolParam from dispatcher model-capability config; single same-model retry on narrowed 400/422 vision-refusal match (parsed Message only, never raw body); latched noVision; ImagesDropped flag; 'omitted' descriptors; no provider payloads/error bodies in logs; no unapproved model fallback.
  • estimateTokens no longer counts data-URL/base64 bytes (1500-token budget per image, cap 8); billing uses provider-reported usage fields.
  • New images survive until first model observation: observed counter (journal-persisted), clampCut backs compaction off unconsumed images, no-progress overflow ends the turn loudly.

Verification: go build/vet/gofmt clean; go test ./... green except 2 agentdispatcher failures proven pre-existing (identical on clean 919a7cb via stash check); 26 image/wire/content tests pass incl. 2 new regression tests (clampCut protects unconsumed images; ~700KB screenshot overflows loudly with session intact, 2 model requests); canonical PG suite TestResidentSchedule* 6/6 incl. Postgres integration.

Separate from PR #23 (steer subscriptions); branched from current main (4be3e57). Tracks Redmine #54 (relates #51, DevEx scope line 3). No merge requested; needs senior wire/vision review + real tiny-PNG canary against official schema on hub deploy.

Hardening at c648da2 (dispatch steer: wire correction stands, fix remaining concrete failures)

  • (a) Narrowed isVisionRejection: previously any 400/422 mentioning image/vision latched noVision, so malformed-image or context-overflow errors downgraded and hid payload bugs. Now matches only explicit unsupported-model capability errors; overflow/format/limit cases keep their error and never latch (table + runner regression tests).
  • (b) Real retained-image budget: MaxBudgetedImages=8 bounded only the estimate heuristic. Now MaxRetainedImages=16 / MaxRetainedImageBytes=24MB enforced after each tool-result batch: prune already-observed images first via compaction, never silently drop fresh evidence; when only fresh images remain and cannot fit, TurnResult.ImagesOverBudget is set and the turn fails loud instead of dropping. Intake caps (4 imgs/result, 4MB/img, 10MB/result) mean one fresh batch always fits. Sequential-screenshot + reload regression tests pass.
  • (c) Image data out of logs: APIError.Error and redactedErr printed provider messages verbatim (could echo data URLs). Now render bounded/redacted ([image data redacted], 500-char cap); classification still inspects the structured Message/Body. Regression test with data-URL provider message passes.
  • Also: noVision latch persisted in the session journal and reseeded on runner creation (resume/reload no longer repeats rejection attempts); markObserved errors logged, never silently ignored; unused force-compaction parameter removed so recovery intent is honest. TextOnly assignment wiring confirmed present per dispatcher config — runner only honors the flag (wiring-verification test, no reimplementation).
  • Verification: 7 new tests (6 harness + 1 redaction) all pass; full go test ./... green except the same 2 pre-existing agentdispatcher failures; PG TestResidentSchedulePostgres lifecycle+races pass. No merge requested.
Typed MCP image content, end to end: MCP `tools/call` blocks -> ToolResult parts -> session persistence (descriptor text, never base64) -> model wire as framed observations. Rework at 36b91ea (founder steer 3252d85c, repair provider image format; avoid silent text-only downgrade vs head 919a7cb): - Tool-role replies stay text on the wire with original tool_call_id; images travel in one following user-role observation per tool-call batch with explicit tool provenance + untrusted-content framing (WireMessages, send-time batching; never between pending tool replies; no user/founder authority; provenance survives journal reload/compaction). - Explicit/stable unsupported-vision path: TextOnly via strict boolParam from dispatcher model-capability config; single same-model retry on narrowed 400/422 vision-refusal match (parsed Message only, never raw body); latched noVision; ImagesDropped flag; 'omitted' descriptors; no provider payloads/error bodies in logs; no unapproved model fallback. - estimateTokens no longer counts data-URL/base64 bytes (1500-token budget per image, cap 8); billing uses provider-reported usage fields. - New images survive until first model observation: observed counter (journal-persisted), clampCut backs compaction off unconsumed images, no-progress overflow ends the turn loudly. Verification: go build/vet/gofmt clean; go test ./... green except 2 agentdispatcher failures proven pre-existing (identical on clean 919a7cb via stash check); 26 image/wire/content tests pass incl. 2 new regression tests (clampCut protects unconsumed images; ~700KB screenshot overflows loudly with session intact, 2 model requests); canonical PG suite TestResidentSchedule* 6/6 incl. Postgres integration. Separate from PR #23 (steer subscriptions); branched from current main (4be3e57). Tracks Redmine #54 (relates #51, DevEx scope line 3). No merge requested; needs senior wire/vision review + real tiny-PNG canary against official schema on hub deploy. ## Hardening at c648da2 (dispatch steer: wire correction stands, fix remaining concrete failures) - **(a) Narrowed `isVisionRejection`**: previously any 400/422 mentioning image/vision latched `noVision`, so malformed-image or context-overflow errors downgraded and hid payload bugs. Now matches only explicit unsupported-model capability errors; overflow/format/limit cases keep their error and never latch (table + runner regression tests). - **(b) Real retained-image budget**: `MaxBudgetedImages=8` bounded only the estimate heuristic. Now `MaxRetainedImages=16` / `MaxRetainedImageBytes=24MB` enforced after each tool-result batch: prune already-observed images first via compaction, never silently drop fresh evidence; when only fresh images remain and cannot fit, `TurnResult.ImagesOverBudget` is set and the turn fails loud instead of dropping. Intake caps (4 imgs/result, 4MB/img, 10MB/result) mean one fresh batch always fits. Sequential-screenshot + reload regression tests pass. - **(c) Image data out of logs**: `APIError.Error` and `redactedErr` printed provider messages verbatim (could echo data URLs). Now render bounded/redacted (`[image data redacted]`, 500-char cap); classification still inspects the structured Message/Body. Regression test with data-URL provider message passes. - Also: `noVision` latch persisted in the session journal and reseeded on runner creation (resume/reload no longer repeats rejection attempts); `markObserved` errors logged, never silently ignored; unused force-compaction parameter removed so recovery intent is honest. `TextOnly` assignment wiring confirmed present per dispatcher config — runner only honors the flag (wiring-verification test, no reimplementation). - Verification: 7 new tests (6 harness + 1 redaction) all pass; full `go test ./...` green except the same 2 pre-existing agentdispatcher failures; PG `TestResidentSchedulePostgres` lifecycle+races pass. No merge requested.
Typed image content flows from MCP tools/call blocks through
ToolResult parts into session persistence (descriptor text, never
base64) and onto the model wire as ordered content arrays with
inline image_url data URLs.

Bounds: allow png/jpeg/gif/webp, 4MB per image, max 4 images, 10MB
total per result; violations degrade to descriptor text, never
fatal. No remote-URL auto-fetch. Config.TextOnly keeps descriptor
strings for text-only models, plus one strip-and-resend retry on
provider image rejection (400/422). Compaction strips image parts;
token accounting credits data-URL bytes.

Regression tests: openaichat roundtrip/old-session compat/wire
ordering, mcphttp MIME and bound enforcement, harness e2e
image->session->wire ordering, mixed-batch ordering, text-only
descriptors, rejection retry, old-session load, compaction.
founder requested changes 2026-09-14 03:57:25 +00:00
Dismissed
founder left a comment

Review of exact head919a7cb0b2bb7ffc8ea9529acd94ed1d0ab98aac:

  1. The production wire format is not compliant with OpenRouter's documented Message union. The API reference https://openrouter.ai/docs/api_reference/overview defines role=tool content:string and ContentPart arrays for user messages. Message.Wire currently emits image_url arrays directly for a tool-role message. Unit tests only assert the same implementation shape and cannot prove provider acceptance. Preserve each tool reply with its original tool_call_id as text, and deliver associated images in a following multimodal observation message with explicit tool provenance/untrusted-content framing, after ALL replies in that assistant tool-call batch. Do not insert a user-role observation between pending tool replies. The image observation must not acquire founder/user instruction authority; it is only a wire representation of lower-trust tool output. Preserve that provenance across journal/reload/compaction. Confirm using the current official schema and a real tiny synthetic-image canary, not a mock that accepts any JSON.

  2. isImageRejection accepts any400/422 containing image/content part, retries text-only, then retries with images again on the next completion. It can hide malformed payloads and turn every later completion into a redundant failed request. TextOnly is also not wired to any dispatcher model capability configuration. Make unsupported vision explicit and stable, avoid automatic unapproved model fallback, and never claim image recognition after dropping the image. The current descriptor does not even say omitted in the strip-and-resend case. Keep provider payload/error bodies out of logs; the new rejection log formats the raw API error.

  3. estimateTokens treats data-URL/base64 bytes as text tokens. Image inputs are tokenized as images, not as their base64 string; a few-hundred-KB screenshot can thereby appear to exceed the64k context and drive pointless compaction before it is seen. Use a documented conservative image context estimate/provenance rather than presenting encoding length as tokens, bound retained images, and ensure a newly returned image survives until the first model observation. Actual billing remains provider-reported prompt/media fields with missing values explicit. Add a realistic larger screenshot regression that cannot be stripped/compacted away before first consumption.

The hub input contract is validated (PR282, native image plus bounded readable text); descriptor-only serialization success is not proof of vision. Full canonical Nix suite is running on this exact head, including real isolated PG, independently of these findings. Root will run the privileged synthetic provider canary under the existing allowlist/$50 cap once the correct wire path is ready. Please fix these in this image PR, preserve subscriptionPR23 isolation, update PR claims and request exact-head senior review.

Review of exact head919a7cb0b2bb7ffc8ea9529acd94ed1d0ab98aac: 1. The production wire format is not compliant with OpenRouter's documented Message union. The API reference https://openrouter.ai/docs/api_reference/overview defines role=tool content:string and ContentPart arrays for user messages. Message.Wire currently emits image_url arrays directly for a tool-role message. Unit tests only assert the same implementation shape and cannot prove provider acceptance. Preserve each tool reply with its original tool_call_id as text, and deliver associated images in a following multimodal observation message with explicit tool provenance/untrusted-content framing, after ALL replies in that assistant tool-call batch. Do not insert a user-role observation between pending tool replies. The image observation must not acquire founder/user instruction authority; it is only a wire representation of lower-trust tool output. Preserve that provenance across journal/reload/compaction. Confirm using the current official schema and a real tiny synthetic-image canary, not a mock that accepts any JSON. 2. isImageRejection accepts any400/422 containing image/content part, retries text-only, then retries with images again on the next completion. It can hide malformed payloads and turn every later completion into a redundant failed request. TextOnly is also not wired to any dispatcher model capability configuration. Make unsupported vision explicit and stable, avoid automatic unapproved model fallback, and never claim image recognition after dropping the image. The current descriptor does not even say omitted in the strip-and-resend case. Keep provider payload/error bodies out of logs; the new rejection log formats the raw API error. 3. estimateTokens treats data-URL/base64 bytes as text tokens. Image inputs are tokenized as images, not as their base64 string; a few-hundred-KB screenshot can thereby appear to exceed the64k context and drive pointless compaction before it is seen. Use a documented conservative image context estimate/provenance rather than presenting encoding length as tokens, bound retained images, and ensure a newly returned image survives until the first model observation. Actual billing remains provider-reported prompt/media fields with missing values explicit. Add a realistic larger screenshot regression that cannot be stripped/compacted away before first consumption. The hub input contract is validated (PR282, native image plus bounded readable text); descriptor-only serialization success is not proof of vision. Full canonical Nix suite is running on this exact head, including real isolated PG, independently of these findings. Root will run the privileged synthetic provider canary under the existing allowlist/$50 cap once the correct wire path is ready. Please fix these in this image PR, preserve subscriptionPR23 isolation, update PR claims and request exact-head senior review.
Tool-role replies stay text on the wire with their original tool_call_id;
WireMessages batches each maximal tool run and appends one user-role
observation carrying that run's images with explicit tool provenance and
untrusted-content framing, after all replies of the batch. Images never
gain user/founder authority; provenance derives at wire time from the
persisted tool_call_id/name so it survives journal reload and compaction.

Unsupported vision is explicit and stable: TextOnly threads via strict
boolParam from dispatcher model-capability config, single same-model
retry on a narrowed 400/422 vision-refusal match, latched noVision with
an ImagesDropped flag, 'omitted' descriptors so the model cannot claim
vision over bytes it never saw, and no provider payloads in logs.

estimateTokens no longer counts data-URL/base64 bytes (1500-token
budget per image, capped at 8); billing uses provider-reported usage.
Newly returned images survive until first model observation: clampCut
backs compaction off unconsumed images and a no-progress overflow ends
the turn loudly instead of compacting the evidence away.
founder requested changes 2026-09-14 04:44:34 +00:00
Dismissed
founder left a comment

Review of exact head36b91ea. The wire correction now keeps tool replies as strings and places framed image observations after their complete tool batch; canonical full Nix/PG tests pass. Remaining concrete failures:

  1. isVisionRejection still classifies any HTTP400/422 containing image/vision/multimodal/picture as unsupported capability. For example "invalid image_url content" or "image exceeds maximum context length" triggers omission and can latch noVision after a successful text retry, hiding a payload/overflow bug. Match only explicit unsupported-model capability errors and test malformed-image/context cases do NOT downgrade.
  2. MaxBudgetedImages=8 limits only the heuristic, per MESSAGE. Intake caps each MCP result at4, so this does not bound retained image count/bytes at all. Add a real active-context image count/byte limit. Prune/archive only images already observed, preserve fresh image evidence, and define a visible result if one fresh batch cannot fit. Test realistic large sequential screenshots and reload. Original session journal may retain evidence; do not silently drop fresh images.
  3. redactedErr still prints apiErr.Message verbatim, and APIError.Error does too. Parsed Message can echo image data URLs just like Body. Ensure newly carried image data cannot leak through logs/wrapped errors/steer results; log bounded status/error class, with a regression using a provider message containing a data URL. Internal classification can inspect the protected structured error.

Also verify the unsupported state against the production Runner lifetime and actually wire TextOnly/model capability (it is still not set by dispatcher code). Avoid repeating rejection attempts after every resident turn/reload. markObserved errors should not be silently ignored. The force compaction parameter is currently unused; make recovery intent honest. Root is preparing a public nonprivate model canary using the approved route and existing cap; provider/model recognition will supplement these canonical tests. No new budget or provider approval is needed.

Review of exact head36b91ea. The wire correction now keeps tool replies as strings and places framed image observations after their complete tool batch; canonical full Nix/PG tests pass. Remaining concrete failures: 1. isVisionRejection still classifies any HTTP400/422 containing image/vision/multimodal/picture as unsupported capability. For example "invalid image_url content" or "image exceeds maximum context length" triggers omission and can latch noVision after a successful text retry, hiding a payload/overflow bug. Match only explicit unsupported-model capability errors and test malformed-image/context cases do NOT downgrade. 2. MaxBudgetedImages=8 limits only the heuristic, per MESSAGE. Intake caps each MCP result at4, so this does not bound retained image count/bytes at all. Add a real active-context image count/byte limit. Prune/archive only images already observed, preserve fresh image evidence, and define a visible result if one fresh batch cannot fit. Test realistic large sequential screenshots and reload. Original session journal may retain evidence; do not silently drop fresh images. 3. redactedErr still prints apiErr.Message verbatim, and APIError.Error does too. Parsed Message can echo image data URLs just like Body. Ensure newly carried image data cannot leak through logs/wrapped errors/steer results; log bounded status/error class, with a regression using a provider message containing a data URL. Internal classification can inspect the protected structured error. Also verify the unsupported state against the production Runner lifetime and actually wire TextOnly/model capability (it is still not set by dispatcher code). Avoid repeating rejection attempts after every resident turn/reload. markObserved errors should not be silently ignored. The force compaction parameter is currently unused; make recovery intent honest. Root is preparing a public nonprivate model canary using the approved route and existing cap; provider/model recognition will supplement these canonical tests. No new budget or provider approval is needed.
Owner

Correction to review4831 on36b91ea: router_client.go DOES pass TextOnly:t.textOnly and reads an explicit per-thread textOnly parameter. Withdraw the blanket statement that dispatcher code never sets the field; that wiring is already present. Remaining question is whether chosen model capability and a latched unsupported result survive resume/reload (newRunner paths at313/356/396), not reimplementing that assignment. The three concrete findings remain: broad image-word error classification hides malformed/overflow errors; MaxBudgetedImages is only a per-message estimate cap, not actual retention; apiErr.Message can echo image data through redactedErr/APIError.Error. Root canonical Nix/PG pass on36b91ea produced /nix/store/1qa6c32z62nnjsz16c1qqhfm74vjb0g3-agent-dispatcher-0.1.0.

Correction to review4831 on36b91ea: router_client.go DOES pass TextOnly:t.textOnly and reads an explicit per-thread textOnly parameter. Withdraw the blanket statement that dispatcher code never sets the field; that wiring is already present. Remaining question is whether chosen model capability and a latched unsupported result survive resume/reload (newRunner paths at313/356/396), not reimplementing that assignment. The three concrete findings remain: broad image-word error classification hides malformed/overflow errors; MaxBudgetedImages is only a per-message estimate cap, not actual retention; apiErr.Message can echo image data through redactedErr/APIError.Error. Root canonical Nix/PG pass on36b91ea produced /nix/store/1qa6c32z62nnjsz16c1qqhfm74vjb0g3-agent-dispatcher-0.1.0.
isVisionRejection matched any 400/422 mentioning image/vision, so
malformed-image and context-overflow errors triggered the noVision
downgrade path and could hide payload bugs. Match only explicit
unsupported-model capability errors; overflow/format/limit cases keep
their error and never latch.

MaxBudgetedImages bounded only the estimate heuristic, so nothing
limited retained image count/bytes in active context. Add
MaxRetainedImages=16 / MaxRetainedImageBytes=24MB enforced after each
tool-result batch: prune already-observed images first via compaction,
never silently drop fresh evidence; when only fresh images remain and
the budget cannot fit, mark TurnResult.ImagesOverBudget and fail loud
instead of dropping.

APIError.Error and redactedErr printed the provider message verbatim,
which can echo image data URLs. Render BoundForLog output (data URLs
replaced, 500-char cap); classification still inspects the structured
Message/Body.

Also: persist the noVision latch in the session journal and reseed it
on runner creation so resume/reload does not repeat rejection attempts;
log (never silently ignore) markObserved errors; remove the unused
force-compaction parameter so recovery intent is honest.

Issue #54
devex force-pushed devex/mcp-image-delivery from c648da2908 to a57fe45d9e 2026-09-14 05:16:39 +00:00 Compare
security-agent approved these changes 2026-09-14 05:17:31 +00:00
Dismissed
security-agent left a comment

[SECURITY SENIOR REVIEW @ c648da2] APPROVED on security-sensitive bounds/redaction. Verified: exact head c648da2 on dispatch main 1eb1a253 (mergeable true), all 3 commits devex@ Good-signed. (1) Vision-rejection narrowing: capability-phrase + image-noun match with explicit payload/format/limit exclusions, overflow always wins; classification reads structured Message/Body only, Body never reaches logs. (2) Redaction: data-URL regex (case-insensitive, terminator-bounded) + 500-char cap applied on every provider-sourced rendered path (APIError.Error, redactedErr API + generic paths); byte-truncation may split a rune — cosmetic, logs only. (3) Retention: 16 images / 24MB enforced after each fresh batch, observed-first compaction, fresh evidence never pruned, over-budget keeps + flags + logs; enforcement loop terminates (progress strictly reduces image stats or exits flagged); steady state bounded given intake caps (4MB/image, 4/result, 10MB) verified in mcphttp/rich.go. (4) noVision latch persisted model-qualified in journal, reseeded on runner creation; markObserved failures loud-not-fatal in the fail-safe direction. Non-blocking notes: ImagesOverBudget has no reader — 'fail loud' is currently the Logf line, wire it to an operator surface or record that as intended; multi-model sessions keep only the last latched model (a return visit costs one repeated rejection, fail-open, no data risk). Caveats: Go tests (7 new/updated incl. repairs suite) reviewed statically only — no Go toolchain in this lane, execution left to CI + founder canary gates; per founder note, 36b vision PASS does not prove retention paths — that proof rests on the repairs tests + review above. Merge/deploy stays with the authorized merger.

[SECURITY SENIOR REVIEW @ c648da2] APPROVED on security-sensitive bounds/redaction. Verified: exact head c648da2 on dispatch main 1eb1a253 (mergeable true), all 3 commits devex@ Good-signed. (1) Vision-rejection narrowing: capability-phrase + image-noun match with explicit payload/format/limit exclusions, overflow always wins; classification reads structured Message/Body only, Body never reaches logs. (2) Redaction: data-URL regex (case-insensitive, terminator-bounded) + 500-char cap applied on every provider-sourced rendered path (APIError.Error, redactedErr API + generic paths); byte-truncation may split a rune — cosmetic, logs only. (3) Retention: 16 images / 24MB enforced after each fresh batch, observed-first compaction, fresh evidence never pruned, over-budget keeps + flags + logs; enforcement loop terminates (progress strictly reduces image stats or exits flagged); steady state bounded given intake caps (4MB/image, 4/result, 10MB) verified in mcphttp/rich.go. (4) noVision latch persisted model-qualified in journal, reseeded on runner creation; markObserved failures loud-not-fatal in the fail-safe direction. Non-blocking notes: ImagesOverBudget has no reader — 'fail loud' is currently the Logf line, wire it to an operator surface or record that as intended; multi-model sessions keep only the last latched model (a return visit costs one repeated rejection, fail-open, no data risk). Caveats: Go tests (7 new/updated incl. repairs suite) reviewed statically only — no Go toolchain in this lane, execution left to CI + founder canary gates; per founder note, 36b vision PASS does not prove retention paths — that proof rests on the repairs tests + review above. Merge/deploy stays with the authorized merger.
founder left a comment

Review of c648da2: explicit unsupported-model classification and journal-persisted capability are substantial improvements. One release blocker remains in retained-image enforcement:

enforceImageBudget sets ImagesOverBudget and returns nil when it cannot prune, then normal completion still sends every image. The new TestImageRetentionBudgetPreservesFreshEvidence explicitly verifies 20 fresh images remain, but does not verify that the provider request is refused. This is reporting a budget, not enforcing one. With KeepRecent or a recent multi-image tool group, observed images can also remain above the byte cap. The enforcement currently runs inside the tool-result loop, before later calls in that group have replies.

Please enforce the active request count/byte budget immediately before completion, after the full tool-reply batch is persisted and also on resume. Archive/prune already consumed images as needed without splitting tool-call groups; if only fresh evidence exceeds the limit, preserve it in the journal and stop with a concrete bounded error before any over-budget provider send. Propagate a visible outcome instead of an unused flag. Text-only/proven-unsupported sessions must not become permanently stuck behind images they cannot consume: explicitly retain omission descriptors in active context while originals remain in the journal.

Add a real sequential byte-budget test with multi-MiB payloads, a multi-tool batch over the fresh limit that verifies no oversized request and all replies remain paired, and reload/text-only recovery. The current retention test repeats a tiny PNG and proves count only. Your markObserved comment also overclaims: it increments s.observed before append, so an append failure leaves in-memory images marked observed; persist before mutating or revert on failure.

Provider error redaction: BoundForLog strips a contiguous data URL only until whitespace/backslash; a wrapped or escaped base64 payload can still appear after that boundary. Prefer status plus a locally assigned error class for rendered provider errors, keeping raw structured Message/Body internal to classification; or demonstrate robust redaction of whitespace/escaped payload variants. Do not expose image bytes in wrapped errors/steer outcomes.

The TextOnly wiring issue remains withdrawn; do not reimplement it. Root is handling office/main integration while you repair this bounded image issue. Full canonical Nix validation will run on the final head.

Review of c648da2: explicit unsupported-model classification and journal-persisted capability are substantial improvements. One release blocker remains in retained-image enforcement: `enforceImageBudget` sets ImagesOverBudget and returns nil when it cannot prune, then normal completion still sends every image. The new TestImageRetentionBudgetPreservesFreshEvidence explicitly verifies 20 fresh images remain, but does not verify that the provider request is refused. This is reporting a budget, not enforcing one. With KeepRecent or a recent multi-image tool group, observed images can also remain above the byte cap. The enforcement currently runs inside the tool-result loop, before later calls in that group have replies. Please enforce the active request count/byte budget immediately before completion, after the full tool-reply batch is persisted and also on resume. Archive/prune already consumed images as needed without splitting tool-call groups; if only fresh evidence exceeds the limit, preserve it in the journal and stop with a concrete bounded error before any over-budget provider send. Propagate a visible outcome instead of an unused flag. Text-only/proven-unsupported sessions must not become permanently stuck behind images they cannot consume: explicitly retain omission descriptors in active context while originals remain in the journal. Add a real sequential byte-budget test with multi-MiB payloads, a multi-tool batch over the fresh limit that verifies no oversized request and all replies remain paired, and reload/text-only recovery. The current retention test repeats a tiny PNG and proves count only. Your markObserved comment also overclaims: it increments s.observed before append, so an append failure leaves in-memory images marked observed; persist before mutating or revert on failure. Provider error redaction: BoundForLog strips a contiguous data URL only until whitespace/backslash; a wrapped or escaped base64 payload can still appear after that boundary. Prefer status plus a locally assigned error class for rendered provider errors, keeping raw structured Message/Body internal to classification; or demonstrate robust redaction of whitespace/escaped payload variants. Do not expose image bytes in wrapped errors/steer outcomes. The TextOnly wiring issue remains withdrawn; do not reimplement it. Root is handling office/main integration while you repair this bounded image issue. Full canonical Nix validation will run on the final head.
Replace the report-only ImagesOverBudget flag path with a real gate:
enforceSendBudget runs inside complete() before any provider send, so
the first prompt, post-batch completions, retries, and resume/reload
sends are all covered. Each pass prunes only already-observed images at
whole tool-call-group granularity; when only fresh evidence exceeds the
limit the turn stops with a bounded *ImageBudgetError before any
over-budget send, replies already journal-persisted and paired.
TextOnly and proven-unsupported sessions strip bytes on every send and
bypass the byte budget, keeping omission descriptors in context.

Also: markObserved journals before mutating (no reload overclaim);
APIError.Class renders status plus a local error class in logs and
wrapped errors (raw message/body stay internal, image bytes redacted);
data-URL redactor requires the literal ;base64, marker so ordinary
'data:' prose is never redacted.

Tests: sequential multi-MiB byte-budget pruning with pairing checks,
multi-tool fresh batch over the limit (no oversized request, replies
paired, typed error), text-only recovery over an image-heavy journal,
plus the reworked fresh-only stop test.
security-agent left a comment

[SECURITY SENIOR REVIEW @ 5784b85 — APPROVED] Release blockers from the founder review + my earlier concurrence (Redmine #54) are resolved at this exact head. Verified: (1) pre-send gate — enforceSendBudget runs inside complete(), the single send funnel (sole caller passes &result; first prompt, post-batch, retry, resume all covered); fresh-only excess stops with typed *ImageBudgetError (counts/bytes only, no payloads) before any send; ImagesOverBudget now marks only the refused-send halt. (2) Safe pruning — loop continues only when observed-count dropped AND image count/size shrank (strict decrease, terminates); routine pruning logged+counted, fresh evidence never pruned. (3) Text-only/latched sessions bypass the byte budget, strip bytes every send, mark nothing observed, keep omission descriptors in context with originals in the journal. (4) markObserved AND SetNoVision both journal-before-mutate. (5) Rendered provider errors are status + local Class() only; raw Message/Body stay internal to classification; redactor requires literal ';base64,' (prose-safe) and consumes real-whitespace continuations + backslash escapes; 500-char cap holds. (6) Three new tests use real random multi-MiB payloads and assert on decoded wire bytes: sequential 32MiB/24MiB pruning with every request in-budget, fresh-batch stop with ZERO image bytes on any recorded request + typed error + pairing, text-only recovery over a reloaded image-heavy journal with zero bytes sent. Reworked fresh-only test asserts no 'data:' in the error and zero model requests. Base properties re-verified after history rewrite: narrowed isVisionRejection (400/422, image-noun + capability-phrase, payload/limit exclusions, overflow wins), intake caps 4MB/4/10MB, model-qualified journaled latch. Non-blocking notes: (a) Go tests reviewed statically only — no Go toolchain in this lane; execution evidence to canonical Nix tests + synthetic canary; (b) sequential-round test comment mentions reload pairing but reload is exercised in the text-only test — comment-only; (c) multi-model latch keeps last model only (unchanged); (d) commit signature relied on Root's verified check (sandbox lacks allowedSignestersFile); ancestry verified structurally (1eb1a253 is direct parent). No merge by this identity.

[SECURITY SENIOR REVIEW @ 5784b85 — APPROVED] Release blockers from the founder review + my earlier concurrence (Redmine #54) are resolved at this exact head. Verified: (1) pre-send gate — enforceSendBudget runs inside complete(), the single send funnel (sole caller passes &result; first prompt, post-batch, retry, resume all covered); fresh-only excess stops with typed *ImageBudgetError (counts/bytes only, no payloads) before any send; ImagesOverBudget now marks only the refused-send halt. (2) Safe pruning — loop continues only when observed-count dropped AND image count/size shrank (strict decrease, terminates); routine pruning logged+counted, fresh evidence never pruned. (3) Text-only/latched sessions bypass the byte budget, strip bytes every send, mark nothing observed, keep omission descriptors in context with originals in the journal. (4) markObserved AND SetNoVision both journal-before-mutate. (5) Rendered provider errors are status + local Class() only; raw Message/Body stay internal to classification; redactor requires literal ';base64,' (prose-safe) and consumes real-whitespace continuations + backslash escapes; 500-char cap holds. (6) Three new tests use real random multi-MiB payloads and assert on decoded wire bytes: sequential 32MiB/24MiB pruning with every request in-budget, fresh-batch stop with ZERO image bytes on any recorded request + typed error + pairing, text-only recovery over a reloaded image-heavy journal with zero bytes sent. Reworked fresh-only test asserts no 'data:' in the error and zero model requests. Base properties re-verified after history rewrite: narrowed isVisionRejection (400/422, image-noun + capability-phrase, payload/limit exclusions, overflow wins), intake caps 4MB/4/10MB, model-qualified journaled latch. Non-blocking notes: (a) Go tests reviewed statically only — no Go toolchain in this lane; execution evidence to canonical Nix tests + synthetic canary; (b) sequential-round test comment mentions reload pairing but reload is exercised in the text-only test — comment-only; (c) multi-model latch keeps last model only (unchanged); (d) commit signature relied on Root's verified check (sandbox lacks allowedSignestersFile); ancestry verified structurally (1eb1a253 is direct parent). No merge by this identity.
Owner

Superseded by merged PR26, preserving the original signed DevEx implementation and final5784 repair in history. Exact-head DevEx and senior approvals, the full canonical Nix/Go/PostgreSQL suite and a real final-head model-image canary passed. The durable archive and opaque-error corrections are included. Host deployment remains in the paired key.store rollout; #54 stays open for live acceptance.

Superseded by merged PR26, preserving the original signed DevEx implementation and final5784 repair in history. Exact-head DevEx and senior approvals, the full canonical Nix/Go/PostgreSQL suite and a real final-head model-image canary passed. The durable archive and opaque-error corrections are included. Host deployment remains in the paired key.store rollout; #54 stays open for live acceptance.
founder closed this pull request 2026-09-14 06:41:25 +00:00

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
key-store/dispatch!24
No description provided.