Skip to content

LCORE-4593: redact the query, replayed turns and summaries of compacted-mode requests - #2997

Open
max-svistunov wants to merge 3 commits into
lightspeed-core:mainfrom
max-svistunov:lcore-4593-pii-redaction-compacted-mode
Open

max-svistunov wants to merge 3 commits into
lightspeed-core:mainfrom
max-svistunov:lcore-4593-pii-redaction-compacted-mode

Conversation

@max-svistunov

Copy link
Copy Markdown
Contributor

Description

LCORE-4593. With a redaction shield and compaction enabled, the query of a compacted turn reached the model and the conversation store as typed, on /v1/query, /v1/streaming_query and A2A. Later turns replayed and summarized it that way. The caller did not see it, because the answer is still redacted on its way out. Compaction is off by default.

Cause. The redaction shield is an agent capability that rewrites the prompt in pydantic-ai's messages. In compacted mode the model input is the explicit list apply_compaction builds (summaries, recent turns, new query), and the stored turn comes from CompactionResult.original_input. Both used the query as it arrived. The summarizer is a direct client call, so it got the stored turns as they were.

Fix.

  • request_redactor() (src/utils/shields.py) turns the redaction shields a request selects into one function, or None if there are no rules.
  • apply_compaction / apply_compaction_blocking take it as redact and apply it to the new query, the replayed user turns and summaries, and the user turns sent to the summarizer. Replayed turns are redacted only after the summary boundary is found, because the kept turns are found by identity; a test pins that.
  • /v1/query and /v1/streaming_query pass the request's shield_ids; A2A uses every configured shield.
  • docs/user_doc/shields_guide.md says what compacted mode redacts and what it does not.

Without a redaction shield selected, redact is None and requests are built and stored as on main. Requests that are not compacted are not changed.

Size: +535/-4 in three commits (source 105, tests 414, docs 16).

Not covered.

  • Model answers and system/developer messages are stored, replayed and summarized as they are, in both modes.
  • Stored text is not rewritten. Turns stored unredacted before this fix are redacted each time they are sent, but GET /v1/conversations/{id} returns them as stored.
  • The recursive re-summarization sends stored summaries as they are.
  • /v1/responses gets no redact, because its redaction shield blocks instead of rewriting.
  • Not about compaction: topic summary (LCORE-4598); transcript, conversation cache, OTel span, RAG lookup, A2A log, interrupted streams (LCORE-4607).

Side effects. In a compacted turn, question validity and Granite Guardian now judge the redacted query (read in the code, not tested). An unknown shield_ids name is now rejected before compaction instead of after it. A broad rule also rewrites model-written text in replayed summaries, and its replacement must not match a rule itself (documented).

Related work. Built on current main, which already has #2796, #2867 and #2868. #2866 (LCORE-3789) does not conflict with this PR. The pydantic-ai compaction (LCORE-4691) replaces these call sites and has to carry the fix over.

Type of change

  • Refactor
  • New feature
  • Bug fix
  • CVE fix
  • Optimization
  • Documentation Update
  • Configuration Update
  • Bump-up service version
  • Bump-up dependent library [pyproject.toml + uv.lock]
  • Bump-up dependent library [requirements.*.txt for Konflux]
  • Bump-up library or tool used for development (does not change the final image)
  • CI configuration change
  • Konflux configuration change
  • Unit tests improvement
  • Integration tests improvement
  • End to end tests improvement
  • Benchmarks improvement

Tools used to create PR

Identify any AI code assistants used in this PR (for transparency and review context)

  • Assisted-by: Claude Opus 4.8
  • Generated by: Claude Opus 4.8

Related Tickets & Documents

  • Related Issue # LCORE-4593
  • Closes # LCORE-4593

Checklist before requesting a review

  • I have performed a self-review of my code.
  • PR has passed all pre-merge test jobs.
  • If it is a core feature, I have added thorough tests.

Testing

  1. Reproduce and check the fix on a running service. Start library mode with openai/gpt-4o-mini, aggressive compaction (context_windows: 2000, threshold_ratio: 0.1, token_floor: 100, buffer_turns: 1), a redaction shield (e-mail -> [REDACTED_EMAIL], \b\d{3}-\d{2}-\d{4}\b -> [REDACTED_NUMBER]), and a recording proxy in front of the provider. Send three turns with PII; turn 3 is the first compacted one. Expected: the provider request and the stored item are redacted on every turn. Recorded on /v1/query (/v1/streaming_query and A2A message/send gave the same result):
turn 3, compacted
  main    sent   user: ...Mail the report to bob@example.org and quote reference 987-65-4321.
  main    stored user: ...Mail the report to bob@example.org and quote reference 987-65-4321.
  branch  sent   user: ...Mail the report to [REDACTED_EMAIL] and quote reference [REDACTED_NUMBER].
  branch  stored user: ...Mail the report to [REDACTED_EMAIL] and quote reference [REDACTED_NUMBER].

