06

The Background Learning Loop

Keep the user-visible path fast while a governed background worker extracts, evaluates, and maintains durable memory.

Separate serving from learning

The agent’s hot path should authenticate the event, load necessary context, make the decision, perform authorized work, and return. Memory extraction, summarization, embedding, deduplication, contradiction review, and consolidation can be expensive and retryable. Doing all of them before replying increases latency and couples user success to a learning subsystem.

Publish a learning event after the visible result is durable. A background worker can process it independently.

The queue is not long-term memory. It is a delivery mechanism for work that will eventually update the authoritative store.

HOT PATH
email ─▶ triage ─▶ agent ⇄ tools ─▶ response
                                  │
                                  ▼ event
BACKGROUND PATH               durable queue
                                  │
        extract ─▶ classify ─▶ policy ─▶ embed ─▶ persist/review

Emit a bounded learning event

The hot path should publish stable identifiers and the smallest necessary payload. Raw content may remain in protected object storage with a reference rather than being copied through every system.

async def publish_learning_event(result, context):
    await memory_jobs.publish({
        "event_id": context.event_id,
        "tenant_id": context.tenant_id,
        "user_id": context.user_id,
        "thread_id": context.thread_id,
        "procedure_versions": result.procedure_versions,
        "route": result.classification,
        "tool_outcomes": result.tool_outcomes,
        "feedback_ref": result.feedback_ref,
        "content_ref": result.redacted_content_ref,
    })

Use an outbox or another atomic publication pattern if losing the event after committing the response would violate requirements. Consumers must be idempotent because queues can deliver the same event more than once.

Build the background worker as a policy pipeline

The worker loads the protected source, redacts data the memory system should not retain, extracts candidates, classifies their memory type, and applies policy. It does not store every model suggestion.

async def process_memory_event(event):
    if await processed_events.exists(event["event_id"]):
        return

    source = await content_store.read(event["content_ref"])
    candidates = extract_candidates(redact(source), event)

    for candidate in candidates:
        decision = memory_policy(candidate, event)
        if decision == "store":
            await persist_active(candidate, event)
        elif decision == "review":
            await review_queue.publish(candidate.model_dump())
        else:
            await audit_rejection(candidate, event)

    await processed_events.record(event["event_id"])

The processed_events record prevents duplicate delivery from creating duplicate memories. Candidate-level deduplication handles semantically equivalent facts across different conversations.

Apply memory policy before persistence

Policy considers authority, sensitivity, confidence, provenance, expected lifetime, contradictions, and future impact. Procedural candidates receive the strictest treatment.

def memory_policy(candidate, event):
    if candidate.origin == "external_email":
        if candidate.type == "procedural":
            return "reject"

    if candidate.sensitivity in {"secret", "restricted"}:
        return "reject"

    if candidate.type == "procedural":
        return "review"

    if candidate.confidence < 0.85:
        return "review"

    if contradicts_active_memory(candidate):
        return "review"

    return "store"

Thresholds should be calibrated on labelled candidates, not chosen by intuition. A high precision requirement is sensible because one missing convenience memory is usually cheaper than a durable false belief.

Resolve contradictions through supersession

Preferences change. If the user later says, “Morning meetings are fine after 09:00,” the old memory should not remain equally active beside the new one. Preserve history but change retrieval eligibility.

async def supersede_memory(old_id, new_memory):
    async with database.transaction():
        old = await memories.lock(old_id)
        await memories.update(old_id, {
            "status": "superseded",
            "superseded_by": new_memory.memory_id,
        })
        await memories.insert({
            **new_memory.model_dump(),
            "status": "active",
            "supersedes": old_id,
        })

Retrieval filters to active records. Audit and correction interfaces can still show the chain. Do not silently edit the old content: provenance would no longer match what was originally learned.

Treat memory poisoning as a durable attack

Prompt injection usually targets the current model call. Memory poisoning tries to persist attacker-controlled instructions so they influence future calls. External emails, retrieved web pages, and tool results are untrusted even when they contain phrases such as “remember this forever.”

Defenses work in layers:

  • separate data from instructions in prompts;
  • forbid external content from creating procedural memory;
  • require authenticated authority and review for high-impact changes;
  • redact secrets and sensitive categories before embedding;
  • keep provenance and quarantine suspicious candidates;
  • prevent any memory from overriding system policy or tool authorization;
  • evaluate known poisoning cases on every release.

The safest interpretation of retrieved memory is evidence to consider, never executable authority.

Give memories a lifecycle

Useful statuses include candidate, active, superseded, expired, quarantined, and deleted. Different types need different retention. A stable accessibility preference may be long-lived; a travel location may expire in days; a procedural version remains auditable after retirement.

Deletion requires more than removing one database row. Track vector indexes, caches, replicas, backups, and derived episodes. A deletion workflow should invalidate retrieval immediately, clear caches, schedule physical removal according to policy, and retain only the minimum audit evidence legally required.

candidate ─▶ active ─────▶ superseded
    │           ├────────▶ expired
    │           └────────▶ quarantined
    └─────────── reject
                     all eligible states ─▶ deleted

Observe the learning loop

Trace the event from hot-path publication through worker processing and persistence. Track queue age, extraction latency, accepted-memory precision, review volume, duplicate rate, contradiction rate, stale-memory rate, and deletion completion time. Do not place raw email bodies or secrets in logs and span attributes.

The background loop completes the conceptual agent: today’s outcome can improve tomorrow’s context. The next question is where the durable records, vectors, events, blobs, and caches should live in production.

Key takeaways

  • Serving and learning have different latency and failure requirements, so connect them with a durable background job.
  • Workers and consumers must be idempotent.
  • Extraction proposes candidates; policy decides whether they are stored, reviewed, or rejected.
  • Contradictions create new versions and supersede old records rather than rewriting history.
  • Memory poisoning is durable prompt injection and requires layered authority and lifecycle controls.

Checklist

  • [ ] I can separate the hot path from the background memory path.
  • [ ] I can explain why queue delivery and worker processing require idempotency.
  • [ ] I can list the inputs to a memory-policy decision.
  • [ ] I can model a corrected preference with a supersession chain.
  • [ ] I can name three defenses against memory poisoning.