Skip to content

Commit 64233ff

Browse files
committed
address github-actions review comments
1 parent eee7dea commit 64233ff

5 files changed

Lines changed: 96 additions & 17 deletions

File tree

packages/letta-moss/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Then register the server as an MCP tool source on your Letta agent, pointing at
7474

7575
| Symbol | Kind | Purpose |
7676
|---|---|---|
77-
| `MossLettaMemory(*, project_id=None, project_key=None, index_name, top_k=5, alpha=0.8)` | class | Core adapter; `async load_index()`, `async insert_memory(content, tags=, metadata=) -> str`, `async search_memory(query, top_k=, tags=) -> list[ArchivalMemoryItem]`, `async delete_memory(memory_id)`, `async get_memory(memory_id) -> ArchivalMemoryItem \| None`, `async list_memories(limit=) -> list[ArchivalMemoryItem]`. Falls back to `MOSS_PROJECT_ID`/`MOSS_PROJECT_KEY` env vars when credentials are omitted. |
77+
| `MossLettaMemory(*, project_id=None, project_key=None, index_name, top_k=5, alpha=0.8, auto_refresh=True, refresh_interval_seconds=60)` | class | Core adapter; `async load_index()`, `async insert_memory(content, tags=, metadata=) -> str`, `async search_memory(query, top_k=, tags=) -> list[ArchivalMemoryItem]`, `async delete_memory(memory_id)`, `async get_memory(memory_id) -> ArchivalMemoryItem \| None`, `async list_memories(limit=) -> list[ArchivalMemoryItem]`. Falls back to `MOSS_PROJECT_ID`/`MOSS_PROJECT_KEY` env vars when credentials are omitted. `auto_refresh`/`refresh_interval_seconds` are forwarded to Moss's `load_index()` so this process's in-memory snapshot picks up writes made by *other* processes against the same index, not just its own. |
7878
| `moss_memory_insert(content, tags=None) -> str` | async function | Custom-tool wrapper; insert a memory, return its id. |
7979
| `moss_memory_search(query, top_k=5, tags=None) -> list[dict]` | async function | Custom-tool wrapper; search memories, return dicts. |
8080
| `moss_memory_delete(memory_id) -> None` | async function | Custom-tool wrapper; delete a memory by id. |
@@ -91,6 +91,8 @@ Then register the server as an MCP tool source on your Letta agent, pointing at
9191

9292
**Env var fallback:** `MossLettaMemory` falls back to `MOSS_PROJECT_ID`/`MOSS_PROJECT_KEY` when constructor args are omitted, unlike some other Moss integrations in this repo (e.g. `agora-moss`) that require explicit args. This is deliberate: these tools typically run inside Letta's sandboxed tool-execution environment, where env vars passed via `tool_exec_environment_variables` are the natural way to configure credentials without hardcoding them in tool source.
9393

94+
**Sandbox process reuse:** the Option A custom tools cache a single `MossLettaMemory` instance at module scope per worker process (so `load_index()` only runs once). That cache is keyed on `MOSS_PROJECT_ID`/`MOSS_PROJECT_KEY`/`MOSS_INDEX_NAME` — if Letta reuses the same worker process for a different agent whose `tool_exec_environment_variables` set different values, the cached instance is discarded and rebuilt against the new values rather than leaking reads/writes across agents.
95+
9496
## Dependencies
9597

9698
- `moss>=1.1.1`

