Truncate oversized run-event payloads at the durable append boundary - #2774
Conversation
Oversized tool results (e.g. a large web_fetch) exceeded the API's 256 KB per-event limit, were rejected, retried indefinitely, and silently dropped — leaving the run's durable event log with a hole while the run still reported completed and the UI waited on a tool result that never arrived. - Normalize every batch at the appendConversationRunEvents chokepoint, so no producer path (hosted lifecycle, child-run progress, direct enqueue) can bypass the per-event size limit. Idempotent on already-normalized events. - Make run-event normalization total and measured in JSON-serialized bytes — the same unit the API enforces. Previously truncation measured raw UTF-8 bytes, so escape-heavy content slipped past the limit; it also never re-checked the total and ignored the redundant tool `input` field. Now it drops `input`, truncates via a whole-event binary search, and a backstop guarantees every normalized event fits regardless of type, field, or escaping. - Classify a payload-too-large rejection as permanent so the durable mirror stops with an ERROR log instead of retry-storming the API (~34 retries in the observed incident).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0728263f71
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| type, | ||
| truncated: true, | ||
| note: "Conversation-run event payload was summarized to stay within storage limits.", | ||
| summary: summarizeValue(rest), |
There was a problem hiding this comment.
Preserve custom event fields during summarization
When a CUSTOM event exceeds the limit, this moves name and value under summary instead of preserving the top-level contract: the encoder emits CUSTOM as top-level name/value (src/agent/conversation/run-events.ts), and AG-UI validation requires payload.name/payload.value (src/chat/ag-ui.ts). Large data-* events persisted through this path therefore cannot be replayed or validated as custom events; preserve the required top-level fields and summarize only the oversized value.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR hardens durable conversation-run event mirroring against oversized per-event payloads by enforcing normalization at the durable append boundary, guaranteeing every event is under the API byte limit and preventing retry storms on permanent “payload too large” rejections.
Changes:
- Enforce run-event normalization in
appendConversationRunEvents(append chokepoint) so no producer path can bypass size limits. - Make normalization byte-limit-correct (JSON-serialized bytes, redundant tool
inputdropped on oversize paths, whole-event truncation/backstop guard). - Classify payload-too-large append failures as permanent and stop mirroring instead of repeatedly retrying.
- Add targeted tests covering escape-heavy content, non-
contentoversize, split delta parts, chokepoint clamping, and permanent-stop routing. - Bump package version to
0.1.1009.
Verification
- Not run here (no command execution available in this review environment).
- Safest next step: run the repo’s unit tests and check on the changed modules:
deno check src/agent/conversation/{durable.ts,run-event-normalization.ts,durable-append-errors.ts}deno test --no-check --allow-all --parallel src/agent/conversation/
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/version-constant.ts | Bumps exported VERSION to align with release. |
| deno.json | Bumps package version to 0.1.1009. |
| src/agent/conversation/run-mirror.ts | Adds payload_too_large as a durable-mirror stop reason. |
| src/agent/conversation/run-event-normalization.ts | Ensures normalization is JSON-byte-correct and enforces a final per-event size invariant. |
| src/agent/conversation/run-event-normalization.test.ts | Adds tests for escape-heavy, non-content, generic, and split-delta normalization staying within the limit. |
| src/agent/conversation/run-chunk-mirror.ts | Logs and handles the new payload_too_large disable reason distinctly. |
| src/agent/conversation/durable.ts | Normalizes events at append chokepoint and treats payload-too-large append failures as permanent stops. |
| src/agent/conversation/durable.test.ts | Adds coverage for chokepoint clamping and permanent-stop behavior on oversize rejections. |
| src/agent/conversation/durable-contracts.ts | Extends controller contract union to include payload_too_large. |
| src/agent/conversation/durable-append-errors.ts | Adds classifier for payload-too-large append errors. |
| src/agent/conversation/durable-append-errors.test.ts | Tests payload-too-large classification and ensures it is not treated as ignorable. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Critical review — 92/100 ✅Reviewed the full diff against the source, ran the touched unit suites, and typechecked the changed files. Verification
What's strong
Minor gaps (non-blocking, worth a follow-up)
The "why runtime-only" reasoning (API already flips orphaned tool calls terminal at finalize) is sound and correctly avoids over-engineering a now-near-impossible state. Merging at 92. Don't forget the noted follow-up: bump the |
Addressed review finding #1 (escape-heavy split data loss) — commit c70f5a6The split path ( Fix: split by measuring the whole serialized event per candidate prefix (binary search on the same JSON-byte unit Tests (TDD, RED→GREEN): added lossless-reconstruction assertions for escape-heavy Verification: full The other two review notes were informational, not defects: the ~2× serialization at the chokepoint is inherent to a size-enforcing guard (fast-path early-return keeps it cheap), and the 240KB-vs-256KB margin is intentional envelope headroom. |
What & why
An agent run could silently drop an oversized tool result and still report
completed, leaving the durable event log with a hole and the UI waiting on a tool result that never arrived (observed on staging as a run stuck on "Fetching from the web… / Continuing…"). Tracked in veryfront/veryfront-studio#5552.Root cause: a large tool result (e.g. a big
web_fetch) serializes past the API's 256 KB per-event limit on the durable append endpoint. A truncation layer already existed (run-event-normalization.ts) but was bypassable (only wired into some producer paths, not the append chokepoint) and not size-correct (it truncated by raw UTF-8 bytes while the API measures JSON-escaped bytes, never re-checked the total, and ignored the redundantinputfield). The rejected event was then retried ~34× with no give-up, and completion is decided on a separate lifecycle path, so the run reported success with the result missing.Changes
appendConversationRunEventsnow normalizes every batch immediately before POST, so no producer path (hosted lifecycle, child-run progress, or a direct enqueue) can bypass it. Idempotent on already-normalized events.input, truncate via a whole-event binary search, and add a backstop that guarantees every normalized event fits — regardless of event type, which field holds the bulk, or JSON escaping.Why runtime-only (no API change)
Layers above make an oversized event unreachable at the source, and the API already degrades gracefully:
normalizeTerminalToolCallStatesflips any orphaned (result-less) tool call to a terminal state at finalize, so there is no permanent-pending. A schema flag / compensating-event layer / append-validation reorder would be over-engineering a now-near-impossible state — deliberately omitted.Testing
content/ generic / split-delta all stay within the byte limit; the append chokepoint clamps a raw oversized event; the oversize classifier + failure-handler routing to a permanent stop.src/agent/conversationunit suite green (18 files / 129 steps);deno checkclean on all changed files.Follow-up
After this ships to npm, bump the
veryfrontdependency inveryfront-agentto pick it up on the hosted runtime.