Skip to content

feat(compact): reactive compaction with direct input support - #726

Open
crstrn13 wants to merge 3 commits into
praxis-proxy:mainfrom
crstrn13:feat/compact-filter-issue-30
Open

feat(compact): reactive compaction with direct input support#726
crstrn13 wants to merge 3 commits into
praxis-proxy:mainfrom
crstrn13:feat/compact-filter-issue-30

Conversation

@crstrn13

@crstrn13 crstrn13 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Completes the remaining work items from #30:

  • Use previous_usage for token estimationshould_compact() checks the stored usage.total_tokens from the rehydrated response before falling back to local tiktoken counting
  • Configurable summary prefixsummary_prefix config option (default: [Previous conversation summary]) carried through to compaction items and both translation paths
  • Hide compaction items from input_items APInormalize_input_items() filters out {"type": "compaction"} items so clients never see internal state
  • Persist compaction as a stored response — after summarization, the compacted response is written back to the response store with the compaction item in its input
  • Explicit compact endpointPOST /v1/responses/{id}/compact triggers compaction on a previously stored response

Test plan

  • cargo test -p praxis-ai-apis — new unit tests for all 5 items
  • cargo test -p praxis-ai-filters — no regressions in filter tests
  • make lint passes
  • Manual test with example config against a real inference backend

@crstrn13 crstrn13 changed the title feat(compact): complete remaining issue #30 items feat(compact): reactive compaction with direct input support Aug 14, 2026
@crstrn13
crstrn13 marked this pull request as ready for review August 14, 2026 13:50
@crstrn13
crstrn13 requested review from a team and jland-redhat August 14, 2026 13:50
@crstrn13 crstrn13 self-assigned this Aug 14, 2026

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary: Completes five compaction features from #30: previous_usage token estimation fast-path, configurable summary_prefix, hiding compaction items from input_items, persisting compaction responses, and an explicit POST /v1/responses/compact endpoint.

Overall: Solid implementation with good integration tests covering both the rehydrated and direct-input paths. The summary prefix plumbing is clean and consistent across both translation paths. A few issues below around blocking in async context, stale docstrings, and missing unit test coverage for the new endpoint.

Severity Count
Large 3
Medium 3

Findings without inline placement

[Large] Stale docstrings — The struct-level doc comment on CompactFilter (~line 96 of mod.rs) says compaction "only applies to multi-turn requests where openai_responses_rehydrate has loaded stored conversation history". The generated docs page (docs/filters/openai_responses_compact.md, Configuration Notes section) says the same. Both are now incorrect — this PR adds a direct-input compaction path that runs without rehydration. Update both to match the module-level doc comment (lines 14-20), which was correctly updated.

[Large] Missing unit tests for explicit compact endpoint — The new pure/mostly-pure functions parse_compact_request_body, extract_stored_messages, is_explicit_compact_request, and ensure_compactable_state have no unit tests. Per project convention each testable function should have coverage. Suggested cases:

  • parse_compact_request_body: empty body, invalid JSON, missing response_id, valid request with optional fields
  • extract_stored_messages: empty array, non-array messages, valid array
  • is_explicit_compact_request: POST to correct path, GET to correct path, POST to wrong path
  • ensure_compactable_state: no ResponsesState, rehydrated state, non-rehydrated with compaction config, non-rehydrated without compaction config

req: &ExplicitCompactRequest,
) -> Result<ResponseRecord, FilterAction> {
let handle = tokio::runtime::Handle::current();
match tokio::task::block_in_place(|| handle.block_on(store.get_response(tenant_id, &req.response_id))) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Large] fetch_response_blocking uses block_in_place + block_on to call the async store.get_response, but its only caller (do_explicit_compact) is itself async. The same pattern appears in build_and_persist_compaction (the store.upsert_response call around line 607). Both block a tokio worker thread unnecessarily during potentially slow I/O (e.g., a PostgreSQL round-trip).

Make both functions async and .await the store calls directly. The persist_compaction_response function uses the same block_in_place pattern but its caller (apply_compaction) is not async, so block_in_place is correct there — no change needed for that one.

return Ok(FilterAction::Continue);
}
if is_explicit_compact_request(ctx) {
return self.handle_explicit_compact(ctx, _body).await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The on_request_body parameter is named _body (underscore prefix indicating unused), but it is now passed to handle_explicit_compact on this line. Rename the parameter from _body to body since it is actively used in the explicit compact path.

.get("response_id")
.and_then(Value::as_str)
.ok_or_else(|| reject_compact(400, "invalid_request_error", "missing required field: response_id"))?
.to_owned();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] response_id is not validated for emptiness. An empty string ("response_id": "") passes this check and produces a confusing 404 "response not found" instead of a clear 400 error. Add an empty check:

if response_id.is_empty() {
    return Err(reject_compact(400, "invalid_request_error", "response_id cannot be empty"));
}

get_token_count(&overhead_text, tiktoken_encoding).unwrap_or(0)
};
let conversation_text = build_conversation_text(&state.messages);
let token_count = get_token_count(&conversation_text, tiktoken_encoding)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The tiktoken fallback path here no longer accounts for instructions and tool-definition overhead — the old build_context_overhead_text was removed. When previous_usage.total_tokens is available it naturally includes all tokens, but the tiktoken path now only counts messages. For the direct-input path (which always hits tiktoken since there is no previous_usage), large system prompts and tool definitions will not count toward the threshold.

