Skip to content

A fact one Bot remembered is reported as remembered to a second Bot, which then never sees it #714

Description

@Chebaleomkar

remember from a Bot is deduplicated per person, but read back per Bot.

  • server/src/memory/store.ts (the create path for formedBy: "bot") looks for an existing row by (ownerUserId, formedBy = 'bot', importDigest). The unique index has the same shape. It does not include formedByAgentId.
  • retrieve returns Bot-formed rows only where formedByAgentId is the asking Bot.

So if Bot A remembers "I prefer metric units" for a person, and Bot B later forms the same sentence, B gets { duplicate: true } with A's id. B never records it, and B's retrieval never returns it. B is told the fact is remembered but never sees it.

There are two possible directions:

  1. Scope the dedup lookup and the index to formedByAgentId, so each Bot keeps its own copy.
  2. Keep one row per person, and let retrieval include Bot-formed rows from other Bots the person uses.

Which one is intended? I'm happy to send a PR for either.

Activity

  1. kvnloo commented on Oct 3, 2026

    @kvnloo
    Contributor

    I traced this against current main, and Chebaleomkar's diagnosis matches the store/schema contract exactly.

    The surrounding code makes the intended ownership pretty explicit:

    • personalMemories.formedByAgentId exists specifically for Bot ownership.
    • The schema comment says a Bot-formed memory is delivered only to the Bot that formed it.
    • retrieve(..., formed) enforces that with formedByAgentId === formed.botId.
    • Source-app revocation is also checked per asking Bot.

    Given that contract, I think option 1 is the internally consistent fix: scope Bot-formed deduplication to the forming Bot, rather than widening retrieval across Bots.

    Concretely, the partial unique key and both duplicate lookups would become effectively:

    (ownerUserId, formedByAgentId, importDigest) WHERE formedBy = 'bot'

    That also keeps the current provenance/security property: Bot B does not inherit a fact Bot A observed through an app B may not hold.

    The regression matrix I'd want before changing the migration:

    1. same Bot + same content twice → one row / duplicate
    2. two Bots + same content → two rows
    3. Bot A retrieves only A's row; Bot B only B's row
    4. deleting A's row prevents A from silently reforming it, but does not tombstone B's independent memory
    5. concurrent duplicate formation by the same Bot still collapses to one row

    Happy to keep this as architecture guidance rather than competing with the implementation you offered.

  2. NathanTarbert commented on Oct 8, 2026

    @NathanTarbert
    Collaborator

    Thanks @Chebaleomkar for writing this up so clearly, and @kvnloo for tracing it against the schema. With both directions laid out side by side, this one was easy to decide.

    Let's go with option 1, where each Bot keeps its own copy. Everything else about a Bot-formed memory already works per Bot. It is only delivered to the Bot that formed it, and only while that Bot still holds the app it came from. Resetting a Bot clears its own rows and nobody else's. Sharing across Bots would let Bot B read something Bot A learned from an app B was never given.

    One small detail for whoever picks it up. The dedup lives in formMemory, not create (server/src/memory/store.ts:77-96). So remember_personal_fact isn't affected. The paths that hit this are save_observed_memory and proactive research.

    There are three things that option 1 changes, and they're worth handling in the same PR.

    The first is forgetting. Today, when someone deletes a Bot-formed fact, it stays forgotten for every Bot. That's because the deleted row keeps its digest, and the lookup finds it no matter which Bot asks. Once the check is per Bot, Bot B could form the same fact again and put it back in front of the person for review. I'd keep forgetting person-wide. Before inserting, check whether this person deleted the same content from any Bot, and return forgotten: true if they did. Happy to hear it if anyone thinks forgetting should be per Bot instead.

    The second is the Memory page. It lists every row for the person and labels each one "Formed by your Bot". With one row per Bot, the same fact from two Bots would show up as two identical cards. Showing which Bot formed each one would clear that up, and formedByAgentId is already on the row.

    The third is that the index and the conflict target have to change together. The new migration drops personal_memories_formed_digest_idx and recreates it on (owner_user_id, formed_by_agent_id, import_digest) WHERE formed_by = 'bot'. The onConflictDoNothing target in formMemory needs exactly the same columns, or Postgres refuses the insert. Existing rows can't collide under the wider key, so no backfill is needed.

    No test today covers two Bots forming the same fact. It would be good to cover:

    • two Bots, same fact: two rows, each recalled only by its own Bot;
    • the same Bot is still deduplicated, including two runs at once;
    • forgetting across Bots;
    • resetting Bot A leaves Bot B's row alone.

    @Chebaleomkar, you offered to send the PR, so it's yours if you'd like it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions