agent-framework-goodmem 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-framework-goodmem
3
+ Version: 0.1.0
4
+ Summary: GoodMem integration for Microsoft Agent Framework.
5
+ Author-email: GoodMem <support@goodmem.ai>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ License-File: LICENSE
18
+ Requires-Dist: agent-framework-core>=1.0.0rc4
19
+ Requires-Dist: httpx>=0.24.0,<1
20
+ Requires-Dist: pydantic>=2.0,<3
21
+ Requires-Dist: pytest>=7.0 ; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
23
+ Requires-Dist: pytest-timeout ; extra == "dev"
24
+ Requires-Dist: build ; extra == "dev"
25
+ Requires-Dist: twine ; extra == "dev"
26
+ Project-URL: homepage, https://github.com/bashareid/goodmem_agent-framework
27
+ Project-URL: issues, https://github.com/bashareid/goodmem_agent-framework/issues
28
+ Project-URL: source, https://github.com/bashareid/goodmem_agent-framework
29
+ Provides-Extra: dev
30
+
31
+ # agent-framework-goodmem
32
+
33
+ [GoodMem](https://goodmem.ai) integration for the
34
+ [Microsoft Agent Framework](https://github.com/microsoft/agent-framework).
35
+
36
+ This package gives Agent Framework agents persistent, semantic long-term memory
37
+ backed by a GoodMem server. It exposes:
38
+
39
+ - **`GoodMemClient`** — an async REST client for the GoodMem v1 API.
40
+ - **`GoodMemContextProvider`** — a `BaseContextProvider` that automatically
41
+ retrieves relevant memories before each agent run and stores conversations
42
+ afterwards.
43
+ - **`create_goodmem_tools`** — a factory that returns ready-to-use function
44
+ tools so the model itself can manage spaces and memories.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install agent-framework-goodmem
50
+ ```
51
+
52
+ For local development:
53
+
54
+ ```bash
55
+ pip install -e .
56
+ ```
57
+
58
+ ## Quickstart
59
+
60
+ ```python
61
+ import asyncio
62
+ from agent_framework_goodmem import GoodMemClient, create_goodmem_tools
63
+
64
+ async def main():
65
+ client = GoodMemClient(
66
+ base_url="https://localhost:8080",
67
+ api_key="gm_xxxxxxxxxxxxxxxxxxxxxxxx",
68
+ verify_ssl=False, # self-signed local server
69
+ )
70
+
71
+ embedders = await client.list_embedders()
72
+ embedder_id = embedders[0]["embedderId"]
73
+
74
+ space = await client.create_space(name="quickstart", embedder_id=embedder_id)
75
+ space_id = space["spaceId"]
76
+
77
+ await client.create_memory(
78
+ space_id=space_id,
79
+ text_content="The capital of France is Paris.",
80
+ )
81
+
82
+ results = await client.retrieve_memories(
83
+ query="What is the capital of France?",
84
+ space_ids=[space_id],
85
+ max_results=3,
86
+ wait_for_indexing=True,
87
+ )
88
+ print(results)
89
+
90
+ await client.close()
91
+
92
+ asyncio.run(main())
93
+ ```
94
+
95
+ ## Available tools
96
+
97
+ `create_goodmem_tools(client)` returns the following 11 function tools:
98
+
99
+ | Tool | Description |
100
+ |------|-------------|
101
+ | `goodmem_list_embedders` | List embedder models available on the server |
102
+ | `goodmem_list_spaces` | List all spaces accessible to the API key |
103
+ | `goodmem_get_space` | Fetch a space by ID |
104
+ | `goodmem_create_space` | Create a space (idempotent by name) |
105
+ | `goodmem_update_space` | Update a space's name/labels/visibility |
106
+ | `goodmem_delete_space` | Delete a space |
107
+ | `goodmem_create_memory` | Store text or a file as a memory |
108
+ | `goodmem_list_memories` | List memories in a space |
109
+ | `goodmem_retrieve_memories` | Semantic retrieval, with optional reranker/LLM |
110
+ | `goodmem_get_memory` | Fetch a memory by ID (with original content) |
111
+ | `goodmem_delete_memory` | Delete a memory |
112
+
113
+ ### Retrieval options
114
+
115
+ `goodmem_retrieve_memories` (and `GoodMemClient.retrieve_memories`) accept the
116
+ GoodMem post-processor parameters:
117
+
118
+ | Parameter | Type | Description |
119
+ |-----------|------|-------------|
120
+ | `reranker_id` | UUID | Reranker model to improve result ordering |
121
+ | `llm_id` | UUID | LLM used to generate a contextual abstract reply |
122
+ | `relevance_threshold` | 0–1 | Minimum score for including a result |
123
+ | `llm_temperature` | 0–2 | Creativity for the LLM post-processor |
124
+ | `max_results` | int | Cap on returned chunks |
125
+ | `chronological_resort` | bool | Reorder results by memory creation time |
126
+
127
+ ## Context provider
128
+
129
+ ```python
130
+ from agent_framework import Agent
131
+ from agent_framework.openai import OpenAIChatClient
132
+ from agent_framework_goodmem import GoodMemClient, GoodMemContextProvider
133
+
134
+ client = GoodMemClient(base_url="https://localhost:8080", api_key="gm_...", verify_ssl=False)
135
+
136
+ provider = GoodMemContextProvider(
137
+ client=client,
138
+ space_id=space_id,
139
+ max_results=5,
140
+ store_conversations=True,
141
+ )
142
+
143
+ agent = Agent(
144
+ client=OpenAIChatClient(model="gpt-4o"),
145
+ name="memory-agent",
146
+ instructions="You are a helpful assistant with persistent memory.",
147
+ context_providers=[provider],
148
+ )
149
+ ```
150
+
151
+ ## Running the integration tests
152
+
153
+ ```bash
154
+ export GOODMEM_API_KEY=gm_xxxxxxxxxxxxxxxxxxxxxxxx
155
+ export GOODMEM_BASE_URL=https://localhost:8080
156
+ pip install -e ".[dev]"
157
+ pytest -m integration -v tests/test_goodmem_integration.py
158
+ ```
159
+
@@ -0,0 +1,128 @@
1
+ # agent-framework-goodmem
2
+
3
+ [GoodMem](https://goodmem.ai) integration for the
4
+ [Microsoft Agent Framework](https://github.com/microsoft/agent-framework).
5
+
6
+ This package gives Agent Framework agents persistent, semantic long-term memory
7
+ backed by a GoodMem server. It exposes:
8
+
9
+ - **`GoodMemClient`** — an async REST client for the GoodMem v1 API.
10
+ - **`GoodMemContextProvider`** — a `BaseContextProvider` that automatically
11
+ retrieves relevant memories before each agent run and stores conversations
12
+ afterwards.
13
+ - **`create_goodmem_tools`** — a factory that returns ready-to-use function
14
+ tools so the model itself can manage spaces and memories.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install agent-framework-goodmem
20
+ ```
21
+
22
+ For local development:
23
+
24
+ ```bash
25
+ pip install -e .
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```python
31
+ import asyncio
32
+ from agent_framework_goodmem import GoodMemClient, create_goodmem_tools
33
+
34
+ async def main():
35
+ client = GoodMemClient(
36
+ base_url="https://localhost:8080",
37
+ api_key="gm_xxxxxxxxxxxxxxxxxxxxxxxx",
38
+ verify_ssl=False, # self-signed local server
39
+ )
40
+
41
+ embedders = await client.list_embedders()
42
+ embedder_id = embedders[0]["embedderId"]
43
+
44
+ space = await client.create_space(name="quickstart", embedder_id=embedder_id)
45
+ space_id = space["spaceId"]
46
+
47
+ await client.create_memory(
48
+ space_id=space_id,
49
+ text_content="The capital of France is Paris.",
50
+ )
51
+
52
+ results = await client.retrieve_memories(
53
+ query="What is the capital of France?",
54
+ space_ids=[space_id],
55
+ max_results=3,
56
+ wait_for_indexing=True,
57
+ )
58
+ print(results)
59
+
60
+ await client.close()
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ## Available tools
66
+
67
+ `create_goodmem_tools(client)` returns the following 11 function tools:
68
+
69
+ | Tool | Description |
70
+ |------|-------------|
71
+ | `goodmem_list_embedders` | List embedder models available on the server |
72
+ | `goodmem_list_spaces` | List all spaces accessible to the API key |
73
+ | `goodmem_get_space` | Fetch a space by ID |
74
+ | `goodmem_create_space` | Create a space (idempotent by name) |
75
+ | `goodmem_update_space` | Update a space's name/labels/visibility |
76
+ | `goodmem_delete_space` | Delete a space |
77
+ | `goodmem_create_memory` | Store text or a file as a memory |
78
+ | `goodmem_list_memories` | List memories in a space |
79
+ | `goodmem_retrieve_memories` | Semantic retrieval, with optional reranker/LLM |
80
+ | `goodmem_get_memory` | Fetch a memory by ID (with original content) |
81
+ | `goodmem_delete_memory` | Delete a memory |
82
+
83
+ ### Retrieval options
84
+
85
+ `goodmem_retrieve_memories` (and `GoodMemClient.retrieve_memories`) accept the
86
+ GoodMem post-processor parameters:
87
+
88
+ | Parameter | Type | Description |
89
+ |-----------|------|-------------|
90
+ | `reranker_id` | UUID | Reranker model to improve result ordering |
91
+ | `llm_id` | UUID | LLM used to generate a contextual abstract reply |
92
+ | `relevance_threshold` | 0–1 | Minimum score for including a result |
93
+ | `llm_temperature` | 0–2 | Creativity for the LLM post-processor |
94
+ | `max_results` | int | Cap on returned chunks |
95
+ | `chronological_resort` | bool | Reorder results by memory creation time |
96
+
97
+ ## Context provider
98
+
99
+ ```python
100
+ from agent_framework import Agent
101
+ from agent_framework.openai import OpenAIChatClient
102
+ from agent_framework_goodmem import GoodMemClient, GoodMemContextProvider
103
+
104
+ client = GoodMemClient(base_url="https://localhost:8080", api_key="gm_...", verify_ssl=False)
105
+
106
+ provider = GoodMemContextProvider(
107
+ client=client,
108
+ space_id=space_id,
109
+ max_results=5,
110
+ store_conversations=True,
111
+ )
112
+
113
+ agent = Agent(
114
+ client=OpenAIChatClient(model="gpt-4o"),
115
+ name="memory-agent",
116
+ instructions="You are a helpful assistant with persistent memory.",
117
+ context_providers=[provider],
118
+ )
119
+ ```
120
+
121
+ ## Running the integration tests
122
+
123
+ ```bash
124
+ export GOODMEM_API_KEY=gm_xxxxxxxxxxxxxxxxxxxxxxxx
125
+ export GOODMEM_BASE_URL=https://localhost:8080
126
+ pip install -e ".[dev]"
127
+ pytest -m integration -v tests/test_goodmem_integration.py
128
+ ```
@@ -0,0 +1,45 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """GoodMem integration for Microsoft Agent Framework.
4
+
5
+ This package provides two ways to use GoodMem with Agent Framework agents:
6
+
7
+ 1. **Context provider** — :class:`GoodMemContextProvider` automatically
8
+ retrieves relevant memories before each agent run and stores
9
+ conversations after, using the ``BaseContextProvider`` hooks pattern.
10
+
11
+ 2. **Tools** — :func:`create_goodmem_tools` exposes GoodMem operations
12
+ as agent tools, letting the model decide when to read/write memories.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.metadata
18
+ from typing import TYPE_CHECKING
19
+
20
+ from ._client import GoodMemClient
21
+ from ._context_provider import GoodMemContextProvider
22
+
23
+ if TYPE_CHECKING:
24
+ from ._tools import create_goodmem_tools as create_goodmem_tools
25
+ else:
26
+
27
+ def __getattr__(name: str): # noqa: ANN001, ANN202
28
+ if name == "create_goodmem_tools":
29
+ from ._tools import create_goodmem_tools
30
+
31
+ return create_goodmem_tools
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
33
+
34
+
35
+ try:
36
+ __version__ = importlib.metadata.version(__name__)
37
+ except importlib.metadata.PackageNotFoundError:
38
+ __version__ = "0.0.0" # Fallback for development mode
39
+
40
+ __all__ = [
41
+ "GoodMemClient",
42
+ "GoodMemContextProvider",
43
+ "create_goodmem_tools",
44
+ "__version__",
45
+ ]
@@ -0,0 +1,424 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Low-level HTTP client for the GoodMem API.
4
+
5
+ This module provides ``GoodMemClient``, a thin async wrapper around the
6
+ GoodMem REST API. It handles authentication, URL normalization, and
7
+ JSON serialization so that the tool layer can stay focused on schema
8
+ definitions and result formatting.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ import time
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+
21
+ # -- MIME helpers --------------------------------------------------------------
22
+
23
+ _MIME_TYPES: dict[str, str] = {
24
+ "pdf": "application/pdf",
25
+ "png": "image/png",
26
+ "jpg": "image/jpeg",
27
+ "jpeg": "image/jpeg",
28
+ "gif": "image/gif",
29
+ "webp": "image/webp",
30
+ "txt": "text/plain",
31
+ "html": "text/html",
32
+ "md": "text/markdown",
33
+ "csv": "text/csv",
34
+ "json": "application/json",
35
+ "xml": "application/xml",
36
+ "doc": "application/msword",
37
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
38
+ "xls": "application/vnd.ms-excel",
39
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40
+ "ppt": "application/vnd.ms-powerpoint",
41
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
42
+ }
43
+
44
+
45
+ def _guess_mime(extension: str) -> str:
46
+ """Return MIME type for a file extension, falling back to octet-stream."""
47
+ return _MIME_TYPES.get(extension.lower().lstrip("."), "application/octet-stream")
48
+
49
+
50
+ class GoodMemClient:
51
+ """Async client for the GoodMem REST API.
52
+
53
+ Args:
54
+ base_url: Base URL of the GoodMem server (e.g. ``https://api.goodmem.ai``).
55
+ api_key: API key used for ``X-API-Key`` authentication.
56
+ """
57
+
58
+ def __init__(self, base_url: str, api_key: str, *, verify_ssl: bool = True) -> None:
59
+ self._base_url = base_url.rstrip("/")
60
+ self._api_key = api_key
61
+ self._http = httpx.AsyncClient(
62
+ base_url=self._base_url,
63
+ headers={
64
+ "X-API-Key": self._api_key,
65
+ "Content-Type": "application/json",
66
+ "Accept": "application/json",
67
+ },
68
+ verify=verify_ssl,
69
+ timeout=httpx.Timeout(60.0),
70
+ )
71
+
72
+ async def close(self) -> None:
73
+ """Close the underlying HTTP client."""
74
+ await self._http.aclose()
75
+
76
+ # -- Spaces ----------------------------------------------------------------
77
+
78
+ async def list_spaces(self) -> list[dict[str, Any]]:
79
+ """List all spaces."""
80
+ resp = await self._http.get("/v1/spaces")
81
+ resp.raise_for_status()
82
+ body = resp.json()
83
+ return body if isinstance(body, list) else body.get("spaces", [])
84
+
85
+ async def create_space(
86
+ self,
87
+ name: str,
88
+ embedder_id: str,
89
+ chunk_size: int = 256,
90
+ chunk_overlap: int = 25,
91
+ keep_strategy: str = "KEEP_END",
92
+ length_measurement: str = "CHARACTER_COUNT",
93
+ ) -> dict[str, Any]:
94
+ """Create a new space, or return the existing one if a space with *name* already exists."""
95
+ # Check for existing space with the same name
96
+ spaces = await self.list_spaces()
97
+ for space in spaces:
98
+ if space.get("name") == name:
99
+ return {
100
+ "success": True,
101
+ "spaceId": space["spaceId"],
102
+ "name": space["name"],
103
+ "embedderId": embedder_id,
104
+ "message": "Space already exists, reusing existing space",
105
+ "reused": True,
106
+ }
107
+
108
+ payload: dict[str, Any] = {
109
+ "name": name,
110
+ "spaceEmbedders": [{"embedderId": embedder_id, "defaultRetrievalWeight": 1.0}],
111
+ "defaultChunkingConfig": {
112
+ "recursive": {
113
+ "chunkSize": chunk_size,
114
+ "chunkOverlap": chunk_overlap,
115
+ "separators": ["\n\n", "\n", ". ", " ", ""],
116
+ "keepStrategy": keep_strategy,
117
+ "separatorIsRegex": False,
118
+ "lengthMeasurement": length_measurement,
119
+ },
120
+ },
121
+ }
122
+ resp = await self._http.post("/v1/spaces", json=payload)
123
+ resp.raise_for_status()
124
+ data = resp.json()
125
+ return {
126
+ "success": True,
127
+ "spaceId": data["spaceId"],
128
+ "name": data["name"],
129
+ "embedderId": embedder_id,
130
+ "chunkingConfig": payload["defaultChunkingConfig"],
131
+ "message": "Space created successfully",
132
+ "reused": False,
133
+ }
134
+
135
+ async def get_space(self, space_id: str) -> dict[str, Any]:
136
+ """Fetch a single space by ID."""
137
+ resp = await self._http.get(f"/v1/spaces/{space_id}")
138
+ resp.raise_for_status()
139
+ return {"success": True, "space": resp.json()}
140
+
141
+ async def update_space(
142
+ self,
143
+ space_id: str,
144
+ *,
145
+ name: str | None = None,
146
+ replace_labels: dict[str, str] | None = None,
147
+ merge_labels: dict[str, str] | None = None,
148
+ public_read: bool | None = None,
149
+ ) -> dict[str, Any]:
150
+ """Update an existing space.
151
+
152
+ The server accepts ``name``, ``publicRead``, ``replaceLabels`` (full
153
+ replacement of the labels map), and ``mergeLabels`` (per-key upsert).
154
+ """
155
+ payload: dict[str, Any] = {}
156
+ if name is not None:
157
+ payload["name"] = name
158
+ if replace_labels is not None:
159
+ payload["replaceLabels"] = replace_labels
160
+ if merge_labels is not None:
161
+ payload["mergeLabels"] = merge_labels
162
+ if public_read is not None:
163
+ payload["publicRead"] = public_read
164
+ if not payload:
165
+ return {"success": False, "error": "No fields provided to update."}
166
+
167
+ resp = await self._http.put(f"/v1/spaces/{space_id}", json=payload)
168
+ resp.raise_for_status()
169
+ return {
170
+ "success": True,
171
+ "spaceId": space_id,
172
+ "space": resp.json(),
173
+ "message": "Space updated successfully",
174
+ }
175
+
176
+ async def delete_space(self, space_id: str) -> dict[str, Any]:
177
+ """Delete a space by ID."""
178
+ resp = await self._http.delete(f"/v1/spaces/{space_id}")
179
+ resp.raise_for_status()
180
+ return {"success": True, "spaceId": space_id, "message": "Space deleted successfully"}
181
+
182
+ # -- Embedders -------------------------------------------------------------
183
+
184
+ async def list_embedders(self) -> list[dict[str, Any]]:
185
+ """List available embedder models."""
186
+ resp = await self._http.get("/v1/embedders")
187
+ resp.raise_for_status()
188
+ body = resp.json()
189
+ return body if isinstance(body, list) else body.get("embedders", [])
190
+
191
+ # -- Memories --------------------------------------------------------------
192
+
193
+ async def list_memories(
194
+ self,
195
+ space_id: str,
196
+ *,
197
+ page_size: int | None = None,
198
+ next_token: str | None = None,
199
+ ) -> dict[str, Any]:
200
+ """List memories in a space."""
201
+ params: dict[str, Any] = {}
202
+ if page_size is not None:
203
+ params["pageSize"] = page_size
204
+ if next_token:
205
+ params["nextToken"] = next_token
206
+
207
+ resp = await self._http.get(f"/v1/spaces/{space_id}/memories", params=params)
208
+ resp.raise_for_status()
209
+ body = resp.json()
210
+ return {
211
+ "success": True,
212
+ "spaceId": space_id,
213
+ "memories": body.get("memories", []) if isinstance(body, dict) else body,
214
+ "nextToken": body.get("nextToken") if isinstance(body, dict) else None,
215
+ }
216
+
217
+
218
+ async def create_memory(
219
+ self,
220
+ space_id: str,
221
+ *,
222
+ text_content: str | None = None,
223
+ file_path: str | None = None,
224
+ file_bytes: bytes | None = None,
225
+ file_extension: str | None = None,
226
+ metadata: dict[str, Any] | None = None,
227
+ ) -> dict[str, Any]:
228
+ """Create a memory from text or a file (binary uploaded as base64).
229
+
230
+ Exactly one of *text_content* or *file_path*/*file_bytes* must be provided.
231
+ """
232
+ payload: dict[str, Any] = {"spaceId": space_id}
233
+
234
+ if file_path is not None:
235
+ ext = file_path.rsplit(".", 1)[-1] if "." in file_path else ""
236
+ mime = _guess_mime(ext)
237
+ with open(file_path, "rb") as fh:
238
+ raw = fh.read()
239
+ if mime.startswith("text/"):
240
+ payload["contentType"] = mime
241
+ payload["originalContent"] = raw.decode("utf-8", errors="replace")
242
+ else:
243
+ payload["contentType"] = mime
244
+ payload["originalContentB64"] = base64.b64encode(raw).decode()
245
+ elif file_bytes is not None:
246
+ ext = file_extension or ""
247
+ mime = _guess_mime(ext)
248
+ if mime.startswith("text/"):
249
+ payload["contentType"] = mime
250
+ payload["originalContent"] = file_bytes.decode("utf-8", errors="replace")
251
+ else:
252
+ payload["contentType"] = mime
253
+ payload["originalContentB64"] = base64.b64encode(file_bytes).decode()
254
+ elif text_content is not None:
255
+ payload["contentType"] = "text/plain"
256
+ payload["originalContent"] = text_content
257
+ else:
258
+ raise ValueError("No content provided. Supply text_content, file_path, or file_bytes.")
259
+
260
+ if metadata:
261
+ payload["metadata"] = metadata
262
+
263
+ resp = await self._http.post("/v1/memories", json=payload)
264
+ resp.raise_for_status()
265
+ data = resp.json()
266
+ return {
267
+ "success": True,
268
+ "memoryId": data.get("memoryId"),
269
+ "spaceId": data.get("spaceId"),
270
+ "status": data.get("processingStatus", "PENDING"),
271
+ "contentType": payload["contentType"],
272
+ "message": "Memory created successfully",
273
+ }
274
+
275
+ async def retrieve_memories(
276
+ self,
277
+ query: str,
278
+ space_ids: list[str],
279
+ *,
280
+ max_results: int = 5,
281
+ include_memory_definition: bool = True,
282
+ wait_for_indexing: bool = True,
283
+ reranker_id: str | None = None,
284
+ llm_id: str | None = None,
285
+ relevance_threshold: float | None = None,
286
+ llm_temperature: float | None = None,
287
+ chronological_resort: bool = False,
288
+ ) -> dict[str, Any]:
289
+ """Retrieve memories via semantic search."""
290
+ space_keys = [{"spaceId": sid} for sid in space_ids if sid]
291
+ if not space_keys:
292
+ return {"success": False, "error": "At least one space must be provided."}
293
+
294
+ payload: dict[str, Any] = {
295
+ "message": query,
296
+ "spaceKeys": space_keys,
297
+ "requestedSize": max_results,
298
+ "fetchMemory": include_memory_definition,
299
+ }
300
+
301
+ if reranker_id or llm_id:
302
+ config: dict[str, Any] = {}
303
+ if reranker_id:
304
+ config["reranker_id"] = reranker_id
305
+ if llm_id:
306
+ config["llm_id"] = llm_id
307
+ if relevance_threshold is not None:
308
+ config["relevance_threshold"] = relevance_threshold
309
+ if llm_temperature is not None:
310
+ config["llm_temp"] = llm_temperature
311
+ if max_results:
312
+ config["max_results"] = max_results
313
+ if chronological_resort:
314
+ config["chronological_resort"] = True
315
+ payload["postProcessor"] = {
316
+ "name": "com.goodmem.retrieval.postprocess.ChatPostProcessorFactory",
317
+ "config": config,
318
+ }
319
+
320
+ max_wait = 60.0 if wait_for_indexing else 0.0
321
+ poll_interval = 0.5
322
+ should_wait = wait_for_indexing
323
+ start = time.monotonic()
324
+
325
+ while True:
326
+ headers = {
327
+ "X-API-Key": self._api_key,
328
+ "Content-Type": "application/json",
329
+ "Accept": "application/x-ndjson",
330
+ }
331
+ resp = await self._http.post("/v1/memories:retrieve", json=payload, headers=headers)
332
+ resp.raise_for_status()
333
+
334
+ results, memories, result_set_id, abstract_reply = self._parse_ndjson(resp.text)
335
+
336
+ result: dict[str, Any] = {
337
+ "success": True,
338
+ "resultSetId": result_set_id,
339
+ "results": results,
340
+ "memories": memories,
341
+ "totalResults": len(results),
342
+ "query": query,
343
+ }
344
+ if abstract_reply:
345
+ result["abstractReply"] = abstract_reply
346
+
347
+ if results or not should_wait:
348
+ return result
349
+
350
+ elapsed = time.monotonic() - start
351
+ if elapsed >= max_wait:
352
+ result["message"] = "No results found after waiting 60 seconds for indexing. Memories may still be processing."
353
+ return result
354
+
355
+ import asyncio
356
+ await asyncio.sleep(poll_interval)
357
+
358
+ async def get_memory(self, memory_id: str, *, include_content: bool = True) -> dict[str, Any]:
359
+ """Fetch a single memory by ID."""
360
+ resp = await self._http.get(f"/v1/memories/{memory_id}")
361
+ resp.raise_for_status()
362
+ result: dict[str, Any] = {"success": True, "memory": resp.json()}
363
+
364
+ if include_content:
365
+ try:
366
+ content_resp = await self._http.get(f"/v1/memories/{memory_id}/content")
367
+ content_resp.raise_for_status()
368
+ # The content endpoint may return raw text or JSON depending on content type
369
+ try:
370
+ result["content"] = content_resp.json()
371
+ except Exception:
372
+ result["content"] = content_resp.text
373
+ except Exception as exc:
374
+ result["contentError"] = f"Failed to fetch content: {exc}"
375
+
376
+ return result
377
+
378
+ async def delete_memory(self, memory_id: str) -> dict[str, Any]:
379
+ """Delete a memory by ID."""
380
+ resp = await self._http.delete(f"/v1/memories/{memory_id}")
381
+ resp.raise_for_status()
382
+ return {"success": True, "memoryId": memory_id, "message": "Memory deleted successfully"}
383
+
384
+ # -- Helpers ---------------------------------------------------------------
385
+
386
+ @staticmethod
387
+ def _parse_ndjson(text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str, dict[str, Any] | None]:
388
+ """Parse NDJSON / SSE response from the retrieve endpoint."""
389
+ results: list[dict[str, Any]] = []
390
+ memories: list[dict[str, Any]] = []
391
+ result_set_id = ""
392
+ abstract_reply: dict[str, Any] | None = None
393
+
394
+ for line in text.strip().split("\n"):
395
+ json_str = line.strip()
396
+ if not json_str:
397
+ continue
398
+ if json_str.startswith("data:"):
399
+ json_str = json_str[5:].strip()
400
+ if json_str.startswith("event:") or not json_str:
401
+ continue
402
+ try:
403
+ item = json.loads(json_str)
404
+ except json.JSONDecodeError:
405
+ continue
406
+
407
+ if "resultSetBoundary" in item:
408
+ result_set_id = item["resultSetBoundary"].get("resultSetId", "")
409
+ elif "memoryDefinition" in item:
410
+ memories.append(item["memoryDefinition"])
411
+ elif "abstractReply" in item:
412
+ abstract_reply = item["abstractReply"]
413
+ elif "retrievedItem" in item:
414
+ chunk = item["retrievedItem"].get("chunk", {})
415
+ inner = chunk.get("chunk", {})
416
+ results.append({
417
+ "chunkId": inner.get("chunkId"),
418
+ "chunkText": inner.get("chunkText"),
419
+ "memoryId": inner.get("memoryId"),
420
+ "relevanceScore": chunk.get("relevanceScore"),
421
+ "memoryIndex": chunk.get("memoryIndex"),
422
+ })
423
+
424
+ return results, memories, result_set_id, abstract_reply
@@ -0,0 +1,195 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """GoodMem context provider using BaseContextProvider.
4
+
5
+ This module provides ``GoodMemContextProvider``, built on the
6
+ :class:`BaseContextProvider` hooks pattern. It automatically retrieves
7
+ relevant memories before each agent run and optionally stores
8
+ conversations after.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import TYPE_CHECKING, Any, ClassVar
15
+
16
+ from agent_framework import Message
17
+ from agent_framework._sessions import AgentSession, SessionContext
18
+
19
+ try:
20
+ # agent-framework-core >= 1.2 renamed BaseContextProvider -> ContextProvider.
21
+ from agent_framework._sessions import ContextProvider as _BaseContextProvider
22
+ except ImportError: # pragma: no cover - older versions
23
+ from agent_framework._sessions import BaseContextProvider as _BaseContextProvider # type: ignore[no-redef]
24
+
25
+ from ._client import GoodMemClient
26
+
27
+ if TYPE_CHECKING:
28
+ from agent_framework._agents import SupportsAgentRun
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class GoodMemContextProvider(_BaseContextProvider):
34
+ """GoodMem context provider using the BaseContextProvider hooks pattern.
35
+
36
+ Integrates GoodMem for persistent semantic memory, automatically
37
+ searching for relevant memories before each agent run and optionally
38
+ storing conversations after.
39
+
40
+ Two integration approaches are supported:
41
+
42
+ - **Automatic (context provider)** — attach this provider to an agent
43
+ via ``context_providers=[provider]``. Memories are retrieved and
44
+ stored transparently on every ``agent.run()`` call.
45
+ - **Tool-based** — use :func:`create_goodmem_tools` to let the agent
46
+ decide when to read/write memories via tool calls.
47
+
48
+ Args:
49
+ source_id: Unique identifier for this provider instance.
50
+ client: A configured :class:`GoodMemClient` instance.
51
+ space_id: The GoodMem space ID to search and store memories in.
52
+ max_results: Maximum number of memory chunks to retrieve per query.
53
+ context_prompt: Prompt prepended to retrieved memories in the context.
54
+ store_conversations: Whether to store input/response messages as
55
+ new memories after each run.
56
+ wait_for_indexing: Whether to wait for newly created memories to
57
+ be indexed before returning retrieval results.
58
+ """
59
+
60
+ DEFAULT_CONTEXT_PROMPT: ClassVar[str] = (
61
+ "## Relevant Memories\n"
62
+ "The following memories were retrieved from long-term storage "
63
+ "and may be relevant to the current conversation:"
64
+ )
65
+ DEFAULT_SOURCE_ID: ClassVar[str] = "goodmem"
66
+
67
+ def __init__(
68
+ self,
69
+ client: GoodMemClient,
70
+ space_id: str,
71
+ source_id: str = DEFAULT_SOURCE_ID,
72
+ *,
73
+ max_results: int = 5,
74
+ context_prompt: str | None = None,
75
+ store_conversations: bool = True,
76
+ wait_for_indexing: bool = False,
77
+ ) -> None:
78
+ """Initialize the GoodMem context provider.
79
+
80
+ Args:
81
+ client: A configured :class:`GoodMemClient` instance.
82
+ space_id: The GoodMem space ID to search and store memories in.
83
+ source_id: Unique identifier for this provider instance.
84
+ max_results: Maximum number of memory chunks to retrieve.
85
+ context_prompt: Prompt prepended to retrieved memories.
86
+ store_conversations: Whether to store conversations after each run.
87
+ wait_for_indexing: Whether to wait for indexing during retrieval.
88
+ """
89
+ super().__init__(source_id)
90
+ self.client = client
91
+ self.space_id = space_id
92
+ self.max_results = max_results
93
+ self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT
94
+ self.store_conversations = store_conversations
95
+ self.wait_for_indexing = wait_for_indexing
96
+
97
+ # -- Hooks pattern ---------------------------------------------------------
98
+
99
+ async def before_run(
100
+ self,
101
+ *,
102
+ agent: SupportsAgentRun,
103
+ session: AgentSession,
104
+ context: SessionContext,
105
+ state: dict[str, Any],
106
+ ) -> None:
107
+ """Search GoodMem for relevant memories and add to the session context.
108
+
109
+ Extracts text from the input messages, performs a semantic search
110
+ across the configured space, and injects matching chunks as a
111
+ user message so the model can reference them.
112
+ """
113
+ input_text = "\n".join(
114
+ msg.text for msg in context.input_messages if msg and msg.text and msg.text.strip()
115
+ )
116
+ if not input_text.strip():
117
+ return
118
+
119
+ try:
120
+ result = await self.client.retrieve_memories(
121
+ query=input_text,
122
+ space_ids=[self.space_id],
123
+ max_results=self.max_results,
124
+ wait_for_indexing=self.wait_for_indexing,
125
+ )
126
+ except Exception:
127
+ logger.warning("GoodMem retrieval failed", exc_info=True)
128
+ return
129
+
130
+ if not result.get("success"):
131
+ logger.warning("GoodMem retrieval returned unsuccessful: %s", result.get("error"))
132
+ return
133
+
134
+ chunks = result.get("results", [])
135
+ if not chunks:
136
+ return
137
+
138
+ memory_lines = [chunk.get("chunkText", "") for chunk in chunks if chunk.get("chunkText")]
139
+ if not memory_lines:
140
+ return
141
+
142
+ memory_text = "\n".join(memory_lines)
143
+ context.extend_messages(
144
+ self,
145
+ [Message(role="user", text=f"{self.context_prompt}\n{memory_text}")],
146
+ )
147
+
148
+ async def after_run(
149
+ self,
150
+ *,
151
+ agent: SupportsAgentRun,
152
+ session: AgentSession,
153
+ context: SessionContext,
154
+ state: dict[str, Any],
155
+ ) -> None:
156
+ """Store request/response messages to GoodMem for future retrieval.
157
+
158
+ Concatenates input and response messages into a single text block
159
+ and creates a new memory in the configured space.
160
+ """
161
+ if not self.store_conversations:
162
+ return
163
+
164
+ messages_to_store: list[Message] = list(context.input_messages)
165
+ if context.response and context.response.messages:
166
+ messages_to_store.extend(context.response.messages)
167
+
168
+ def _get_role(role: Any) -> str:
169
+ return role.value if hasattr(role, "value") else str(role)
170
+
171
+ lines: list[str] = [
172
+ f"{_get_role(msg.role)}: {msg.text}"
173
+ for msg in messages_to_store
174
+ if _get_role(msg.role) in {"user", "assistant", "system"} and msg.text and msg.text.strip()
175
+ ]
176
+
177
+ if not lines:
178
+ return
179
+
180
+ conversation_text = "\n".join(lines)
181
+
182
+ try:
183
+ await self.client.create_memory(
184
+ space_id=self.space_id,
185
+ text_content=conversation_text,
186
+ metadata={
187
+ "source": "agent-framework-context-provider",
188
+ "session_id": session.session_id,
189
+ },
190
+ )
191
+ except Exception:
192
+ logger.warning("GoodMem memory storage failed", exc_info=True)
193
+
194
+
195
+ __all__ = ["GoodMemContextProvider"]
@@ -0,0 +1,330 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """GoodMem tools for the Agent Framework.
4
+
5
+ Each public function in this module is decorated with ``@tool`` so it can be
6
+ passed directly to an ``Agent`` instance. The functions are thin wrappers
7
+ around :class:`GoodMemClient` that handle JSON serialization of results.
8
+
9
+ All tools require a pre-configured ``GoodMemClient`` instance to be injected
10
+ via :func:`create_goodmem_tools`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Annotated, Any
17
+
18
+ from agent_framework import tool
19
+ from pydantic import Field
20
+
21
+ from ._client import GoodMemClient
22
+
23
+
24
+ def create_goodmem_tools(client: GoodMemClient) -> list[Any]:
25
+ """Create a list of GoodMem ``FunctionTool`` instances bound to *client*.
26
+
27
+ Args:
28
+ client: A configured :class:`GoodMemClient` instance.
29
+
30
+ Returns:
31
+ A list of ``FunctionTool`` objects covering embedders, spaces (CRUD),
32
+ and memories (CRUD + retrieve).
33
+ """
34
+
35
+ # -- List Embedders --------------------------------------------------------
36
+
37
+ @tool(
38
+ name="goodmem_list_embedders",
39
+ description=(
40
+ "List the embedder models available on the GoodMem server. "
41
+ "Use the returned embedderId values when creating new spaces."
42
+ ),
43
+ )
44
+ async def goodmem_list_embedders() -> str:
45
+ """List available embedder models."""
46
+ try:
47
+ embedders = await client.list_embedders()
48
+ return json.dumps({"success": True, "embedders": embedders, "count": len(embedders)})
49
+ except Exception as exc:
50
+ return json.dumps({"success": False, "error": str(exc)})
51
+
52
+ # -- List Spaces -----------------------------------------------------------
53
+
54
+ @tool(
55
+ name="goodmem_list_spaces",
56
+ description="List all GoodMem spaces accessible to the current API key.",
57
+ )
58
+ async def goodmem_list_spaces() -> str:
59
+ """List all spaces."""
60
+ try:
61
+ spaces = await client.list_spaces()
62
+ return json.dumps({"success": True, "spaces": spaces, "count": len(spaces)}, default=str)
63
+ except Exception as exc:
64
+ return json.dumps({"success": False, "error": str(exc)})
65
+
66
+ # -- Get Space -------------------------------------------------------------
67
+
68
+ @tool(
69
+ name="goodmem_get_space",
70
+ description="Fetch the metadata for a specific GoodMem space by its ID.",
71
+ )
72
+ async def goodmem_get_space(
73
+ space_id: Annotated[str, Field(description="The UUID of the space to fetch.")],
74
+ ) -> str:
75
+ """Fetch a single space by ID."""
76
+ try:
77
+ result = await client.get_space(space_id)
78
+ return json.dumps(result, default=str)
79
+ except Exception as exc:
80
+ return json.dumps({"success": False, "error": str(exc)})
81
+
82
+ # -- Create Space ----------------------------------------------------------
83
+
84
+ @tool(
85
+ name="goodmem_create_space",
86
+ description=(
87
+ "Create a new GoodMem space or reuse an existing one. "
88
+ "A space is a logical container for organizing related memories, "
89
+ "configured with an embedder that converts text to vector embeddings."
90
+ ),
91
+ )
92
+ async def goodmem_create_space(
93
+ name: Annotated[str, Field(description="A unique name for the space. If a space with this name already exists, its ID will be returned instead of creating a duplicate.")],
94
+ embedder_id: Annotated[str, Field(description="The embedder ID that converts text into vector representations for similarity search. Use goodmem_list_embedders to find available IDs.")] = "",
95
+ chunk_size: Annotated[int, Field(description="Number of characters per chunk when splitting documents.")] = 256,
96
+ chunk_overlap: Annotated[int, Field(description="Number of overlapping characters between consecutive chunks.")] = 25,
97
+ keep_strategy: Annotated[str, Field(description="Where to attach the separator when splitting: KEEP_END, KEEP_START, or DISCARD.")] = "KEEP_END",
98
+ length_measurement: Annotated[str, Field(description="How chunk size is measured: CHARACTER_COUNT or TOKEN_COUNT.")] = "CHARACTER_COUNT",
99
+ ) -> str:
100
+ """Create a new GoodMem space or reuse an existing one."""
101
+ actual_embedder_id = embedder_id
102
+ if not actual_embedder_id:
103
+ embedders = await client.list_embedders()
104
+ if embedders:
105
+ actual_embedder_id = embedders[0].get("embedderId") or embedders[0].get("id", "")
106
+ if not actual_embedder_id:
107
+ return json.dumps({"success": False, "error": "No embedder_id provided and no embedders available on the server."})
108
+
109
+ try:
110
+ result = await client.create_space(
111
+ name=name,
112
+ embedder_id=actual_embedder_id,
113
+ chunk_size=chunk_size,
114
+ chunk_overlap=chunk_overlap,
115
+ keep_strategy=keep_strategy,
116
+ length_measurement=length_measurement,
117
+ )
118
+ return json.dumps(result)
119
+ except Exception as exc:
120
+ return json.dumps({"success": False, "error": str(exc)})
121
+
122
+ # -- Update Space ----------------------------------------------------------
123
+
124
+ @tool(
125
+ name="goodmem_update_space",
126
+ description="Update an existing GoodMem space's name, labels, or public_read flag.",
127
+ )
128
+ async def goodmem_update_space(
129
+ space_id: Annotated[str, Field(description="The UUID of the space to update.")],
130
+ name: Annotated[str, Field(description="Optional new name for the space.")] = "",
131
+ replace_labels_json: Annotated[str, Field(description="Optional JSON object that REPLACES all labels on the space.")] = "",
132
+ merge_labels_json: Annotated[str, Field(description="Optional JSON object whose entries are MERGED into existing labels (upsert per key).")] = "",
133
+ public_read: Annotated[bool, Field(description="If true, makes the space publicly readable. If false, restricts to the owner.")] = False,
134
+ set_public_read: Annotated[bool, Field(description="Whether to apply the public_read value (so the default `false` is not always sent).")] = False,
135
+ ) -> str:
136
+ """Update an existing GoodMem space."""
137
+
138
+ def _parse_labels(raw: str, field: str) -> dict[str, str] | None | str:
139
+ if not raw:
140
+ return None
141
+ try:
142
+ parsed = json.loads(raw)
143
+ except json.JSONDecodeError:
144
+ return f"{field} is not valid JSON."
145
+ if not isinstance(parsed, dict):
146
+ return f"{field} must decode to a JSON object."
147
+ return {str(k): str(v) for k, v in parsed.items()}
148
+
149
+ replace_labels = _parse_labels(replace_labels_json, "replace_labels_json")
150
+ if isinstance(replace_labels, str):
151
+ return json.dumps({"success": False, "error": replace_labels})
152
+ merge_labels = _parse_labels(merge_labels_json, "merge_labels_json")
153
+ if isinstance(merge_labels, str):
154
+ return json.dumps({"success": False, "error": merge_labels})
155
+
156
+ try:
157
+ result = await client.update_space(
158
+ space_id,
159
+ name=name or None,
160
+ replace_labels=replace_labels,
161
+ merge_labels=merge_labels,
162
+ public_read=public_read if set_public_read else None,
163
+ )
164
+ return json.dumps(result, default=str)
165
+ except Exception as exc:
166
+ return json.dumps({"success": False, "error": str(exc)})
167
+
168
+ # -- Delete Space ----------------------------------------------------------
169
+
170
+ @tool(
171
+ name="goodmem_delete_space",
172
+ description="Permanently delete a GoodMem space and all of its memories.",
173
+ )
174
+ async def goodmem_delete_space(
175
+ space_id: Annotated[str, Field(description="The UUID of the space to delete.")],
176
+ ) -> str:
177
+ """Delete a space by ID."""
178
+ try:
179
+ result = await client.delete_space(space_id)
180
+ return json.dumps(result)
181
+ except Exception as exc:
182
+ return json.dumps({"success": False, "error": str(exc)})
183
+
184
+ # -- Create Memory ---------------------------------------------------------
185
+
186
+ @tool(
187
+ name="goodmem_create_memory",
188
+ description=(
189
+ "Store a document as a new memory in a GoodMem space. "
190
+ "The memory is processed asynchronously -- chunked into searchable "
191
+ "pieces and embedded into vectors. Accepts plain text or a file path."
192
+ ),
193
+ )
194
+ async def goodmem_create_memory(
195
+ space_id: Annotated[str, Field(description="The ID of the space to store the memory in.")],
196
+ text_content: Annotated[str, Field(description="Plain text content to store as memory. Ignored when file_path is provided.")] = "",
197
+ file_path: Annotated[str, Field(description="Absolute path to a file to store as memory (PDF, DOCX, image, etc.). Takes priority over text_content.")] = "",
198
+ metadata_json: Annotated[str, Field(description="Optional JSON string of key-value metadata to attach to the memory.")] = "",
199
+ ) -> str:
200
+ """Store a document as a new memory in a GoodMem space."""
201
+ metadata: dict[str, Any] | None = None
202
+ if metadata_json:
203
+ try:
204
+ metadata = json.loads(metadata_json)
205
+ except json.JSONDecodeError:
206
+ return json.dumps({"success": False, "error": "metadata_json is not valid JSON."})
207
+
208
+ try:
209
+ result = await client.create_memory(
210
+ space_id=space_id,
211
+ text_content=text_content or None,
212
+ file_path=file_path or None,
213
+ metadata=metadata,
214
+ )
215
+ return json.dumps(result)
216
+ except Exception as exc:
217
+ return json.dumps({"success": False, "error": str(exc)})
218
+
219
+ # -- List Memories ---------------------------------------------------------
220
+
221
+ @tool(
222
+ name="goodmem_list_memories",
223
+ description="List the memories stored in a specific GoodMem space.",
224
+ )
225
+ async def goodmem_list_memories(
226
+ space_id: Annotated[str, Field(description="The UUID of the space whose memories should be listed.")],
227
+ page_size: Annotated[int, Field(description="Maximum number of memories to return in this page. 0 means use server default.")] = 0,
228
+ next_token: Annotated[str, Field(description="Pagination token returned from a previous call.")] = "",
229
+ ) -> str:
230
+ """List memories in a space."""
231
+ try:
232
+ result = await client.list_memories(
233
+ space_id,
234
+ page_size=page_size or None,
235
+ next_token=next_token or None,
236
+ )
237
+ return json.dumps(result, default=str)
238
+ except Exception as exc:
239
+ return json.dumps({"success": False, "error": str(exc)})
240
+
241
+ # -- Retrieve Memories -----------------------------------------------------
242
+
243
+ @tool(
244
+ name="goodmem_retrieve_memories",
245
+ description=(
246
+ "Perform similarity-based semantic retrieval across one or more "
247
+ "GoodMem spaces. Returns matching chunks ranked by relevance. "
248
+ "Supports optional reranker and LLM post-processing."
249
+ ),
250
+ )
251
+ async def goodmem_retrieve_memories(
252
+ query: Annotated[str, Field(description="A natural language query used to find semantically similar memory chunks.")],
253
+ space_ids: Annotated[str, Field(description="Comma-separated list of space IDs to search across.")],
254
+ max_results: Annotated[int, Field(description="Maximum number of results to return.")] = 5,
255
+ include_memory_definition: Annotated[bool, Field(description="Include full memory metadata alongside matched chunks.")] = True,
256
+ wait_for_indexing: Annotated[bool, Field(description="Retry for up to 60 seconds when no results are found (useful for recently added memories).")] = True,
257
+ reranker_id: Annotated[str, Field(description="Optional UUID of a reranker model to improve result ordering.")] = "",
258
+ llm_id: Annotated[str, Field(description="Optional UUID of an LLM to generate a contextual abstract reply.")] = "",
259
+ relevance_threshold: Annotated[float, Field(description="Minimum relevance score (0-1) for including a result. 0 disables.")] = 0.0,
260
+ llm_temperature: Annotated[float, Field(description="Creativity setting for the LLM post-processor (0-2). Negative disables.")] = -1.0,
261
+ chronological_resort: Annotated[bool, Field(description="If true, reorder results by memory creation time.")] = False,
262
+ ) -> str:
263
+ """Retrieve memories via semantic search."""
264
+ ids = [s.strip() for s in space_ids.split(",") if s.strip()]
265
+ try:
266
+ result = await client.retrieve_memories(
267
+ query=query,
268
+ space_ids=ids,
269
+ max_results=max_results,
270
+ include_memory_definition=include_memory_definition,
271
+ wait_for_indexing=wait_for_indexing,
272
+ reranker_id=reranker_id or None,
273
+ llm_id=llm_id or None,
274
+ relevance_threshold=relevance_threshold if relevance_threshold > 0 else None,
275
+ llm_temperature=llm_temperature if llm_temperature >= 0 else None,
276
+ chronological_resort=chronological_resort,
277
+ )
278
+ return json.dumps(result, default=str)
279
+ except Exception as exc:
280
+ return json.dumps({"success": False, "error": str(exc)})
281
+
282
+ # -- Get Memory ------------------------------------------------------------
283
+
284
+ @tool(
285
+ name="goodmem_get_memory",
286
+ description=(
287
+ "Fetch a specific memory record by its ID, including metadata, "
288
+ "processing status, and optionally the original content."
289
+ ),
290
+ )
291
+ async def goodmem_get_memory(
292
+ memory_id: Annotated[str, Field(description="The UUID of the memory to fetch.")],
293
+ include_content: Annotated[bool, Field(description="Also fetch the original document content.")] = True,
294
+ ) -> str:
295
+ """Fetch a specific memory by ID."""
296
+ try:
297
+ result = await client.get_memory(memory_id, include_content=include_content)
298
+ return json.dumps(result, default=str)
299
+ except Exception as exc:
300
+ return json.dumps({"success": False, "error": str(exc)})
301
+
302
+ # -- Delete Memory ---------------------------------------------------------
303
+
304
+ @tool(
305
+ name="goodmem_delete_memory",
306
+ description="Permanently delete a memory and its associated chunks and vector embeddings.",
307
+ )
308
+ async def goodmem_delete_memory(
309
+ memory_id: Annotated[str, Field(description="The UUID of the memory to delete.")],
310
+ ) -> str:
311
+ """Delete a memory by ID."""
312
+ try:
313
+ result = await client.delete_memory(memory_id)
314
+ return json.dumps(result)
315
+ except Exception as exc:
316
+ return json.dumps({"success": False, "error": str(exc)})
317
+
318
+ return [
319
+ goodmem_list_embedders,
320
+ goodmem_list_spaces,
321
+ goodmem_get_space,
322
+ goodmem_create_space,
323
+ goodmem_update_space,
324
+ goodmem_delete_space,
325
+ goodmem_create_memory,
326
+ goodmem_list_memories,
327
+ goodmem_retrieve_memories,
328
+ goodmem_get_memory,
329
+ goodmem_delete_memory,
330
+ ]
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "agent-framework-goodmem"
3
+ description = "GoodMem integration for Microsoft Agent Framework."
4
+ authors = [{ name = "GoodMem", email = "support@goodmem.ai"}]
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ version = "0.1.0"
8
+ license-files = ["LICENSE"]
9
+ urls.homepage = "https://github.com/bashareid/goodmem_agent-framework"
10
+ urls.source = "https://github.com/bashareid/goodmem_agent-framework"
11
+ urls.issues = "https://github.com/bashareid/goodmem_agent-framework/issues"
12
+ classifiers = [
13
+ "License :: OSI Approved :: MIT License",
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "agent-framework-core>=1.0.0rc4",
25
+ "httpx>=0.24.0,<1",
26
+ "pydantic>=2.0,<3",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=7.0",
32
+ "pytest-asyncio>=0.23",
33
+ "pytest-timeout",
34
+ "build",
35
+ "twine",
36
+ ]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ['tests']
40
+ addopts = "-ra -q -r fEX"
41
+ asyncio_mode = "auto"
42
+ asyncio_default_fixture_loop_scope = "function"
43
+ filterwarnings = [
44
+ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
45
+ ]
46
+ timeout = 120
47
+ markers = [
48
+ "integration: marks tests as integration tests that require external services",
49
+ ]
50
+
51
+ [build-system]
52
+ requires = ["flit-core >= 3.11,<4.0"]
53
+ build-backend = "flit_core.buildapi"