attestari-langchain 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .env
11
+ .python-version
12
+
13
+ # Tooling
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .mypy_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # Data / local
21
+ *.sqlite
22
+ *.db
23
+ eval/data/
24
+ .attestari/
25
+
26
+ # Internal planning / strategy docs — never publish
27
+ internal/
28
+
29
+ # Node / TS SDK
30
+ node_modules/
31
+ clients/ts/dist/
32
+ *.tsbuildinfo
33
+
34
+ # OS / editor
35
+ .DS_Store
36
+ .idea/
37
+ .vscode/
38
+
39
+ # Local agent tooling (launch.json can hold a dev KEK / local env)
40
+ .claude/
41
+
42
+ # Screen-recording source for docs/deletion-demo.gif — the GIF is committed, the source is not
43
+ deletion-demo.mp4
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: attestari-langchain
3
+ Version: 0.0.1
4
+ Summary: LangChain integration for Attestari — the auditable memory layer for AI agents.
5
+ License: Apache-2.0
6
+ Keywords: agents,ai,attestari,langchain,memory
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: attestari>=0.0.1
9
+ Requires-Dist: langchain-core>=0.3
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # attestari-langchain
15
+
16
+ LangChain integration for [Attestari](https://github.com/attestari/attestari) — the
17
+ auditable memory layer for AI agents.
18
+
19
+ Two pieces, both built on stable `langchain-core` primitives, so instead of
20
+ replaying a raw chat transcript your chain stores **facts with provenance** —
21
+ bi-temporal recall, a tamper-evident audit trail, and a signed deletion
22
+ certificate, on plain Postgres or zero-dependency in memory:
23
+
24
+ - **`AttestariRetriever`** (`BaseRetriever`) — recall a subject's relevant facts as
25
+ `Document`s, each carrying provenance in its metadata (fact id, source episode,
26
+ valid-from, confidence). Supports bi-temporal `as_of` recall. The idiomatic way
27
+ to inject long-term memory into an LCEL / RAG chain or an agent tool.
28
+ - **`AttestariChatMessageHistory`** (`BaseChatMessageHistory`) — a drop-in history for
29
+ `RunnableWithMessageHistory`: it *learns facts* from each **human** turn (not a raw
30
+ transcript, and never from the assistant's own words), surfaces the subject's known facts as a system message, and maps
31
+ `clear()` to Attestari's provable deletion (`forget`).
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install attestari-langchain # pulls in attestari + langchain-core
37
+ ```
38
+
39
+ ## Use — inject memory into a chain (AttestariRetriever)
40
+
41
+ ```python
42
+ from attestari import Memory
43
+ from attestari_langchain import AttestariRetriever
44
+
45
+ mem = Memory() # or Memory.local() / Memory.postgres()
46
+ mem.add("Hi, I'm Alice. I live in Toronto.", subject_id="user_42")
47
+
48
+ retriever = AttestariRetriever(
49
+ mem=mem, # the Attestari engine
50
+ subject_id="user_42", # an opaque pseudonym, not raw PII
51
+ k=5, # facts to recall
52
+ # as_of="2022-01-01", # optional: recall what was true then
53
+ )
54
+
55
+ docs = retriever.invoke("where does the user live?")
56
+ # -> [Document("user_42 lives in Toronto", metadata={fact_id, source_episode_id, score, ...})]
57
+ ```
58
+
59
+ > The zero-dependency default extractor understands **first-person** statements
60
+ > ("I live in…", "my name is…"). For arbitrary third-person text, install
61
+ > `attestari[anthropic]` and set `ANTHROPIC_API_KEY` for Claude-powered extraction.
62
+
63
+ Drop `retriever` into any LCEL chain the way you would any other retriever; each
64
+ returned `Document` carries provenance in `.metadata` you can show or audit.
65
+
66
+ ## Use — conversational memory (AttestariChatMessageHistory)
67
+
68
+ ```python
69
+ from attestari import Memory
70
+ from attestari_langchain import AttestariChatMessageHistory
71
+ from langchain_core.messages import HumanMessage
72
+
73
+ history = AttestariChatMessageHistory(mem=Memory(), subject_id="user_42")
74
+ history.add_messages([HumanMessage("Hi, I'm Alice and I live in Toronto.")])
75
+ history.messages # -> [SystemMessage("Known facts about the user:\n- user lives_in Toronto")]
76
+ history.clear() # provable deletion (forget) for this subject
77
+ ```
78
+
79
+ Wire it into `RunnableWithMessageHistory` as the `get_session_history` factory:
80
+
81
+ ```python
82
+ from langchain_core.runnables.history import RunnableWithMessageHistory
83
+
84
+ chain_with_memory = RunnableWithMessageHistory(
85
+ chain,
86
+ lambda session_id: AttestariChatMessageHistory(mem=Memory.local(), subject_id=session_id),
87
+ input_messages_key="input",
88
+ history_messages_key="history",
89
+ )
90
+ ```
91
+
92
+ ## Constructor reference
93
+
94
+ **`AttestariRetriever`**
95
+
96
+ | Field | Default | Purpose |
97
+ |---|---|---|
98
+ | `mem` | — | The Attestari engine: `Memory()`, `Memory.local()`, or `Memory.postgres()`. |
99
+ | `subject_id` | `None` | Scope recall to one subject (opaque pseudonym). |
100
+ | `k` | `5` | How many facts to recall. |
101
+ | `as_of` | `None` | Bi-temporal instant — recall what was true `as_of` this ISO date. |
102
+
103
+ **`AttestariChatMessageHistory(mem, subject_id)`** — `add_messages(msgs)` ingests each
104
+ human turn (assistant messages are conversation, not testimony — they are not
105
+ attributed to the user as facts), `messages` returns the subject's live facts as one system message, and
106
+ `clear()` maps to provable deletion.
107
+
108
+ A runnable, no-LLM example is in [`example.py`](example.py):
109
+ `python clients/langchain/example.py`.
110
+
111
+ Apache-2.0.
@@ -0,0 +1,98 @@
1
+ # attestari-langchain
2
+
3
+ LangChain integration for [Attestari](https://github.com/attestari/attestari) — the
4
+ auditable memory layer for AI agents.
5
+
6
+ Two pieces, both built on stable `langchain-core` primitives, so instead of
7
+ replaying a raw chat transcript your chain stores **facts with provenance** —
8
+ bi-temporal recall, a tamper-evident audit trail, and a signed deletion
9
+ certificate, on plain Postgres or zero-dependency in memory:
10
+
11
+ - **`AttestariRetriever`** (`BaseRetriever`) — recall a subject's relevant facts as
12
+ `Document`s, each carrying provenance in its metadata (fact id, source episode,
13
+ valid-from, confidence). Supports bi-temporal `as_of` recall. The idiomatic way
14
+ to inject long-term memory into an LCEL / RAG chain or an agent tool.
15
+ - **`AttestariChatMessageHistory`** (`BaseChatMessageHistory`) — a drop-in history for
16
+ `RunnableWithMessageHistory`: it *learns facts* from each **human** turn (not a raw
17
+ transcript, and never from the assistant's own words), surfaces the subject's known facts as a system message, and maps
18
+ `clear()` to Attestari's provable deletion (`forget`).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install attestari-langchain # pulls in attestari + langchain-core
24
+ ```
25
+
26
+ ## Use — inject memory into a chain (AttestariRetriever)
27
+
28
+ ```python
29
+ from attestari import Memory
30
+ from attestari_langchain import AttestariRetriever
31
+
32
+ mem = Memory() # or Memory.local() / Memory.postgres()
33
+ mem.add("Hi, I'm Alice. I live in Toronto.", subject_id="user_42")
34
+
35
+ retriever = AttestariRetriever(
36
+ mem=mem, # the Attestari engine
37
+ subject_id="user_42", # an opaque pseudonym, not raw PII
38
+ k=5, # facts to recall
39
+ # as_of="2022-01-01", # optional: recall what was true then
40
+ )
41
+
42
+ docs = retriever.invoke("where does the user live?")
43
+ # -> [Document("user_42 lives in Toronto", metadata={fact_id, source_episode_id, score, ...})]
44
+ ```
45
+
46
+ > The zero-dependency default extractor understands **first-person** statements
47
+ > ("I live in…", "my name is…"). For arbitrary third-person text, install
48
+ > `attestari[anthropic]` and set `ANTHROPIC_API_KEY` for Claude-powered extraction.
49
+
50
+ Drop `retriever` into any LCEL chain the way you would any other retriever; each
51
+ returned `Document` carries provenance in `.metadata` you can show or audit.
52
+
53
+ ## Use — conversational memory (AttestariChatMessageHistory)
54
+
55
+ ```python
56
+ from attestari import Memory
57
+ from attestari_langchain import AttestariChatMessageHistory
58
+ from langchain_core.messages import HumanMessage
59
+
60
+ history = AttestariChatMessageHistory(mem=Memory(), subject_id="user_42")
61
+ history.add_messages([HumanMessage("Hi, I'm Alice and I live in Toronto.")])
62
+ history.messages # -> [SystemMessage("Known facts about the user:\n- user lives_in Toronto")]
63
+ history.clear() # provable deletion (forget) for this subject
64
+ ```
65
+
66
+ Wire it into `RunnableWithMessageHistory` as the `get_session_history` factory:
67
+
68
+ ```python
69
+ from langchain_core.runnables.history import RunnableWithMessageHistory
70
+
71
+ chain_with_memory = RunnableWithMessageHistory(
72
+ chain,
73
+ lambda session_id: AttestariChatMessageHistory(mem=Memory.local(), subject_id=session_id),
74
+ input_messages_key="input",
75
+ history_messages_key="history",
76
+ )
77
+ ```
78
+
79
+ ## Constructor reference
80
+
81
+ **`AttestariRetriever`**
82
+
83
+ | Field | Default | Purpose |
84
+ |---|---|---|
85
+ | `mem` | — | The Attestari engine: `Memory()`, `Memory.local()`, or `Memory.postgres()`. |
86
+ | `subject_id` | `None` | Scope recall to one subject (opaque pseudonym). |
87
+ | `k` | `5` | How many facts to recall. |
88
+ | `as_of` | `None` | Bi-temporal instant — recall what was true `as_of` this ISO date. |
89
+
90
+ **`AttestariChatMessageHistory(mem, subject_id)`** — `add_messages(msgs)` ingests each
91
+ human turn (assistant messages are conversation, not testimony — they are not
92
+ attributed to the user as facts), `messages` returns the subject's live facts as one system message, and
93
+ `clear()` maps to provable deletion.
94
+
95
+ A runnable, no-LLM example is in [`example.py`](example.py):
96
+ `python clients/langchain/example.py`.
97
+
98
+ Apache-2.0.
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """AttestariRetriever + AttestariChatMessageHistory — no LLM or API key needed.
3
+
4
+ pip install langchain-core
5
+ python clients/langchain/example.py
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pathlib
11
+ import sys
12
+
13
+ # Make `attestari` and `attestari_langchain` importable without installing.
14
+ ROOT = pathlib.Path(__file__).resolve().parents[2]
15
+ sys.path.insert(0, str(ROOT / "src"))
16
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent / "src"))
17
+
18
+ from attestari import Memory # noqa: E402
19
+ from attestari_langchain import AttestariChatMessageHistory, AttestariRetriever # noqa: E402
20
+
21
+
22
+ def main() -> int:
23
+ mem = Memory()
24
+ history = AttestariChatMessageHistory(mem, subject_id="user_42")
25
+
26
+ # What RunnableWithMessageHistory would feed in as the conversation happens:
27
+ from langchain_core.messages import AIMessage, HumanMessage
28
+
29
+ history.add_messages([HumanMessage("Hi, I'm Alice and I live in Toronto.")])
30
+ history.add_messages([AIMessage("Nice to meet you, Alice!")])
31
+ history.add_messages([HumanMessage("I moved to Berlin.")])
32
+
33
+ print("Memory injected into the prompt (history.messages):")
34
+ print(" " + history.messages[0].content.replace("\n", "\n "))
35
+
36
+ # The retriever is how you inject relevant facts into a RAG/agent chain —
37
+ # each Document carries provenance you can show or audit.
38
+ retriever = AttestariRetriever(mem=mem, subject_id="user_42", k=3)
39
+ docs = retriever.invoke("where does the user live?")
40
+ print("\nAttestariRetriever results (with provenance):")
41
+ for d in docs:
42
+ print(f" - {d.page_content!r} [fact_id={d.metadata['fact_id'][:8]}…, "
43
+ f"score={d.metadata['score']:.2f}]")
44
+
45
+ # The differentiator: provable deletion via clear().
46
+ history.clear()
47
+ after = retriever.invoke("where does the user live?")
48
+
49
+ assert any("Berlin" in d.page_content for d in docs), "expected Berlin to supersede Toronto"
50
+ assert after == [], "expected nothing recalled after clear()"
51
+ print("\n✅ Recall, supersession (Berlin > Toronto), and provable deletion all hold.")
52
+ return 0
53
+
54
+
55
+ if __name__ == "__main__":
56
+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "attestari-langchain"
7
+ version = "0.0.1"
8
+ description = "LangChain integration for Attestari — the auditable memory layer for AI agents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ keywords = ["ai", "agents", "memory", "attestari", "langchain"]
13
+ dependencies = [
14
+ "attestari>=0.0.1",
15
+ "langchain-core>=0.3",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8"]
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/attestari_langchain"]
@@ -0,0 +1,125 @@
1
+ """LangChain integration for Attestari — the auditable memory layer for AI agents.
2
+
3
+ Two pieces, both built on the stable `langchain-core` primitives:
4
+
5
+ - **`AttestariRetriever`** (`BaseRetriever`) — recall a subject's relevant facts and
6
+ hand them to a chain/agent as `Document`s, each carrying **provenance** in its
7
+ metadata (fact id, source episode, valid-from, confidence). Supports bi-temporal
8
+ `as_of` retrieval. This is the idiomatic way to inject long-term memory into a
9
+ modern LCEL / RAG chain or an agent tool.
10
+ - **`AttestariChatMessageHistory`** (`BaseChatMessageHistory`) — a drop-in history for
11
+ `RunnableWithMessageHistory`: it *learns facts* from each turn (not a raw
12
+ transcript), surfaces the subject's known facts as context, and maps `clear()`
13
+ to Attestari's provable deletion (`forget`).
14
+
15
+ from attestari import Memory
16
+ from attestari_langchain import AttestariRetriever
17
+
18
+ mem = Memory() # or Memory.postgres() for durable storage
19
+ mem.add("Alice lives in Toronto.", subject_id="user_42")
20
+ retriever = AttestariRetriever(mem=mem, subject_id="user_42")
21
+ retriever.invoke("where does the user live?") # -> [Document("user lives_in Toronto", ...)]
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from attestari import Memory
29
+
30
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
31
+ from langchain_core.chat_history import BaseChatMessageHistory
32
+ from langchain_core.documents import Document
33
+ from langchain_core.messages import BaseMessage, SystemMessage
34
+ from langchain_core.retrievers import BaseRetriever
35
+ from pydantic import ConfigDict
36
+
37
+ __all__ = ["AttestariRetriever", "AttestariChatMessageHistory"]
38
+ __version__ = "0.0.1"
39
+
40
+
41
+ def _triple(edge: Any) -> str:
42
+ return f"{edge.subject} {edge.predicate.replace('_', ' ')} {edge.object}"
43
+
44
+
45
+ class AttestariRetriever(BaseRetriever):
46
+ """Retrieve a subject's relevant facts as provenance-carrying `Document`s."""
47
+
48
+ model_config = ConfigDict(arbitrary_types_allowed=True)
49
+
50
+ mem: Memory
51
+ """The Attestari engine — `Memory()` (in-memory) or `Memory.postgres()` (durable)."""
52
+ subject_id: str | None = None
53
+ """Scope retrieval to one subject. Use an opaque pseudonym, not raw PII."""
54
+ k: int = 5
55
+ """How many facts to recall."""
56
+ as_of: str | None = None
57
+ """Optional bi-temporal instant — recall what was true `as_of` this date."""
58
+
59
+ def _get_relevant_documents(
60
+ self, query: str, *, run_manager: CallbackManagerForRetrieverRun
61
+ ) -> list[Document]:
62
+ results = self.mem.search(
63
+ query, subject_id=self.subject_id, as_of=self.as_of, limit=self.k
64
+ )
65
+ docs: list[Document] = []
66
+ for r in results:
67
+ e = r.edge
68
+ docs.append(
69
+ Document(
70
+ page_content=_triple(e),
71
+ metadata={
72
+ "fact_id": e.fact_id,
73
+ "subject": e.subject,
74
+ "predicate": e.predicate,
75
+ "object": e.object,
76
+ "valid_from": e.valid_from.isoformat() if e.valid_from else None,
77
+ "source_episode_id": e.source_episode_id,
78
+ "confidence": e.confidence,
79
+ "score": r.score,
80
+ },
81
+ )
82
+ )
83
+ return docs
84
+
85
+
86
+ class AttestariChatMessageHistory(BaseChatMessageHistory):
87
+ """A `BaseChatMessageHistory` that learns facts instead of storing a transcript.
88
+
89
+ Drop into `RunnableWithMessageHistory`. `add_messages` ingests each turn into
90
+ Attestari; `messages` returns the subject's current facts as a single system
91
+ message; `clear` performs provable deletion.
92
+ """
93
+
94
+ def __init__(self, mem: Memory, subject_id: str):
95
+ self.mem = mem
96
+ self.subject_id = subject_id
97
+
98
+ @property
99
+ def messages(self) -> list[BaseMessage]:
100
+ live = [
101
+ e
102
+ for e in self.mem.timeline(subject_id=self.subject_id)
103
+ if e.valid_to is None
104
+ ]
105
+ if not live:
106
+ return []
107
+ facts = "\n".join(f"- {_triple(e)}" for e in live)
108
+ return [SystemMessage(content=f"Known facts about the user:\n{facts}")]
109
+
110
+ def add_messages(self, messages: list[BaseMessage]) -> None:
111
+ # Only HUMAN turns become memories. Extractors attribute first-person
112
+ # statements ("I", "my") to the subject — ingesting an AIMessage would
113
+ # mint facts about the *user* out of the assistant's own words (e.g.
114
+ # "I'd say Berlin is lovely" -> lives_in Berlin). The assistant's turns
115
+ # are conversation, not testimony.
116
+ for m in messages:
117
+ if m.type != "human":
118
+ continue
119
+ content = m.content if isinstance(m.content, str) else str(m.content)
120
+ if content.strip():
121
+ self.mem.add(content, subject_id=self.subject_id)
122
+
123
+ def clear(self) -> None:
124
+ """Provable deletion: forget everything for this subject."""
125
+ self.mem.forget(self.subject_id)
@@ -0,0 +1,104 @@
1
+ """Tests for the Attestari LangChain integration (retriever + chat history)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sys
7
+
8
+ import pytest
9
+
10
+ ROOT = pathlib.Path(__file__).resolve().parents[3]
11
+ sys.path.insert(0, str(ROOT / "src"))
12
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
13
+
14
+ pytest.importorskip("langchain_core", reason="needs langchain-core")
15
+
16
+ from langchain_core.messages import AIMessage, HumanMessage # noqa: E402
17
+
18
+ from attestari import Memory # noqa: E402
19
+ from attestari_langchain import AttestariChatMessageHistory, AttestariRetriever # noqa: E402
20
+
21
+
22
+ # -- AttestariRetriever ----------------------------------------------------- #
23
+
24
+ def test_retriever_recalls_with_provenance():
25
+ mem = Memory()
26
+ mem.add("I live in Toronto and I work at Acme.", subject_id="alice")
27
+ docs = AttestariRetriever(mem=mem, subject_id="alice").invoke("where does the user live?")
28
+ assert any("Toronto" in d.page_content for d in docs)
29
+ d = docs[0]
30
+ assert d.metadata["fact_id"] and d.metadata["source_episode_id"]
31
+ assert "score" in d.metadata
32
+
33
+
34
+ def test_retriever_supersession():
35
+ mem = Memory()
36
+ mem.add("I live in Toronto.", subject_id="alice")
37
+ mem.add("I moved to Berlin.", subject_id="alice")
38
+ docs = AttestariRetriever(mem=mem, subject_id="alice").invoke("where does the user live?")
39
+ blob = " ".join(d.page_content for d in docs)
40
+ assert "Berlin" in blob and "Toronto" not in blob
41
+
42
+
43
+ def test_retriever_time_travel_as_of():
44
+ mem = Memory()
45
+ mem.add("I live in Toronto.", subject_id="alice", valid_from="2021-01-01")
46
+ mem.add("I moved to Berlin.", subject_id="alice", valid_from="2026-01-01")
47
+ docs = AttestariRetriever(mem=mem, subject_id="alice", as_of="2022-01-01").invoke(
48
+ "where does the user live?"
49
+ )
50
+ assert any("Toronto" in d.page_content for d in docs)
51
+
52
+
53
+ def test_retriever_respects_k():
54
+ mem = Memory()
55
+ mem.add("I live in Toronto. I work at Acme. My name is Alice.", subject_id="alice")
56
+ docs = AttestariRetriever(mem=mem, subject_id="alice", k=1).invoke("tell me about the user")
57
+ assert len(docs) <= 1
58
+
59
+
60
+ # -- AttestariChatMessageHistory -------------------------------------------- #
61
+
62
+ def test_history_learns_and_surfaces_facts():
63
+ mem = Memory()
64
+ h = AttestariChatMessageHistory(mem, subject_id="alice")
65
+ h.add_messages([HumanMessage("I live in Toronto.")])
66
+ h.add_messages([AIMessage("Noted.")])
67
+ msgs = h.messages
68
+ assert len(msgs) == 1 and "Toronto" in msgs[0].content
69
+
70
+
71
+ def test_history_empty_when_no_facts():
72
+ h = AttestariChatMessageHistory(Memory(), subject_id="nobody")
73
+ assert h.messages == []
74
+
75
+
76
+ def test_history_clear_is_provable_deletion():
77
+ mem = Memory()
78
+ h = AttestariChatMessageHistory(mem, subject_id="alice")
79
+ h.add_messages([HumanMessage("I live in Toronto.")])
80
+ h.clear()
81
+ assert h.messages == []
82
+ assert AttestariRetriever(mem=mem, subject_id="alice").invoke("where?") == []
83
+
84
+
85
+ def test_subject_isolation():
86
+ mem = Memory()
87
+ AttestariChatMessageHistory(mem, "alice").add_messages([HumanMessage("I live in Toronto.")])
88
+ AttestariChatMessageHistory(mem, "bob").add_messages([HumanMessage("I live in Berlin.")])
89
+ bob_docs = AttestariRetriever(mem=mem, subject_id="bob").invoke("where does the user live?")
90
+ blob = " ".join(d.page_content for d in bob_docs)
91
+ assert "Berlin" in blob and "Toronto" not in blob
92
+
93
+
94
+ def test_history_does_not_learn_from_ai_messages():
95
+ """Assistant turns are conversation, not testimony: a first-person pattern
96
+ in an AIMessage must not mint a fact attributed to the user."""
97
+ mem = Memory()
98
+ h = AttestariChatMessageHistory(mem, subject_id="alice")
99
+ h.add_messages([AIMessage("I moved to Berlin and I work at Globex.")])
100
+ assert h.messages == [] # nothing was learned
101
+ assert mem.timeline(subject_id="alice") == []
102
+ h.add_messages([HumanMessage("I live in Toronto.")])
103
+ blob = " ".join(m.content for m in h.messages)
104
+ assert "Toronto" in blob and "Berlin" not in blob