NIE EXPERTS SPAIN

WhatsApp Agent — Hardening Audit

Runtime audit of the “Milene” agent · Node Agency · July 2026

Verdict. This is not an amateur build. The architecture is clean, payment handling is safe, escalation is safe, and the test suite (379 tests) is green. The “agent keeps tripping” problem traces to a small, precise set of runtime defects — four critical, six high — and every one is fixable inside the go-live week. This document is what we found, how we fix each, and the exact order we do it in.

4
Critical — customer-visible, fix first
6
High — reliability & correctness
7
Medium — quality & hardening
5
Confirmed strengths — keep as-is

Method: six parallel deep-dive reviews across the runtime — concurrency, roster freshness, retrieval, output safety, data contract, test coverage — cross-checked against a direct read of the adapter and a full run of the existing 379-test suite. Every finding below is anchored to a specific line of code.

How this maps to the handover

Category (handover)Status after code audit
A · Runtime infrastructureThe bulk of the open work. C1–C4 and H1–H4 live here.
B · Content & retrievalReframed (next section). H6 (wrong-tier price) and the output leak (C1) are the real B-class items.
C · Data & wiringConfirmed resolved backend-side. One adapter boundary bug remains: H5.

What we verified — and where the picture changed

Reading the code closely confirmed most of the handover and refined three points. These matter because they move budget away from a layer that would not have shifted the outcome.

Prior understandingWhat the code shows
A per-conversation “serialization lock” was added.There is no lock. Ordering is an optimistic generation-counter — correct only within one process, which is why the single-worker rule matters (C4).
“Automatic context compaction” was added.It does not exist. The live path sends full, untruncated history every turn — the direct cause of context loss on long chats (H1).
The top fix is a RAG router + relevance floor (~2–3 days).The production agent does no retrieval at all — that engine is wired only into an internal CLI. A RAG fix would have near-zero live effect. The real driver is prompt bloat and the missing trivial-turn fast-path — cheaper, in a different place.

Net: the same budget buys a more durable result by targeting the runtime and the output gate — not the retriever.

Critical findings

C1CriticalCat A / B
No output safety net — and the code itself emits a banned phrase
ImpactThe agent can leak internal vocabulary or reveal it is a bot. The deny-list described in the handover was never built. Worse, the sanitizer manufactures one of the exact forbidden strings — “continuiamo da qui in chat” — the leak seen three times in live tests. The only current safeguard is a non-deterministic LLM review that is not even told to look for leaks.
Evidenceprompting.py:412 synthesises the banned phrase; sanitize_answer_output checks none of the 13 strings; no canonical “sei un bot?” reply exists.
FixA deterministic pre-send deny-list gate (accent/spacing-normalised) at the single point every reply passes through — a hit forces silence + silent escalation. Remove the phrase-injection; add a fixed bot-probe reply.
C2CriticalCat A
Stale roster — the “not in the context I have” bug, explained
ImpactThe customer record is fetched when the message arrives, then the agent waits out its human-like delay and answers from the old copy. If a call is booked or a date is written during that window, the agent denies it exists. Quiet-hours deferral stretches that window to hours — the exact mechanism behind the “five hours later” incident.
EvidenceFetch at web.py:3055; the delayed send reuses the captured object and skips re-lookup at web.py:2575. History is refreshed at send time; the roster is not.
FixRe-fetch the roster inside the send step, after the delay, so data-touching answers always use fresh data.
C3CriticalCat A / C
Access-level fallback fails open (safety regression)
ImpactWhen the backend omits access_level, the adapter guesses — and can only ever guess “full”. A customer who should be blocked, closed or escalate then gets normal answers and payment links instead of silence or a human handoff. It fires silently — there is no logging in that file.
Evidencerepository.py:207–216 derives only full/upsell_only and ignores status; no logger present.
FixFail closed (treat missing as escalate), log every time it fires, and share one canonical access-level map so backend and adapter cannot drift.
C4CriticalCat A
Duplicate replies the moment there is more than one process
ImpactEvery anti-duplicate, ordering and abort guarantee lives in one process’s memory. The “single worker / single replica” rule exists only in the README. One routine ops change — scaling replicas or adding a worker — silently brings back duplicate messages and double escalations, with no error.
EvidenceDedup, generation and pending-timer state are all process-local; nothing in code enforces the constraint.
FixImmediate: refuse to boot with more than one process. Durable: move shared state to Redis — the post-go-live scale item, named openly below.

High findings

H1HighCat A
Context rot on long conversations → mid-transaction reset
ImpactThe live path sends the entire chat history, untruncated, every turn. Real customers accumulate weeks of chat; the prompt grows until the model loses the thread and re-greets (“Ciao! dimmi pure”) or re-asks what was just answered.
Evidenceweb.py:3229 bypasses the turn cap; no summarisation anywhere.
FixKeep the last N turns verbatim plus a rolling summary of older ones; cap by tokens, not turn count.
H2HighCat A
History-builder deletes the very confirmations it needs
ImpactA filter drops earlier user messages whose text matches the current one. Two “sì” replies erase the record of the first, so the agent re-asks for data already given — a precise match to the reported reset.
Evidencechatwoot_history.py:77–81 excludes by content instead of by message id.
FixExclude the current message by its event id, never by matching text.
H3HighCat A
Bubbles cross when a new message arrives mid-reply
ImpactRemaining bubbles of an old reply are stopped, but already-sent bubbles are never retracted and an in-flight send cannot be cancelled — so an orphaned bubble from the previous turn lands next to the new answer.
EvidenceSends run in a thread pool; the abort check sits only between bubbles (web.py:3260–3288).
FixRe-check the generation atomically before the model call and every send; track sent bubbles per turn. Fully resolved by the shared-state work (with C4).
H4HighCat A
Pending replies are lost on every deploy or restart
ImpactA message still inside its send-delay window when the app restarts is dropped silently — no reply, no trace. Deploys during business hours quietly eat customer answers.
EvidenceThe delay is a volatile in-memory timer; shutdown cancels it (web.py:2297–2301).
FixPersist delayed sends as durable jobs that resume after restart (part of the shared-state work).
H5HighCat C boundary
Shared-number (capofamiglia) members lose per-person data
ImpactFor a family on one number, everyone except the “current” person is shown a reduced record — missing the per-person priority flag, the consultation flag and the guide-shared signal. For person B the agent literally cannot see their state and answers wrong. The group version of “the data was there, the agent didn’t see it.”
Evidenceprompting.py:162–189 omits ~10 fields for non-current members.
FixRender every active household member with the full field set (or at minimum the per-person flags).
H6HighCat B
Wrong-tier price is structurally invited (the “24€ to a Digital” error)
ImpactBoth prices (24€ Expert / 29€ Digital) sit side-by-side in the prompt, separated only by a single “tier” line. One attribution slip and a Digital customer hears the Expert price. A distractor problem, not a retrieval problem.
EvidencePrice matrix co-resident in the static instructions; no code-side tier scoping.
FixCompute the one correct price for this practice in code and inject a single price line — the model sees one number, not a menu.

Medium findings

Quality and hardening — folded into the same waves, none block go-live on their own.

IDFindingFix
M1The “second-pass review” is an LLM editor, not a guard; content-safety failures are invisible (only crashes escalate).Pair it with the deterministic gate from C1.
M2The phone-wording normaliser can corrupt a correct consultation-call reference into the banned phrase.Widen its guard; removed with C1.
M3No fast-path for trivial turns — “ciao”/“ok” triggers two model calls over the full prompt (cost, latency, dilution).Add a lightweight greeting/ack router.
M4The count disambiguation signal is validated then ignored; a related quantity is used instead.Thread count through, or document intent.
M5The stored schema can hold only 2 of the 6 access-level values — a latent trap.Widen to all six; unify with C3.
M6Tool appointment_date reaches the system of record as unvalidated free text.Validate ISO date before the call.
M7An empty history fetch can produce a cold greeting mid-conversation.Fall back to stored history; fail loud.

What already works — and we won’t touch

Important for scoping: several things the tests worried about are genuinely solid. We are not re-building these.

Remediation & order

We work reproduction-first: for each defect we write the failing test that proves it, then fix until green. “Ready” is only said with a green suite and this report attached — the discipline Niek proposed. Nearly all critical fixes are deterministic and need no live model, so they are fast and low-risk.

1
First · deterministic · highest customer impact

Stop the bleeding

  • C1 — output deny-list gate + remove the phrase-injection + canonical bot reply.
  • C2 — re-fetch the roster at send time. Ends the “not in my context” errors.
  • C3 — access-level fails closed + logging.
  • C4 — refuse to boot with more than one process.
C1C2C3C4M2
2
Next · behavioural · behind the harness

Stop the tripping

  • H2 then H1 — fix history exclusion, then add real compaction (H1 last, with the most tests around it).
  • H5 — restore per-person data for family members.
  • H6 + M3 — inject one tier-correct price; add the trivial-turn fast-path.
H1H2H5H6M3M4M6
3
Then · the deliverable that lets us say “green”

Prove it, repeatably

  • The customer-simulating eval harness: fake rosters for the key cases, driven through the real agent, with hard checks — correct tier price present, deny-list absent, expected silence on escalation — plus an LLM judge for tone.
  • Author the scenario matrix (the 19 “Part 6” cases → 60–100). From here on “ready” = suite green + report attached.
eval-harnessscenario-matrixllm-judge
Beyond the week · named openly, not hidden

Durability for scale

  • Durable delayed-send + shared dedup/ordering state (Redis) is the real fix for C4, H3, H4. The single-process guard buys a safe go-live; this buys scale without re-introducing duplicates.

What we need from you

Definition of done. Every fix ships with the failing test that first reproduced it, turned green. No behaviour change to the three instruction files without Christian — they stay the single source of truth. The word “ready” is used only once the full suite is green and this report is attached.

NIE EXPERTS SPAIN · WHATSAPP AGENT HARDENING AUDIT · NODE AGENCY · JULY 2026 · complements the backend AUDIT-FINDINGS
NIE EXPERTS SPAIN

Agente WhatsApp — Audit di Hardening

Audit del runtime dell’agente “Milene” · Node Agency · Luglio 2026

Verdetto. Non è un lavoro amatoriale. L’architettura è pulita, la gestione dei pagamenti è sicura, l’escalation è sicura e la suite di test (379 test) è verde. Il problema del “l’agente si perde” si riduce a un insieme piccolo e preciso di difetti di runtime — quattro critici, sei alti — e ognuno è risolvibile entro la settimana del go-live. Questo documento è ciò che abbiamo trovato, come correggiamo ciascun punto e in quale ordine lo facciamo.

4
Critici — visibili al cliente, per primi
6
Alti — affidabilità e correttezza
7
Medi — qualità e hardening
5
Punti di forza confermati — da mantenere

Metodo: sei revisioni approfondite in parallelo sul runtime — concorrenza, freschezza del roster, retrieval, sicurezza dell’output, contratto dati, copertura dei test — verificate contro una lettura diretta dell’adapter e un’esecuzione completa della suite esistente da 379 test. Ogni riscontro è ancorato a una riga di codice precisa.

Mappatura sulla tassonomia dell’handover

Categoria (handover)Stato dopo l’audit del codice
A · Infrastruttura di runtimeLa gran parte del lavoro aperto. Qui vivono C1–C4 e H1–H4.
B · Contenuti & retrievalRiformulata (sezione seguente). I veri item di classe B sono H6 (prezzo del tier sbagliato) e il leak in output (C1).
C · Dati & wiringConfermata risolta lato backend. Resta un bug di confine nell’adapter: H5.

Cosa abbiamo verificato — e dove il quadro è cambiato

La lettura attenta del codice ha confermato gran parte dell’handover e ha precisato tre punti. Contano perché spostano il budget lontano da un layer che non avrebbe cambiato il risultato.

