Agent Memory User Guide
Activity signals are renewed by the managed request owner while work is running, including long model/tool calls. Renewal uses the latest status and original request identity every signal TTL / 3. All exits join renewal before bounded removal; a suspended request is no longer advertised as active. Process loss still relies on signal TTL. This is distinct from advisory investigation claims, which are not renewed. Direct hook users retain their own lifecycle ownership. Submission effects are not proof of backend health; inspect actual signals and provider diagnostics when troubleshooting.
Recent episodic recall selects the newest visible events by their event timestamps, rather than when they arrived in storage. Global events participate in that same ordering; global visibility does not reserve space on a limited page. Older events remain retained according to the backend's retention policy. This ordering fix does not by itself establish complete history or safe incremental cache advancement during concurrent writes or backend failures.
Hey there! This guide shows you how to give your TruvaG3 agents shared memory — so they can see what other agents have done, avoid duplicating work, and learn from past experience. If you've ever had two agents investigate the same incident independently (wasting tokens and time), this is the fix.
Working Example
Everything in this guide comes from a fully working, production-tested implementation:
- Agent:
examples/devops-chat-agent/- Memory wiring:
examples/devops-chat-agent/main.go(search forNewSharedBackends)- Main entry point:
examples/devops-chat-agent/main.goWe recommend having the example open alongside this guide.
Table of Contents
- What is Agent Memory?
- The Problem It Solves
- How It Works
- Quick Start
- Step 1: Environment Setup
- Step 2: Add Memory to Your Agent
- Customising Memory Behaviour
- What the LLM Sees
- Activity Compaction: Scaling to Thousands of Events
- Real-Time Coordination: What Are Other Agents Doing Right Now?
- Knowledge Extraction: Learning From Experience
- Long-Term Knowledge Retention: The Reflection Job
- Tuning for Your Use Case
- Observability: Knowing What Memory Is Doing
- Troubleshooting
- User Memory: Per-User Personalization
- How Shared Memory and User Memory Compare
- How User Memory Works
- User Memory Environment Setup
- Adding User Memory to Your Agent
- What the LLM Sees (User Profile)
- Fact Reconciliation: How Contradictions Are Handled
- Customising User Memory Behaviour
- GDPR: Right to Erasure
- User Memory Observability
- User Memory Troubleshooting
- See Also
What is Agent Memory?
In TruvaG3, each ProcessRequest call starts with a blank slate. The AI has no idea what happened on the previous request — or what other agents did. Agent memory fixes this by giving agents a shared history.
Think of it like a team shared notebook:
- After each request, the agent writes down what it did (episodic events)
- Before the next request, agents read the notebook to see what happened recently
- If two agents reach for the same problem, the second one sees "already being handled" and backs off
Without memory: With memory:
Agent A: "Pod X is OOMKilled" Agent A: "Pod X is OOMKilled"
→ Investigates (5 steps) → Investigates (5 steps)
→ Creates JIRA ticket → Creates JIRA DEVOPS-42
→ Sends Slack notification → Writes event to memory
Agent B: "Pod X is slow" Agent B: "Pod X is slow"
→ Investigates (5 steps) AGAIN → Reads memory: "Agent A restarted
→ Creates DUPLICATE ticket Pod X and created DEVOPS-42"
→ Sends DUPLICATE notification → 2-step plan: check status,
add comment to DEVOPS-42
The Problem It Solves
Without memory, agents have three concrete failures:
-
Duplicated work: Two agents investigate the same root cause independently, wasting LLM tokens and tool invocations.
-
Missing context: Agent B makes a decision without knowing Agent A's recent action on the same entity, leading to conflicting remediations.
-
No institutional learning: Each agent starts from zero. If the system resolved the same class of issue 50 times, the 51st still runs the full investigation.
Agent memory solves all three with five components:
| Component | What It Does | When It Runs |
|---|---|---|
| Episodic Memory | Records what each agent did (events) | After execution |
| Investigation Coordinator | Prevents two agents from investigating the same entity | Before planning |
| Activity Compaction | Compresses 200 events into a ~500-token digest via LLM | Before planning |
| Activity Coordination | Shows what agents are working on right now (real-time signals) | Before planning |
| Knowledge Extraction | Learns reusable patterns from execution results | After synthesis |
You don't need all five. Start with episodic memory + coordination (Phase 1), and add the rest as needed.
How It Works
Memory plugs into the orchestration pipeline via pipeline hooks — middleware that runs at specific stages of request processing. You register the hooks once, and they run automatically on every request.
User Request
│
▼
┌──────────────────────────────────────────────────────────┐
│ 1. ActivityAnnouncementHook (BeforePlanning) │
│ Announces "I'm working on X" + shows other agents' │
│ signals → injected into <agent_coordination> │
├──────────────────────────────────────────────────────────┤
│ 2. MemoryEnrichmentHook (BeforePlanning) │
│ Queries episodic events + compacts into digest + │
│ searches knowledge → injected into <agent_memory> │
└──────────────────────────────────────────────────────────┘
│
▼ LLM sees memory context when generating its plan
│
Planning → Execution → Synthesis
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────────────────┐
│ 3. MemoryRecordHook│ │ 4. KnowledgeExtractionHook │
│ (AfterExecution)│ │ (AfterSynthesis) │
│ │ │ │
│ Records events for │ │ Extracts reusable knowledge from │
│ each execution step│ │ the response → stores in vector DB│
├────────────────────┤ ├──────────────────────────────────┤
│ 5. ActivityCleanup │ │ (async, fail-open) │
│ (AfterSynthesis)│ │ │
│ Removes "working" │ │ │
│ signal │ │ │
└────────────────────┘ └──────────────────────────────────┘
The key insight: hooks are fail-open. If Redis is down, if the LLM compaction fails, if vector search is unavailable — the pipeline continues without memory. It just won't have the extra context.
Quick Start
Prerequisites
- A working TruvaG3 agent with an orchestrator
- Redis running (memory uses Redis for event storage and coordination)
- An AI provider API key
Optional (for knowledge extraction):
- Qdrant vector DB (for semantic knowledge search)
- An embedding endpoint (Ollama or OpenAI)
Step 1: Environment Setup
Add these to your .env file:
# Required: Redis for episodic memory + coordination
REDIS_URL=redis://redis:6379
# Domain scoping — agents in the same domain share memory
TRUVAG3_AGENT_DOMAIN=infrastructure
# Optional: Model for LLM summarization/compaction (default: agent's model)
# Use a fast, cheap model — these calls don't need reasoning power
TRUVAG3_SHARED_MEMORY_SUMMARIZER_MODEL=fast
# Optional: Vector DB for knowledge extraction (Phase 2)
# Without these, Phase 1 (events + coordination) still works
TRUVAG3_VECTOR_DB_URL=qdrant.truvag3-examples:6334
TRUVAG3_EMBEDDING_BASE_URL=http://host.docker.internal:11434/v1
TRUVAG3_EMBEDDING_MODEL=nomic-embed-text
What's a domain? Agents in the same domain see each other's events. A DevOps agent and an event-driven alert agent should share the same domain (e.g., infrastructure) because they operate on the same entities (pods, services, deployments). A commerce order agent would use a different domain.
Embedding provider options
The framework's embedding client speaks any OpenAI-compatible /v1/embeddings endpoint, so the same three env vars work across providers — switch by changing TRUVAG3_EMBEDDING_BASE_URL, TRUVAG3_EMBEDDING_MODEL, and (where required) TRUVAG3_EMBEDDING_API_KEY:
TRUVAG3_EMBEDDING_BASE_URL=<provider base URL ending in /v1>
TRUVAG3_EMBEDDING_MODEL=<model ID>
TRUVAG3_EMBEDDING_API_KEY=<api key, if required>
TRUVAG3_EMBEDDING_API_KEY is omitted for local Ollama (no auth) and required for hosted providers. Precedence is: explicit With... options > env vars > defaults (http://localhost:11434/v1 + nomic-embed-text).
| Provider | Base URL | Models (ID — output dim) |
|---|---|---|
| Ollama (local) | http://<host>:11434/v1 | Any pulled embedding model (e.g., nomic-embed-text — 768, mxbai-embed-large — 1024) |
| OpenAI | https://api.openai.com/v1 | text-embedding-3-small — 1536; text-embedding-3-large — 3072; text-embedding-ada-002 — 1536 (legacy). All accept a dimensions parameter to truncate. Max input: 8192 tokens. |
| Mistral | https://api.mistral.ai/v1 | mistral-embed — 1024 (8k tokens); codestral-embed — up to 3072, configurable (8k tokens) |
| Voyage AI | https://api.voyageai.com/v1 | Hosted: voyage-4-large, voyage-4, voyage-4-lite, voyage-code-3 — 1024 default (configurable to 256 / 512 / 2048, 32k tokens); voyage-finance-2 — 1024 (32k); voyage-law-2 — 1024 (16k); voyage-code-2 — 1536 (16k, legacy). Note: Voyage's recommended usage includes an input_type parameter ("query"/"document") that the framework's client does not send, so retrieval quality may be lower than Voyage's recommended setup. |
| Jina AI | https://api.jina.ai/v1 | jina-embeddings-v4 — 2048, truncatable to 128 (32k tokens); jina-embeddings-v3 — 1024, truncatable to 32 (8k tokens). A v5 family (jina-embeddings-v5-text, jina-embeddings-v5-omni) has also been announced — check Jina's docs for current hosted-API availability and model IDs. |
| Together AI | https://api.together.ai/v1 | Multiple open-weight models served via /v1/embeddings (e.g., BAAI/bge-large-en-v1.5 — 1024; BAAI/bge-base-en-v1.5 — 768; WhereIsAI/UAE-Large-V1 — 1024; intfloat/multilingual-e5-large-instruct — 1024; togethercomputer/m2-bert-80M-{2k,8k,32k}-retrieval — 768). See Together's serverless models catalog for the current list. |
| Cohere (Compatibility API) | https://api.cohere.ai/compatibility/v1 | embed-v4.0 — 1536 default (Matryoshka: 256, 512, 1024, 1536). Note: Cohere's standard API at api.cohere.com/v1 is not OpenAI-schema-compatible — only the /compatibility/v1 path works with this client. |
Model IDs, output dimensions, and context lengths above are sourced from each provider's official documentation at the time of writing; verify against the provider's docs before deploying.
Switching providers and Qdrant collections. A Qdrant collection is created with the embedding dimension of the model that first writes to it. Switching to a different dimension (e.g., 768 → 1536) requires recreating the affected collections — Qdrant vector dimensions cannot change in place. Either drop the relevant collections in Qdrant and let the framework rebuild them, or use a fresh
TRUVAG3_AGENT_DOMAIN.
Step 2: Add Memory to Your Agent
Add these lines to your main.go, after the Redis client is available and before orchestrator initialization:
import (
"github.com/redis/go-redis/v9"
"github.com/truvaagents/truva-g3/core"
"github.com/truvaagents/truva-g3/memory"
"github.com/truvaagents/truva-g3/orchestration"
)
// Create Redis client (you may already have one for discovery)
redisOpt, _ := redis.ParseURL(os.Getenv("REDIS_URL"))
redisClient := redis.NewClient(core.ApplyRedisClientDefaults(redisOpt))
// Step 1: memory module creates backends
backends, err := memory.NewSharedBackends(redisClient, agent.Logger,
memory.WithAgentName("my-agent"),
memory.WithDomain("infrastructure"),
)
if err != nil {
return fmt.Errorf("configure memory backends: %w", err)
}
defer backends.Close()
// Step 2: orchestration module creates hooks from backends
hooks, activityCoord, err := orchestration.BuildMemoryHooks(backends.ToDeps(), agent.AI, agent.Logger)
if err != nil {
return fmt.Errorf("configure memory hooks: %w", err)
}
Then pass them to your orchestrator:
deps := orchestration.OrchestratorDependencies{
Discovery: discovery,
AIClient: agent.AI,
Logger: agent.Logger,
Telemetry: telemetry.GetTelemetryProvider(),
PipelineHooks: hooks, // Memory hooks
ActivityCoordinator: activityCoord, // Real-time signals
}
orchestrator, err := orchestration.CreateOrchestrator(config, deps)
That's it. No memory_setup.go file needed. From this point on, every request automatically reads memory before planning and writes events after execution.
With Phase 2 (knowledge search) — add an embedding client:
embedder, _ := ai.NewEmbeddingClient(ai.WithEmbeddingLogger(agent.Logger))
backends, err := memory.NewSharedBackends(redisClient, agent.Logger,
memory.WithAgentName("my-agent"),
memory.WithDomain("infrastructure"),
memory.WithEmbeddingClient(embedder), // Enables Phase 2
)
if err != nil {
return fmt.Errorf("configure memory backends: %w", err)
}
defer backends.Close()
What each call does:
NewSharedBackendsreadsTRUVAG3_AGENT_DOMAINand creates all storage backends (episodic memory, coordination, activity signals, digest cache, and optionally Qdrant knowledge store)BuildMemoryHooksreadsTRUVAG3_SHARED_MEMORY_*env vars for tuning and creates all 5 pipeline hooks in the correct order (announcement → enrichment → record → extraction → cleanup)- With a lease-aware coordinator, managed requests own their investigation claims
until the segment exits. Cleanup also runs after failures, cancellation and
HITL suspension; resume acquires fresh ownership from restored request metadata.
Event summaries do not determine which claims to release. Cleanup is bounded
by
TRUVAG3_EXECUTION_CLEANUP_TIMEOUT(5s default). Claims remain advisory and expire without renewal. Direct hook users and legacy custom coordinators keep the older caller-/name-owned lifecycle rather than gaining these guarantees. - Both calls are visible, replaceable, and testable independently
Layer 3 (manual control): For full control over every hook option, construct hooks individually via
NewMemoryEnrichmentHook,NewMemoryRecordHook, etc. See Adding Context to Your Agent for the manual hook construction pattern.
Customising Memory Behaviour
For domain-specific importance scoring, custom entity extraction, or different lookback windows, pass options to BuildMemoryHooks:
hooks, activityCoord, err := orchestration.BuildMemoryHooks(backends.ToDeps(), agent.AI, agent.Logger,
orchestration.WithMemoryEntityExtractor(myExtractor), // override BOTH hooks
orchestration.WithMemoryImportanceFunc(myScorer), // domain-specific scoring
orchestration.WithMemoryRetrievalWeights(myWeights), // Phase 2 tuning
orchestration.WithMemoryLookback(48 * time.Hour), // longer event history
orchestration.WithMemoryActivityFilter(myFilter), // custom signal filtering
)
if err != nil {
return fmt.Errorf("configure memory hooks: %w", err)
}
These are behavioural plugs — interfaces and functions you swap per-domain. Numeric tuning (token budgets, TTLs, model names) uses environment variables instead.
How Entity Extraction Works
Entity extraction identifies which domain entities a step operated on (a pod, an order, a flight, a patient, etc.) so events can be indexed for fast retrieval (e.g., "show me everything about pod product-catalog-api"). The framework ships two implementations and picks one automatically.
Auto-default selection:
- Record hook (post-execution event recording):
- When you wire an
AIClientintoBuildMemoryHooks(the common case): defaults toLLMEntityExtractor. This piggybacks on the existing batchedEventSummarizerLLM call — the LLM identifies entities from each included step's parameters and response as a side effect of generating its summary. Zero additional LLM calls. Domain-appropriate entity types (pod,service,cluster_component,ticket,channel,endpoint, ...) are produced by the LLM, not hardcoded by the framework. - When no
AIClientis wired: defaults toNoOpEntityExtractor. Honors explicitmetadata["entity_type"]/metadata["entity_id"]or multi-entitymetadata["entities"]if your tools populate them; performs no extraction otherwise.
- When you wire an
- Enrichment hook (pre-planning context lookup): always defaults to
NoOpEntityExtractor. Pre-planning, the summarizer hasn't run yet, soLLMEntityExtractorwould silently return nothing — makingNoOpEntityExtractorthe explicit default removes that ambiguity. Collision avoidance pre-planning is handled byActivityCoordinator(see Real-Time Coordination below), which has zero dependency on entity extraction.
The built-in summary contract includes entities even when the array is empty.
The recording effect's summary_coverage.batch.entity_fields reports counts of
retained summaries with missing, empty, valid, invalid or mixed entity fields,
plus accepted/rejected entity counts. Duplicate or unusable summaries are excluded
from these counts. A usable summary with a missing entity field still records an
event; explicit metadata/custom extractors retain their existing fallback paths.
Summary completeness, field coverage, factual entity accuracy and durable storage
are separate properties. Custom summarizers have no built-in batch observation;
its absence means unobserved, not failed or zero entity extraction.
Complete built-in summary arrays can have plain explanatory prose before and/or after them, or an outer JSON fence. The parser preserves the array's values and rejects ambiguous extra JSON. Complete valid entries before a truncated raw or outer-fenced array remain partial coverage; only missing/unusable summaries get the recorder's fallback. Prose-wrapped truncated arrays are not salvaged.
Per-hook override for fine-grained control:
// Opt out of LLM extraction on the record path (use explicit metadata only)
hooks, _, err := orchestration.BuildMemoryHooks(backends.ToDeps(), agent.AI, agent.Logger,
orchestration.WithBuilderRecordEntityExtractor(orchestration.NoOpEntityExtractor{}))
if err != nil {
return fmt.Errorf("configure memory hooks: %w", err)
}
Alternatively, use a domain-specific extractor on the enrichment path:
// Pre-planning entity-keyed lookup
hooks, _, err := orchestration.BuildMemoryHooks(backends.ToDeps(), agent.AI, agent.Logger,
orchestration.WithBuilderEnrichmentEntityExtractor(myDomainExtractor))
if err != nil {
return fmt.Errorf("configure memory hooks: %w", err)
}
Writing a custom extractor: implement the orchestration.EntityExtractor interface. The framework treats Entity{Type, ID} as an opaque key — domain semantics are entirely up to you.
Retaining explicit request entities during recording: by default, the record hook extracts from each step's instruction/parameters and model summary, not the request metadata. To keep an application-supplied request identity even when summarization fails, opt in separately:
hooks, _, err := orchestration.BuildMemoryHooks(backends.ToDeps(), agent.AI, agent.Logger,
orchestration.WithBuilderRecordRequestEntityExtractor(orchestration.NoOpEntityExtractor{}))
if err != nil {
return fmt.Errorf("configure memory hooks: %w", err)
}
This extractor receives PipelineContext.Request and cloned JSON-like request
metadata. Request entities precede distinct step/model entities; empty type/ID
pairs are ignored. Opaque metadata values remain read-only and application-owned.
This recording-only option does not change enrichment, acquire new investigation
claims, or release request-only entities through legacy name-based cleanup.
Direct hook callers use WithRecordRequestEntityExtractor.
Event metadata records summary_source=model|heuristic|custom, a fixed
summary_fallback_reason when needed, recorded_at and event_time_basis.
Failure summaries receive separate error text/structured bytes even when the
response is empty. The built-in prompt shares a configurable 4000-byte per-step
payload allowance between encoded failure facts and response; use
TRUVAG3_SHARED_MEMORY_EVENT_SUMMARIZER_MAX_RESPONSE_BYTES with the builder or
WithSummarizerMaxResponseChars directly (despite the name, it counts bytes).
Output tokens remain a separate setting. New event timestamps prefer valid
step completion time, then start time, then recording time with unknown
occurrence. Retention is still ingestion-based; old records are not rewritten.
Heuristic failure causes use a separate 256-byte default limit, configured with
TRUVAG3_SHARED_MEMORY_RECORD_FAILURE_CAUSE_MAX_BYTES (builder) or
WithRecordFailureCauseMaxBytes (direct hook). Positive values are required.
Shortening preserves UTF-8, adds [shortened] outside the cause allowance and
sets summary_cause_shortened=true. Raw execution evidence and model/custom
summaries remain unchanged. A step outside the built-in summarizer batch records
batch_limit, independently of whether the admitted batch succeeded or failed.
Hook effects distinguish backend ID assignment from timestamp assignment.
type myDomainExtractor struct{}
func (myDomainExtractor) ExtractEntities(text string, metadata map[string]interface{}) []orchestration.Entity {
// Your domain-specific logic here.
// Return []orchestration.Entity with whatever Type/ID makes sense for your domain.
return []orchestration.Entity{{Type: "patient", ID: "MRN-12345"}}
}
Why no regex extractor by default? The framework previously shipped a RegexEntityExtractor with hardcoded K8s patterns (pod, service, order, alert). It produced massive amounts of garbage by matching English compounds in LLM-generated step instructions (pod:non-fatal, pod:human-readable, etc.). The hardcoded patterns were also a layering violation — the framework is supposed to be domain-agnostic. The LLM-based approach is both cleaner (the LLM understands semantics, not strings) and domain-agnostic (entity types are domain-meaningful, not framework-imposed).
What the LLM Sees
When the LLM generates its execution plan, it sees two additional XML sections in the prompt:
<agent_coordination> — Real-Time Signals
<agent_coordination>
Other agents currently active in domain 'infrastructure':
- event-driven-agent: investigating "TruvaG3HighLatency on product-catalog-api" (status: executing, started 3s ago)
</agent_coordination>
This tells the LLM: "someone else is already working on this." The LLM can decide to coordinate rather than duplicate.
<agent_memory> — Domain Activity + Entity History
<agent_memory>
Memory scope: historical domain/entity evidence at request start. Attribute outcomes to their recorded origin; establish current-request progress from current execution results.
Event tuple (event-detail/v2-record-facts): [event_id, origin_request_id, timestamp, agent, action, outcome]. Each record is self-contained.
Historical domain activity digest:
{"section":"domain","admitted":1,"omitted":0,"shortened":0,"duplicates":0}
- {"text":"Coverage: bounded returned event window; older history may be omitted.\nHistorical domain activity: request-a created DEVOPS-52 (event-1, 2026-03-26T23:30Z, success); request-b queried logs (event-2, 2026-03-26T23:31Z, success)."}
Historical domain events (detail):
{"section":"domain","admitted":3,"omitted":0,"shortened":0,"duplicates":0}
- {"event":["event-2","request-b","2026-03-26T23:31:00Z","devops-chat-agent","lookup","success"],"summary":"Queried logs","summary_source":"model","event_time_basis":"step_end"}
- {"event":["event-1","request-a","2026-03-26T23:30:00Z","devops-chat-agent","create","success"],"summary":"Created JIRA ticket DEVOPS-52","summary_source":"model","event_time_basis":"step_end"}
- {"event":["event-0","request-a","2026-03-26T23:29:00Z","devops-chat-agent","lookup","success"],"summary":"Checked ticket status","summary_source":"unknown","event_time_basis":"unknown"}
</agent_memory>
The digest gives the LLM situational awareness. The recent events give it detail on what just happened. Together, they enable the LLM to make informed decisions — like "DEVOPS-52 already exists for this entity, add a comment instead of creating a new ticket."
This is a derived historical view, not current execution status. The default
budget is 8000 encoded bytes (TRUVAG3_SHARED_MEMORY_ENRICHMENT_MAX_TOKENS=2000),
including the tuple legend, headings and count markers. The legend precedes
event detail; each record retains its own exact IDs and origin, with singular
entity_type/entity_id as named fields when supplied. Empty tuple positions
denote absent fields; an absent request origin is labelled unknown.
Sparse histories keep the named-field JSON format instead when estimated
metadata savings would not repay the legend. The view report identifies the
chosen format. This is a presentation choice, not a change to stored events.
JSON records remain complete; shortened
summaries have shortened:true, and omitted records are counted. Repeated entity
lists may be excluded from detail with entities_omitted; stored events and
compactor inputs retain them. Sections retain domain/entity/investigation/knowledge
priority. A budget too small for scope and metadata produces no memory text and
a budget_too_small hook effect. These counts describe this presentation, not
proof that the backend returned complete history. Other hooks' RAG text is outside
this budget, and compactor input/output limits are separate.
Activity Compaction: Scaling to Thousands of Events
Without compaction, enrichment queries the configured recent-event limit (20 by default), then renders a bounded view. With compaction, the default raw window is 200 events, summarized into a digest alongside recent detail. This allows a larger event window without including every raw record in the prompt:
Request 1 (first request, cold cache):
200 events → LLM compaction (5-10s) → 500-token digest → cached in Redis
Request 2 (cache hit, 0 new events):
Cached digest reused → 0ms, no LLM call
Request 3 (cache hit, 5 new events since last request):
Previous digest + 5 new events → LLM incremental update (0.5-1s) → updated digest
Request 4 (burst of 50 new events):
Full recompaction triggered → LLM (5-10s) → new digest cached
These timings are illustrative, not a measured service-level guarantee.
The default 500-token digest length is a prompt target, not an enforced token
count. The built-in compactor sets AIOptions.MaxTokens to five times the target
(2500 by default), for both full and incremental updates. Provider reasoning
translation may further adjust the wire budget. The final enrichment byte
budget is independent of this generation allowance.
Every request still reads its configured retained event window. With built-in observed readers, an unchanged window reuses the digest without an AI call, including a full-size window with older history omitted. Newly returned events can update it incrementally only when all previously represented records remain unchanged. Late equal-timestamp events are eligible. Expiry, edits and page eviction trigger bounded full compaction. This is not an advancing timestamp cursor or a claim to summarize the entire retained history.
Read failures preserve the cache without claiming "nothing new". A failed or
empty compaction leaves the prior cache stored but exposes current raw details
in that request. Readers without core.ObservedEpisodicReader and independently
implemented external compactors bypass digest reuse: the fingerprint method is package-private,
not a public extension point. The built-in compactor requires a stable effective
AI fingerprint. The existing raw-event limit bounds cache event
fingerprints; lookback, limit and digest token target are part of its identity.
Read-health and cache decisions are recorded as hook effects for execution
inspection; the corresponding trace events contain bounded classifications.
Stored event metadata includes entity_source: none, llm_entities,
explicit_metadata, request_metadata, or request_and_step. This describes
indexing provenance, not proof that an inferred entity is correct. The
memory_planning_context hook effect includes agent_domain, entities,
sections, and a view report with version/format, byte budget/usage, outcome,
and per-section admitted/omitted/shortened/duplicate counts. These observations
describe the prepared view, not unlimited historical coverage or backend health.
The cache is shared across all agents in the same domain via Redis — if event-driven-agent compacts the digest, devops-chat-agent benefits from the cache too. Each cached digest also stores the stable, secret-free AI policy and route fingerprint. A rule, middleware, resolved-model, provider-surface, or semantic-route change causes a cache miss instead of reusing a digest produced under different semantics. When the request fingerprint is unstable, activity compaction bypasses both cache reads and writes; this is correct but forces full compaction until the policy becomes stable.
Configuration:
# How long to cache digests (default: 5m)
TRUVAG3_SHARED_MEMORY_DIGEST_CACHE_TTL=5m
# New events above this threshold trigger full recompaction (default: 20)
TRUVAG3_SHARED_MEMORY_DIGEST_INCREMENTAL_THRESHOLD=20
# Requested digest length (default: 500); built-in generation allowance is 5x
TRUVAG3_SHARED_MEMORY_ENRICHMENT_SUMMARY_MAX_TOKENS=500
The digest length is a prompt target, not a tokenizer-enforced output cap.
The built-in compactor requests five times that target as its generation
allowance, before any provider reasoning scaling. Its target must be positive
and fit math.MaxInt / 5; selecting it in a hook validates this at construction,
and direct non-empty calls validate before invoking AI. This arithmetic check
does not establish a provider's output capacity. Custom compactors retain their
own generation rules; the final memory-enrichment view has a separate byte budget.
Real-Time Coordination: What Are Other Agents Doing Right Now?
Episodic events are only visible after execution completes (2-4 second delay). For concurrent requests, that's too slow. Activity coordination fills the gap with transient Redis signals:
T=0s: Alert fires → event-driven-agent starts investigating Pod X
Immediately: signal written to Redis ("event-driven-agent: investigating Pod X")
T=1s: Same alert arrives at devops-chat-agent
Reads signals: sees event-driven-agent is already on it
LLM decides: "skip investigation, monitor status instead"
T=30s: event-driven-agent finishes → signal cleaned up, event recorded
Signals expire automatically via TTL (default: 5 minutes). Managed orchestration requests renew their signal at TTL/3 while the request segment remains active, then stop and join renewal before submitting cleanup on every exit. If an agent crashes, the signal expires after its last renewal. Direct hook users do not receive this request-owned renewal automatically.
Configuration:
# Signal expiry after the last announcement/renewal (default: 5m)
# Managed requests renew at TTL/3; size this for renewal delays and crash expiry.
TRUVAG3_ACTIVITY_SIGNAL_TTL=5m
# Max signals shown to the LLM (default: 10)
TRUVAG3_ACTIVITY_SIGNAL_MAX_IN_PROMPT=10
Knowledge Extraction: Learning From Experience
This is Phase 2 — requires Qdrant vector DB and an embedding endpoint. When configured, the KnowledgeExtractionHook runs after synthesis and extracts reusable knowledge fragments:
Request: "Pod X is OOMKilled"
→ Agent investigates, finds memory limit too low, increases it
→ Synthesis: "Increased memory limit from 256Mi to 512Mi..."
AfterSynthesis (async):
LLM extracts: "OOMKilled on pods with <256Mi memory is resolved by
doubling the memory limit. Check resource quotas first."
→ Embedded via nomic-embed-text → stored in Qdrant
Next request (days later): "Pod Y is OOMKilled"
BeforePlanning:
Vector search finds the knowledge fragment
→ LLM generates a 2-step plan instead of 5-step investigation
Knowledge extraction is async and fail-open. If Qdrant is down, the agent works normally — it just won't have learned patterns from past experience.
Long-Term Knowledge Retention: The Reflection Job
KnowledgeExtractionHook (above) only sees the current request — its lessons come from one synthesis cycle at a time. There's a second, complementary path that learns from patterns across many past requests: the background reflection job.
Why both?
The episodic event log (Tier 2, Redis) has a 60-day TTL by default. The activity compactor only looks back 24 hours (MemoryEnrichmentHook lookback default). Without something else doing periodic distillation, every event older than that window becomes invisible to the LLM and eventually expires unread. Reflection closes that gap.
There are two independent timelines worth keeping straight:
PASS CADENCE — when does the reflection job actually run?
T=0 T=24h T=48h T=72h T=96h ...
└─ pass ──┴─ pass ──┴─ pass ──┴─ pass ──┴─ pass ──→
↑
TRUVAG3_REFLECTION_INTERVAL (default: 24h)
Each pass scans the eligible event window and processes
any entities that have crossed the AGE_THRESHOLD since
the previous pass.
PER-EVENT ELIGIBILITY — when does ONE event become reflection-eligible?
T=0 T=24h T=7d T=60d
│ │ │ │
recorded in digest eligible for reflection expires
via compactor (≥ AGE_THRESHOLD old) (TTL)
(≤24h window)
──────────────►
◄─ window where reflection ─►
passes can pick it up
A typical journey for one event:
T=0: Tool runs → episodic event recorded (60-day TTL)
T=0-24h: Activity compactor sees event in the per-request digest
T>24h: Event still in Redis but no longer in the compactor's window
T=7d+: Event crosses AGE_THRESHOLD — eligible for reflection
(the next reflection pass that runs after this point picks it up)
The reflection LLM extracts 1-3 KnowledgeFragments from this
event + others for the same entity, embeds them, and stores
them in Qdrant (permanent).
T=60d: Original event expires from Redis. Fragments survive in Qdrant.
T=6mo: New incident → semantic search retrieves the reflected fragment
→ Agent plans informed by patterns from events that no longer exist
How it gets wired
The reflection job is created by BuildReflectionJob (Layer 1 convenience) and registered with the framework as a Runnable. Add this to your agent's main.go after creating memory backends:
// Reflection job: long-term knowledge retention via background distillation.
// Returns nil silently if Phase 2 backends (Knowledge + Embedder) are unavailable.
if reflectionJob, _ := memory.BuildReflectionJob(memBackends.ToDeps(), agent.AI, agent.Logger); reflectionJob != nil {
framework.RegisterRunnable(reflectionJob)
}
That's it. The framework starts the job on Run(), manages its lifecycle, and stops it on shutdown. The job uses a distributed Redis lock so multi-replica deployments don't double-process the same events.
Configuration
# How often the reflection pass runs (default: 24h)
TRUVAG3_REFLECTION_INTERVAL=24h
# Only events older than this qualify (default: 168h / 7 days)
# Should be longer than the compactor's 24h window so the two layers don't compete
TRUVAG3_REFLECTION_AGE_THRESHOLD=168h
# Min events per entity to trigger reflection (default: 5)
# Lower values capture sparser entities; higher values produce more confident fragments
TRUVAG3_REFLECTION_MIN_EVENTS=5
# Optional: model used for the reflection LLM call.
# Empty (default) uses your agent's main AI client model — typically a strong model.
# Set to "fast" to route reflection to the cheap/fast alias, "smart" for the
# strong-reasoning alias, or a concrete model name. The alias is resolved
# per-provider by the chain client at call time; see ENVIRONMENT_VARIABLES_GUIDE.md
# for per-provider override env vars.
TRUVAG3_REFLECTION_MODEL=
Choosing a model: cost vs. fragment quality
Reflection writes durable knowledge that influences every future request. A bad fragment isn't a one-off mistake — it lingers in Qdrant and surfaces as confident "prior knowledge" for as long as it remains in the collection. So unlike other background LLM calls, the reflection model choice matters.
| Use case | TRUVAG3_REFLECTION_MODEL | TRUVAG3_REFLECTION_MIN_EVENTS |
|---|---|---|
| Default (production) | unset | 5 |
| High volume / cost-sensitive | fast | 5 |
| Aggressive learning | unset | 2 |
| Bulk indexing of historical events | fast | 10 |
Aliases (fast, smart, default) are resolved per-provider by the chain client at call time, so the same setting works across Anthropic, OpenAI, Groq, Gemini, and the rest. The authoritative alias-to-model mappings live in the provider source files — see ENVIRONMENT_VARIABLES_GUIDE.md — Reflection Job Configuration for the file pointers and per-provider override env vars.
How to verify it's working
- Logs: look for
"Reflection pass starting"and"Reflection pass completed"fromframework/memory. The completion log includesentities_discovered,entities_processed,fragments_stored, andembedding_tokens— these tell you the pass actually did work. - Qdrant:
truvag3_knowledgecollection grows over time with fragments taggedagent_domain=<your domain>. - Registry Viewer LLM Debug screen: each reflection pass appears as a single record with
request_idlikereflect-XXXXXXXXand an interaction per processed entity. You can inspect the exact prompts and the fragments the LLM produced. RequiresTRUVAG3_LLM_DEBUG_ENABLED=trueand the agent'sagent.AIto be wrapped withai.NewInstrumentedClient(...). - The real test: issue a query semantically related to a fragment in a fresh session. The
<agent_memory>block in the planning prompt should contain the reflected fragment under "Prior knowledge relevant to this request:" — and the agent's response should reference that knowledge even though no event in the current session produced it.
Trade-offs and limits
- Cost grows with pass frequency, but sub-linearly. A 1-hour
INTERVALruns 24× more passes per day than the 24-hour default, but each of those passes discovers fewer newly-eligible entities (fewer have crossedAGE_THRESHOLDsince the last run), so total LLM spend grows less than 24×. Pick the interval based on how fast your event log actually accumulates patterns worth learning, not on raw cadence, and validate the real cost by inspectingentities_processed,embedding_tokens, and per-entity LLM call counts in the "Reflection pass completed" log line. MIN_EVENTSis a quality knob: very low values (2-3) extract patterns from sparse data, which is hit-or-miss. Production-grade fragments usually need ≥5 events.- Multi-replica safe: the job acquires a deployment-scoped, versioned Redis lock (
truvag3:v1:<deployment>:locks:{...}:lease) before each pass. Other replicas log "lock held by another replica, skipping" and return. - Fail-open: if the LLM call fails or Qdrant is down, the pass logs and exits cleanly. The next interval will retry. No errors propagate to user requests.
- Append-only: reflection never deletes episodic events. They expire naturally via the 60-day TTL. The reflection-produced fragments outlive them.
Tuning for Your Use Case
Reduce compaction latency (high event volume)
TRUVAG3_SHARED_MEMORY_COMPACTION_RAW_LIMIT=100 # Fewer events sent to LLM
TRUVAG3_SHARED_MEMORY_DIGEST_CACHE_TTL=10m # Cache longer
TRUVAG3_SHARED_MEMORY_DIGEST_INCREMENTAL_THRESHOLD=10 # Incremental updates more often
Disable LLM calls entirely (cost control)
Don't pass WithActivityCompactor() to the enrichment hook. The hook will use raw events (last N) instead of the LLM digest. Set the limit:
TRUVAG3_SHARED_MEMORY_RECENT_EVENTS_LIMIT=15
Use a specific model for summarization
# Use a fast, cheap model for background LLM calls
TRUVAG3_SHARED_MEMORY_SUMMARIZER_MODEL=fast
This affects event summarization and activity compaction LLM calls. Knowledge extraction uses the agent's main AI client. Does not affect planning or synthesis.
Full configuration reference
See ENVIRONMENT_VARIABLES_GUIDE.md — Shared Memory and LIMITS_CHEATSHEET.md — Shared Memory for the complete list of tunable values.
Observability: Knowing What Memory Is Doing
Jaeger Traces
Memory hooks appear as dedicated spans in your Jaeger traces:
▼ pipeline.hook.before_planning.activity-announcement (2ms)
signals_discovered: 2, signals_shown: 1
▼ pipeline.hook.before_planning.memory-enrichment (2836ms)
▼ activity.compaction.cache_decision
cache_hit: true, path: "incremental", coverage: "returned_window", duration_ms: 2705
▼ memory.enrichment.injected
entities_found: 0, context_chars: 6424
└─ orchestrator.activity_compaction_incremental (2704ms) ← child span
▼ pipeline.hook.after_execution.memory-record (1545ms)
events_recorded: 1
The cache_decision span event describes the bounded returned event window:
path | Meaning |
|---|---|
full | No reusable cache entry; compact the current window |
cached | Reuse an unchanged window under the same semantic policy |
incremental | Every previously represented event remains unchanged, with additions within the incremental threshold |
full_recompact | Rebaseline after events change, disappear or expire, window policy changes, or additions exceed the threshold |
raw_fallback | Compaction failed or returned an empty digest; show bounded current raw events |
coverage is returned_window, limited_window (the query bound was reached),
or unknown. It does not certify coverage of all retained history. cache_hit
means a compatible decoded entry with window metadata was found, so it can be
true even when that entry is not reused; raw key existence alone is insufficient.
LLM Debug Store
If you have TRUVAG3_LLM_DEBUG_ENABLED=true, compaction and summarization LLM calls appear in the registry viewer alongside planning and synthesis calls:
[1] type=activity_compaction_incremental, tokens=2308, duration=6209ms
[2] type=tiered_selection, tokens=4750, duration=1445ms
[3] type=plan_generation, tokens=11291, duration=6653ms
[4] type=event_summarization, tokens=3089, duration=3185ms
[5] type=synthesis_streaming, tokens=13705, duration=18236ms
Troubleshooting
Memory Context Not Appearing in Plans
Check the enrichment hook is registered:
// Hooks must be passed to OrchestratorDependencies
deps := orchestration.OrchestratorDependencies{
PipelineHooks: memoryHooks, // Not nil!
}
Verify in Jaeger: look for pipeline.hook.before_planning.memory-enrichment span. If missing, the hook isn't registered.
Events Not Being Recorded
Check pipeline.hook.after_execution.memory-record and its structured effects.
Steps without extracted entities still produce domain-level events. A zero
count can instead reflect no eligible steps, dependency-skipped/refinement
steps, or missing execution results. The event count describes submissions,
including ones whose provider calls fail. Inspect step eligibility and the
submitted-event/provider outcomes; a fail-open provider's
nil return is not an independent confirmation that Redis stored the event.
memory.entity_extraction.completed describes indexing, not permission to
record an entityless step.
Compaction Taking Too Long
If activity.compaction.cache_decision repeatedly shows path: "full", check
cache eligibility as well as backend configuration:
- Is
WithDigestCache(digestCache)passed to the enrichment hook, and is Redis accessible? - Does the episodic provider implement
core.ObservedEpisodicReader, with the current read succeeding? Legacy reads remain usable but cannot authorize cache reuse. - Is the built-in compactor using a stable effective AI fingerprint? External implementations cannot opt into the package-private fingerprint contract.
- Does the returned window have unique event identities? An incompatible view/AI fingerprint or missing cache yields
full. A compatible entry whose events changed, expired from the window, or were displaced yieldsfull_recompact; so does a window-policy change or an extension exceeding the incremental threshold. Cache TTL expiry yieldsfull. Failed/empty compaction yieldsraw_fallback. - Is
TRUVAG3_SHARED_MEMORY_DIGEST_CACHE_TTLtoo short?
cache_hit indicates a compatible decoded entry with window metadata was found;
it does not mean the digest was reused or that any raw cache key merely existed.
Inspect coverage and raw_fallback alongside cache_hit. Raw fallback keeps
the previous cache but does not present it as covering the current window.
Activity Signals Not Showing
If <agent_coordination> is empty when other agents are active:
- Both agents must be in the same
TRUVAG3_AGENT_DOMAIN - Activity coordination hooks must be registered (announcement + cleanup)
- Check
activity.coordination.completespan:signals_discoveredshould be > 0
Knowledge Search Returns Nothing
Phase 2 requires all three: Qdrant running, embedding endpoint configured, and at least one prior request that generated knowledge. Check:
TRUVAG3_VECTOR_DB_URLis accessible from the podTRUVAG3_EMBEDDING_BASE_URLandTRUVAG3_EMBEDDING_MODELare set- The
KnowledgeExtractionHookis in the hooks list (it's only added when Phase 2 backends are available)
User Memory: Per-User Personalization
Everything above covers shared agent memory — cross-agent visibility within a domain. TruvaG3 also provides user memory — per-user private facts for personal assistant agents. These are completely separate concerns with separate stores, hooks, and privacy boundaries.
Think of it like a personal assistant's notebook vs a team shared notebook:
- Shared memory: "Agent A restarted pod X and created DEVOPS-42" — everyone reads it
- User memory: "Jenny is vegan, lives in Coppell TX, prefers DFW airport" — only Jenny's sessions see it
Without user memory: With user memory:
Session 1: "I'm vegan, Session 1: "I'm vegan,
plan a trip to Tokyo" plan a trip to Tokyo"
→ Plans trip (no dietary filter) → Plans trip (no dietary filter)
→ Extracts: "User is vegan"
→ Stores in Qdrant
Session 2: "Plan a trip to Paris" Session 2: "Plan a trip to Paris"
→ Has no idea about dietary needs → Recalls: "User is vegan"
→ Suggests steak restaurants → Plans with vegan dining
→ User has to repeat themselves → No repetition needed
Working Example
The travel-chat-agent has a fully working user memory implementation:
- Agent:
examples/travel-chat-agent/- Memory wiring:
examples/travel-chat-agent/chat_agent.go(search forNewUserMemoryBackend)- User identity:
examples/chat-ui/index.html(dialog +X-User-IDheader)
How Shared Memory and User Memory Compare
| Shared Memory | User Memory | |
|---|---|---|
| Scope | All agents in a domain | One user only |
| Content | "Agent X restarted pod Y" | "User prefers DFW airport" |
| Visibility | Domain-wide | Private to user |
| Lifetime | 24h lookback + knowledge persists | Months/years |
| GDPR delete | Not per-user | Forget("user-42") deletes everything |
| Hooks | BuildMemoryHooks() | BuildUserMemoryHooks() |
| Enrichment tag | <agent_memory> | <user_profile> |
| Backend | Redis (events) + Qdrant (knowledge) | Qdrant or InMemory |
| Required | Redis | Nothing (InMemory fallback) |
How User Memory Works
User memory plugs into the same pipeline hook system as shared memory, but at different stages with different data:
User Request (with X-User-ID header)
│
▼
┌──────────────────────────────────────────────────────────┐
│ UserMemoryEnrichmentHook (BeforePlanning) │
│ Recalls facts for this user: │
│ - Identity (always): name, location, language │
│ - Summaries (always): last 3 session summaries │
│ - Preferences (by relevance): semantic search │
│ → Injected into <user_profile> tag │
└──────────────────────────────────────────────────────────┘
│
▼ LLM sees user profile when generating its plan
│
Planning → Execution → Synthesis → Response streamed
│
▼
┌──────────────────────────────────────────────────────────┐
│ UserMemoryExtractionHook (AfterSynthesis) │
│ 1. LLM extracts persistent facts from conversation │
│ 2. For each candidate fact: │
│ - Searches for similar existing facts (Qdrant) │
│ - LLM classifies: ADD / UPDATE / CONTRADICT / DUPLICATE│
│ - Stores new or updated facts │
│ 3. Generates one-sentence session summary │
└──────────────────────────────────────────────────────────┘
Important: extraction runs after the response is streamed to the user. It doesn't add latency to the response — the user sees the answer immediately, and facts are stored in the background.
User Memory Environment Setup
Add these to your .env file (you may already have them for shared memory):
# Vector DB for user fact storage (Qdrant)
TRUVAG3_VECTOR_DB_URL=qdrant.truvag3-examples:6334
# Embedding endpoint (for semantic search when recalling facts)
TRUVAG3_EMBEDDING_BASE_URL=http://host.docker.internal:11434/v1
TRUVAG3_EMBEDDING_MODEL=nomic-embed-text
To use a hosted provider (OpenAI, Mistral, Voyage, Jina, Together, Cohere) instead of local Ollama, see Embedding provider options in Step 1 for the full list of supported base URLs and model IDs.
Without a vector DB, user memory falls back to InMemoryUserMemory — functional for development but facts are lost on restart.
Optional tuning (all have sensible defaults):
# Max facts injected into the planning prompt (default: 15)
TRUVAG3_USER_MEMORY_MAX_FACTS_IN_PROMPT=15
# Model for fact extraction + session summary LLM calls (default: agent's model)
# Use a fast/cheap model — these are high-volume, low-complexity calls
TRUVAG3_USER_MEMORY_EXTRACTION_MODEL=fast
# Cosine similarity threshold for triggering LLM reconciliation (default: 0.75)
# Below this, new facts are added without checking for contradictions
TRUVAG3_USER_MEMORY_RECONCILIATION_THRESHOLD=0.75
See Environment Variables Guide — User Memory for all 10 env vars.
Adding User Memory to Your Agent
Add these lines to your main.go, after AI client creation and before orchestrator initialization:
import (
"github.com/truvaagents/truva-g3/ai"
"github.com/truvaagents/truva-g3/memory"
"github.com/truvaagents/truva-g3/orchestration"
)
// Create embedding client (memory module can't import ai — passed via option)
embedder, _ := ai.NewEmbeddingClient(ai.WithEmbeddingLogger(agent.Logger))
// Step 1: memory module creates backend
userMemBackend, _ := memory.NewUserMemoryBackend(agent.Logger,
memory.WithUserMemoryNamespace("travel"), // scopes facts by agent type
memory.WithUserMemoryEmbeddingClient(embedder), // enables vector search
)
if userMemBackend != nil {
defer userMemBackend.Close()
}
// Step 2: orchestration module creates hooks
userHooks, userHooksCloser := orchestration.BuildUserMemoryHooks(
userMemBackend.ToDeps(), agent.AI, agent.Logger,
)
defer userHooksCloser.Close()
BuildUserMemoryHooks(...) returns both the hooks and an io.Closer. The Layer 1 preset runs extraction asynchronously by default so chat responses are not delayed; use orchestration.WithSynchronousExtraction() when tests or downstream logic need extraction to complete before continuing.
Then pass them to your orchestrator alongside shared memory hooks:
deps := orchestration.OrchestratorDependencies{
// Shared memory hooks + user memory hooks compose together
PipelineHooks: append(sharedMemoryHooks, userHooks...),
}
What's a namespace? It scopes which facts an agent reads and writes. A travel agent with namespace "travel" stores "prefers DFW airport" — a DevOps agent with namespace "devops" won't see it. Both always query the "universal" namespace for cross-agent facts (name, language, timezone).
Where does user_id come from? The hooks read pctx.Metadata["user_id"]. Your HTTP handler sets it from the request — typically from an X-User-ID header, a JWT claim, or a session store. If user_id is missing, both hooks skip silently (event-driven agents, cron agents — no user context, no user memory).
What the LLM Sees (User Profile)
When the enrichment hook runs, the LLM receives a <user_profile> tag in its planning prompt:
<user_profile>
Identity:
- User lives in Coppell, TX (explicit, high confidence)
Constraint:
- User and family are vegan, no eggs or gelatin (explicit, high confidence)
Preference:
- User prefers DFW airport for flights (explicit, high confidence)
Relationship:
- Family of three: user, wife, and 12-year-old son (explicit, high confidence)
Summary:
- Planned a week-long vegan-friendly family trip to Zurich (derived, recorded 3 days ago)
- Asked about Switzerland weather, currency, and travel tips (derived, recorded 3 days ago)
</user_profile>
<context_precedence>
When <user_profile>, <conversation_history>, and <user_request> disagree about a subject — destination, dates, party, budget, or any named entity — trust the live turn: the current <user_request> first, then the most recent <conversation_history> turn. Treat <user_profile> "Context" entries as hints that may be stale; recency labels reflect when they were last recorded.
</context_precedence>
<user_request>
Plan a summer vacation in Europe
</user_request>
Durable facts (identity, preference, constraint, relationship) render with a confidence band. Transient facts (category: "context") and summary facts render with a recency label instead — their current truth depends on time, not on how strongly the user stated them once. The <context_precedence> rule tells the planner to trust the live turn when a stored "Context" fact disagrees with the new request.
The LLM now plans around the user's dietary constraints, family size, and airport preference — without the user repeating any of it.
This is separate from <agent_memory> (shared operational memory). Both can be present in the same prompt — different sources, different privacy boundaries.
Fact Reconciliation: How Contradictions Are Handled
When the extraction hook finds a new candidate fact, it doesn't blindly store it. It searches for similar existing facts and classifies the relationship:
| Operation | When | What Happens |
|---|---|---|
| ADD | No similar facts exist | New fact stored as-is |
| DUPLICATE | Same information already stored | Skip — update last_used_at timestamp |
| UPDATE | New fact refines existing | "Likes cricket" → "Likes cricket with friends on weekends" |
| CONTRADICT | New fact supersedes existing | "Vegan" → "No longer vegan" — old fact deleted, new stored |
This is LLM-driven — pure embedding similarity can't distinguish "prefers window seats" from "prefers aisle seats" (both are about seating preferences, high similarity, but contradictory). The LLM classifies the actual semantic relationship.
Cost optimization: If no similar facts exist (cosine similarity < 0.75), reconciliation skips the LLM call entirely and does a direct ADD. For new users, most facts are new — zero reconciliation LLM cost.
Customising User Memory Behaviour
Like shared memory, user memory uses the configuration split: numbers via env vars, behaviour via options.
// Custom fact extractor (e.g., domain-specific extraction rules)
userHooks, userHooksCloser := orchestration.BuildUserMemoryHooks(deps, aiClient, logger,
orchestration.WithUserFactExtractor(myCustomExtractor),
orchestration.WithUserFactReconciler(myCustomReconciler),
orchestration.WithUserMemoryRetrievalWeights(core.RetrievalWeights{
Recency: 0.10, // less weight on recency
Relevance: 0.60, // more weight on semantic match
Importance: 0.30, // unchanged
}),
orchestration.WithSynchronousExtraction(), // optional: opt out of async preset
)
defer userHooksCloser.Close()
Or swap the entire backend — use PostgreSQL with pgvector instead of Qdrant:
pgUserMem := mycompany.NewPgUserMemory(pgPool, logger)
deps := &core.UserMemoryDeps{
UserMemory: pgUserMem,
Embedder: embedder,
Namespace: "travel",
}
userHooks, userHooksCloser := orchestration.BuildUserMemoryHooks(deps, aiClient, logger)
defer userHooksCloser.Close()
Same hooks, same extraction, same reconciliation — different storage backend. The UserMemory interface is the boundary.
GDPR: Right to Erasure
User memory has first-class deletion support:
// Delete all facts for a user (GDPR Article 17)
err := userMemory.Forget(ctx, "user-42")
// Delete facts in a specific namespace
err := userMemAdmin.ForgetNamespace(ctx, "user-42", "travel")
// Delete a single fact
err := userMemAdmin.ForgetFact(ctx, "user-42", "fact-id-123")
Forget() deletes all vectors and payloads for the user from the vector DB. After compaction, no residual data remains in indexes.
User Memory Observability
Jaeger traces — both hooks emit span events under auto-created parent spans:
pipeline.hook.before_planning.user-memory-enrichment
├─ user_memory.recall.identity (user_id, namespace)
└─ user_memory.enrichment.injected (facts_count, profile_chars)
pipeline.hook.after_synthesis.user-memory-extraction
├─ user_memory.extraction.llm_request (user_id)
├─ user_memory.extraction.complete (candidates, stored)
├─ user_memory.summary.llm_request (user_id)
└─ user_memory.summary.stored (user_id)
Telemetry counters:
| Metric | What It Tracks |
|---|---|
user_memory.reconciliation.operation | Count per operation type (ADD/UPDATE/CONTRADICT/DUPLICATE) |
user_memory.reconciliation.batch{outcome} | Batched reconciliation attempts, labelled success (one LLM call classified all candidates) or fallback (batched call failed, dropped to per-candidate path). A rising fallback rate signals a model regression on structured-output compliance. |
user_memory.summary.stored | Session summaries generated |
User Memory Troubleshooting
Facts Not Being Recalled
Check pipeline.hook.before_planning.user-memory-enrichment span in Jaeger. If facts_count: 0:
- Is
user_idset inpctx.Metadata? (Check your HTTP handler — must set"user_id"in the metadata map passed toProcessRequest) - Is
TRUVAG3_VECTOR_DB_URLaccessible from the pod? - Does the Qdrant collection
truvag3_user_memoryexist? (Auto-created on firstRemember()call) - Are facts stored for this user ID? (Check Qdrant directly via REST API)
Extraction Not Running
Check pipeline.hook.after_synthesis.user-memory-extraction span. If missing:
- Is
BuildUserMemoryHooksregistered inPipelineHooks? - Is
user_idpresent in metadata? (Hooks skip silently without it)
Too Many Reconciliation LLM Calls
If every fact triggers a reconciliation LLM call, the similarity threshold may be too low. Increase TRUVAG3_USER_MEMORY_RECONCILIATION_THRESHOLD (default 0.75). Higher values mean fewer false "similar" matches, fewer LLM calls, but slightly higher chance of storing near-duplicates.
Facts Not Appearing in Response
The <user_profile> tag must be consumed by the prompt builder. Check the debug store (/api/llm-debug/{request_id}) — look for <user_profile> in the plan_generation, continuation_plan_generation, and synthesis_streaming prompts. If missing from any, the prompt builder isn't reading EnrichmentUserProfile.
See Also
Shared Memory:
- Adding Context to Your Agent — Building custom pipeline hooks
- Environment Variables Guide — Shared Memory — All configurable env vars
- Limits Cheatsheet — Shared Memory — Quick reference for all limits
- Distributed Tracing Guide — LLM Telemetry — Span events emitted by memory hooks
- API Reference — Shared Memory Interfaces — Full interface signatures
- Memory Module Architecture — Deep dive into storage backends, sharing topology, and design decisions
examples/devops-chat-agent/— Full working implementation with all phasesexamples/event-driven-agent/— Event-driven agent with the same memory wiring
User Memory:
- API Reference — User Memory Interfaces — Interface signatures and convenience constructors
- Environment Variables Guide — User Memory — All configurable env vars
- Limits Cheatsheet — User Memory — Quick reference for all limits
examples/travel-chat-agent/— Full working implementation with user memory
Bounded event-summary calls
The builtin event summarizer now uses a 2m AI-invocation deadline. Configure
TRUVAG3_SHARED_MEMORY_EVENT_SUMMARIZER_TIMEOUT with BuildMemoryHooks, or
WithSummarizerTimeout when constructing the summarizer directly. Invalid explicit
values fail startup. A child timeout records summary_timeout and leaves the
recorder's one-per-step heuristic fallback on the parent context. Parent cancellation
records parent_cancelled and stops fallback writes. A successful response returned
at the deadline is still parsed. Custom summarizers are unchanged.
Direct users can tune the existing WithSummarizerMaxStepsPerBatch (20 default).
Steps beyond the single batch remain recorded with heuristic summaries; their
model-derived entity coverage is not implied. No new batch environment setting or
unbounded second call is introduced.