Repository navigation
fix(agent-core): cap images per request and recover sessions over the provider image limit - #431
Conversation
…vider limit
Inline images stay in persisted history and every request re-sends the
whole provider-visible history, so a long session eventually exceeded the
provider's per-request image limit ("Too many images in request: 31 > 30").
Every later request, /retry, /compact and a restarted session then failed
the same way, each after the full BYOK retry window, behind a generic
"temporarily unavailable / Run /retry" message.
- Request-time image ceiling: projectAgentMessagesForModel keeps the newest
N images across user messages and tool results and replaces older ones
with a short placeholder naming the original file. Stored history is
never rewritten. N comes from capabilities.max_images_per_request
(default 20; api.mistral.ai defaults to its documented 8). Agent turns,
checkpoint requests and footprint estimates share the projection.
- Deterministic rejections are terminal: an image-count rejection, or an
HTTP 400 invalid_request_error, is no longer retried by the BYOK
retry-all path (timeouts, network, 408/429/5xx stay retryable). The TUI
shows the upstream reason and suggests /compact or /new instead of /retry.
- Compaction fallback: when the checkpoint Provider rejects a candidate for
too many images, compaction retries the same history with media replaced
by text (Hvideo) and continues down the attachment-free ladder.
Refs #425
CodeQL flagged the attribute regular expression as polynomial on user-controlled message text (js/polynomial-redos). Parse the attachment tags with indexOf instead and cover escaped paths and adversarial input. Refs #425
hetaoBackend
left a comment
There was a problem hiding this comment.
Looked at the request-side image cap, the classifier, and the BYOK retry-all exemption on 6ca1a8f. Cannot formally APPROVE (PR is under this GitHub account), so this is a review comment.
Image cap — Looks right. Applied last in projectAgentMessagesForModel after video/undersized projection, so the count matches what is actually sent. Newest N kept, older replaced with path placeholders, stored history untouched. Default 20 with headroom under the #425 gateway's 30, Mistral host exact-match → 8, override via max_images_per_request. Linear attachment parse on this head addresses the earlier CodeQL alert.
Error classification / TUI — image_limit and invalid_request are recognized, and the rejection branch runs before the generic 50113 "temporarily unavailable" path, so the real reason shows and retryable: false.
BYOK retry exemption — Narrow enough. Only isLLMDeterministicRequestRejection exits retry-all: image-limit with status undefined/400/413/422, or HTTP 400 + invalid_request_error. Abort/timeout/network/408/429/5xx stay retryable. Plain 400 Bad Request and "too many images queued" on 503 still take the full 6 attempts, matching the tests.
Non-blocking note: the exemption covers any HTTP 400 invalid_request_error, not only image-count failures (empty text blocks etc.). That is deliberate and fine for #425; just calling out the tradeoff already in the PR description (a gateway that mislabels a transient failure as 400 invalid_request_error will no longer retry).
From my side this is good to leave draft and merge once Jack is happy with /compact + defaults.
Change
Refs #425
A session that has accumulated more images than the provider accepts in one request (the reporter's gateway rejects
Too many images in request: 31 > 30) becomes permanently stuck: every following request fails, including a plain text turn,/retry,/compactand a session restarted with-c. Each failure takes the full BYOK retry window (six requests, about 20 s), and the TUI says "The model provider is temporarily unavailable … Run /retry". Only/newescapes.After this PR:
invalid_request_error, fails once with the upstream reason and points to/compactor/new;/compacton a session the checkpoint provider rejects for too many images falls back to the image-free candidate and recovers the session.Root cause
messages.jsonl). Each request re-sends the whole provider-visible history throughprojectAgentMessagesForModel(packages/agent-core/src/pi-turn-runner/outbound-message-normalizer.ts). That function had a per-message attachment cap (max_attachments_count: 4) but no request-level image cap. In the repro, the image count rose 4, 8 … 30, 31, and every request after that point was rejected.packages/agent-core/src/pi-turn-runner/llm-retry.ts(failureResult): BYOK providers retry every pre-output failure (the retry-all policy). Only aborts and safety refusals were exempt, so a deterministic 400 was sent six times.packages/tui/src/tui/controller/runtime/runtime-error-presentation.ts: BYOK upstream failures arrive with code 50113 (LLM_UPSTREAM_ERROR). That code maps to "temporarily unavailable / Run /retry" and hides the upstream reason.packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.ts) carried the same images. A rejection was neither a typed overflow nor a success, socompactContextfailed with "Checkpoint Provider request failed after one retry" and never reached the image-free candidate (Hvideo).Changes
packages/agent-core/src/pi-turn-runner/request-image-limit.ts.limitRequestImageskeeps the newest N image blocks across the whole request, counting both user messages and tool results. Each older image becomes[Earlier image omitted: this request keeps only the N most recent images. Original file: <path>].<attachment … path=… kind="image" inline="true">reminders, used only when their count matches the image blocks.path/file_pathargument when the result holds a single image.js/polynomial-redosalert on the first push; a test covers adversarial input.projectAgentMessagesForModelapplies the cap last, after video and undersized-image projection, so the count matches what is actually sent. The agent turn, the checkpoint request and footprint measurement all use this projection with the target model.agent.tslogsoutbound_images_limitedwhen images are omitted.max_images_per_request(IModelCapabilitiesinpackages/protocol/src/runtime.tsandModelCapabilitiesConfiginpackages/config/src/config.ts). It reaches the resolved model throughmodel-ref.tsandlocal-model-resolver.ts.DEFAULT_MAX_IMAGES_PER_REQUEST = 20.api.mistral.ai(exact host) gets 8, the limit given in Mistral's vision FAQ.docs/examples.mddocuments the behavior and the override.packages/shared/src/llm-error-classifier.ts: newimage_limitandinvalid_requestsignals, plus three exports:isLLMImageLimitMessagematches the common phrasings: "too many images", "maximum (number) of N images", "number of images … exceeds", "at most N images".classifyLLMRequestRejectionMessage, for UI layers.isLLMDeterministicRequestRejectionis true for an image-limit signal (status unknown, 400, 413 or 422) or for HTTP 400 together withinvalid_request_error. It is always false for aborts, timeouts, network errors and 408/429/5xx.typefield.llm-retry.ts: the BYOK retry-all branch now also excludesisLLMDeterministicRequestRejection. Those errors fall through to the normal decision, which returns non-retryablebad_request. The narrow scope is deliberate: retry-all exists because custom gateways report transient errors inconsistently. A plain400 Bad Requestwithout the error type is still retried six times, as are 401, 429 and 5xx.runtime-error-presentation.ts: an image-limit or invalid-request failure shows "The model provider rejected the request: the conversation carries too many images" (or "…as invalid"). It adds aReason:line with the upstream text (the BYOK prefix is stripped), the provider and code, and "Resending fails the same way. Run /compact … or /new …", withretryable: false. Other 50113 failures are unchanged.compaction/contracts.ts: newCheckpointCandidateMediaRejectedError.checkpoint-provider.tsthrows it when a checkpoint result or thrown error matchesisLLMImageLimitMessage.compaction/algorithm/compact-context.ts: on that error,recoverAfterMediaRejectionretries the rejected candidate with media replaced by text (buildAttachmentFreeCandidate, reported ashvideo).CHECKPOINT_PROVIDER_FAILED("…rejected the request for carrying too many images").Tests (all registered in the
capabilitysuite):packages/agent-core/test/unit/pi-turn-runner/request-image-limit.test.ts:llm-retry.test.ts:invalid_request_error, BYOK and non-BYOK) make one attempt and no sleep;packages/tui/test/unit/runtime-error-presentation-rejection.test.ts: the new message, reason,/compactand/newguidance andretryable: false; the BYOK prefix stripped; the invalid-request variant; generic 50113 unchanged.compact-context.test.ts: image rejection falls back to an image-free Hvideo; the overflow continues the ladder; no duplicate attachment-free request; failure without media; failure on a second rejection.checkpoint-provider.test.ts: an error result and a thrown error both map to the media rejection; the checkpoint request is capped at 20 images.local-model-resolver.test.ts: a configured value (number or string) reaches the executor model; invalid or missing values leave the default; the Mistral host gets 8 unless overridden; a gateway path containingapi.mistral.aidoes not.Validation
6ca1a8f, base56221c1, Linux, Node 24.21.0):mainbehavior: 21 failures across the 6 touched test files. Examples:[31,31,31,31,31,31]);max_images_per_request.pnpm typecheck,pnpm lint,pnpm check:source: pass.node scripts/run-vitest-suite.mjs capability: 210 files, 5285 passed, 18 skipped.pnpm verify: passed, 14 gates on Linux (test:windows, test:sandbox and test:release-package skipped as not applicable).dist) in node-pty against an OpenAI-compatible mock. The mock countsimage_urlparts and returns the reporter's HTTP 400invalid_request_errorbody above its limit:hi,/compactandcontinue. Every request carried at most 20 images and returned 200. The stored pre-compaction history still held all 31 images and no placeholders.mainrepro (31 images, failed/retry,/compactand restart) was resumed with the fix. A plainhisent one request with 20 images and 11 path placeholders and returned 200./compactand the next turn succeeded. The old stored error now renders with the new text.Reason: 400 Upstream [invalid_request_error] Too many images in request: 12 > 10and the/compactor/newguidance. The prompt was preserved./compactthen sent a checkpoint (12 images, 400) and the image-free fallback (0 images, 200); the next turn succeeded.max_images_per_request: 8inconfig.yamlwith a gateway limit of 10: every request carried 8 images, all 200 (re-run on the final head).perf:full. I did not add labels; maintainers may want to addperf:full.Risks / tradeoffs
max_images_per_request; with this PR they fail once with a clear message and/compactrecovers. Mistral's current docs also say the limit depends on the model; 8 is the documented API limit and a conservative choice.invalid_request_errortogether with an HTTP 400. A gateway that sends a transient failure as HTTP 400invalid_request_errorwill no longer be retried. Bare 400s and anything that looks transient keep the old behavior.main, so their images count toward the cap.Publication and contribution checks
release/public-source.json; new tests are declared intest/vitest-suites.jsonwhere applicable.docs/examples.mdhas no Chinese counterpart;README_ZH.mddoes not cover image configuration.)Maintainer handoff
Publication scope or license changes (if any): none.
Shared-source port: pending.