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.
- citadeldb_ms_agent_framework-2.0.0/.gitignore +18 -0
- citadeldb_ms_agent_framework-2.0.0/PKG-INFO +100 -0
- citadeldb_ms_agent_framework-2.0.0/README.md +78 -0
- citadeldb_ms_agent_framework-2.0.0/pyproject.toml +45 -0
- citadeldb_ms_agent_framework-2.0.0/src/citadeldb_ms_agent_framework/__init__.py +14 -0
- citadeldb_ms_agent_framework-2.0.0/src/citadeldb_ms_agent_framework/history.py +164 -0
- citadeldb_ms_agent_framework-2.0.0/src/citadeldb_ms_agent_framework/memory.py +163 -0
- citadeldb_ms_agent_framework-2.0.0/tests/test_history.py +366 -0
- citadeldb_ms_agent_framework-2.0.0/tests/test_memory.py +264 -0
|
@@ -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"
|