Convinzione precedenteCosa mostra il codice
È stato aggiunto un “lock di serializzazione” per conversazione.Non c’è nessun lock. L’ordinamento è un contatore di generazione ottimistico — corretto solo dentro un singolo processo, ed è per questo che la regola “un solo worker” conta (C4).
È stata aggiunta una “compattazione automatica del contesto”.Non esiste. Il percorso live invia tutta la cronologia, senza troncamento, a ogni turno — causa diretta della perdita di contesto nelle chat lunghe (H1).
La fix a più alto impatto è un router RAG + soglia di rilevanza (~2–3 giorni).L’agente in produzione non fa alcun retrieval — quel motore è collegato solo a una CLI interna. Una fix RAG avrebbe effetto quasi nullo dal vivo. Il vero driver è il gonfiore del prompt e l’assenza di una corsia rapida per i turni banali — più economico, in un altro punto.

In sintesi: lo stesso budget produce un risultato più duraturo agendo sul runtime e sul filtro di output — non sul retriever.

Riscontri critici

C1CriticoCat A / B
Nessuna rete di sicurezza sull’output — ed è il codice stesso a produrre una frase vietata
ImpattoL’agente può lasciar trapelare vocabolario interno o rivelare di essere un bot. La deny-list descritta nell’handover non è mai stata costruita. Peggio: il sanitizer fabbrica una delle stringhe vietate esatte — “continuiamo da qui in chat” — il leak visto tre volte nei test live. L’unica salvaguardia attuale è una revisione LLM non deterministica, a cui non è nemmeno chiesto di cercare i leak.
Proveprompting.py:412 sintetizza la frase vietata; sanitize_answer_output non controlla nessuna delle 13 stringhe; non esiste una risposta canonica a “sei un bot?”.
FixUn filtro deny-list deterministico pre-invio (normalizzato per accenti/spazi) nel singolo punto attraversato da ogni risposta — una corrispondenza forza silenzio + escalation silenziosa. Rimuovere l’iniezione della frase; aggiungere una risposta fissa a “sei un bot?”.
C2CriticoCat A
Roster stantìo — il bug del “non risulta nel contesto che ho”, spiegato
ImpattoLa scheda cliente viene letta all’arrivo del messaggio, poi l’agente attende il suo ritardo “umano” e risponde dalla copia vecchia. Se durante quella finestra viene prenotata una chiamata o scritta una data, l’agente ne nega l’esistenza. Il rinvio in fascia silenziosa allunga quella finestra a ore — il meccanismo esatto dietro l’episodio delle “cinque ore dopo”.
ProveLettura a web.py:3055; l’invio ritardato riusa l’oggetto catturato e salta la ri-lettura a web.py:2575. La cronologia è aggiornata all’invio; il roster no.
FixRileggere il roster dentro lo step di invio, dopo il ritardo, così le risposte che toccano i dati usano sempre dati freschi.
C3CriticoCat A / C
Il fallback dell’access-level fallisce “aperto” (regressione di sicurezza)
ImpattoQuando il backend omette access_level, l’adapter indovina — e può indovinare solo “full”. Un cliente che dovrebbe essere blocked, closed o escalate riceve invece risposte normali e link di pagamento anziché silenzio o handoff a un umano. Scatta in silenzio — in quel file non c’è alcun logging.
Proverepository.py:207–216 deriva solo full/upsell_only e ignora lo status; nessun logger presente.
FixFallire “chiuso” (trattare l’assenza come escalate), loggare ogni volta che scatta, e condividere un’unica mappa canonica di access-level così backend e adapter non possono divergere.
C4CriticoCat A
Risposte duplicate nel momento in cui i processi sono più di uno
ImpattoOgni garanzia anti-duplicato, di ordinamento e di abort vive nella memoria di un solo processo. La regola “un worker / una replica” esiste solo nel README. Un normale cambio operativo — scalare le repliche o aggiungere un worker — riporta in silenzio messaggi duplicati e doppie escalation, senza errori.
ProveDeduplica, generazione e timer pendenti sono tutti locali al processo; nulla nel codice impone il vincolo.
FixImmediato: rifiutare l’avvio con più di un processo. Duraturo: spostare lo stato condiviso su Redis — l’item di scala post-go-live, dichiarato apertamente qui sotto.

Riscontri alti

H1AltoCat A
Degrado del contesto nelle chat lunghe → reset a metà transazione
ImpattoIl percorso live invia tutta la cronologia, senza troncamento, a ogni turno. I clienti reali accumulano settimane di chat; il prompt cresce finché il modello perde il filo e ri-saluta (“Ciao! dimmi pure”) o richiede ciò che ha appena ricevuto.
Proveweb.py:3229 aggira il limite di turni; nessuna sintesi da nessuna parte.
FixTenere gli ultimi N turni testuali più un riassunto progressivo dei precedenti; limitare per token, non per numero di turni.
H2AltoCat A
Il costruttore della cronologia cancella proprio le conferme che gli servono
ImpattoUn filtro scarta i messaggi utente precedenti il cui testo coincide con quello attuale. Due “sì” cancellano la traccia del primo, così l’agente richiede dati già forniti — coincide esattamente con il reset segnalato.
Provechatwoot_history.py:77–81 esclude per contenuto invece che per id del messaggio.
FixEscludere il messaggio corrente tramite il suo event id, mai per corrispondenza di testo.
H3AltoCat A
Le bolle si incrociano quando arriva un nuovo messaggio a metà risposta
ImpattoLe bolle residue di una vecchia risposta vengono fermate, ma quelle già inviate non vengono mai ritirate e un invio in corso non è annullabile — così una bolla orfana del turno precedente atterra accanto alla nuova risposta.
ProveGli invii girano in un thread pool; il controllo di abort è solo fra una bolla e l’altra (web.py:3260–3288).
FixRicontrollare la generazione in modo atomico prima della chiamata al modello e prima di ogni invio; tracciare le bolle inviate per turno. Risolto pienamente dal lavoro sullo stato condiviso (con C4).
H4AltoCat A
Le risposte in attesa vanno perse a ogni deploy o riavvio
ImpattoUn messaggio ancora dentro la finestra di ritardo quando l’app si riavvia viene scartato in silenzio — nessuna risposta, nessuna traccia. I deploy in orario lavorativo mangiano silenziosamente risposte ai clienti.
ProveIl ritardo è un timer volatile in memoria; lo shutdown lo annulla (web.py:2297–2301).
FixPersistere gli invii ritardati come job durevoli che riprendono dopo il riavvio (parte del lavoro sullo stato condiviso).
H5AltoConfine Cat C
I membri del numero condiviso (capofamiglia) perdono i dati per-persona
ImpattoPer una famiglia su un solo numero, a tutti tranne la persona “corrente” viene mostrata una scheda ridotta — senza il flag prioritario per-persona, il flag consulenza e il segnale guida-condivisa. Per la persona B l’agente letteralmente non vede lo stato e risponde male. È la versione di gruppo di “il dato c’era, l’agente non lo vedeva”.
Proveprompting.py:162–189 omette ~10 campi per i membri non correnti.
FixRendere ogni membro attivo del nucleo con il set completo di campi (o almeno i flag per-persona).
H6AltoCat B
Il prezzo del tier sbagliato è invitato dalla struttura (l’errore “24€ a un Digital”)
ImpattoEntrambi i prezzi (24€ Expert / 29€ Digital) stanno affiancati nel prompt, separati solo da una riga “tier”. Un’attribuzione sbagliata e un cliente Digital sente il prezzo Expert. Un problema di distrattore, non di retrieval.
ProveMatrice prezzi co-residente nelle istruzioni statiche; nessuno scoping per tier lato codice.
FixCalcolare in codice l’unico prezzo corretto per questa pratica e iniettare una singola riga di prezzo — il modello vede un numero, non un menù.

Riscontri medi

Qualità e hardening — inseriti nelle stesse ondate, nessuno blocca il go-live da solo.

IDRiscontroFix
M1La “revisione di seconda passata” è un editor LLM, non un guardiano; i fallimenti di sicurezza sui contenuti sono invisibili (solo i crash fanno escalation).Abbinarla al filtro deterministico di C1.
M2Il normalizzatore del linguaggio telefonico può corrompere un riferimento corretto alla chiamata di consulenza nella frase vietata.Allargare il controllo; rimosso con C1.
M3Nessuna corsia rapida per i turni banali — “ciao”/“ok” attiva due chiamate al modello sull’intero prompt (costo, latenza, diluizione).Aggiungere un router leggero saluti/conferme.
M4Il segnale di disambiguazione count è validato e poi ignorato; si usa una quantità correlata.Portare count fino in fondo, o documentare l’intento.
M5Lo schema salvato può contenere solo 2 dei 6 valori di access-level — una trappola latente.Allargare a tutti e sei; unificare con C3.
M6Il campo tool appointment_date arriva al sistema di record come testo libero non validato.Validare la data ISO prima della chiamata.
M7Una lettura di cronologia vuota può produrre un saluto “a freddo” a metà conversazione.Ripiegare sulla cronologia salvata; fallire in modo rumoroso.

Cosa già funziona — e non tocchiamo

Importante per lo scoping: diverse cose che i test temevano sono davvero solide. Non le ricostruiamo.

Rimedi & ordine

Lavoriamo prima-riproduzione: per ogni difetto scriviamo il test che lo riproduce, poi correggiamo fino al verde. “Pronto” si dice solo con la suite verde e questo report allegato — la disciplina proposta da Niek. Quasi tutte le fix critiche sono deterministiche e non richiedono il modello live, quindi sono rapide e a basso rischio.

1
Per prima · deterministica · massimo impatto sul cliente

Fermare l’emorragia

  • C1 — filtro deny-list in output + rimozione dell’iniezione della frase + risposta canonica al bot.
  • C2 — rilettura del roster all’invio. Chiude gli errori del “non risulta nel contesto”.
  • C3 — access-level fallisce chiuso + logging.
  • C4 — rifiuto di avvio con più di un processo.
C1C2C3C4M2
2
Poi · comportamentale · dietro l’harness

Fermare gli inciampi

  • H2 poi H1 — correggere l’esclusione della cronologia, poi aggiungere la vera compattazione (H1 per ultima, con la massima copertura di test).
  • H5 — ripristinare i dati per-persona per i membri della famiglia.
  • H6 + M3 — iniettare un unico prezzo corretto per tier; aggiungere la corsia rapida per i turni banali.
H1H2H5H6M3M4M6
3
Quindi · il deliverable che ci fa dire “verde”

Dimostrarlo, in modo ripetibile

  • L’harness che simula il cliente: roster finti per i casi chiave, guidati attraverso l’agente reale, con controlli duri — prezzo del tier corretto presente, deny-list assente, silenzio atteso sull’escalation — più un giudice LLM per il tono.
  • Scrivere la matrice degli scenari (i 19 casi della “Parte 6” → 60–100). Da qui in avanti “pronto” = suite verde + report allegato.
eval-harnessmatrice-scenarigiudice-llm
Oltre la settimana · dichiarato apertamente, non nascosto

Durabilità per la scala

  • Invio ritardato durevole + stato condiviso di deduplica/ordinamento (Redis) è la vera fix per C4, H3, H4. Il guard a processo singolo compra un go-live sicuro; questo compra la scala senza reintrodurre duplicati.

Cosa ci serve da voi

Definizione di “fatto”. Ogni fix arriva con il test che l’ha riprodotta per primo, portato al verde. Nessun cambio di comportamento ai tre file di istruzioni senza Christian — restano l’unica fonte di verità. La parola “pronto” si usa solo quando l’intera suite è verde e questo report è allegato.

NIE EXPERTS SPAIN · AUDIT DI HARDENING AGENTE WHATSAPP · NODE AGENCY · LUGLIO 2026 · complementare all’AUDIT-FINDINGS del backend