citadeldb-ms-agent-framework 2.0.0__py3-none-any.whl

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,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,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,6 @@
1
+ citadeldb_ms_agent_framework/__init__.py,sha256=BKdeYcX4OYg0f4oihIjpPKZs4wo_xBYAw39Y4vlj55c,478
2
+ citadeldb_ms_agent_framework/history.py,sha256=9XvFQ70kJ9_-SiSo0pDdt7_y7VZHLGRlvUuluFO73C0,5464
3
+ citadeldb_ms_agent_framework/memory.py,sha256=KQKvsvIlrz0IsHAkIc1JvMq4NDk8WlSD3UuTYemPnWk,5770
4
+ citadeldb_ms_agent_framework-2.0.0.dist-info/METADATA,sha256=YDDb7Id6acv1jwJRN6_lYybTNwl-i8WdZ3rZ2IR3qFo,3801
5
+ citadeldb_ms_agent_framework-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ citadeldb_ms_agent_framework-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any