This may be intentional, but it is a behavioral change from the prior code. Either restore overhead counting for the tiktoken branch, or add a targeted test that documents the difference and asserts the intended behavior.

@crstrn13
crstrn13 force-pushed the feat/compact-filter-issue-30 branch 2 times, most recently from 43ac0b3 to b0046dd Compare August 18, 2026 06:57

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (Aug 18 commits)

Previous review had 6 findings. The new commits addressed 4 fully (async conversion of fetch_response/build_and_persist_compaction, _body rename, response_id emptiness validation, tiktoken overhead restoration). One partially addressed (unit tests added for parse_compact_request_body and extract_stored_messages, but is_explicit_compact_request and ensure_compactable_state still lack coverage). One unaddressed (stale docstrings).

One new finding below from the rewritten explicit compact endpoint.

Severity Count
Medium 1

Comment thread apis/src/openai/responses/compact/mod.rs

@aslakknutsen aslakknutsen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside the PR diff, but

fn should_init_store_for_request(ctx: &HttpFilterContext<'_>) -> bool {

"ResponseStoreFilter::should_init_store_for_request() returns false for POST /v1/responses/compact because the path is not is_responses_create and the classifier does not set has_previous_response_id for sub-resource routes. The store Arc is therefore never registered into ResponseStoreRegistry before CompactFilter::resolve_store_and_tenant() runs. On a freshly built pipeline (or after reload), the first explicit compact call fails with "response store not available" even when the backend is healthy. Extend should_init_store_for_request (or equivalent) to initialize the store for POST /v1/responses/compact, mirroring how rehydrate paths are handled."

// -----------------------------------------------------------------------------

/// Returns `true` when compaction should proceed.
fn ensure_compactable_state(ctx: &HttpFilterContext<'_>) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is untested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be added.

// -----------------------------------------------------------------------------

#[test]
fn compact_passthrough() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"AGENTS.md requires functional integration coverage for new capabilities in example configs."

No tests POST to /v1/responses/compact.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added.

Comment thread apis/src/openai/responses/compact/mod.rs Outdated

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review (re-review)

The Aug 20 commits fixed the response_object finding from the previous re-review (now includes model, created_at, and output).

One new finding from the direct-input compaction path.

Severity Count
Medium 1

state.persisted_messages = new_messages.clone();
let direct_input = !state.history_rehydrated;
let new_messages = if direct_input {
vec![compaction_item]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] In the direct-input reactive path, replace_messages drops the entire conversation — including the current user question — and replaces it with [compaction_item]. The compaction item translates to an assistant-role summary, so the backend receives a request with no user message.

Most Chat Completions backends reject requests without a user message (e.g. OpenAI returns "Messages must contain at least one user message"). Even Responses API backends would have no explicit question to answer — the model would generate a generic continuation of the summary rather than responding to the user's actual question.

The integration test compact_direct_input_compacts_full_conversation asserts this as correct (input.len() == 1), but it passes only because the mock backend returns a canned response regardless of input.

Suggested: either (a) skip reactive compaction in the direct-input path — the explicit POST /v1/responses/compact endpoint already covers the non-rehydrated use case — or (b) heuristically preserve the trailing user message(s) from state.input as the "current turn" so the backend has a question to answer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to take path a instead due to simplicity. If a user wants compact without rehydration, it can use the explicit POST /v1/responses/compact

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (Aug 24 commits)

The two new commits fix the summarization scope (exclude current input from summarization text in the rehydrated path), add is_compactable unit tests, add an explicit compact endpoint integration test, fix the stale docstrings on CompactFilter and the docs page, and add store initialization for the compact endpoint.

Previous findings addressed:

  • Stale docstrings: fixed (struct doc and docs page updated)
  • Missing is_compactable / ensure_compactable_state tests: fixed (extracted is_compactable and added 5 tests)

One new finding below.

Severity Count
Medium 1

Comment thread apis/src/openai/responses/store/filter.rs Outdated
@leseb

leseb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@crstrn13 please rebase

@crstrn13
crstrn13 force-pushed the feat/compact-filter-issue-30 branch from b9908e8 to e849dfe Compare September 7, 2026 10:06
@praxis-bot-app

praxis-bot-app Bot commented Sep 7, 2026

Copy link
Copy Markdown

Missing Signed-off-by: e849dfe. All commits require sign-off (via git commit --signoff).

@crstrn13
crstrn13 force-pushed the feat/compact-filter-issue-30 branch from e849dfe to d2de464 Compare September 7, 2026 10:08
…roxy#30)

Signed-off-by: Alexander Cristurean <acristur@redhat.com>
@crstrn13
crstrn13 force-pushed the feat/compact-filter-issue-30 branch from f6dc948 to 359559a Compare September 7, 2026 11:40

@leseb leseb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Explicit compact rejects conforming requests. parse_compact_request_body requires nonstandard response_id. The checked-in API contract requires model and supports input/previous_response_id. Standard {model,input} requests therefore return 400.

P1 — Response violates CompactResource. The response builder returns object: "response" without usage; the contract requires object: "response.compaction" and usage.

P2 — Fail-open becomes fail-closed. Explicit compaction converts the Ok(None) produced by on_failure: open into a hard-coded 502.

P2 — Reactive compaction creates unreachable rows. apply_compaction generates and persists a response ID, then discards it without exposing or linking it. Every compaction leaves an orphan record despite the normal response subsequently persisting the compacted history.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

5 participants