Symptom
An agent repeats the same wrong behaviour across new sessions, even after the prompt was fixed. Examples:
- It keeps telling users a service "is decommissioned" because it saved that from one bad ticket.
- It skips an approval step because it "remembers" that a user is pre-approved.
- One user's preference ("always reply in French", "my manager is X") shows up for another user.
- After a prompt injection, it keeps following an instruction that now lives in memory instead of the prompt.
Long-term memory is a write path from model output to future prompts. A bad entry acts like a persistent prompt injection, and reverting the prompt won't remove it.
Step 0: assumed schema
The SQL below assumes a table like this. Map the columns to your own store.
CREATE TABLE agent_memory (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
user_id text,
scope text NOT NULL, -- 'user' | 'tenant' | 'global'
content text NOT NULL,
embedding vector(1536) NOT NULL,
source_session text,
source_kind text, -- 'user_message' | 'tool_output' | 'model_inference'
prompt_version text,
model text,
status text NOT NULL DEFAULT 'active', -- 'active' | 'quarantined' | 'deleted'
created_at timestamptz NOT NULL DEFAULT now()
);
If you don't have source_kind, prompt_version or status, the incident is harder. Step 8 adds them.
Step 1: stop new bad writes
Turn off memory writes first, and leave reads on for now. Use whatever switch you have: a feature flag, an environment variable, or the memory-write tool removed from the agent's tool list.
-- If the agent writes through its own role, a hard stop at the database:
REVOKE INSERT, UPDATE ON agent_memory FROM agent_writer;
Warning
Don't truncate the table or drop the index as a first move. You lose the evidence you need to find the blast radius, and every user loses legitimate memory. Quarantine is reversible; delete is not.
If the bad memory is causing harmful actions right now, also stop memory reads for the affected scope. For global or tenant scope, this may mean running the agent with memory disabled until Step 5 is done.
Step 2: find the seed entries
Start from a known-bad conversation or the text the agent repeats.
SELECT id, tenant_id, user_id, scope, source_kind, source_session, created_at, left(content, 120)
FROM agent_memory
WHERE status = 'active'
AND content ILIKE '%decommission%'
AND tenant_id = 'acme'
ORDER BY created_at DESC;
Write the confirmed bad IDs into a working table so every later query is reproducible:
CREATE TABLE mem_incident_seed (id bigint PRIMARY KEY);
INSERT INTO mem_incident_seed VALUES (48211), (48230);
Step 3: widen the blast radius
Bad memories cluster. Search four ways and union the results.
Semantic neighbours of the seeds (cosine distance; check the operator matches your index's opclass):
CREATE TABLE mem_incident_candidates AS
SELECT DISTINCT m.id, 'similar'::text AS why
FROM mem_incident_seed s
JOIN agent_memory seed ON seed.id = s.id
JOIN LATERAL (
SELECT id FROM agent_memory m
WHERE m.status = 'active' AND m.tenant_id = seed.tenant_id
ORDER BY m.embedding <=> seed.embedding
LIMIT 50
) m ON true;
Same source sessions:
INSERT INTO mem_incident_candidates
SELECT m.id, 'same_session' FROM agent_memory m
WHERE m.source_session IN (SELECT a.source_session FROM agent_memory a JOIN mem_incident_seed s USING (id));
Same faulty release. If a prompt version or model caused the bad writes:
INSERT INTO mem_incident_candidates
SELECT id, 'bad_release' FROM agent_memory
WHERE prompt_version = 'triage-v14' AND status = 'active';
Scope leaks. User-level facts written at tenant or global scope:
INSERT INTO mem_incident_candidates
SELECT id, 'scope_leak' FROM agent_memory
WHERE scope IN ('tenant', 'global') AND source_kind IN ('user_message', 'tool_output')
AND created_at > now() - interval '30 days';
Count by reason and scope before you act:
SELECT why, m.scope, count(*) FROM mem_incident_candidates c
JOIN agent_memory m USING (id) GROUP BY 1, 2 ORDER BY 3 DESC;
| Pattern | Likely cause |
|---|---|
Many similar hits across many users | Agent generalised one bad fact into many memories |
Concentrated in one prompt_version | A release changed what the agent saves |
tool_output sources with instructions in the text | Indirect prompt injection via a document, ticket or web page |
scope_leak rows | Write path doesn't enforce scope; a privacy issue, not just quality |
Step 4: review a sample
Similarity search over-collects. Have a person check 30-50 candidates, or run an LLM judge with a strict rubric and spot-check it. Remove false positives from the candidate table before you quarantine.
Step 5: quarantine
BEGIN;
UPDATE agent_memory SET status = 'quarantined'
WHERE id IN (SELECT id FROM mem_incident_candidates) AND status = 'active';
-- check the row count against Step 3 before committing
COMMIT;
Make sure every read path filters on status = 'active'. If retrieval uses an HNSW index without that filter, quarantined rows still come back. Check the query the agent actually runs:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM agent_memory
WHERE tenant_id = 'acme' AND status = 'active'
ORDER BY embedding <=> (SELECT embedding FROM agent_memory WHERE id = 48211)
LIMIT 10;
On pgvector 0.8+, set hnsw.iterative_scan if filters leave too few results.
Step 6: verify
- Replay the conversations that showed the problem, with memory reads on. The bad behaviour should be gone.
- Run your memory recall test set: questions whose answers depend on legitimate memories. Recall should not drop. If it did, you over-quarantined.
- Confirm no quarantined ID shows up in retrieval traces for an hour of live traffic.
Then turn memory writes back on, behind the Step 8 controls.
Step 7: delete and notify
After a review window agreed with the data owner, hard-delete quarantined rows. Scope leaks containing personal data may need a privacy notification. Follow your data protection process, don't decide it in the incident channel.
DELETE FROM agent_memory WHERE status = 'quarantined' AND id IN (SELECT id FROM mem_incident_candidates);
VACUUM (ANALYZE) agent_memory;
Caution
Memory text may also exist in traces, caches and backups. Purge or expire those copies too, or the deleted fact can come back through a restore.
Step 8: prevent it next time
| Control | How |
|---|---|
| Provenance on every row | source_kind, source_session, prompt_version, model are mandatory |
| Write classification | Only save facts the user stated about themselves; never save instructions or text from tool outputs as memory |
| Scope enforced in code | The write tool takes scope from the authenticated context, not from model arguments |
| Validation before write | A cheap classifier or rules reject imperative text ("always", "ignore", "you must") and secrets |
| TTL by kind | Inferred memories expire (e.g. 30-90 days) unless reconfirmed |
| User visibility | Users can list and delete what the agent remembers about them |
| Release gate | A recall and contamination test set runs on every prompt or model change |
Keep the seed and candidate tables from this incident as regression data for the write classifier.