agent-framework-goodmem 0.1.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.
- agent_framework_goodmem/__init__.py +45 -0
- agent_framework_goodmem/_client.py +424 -0
- agent_framework_goodmem/_context_provider.py +195 -0
- agent_framework_goodmem/_tools.py +330 -0
- agent_framework_goodmem/py.typed +0 -0
- agent_framework_goodmem-0.1.0.dist-info/METADATA +159 -0
- agent_framework_goodmem-0.1.0.dist-info/RECORD +9 -0
- agent_framework_goodmem-0.1.0.dist-info/WHEEL +4 -0
- agent_framework_goodmem-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
|
File without changes
|
|
@@ -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,9 @@
|
|
|
1
|
+
agent_framework_goodmem/__init__.py,sha256=Vi1zByGjJHUfSb223Rz72gYBW5mweYhIgzAbopOxPeA,1377
|
|
2
|
+
agent_framework_goodmem/_client.py,sha256=MKVE-nTD35MeWJsrc76ueB0pqYPnW1O0v1kB_na61Og,15959
|
|
3
|
+
agent_framework_goodmem/_context_provider.py,sha256=EClXl3v3UgwgP601_RIRnrEMNc-6Pv9nFOFGkkskPKw,7105
|
|
4
|
+
agent_framework_goodmem/_tools.py,sha256=MZoEcnHvo6AimCXD0DG8zGekZugsMPl2GxFvaB3OT_k,15407
|
|
5
|
+
agent_framework_goodmem/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
agent_framework_goodmem-0.1.0.dist-info/licenses/LICENSE,sha256=ffINzfkZfplFwUhY1Bxg8RtSuT5baeK2NBa4dNWY0yI,1074
|
|
7
|
+
agent_framework_goodmem-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
8
|
+
agent_framework_goodmem-0.1.0.dist-info/METADATA,sha256=7_jruaHkix2JT3bFdcae7s6ymFZhgUkie_Z9jEwGIEA,5210
|
|
9
|
+
agent_framework_goodmem-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|