With "shield_ids": [] the query is sent and stored as typed.

  1. Continue a conversation stored by main. Expected: the unredacted turn 3 is sent redacted to the chat request (turn 4) and the summarizer (turn 5), and the stored item stays as it was. Observed as expected; on main both requests carry the raw values.

The live runs were made before the rebase onto current main. Since then the A2A call site moved into _compact_a2a_request (#2796), and the compaction code takes its context window from resolve_context_window (#2993). The integration tests cover all three entry points on the rebased code. Not run live: server mode, /v1/responses, A2A streaming, attachments/RAG. No e2e config has both shields and compaction.

  1. Run the tests and linters. With the redaction switched off at the three call sites, the compacted-turn integration tests fail.
uv run pytest tests/unit/utils/test_shields.py tests/unit/utils/test_conversation_compaction_redaction.py -q  # 29 passed
uv run pytest tests/integration/endpoints/test_compaction_redaction.py -q                                    # 7 passed
uv run pytest tests/unit -q                                                          # 3939 passed, 1 skipped
uv run pytest tests/integration --ignore=tests/integration/container_lifecycle -q   # 377 passed
ruff, black, pydocstyle; pylint and pyright on the changed files                     # all pass

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 401a7b5 and ad38391.

Files selected for processing: 11
  • docs/user_doc/shields_guide.md
  • src/app/endpoints/a2a.py
  • src/app/endpoints/query.py
  • src/app/endpoints/streaming_query.py
  • src/utils/conversation_compaction.py
  • src/utils/shields.py
  • src/utils/types.py
  • tests/integration/endpoints/test_compaction_redaction.py
  • tests/unit/app/endpoints/test_streaming_query.py
  • tests/unit/utils/test_conversation_compaction_redaction.py
  • tests/unit/utils/test_shields.py
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The redaction shield rewrites the user prompt as an agent capability, so
it only reaches text that goes through the agent. Compacted mode builds
the model input and stores the turn outside the agent and needs the same
rules as a plain function.

request_redactor() returns that function for a request: the rules of the
redaction shields the request selects, applied in configuration order,
which is the order the capabilities run in. It returns None when the
selection holds no redaction rule and raises the 404 of
get_shields_for_request for an unknown shield name. TextRedactor is the
type of the returned function.

Nothing calls it yet, so no behaviour changes.

Unit tests cover the None cases, the default selection, the rule order
and the 404.
With a redaction shield and compaction enabled, the query of a compacted
turn reached the model and the conversation store unredacted. The
redaction capability rewrites pydantic-ai's messages, but in compacted
mode the request input is the explicit list built by apply_compaction,
sent as an extra_body override that replaces the message-derived input,
and the endpoints store CompactionResult.original_input themselves. Both
were built from the raw query.

apply_compaction and apply_compaction_blocking take the redaction of the
request (request_redactor) and apply it to the new query, so the explicit
input and original_input carry the redacted text. /v1/query,
/v1/streaming_query and A2A pass it; /v1/responses does not, its
redaction shield blocks instead of rewriting. A request that is not
compacted is returned untouched, and without a selected redaction shield
nothing changes.

Two side effects. On /v1/query and /v1/streaming_query the agent prompt
of a compacted turn is taken from the explicit input, so it is now the
redacted query and the other shields judge the text the model gets. An
unknown shield name fails before compaction instead of after it.

Tested at unit level (explicit input, the override built from it and
original_input, with the shield selected and deselected) and with
integration tests on the three entry points: model input and stored turn
of a compacted conversation, per-request shield_ids, and the unchanged
request of a conversation that is not compacted. The integration tests
of the compacted turn fail without the fix.
Conversations hold user turns that were stored unredacted: compacted
turns written before the previous commit, and turns of requests that
deselected the shield. Compacted mode replayed them as stored, handed
them to the summarizer as stored, and replayed the summaries made from
them.

apply_compaction now applies the redaction of the request to the user
turns and the summaries it puts into the explicit input, and to the user
turns it hands to the summarizer. Summaries are covered because they are
sent as user messages. Assistant turns are left as stored, as the
redaction capability leaves them. Nothing stored is rewritten, and the
recursive fold still sends the stored summaries.

The replayed turns are redacted only after the summary boundary is
taken: the first kept turn is found among the stored items by identity,
and a redacted copy would move the boundary past a turn that is still
verbatim.

Unit tests cover the explicit input (summary and user turns redacted,
assistant and system turns untouched), the items handed to the
summarizer, and the boundary on a conversation with an older marker
among the kept turns. The shields guide says what is redacted in
compacted mode and what is not.
@max-svistunov
max-svistunov force-pushed the lcore-4593-pii-redaction-compacted-mode branch from 66cc834 to ad38391 Compare October 29, 2026 17:33

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant