citadeldb-ms-agent-framework 2.0.0__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,18 @@
1
+ /target
2
+ **/*.rs.bk
3
+ *.swp
4
+ *.swo
5
+ *~
6
+ .DS_Store
7
+ site/public/
8
+ site/static/wasm/*.wasm
9
+ site/static/wasm/*.js
10
+ /notes/
11
+ __pycache__/
12
+ *.py[cod]
13
+ # maturin build output; the .pyd is caught by the line above only by accident.
14
+ *.pdb
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ /dist/
18
+ packaging/*/dist/
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: citadeldb-ms-agent-framework
3
+ Version: 2.0.0
4
+ Summary: Microsoft Agent Framework chat history backed by Citadel: encrypted at rest, with deletes that destroy the key
5
+ Project-URL: Homepage, https://citadeldb.dev
6
+ Project-URL: Repository, https://github.com/yp3y5akh0v/citadel
7
+ Author: Yuriy Peysakhov
8
+ License-Expression: Apache-2.0
9
+ Keywords: agent-framework,agents,autogen,chat-history,encryption,memory,microsoft-agent-framework
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Database
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: agent-framework-core<2,>=1.13
17
+ Requires-Dist: citadeldb<3,>=2.0
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
20
+ Requires-Dist: pytest>=8; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # citadeldb-ms-agent-framework
24
+
25
+ [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) storage backed
26
+ by [Citadel](https://citadeldb.dev). Encrypted at rest, embedded in your process, and
27
+ deletes that destroy the key, not just the row.
28
+
29
+ ```
30
+ pip install citadeldb-ms-agent-framework
31
+ ```
32
+
33
+ Two providers for two jobs, matching how the framework's own Redis integration is split:
34
+
35
+ | Class | Implements | Use when |
36
+ |---|---|---|
37
+ | `CitadelHistoryProvider` | `HistoryProvider` | a session must recover its complete transcript |
38
+ | `CitadelContextProvider` | `ContextProvider` | an agent should recall relevant facts across sessions |
39
+
40
+ ```python
41
+ from agent_framework import Agent
42
+ from citadeldb_ms_agent_framework import CitadelContextProvider, CitadelHistoryProvider
43
+
44
+ agent = Agent(
45
+ client=chat_client, # any agent_framework chat client
46
+ context_providers=[
47
+ CitadelHistoryProvider("agent.cdl", key="your-passphrase"),
48
+ CitadelContextProvider("agent.cdl", key="your-passphrase", scope="user-123"),
49
+ ],
50
+ )
51
+ ```
52
+
53
+ Both can share one encrypted file: a path already open on this thread, under the same
54
+ passphrase, is shared. Construct them on the same thread.
55
+
56
+ These are the framework's own extension points, with the file encrypted and a key per
57
+ message. The built-in `FileHistoryProvider` writes plaintext JSONL or MessagePack.
58
+
59
+ ## Deletes destroy the key
60
+
61
+ ```python
62
+ history = CitadelHistoryProvider("agent.cdl", key="your-passphrase")
63
+ memory = CitadelContextProvider("agent.cdl", key="your-passphrase", scope="user-123")
64
+
65
+ await history.forget("session-42") # returns the number erased
66
+ await memory.forget() # this provider's whole scope
67
+ ```
68
+
69
+ Clearing a conversation destroys each message's own key and then deletes its row, so any
70
+ ciphertext surviving elsewhere stays unreadable.
71
+
72
+ ## History provider
73
+
74
+ Implements `get_messages` and `save_messages`; the base class's `before_run`/`after_run`
75
+ handle loading and storing according to its configuration flags, so an audit-only or
76
+ evaluation-only provider works as documented:
77
+
78
+ ```python
79
+ CitadelHistoryProvider("agent.cdl", key="your-passphrase", load_messages=False) # stores, never loads
80
+ ```
81
+
82
+ Messages round-trip through the framework's own serialization, so roles, author names,
83
+ multi-part contents and `additional_properties` all survive.
84
+
85
+ ## Context provider
86
+
87
+ Recalls with Citadel's hybrid search: vector distance, keyword rank and recency, fused
88
+ into one score. The default `MockEmbedder` is lexical; pass `embedder=` a real one to
89
+ match across wording.
90
+
91
+ ```python
92
+ memory = CitadelContextProvider("agent.cdl", key="your-passphrase", scope="user-123", limit=5)
93
+ ```
94
+
95
+ Memories are scoped rather than session-bound, so a later conversation can recall an
96
+ earlier one. `scope` is the boundary an erasure request applies to.
97
+
98
+ ## License
99
+
100
+ Apache-2.0
@@ -0,0 +1,78 @@
1
+ # citadeldb-ms-agent-framework
2
+
3
+ [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) storage backed
4
+ by [Citadel](https://citadeldb.dev). Encrypted at rest, embedded in your process, and
5
+ deletes that destroy the key, not just the row.
6
+
7
+ ```
8
+ pip install citadeldb-ms-agent-framework
9
+ ```
10
+
11
+ Two providers for two jobs, matching how the framework's own Redis integration is split:
12
+
13
+ | Class | Implements | Use when |
14
+ |---|---|---|
15
+ | `CitadelHistoryProvider` | `HistoryProvider` | a session must recover its complete transcript |
16
+ | `CitadelContextProvider` | `ContextProvider` | an agent should recall relevant facts across sessions |
17
+
18
+ ```python
19
+ from agent_framework import Agent
20
+ from citadeldb_ms_agent_framework import CitadelContextProvider, CitadelHistoryProvider
21
+
22
+ agent = Agent(
23
+ client=chat_client, # any agent_framework chat client
24
+ context_providers=[
25
+ CitadelHistoryProvider("agent.cdl", key="your-passphrase"),
26
+ CitadelContextProvider("agent.cdl", key="your-passphrase", scope="user-123"),
27
+ ],
28
+ )
29
+ ```
30
+
31
+ Both can share one encrypted file: a path already open on this thread, under the same
32
+ passphrase, is shared. Construct them on the same thread.
33
+
34
+ These are the framework's own extension points, with the file encrypted and a key per
35
+ message. The built-in `FileHistoryProvider` writes plaintext JSONL or MessagePack.
36
+
37
+ ## Deletes destroy the key
38
+
39
+ ```python
40
+ history = CitadelHistoryProvider("agent.cdl", key="your-passphrase")
41
+ memory = CitadelContextProvider("agent.cdl", key="your-passphrase", scope="user-123")
42
+
43
+ await history.forget("session-42") # returns the number erased
44
+ await memory.forget() # this provider's whole scope
45
+ ```
46
+
47
+ Clearing a conversation destroys each message's own key and then deletes its row, so any
48
+ ciphertext surviving elsewhere stays unreadable.
49
+
50
+ ## History provider
51
+
52
+ Implements `get_messages` and `save_messages`; the base class's `before_run`/`after_run`
53
+ handle loading and storing according to its configuration flags, so an audit-only or
54
+ evaluation-only provider works as documented:
55
+
56
+ ```python
57
+ CitadelHistoryProvider("agent.cdl", key="your-passphrase", load_messages=False) # stores, never loads
58
+ ```
59
+
60
+ Messages round-trip through the framework's own serialization, so roles, author names,
61
+ multi-part contents and `additional_properties` all survive.
62
+
63
+ ## Context provider
64
+
65
+ Recalls with Citadel's hybrid search: vector distance, keyword rank and recency, fused
66
+ into one score. The default `MockEmbedder` is lexical; pass `embedder=` a real one to
67
+ match across wording.
68
+
69
+ ```python
70
+ memory = CitadelContextProvider("agent.cdl", key="your-passphrase", scope="user-123", limit=5)
71
+ ```
72
+
73
+ Memories are scoped rather than session-bound, so a later conversation can recall an
74
+ earlier one. `scope` is the boundary an erasure request applies to.
75
+
76
+ ## License
77
+
78
+ Apache-2.0
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # Named for the framework's owner: "agent framework" alone reads as a category.
7
+ name = "citadeldb-ms-agent-framework"
8
+ dynamic = ["version"]
9
+ description = "Microsoft Agent Framework chat history backed by Citadel: encrypted at rest, with deletes that destroy the key"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = "Apache-2.0"
13
+ authors = [{ name = "Yuriy Peysakhov" }]
14
+ keywords = [
15
+ "microsoft-agent-framework", "agent-framework", "autogen", "agents", "memory",
16
+ "chat-history", "encryption",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Database",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ ]
25
+ # A filtered recall answers `k` matching rows from 2.0; before it, a scope's
26
+ # own memories could rank outside the scan and recall would return short.
27
+ dependencies = ["citadeldb>=2.0,<3", "agent-framework-core>=1.13,<2"]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8", "pytest-asyncio>=0.23"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://citadeldb.dev"
34
+ Repository = "https://github.com/yp3y5akh0v/citadel"
35
+
36
+ # The version comes from the release tag, so there is nothing to bump.
37
+ [tool.hatch.version]
38
+ source = "vcs"
39
+ raw-options = { root = "../..", tag_regex = '^v(?P<version>\d+\.\d+\.\d+)$' }
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/citadeldb_ms_agent_framework"]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
@@ -0,0 +1,14 @@
1
+ """Microsoft Agent Framework storage backed by Citadel, encrypted at rest."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .history import CitadelHistoryProvider
6
+ from .memory import CitadelContextProvider
7
+
8
+ __all__ = ["CitadelContextProvider", "CitadelHistoryProvider", "__version__"]
9
+
10
+
11
+ try:
12
+ __version__ = version("citadeldb-ms-agent-framework")
13
+ except PackageNotFoundError: # running from a source tree, never installed
14
+ __version__ = "0+unknown"
@@ -0,0 +1,164 @@
1
+ """HistoryProvider over an encrypted Citadel region."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any, ClassVar, Sequence
6
+
7
+ import citadeldb
8
+ from agent_framework import HistoryProvider, Message
9
+
10
+ KIND = "message"
11
+ DEFAULT_PATH = "agent_history.cdl"
12
+ DEFAULT_REGION = "history"
13
+ PAGE = 10_000
14
+
15
+
16
+ def _page(mem: Any, region: str, session_id: str) -> list[Any]:
17
+ """Page to the end: one fetch is bounded, and a partial erase must not look whole."""
18
+ out: list[Any] = []
19
+ after = None
20
+ while True:
21
+ got = mem.fetch(region, KIND, payload_filter={"sid": session_id}, limit=PAGE,
22
+ after_id=after)
23
+ out.extend(got)
24
+ if len(got) < PAGE:
25
+ return out
26
+ after = got[-1].id
27
+ # session_id is optional in the protocol; keep unattributed history together.
28
+ DEFAULT_SESSION = "default"
29
+
30
+
31
+ # A Database is pinned to its opening thread, so workers take Memory, not self.
32
+
33
+
34
+ def _text_of(message: Message) -> str:
35
+ """An atom needs text to embed; a textless message falls back to role."""
36
+ return message.text or str(message.role)
37
+
38
+
39
+ def _load(mem: Any, region: str, session_id: str) -> list[Message]:
40
+ hits = _page(mem, region, session_id)
41
+ return [Message.from_dict(h.payload["msg"]) for h in hits]
42
+
43
+
44
+ def _append(mem: Any, region: str, session_id: str, messages: Sequence[Message]) -> None:
45
+ atoms = [
46
+ {
47
+ "kind": KIND,
48
+ "text": _text_of(m),
49
+ "payload": {"sid": session_id, "msg": m.to_dict()},
50
+ }
51
+ for m in messages
52
+ ]
53
+ if atoms:
54
+ # One batch draws a contiguous id range, so list order survives.
55
+ mem.remember_batch(region, atoms)
56
+
57
+
58
+ def _forget(mem: Any, region: str, session_id: str) -> int:
59
+ hits = _page(mem, region, session_id)
60
+ if not hits:
61
+ return 0
62
+ return mem.forget(region, [h.id for h in hits]).erased_count
63
+
64
+
65
+ def _recall(
66
+ mem: Any, region: str, session_id: str, query: str, limit: int
67
+ ) -> list[Message]:
68
+ hits = mem.recall(
69
+ region,
70
+ text=query,
71
+ k=limit,
72
+ kinds=[KIND],
73
+ options=citadeldb.RecallOptions(payload_filter={"sid": session_id}),
74
+ )
75
+ return [Message.from_dict(h.payload["msg"]) for h in hits]
76
+
77
+
78
+ class CitadelHistoryProvider(HistoryProvider):
79
+ """A `HistoryProvider` backed by one encrypted Citadel region."""
80
+
81
+ DEFAULT_SOURCE_ID: ClassVar[str] = "citadel_history"
82
+
83
+ def __init__(
84
+ self,
85
+ path: str = DEFAULT_PATH,
86
+ key: str = "",
87
+ *,
88
+ source_id: str = DEFAULT_SOURCE_ID,
89
+ region: str = DEFAULT_REGION,
90
+ embedder: Any | None = None,
91
+ load_messages: bool = True,
92
+ store_inputs: bool = True,
93
+ store_context_messages: bool = False,
94
+ store_context_from: set[str] | None = None,
95
+ store_outputs: bool = True,
96
+ ) -> None:
97
+ super().__init__(
98
+ source_id=source_id,
99
+ load_messages=load_messages,
100
+ store_inputs=store_inputs,
101
+ store_context_messages=store_context_messages,
102
+ store_context_from=store_context_from,
103
+ store_outputs=store_outputs,
104
+ )
105
+ if not key:
106
+ raise ValueError("a passphrase is required: transcripts are the payload")
107
+ try:
108
+ self._db = citadeldb.connect(path, key=key, region_keys=True)
109
+ except citadeldb.OperationalError as e:
110
+ if "locked" not in str(e):
111
+ raise
112
+ raise RuntimeError(
113
+ f"{path} is open in another process. Citadel is embedded, so one "
114
+ f"process owns the file."
115
+ ) from e
116
+ self._mem = self._db.memory()
117
+ self._region = region
118
+ # Idempotent for a region of the same width, so a dim clash raises here.
119
+ self._mem.create_encrypted_region(
120
+ region, embedder or citadeldb.MockEmbedder(dim=64)
121
+ )
122
+
123
+ # ---- the abstract surface --------------------------------------------
124
+ # The bindings are sync, so a worker thread keeps the event loop free.
125
+
126
+ async def get_messages(
127
+ self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
128
+ ) -> list[Message]:
129
+ return await asyncio.to_thread(
130
+ _load, self._mem, self._region, session_id or DEFAULT_SESSION
131
+ )
132
+
133
+ async def save_messages(
134
+ self,
135
+ session_id: str | None,
136
+ messages: Sequence[Message],
137
+ *,
138
+ state: dict[str, Any] | None = None,
139
+ **kwargs: Any,
140
+ ) -> None:
141
+ # Appends rather than replaces: history is a transcript, not a set.
142
+ await asyncio.to_thread(
143
+ _append,
144
+ self._mem,
145
+ self._region,
146
+ session_id or DEFAULT_SESSION,
147
+ list(messages),
148
+ )
149
+
150
+ # ---- beyond the protocol ---------------------------------------------
151
+
152
+ async def search(
153
+ self, session_id: str | None, query: str, *, limit: int = 5
154
+ ) -> list[Message]:
155
+ """Messages from one session ranked by hybrid recall, best first."""
156
+ return await asyncio.to_thread(
157
+ _recall, self._mem, self._region, session_id or DEFAULT_SESSION, query, limit
158
+ )
159
+
160
+ async def forget(self, session_id: str | None) -> int:
161
+ """Destroy one session's messages, returning the number erased."""
162
+ return await asyncio.to_thread(
163
+ _forget, self._mem, self._region, session_id or DEFAULT_SESSION
164
+ )
@@ -0,0 +1,163 @@
1
+ """ContextProvider over an encrypted Citadel region."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any, ClassVar, Sequence
6
+
7
+ import citadeldb
8
+ from agent_framework import ContextProvider, Message
9
+
10
+ KIND = "memory"
11
+ DEFAULT_PATH = "agent_memory.cdl"
12
+ DEFAULT_REGION = "memories"
13
+ PAGE = 10_000
14
+
15
+
16
+ def _page(mem: Any, region: str, criterion: dict[str, Any]) -> list[Any]:
17
+ """Page to the end: one fetch is bounded, and a partial erase must not look whole."""
18
+ out: list[Any] = []
19
+ after = None
20
+ while True:
21
+ got = mem.fetch(region, KIND, payload_filter=criterion, limit=PAGE, after_id=after)
22
+ out.extend(got)
23
+ if len(got) < PAGE:
24
+ return out
25
+ after = got[-1].id
26
+ # Roles worth remembering; tool traffic is transcript detail, not knowledge.
27
+ _REMEMBERED_ROLES = ("user", "assistant", "system")
28
+
29
+
30
+ # A Database is pinned to its opening thread, so workers take Memory, not self.
31
+
32
+
33
+ def _role_of(message: Message) -> str:
34
+ role = message.role
35
+ return getattr(role, "value", None) or str(role)
36
+
37
+
38
+ def _remember(mem: Any, region: str, scope: str, texts: Sequence[str]) -> None:
39
+ atoms = [
40
+ {"kind": KIND, "text": t, "payload": {"scope": scope, "text": t}} for t in texts
41
+ ]
42
+ if atoms:
43
+ mem.remember_batch(region, atoms)
44
+
45
+
46
+ def _recall(mem: Any, region: str, scope: str, query: str, limit: int) -> list[str]:
47
+ """Distinct memories for `scope`, best first.
48
+
49
+ after_run stores every turn verbatim, so a fact the user restates is stored
50
+ once per turn. Those copies are one memory to the model, and asking the
51
+ engine for `limit` rows would spend the whole budget on them - 30 repeats of
52
+ one fact deliver one line and hide everything else in the scope. Widen until
53
+ `limit` distinct texts are found or the scope runs out.
54
+ """
55
+ options = citadeldb.RecallOptions(payload_filter={"scope": scope})
56
+ k = max(limit, 32)
57
+ while True:
58
+ hits = mem.recall(region, text=query, k=k, kinds=[KIND], options=options)
59
+ out: list[str] = []
60
+ seen: set[str] = set()
61
+ for h in hits:
62
+ text = h.payload["text"]
63
+ if text in seen:
64
+ continue
65
+ seen.add(text)
66
+ out.append(text)
67
+ if len(out) >= limit or len(hits) < k:
68
+ return out[:limit]
69
+ k *= 2
70
+
71
+
72
+ def _forget(mem: Any, region: str, scope: str) -> int:
73
+ hits = _page(mem, region, {"scope": scope})
74
+ if not hits:
75
+ return 0
76
+ return mem.forget(region, [h.id for h in hits]).erased_count
77
+
78
+
79
+ class CitadelContextProvider(ContextProvider):
80
+ """A `ContextProvider` backed by one encrypted Citadel region."""
81
+
82
+ DEFAULT_SOURCE_ID: ClassVar[str] = "citadel_memory"
83
+ DEFAULT_CONTEXT_PROMPT: ClassVar[str] = (
84
+ "## Memories\nConsider the following memories from earlier conversations:"
85
+ )
86
+
87
+ def __init__(
88
+ self,
89
+ path: str = DEFAULT_PATH,
90
+ key: str = "",
91
+ *,
92
+ source_id: str = DEFAULT_SOURCE_ID,
93
+ scope: str = "default",
94
+ region: str = DEFAULT_REGION,
95
+ embedder: Any | None = None,
96
+ limit: int = 5,
97
+ context_prompt: str = DEFAULT_CONTEXT_PROMPT,
98
+ ) -> None:
99
+ super().__init__(source_id)
100
+ if not key:
101
+ raise ValueError("a passphrase is required: memories are the payload")
102
+ self.scope = scope
103
+ self.limit = limit
104
+ self.context_prompt = context_prompt
105
+ try:
106
+ self._db = citadeldb.connect(path, key=key, region_keys=True)
107
+ except citadeldb.OperationalError as e:
108
+ if "locked" not in str(e):
109
+ raise
110
+ raise RuntimeError(
111
+ f"{path} is open in another process. Citadel is embedded, so one "
112
+ f"process owns the file."
113
+ ) from e
114
+ self._mem = self._db.memory()
115
+ self._region = region
116
+ # Idempotent for a region of the same width, so a dim clash raises here.
117
+ self._mem.create_encrypted_region(
118
+ region, embedder or citadeldb.MockEmbedder(dim=64)
119
+ )
120
+
121
+ # ---- the pipeline hooks ----------------------------------------------
122
+
123
+ async def before_run(
124
+ self, *, agent: Any, session: Any, context: Any, state: dict[str, Any]
125
+ ) -> None:
126
+ """Recall what is relevant to this turn and add it to the context."""
127
+ query = "\n".join(
128
+ m.text for m in context.input_messages if m and m.text and m.text.strip()
129
+ )
130
+ if not query:
131
+ return
132
+ memories = await asyncio.to_thread(
133
+ _recall, self._mem, self._region, self.scope, query, self.limit
134
+ )
135
+ if not memories:
136
+ return
137
+ context.extend_messages(
138
+ self.source_id,
139
+ [Message("user", [f"{self.context_prompt}\n" + "\n".join(memories)])],
140
+ )
141
+
142
+ async def after_run(
143
+ self, *, agent: Any, session: Any, context: Any, state: dict[str, Any]
144
+ ) -> None:
145
+ """Remember this turn, inputs and response alike."""
146
+ turn: list[Message] = list(context.input_messages)
147
+ if context.response and context.response.messages:
148
+ turn.extend(context.response.messages)
149
+ texts = [
150
+ m.text
151
+ for m in turn
152
+ if m and m.text and m.text.strip() and _role_of(m) in _REMEMBERED_ROLES
153
+ ]
154
+ if texts:
155
+ await asyncio.to_thread(
156
+ _remember, self._mem, self._region, self.scope, texts
157
+ )
158
+
159
+ # ---- beyond the pipeline ---------------------------------------------
160
+
161
+ async def forget(self) -> int:
162
+ """Destroy this scope's memories, returning the number erased."""
163
+ return await asyncio.to_thread(_forget, self._mem, self._region, self.scope)
@@ -0,0 +1,366 @@
1
+ import pytest
2
+ from agent_framework import (
3
+ AgentSession,
4
+ ContextProvider,
5
+ FileHistoryProvider,
6
+ HistoryProvider,
7
+ InMemoryHistoryProvider,
8
+ Message,
9
+ SessionContext,
10
+ )
11
+
12
+ from citadeldb_ms_agent_framework import CitadelHistoryProvider
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def path(tmp_path_factory):
17
+ # Citadel takes an exclusive lock, so the whole module shares one file.
18
+ return str(tmp_path_factory.mktemp("maf") / "h.cdl")
19
+
20
+
21
+ @pytest.fixture()
22
+ def provider(path, request):
23
+ return CitadelHistoryProvider(path, "pw", source_id=request.node.name)
24
+
25
+
26
+ def sid(request) -> str:
27
+ return request.node.name
28
+
29
+
30
+ def msg(text, role="user", **kw):
31
+ return Message(role, [text], **kw)
32
+
33
+
34
+ def context(session_id, inputs):
35
+ return SessionContext(session_id=session_id, input_messages=list(inputs))
36
+
37
+
38
+ # ---- conformance ---------------------------------------------------------
39
+
40
+
41
+ def test_is_a_history_provider(provider):
42
+ assert isinstance(provider, HistoryProvider)
43
+ assert isinstance(provider, ContextProvider)
44
+
45
+
46
+ def test_configuration_flags_reach_the_base_class(path):
47
+ """before_run/after_run are the base class's; they read these flags."""
48
+ p = CitadelHistoryProvider(
49
+ path, "pw", source_id="flags", load_messages=False, store_outputs=False,
50
+ store_context_messages=True, store_context_from={"other"},
51
+ )
52
+ assert p.load_messages is False
53
+ assert p.store_outputs is False
54
+ assert p.store_context_messages is True
55
+ assert p.store_context_from == {"other"}
56
+ assert p.store_inputs is True
57
+
58
+
59
+ def test_source_id_defaults_to_a_named_constant(path):
60
+ p = CitadelHistoryProvider(path, "pw")
61
+ assert p.source_id == CitadelHistoryProvider.DEFAULT_SOURCE_ID
62
+
63
+
64
+ # ---- round-trip ----------------------------------------------------------
65
+
66
+
67
+ async def test_messages_round_trip_in_order(provider, request):
68
+ s = sid(request)
69
+ await provider.save_messages(s, [msg("one"), msg("two", "assistant")])
70
+ await provider.save_messages(s, [msg("three")])
71
+ got = await provider.get_messages(s)
72
+ assert [m.text for m in got] == ["one", "two", "three"]
73
+
74
+
75
+ async def test_roles_and_author_survive(provider, request):
76
+ s = sid(request)
77
+ await provider.save_messages(s, [
78
+ msg("sys", "system"),
79
+ msg("q", "user", author_name="alice"),
80
+ msg("a", "assistant"),
81
+ ])
82
+ got = await provider.get_messages(s)
83
+ assert [str(m.role) for m in got] == [str(m.role) for m in got] # stable
84
+ assert got[1].author_name == "alice"
85
+
86
+
87
+ async def test_additional_properties_survive(provider, request):
88
+ s = sid(request)
89
+ await provider.save_messages(
90
+ s, [Message("user", ["x"], additional_properties={"_excluded": True})]
91
+ )
92
+ got = await provider.get_messages(s)
93
+ assert got[0].additional_properties["_excluded"] is True
94
+
95
+
96
+ async def test_multi_content_messages_round_trip(provider, request):
97
+ s = sid(request)
98
+ await provider.save_messages(s, [Message("user", ["first", "second"])])
99
+ got = await provider.get_messages(s)
100
+ assert len(got[0].contents) == 2
101
+
102
+
103
+ # ---- semantics against the built-in providers ----------------------------
104
+
105
+
106
+ async def test_save_appends_rather_than_replaces(provider, request):
107
+ """Built-in providers extend the stored list; history is a transcript."""
108
+ s = sid(request)
109
+ reference = InMemoryHistoryProvider()
110
+ state: dict = {}
111
+ for batch in ([msg("a")], [msg("b")]):
112
+ await provider.save_messages(s, batch)
113
+ await reference.save_messages(s, batch, state=state)
114
+ ours = [m.text for m in await provider.get_messages(s)]
115
+ theirs = [m.text for m in await reference.get_messages(s, state=state)]
116
+ assert ours == theirs == ["a", "b"]
117
+
118
+
119
+ async def test_saving_nothing_is_not_an_error(provider, request):
120
+ s = sid(request)
121
+ await provider.save_messages(s, [])
122
+ assert await provider.get_messages(s) == []
123
+
124
+
125
+ async def test_an_unseen_session_is_empty_not_an_error(provider):
126
+ assert await provider.get_messages("never-used") == []
127
+
128
+
129
+ # ---- before_run and after_run --------------------------------------------
130
+
131
+
132
+ async def test_before_run_loads_stored_history_into_context(provider, request):
133
+ s = sid(request)
134
+ await provider.save_messages(s, [msg("remembered")])
135
+ ctx = context(s, [msg("new question")])
136
+ await provider.before_run(
137
+ agent=None, session=AgentSession(session_id=s), context=ctx, state={}
138
+ )
139
+ loaded = ctx.get_messages(sources={provider.source_id})
140
+ assert [m.text for m in loaded] == ["remembered"]
141
+
142
+
143
+ async def test_after_run_stores_the_input_messages(provider, request):
144
+ """store_inputs defaults True, so a turn's inputs land in the transcript."""
145
+ s = sid(request)
146
+ ctx = context(s, [msg("asked")])
147
+ await provider.after_run(
148
+ agent=None, session=AgentSession(session_id=s), context=ctx, state={}
149
+ )
150
+ assert [m.text for m in await provider.get_messages(s)] == ["asked"]
151
+
152
+
153
+ async def test_a_full_turn_accumulates(provider, request):
154
+ s = sid(request)
155
+ session = AgentSession(session_id=s)
156
+ for text in ("first", "second"):
157
+ ctx = context(s, [msg(text)])
158
+ await provider.before_run(agent=None, session=session, context=ctx, state={})
159
+ await provider.after_run(agent=None, session=session, context=ctx, state={})
160
+ assert [m.text for m in await provider.get_messages(s)] == ["first", "second"]
161
+
162
+
163
+ async def test_load_messages_false_still_stores(path):
164
+ """An audit-only provider: stores but never loads."""
165
+ p = CitadelHistoryProvider(path, "pw", source_id="audit", load_messages=False)
166
+ ctx = context("audit-s", [msg("recorded")])
167
+ await p.after_run(
168
+ agent=None, session=AgentSession(session_id="audit-s"), context=ctx, state={}
169
+ )
170
+ assert [m.text for m in await p.get_messages("audit-s")] == ["recorded"]
171
+
172
+
173
+ async def test_store_inputs_false_records_nothing_from_inputs(path):
174
+ p = CitadelHistoryProvider(path, "pw", source_id="nostore", store_inputs=False)
175
+ ctx = context("nostore-s", [msg("ignored")])
176
+ await p.after_run(
177
+ agent=None, session=AgentSession(session_id="nostore-s"), context=ctx, state={}
178
+ )
179
+ assert await p.get_messages("nostore-s") == []
180
+
181
+
182
+ @pytest.mark.filterwarnings("ignore:.*FileHistoryProvider is experimental.*")
183
+ async def test_matches_the_file_provider_on_a_turn(provider, request, tmp_path):
184
+ """FileHistoryProvider is the closest built-in analogue; behave like it."""
185
+ s = sid(request)
186
+ reference = FileHistoryProvider(str(tmp_path / "hist"), source_id="ref")
187
+ for p in (provider, reference):
188
+ ctx = context(s, [msg("a"), msg("b", "assistant")])
189
+ await p.after_run(
190
+ agent=None, session=AgentSession(session_id=s), context=ctx, state={}
191
+ )
192
+ ours = [m.text for m in await provider.get_messages(s)]
193
+ theirs = [m.text for m in await reference.get_messages(s)]
194
+ assert ours == theirs
195
+
196
+
197
+ async def test_state_is_ignored_like_the_file_provider(provider, request):
198
+ """State-backed storage is InMemoryHistoryProvider's model, not ours."""
199
+ s = sid(request)
200
+ await provider.save_messages(s, [msg("stored")], state={"messages": []})
201
+ got = await provider.get_messages(s, state={"messages": [msg("phantom")]})
202
+ assert [m.text for m in got] == ["stored"]
203
+
204
+
205
+ # ---- session scoping -----------------------------------------------------
206
+
207
+
208
+ async def test_search_finds_a_session_buried_under_another(provider):
209
+ """Discarding after the scan spends the budget on the busy session."""
210
+ quiet, noisy = "buried-quiet", "buried-noisy"
211
+ # Ranked above the target for this query, and enough of them to fill the scan.
212
+ await provider.save_messages(
213
+ noisy, [msg(f"why did the release break run {i}") for i in range(60)]
214
+ )
215
+ await provider.save_messages(
216
+ quiet, [msg("the deployment failed because the disk was full")]
217
+ )
218
+ hits = await provider.search(quiet, "why did the release break?", limit=1)
219
+ assert hits, "the quiet session's only match was crowded out"
220
+ assert "disk was full" in hits[0].text
221
+
222
+
223
+ async def test_sessions_do_not_see_each_other(provider):
224
+ await provider.save_messages("iso-a", [msg("mine")])
225
+ await provider.save_messages("iso-b", [msg("yours")])
226
+ assert [m.text for m in await provider.get_messages("iso-a")] == ["mine"]
227
+
228
+
229
+ async def test_session_ids_are_matched_exactly_not_by_prefix(provider):
230
+ await provider.save_messages("pre", [msg("outer")])
231
+ await provider.save_messages("pre-fix", [msg("inner")])
232
+ assert [m.text for m in await provider.get_messages("pre")] == ["outer"]
233
+
234
+
235
+ async def test_a_none_session_id_uses_one_stable_bucket(provider):
236
+ """The protocol allows None; the file provider uses a fixed stem too."""
237
+ await provider.save_messages(None, [msg("unattributed")])
238
+ assert [m.text for m in await provider.get_messages(None)] == ["unattributed"]
239
+
240
+
241
+ # ---- erasure -------------------------------------------------------------
242
+
243
+
244
+ async def test_forget_destroys_one_session(provider):
245
+ await provider.save_messages("gone", [msg("a"), msg("b")])
246
+ await provider.save_messages("kept", [msg("c")])
247
+ assert await provider.forget("gone") == 2
248
+ assert await provider.get_messages("gone") == []
249
+ assert len(await provider.get_messages("kept")) == 1
250
+
251
+
252
+ async def test_forgetting_nothing_is_zero_not_an_error(provider):
253
+ assert await provider.forget("never-existed") == 0
254
+
255
+
256
+ async def test_a_session_is_reusable_after_forgetting(provider):
257
+ await provider.save_messages("reuse", [msg("first")])
258
+ await provider.forget("reuse")
259
+ await provider.save_messages("reuse", [msg("second")])
260
+ assert [m.text for m in await provider.get_messages("reuse")] == ["second"]
261
+
262
+
263
+ async def test_it_survives_a_reopen(tmp_path):
264
+ """A provider that cannot be reopened is not persistence."""
265
+ import gc
266
+
267
+ p = str(tmp_path / "reopen.cdl")
268
+ first = CitadelHistoryProvider(p, "pw")
269
+ await first.save_messages("s", [msg("the disk was full"), msg("ok", "assistant")])
270
+ del first
271
+ gc.collect()
272
+
273
+ again = CitadelHistoryProvider(p, "pw")
274
+ assert [m.text for m in await again.get_messages("s")] == [
275
+ "the disk was full", "ok"
276
+ ]
277
+
278
+
279
+ async def test_concurrent_saves_all_land(tmp_path):
280
+ """Many in-flight sessions means many worker threads against one engine."""
281
+ import asyncio
282
+
283
+ h = CitadelHistoryProvider(str(tmp_path / "conc.cdl"), "pw")
284
+ await asyncio.gather(*(h.save_messages(f"s{i}", [msg(f"m{i}")]) for i in range(40)))
285
+ total = 0
286
+ for i in range(40):
287
+ total += len(await h.get_messages(f"s{i}"))
288
+ assert total == 40
289
+
290
+
291
+ async def test_a_wrong_passphrase_cannot_reopen(tmp_path):
292
+ """Transcripts are the payload, so the encryption claim is pinned here."""
293
+ import gc
294
+
295
+ import citadeldb
296
+
297
+ p = str(tmp_path / "enc.cdl")
298
+ first = CitadelHistoryProvider(p, "right")
299
+ await first.save_messages("s", [msg("secret")])
300
+ del first
301
+ gc.collect()
302
+
303
+ with pytest.raises(citadeldb.EncryptionError):
304
+ CitadelHistoryProvider(p, "wrong")
305
+
306
+
307
+ def test_a_passphrase_is_required(tmp_path):
308
+ with pytest.raises(ValueError, match="passphrase"):
309
+ CitadelHistoryProvider(str(tmp_path / "k.cdl"), "")
310
+
311
+
312
+ # ---- edges ---------------------------------------------------------------
313
+
314
+
315
+ async def test_a_message_with_no_text_is_storable(provider, request):
316
+ """An atom needs text to embed, so a textless message needs a handle."""
317
+ s = sid(request)
318
+ await provider.save_messages(s, [Message("assistant", [])])
319
+ got = await provider.get_messages(s)
320
+ assert len(got) == 1 and got[0].text == ""
321
+
322
+
323
+ async def test_a_long_history_keeps_its_order(provider, request):
324
+ s = sid(request)
325
+ await provider.save_messages(s, [msg(f"turn {i}") for i in range(200)])
326
+ assert [m.text for m in await provider.get_messages(s)] == [
327
+ f"turn {i}" for i in range(200)
328
+ ]
329
+
330
+
331
+ async def test_order_survives_across_separate_batches(provider, request):
332
+ s = sid(request)
333
+ for i in range(20):
334
+ await provider.save_messages(s, [msg(f"m{i}")])
335
+ assert [m.text for m in await provider.get_messages(s)] == [
336
+ f"m{i}" for i in range(20)
337
+ ]
338
+
339
+
340
+ async def test_two_providers_share_one_database_file(tmp_path):
341
+ p = str(tmp_path / "shared.cdl")
342
+ a = CitadelHistoryProvider(p, "pw", source_id="a")
343
+ b = CitadelHistoryProvider(p, "pw", source_id="b")
344
+ await a.save_messages("s", [msg("written by a")])
345
+ assert [m.text for m in await b.get_messages("s")] == ["written by a"]
346
+
347
+
348
+ async def test_the_event_loop_is_not_blocked(provider, request):
349
+ """The abstract methods are async, so sync bindings run off the loop."""
350
+ import asyncio
351
+
352
+ s = sid(request)
353
+ ticks = 0
354
+
355
+ async def tick():
356
+ nonlocal ticks
357
+ while True:
358
+ ticks += 1
359
+ await asyncio.sleep(0)
360
+
361
+ ticker = asyncio.create_task(tick())
362
+ await asyncio.sleep(0)
363
+ await provider.save_messages(s, [msg(f"loop {i}") for i in range(40)])
364
+ await provider.get_messages(s)
365
+ ticker.cancel()
366
+ assert ticks > 1, "the loop made no progress during a provider call"
@@ -0,0 +1,264 @@
1
+ import pytest
2
+ from agent_framework import AgentSession, ContextProvider, Message, SessionContext
3
+
4
+ from citadeldb_ms_agent_framework import CitadelContextProvider
5
+
6
+
7
+ @pytest.fixture(scope="module")
8
+ def path(tmp_path_factory):
9
+ # Citadel takes an exclusive lock, so the whole module shares one file.
10
+ return str(tmp_path_factory.mktemp("mem") / "m.cdl")
11
+
12
+
13
+ @pytest.fixture()
14
+ def provider(path, request):
15
+ return CitadelContextProvider(
16
+ path, "pw", source_id=request.node.name, scope=request.node.name
17
+ )
18
+
19
+
20
+ def msg(text, role="user"):
21
+ return Message(role, [text])
22
+
23
+
24
+ def context(inputs, session_id="s"):
25
+ return SessionContext(session_id=session_id, input_messages=list(inputs))
26
+
27
+
28
+ async def turn(provider, texts, session_id="s"):
29
+ ctx = context([msg(t) for t in texts], session_id)
30
+ await provider.after_run(
31
+ agent=None, session=AgentSession(session_id=session_id), context=ctx, state={}
32
+ )
33
+
34
+
35
+ # ---- conformance ---------------------------------------------------------
36
+
37
+
38
+ def test_is_a_context_provider(provider):
39
+ assert isinstance(provider, ContextProvider)
40
+ assert provider.source_id
41
+
42
+
43
+ def test_a_passphrase_is_required(tmp_path):
44
+ with pytest.raises(ValueError, match="passphrase"):
45
+ CitadelContextProvider(str(tmp_path / "k.cdl"), "")
46
+
47
+
48
+ # ---- recall --------------------------------------------------------------
49
+
50
+
51
+ async def test_a_restated_fact_does_not_crowd_out_the_rest_of_the_scope(provider):
52
+ """after_run stores every turn verbatim, so a fact the user restates is
53
+ stored once per turn. Those copies are one memory to the model, so asking
54
+ the engine for `limit` rows spends the whole budget on them."""
55
+ for _ in range(40):
56
+ await turn(provider, ["the deploy failed because the disk was full"])
57
+ for n in range(4):
58
+ await turn(provider, [f"unrelated note {n}"])
59
+
60
+ ctx = context([msg("why did the release break?")])
61
+ await provider.before_run(
62
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
63
+ )
64
+ delivered = "\n".join(m.text for m in ctx.get_messages(sources={provider.source_id}))
65
+ assert delivered.count("the disk was full") == 1, "duplicates reached the model"
66
+ for n in range(4):
67
+ assert f"unrelated note {n}" in delivered, (
68
+ f"note {n} was crowded out by 40 copies of one fact"
69
+ )
70
+
71
+
72
+ async def test_a_memory_is_recalled_into_the_context(provider):
73
+ await turn(provider, ["the deploy failed because the disk was full"])
74
+ ctx = context([msg("why did the release break?")])
75
+ await provider.before_run(
76
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
77
+ )
78
+ added = ctx.get_messages(sources={provider.source_id})
79
+ assert added, "nothing was recalled"
80
+ assert "disk was full" in added[0].text
81
+
82
+
83
+ async def test_a_scope_buried_under_another_is_still_recalled(path, request):
84
+ """Discarding after the scan spends the budget on the crowded scope."""
85
+ name = request.node.name
86
+ noisy = CitadelContextProvider(path, "pw", source_id=f"{name}-n", scope=f"{name}-n")
87
+ quiet = CitadelContextProvider(path, "pw", source_id=name, scope=name)
88
+ # Ranked above the target for this query, and enough of them to fill the scan.
89
+ await turn(noisy, [f"why did the release break run {i}" for i in range(60)])
90
+ await turn(quiet, ["the deploy failed because the disk was full"])
91
+
92
+ ctx = context([msg("why did the release break?")])
93
+ await quiet.before_run(
94
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
95
+ )
96
+ added = ctx.get_messages(sources={quiet.source_id})
97
+ assert added, "the quiet scope's only memory was crowded out"
98
+ assert "disk was full" in added[0].text
99
+
100
+
101
+ async def test_the_context_prompt_frames_the_memories(provider):
102
+ await turn(provider, ["remembered fact"])
103
+ ctx = context([msg("recall")])
104
+ await provider.before_run(
105
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
106
+ )
107
+ added = ctx.get_messages(sources={provider.source_id})
108
+ assert added[0].text.startswith(CitadelContextProvider.DEFAULT_CONTEXT_PROMPT)
109
+
110
+
111
+ async def test_recall_is_capped_by_limit(path):
112
+ p = CitadelContextProvider(path, "pw", source_id="cap", scope="cap", limit=2)
113
+ await turn(p, [f"fact number {i}" for i in range(6)])
114
+ ctx = context([msg("fact")])
115
+ await p.before_run(
116
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
117
+ )
118
+ added = ctx.get_messages(sources={p.source_id})
119
+ recalled = added[0].text.removeprefix(
120
+ CitadelContextProvider.DEFAULT_CONTEXT_PROMPT
121
+ ).strip().splitlines()
122
+ assert len(recalled) == 2, recalled
123
+
124
+
125
+ async def test_a_repeated_fact_does_not_spend_the_whole_budget(path):
126
+ """A fact repeated across turns is stored each time, and it is one fact."""
127
+ p = CitadelContextProvider(path, "pw", source_id="dedup", scope="dedup", limit=5)
128
+ for _ in range(5):
129
+ await turn(p, ["my dog is called Mochi"])
130
+ await turn(p, ["I live in Berlin"])
131
+
132
+ ctx = context([msg("what do you know about me?")])
133
+ await p.before_run(
134
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
135
+ )
136
+ recalled = ctx.get_messages(sources={p.source_id})[0].text.removeprefix(
137
+ CitadelContextProvider.DEFAULT_CONTEXT_PROMPT
138
+ ).strip().splitlines()
139
+ assert len(recalled) == len(set(recalled)), recalled
140
+ assert any("Mochi" in r for r in recalled)
141
+ assert any("Berlin" in r for r in recalled)
142
+
143
+
144
+ async def test_nothing_is_added_when_there_is_nothing_to_recall(provider):
145
+ ctx = context([msg("a question with no history behind it")])
146
+ await provider.before_run(
147
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
148
+ )
149
+ assert ctx.get_messages(sources={provider.source_id}) == []
150
+
151
+
152
+ async def test_an_empty_input_recalls_nothing(provider):
153
+ await turn(provider, ["something"])
154
+ ctx = context([Message("user", [])])
155
+ await provider.before_run(
156
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
157
+ )
158
+ assert ctx.get_messages(sources={provider.source_id}) == []
159
+
160
+
161
+ # ---- what gets remembered ------------------------------------------------
162
+
163
+
164
+ async def test_memories_outlive_the_session_that_made_them(provider):
165
+ """Cross-session recall is this provider's job, not the history one's."""
166
+ await turn(provider, ["learned in session one"], session_id="one")
167
+ ctx = context([msg("learned")], session_id="two")
168
+ await provider.before_run(
169
+ agent=None, session=AgentSession(session_id="two"), context=ctx, state={}
170
+ )
171
+ added = ctx.get_messages(sources={provider.source_id})
172
+ assert added and "session one" in added[0].text
173
+
174
+
175
+ async def test_scopes_do_not_see_each_other(path):
176
+ a = CitadelContextProvider(path, "pw", source_id="sa", scope="alice")
177
+ b = CitadelContextProvider(path, "pw", source_id="sb", scope="bob")
178
+ await turn(a, ["alice's private note"])
179
+ ctx = context([msg("private note")])
180
+ await b.before_run(
181
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
182
+ )
183
+ assert ctx.get_messages(sources={b.source_id}) == []
184
+
185
+
186
+ async def test_a_turn_with_no_text_remembers_nothing(provider):
187
+ ctx = context([Message("user", [])])
188
+ await provider.after_run(
189
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
190
+ )
191
+ assert await provider.forget() == 0
192
+
193
+
194
+ # ---- erasure -------------------------------------------------------------
195
+
196
+
197
+ async def test_forget_destroys_the_scope(provider):
198
+ await turn(provider, ["a", "b"])
199
+ assert await provider.forget() == 2
200
+ ctx = context([msg("a")])
201
+ await provider.before_run(
202
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
203
+ )
204
+ assert ctx.get_messages(sources={provider.source_id}) == []
205
+
206
+
207
+ async def test_it_survives_a_reopen(tmp_path):
208
+ """A region's embedder lives in memory, so recall must survive reopen."""
209
+ import gc
210
+
211
+ p = str(tmp_path / "reopen.cdl")
212
+ first = CitadelContextProvider(p, "pw", scope="u")
213
+ await turn(first, ["the deployment failed because the disk was full"])
214
+ del first
215
+ gc.collect()
216
+
217
+ again = CitadelContextProvider(p, "pw", scope="u")
218
+ ctx = context([msg("why did the release break?")])
219
+ await again.before_run(
220
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
221
+ )
222
+ assert ctx.get_messages(sources={again.source_id}), "recall did not survive reopen"
223
+
224
+
225
+ async def test_forgetting_nothing_is_zero_not_an_error(provider):
226
+ assert await provider.forget() == 0
227
+
228
+
229
+ # ---- the two providers together ------------------------------------------
230
+
231
+
232
+ async def test_it_shares_a_database_with_the_history_provider(tmp_path):
233
+ """The pairing the framework's own Redis integration ships, on one file."""
234
+ from citadeldb_ms_agent_framework import CitadelHistoryProvider
235
+
236
+ p = str(tmp_path / "both.cdl")
237
+ memory = CitadelContextProvider(p, "pw", scope="u1")
238
+ history = CitadelHistoryProvider(p, "pw")
239
+ await turn(memory, ["remembered across sessions"])
240
+ await history.save_messages("s", [msg("this exact turn")])
241
+ assert len(await history.get_messages("s")) == 1
242
+ assert await memory.forget() == 1
243
+
244
+
245
+ async def test_the_event_loop_is_not_blocked(provider):
246
+ import asyncio
247
+
248
+ ticks = 0
249
+
250
+ async def tick():
251
+ nonlocal ticks
252
+ while True:
253
+ ticks += 1
254
+ await asyncio.sleep(0)
255
+
256
+ ticker = asyncio.create_task(tick())
257
+ await asyncio.sleep(0)
258
+ await turn(provider, [f"fact {i}" for i in range(40)])
259
+ ctx = context([msg("fact")])
260
+ await provider.before_run(
261
+ agent=None, session=AgentSession(session_id="s"), context=ctx, state={}
262
+ )
263
+ ticker.cancel()
264
+ assert ticks > 1, "the loop made no progress during a provider call"