Repository navigation
feat(server): add Responses conversation compaction (phase A) - #2520
Conversation
Long /v1/responses sessions fail once history exceeds the context window. This adds the model-free core of conversation compaction: a versioned base64url capsule ({"v":1,"summary","retained"}), replay of a `compaction` input item, and selection that keeps system/developer messages, the latest user message and the newest whole turns within keep_tokens, splitting only before a user turn or after a completed tool group so a call never loses its output.
ResponseInputItem gains a Compaction variant and ResponseOutputItem a Compaction item. responses_request_to_chat expands the newest capsule after prior and current items are concatenated, so previous_response_id and conversation chains replay it too. A capsule that does not decode, has v != 1, exceeds the body limit, or nests another capsule is a 400 invalid_request_error.
compact_history drives selection and one summary pass through a CompactionBackend trait and refuses, without changing context, a history over one window, a split-free history, an empty summary, or a summary that does not shorten the conversation.
Validated with cargo test --lib server::responses_compaction.
Refs #2397
Mount POST /v1/responses/compact and /responses/compact. The handler expands any capsule already in input, then runs compact_history with a backend that counts through the /v1/responses/input_tokens render and count path and summarizes with one non-streaming generation on the provider call /v1/responses uses (temperature 0, thinking off, no tools, no reasoning-echo record, nothing stored).
The answer is {id, object: "response.compaction", output: [instructions, retained items, one compaction item], usage} with usage covering the summary pass. max_output_tokens defaults to 1024 and is capped at 16384; keep_tokens defaults to 1/16 of the per-slot context, at most 8192. Overflow, a summary prompt that does not fit, and a summary that is not shorter answer 400 with type exceed_context_size_error (the server's existing overflow class) and code context_length_exceeded. stream, previous_response_id and conversation are refused.
Route tests against the scripted provider cover the compact response shape, replay of [...output, new message] on /v1/responses and through a previous_response_id chain, and the error paths. The no-chat-model 501 parity test now includes both compact routes.
Refs #2397
docs/responses-api.md gains a Conversation compaction section (request, response, selection, summarization, failure table, capsule format and why it is not encrypted, replay), lists the compaction input item, and notes that chained history expands capsules. The endpoint table now lists /v1/responses/compact and /v1/responses/input_tokens, which was already mounted but still listed as not mounted, and the gaps list drops the stale compaction and token-count entries in favor of the still-missing context_management auto-compaction. server-features.md and the CHANGELOG are updated. Refs #2397
The compact handler parsed its one compact-only field by deserializing a clone of the whole JSON body, which doubles memory for a large history or inline images. It now reads keep_tokens from the body directly. Route tests unchanged and passing. Refs #2397
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Verification
The error mapping is 400 |
Store the newest capsule and its continuation in response snapshots so repeated previous_response_id calls retain the summary. Keep conversation appends limited to the current request and deduplicate exact resent system/developer instructions during capsule replay. Reject compaction output that exceeds the server's replay body limit, using the existing allocation-free JSON byte counter. Add multi-hop, conversation-append, instruction and byte-boundary regressions, and document retained-media overhead and keep_tokens accounting. Workspace clippy and formatting pass; focused and workspace library tests are running. Refs #2520, #2397.
Replay kept every system/developer message before the capsule, so a client that resent the compact output over a previous_response_id or conversation history rendered its instructions twice. resolve_compaction now keeps the first of exactly equal system/developer messages (same role and content), as upstream does. Each compaction count re-rendered the whole candidate, which re-read every image URL on each render. A per-compaction MediaCache now reads each non-data image URL once and inlines it as a data URI for the rendered copies (counts and the summary prompt); the compact output and capsule keep the URLs the client sent. Media bodies are still counted on the worker per count. Tests: a unit and a route test for the resent system message over a previous_response_id chain, and a route test whose loopback origin sees one fetch for a two-image compaction (five without the cache). Refs #2397
The chaining note now says a response whose history held a capsule stores it with its tail, so later previous_response_id hops keep replaying the summary. The selection paragraph says keep_tokens also counts system/developer messages and instructions, and that only the newest turn is kept when they alone exceed it. The compaction section notes that the retained turns travel twice in [...output, new] and that clients may send only the instructions plus the compaction item, records the per-compaction image cache, and lists the last-tool-group and strict-alternation limits. Refs #2397
|
Reviewed #2520 against phase A of #2397. Fixed summary loss after repeated previous_response_id chaining by storing a self-contained capsule continuation, while conversation transcripts append only the new request. Exact resent system/developer messages are deduplicated without conflating roles or names. Added a replay-body size guard so retained media cannot produce a successful but immediately unusable compact result; it uses the existing allocation-free JSON byte counter. Regression coverage includes a third chained request, conversation append counts, instruction deduplication and the exact byte-limit boundary. Focused Responses/compaction/types tests: 213 passed. Workspace/all-target clippy and fmt pass. The wider CPU-only workspace run exposes existing GPU-dependent NVFP4, catalog and paged-decode assumptions; no new compaction failure appeared. Real-model Qwen/Metal inference was not available on this host. Security: version, encoded-size and nested-capsule rejection remain enforced, and storage byte budgets still apply to the expanded response snapshots. Reliability: chain context persists without duplicating conversation history. Remaining performance limit: multimodal candidate counts still re-render/refetch through the existing input-token path; version-1 retained data still appears in both output and capsule. The body guard prevents oversize success but does not remove that overhead. Automatic compaction remains phase B, so #2397 stays open. |
Run translation, media-capability, tool and image-count admission before the new per-compaction cache downloads any URL. Otherwise text-only models and over-limit image histories fetch remote content before their eventual rejection. Add zero-fetch regressions for both cases. Use hash sets for instruction and URL deduplication so attacker-controlled item counts do not cause quadratic scans. Keep the concurrent image-cache improvements and document failed-read behavior. Refs #2520, #2397.
|
Re-reviewed the concurrent image-cache commits and hardened their admission order in 95baf78: translation, media capability, tool guards and image-count limits now run before cache prefetch. New route regressions verify zero downloads for unsupported images and excessive image counts. Instruction and URL deduplication now use hash sets instead of quadratic scans. Successful remote images are fetched once per compaction; failed reads retain the renderer's error path. Latest validation: 217 focused Responses/compaction/types tests pass, including the image-cache and zero-fetch regressions; workspace/all-target clippy and fmt pass. The complete CPU-only workspace library run finished with the root at 9,659 passed/7 GPU-dependent failures, and mlxcel-core aborted in its pre-existing new_stream_on_gpu test because this build has no GPU backend. The pin, surgery and XLA suites passed (26/185/364). This full run is not a green GPU validation. Final merge is waiting for the latest CI head. |
Security and performance review (head 95baf78)Both HIGH findings from this pass are already fixed by 95baf78, which landed while the review ran. No extra commits were needed. On 95baf78, Fixed (HIGH)
Verified, no action
Remaining
|
|
Hardening fixes and focused validation are complete; merge is blocked by the shared GB10 runner environment. PR #2514 job https://github.com/lablup/mlxcel/actions/runs/39560610832/job/119090751455 passed its build and real-router browser stages, then failed before Activity performance measurement: PR #2520's remaining self-hosted checks use the same runner and are still pending. No performance threshold was relaxed and neither PR was merged. After the runner workload is resolved, rerun the failed checks, finish the pending checks, verify the latest heads, and merge only after validation passes. Local validation and hardware limitations are recorded in the preceding hardening comments. |
Phase B of #2397, after phase A (#2520): automatic compaction on `/v1/responses` through `context_management`, the output cap at the remaining context, and the streamed `mlx.compaction.*` events. ## Behavior - `context_management: [{"type":"compaction","compact_threshold":N}]` on `/v1/responses` and `/responses`. The rendered input (chained `previous_response_id`/`conversation` history plus `input`, capsules expanded) is counted on the `/v1/responses/input_tokens` path. At the threshold, phase A's `compact_history` runs with the same `ResponsesCompactionBackend` and default `keep_tokens`, and generation runs on instructions + summary + retained turns. Summary budget is upstream's automatic one: context/16, clamped to 128..1024. - `output` starts with the `compaction` item. `usage` counts the final generation only, as upstream does. - Stored shape: the response stores the instructions as its input and the capsule as `output[0]`. The next `previous_response_id` hop therefore assembles instructions + capsule + tail, replays the summary, and compacts again only at the threshold. A `conversation` transcript appends the new input plus output. - Streaming: `mlx.compaction.started` (`input_tokens`) and `mlx.compaction.completed` (`input_tokens_before`/`input_tokens_after`), in upstream's payload shape plus `sequence_number`, come right after `response.in_progress`. The capsule follows as `output_item.added`/`done` at `output_index` 0, and generated items start at 1. `response.completed` lists the same capsule once, including the tool-streamed path from #2504. - Without the field, or with `[]`/`null`: no extra count and no cap, and the response is unchanged. ## Decisions to review - **Threshold is `>=`**, as in upstream and OpenAI's "reaches", although the issue says "exceeds". Compaction also fires below the threshold when the input leaves no room for output. - **Output cap and `incomplete` apply only to `context_management` requests.** Today every `length` stop reports `completed`, and with `--context-shift` generation may run past the window. A general cap would change both. `incomplete_details.reason` is `max_output_tokens`, the OpenAI and upstream value, not a literal `length`. The streamed reply then ends with `response.incomplete`. As in OpenAI's shape, `completed_at` is null and the cut-off message item is `status: "incomplete"`, in the final object and in its streamed `output_item.done`. A limited reasoning budget above the capped limit is lowered to one token below it. A server with no free slot returns 503 before any count. - **Failures are HTTP errors before any output, on both arms.** Compaction runs before the SSE stream opens, because `ResponseStreamSender` sends from the blocking task. A streamed overflow therefore returns 400 `exceed_context_size_error`/`context_length_exceeded`, as a stream admission overflow does today. The cost is that the two events carry counts, not live progress. - **No safe split** (one turn, or one unfinished tool group): the request proceeds uncompacted while the input fits, as upstream does. Otherwise it returns `context_length_exceeded`. Every other compaction failure keeps phase A's error. - A malformed `context_management` returns 400 `invalid_request_error`: not an array, more than one entry (duplicates included), a type other than `compaction`, or a threshold that is not an integer from 1 to 100000000. ## Test plan - [x] `cargo test --profile test-fast -p mlxcel --lib` filters: `responses_auto_compact` (15, including the reasoning-budget clamp, the busy-server 503, the `conversation` path and a streamed request without `context_management` that keeps its pre-PR event sequence), `responses_compact` (28), `server::routes::` (508), `server::responses` (74), `server::types` (107, before the review fixes), `streaming_responses` (4) - [x] `cargo clippy -p mlxcel --lib --tests -- -D warnings`, `cargo fmt --check` - [ ] Known limit: a streamed request that compacts runs the summary pass on the request task, and a client disconnect does not cancel it. - [ ] Unverified (no Metal or CUDA here): a real model such as `mlx-community/Qwen3-30B-A3B-4bit`. Media and video prompts with `context_management` have no test: each count takes a worker slot, and video substitution runs again after compaction. Out of scope, per the issue: Chat Completions and `/v1/messages` auto-compaction, the Codex `compaction_trigger` input, and multi-pass summarization. Follow-up: #2523. Closes #2397
Phase A of #2397: explicit conversation compaction for the Responses API. Phase B (automatic
context_managementcompaction on/v1/responses, the output cap at remaining context, and the streamedmlx.compaction.started/mlx.compaction.completedevents) follows in a separate PR that closes the issue. The split keeps phase B separate; phase A is still +2337 lines, of which about 980 are tests and 130 docs.Behavior
POST /v1/responses/compactand/responses/compacttake{model, input, instructions?, tools?, max_output_tokens?, keep_tokens?}and return{id, object: "response.compaction", output: [instructions, retained items, one compaction item], usage}.usagecounts the one summary pass. Nothing is stored.keep_tokens(default 1/16 of the per-slot context, at most 8192). Splits fall only before a user turn or after the output that closes the last open call (mlx-vlm#2501), so a history ending in an unfinished tool group keeps it whole./v1/responsesuses (temperature 0, thinking off, no tools, no reasoning-echo record). Counts use the/v1/responses/input_tokensrender and count path.max_output_tokensdefaults to 1024, at most 16384./v1/responsesand/v1/responses/input_tokensaccept acompactioninput item. Everything before the newest one except system/developer messages is replaced by the summary (assistant message) and the retained items. It runs after chained history is assembled; a response whose history held a capsule stores the instructions, capsule and tail as its input, so multi-hopprevious_response_idchains keep the summary, whileconversationappends only the new turn. Exactly equal system/developer messages are kept once (hash-based, linear).ResponseOutputItem::Compactionmaps back to the input item for phase B.{"v":1,"summary","retained"}, not encrypted by design (documented). Bad base64/JSON,v != 1, a nested capsule, or a capsule over the body limit is 400invalid_request_error.Decisions
Known limits, documented: the most recent completed tool group can be summarized away (upstream keeps it), and the summary replays as an assistant turn right after the instructions, which strict-alternation templates may reject.
Overflow errors (history over one window, summary prompt that does not fit, summary not shorter) are HTTP 400 with
type: exceed_context_size_error(the server's existing overflow class) andcode: context_length_exceeded(OpenAI's). Context is never replaced on failure.An empty summary or one that stops at
max_output_tokensis 502server_error, as upstream does.stream,previous_response_idandconversationon the compact route are 400.docs/responses-api.mdalso stops listing/v1/responses/input_tokensas not mounted; it has been mounted since feat(server): add b10621 tokenization, template, infill, and native embedding utilities #1442.Changes during review
previous_response_idchains keep the summary,conversationappends only the new turn, and a replay-size guard refuses unusable compact output (d71e90a).Test plan
cargo test --profile test-fast -p mlxcel --libwith filtersresponses_compact(28: compaction unit and compact route tests),server::routes::(493, includes 501 parity),server::responses(74),server::types(107)cargo clippy -p mlxcel --lib --tests -- -D warnings,cargo fmt --checkmlx-community/Qwen3-30B-A3B-4bitwith the OpenAI SDKclient.responses.compact(...)thenclient.responses.create(input=[*compacted.output, ...])Refs #2397