feat(voice): opt-in PII redaction at the LLM egress boundary#6342
feat(voice): opt-in PII redaction at the LLM egress boundary#6342mcdgavin wants to merge 1 commit into
Conversation
Adds a provider-agnostic redaction contract (Redactor protocol, RedactionOptions, RedactionSink) and an opt-in `redaction=` knob on AgentSession. When enabled, a redacted copy of the chat context is what crosses the LLM boundary - applied before the llm_node call so overridden llm_node implementations are covered, and on every realtime update_chat_ctx push - while the in-memory history keeps the raw values. Fails closed: if the redactor raises or exceeds its timeout, the affected text is replaced with a redaction marker instead of passing through raw. Ships NoopRedactor (the implicit default; zero behavior change when the option is unset) and RegexRedactor, a best-effort reference detector for structured PII (Luhn-validated card numbers, format-checked US SSNs, mod-97-validated IBANs) that is documented as pattern matching, not a compliance guarantee. Phase 1 of the redaction interception contract discussed in livekit#6050 and livekit#6204: contract + LLM egress sink. Transcript and telemetry sinks are declared in RedactionSink but not wired yet.
| for item in chat_ctx.items: | ||
| if item.type == "message": | ||
| ctx = RedactionContext(sink=sink, role=item.role) | ||
| new_content = [ | ||
| await _redact_text(c, opts, ctx) if isinstance(c, str) else c for c in item.content | ||
| ] | ||
| if new_content != item.content: | ||
| item = item.model_copy(update={"content": new_content}) | ||
| elif item.type == "function_call_output": | ||
| ctx = RedactionContext(sink=sink, role="tool") | ||
| new_output = await _redact_text(item.output, opts, ctx) | ||
| if new_output != item.output: | ||
| item = item.model_copy(update={"output": new_output}) | ||
|
|
||
| items.append(item) |
There was a problem hiding this comment.
π FunctionCall arguments field is not redacted
The redact_chat_ctx function handles message content and function_call_output output, but skips function_call items entirely. The arguments field of FunctionCall (livekit-agents/livekit/agents/llm/chat_context.py:348) contains a JSON string that could include user-provided data (e.g., if the LLM echoes PII into tool call arguments). This is likely intentional since function call arguments are LLM-generated (and thus already seen by the LLM), but it's worth documenting the boundary explicitly β especially for the TELEMETRY and TRANSCRIPT sinks where the concern is data leaving the agent process rather than data reaching the LLM.
Was this helpful? React with π or π to provide feedback.
What
Adds an opt-in, provider-agnostic PII redaction contract to
livekit-agentscore, and wires it at the first and most important egress: the chat context sent to the LLM.This is Phase 1 of the redaction interception contract proposed in this RFC, which unifies #6204 (in-flight STTβLLM redaction) and #6050 (at-rest transcript redaction). It is deliberately the smallest reviewable slice: one protocol, one knob, one sink.
Closes #6204.
Why
Voice agents routinely process regulated data (PCI/HIPAA/GDPR). Today the only native option is Deepgram's vendor-side
redactβ provider-specific, STT-only. Userland can hookon_user_turn_completedor overridellm_node, but each project re-derives the wiring and typically misses the follow-up LLM calls in multi-step tool loops. A core-owned seam applies redaction consistently; detection stays pluggable.Usage
When
redactionis unset (the default), nothing changes β the feature is entirely opt-in.Design
session.historykeeps raw values, so a compliant local tool (e.g. a PCI-scoped payment API) can still receive the real card number while the third-party LLM never sees it.AgentActivitybeforeperform_llm_inferencehands the context tollm_nodeβ so it covers overriddenllm_nodeimplementations (a hook inside the default node would be silently bypassed) and re-runs on every step of a multi-step tool loop. The four realtimeupdate_chat_ctxpushes are wired through the same helper, covering the text seam that exists even for speech-to-speech models.[REDACTED]β raw PII is never silently emitted.RegexRedactoris honestly scoped: Luhn-validated card numbers, format-checked US SSNs, mod-97-validated IBANs. It's best-effort pattern matching, not a compliance guarantee, and says so in its docstring β free-form PII (names, addresses) is left to pluggable backends (Presidio, piiguard, vendor-side redaction) that implement the sameRedactorprotocol.Testing
tests/test_redaction.py): validator accept/reject cases, idempotency, fail-closed on raising and hanging redactors, entities carrying spans but never raw values.AgentSessionpipeline: a sentinel card number never reaches a capturing fake LLM while history retains it raw β including with an overriddenllm_node(the case a naive hook placement would fail), plus a no-redaction control proving the sentinel otherwise flows, and a stub realtime session asserting context pushes arrive redacted.make checkgreen (ruff, strict mypy); full--unitsuite passing.Known gaps (documented, not hidden)
llm_node: the history-summarization helper (chat_context.py), the AMD classifier, and the evals judge. Mechanical follow-ups once the contract lands.Roadmap
Phase 2 (transcript sink at the session-report egress, resolves #6050) and Phase 3 (telemetry sink) are implemented on stacked branches and will be PR'd separately once this lands.