diff --git a/.claude-flow/data/auto-memory-store.json b/.claude-flow/data/auto-memory-store.json new file mode 100644 index 0000000..7bdd36a --- /dev/null +++ b/.claude-flow/data/auto-memory-store.json @@ -0,0 +1,62 @@ +[ + { + "id": "task-task_1783449649108_gdz3st", + "key": "task_1783449649108_gdz3st", + "content": "Task completed: task_1783449649108_gdz3st", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449649579 + }, + { + "id": "task-task_1783449682752_ka3jge", + "key": "task_1783449682752_ka3jge", + "content": "Task completed: task_1783449682752_ka3jge", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449683196 + }, + { + "id": "task-task_1783449683525_360381", + "key": "task_1783449683525_360381", + "content": "Task completed: task_1783449683525_360381", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449684212 + }, + { + "id": "task-task_1783449747595_16svkj", + "key": "task_1783449747595_16svkj", + "content": "Task completed: task_1783449747595_16svkj", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449748079 + }, + { + "id": "task-task_1783450000362_5snngx", + "key": "task_1783450000362_5snngx", + "content": "Task completed: task_1783450000362_5snngx", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783450000807 + } +] \ No newline at end of file diff --git a/.claude-flow/neural/stats.json b/.claude-flow/neural/stats.json new file mode 100644 index 0000000..0169f92 --- /dev/null +++ b/.claude-flow/neural/stats.json @@ -0,0 +1,6 @@ +{ + "trajectoriesRecorded": 4, + "patternsLearned": 1, + "signalsProcessed": 0, + "lastAdaptation": 1783450000807 +} \ No newline at end of file diff --git a/README.md b/README.md index b96305a..b8def0a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,44 @@ Mutative actions (`store`, `update`, `delete`) prompt for confirmation unless `B See [`docs/dakera_memory_tool.md`](docs/dakera_memory_tool.md) for the full action reference. +## Memory store + +`DakeraMemoryStore` is a Strands [`MemoryStore`](https://strandsagents.com/) — a +`MemoryManager` extension point (Strands ≥ 1.45). Where the `dakera_memory` tool is +called explicitly by the model, a store plugs into the **agent loop** directly: the +manager searches it to recall context (injected into the prompt automatically) and, +when writable, writes new memories — either directly or via periodic **extraction** +from the conversation. Both share one Dakera server and agent namespace. + +```python +from strands import Agent +from strands.memory import MemoryManager +from strands_dakera import DakeraMemoryStore + +# Recall + write, distilling facts from the conversation every few turns. +store = DakeraMemoryStore(agent_id="alex", writable=True, extraction=True) +agent = Agent(memory_manager=MemoryManager(stores=[store])) + +# The agent now recalls from and writes to Dakera without any explicit tool call. +agent("Remember that I prefer dark-mode dashboards.") +agent("How do I like my dashboards?") # recalls the stored preference +``` + +`DakeraMemoryStore` implements `search` (decay-weighted recall) and `add` (a client-side +write sink), so enabling `extraction` uses the manager's client-side `ModelExtractor` to +distill facts before storing them. + +| Argument | Default | Description | +|---|---|---| +| `agent_id` | _(required)_ | Dakera agent namespace that owns the memories | +| `name` | `"dakera"` | Store identifier, used to target it from memory tools | +| `writable` | `True` | Whether the manager may write to the store | +| `extraction` | `None` | Automatic extraction (`bool` or `ExtractionConfig`) | +| `max_search_results` | `None` | Default result cap per search (falls back to 5) | +| `importance` | `None` | Default importance (0.0–1.0) applied to writes | +| `memory_type` | `"episodic"` | Default Dakera memory type for writes | +| `base_url` / `api_key` | env | Override `DAKERA_BASE_URL` / `DAKERA_API_KEY` | + ## Development The package lives under [`python/`](python/) (monorepo-style layout matching the diff --git a/agentdb.rvf b/agentdb.rvf new file mode 100644 index 0000000..edb358c Binary files /dev/null and b/agentdb.rvf differ diff --git a/agentdb.rvf.lock b/agentdb.rvf.lock new file mode 100644 index 0000000..a25adee Binary files /dev/null and b/agentdb.rvf.lock differ diff --git a/python/.claude-flow/data/auto-memory-store.json b/python/.claude-flow/data/auto-memory-store.json new file mode 100644 index 0000000..1479c59 --- /dev/null +++ b/python/.claude-flow/data/auto-memory-store.json @@ -0,0 +1,62 @@ +[ + { + "id": "task-task_1783449905604_0fqkri", + "key": "task_1783449905604_0fqkri", + "content": "Task completed: task_1783449905604_0fqkri", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449906138 + }, + { + "id": "task-task_1783449911710_7k33m3", + "key": "task_1783449911710_7k33m3", + "content": "Task completed: task_1783449911710_7k33m3", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449912371 + }, + { + "id": "task-task_1783449935947_cq3mgy", + "key": "task_1783449935947_cq3mgy", + "content": "Task completed: task_1783449935947_cq3mgy", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449936390 + }, + { + "id": "task-task_1783449963076_vhlzn7", + "key": "task_1783449963076_vhlzn7", + "content": "Task completed: task_1783449963076_vhlzn7", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449963759 + }, + { + "id": "task-task_1783449972951_pg1sde", + "key": "task_1783449972951_pg1sde", + "content": "Task completed: task_1783449972951_pg1sde", + "namespace": "tasks", + "type": "task-outcome", + "metadata": { + "success": true, + "quality": 0.85 + }, + "createdAt": 1783449973416 + } +] \ No newline at end of file diff --git a/python/.claude-flow/neural/stats.json b/python/.claude-flow/neural/stats.json new file mode 100644 index 0000000..1f31699 --- /dev/null +++ b/python/.claude-flow/neural/stats.json @@ -0,0 +1,6 @@ +{ + "trajectoriesRecorded": 4, + "patternsLearned": 1, + "signalsProcessed": 0, + "lastAdaptation": 1783449973416 +} \ No newline at end of file diff --git a/python/README.md b/python/README.md index ceeb525..bc5c21b 100644 --- a/python/README.md +++ b/python/README.md @@ -9,6 +9,8 @@ See the [repository README](../README.md) for full usage. Quick start: pip install strands-dakera ``` +As an explicit tool: + ```python from strands import Agent from strands_dakera import dakera_memory @@ -16,6 +18,17 @@ from strands_dakera import dakera_memory agent = Agent(tools=[dakera_memory]) ``` +Or as a `MemoryStore` that plugs into the agent loop (Strands ≥ 1.45): + +```python +from strands import Agent +from strands.memory import MemoryManager +from strands_dakera import DakeraMemoryStore + +store = DakeraMemoryStore(agent_id="alex", writable=True, extraction=True) +agent = Agent(memory_manager=MemoryManager(stores=[store])) +``` + ## Local development ```bash diff --git a/python/agentdb.rvf b/python/agentdb.rvf new file mode 100644 index 0000000..0fc2e38 Binary files /dev/null and b/python/agentdb.rvf differ diff --git a/python/agentdb.rvf.lock b/python/agentdb.rvf.lock new file mode 100644 index 0000000..38748a1 Binary files /dev/null and b/python/agentdb.rvf.lock differ diff --git a/python/pyproject.toml b/python/pyproject.toml index 700e5af..309617f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -25,8 +25,10 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] +# strands-agents>=1.45.0: first release shipping the `strands.memory` module +# (MemoryStore / MemoryManager) that DakeraMemoryStore implements. dependencies = [ - "strands-agents>=1.0.0", + "strands-agents>=1.45.0", "dakera>=0.12.0", "rich>=13.0.0", ] diff --git a/python/src/strands_dakera/__init__.py b/python/src/strands_dakera/__init__.py index c509e7f..1ab952a 100644 --- a/python/src/strands_dakera/__init__.py +++ b/python/src/strands_dakera/__init__.py @@ -1,8 +1,14 @@ """Strands Dakera — persistent, decay-weighted memory for Strands agents. Backed by a self-hosted `Dakera `_ -memory server. Exposes the ``dakera_memory`` tool for store / retrieve / get / -update / delete operations with importance-weighted, decay-aware recall. +memory server. Two integration points, sharing one server: + +- :func:`dakera_memory` — a tool the model calls explicitly for store / retrieve + / get / update / delete operations. +- :class:`DakeraMemoryStore` — a ``MemoryStore`` that plugs into the agent loop + via a ``MemoryManager``, with automatic memory injection and extraction. + +Both offer importance-weighted, decay-aware recall. """ from strands_dakera.memory import ( @@ -10,10 +16,16 @@ DakeraServiceClient, dakera_memory, ) +from strands_dakera.store import ( + DakeraMemoryStore, + DakeraMemoryStoreConfig, +) __all__ = [ "dakera_memory", "DakeraServiceClient", + "DakeraMemoryStore", + "DakeraMemoryStoreConfig", "TOOL_SPEC", ] diff --git a/python/src/strands_dakera/memory.py b/python/src/strands_dakera/memory.py index 63733a1..e1fae3a 100644 --- a/python/src/strands_dakera/memory.py +++ b/python/src/strands_dakera/memory.py @@ -139,10 +139,13 @@ class DakeraServiceClient: """Thin wrapper around the Dakera Python SDK for the memory tool.""" - def __init__(self) -> None: - """Initialize the Dakera client from environment configuration. + def __init__(self, base_url: str | None = None, api_key: str | None = None) -> None: + """Initialize the Dakera client. - Reads DAKERA_BASE_URL (default http://localhost:3000) and DAKERA_API_KEY. + Args: + base_url: Dakera server URL. Falls back to ``$DAKERA_BASE_URL``, + then ``http://localhost:3000``. + api_key: Dakera API key. Falls back to ``$DAKERA_API_KEY``. """ try: from dakera import DakeraClient @@ -152,8 +155,8 @@ def __init__(self) -> None: "Install it with: pip install 'strands-dakera'" ) from err - base_url = os.environ.get("DAKERA_BASE_URL", "http://localhost:3000") - api_key = os.environ.get("DAKERA_API_KEY") + base_url = base_url or os.environ.get("DAKERA_BASE_URL", "http://localhost:3000") + api_key = api_key or os.environ.get("DAKERA_API_KEY") self.client = DakeraClient(base_url=base_url, api_key=api_key) def store_memory( diff --git a/python/src/strands_dakera/store.py b/python/src/strands_dakera/store.py new file mode 100644 index 0000000..fd43108 --- /dev/null +++ b/python/src/strands_dakera/store.py @@ -0,0 +1,180 @@ +"""A Strands ``MemoryStore`` backed by a self-hosted Dakera memory server. + +A memory store gives a Strands agent cross-session recall: a +:class:`~strands.memory.MemoryManager` searches it to recall facts and, when +writable, writes new ones — either directly or via automatic extraction from the +conversation. Unlike the ``dakera_memory`` tool (which the model calls +explicitly), a store plugs into the agent loop out of the box, with memory +injection and extraction triggers handled by the manager. + +The store wraps the same Dakera REST API used by the ``dakera_memory`` tool +(via :class:`~strands_dakera.memory.DakeraServiceClient`), so a store and the +tool can share one server and one agent namespace. + +Example: + ```python + from strands import Agent + from strands.memory import MemoryManager + from strands_dakera import DakeraMemoryStore + + # Recall + write, with automatic extraction every few turns. + store = DakeraMemoryStore(agent_id="alex", writable=True, extraction=True) + agent = Agent(memory_manager=MemoryManager(stores=[store])) + ``` + +Configure the server via the ``base_url`` / ``api_key`` arguments, or the +``DAKERA_BASE_URL`` (default ``http://localhost:3000``) and ``DAKERA_API_KEY`` +environment variables. See https://github.com/dakera-ai/dakera-deploy. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from strands.memory import MemoryEntry, MemoryStore, MemoryStoreConfig, SearchOptions + +from strands_dakera.memory import DakeraServiceClient + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_SEARCH_RESULTS = 5 +DEFAULT_MEMORY_TYPE = "episodic" + + +class DakeraMemoryStoreConfig(MemoryStoreConfig, total=False): + """Configuration for a :class:`DakeraMemoryStore`. + + Extends the base :class:`~strands.memory.MemoryStoreConfig` (``name``, + ``description``, ``max_search_results``, ``writable``, ``extraction``) with + Dakera-specific fields. + + Attributes: + agent_id: Dakera agent namespace that owns the memories. Required. + importance: Default importance (0.0-1.0) applied to writes. + memory_type: Default Dakera memory type for writes + (``episodic`` | ``semantic`` | ``procedural`` | ``working``). + base_url: Dakera server URL. Defaults to ``$DAKERA_BASE_URL`` or + ``http://localhost:3000``. + api_key: Dakera API key. Defaults to ``$DAKERA_API_KEY``. + """ + + agent_id: str + importance: float + memory_type: str + base_url: str + api_key: str + + +class DakeraMemoryStore(MemoryStore): + """A Strands :class:`~strands.memory.MemoryStore` backed by Dakera. + + Implements :meth:`search` (decay-weighted semantic recall) and :meth:`add` + (a single write sink). Because it implements ``add`` (client-side write) + rather than ``add_messages``, enabling ``extraction`` uses the manager's + client-side :class:`~strands.memory.extraction.model_extractor.ModelExtractor` + to distill facts from the conversation before they are stored. + """ + + def __init__( + self, + agent_id: str, + *, + name: str = "dakera", + description: str | None = "Persistent, decay-weighted long-term memory backed by Dakera.", + max_search_results: int | None = None, + writable: bool = True, + extraction: Any = None, + importance: float | None = None, + memory_type: str = DEFAULT_MEMORY_TYPE, + base_url: str | None = None, + api_key: str | None = None, + client: DakeraServiceClient | None = None, + ) -> None: + """Initialize the store. + + Args: + agent_id: Dakera agent namespace that owns the memories. Required. + name: Unique store identifier, used to target it in tools. + description: Human-readable description, included in tool descriptions. + max_search_results: Default maximum results per search. + writable: Whether the store accepts writes. + extraction: Automatic-extraction config (``bool | ExtractionConfig``). + importance: Default importance (0.0-1.0) applied to writes. + memory_type: Default Dakera memory type for writes. + base_url: Dakera server URL (defaults to ``$DAKERA_BASE_URL``). + api_key: Dakera API key (defaults to ``$DAKERA_API_KEY``). + client: Pre-built :class:`DakeraServiceClient` (mainly for testing); + when omitted, one is constructed lazily on first use. + """ + if not agent_id: + raise ValueError("agent_id is required for DakeraMemoryStore") + + # MemoryStore Protocol attributes. + self.name = name + self.description = description + self.max_search_results = max_search_results + self.writable = writable + self.extraction = extraction + + # Dakera-specific configuration. + self.agent_id = agent_id + self.importance = importance + self.memory_type = memory_type + + self._base_url = base_url + self._api_key = api_key + self._client = client + + @property + def client(self) -> DakeraServiceClient: + """The Dakera client, constructed lazily on first use.""" + if self._client is None: + self._client = DakeraServiceClient(base_url=self._base_url, api_key=self._api_key) + return self._client + + async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]: + """Search Dakera for entries matching ``query``, ordered by relevance. + + Uses Dakera's decay-weighted, access-aware recall, so results favor + important, recently used memories. + """ + top_k = options.get("max_search_results") if options is not None else None + if top_k is None: + top_k = self.max_search_results + if top_k is None: + top_k = DEFAULT_MAX_SEARCH_RESULTS + + memories = await asyncio.to_thread(self.client.search_memories, self.agent_id, query, top_k) + return [self._to_entry(m) for m in memories] + + async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any: + """Write a single memory to Dakera. + + Extraction writes are at-least-once, so this tolerates duplicate content; + Dakera de-duplicates on the server. The resolved value is the stored + memory dict returned by the server. + """ + return await asyncio.to_thread( + self.client.store_memory, + self.agent_id, + content, + self.importance, + self.memory_type, + metadata, + ) + + @staticmethod + def _to_entry(memory: dict[str, Any]) -> MemoryEntry: + """Map a Dakera memory dict to a Strands :class:`~strands.memory.MemoryEntry`.""" + content = memory.get("content") or "" + metadata: dict[str, Any] = {} + for key in ("id", "score", "importance", "memory_type", "created_at"): + value = memory.get(key) + if value is not None: + metadata[key] = value + extra = memory.get("metadata") + if isinstance(extra, dict): + metadata.update(extra) + return MemoryEntry(content=content, metadata=metadata or None) diff --git a/python/tests/test_store.py b/python/tests/test_store.py new file mode 100644 index 0000000..8326e1e --- /dev/null +++ b/python/tests/test_store.py @@ -0,0 +1,204 @@ +""" +Tests for the DakeraMemoryStore (Strands MemoryStore integration). + +The store is exercised with a mocked DakeraServiceClient, so no live Dakera +server (or the `dakera` SDK) is required. +""" + +from unittest.mock import MagicMock + +import pytest +from strands.memory import MemoryEntry, MemoryStore +from strands.memory.types import _has_method, _has_write_sink + +from strands_dakera import DakeraMemoryStore + + +@pytest.fixture +def mock_client(): + """A mocked DakeraServiceClient.""" + return MagicMock() + + +def make_store(mock_client, **kwargs): + """Build a store wired to the mocked client.""" + return DakeraMemoryStore(agent_id="alex", client=mock_client, **kwargs) + + +# --------------------------------------------------------------------------- +# Construction / protocol conformance +# --------------------------------------------------------------------------- + + +def test_requires_agent_id(): + """agent_id is mandatory.""" + with pytest.raises(ValueError, match="agent_id is required"): + DakeraMemoryStore(agent_id="") + + +def test_is_a_memory_store(mock_client): + """The store is a genuine MemoryStore subclass (MemoryStore is a + non-runtime-checkable Protocol, so check the MRO rather than isinstance).""" + store = make_store(mock_client) + assert MemoryStore in type(store).__mro__ + + +def test_protocol_attributes_default(mock_client): + """Protocol attributes take sensible, writable-by-default values.""" + store = make_store(mock_client) + assert store.name == "dakera" + assert store.description is not None + assert store.max_search_results is None + assert store.writable is True + assert store.extraction is None + assert store.agent_id == "alex" + + +def test_protocol_attributes_override(mock_client): + """Config fields are honored.""" + store = make_store( + mock_client, + name="notes", + description="d", + max_search_results=3, + writable=False, + extraction=True, + importance=0.9, + memory_type="semantic", + ) + assert store.name == "notes" + assert store.max_search_results == 3 + assert store.writable is False + assert store.extraction is True + assert store.importance == 0.9 + assert store.memory_type == "semantic" + + +def test_write_sink_detection(mock_client): + """`add` is a real write sink; `add_messages` is only the inherited stub.""" + store = make_store(mock_client) + assert _has_method(store, "search") is True + assert _has_method(store, "add") is True + assert _has_method(store, "add_messages") is False + assert _has_method(store, "initialize") is False + assert _has_method(store, "get_tools") is False + assert _has_write_sink(store) is True + + +# --------------------------------------------------------------------------- +# search +# --------------------------------------------------------------------------- + + +async def test_search_maps_to_memory_entries(mock_client): + """Dakera hits are mapped to MemoryEntry with metadata preserved.""" + mock_client.search_memories.return_value = [ + { + "id": "mem-1", + "content": "Alex prefers dark roast", + "score": 0.91, + "importance": 0.8, + "memory_type": "semantic", + "created_at": "2026-07-02T00:00:00Z", + "metadata": {"category": "prefs"}, + } + ] + store = make_store(mock_client) + + results = await store.search("coffee") + + assert len(results) == 1 + entry = results[0] + assert isinstance(entry, MemoryEntry) + assert entry.content == "Alex prefers dark roast" + assert entry.metadata["id"] == "mem-1" + assert entry.metadata["score"] == 0.91 + assert entry.metadata["category"] == "prefs" + + +async def test_search_default_top_k(mock_client): + """With no options and no configured max, the default top_k is used.""" + mock_client.search_memories.return_value = [] + store = make_store(mock_client) + + await store.search("q") + + mock_client.search_memories.assert_called_once_with("alex", "q", 5) + + +async def test_search_options_override_top_k(mock_client): + """SearchOptions.max_search_results wins over the configured default.""" + mock_client.search_memories.return_value = [] + store = make_store(mock_client, max_search_results=3) + + await store.search("q", {"max_search_results": 10}) + + mock_client.search_memories.assert_called_once_with("alex", "q", 10) + + +async def test_search_config_top_k(mock_client): + """The configured max is used when options omit it.""" + mock_client.search_memories.return_value = [] + store = make_store(mock_client, max_search_results=7) + + await store.search("q") + + mock_client.search_memories.assert_called_once_with("alex", "q", 7) + + +async def test_search_handles_missing_content(mock_client): + """A hit without content maps to an empty string, not None.""" + mock_client.search_memories.return_value = [{"id": "mem-2"}] + store = make_store(mock_client) + + results = await store.search("q") + + assert results[0].content == "" + assert results[0].metadata == {"id": "mem-2"} + + +# --------------------------------------------------------------------------- +# add +# --------------------------------------------------------------------------- + + +async def test_add_writes_to_dakera(mock_client): + """add() forwards agent_id, content, importance, memory_type and metadata.""" + stored = {"id": "mem-9", "content": "new fact"} + mock_client.store_memory.return_value = stored + store = make_store(mock_client, importance=0.6, memory_type="semantic") + + result = await store.add("new fact", {"source": "chat"}) + + assert result == stored + mock_client.store_memory.assert_called_once_with("alex", "new fact", 0.6, "semantic", {"source": "chat"}) + + +# --------------------------------------------------------------------------- +# lazy client construction +# --------------------------------------------------------------------------- + + +def test_client_constructed_lazily(monkeypatch): + """No DakeraServiceClient is built until the client property is accessed.""" + calls = {"n": 0} + + class FakeClient: + def __init__(self, base_url=None, api_key=None): + calls["n"] += 1 + self.base_url = base_url + self.api_key = api_key + + monkeypatch.setattr("strands_dakera.store.DakeraServiceClient", FakeClient) + + store = DakeraMemoryStore(agent_id="alex", base_url="http://h:3000", api_key="dk-x") + assert calls["n"] == 0 # not built yet + + client = store.client + assert calls["n"] == 1 + assert client.base_url == "http://h:3000" + assert client.api_key == "dk-x" + + # Second access reuses the same instance. + assert store.client is client + assert calls["n"] == 1