strands-dakera 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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .venv/
11
+ venv/
12
+ .env
13
+ _version.py
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: strands-dakera
3
+ Version: 0.1.0
4
+ Summary: Persistent, decay-weighted memory for Strands agents, backed by a self-hosted Dakera server.
5
+ Project-URL: Homepage, https://dakera.ai
6
+ Project-URL: Documentation, https://github.com/dakera-ai/strands-dakera#readme
7
+ Project-URL: Repository, https://github.com/dakera-ai/strands-dakera
8
+ Project-URL: Issues, https://github.com/dakera-ai/strands-dakera/issues
9
+ Author-email: Dakera <hello@dakera.ai>
10
+ License: Apache-2.0
11
+ Keywords: agents,ai,dakera,decay-weighted,memory,strands,strands-agents,vector-search
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: dakera>=0.12.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: strands-agents>=1.0.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: hatch; extra == 'dev'
27
+ Requires-Dist: mypy<2.0.0,>=1.15.0; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio<1.0.0,>=0.25.0; extra == 'dev'
29
+ Requires-Dist: pytest<9.0.0,>=8.0.0; extra == 'dev'
30
+ Requires-Dist: ruff<1.0.0,>=0.11.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # strands-dakera (Python)
34
+
35
+ Persistent, decay-weighted memory for [Strands Agents](https://github.com/strands-agents/sdk-python),
36
+ backed by a self-hosted [Dakera](https://github.com/dakera-ai/dakera-deploy) server.
37
+
38
+ See the [repository README](../README.md) for full usage. Quick start:
39
+
40
+ ```bash
41
+ pip install strands-dakera
42
+ ```
43
+
44
+ ```python
45
+ from strands import Agent
46
+ from strands_dakera import dakera_memory
47
+
48
+ agent = Agent(tools=[dakera_memory])
49
+ ```
50
+
51
+ ## Local development
52
+
53
+ ```bash
54
+ pip install hatch
55
+ hatch run test # run the test suite (mocked client, no live server)
56
+ hatch run lint # ruff
57
+ hatch run typecheck # mypy
58
+ hatch run prepare # format + lint + typecheck + test
59
+ ```
60
+
61
+ ## Release
62
+
63
+ Tag a GitHub release with a `python-v*` tag (e.g. `python-v0.1.0`); the
64
+ `publish-python` workflow builds the wheel and publishes it to PyPI via trusted
65
+ publishing.
@@ -0,0 +1,33 @@
1
+ # strands-dakera (Python)
2
+
3
+ Persistent, decay-weighted memory for [Strands Agents](https://github.com/strands-agents/sdk-python),
4
+ backed by a self-hosted [Dakera](https://github.com/dakera-ai/dakera-deploy) server.
5
+
6
+ See the [repository README](../README.md) for full usage. Quick start:
7
+
8
+ ```bash
9
+ pip install strands-dakera
10
+ ```
11
+
12
+ ```python
13
+ from strands import Agent
14
+ from strands_dakera import dakera_memory
15
+
16
+ agent = Agent(tools=[dakera_memory])
17
+ ```
18
+
19
+ ## Local development
20
+
21
+ ```bash
22
+ pip install hatch
23
+ hatch run test # run the test suite (mocked client, no live server)
24
+ hatch run lint # ruff
25
+ hatch run typecheck # mypy
26
+ hatch run prepare # format + lint + typecheck + test
27
+ ```
28
+
29
+ ## Release
30
+
31
+ Tag a GitHub release with a `python-v*` tag (e.g. `python-v0.1.0`); the
32
+ `publish-python` workflow builds the wheel and publishes it to PyPI via trusted
33
+ publishing.
@@ -0,0 +1,101 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "strands-dakera"
7
+ dynamic = ["version"]
8
+ description = "Persistent, decay-weighted memory for Strands agents, backed by a self-hosted Dakera server."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [
13
+ {name = "Dakera", email = "hello@dakera.ai"}
14
+ ]
15
+ keywords = ["strands", "strands-agents", "agents", "ai", "memory", "dakera", "vector-search", "decay-weighted"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ ]
27
+
28
+ dependencies = [
29
+ "strands-agents>=1.0.0",
30
+ "dakera>=0.12.0",
31
+ "rich>=13.0.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://dakera.ai"
36
+ Documentation = "https://github.com/dakera-ai/strands-dakera#readme"
37
+ Repository = "https://github.com/dakera-ai/strands-dakera"
38
+ Issues = "https://github.com/dakera-ai/strands-dakera/issues"
39
+
40
+ [project.optional-dependencies]
41
+ dev = [
42
+ "pytest>=8.0.0,<9.0.0",
43
+ "pytest-asyncio>=0.25.0,<1.0.0",
44
+ "ruff>=0.11.0,<1.0.0",
45
+ "mypy>=1.15.0,<2.0.0",
46
+ "hatch",
47
+ ]
48
+
49
+ [tool.hatch.version]
50
+ source = "vcs"
51
+
52
+ [tool.hatch.version.raw-options]
53
+ # The git repo lives one level up (monorepo-style layout with a python/ subdir,
54
+ # matching the Strands extension-template). Tell hatch-vcs where the .git is.
55
+ root = ".."
56
+ # Match release tags like `python-v0.1.0` and strip the `python-v` prefix so the
57
+ # published package version is a clean SemVer string.
58
+ tag_regex = "^python-v(?P<version>.+)$"
59
+ # hatch-vcs refuses a non-default `root` unless given a reference file next to it.
60
+ relative_to = "pyproject.toml"
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["src/strands_dakera"]
64
+
65
+ [tool.hatch.envs.default]
66
+ dependencies = [
67
+ "pytest>=8.0.0,<9.0.0",
68
+ "pytest-asyncio>=0.25.0,<1.0.0",
69
+ "ruff>=0.11.0,<1.0.0",
70
+ "mypy>=1.15.0,<2.0.0",
71
+ ]
72
+
73
+ [tool.hatch.envs.default.scripts]
74
+ test = "pytest {args}"
75
+ lint = "ruff check src tests"
76
+ format = "ruff format src tests"
77
+ typecheck = "mypy src"
78
+ prepare = ["format", "lint", "typecheck", "test"]
79
+
80
+ [tool.ruff]
81
+ line-length = 120
82
+ include = ["src/**/*.py", "tests/**/*.py"]
83
+
84
+ [tool.ruff.lint]
85
+ select = [
86
+ "E", # pycodestyle
87
+ "F", # pyflakes
88
+ "I", # isort
89
+ "B", # flake8-bugbear
90
+ ]
91
+
92
+ [tool.mypy]
93
+ python_version = "3.10"
94
+ warn_return_any = true
95
+ warn_unused_configs = true
96
+ ignore_missing_imports = true
97
+
98
+ [tool.pytest.ini_options]
99
+ testpaths = ["tests"]
100
+ pythonpath = ["src"]
101
+ asyncio_mode = "auto"
@@ -0,0 +1,20 @@
1
+ """Strands Dakera — persistent, decay-weighted memory for Strands agents.
2
+
3
+ Backed by a self-hosted `Dakera <https://github.com/dakera-ai/dakera-deploy>`_
4
+ memory server. Exposes the ``dakera_memory`` tool for store / retrieve / get /
5
+ update / delete operations with importance-weighted, decay-aware recall.
6
+ """
7
+
8
+ from strands_dakera.memory import (
9
+ TOOL_SPEC,
10
+ DakeraServiceClient,
11
+ dakera_memory,
12
+ )
13
+
14
+ __all__ = [
15
+ "dakera_memory",
16
+ "DakeraServiceClient",
17
+ "TOOL_SPEC",
18
+ ]
19
+
20
+ __version__ = "0.1.0"
@@ -0,0 +1,420 @@
1
+ """
2
+ Tool for managing agent memories using Dakera (store, retrieve, get, update, delete).
3
+
4
+ This module provides persistent, decay-weighted vector memory for Strands agents,
5
+ backed by a self-hosted Dakera memory server. It mirrors the structure of the
6
+ ``mem0_memory`` tool so it slots naturally into the Strands tools ecosystem.
7
+
8
+ Dakera is a self-hosted AI agent memory server. Run it locally with the
9
+ ``dakera-ai/dakera-deploy`` docker-compose stack (server + MinIO); the REST API
10
+ listens on port 3000 by default. See https://github.com/dakera-ai/dakera-deploy.
11
+
12
+ Key Features:
13
+ ------------
14
+ 1. Memory Management:
15
+ - store: Add a new memory for an agent, with importance and metadata
16
+ - retrieve: Semantic (decay-weighted) recall across an agent's memories
17
+ - get: Fetch a specific memory by its ID
18
+ - update: Update the content/metadata of an existing memory
19
+ - delete: Remove a memory by its ID
20
+
21
+ 2. Safety Features:
22
+ - User confirmation for mutative operations (store, update, delete)
23
+ - Content previews before storage
24
+ - Warning messages before deletion
25
+ - BYPASS_TOOL_CONSENT mode for bypassing confirmations in tests
26
+
27
+ 3. Advanced Capabilities:
28
+ - Decay-weighted, access-aware ranking (recall favors important, fresh memories)
29
+ - Importance scoring (0.0-1.0) and typed memories (episodic/semantic/procedural)
30
+ - Structured metadata storage
31
+ - Rich output formatting
32
+
33
+ Configuration (environment variables):
34
+ - DAKERA_BASE_URL: Dakera server URL (default: http://localhost:3000)
35
+ - DAKERA_API_KEY: API key for the Dakera server (dk-...)
36
+
37
+ Usage Examples:
38
+ --------------
39
+ ```python
40
+ from strands import Agent
41
+ from strands_dakera import dakera_memory
42
+
43
+ agent = Agent(tools=[dakera_memory])
44
+
45
+ # Store a memory
46
+ agent.tool.dakera_memory(
47
+ action="store",
48
+ agent_id="alex",
49
+ content="Important information to remember",
50
+ importance=0.8,
51
+ metadata={"category": "meeting_notes"},
52
+ )
53
+
54
+ # Retrieve memories with decay-weighted semantic search
55
+ agent.tool.dakera_memory(
56
+ action="retrieve",
57
+ agent_id="alex",
58
+ query="meeting information",
59
+ top_k=5,
60
+ )
61
+ ```
62
+ """
63
+
64
+ import json
65
+ import logging
66
+ import os
67
+ from typing import Any
68
+
69
+ from rich.console import Console
70
+ from rich.panel import Panel
71
+ from rich.table import Table
72
+ from rich.text import Text
73
+ from strands.types.tools import ToolResult, ToolResultContent, ToolUse
74
+
75
+ logger = logging.getLogger(__name__)
76
+ console = Console()
77
+
78
+ TOOL_SPEC = {
79
+ "name": "dakera_memory",
80
+ "description": (
81
+ "Persistent, decay-weighted memory for agents, backed by a self-hosted Dakera server.\n\n"
82
+ "Actions:\n"
83
+ "- store: Store a new memory (requires agent_id and content)\n"
84
+ "- retrieve: Decay-weighted semantic search (requires agent_id and query)\n"
85
+ "- get: Get a memory by ID (requires agent_id and memory_id)\n"
86
+ "- update: Update a memory's content/metadata (requires agent_id and memory_id)\n"
87
+ "- delete: Delete a memory by ID (requires agent_id and memory_id)\n\n"
88
+ "Configure the server via DAKERA_BASE_URL (default http://localhost:3000) and DAKERA_API_KEY."
89
+ ),
90
+ "inputSchema": {
91
+ "json": {
92
+ "type": "object",
93
+ "properties": {
94
+ "action": {
95
+ "type": "string",
96
+ "description": "Action to perform (store, retrieve, get, update, delete)",
97
+ "enum": ["store", "retrieve", "get", "update", "delete"],
98
+ },
99
+ "agent_id": {
100
+ "type": "string",
101
+ "description": "Agent identifier that owns the memories (required for all actions)",
102
+ },
103
+ "content": {
104
+ "type": "string",
105
+ "description": "Memory content (required for store; optional for update)",
106
+ },
107
+ "memory_id": {
108
+ "type": "string",
109
+ "description": "Memory ID (required for get, update, delete actions)",
110
+ },
111
+ "query": {
112
+ "type": "string",
113
+ "description": "Search query (required for retrieve action)",
114
+ },
115
+ "top_k": {
116
+ "type": "integer",
117
+ "description": "Number of results to return for retrieve (default: 5)",
118
+ },
119
+ "importance": {
120
+ "type": "number",
121
+ "description": "Importance score 0.0-1.0 for store action",
122
+ },
123
+ "memory_type": {
124
+ "type": "string",
125
+ "description": "Memory type for store/update (episodic, semantic, procedural, working)",
126
+ "enum": ["episodic", "semantic", "procedural", "working"],
127
+ },
128
+ "metadata": {
129
+ "type": "object",
130
+ "description": "Optional metadata to store with the memory",
131
+ },
132
+ },
133
+ "required": ["action", "agent_id"],
134
+ }
135
+ },
136
+ }
137
+
138
+
139
+ class DakeraServiceClient:
140
+ """Thin wrapper around the Dakera Python SDK for the memory tool."""
141
+
142
+ def __init__(self) -> None:
143
+ """Initialize the Dakera client from environment configuration.
144
+
145
+ Reads DAKERA_BASE_URL (default http://localhost:3000) and DAKERA_API_KEY.
146
+ """
147
+ try:
148
+ from dakera import DakeraClient
149
+ except ImportError as err:
150
+ raise ImportError(
151
+ "The dakera package is required for the dakera_memory tool. "
152
+ "Install it with: pip install 'strands-dakera'"
153
+ ) from err
154
+
155
+ base_url = os.environ.get("DAKERA_BASE_URL", "http://localhost:3000")
156
+ api_key = os.environ.get("DAKERA_API_KEY")
157
+ self.client = DakeraClient(base_url=base_url, api_key=api_key)
158
+
159
+ def store_memory(
160
+ self,
161
+ agent_id: str,
162
+ content: str,
163
+ importance: float | None = None,
164
+ memory_type: str = "episodic",
165
+ metadata: dict[str, Any] | None = None,
166
+ ) -> dict[str, Any]:
167
+ """Store a memory for an agent."""
168
+ result: dict[str, Any] = self.client.store_memory(
169
+ agent_id=agent_id,
170
+ content=content,
171
+ memory_type=memory_type,
172
+ importance=importance,
173
+ metadata=metadata,
174
+ )
175
+ return result
176
+
177
+ def get_memory(self, agent_id: str, memory_id: str) -> dict[str, Any]:
178
+ """Get a memory by ID."""
179
+ result: dict[str, Any] = self.client.get_memory(agent_id, memory_id)
180
+ return result
181
+
182
+ def update_memory(
183
+ self,
184
+ agent_id: str,
185
+ memory_id: str,
186
+ content: str | None = None,
187
+ metadata: dict[str, Any] | None = None,
188
+ memory_type: str | None = None,
189
+ ) -> dict[str, Any]:
190
+ """Update an existing memory."""
191
+ result: dict[str, Any] = self.client.update_memory(
192
+ agent_id=agent_id,
193
+ memory_id=memory_id,
194
+ content=content,
195
+ metadata=metadata,
196
+ memory_type=memory_type,
197
+ )
198
+ return result
199
+
200
+ def search_memories(self, agent_id: str, query: str, top_k: int = 5) -> list[dict[str, Any]]:
201
+ """Decay-weighted semantic recall for an agent."""
202
+ # `recall` returns a RecallResponse (with `.memories`); we defensively also
203
+ # accept a raw iterable, so treat the result as untyped for duck-typing.
204
+ response: Any = self.client.recall(agent_id=agent_id, query=query, top_k=top_k)
205
+ memories = getattr(response, "memories", response)
206
+ return [_memory_to_dict(m) for m in memories]
207
+
208
+ def delete_memory(self, agent_id: str, memory_id: str) -> dict[str, Any]:
209
+ """Delete a memory by ID."""
210
+ result: dict[str, Any] = self.client.forget(agent_id, memory_id)
211
+ return result
212
+
213
+
214
+ def _memory_to_dict(memory: Any) -> dict[str, Any]:
215
+ """Normalize an SDK memory object (or dict) into a plain dict."""
216
+ if isinstance(memory, dict):
217
+ return memory
218
+ return {
219
+ "id": getattr(memory, "id", None),
220
+ "content": getattr(memory, "content", None),
221
+ "importance": getattr(memory, "importance", None),
222
+ "score": getattr(memory, "score", None),
223
+ "memory_type": getattr(memory, "memory_type", None),
224
+ "metadata": getattr(memory, "metadata", None),
225
+ "created_at": getattr(memory, "created_at", None),
226
+ }
227
+
228
+
229
+ def format_store_response(memory: dict[str, Any]) -> Panel:
230
+ """Format a store response."""
231
+ content = [
232
+ "✅ Memory stored successfully:",
233
+ f"🔑 Memory ID: {memory.get('id', 'unknown')}",
234
+ f"📊 Importance: {memory.get('importance', 'default')}",
235
+ ]
236
+ text = memory.get("content")
237
+ if text:
238
+ preview = text[:100] + "..." if len(text) > 100 else text
239
+ content.append(f"\n📄 Content: {preview}")
240
+ return Panel("\n".join(content), title="[bold green]Memory Stored", border_style="green")
241
+
242
+
243
+ def format_get_response(memory: dict[str, Any]) -> Panel:
244
+ """Format a get/update response."""
245
+ result = [
246
+ "✅ Memory retrieved successfully:",
247
+ f"🔑 Memory ID: {memory.get('id', 'unknown')}",
248
+ f"📊 Importance: {memory.get('importance', 'Unknown')}",
249
+ f"🕒 Created: {memory.get('created_at', 'Unknown')}",
250
+ ]
251
+ metadata = memory.get("metadata")
252
+ if metadata:
253
+ result.append(f"📋 Metadata: {json.dumps(metadata, indent=2)}")
254
+ result.append(f"\n📄 Memory: {memory.get('content', 'No content available')}")
255
+ return Panel("\n".join(result), title="[bold green]Memory Retrieved", border_style="green")
256
+
257
+
258
+ def format_retrieve_response(memories: list[dict[str, Any]]) -> Panel:
259
+ """Format a retrieve (semantic search) response."""
260
+ if not memories:
261
+ return Panel(
262
+ "No memories found matching the query.",
263
+ title="[bold yellow]No Matches",
264
+ border_style="yellow",
265
+ )
266
+
267
+ table = Table(title="Search Results", show_header=True, header_style="bold magenta")
268
+ table.add_column("ID", style="cyan")
269
+ table.add_column("Memory", style="yellow", width=50)
270
+ table.add_column("Score", style="green")
271
+ table.add_column("Importance", style="blue")
272
+
273
+ for memory in memories:
274
+ content = memory.get("content") or "No content available"
275
+ preview = content[:100] + "..." if len(content) > 100 else content
276
+ table.add_row(
277
+ str(memory.get("id", "unknown")),
278
+ preview,
279
+ str(memory.get("score", "N/A")),
280
+ str(memory.get("importance", "N/A")),
281
+ )
282
+
283
+ return Panel(table, title="[bold green]Search Results", border_style="green")
284
+
285
+
286
+ def format_delete_response(memory_id: str) -> Panel:
287
+ """Format a delete response."""
288
+ content = [
289
+ "✅ Memory deleted successfully:",
290
+ f"🔑 Memory ID: {memory_id}",
291
+ ]
292
+ return Panel("\n".join(content), title="[bold green]Memory Deleted", border_style="green")
293
+
294
+
295
+ def dakera_memory(tool: ToolUse, **kwargs: Any) -> ToolResult:
296
+ """Persistent decay-weighted memory management for agents, backed by Dakera.
297
+
298
+ Args:
299
+ tool: ToolUse object containing the input fields (action, agent_id, content,
300
+ memory_id, query, top_k, importance, memory_type, metadata).
301
+ **kwargs: Additional keyword arguments.
302
+
303
+ Returns:
304
+ ToolResult containing status and response content.
305
+ """
306
+ tool_use_id = tool.get("toolUseId", "default-id")
307
+ try:
308
+ tool_input = tool.get("input", {})
309
+
310
+ action = tool_input.get("action")
311
+ if not action:
312
+ raise ValueError("action parameter is required")
313
+
314
+ agent_id = tool_input.get("agent_id")
315
+ if not agent_id:
316
+ raise ValueError("agent_id parameter is required")
317
+
318
+ client = DakeraServiceClient()
319
+ bypass_consent = os.environ.get("BYPASS_TOOL_CONSENT", "").lower() == "true"
320
+
321
+ mutative_actions = {"store", "update", "delete"}
322
+ needs_confirmation = action in mutative_actions and not bypass_consent
323
+
324
+ if needs_confirmation:
325
+ if action == "store":
326
+ if not tool_input.get("content"):
327
+ raise ValueError("content is required for store action")
328
+ preview = tool_input["content"][:15000]
329
+ console.print(Panel(preview, title=f"[bold green]Memory for agent {agent_id}", border_style="green"))
330
+ elif action in {"update", "delete"}:
331
+ if not tool_input.get("memory_id"):
332
+ raise ValueError(f"memory_id is required for {action} action")
333
+ console.print(
334
+ Panel(
335
+ f"Memory ID: {tool_input['memory_id']}",
336
+ title=f"[bold red]⚠️ Memory to be {action}d",
337
+ border_style="red",
338
+ )
339
+ )
340
+
341
+ if action == "store":
342
+ if not tool_input.get("content"):
343
+ raise ValueError("content is required for store action")
344
+ memory = client.store_memory(
345
+ agent_id=agent_id,
346
+ content=tool_input["content"],
347
+ importance=tool_input.get("importance"),
348
+ memory_type=tool_input.get("memory_type", "episodic"),
349
+ metadata=tool_input.get("metadata"),
350
+ )
351
+ console.print(format_store_response(memory))
352
+ return ToolResult(
353
+ toolUseId=tool_use_id,
354
+ status="success",
355
+ content=[ToolResultContent(text=json.dumps(memory, indent=2, default=str))],
356
+ )
357
+
358
+ if action == "retrieve":
359
+ if not tool_input.get("query"):
360
+ raise ValueError("query is required for retrieve action")
361
+ memories = client.search_memories(
362
+ agent_id=agent_id,
363
+ query=tool_input["query"],
364
+ top_k=tool_input.get("top_k", 5),
365
+ )
366
+ console.print(format_retrieve_response(memories))
367
+ return ToolResult(
368
+ toolUseId=tool_use_id,
369
+ status="success",
370
+ content=[ToolResultContent(text=json.dumps(memories, indent=2, default=str))],
371
+ )
372
+
373
+ if action == "get":
374
+ if not tool_input.get("memory_id"):
375
+ raise ValueError("memory_id is required for get action")
376
+ memory = client.get_memory(agent_id, tool_input["memory_id"])
377
+ console.print(format_get_response(memory))
378
+ return ToolResult(
379
+ toolUseId=tool_use_id,
380
+ status="success",
381
+ content=[ToolResultContent(text=json.dumps(memory, indent=2, default=str))],
382
+ )
383
+
384
+ if action == "update":
385
+ if not tool_input.get("memory_id"):
386
+ raise ValueError("memory_id is required for update action")
387
+ memory = client.update_memory(
388
+ agent_id=agent_id,
389
+ memory_id=tool_input["memory_id"],
390
+ content=tool_input.get("content"),
391
+ metadata=tool_input.get("metadata"),
392
+ memory_type=tool_input.get("memory_type"),
393
+ )
394
+ console.print(format_get_response(memory))
395
+ return ToolResult(
396
+ toolUseId=tool_use_id,
397
+ status="success",
398
+ content=[ToolResultContent(text=json.dumps(memory, indent=2, default=str))],
399
+ )
400
+
401
+ if action == "delete":
402
+ if not tool_input.get("memory_id"):
403
+ raise ValueError("memory_id is required for delete action")
404
+ client.delete_memory(agent_id, tool_input["memory_id"])
405
+ console.print(format_delete_response(tool_input["memory_id"]))
406
+ return ToolResult(
407
+ toolUseId=tool_use_id,
408
+ status="success",
409
+ content=[ToolResultContent(text=f"Memory {tool_input['memory_id']} deleted successfully")],
410
+ )
411
+
412
+ raise ValueError(f"Invalid action: {action}")
413
+
414
+ except Exception as e:
415
+ console.print(Panel(Text(str(e), style="red"), title="❌ Memory Operation Error", border_style="red"))
416
+ return ToolResult(
417
+ toolUseId=tool_use_id,
418
+ status="error",
419
+ content=[ToolResultContent(text=f"Error: {str(e)}")],
420
+ )
@@ -0,0 +1,294 @@
1
+ """
2
+ Tests for the dakera_memory tool.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+ from strands.types.tools import ToolUse
11
+
12
+ from strands_dakera.memory import DakeraServiceClient, dakera_memory
13
+
14
+
15
+ @pytest.fixture
16
+ def mock_tool():
17
+ """Create a mock ToolUse object."""
18
+ mock = MagicMock(spec=ToolUse)
19
+ mock.get.side_effect = lambda key, default=None: {"toolUseId": "test-id", "input": {}}.get(key, default)
20
+ return mock
21
+
22
+
23
+ def make_tool(input_data: dict) -> MagicMock:
24
+ """Helper: build a mock ToolUse with given input dict."""
25
+ mock = MagicMock(spec=ToolUse)
26
+ mock.get.side_effect = lambda key, default=None: {
27
+ "toolUseId": "test-id",
28
+ "input": input_data,
29
+ }.get(key, default)
30
+ return mock
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # DakeraServiceClient init
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def test_service_client_raises_on_missing_package():
39
+ """ImportError when dakera package is not installed."""
40
+ with patch("builtins.__import__", side_effect=ImportError("No module named 'dakera'")):
41
+ with pytest.raises(ImportError, match="dakera package is required"):
42
+ DakeraServiceClient()
43
+
44
+
45
+ @patch.dict(os.environ, {"DAKERA_BASE_URL": "http://localhost:3000", "DAKERA_API_KEY": "dk-test"})
46
+ def test_service_client_init():
47
+ """Client initialises from env vars."""
48
+ mock_dakera_client = MagicMock()
49
+ with patch("strands_dakera.memory.DakeraServiceClient.__init__", return_value=None) as patched:
50
+ patched.return_value = None
51
+ client = DakeraServiceClient.__new__(DakeraServiceClient)
52
+ client.client = mock_dakera_client
53
+ assert client.client is mock_dakera_client
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # store action
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ @patch.dict(os.environ, {"BYPASS_TOOL_CONSENT": "true"})
62
+ def test_store_memory():
63
+ """store action returns success with memory dict."""
64
+ stored = {
65
+ "id": "mem-abc",
66
+ "content": "Test memory content",
67
+ "importance": 0.8,
68
+ "created_at": "2026-07-02T00:00:00Z",
69
+ }
70
+
71
+ with patch("strands_dakera.memory.DakeraServiceClient") as MockClient:
72
+ instance = MockClient.return_value
73
+ instance.store_memory.return_value = stored
74
+
75
+ tool = make_tool(
76
+ {
77
+ "action": "store",
78
+ "agent_id": "alex",
79
+ "content": "Test memory content",
80
+ "importance": 0.8,
81
+ "metadata": {"category": "notes"},
82
+ }
83
+ )
84
+ result = dakera_memory(tool=tool)
85
+
86
+ assert result["status"] == "success"
87
+ body = json.loads(result["content"][0]["text"])
88
+ assert body["id"] == "mem-abc"
89
+ instance.store_memory.assert_called_once_with(
90
+ agent_id="alex",
91
+ content="Test memory content",
92
+ importance=0.8,
93
+ memory_type="episodic",
94
+ metadata={"category": "notes"},
95
+ )
96
+
97
+
98
+ def test_store_missing_content():
99
+ """store without content returns error."""
100
+ tool = make_tool({"action": "store", "agent_id": "alex"})
101
+ with patch("strands_dakera.memory.DakeraServiceClient"):
102
+ result = dakera_memory(tool=tool)
103
+ assert result["status"] == "error"
104
+ assert "content is required for store action" in result["content"][0]["text"]
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # retrieve action
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @patch.dict(os.environ, {"BYPASS_TOOL_CONSENT": "true"})
113
+ def test_retrieve_memories():
114
+ """retrieve returns a list of matching memories."""
115
+ memories = [
116
+ {"id": "mem-1", "content": "hello world", "score": 0.95, "importance": 0.7},
117
+ {"id": "mem-2", "content": "another fact", "score": 0.80, "importance": 0.5},
118
+ ]
119
+ with patch("strands_dakera.memory.DakeraServiceClient") as MockClient:
120
+ instance = MockClient.return_value
121
+ instance.search_memories.return_value = memories
122
+
123
+ tool = make_tool({"action": "retrieve", "agent_id": "alex", "query": "hello", "top_k": 2})
124
+ result = dakera_memory(tool=tool)
125
+
126
+ assert result["status"] == "success"
127
+ body = json.loads(result["content"][0]["text"])
128
+ assert len(body) == 2
129
+ assert body[0]["id"] == "mem-1"
130
+ instance.search_memories.assert_called_once_with(agent_id="alex", query="hello", top_k=2)
131
+
132
+
133
+ def test_retrieve_missing_query():
134
+ """retrieve without query returns error."""
135
+ tool = make_tool({"action": "retrieve", "agent_id": "alex"})
136
+ with patch("strands_dakera.memory.DakeraServiceClient"):
137
+ result = dakera_memory(tool=tool)
138
+ assert result["status"] == "error"
139
+ assert "query is required" in result["content"][0]["text"]
140
+
141
+
142
+ def test_retrieve_empty_results():
143
+ """retrieve with no matches returns success with empty list."""
144
+ with patch("strands_dakera.memory.DakeraServiceClient") as MockClient:
145
+ instance = MockClient.return_value
146
+ instance.search_memories.return_value = []
147
+
148
+ tool = make_tool({"action": "retrieve", "agent_id": "alex", "query": "xyz"})
149
+ result = dakera_memory(tool=tool)
150
+
151
+ assert result["status"] == "success"
152
+ body = json.loads(result["content"][0]["text"])
153
+ assert body == []
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # get action
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ def test_get_memory():
162
+ """get returns the memory dict."""
163
+ memory = {
164
+ "id": "mem-abc",
165
+ "content": "stored fact",
166
+ "importance": 0.9,
167
+ "created_at": "2026-07-02T00:00:00Z",
168
+ "metadata": {},
169
+ }
170
+ with patch("strands_dakera.memory.DakeraServiceClient") as MockClient:
171
+ instance = MockClient.return_value
172
+ instance.get_memory.return_value = memory
173
+
174
+ tool = make_tool({"action": "get", "agent_id": "alex", "memory_id": "mem-abc"})
175
+ result = dakera_memory(tool=tool)
176
+
177
+ assert result["status"] == "success"
178
+ body = json.loads(result["content"][0]["text"])
179
+ assert body["id"] == "mem-abc"
180
+ instance.get_memory.assert_called_once_with("alex", "mem-abc")
181
+
182
+
183
+ def test_get_missing_memory_id():
184
+ """get without memory_id returns error."""
185
+ tool = make_tool({"action": "get", "agent_id": "alex"})
186
+ with patch("strands_dakera.memory.DakeraServiceClient"):
187
+ result = dakera_memory(tool=tool)
188
+ assert result["status"] == "error"
189
+ assert "memory_id is required for get action" in result["content"][0]["text"]
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # update action
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ @patch.dict(os.environ, {"BYPASS_TOOL_CONSENT": "true"})
198
+ def test_update_memory():
199
+ """update returns updated memory dict."""
200
+ updated = {
201
+ "id": "mem-abc",
202
+ "content": "updated content",
203
+ "importance": 0.9,
204
+ "created_at": "2026-07-02T00:00:00Z",
205
+ "metadata": {"tag": "v2"},
206
+ }
207
+ with patch("strands_dakera.memory.DakeraServiceClient") as MockClient:
208
+ instance = MockClient.return_value
209
+ instance.update_memory.return_value = updated
210
+
211
+ tool = make_tool(
212
+ {
213
+ "action": "update",
214
+ "agent_id": "alex",
215
+ "memory_id": "mem-abc",
216
+ "content": "updated content",
217
+ "metadata": {"tag": "v2"},
218
+ }
219
+ )
220
+ result = dakera_memory(tool=tool)
221
+
222
+ assert result["status"] == "success"
223
+ body = json.loads(result["content"][0]["text"])
224
+ assert body["content"] == "updated content"
225
+
226
+
227
+ def test_update_missing_memory_id():
228
+ """update without memory_id returns error."""
229
+ tool = make_tool({"action": "update", "agent_id": "alex", "content": "new"})
230
+ with patch("strands_dakera.memory.DakeraServiceClient"):
231
+ result = dakera_memory(tool=tool)
232
+ assert result["status"] == "error"
233
+ assert "memory_id is required for update action" in result["content"][0]["text"]
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # delete action
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ @patch.dict(os.environ, {"BYPASS_TOOL_CONSENT": "true"})
242
+ def test_delete_memory():
243
+ """delete returns success text with memory_id."""
244
+ with patch("strands_dakera.memory.DakeraServiceClient") as MockClient:
245
+ instance = MockClient.return_value
246
+ instance.delete_memory.return_value = {"status": "ok"}
247
+
248
+ tool = make_tool({"action": "delete", "agent_id": "alex", "memory_id": "mem-abc"})
249
+ result = dakera_memory(tool=tool)
250
+
251
+ assert result["status"] == "success"
252
+ assert "mem-abc" in result["content"][0]["text"]
253
+ instance.delete_memory.assert_called_once_with("alex", "mem-abc")
254
+
255
+
256
+ def test_delete_missing_memory_id():
257
+ """delete without memory_id returns error."""
258
+ tool = make_tool({"action": "delete", "agent_id": "alex"})
259
+ with patch("strands_dakera.memory.DakeraServiceClient"):
260
+ result = dakera_memory(tool=tool)
261
+ assert result["status"] == "error"
262
+ assert "memory_id is required for delete action" in result["content"][0]["text"]
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Guard: missing required params
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ def test_missing_action():
271
+ """No action returns error."""
272
+ tool = make_tool({"agent_id": "alex"})
273
+ with patch("strands_dakera.memory.DakeraServiceClient"):
274
+ result = dakera_memory(tool=tool)
275
+ assert result["status"] == "error"
276
+ assert "action parameter is required" in result["content"][0]["text"]
277
+
278
+
279
+ def test_missing_agent_id():
280
+ """No agent_id returns error."""
281
+ tool = make_tool({"action": "retrieve", "query": "test"})
282
+ with patch("strands_dakera.memory.DakeraServiceClient"):
283
+ result = dakera_memory(tool=tool)
284
+ assert result["status"] == "error"
285
+ assert "agent_id parameter is required" in result["content"][0]["text"]
286
+
287
+
288
+ def test_invalid_action():
289
+ """Unknown action returns error."""
290
+ tool = make_tool({"action": "explode", "agent_id": "alex"})
291
+ with patch("strands_dakera.memory.DakeraServiceClient"):
292
+ result = dakera_memory(tool=tool)
293
+ assert result["status"] == "error"
294
+ assert "Invalid action" in result["content"][0]["text"]