← Laragents 14 / 18

Privacy

Keep personal data out of what reaches the provider, and put it back on the way in.

'privacy' => [
    'enabled' => true,
    'except'  => ['person'],
    'keys'    => ['content', 'tool_calls.*.function.arguments'],
],

Off by default, because turning it on changes what the model sees and that is your decision, not the package's.

Two switches

Both have to say yes. The application-wide privacy.enabled, and the per-session anonymize_pii on top of it. The first is off by default; the second is on by default, so a product that redacts does it unless this user asked otherwise in this conversation.

$session->update(['anonymize_pii' => false]);

That is the shape a per-conversation toggle in your UI wants: a product decision at the top, a user decision underneath.

The order is the whole thing

Three steps, and each one fails quietly if you skip it:

  1. outbound() tokenises a copy. The stored conversation stays in the clear. It is the source of truth and what a human reads later, and nothing anonymised is ever persisted.
  2. inbound() restores the model's text before it is shown or saved.
  3. inboundToolCalls() restores the arguments before the tools run, so they query your database with real values.

Skip the third and a tool searches for "«AP_1»", finds nothing, and reports no error anywhere. The chat simply becomes useless in a way that looks like the tools being bad.

A fresh mapping per turn, deterministic, so the same person keeps the same marker within a turn and nothing has to survive between them.

The shipped implementation

Privacy\LaranonRedactor, over edulazaro/laranon. It throws at construction if laranon is not installed, and loudly on purpose: an application that believes it anonymises and does not is worse than one that never claimed to.

except: ['person'] leaves first names in the clear and tokenises surnames, national ids, phone numbers, emails and bank accounts. That is a deliberate compromise for Spanish, where dropping the given name costs the model the gender it needs to write a correct sentence, and a first name on its own identifies nobody.

Writing your own

Laragents::redactWith(MyRedactor::class);

Bind your own and laranon is never installed. That is why it is a contract and not one class: a law firm cares about surnames and national ids, a clinic about patient numbers, a payroll application about bank accounts and not names at all. No package can decide that for you.

interface Redactor
{
    public function beginTurn(): void;
    public function outbound(array $messages): array;
    public function inbound(string $text): string;
    public function inboundToolCalls(array $toolCalls): array;
}

All three callers, not just the chat

Three things in this package send a conversation to a model, and all three redact:

AgentLoop            the turn itself
HistoryCompressor    summarising the older turns
MemoryDistiller      extracting what is worth remembering

The last two matter more than they look, because they are the ones that are easy to forget. Summarising sends the whole transcript again, and the compression prompt asks the model to preserve names, figures and dates. Distilling sends the widest prompt in the package: the new turns, the summary of everything before them, and every memory already stored, in one call. A chat that redacted turn by turn and then handed the lot over the moment the history grew past its budget would be a promise broken rather than a promise never made.

Which is why the decision of whether a session gets redacted lives in Concerns\ResolvesRedactor and not inside the loop. It used to be private to AgentLoop, and the other two callers did not redact for the plainest reason there is: there was nowhere to get the answer from.

The distiller restores what comes back after decoding it, not before. Its reply is JSON, and putting a value with a quote in it back while the document is still text would break the document and lose the whole window's memories, silently. Nothing laranon detects can contain a quote, but the contract is public and yours might.

What it does not cover

Capabilities. A provider-side search never passes through the redactor, because the call never reaches your server. A redacted conversation with web search on will search for the marker.

Anything you call yourself. The redactor is reached through the session, so a model call your application makes on its own goes out in the clear. Use the same trait if you want the two switches to mean the same thing there.