Skip to content

Commit e1f1faa

Browse files
QUSETIONSclaude
andcommitted
Harden memory + anthropic adapter against None/missing-key crashes
memory.py: MemoryEntry.__post_init__ coerces None/non-str content to "". content is read as a str in 8+ places (search, _score_entry, dedup, prompt injection); a malformed entry with content=None (e.g. from a hand-edited memory file or a runtime add_entry) crashed memory search, which runs on every system-prompt build. BM25/IDF/avgdl math was already correctly guarded. anthropic_adapter.py: replace direct self.runtime["model"] (x3), ["baseUrl"], and else-branch ["authToken"] with .get(...) defaults, matching the openai_adapter fix — a runtime missing these keys no longer raises KeyError (authToken missing → "Bearer " → clear 401 instead of crash). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dd6e934 commit e1f1faa

3 files changed

Lines changed: 36 additions & 5 deletions

File tree

minicode/anthropic_adapter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str],
181181
self._thinking_blocks = []
182182

183183
request_body = {
184-
"model": self.runtime["model"],
184+
"model": self.runtime.get("model", ""),
185185
"system": system_message,
186186
"messages": converted_messages,
187187
"tools": self._get_serialized_tools(),
@@ -196,15 +196,15 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str],
196196
request_body["stream"] = True
197197

198198
request = urllib.request.Request(
199-
url=_messages_endpoint(self.runtime["baseUrl"]),
199+
url=_messages_endpoint(self.runtime.get("baseUrl", "")),
200200
data=json.dumps(request_body).encode("utf-8"),
201201
headers={
202202
"content-type": "application/json",
203203
"anthropic-version": "2023-06-01",
204204
**(
205205
{"x-api-key": self.runtime["apiKey"]}
206206
if self.runtime.get("apiKey")
207-
else {"Authorization": f"Bearer {self.runtime['authToken']}"}
207+
else {"Authorization": f"Bearer {self.runtime.get('authToken', '')}"}
208208
),
209209
},
210210
method="POST",
@@ -262,7 +262,7 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str],
262262
cache_creation_tokens = usage.get("cache_creation_input_tokens", 0)
263263

264264
cost_usd = calculate_cost(
265-
model=self.runtime["model"],
265+
model=self.runtime.get("model", ""),
266266
input_tokens=input_tokens,
267267
output_tokens=output_tokens,
268268
cache_read_tokens=cache_read_tokens,
@@ -406,7 +406,7 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str],
406406
if store:
407407
from minicode.cost_tracker import calculate_cost
408408
cost_usd = calculate_cost(
409-
model=self.runtime["model"],
409+
model=self.runtime.get("model", ""),
410410
input_tokens=stream_input_tokens,
411411
output_tokens=stream_output_tokens,
412412
cache_read_tokens=stream_cache_read_tokens,

minicode/memory.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,14 @@ class MemoryEntry:
648648
related_to: list[str] = field(default_factory=list) # Related memory IDs
649649
_cached_tokens: list[str] | None = field(default=None, repr=False)
650650

651+
def __post_init__(self) -> None:
652+
# `content` is accessed as a str throughout search/scoring/formatting
653+
# (8+ sites use .lower()/.strip()/[:N]); coerce None/non-str at
654+
# construction so a malformed entry can't crash a memory search, which
655+
# is injected into every system prompt.
656+
if not isinstance(self.content, str):
657+
self.content = "" if self.content is None else str(self.content)
658+
651659
def __hash__(self) -> int:
652660
return hash(self.id)
653661

tests/test_memory_integration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,3 +1180,26 @@ def test_memory_file_entry_limit(self, tmp_workspace):
11801180
mm.add_entry(MemoryScope.PROJECT, "test", f"Entry {i}")
11811181

11821182
assert len(mm.memories[MemoryScope.PROJECT].entries) <= max_entries
1183+
1184+
1185+
# ---------------------------------------------------------------------------
1186+
# Robustness: None / non-str content must not crash search (injected into prompts)
1187+
# ---------------------------------------------------------------------------
1188+
1189+
1190+
def test_memory_entry_coerces_none_content():
1191+
"""MemoryEntry.content is accessed as str in 8+ places (.lower()/.strip());
1192+
construction must coerce None/non-str so a malformed entry can't crash a
1193+
memory search (which runs on every system-prompt build)."""
1194+
assert MemoryEntry(id="a", content=None, scope=MemoryScope.PROJECT, category="c").content == ""
1195+
assert MemoryEntry(id="b", content=123, scope=MemoryScope.PROJECT, category="c").content == "123"
1196+
1197+
1198+
def test_search_survives_none_content_entry(tmp_path):
1199+
mgr = MemoryManager(project_root=tmp_path)
1200+
mf = mgr.memories[MemoryScope.PROJECT]
1201+
mf.entries.append(MemoryEntry(id="bad", content=None, scope=MemoryScope.PROJECT, category="c"))
1202+
mf.entries.append(MemoryEntry(id="good", content="how to run the test suite", scope=MemoryScope.PROJECT, category="convention"))
1203+
# Must not raise; the good entry is still searchable.
1204+
results = mgr.search("run the test suite")
1205+
assert any("test suite" in e.content for e in results)

0 commit comments

Comments
 (0)