packages/letta-moss/src/letta_moss/memory.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,20 @@ def __init__(
126126
index_name: str,
127127
top_k: int = 5,
128128
alpha: float = 0.8,
129+
auto_refresh: bool = True,
130+
refresh_interval_seconds: int = 60,
129131
) -> None:
130-
"""Initialize the adapter with Moss credentials and index-query defaults."""
132+
"""Initialize the adapter with Moss credentials and index-query defaults.
133+
134+
``auto_refresh``/``refresh_interval_seconds`` are forwarded to the
135+
underlying ``MossClient.load_index()`` call: they make the SDK poll
136+
for and pull in changes made by *other* processes writing to the same
137+
index (e.g. another sandboxed tool worker, or a script inserting
138+
memories directly). This instance's own ``insert_memory``/
139+
``delete_memory`` calls are reflected immediately regardless, via the
140+
generation-counter invalidation in ``load_index`` below — auto-refresh
141+
only covers the cross-process case that local invalidation can't see.
142+
"""
131143
project_id = project_id or os.getenv("MOSS_PROJECT_ID")
132144
project_key = project_key or os.getenv("MOSS_PROJECT_KEY")
133145
if not project_id or not project_key:
@@ -139,6 +151,8 @@ def __init__(
139151
self._index_name = index_name
140152
self._top_k = top_k
141153
self._alpha = alpha
154+
self._auto_refresh = auto_refresh
155+
self._refresh_interval_seconds = refresh_interval_seconds
142156
self._index_loaded = False
143157
self._index_created = False
144158
# Bumped by insert_memory/delete_memory. load_index() only marks the
@@ -160,7 +174,11 @@ async def load_index(self) -> None:
160174
return
161175
generation_at_start = self._generation
162176
try:
163-
await self._client.load_index(self._index_name)
177+
await self._client.load_index(
178+
self._index_name,
179+
auto_refresh=self._auto_refresh,
180+
polling_interval_in_seconds=self._refresh_interval_seconds,
181+
)
164182
except RuntimeError as e:
165183
if "not found" not in str(e).lower():
166184
raise

packages/letta-moss/src/letta_moss/tools.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,42 @@
2121
from .memory import MossLettaMemory
2222

2323
_memory: MossLettaMemory | None = None
24+
_memory_key: tuple[str | None, str | None, str] | None = None
2425
_memory_lock = asyncio.Lock()
2526

2627

28+
def _current_memory_key() -> tuple[str | None, str | None, str]:
29+
"""Read the env vars that identify which Moss index/credentials to use."""
30+
index_name = os.getenv("MOSS_INDEX_NAME")
31+
if not index_name:
32+
raise ValueError("MOSS_INDEX_NAME env var is required.")
33+
return (os.getenv("MOSS_PROJECT_ID"), os.getenv("MOSS_PROJECT_KEY"), index_name)
34+
35+
2736
async def _get_memory() -> MossLettaMemory:
2837
"""Lazily build and load the module-level ``MossLettaMemory`` singleton.
2938
3039
A single instance is reused across tool calls within a sandbox process so
31-
``load_index()`` (and the underlying index download) only happens once.
32-
Guarded by a lock so concurrent tool calls can't each construct and load
33-
their own instance before the first one is stored.
40+
``load_index()`` (and the underlying index download) only happens once —
41+
but only for as long as ``MOSS_PROJECT_ID``/``MOSS_PROJECT_KEY``/
42+
``MOSS_INDEX_NAME`` keep resolving to the same values. If a worker process
43+
gets reused for a different agent/config (a different set of
44+
``tool_exec_environment_variables``), the cached instance is discarded and
45+
rebuilt against the new env vars instead of silently leaking reads/writes
46+
to the previous agent's index. Guarded by a lock so concurrent tool calls
47+
can't each construct and load their own instance before the first one is
48+
stored.
3449
"""
35-
global _memory
36-
if _memory is not None:
50+
global _memory, _memory_key
51+
key = _current_memory_key()
52+
if _memory is not None and _memory_key == key:
3753
return _memory
3854
async with _memory_lock:
39-
if _memory is None:
40-
index_name = os.getenv("MOSS_INDEX_NAME")
41-
if not index_name:
42-
raise ValueError("MOSS_INDEX_NAME env var is required.")
43-
memory = MossLettaMemory(index_name=index_name)
55+
if _memory is None or _memory_key != key:
56+
memory = MossLettaMemory(index_name=key[2])
4457
await memory.load_index()
4558
_memory = memory
59+
_memory_key = key
4660
return _memory
4761

4862

packages/letta-moss/tests/test_memory.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ def __init__(self, *, project_id=None, project_key=None):
2929
self._query_return = FakeQueryResult(docs=[])
3030
self._create_index_error = None
3131
self._load_index_error = None
32+
self.load_index_calls = []
3233

33-
async def load_index(self, index_name):
34+
async def load_index(self, index_name, auto_refresh=False, polling_interval_in_seconds=600):
35+
self.load_index_calls.append((index_name, auto_refresh, polling_interval_in_seconds))
3436
if self._load_index_error is not None:
3537
raise self._load_index_error
3638

@@ -103,6 +105,22 @@ async def test_delegates_and_marks_loaded(self, monkeypatch):
103105
await m.load_index()
104106
assert m._index_loaded is True
105107

108+
async def test_forwards_auto_refresh_settings_to_client(self, monkeypatch):
109+
import letta_moss.memory as memory_mod
110+
111+
monkeypatch.setattr(memory_mod, "MossClient", FakeClient)
112+
from letta_moss.memory import MossLettaMemory
113+
114+
m = MossLettaMemory(
115+
project_id="p",
116+
project_key="k",
117+
index_name="idx",
118+
auto_refresh=True,
119+
refresh_interval_seconds=45,
120+
)
121+
await m.load_index()
122+
assert m._client.load_index_calls == [("idx", True, 45)]
123+
106124
async def test_marks_index_created_on_successful_load(self, monkeypatch):
107125
import letta_moss.memory as memory_mod
108126

@@ -120,7 +138,9 @@ async def test_is_idempotent(self, monkeypatch):
120138
load_calls = []
121139

122140
class TrackingClient(FakeClient):
123-
async def load_index(self, index_name):
141+
async def load_index(
142+
self, index_name, auto_refresh=False, polling_interval_in_seconds=600
143+
):
124144
load_calls.append(index_name)
125145

126146
monkeypatch.setattr(memory_mod, "MossClient", TrackingClient)
@@ -164,9 +184,11 @@ async def test_mutation_during_load_prevents_stale_loaded_flag(self, monkeypatch
164184
release_load = asyncio.Event()
165185

166186
class SlowClient(FakeClient):
167-
async def load_index(self, index_name):
187+
async def load_index(
188+
self, index_name, auto_refresh=False, polling_interval_in_seconds=600
189+
):
168190
await release_load.wait()
169-
await super().load_index(index_name)
191+
await super().load_index(index_name, auto_refresh, polling_interval_in_seconds)
170192

171193
monkeypatch.setattr(memory_mod, "MossClient", SlowClient)
172194
from letta_moss.memory import MossLettaMemory

packages/letta-moss/tests/test_tools.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@ def _reset_singleton(monkeypatch):
4444
FakeMemory.instances = []
4545
monkeypatch.setattr(tools_mod, "MossLettaMemory", FakeMemory)
4646
monkeypatch.setattr(tools_mod, "_memory", None)
47+
monkeypatch.setattr(tools_mod, "_memory_key", None)
4748
monkeypatch.setenv("MOSS_INDEX_NAME", "idx")
4849
yield
4950
monkeypatch.setattr(tools_mod, "_memory", None)
51+
monkeypatch.setattr(tools_mod, "_memory_key", None)
5052

5153

5254
class TestLazySingleton:
@@ -67,6 +69,27 @@ async def test_concurrent_calls_construct_only_one_instance(self):
6769
assert len(FakeMemory.instances) == 1
6870
assert all(r is results[0] for r in results)
6971

72+
async def test_rebuilds_when_index_name_env_var_changes(self, monkeypatch):
73+
from letta_moss.tools import _get_memory
74+
75+
first = await _get_memory()
76+
monkeypatch.setenv("MOSS_INDEX_NAME", "other-idx")
77+
second = await _get_memory()
78+
79+
assert first is not second
80+
assert len(FakeMemory.instances) == 2
81+
assert second.index_name == "other-idx"
82+
83+
async def test_rebuilds_when_project_credentials_env_vars_change(self, monkeypatch):
84+
from letta_moss.tools import _get_memory
85+
86+
first = await _get_memory()
87+
monkeypatch.setenv("MOSS_PROJECT_ID", "new-project")
88+
second = await _get_memory()
89+
90+
assert first is not second
91+
assert len(FakeMemory.instances) == 2
92+
7093

7194
class TestMossMemoryInsert:
7295
async def test_delegates_to_memory(self):

0 commit comments

Comments
 (0)