Skip to content

Commit 1957eac

Browse files
phernandezclaude
andcommitted
fix: build_context related_results schema validation failure (#627)
Related results in build_context responses were losing required identifying fields (title, file_path, created_at) because _slim_context() stripped them post-serialization, creating a gap between the Pydantic schema and the actual response payload. This caused schema validation failures in Claude Desktop. Replace post-hoc dict stripping with Pydantic Field(exclude=True) on internal-only fields so the models themselves control what gets serialized: - EntitySummary: exclude entity_id, keep created_at (🐛 the bug fix) - RelationSummary: exclude internal IDs, keep title/file_path/created_at - ObservationSummary: exclude parent-redundant fields - MemoryMetadata: exclude generated_at and total_results Delete _slim_context(), _slim_summary(), and all _*_STRIP sets from build_context.py — one set of models, one serialization path. Add get_load_options() to ObservationRepository for eager entity loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 496af07 commit 1957eac

5 files changed

Lines changed: 249 additions & 164 deletions

File tree

src/basic_memory/mcp/tools/build_context.py

Lines changed: 4 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -22,74 +22,6 @@
2222
RelationSummary,
2323
)
2424

25-
# --- Fields to strip from each model (redundant with parent entity) ---
26-
27-
_OBSERVATION_STRIP = {
28-
"observation_id",
29-
"entity_id",
30-
"entity_external_id",
31-
"title",
32-
"file_path",
33-
"created_at",
34-
}
35-
_RELATION_STRIP = {
36-
"relation_id",
37-
"entity_id",
38-
"from_entity_id",
39-
"from_entity_external_id",
40-
"to_entity_id",
41-
"to_entity_external_id",
42-
"title",
43-
"file_path",
44-
"created_at",
45-
}
46-
_ENTITY_STRIP = {"entity_id", "created_at"}
47-
_METADATA_STRIP = {"total_results", "generated_at"}
48-
49-
50-
def _slim_summary(summary: EntitySummary | RelationSummary | ObservationSummary) -> dict:
51-
"""Strip redundant fields from a summary model based on its type."""
52-
if isinstance(summary, ObservationSummary):
53-
strip = _OBSERVATION_STRIP
54-
elif isinstance(summary, RelationSummary):
55-
strip = _RELATION_STRIP
56-
else:
57-
strip = _ENTITY_STRIP
58-
59-
data = summary.model_dump()
60-
for key in strip:
61-
data.pop(key, None)
62-
return data
63-
64-
65-
def _slim_context(graph: GraphContext) -> dict:
66-
"""Transform GraphContext into a slimmed dict, stripping redundant fields.
67-
68-
Reduces payload size ~40% by removing fields on nested objects that
69-
duplicate information already present on the parent entity (IDs,
70-
timestamps, file paths).
71-
"""
72-
slimmed_results = []
73-
for result in graph.results:
74-
slimmed_results.append(
75-
{
76-
"primary_result": _slim_summary(result.primary_result),
77-
"observations": [_slim_summary(obs) for obs in result.observations],
78-
"related_results": [_slim_summary(rel) for rel in result.related_results],
79-
}
80-
)
81-
82-
metadata = graph.metadata.model_dump()
83-
for key in _METADATA_STRIP:
84-
metadata.pop(key, None)
85-
86-
return {
87-
"results": slimmed_results,
88-
"metadata": metadata,
89-
"page": graph.page,
90-
"page_size": graph.page_size,
91-
}
92-
9325

9426
def _format_entity_block(result: ContextResult) -> str:
9527
"""Format a single context result as a markdown block."""
@@ -194,7 +126,7 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
194126
- Or standard formats like "7d", "24h"
195127
196128
Format options:
197-
- "json" (default): Slimmed JSON with redundant fields removed
129+
- "json" (default): Structured JSON with internal fields excluded
198130
- "text": Compact markdown text for LLM consumption
199131
""",
200132
annotations={"readOnlyHint": True, "openWorldHint": False},
@@ -231,12 +163,12 @@ async def build_context(
231163
page: Page number of results to return (default: 1)
232164
page_size: Number of results to return per page (default: 10)
233165
max_related: Maximum number of related results to return (default: 10)
234-
output_format: Response format - "json" for slimmed JSON dict,
166+
output_format: Response format - "json" for structured JSON dict,
235167
"text" for compact markdown text
236168
context: Optional FastMCP context for performance caching.
237169
238170
Returns:
239-
dict (output_format="json"): Slimmed JSON with redundant fields removed
171+
dict (output_format="json"): Structured JSON with internal fields excluded
240172
str (output_format="text"): Compact markdown representation
241173
242174
Examples:
@@ -292,4 +224,4 @@ async def build_context(
292224
if output_format == "text":
293225
return _format_context_markdown(graph, active_project.name)
294226

295-
return _slim_context(graph)
227+
return graph.model_dump()

src/basic_memory/repository/observation_repository.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from typing import Dict, List, Sequence
44

5-
65
from sqlalchemy import select
76
from sqlalchemy.ext.asyncio import async_sessionmaker
7+
from sqlalchemy.orm import selectinload
8+
from sqlalchemy.orm.interfaces import LoaderOption
89

910
from basic_memory.models import Observation
1011
from basic_memory.repository.repository import Repository
@@ -22,6 +23,10 @@ def __init__(self, session_maker: async_sessionmaker, project_id: int):
2223
"""
2324
super().__init__(session_maker, Observation, project_id=project_id)
2425

26+
def get_load_options(self) -> List[LoaderOption]:
27+
"""Eager-load parent entity to prevent N+1 if obs.entity is accessed."""
28+
return [selectinload(Observation.entity)]
29+
2530
async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
2631
"""Find all observations for a specific entity."""
2732
query = select(Observation).filter(Observation.entity_id == entity_id)

src/basic_memory/schemas/memory.py

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ class EntitySummary(BaseModel):
125125

126126
type: Literal["entity"] = "entity"
127127
external_id: str # UUID for v2 API routing
128-
entity_id: int # Database ID for v2 API consistency
128+
entity_id: Optional[int] = Field(None, exclude=True) # Internal DB ID
129129
permalink: Optional[str]
130130
title: str
131131
content: Optional[str] = None
@@ -143,18 +143,18 @@ class RelationSummary(BaseModel):
143143
"""Simplified relation representation."""
144144

145145
type: Literal["relation"] = "relation"
146-
relation_id: int # Database ID for v2 API consistency
147-
entity_id: Optional[int] = None # ID of the entity this relation belongs to
146+
relation_id: Optional[int] = Field(None, exclude=True) # Internal DB ID
147+
entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
148148
title: str
149149
file_path: str
150150
permalink: str
151151
relation_type: str
152152
from_entity: Optional[str] = None
153-
from_entity_id: Optional[int] = None # ID of source entity
154-
from_entity_external_id: Optional[str] = None # UUID of source entity for v2 API routing
153+
from_entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
154+
from_entity_external_id: Optional[str] = Field(None, exclude=True) # Internal routing ID
155155
to_entity: Optional[str] = None
156-
to_entity_id: Optional[int] = None # ID of target entity
157-
to_entity_external_id: Optional[str] = None # UUID of target entity for v2 API routing
156+
to_entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
157+
to_entity_external_id: Optional[str] = Field(None, exclude=True) # Internal routing ID
158158
created_at: Annotated[
159159
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
160160
]
@@ -168,21 +168,15 @@ class ObservationSummary(BaseModel):
168168
"""Simplified observation representation."""
169169

170170
type: Literal["observation"] = "observation"
171-
observation_id: int # Database ID for v2 API consistency
172-
entity_id: Optional[int] = None # ID of the entity this observation belongs to
173-
entity_external_id: Optional[str] = None # UUID of parent entity for v2 API routing
174-
title: str
175-
file_path: str
171+
observation_id: Optional[int] = Field(None, exclude=True) # Internal DB ID
172+
entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
173+
entity_external_id: Optional[str] = Field(None, exclude=True) # Internal routing ID
174+
title: Optional[str] = Field(None, exclude=True) # Redundant with parent entity
175+
file_path: Optional[str] = Field(None, exclude=True) # Redundant with parent entity
176176
permalink: str
177177
category: str
178178
content: str
179-
created_at: Annotated[
180-
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
181-
]
182-
183-
@field_serializer("created_at")
184-
def serialize_created_at(self, dt: datetime) -> str:
185-
return dt.isoformat()
179+
created_at: Optional[datetime] = Field(None, exclude=True) # Redundant with parent entity
186180

187181

188182
class MemoryMetadata(BaseModel):
@@ -192,19 +186,13 @@ class MemoryMetadata(BaseModel):
192186
types: Optional[List[SearchItemType]] = None
193187
depth: int
194188
timeframe: Optional[str] = None
195-
generated_at: Annotated[
196-
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
197-
]
189+
generated_at: Optional[datetime] = Field(None, exclude=True) # Internal timing
198190
primary_count: Optional[int] = None # Changed field name
199191
related_count: Optional[int] = None # Changed field name
200-
total_results: Optional[int] = None # For backward compatibility
192+
total_results: Optional[int] = Field(None, exclude=True) # Internal counter
201193
total_relations: Optional[int] = None
202194
total_observations: Optional[int] = None
203195

204-
@field_serializer("generated_at")
205-
def serialize_generated_at(self, dt: datetime) -> str:
206-
return dt.isoformat()
207-
208196

209197
class ContextResult(BaseModel):
210198
"""Context result containing a primary item with its observations and related items."""

tests/mcp/test_tool_build_context.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
@pytest.mark.asyncio
1111
async def test_get_basic_discussion_context(client, test_graph, test_project):
12-
"""Test getting basic discussion context returns slimmed JSON dict."""
12+
"""Test getting basic discussion context returns JSON dict with excluded fields removed."""
1313
result = await build_context(project=test_project.name, url="memory://test/root")
1414

1515
assert isinstance(result, dict)
@@ -19,7 +19,7 @@ async def test_get_basic_discussion_context(client, test_graph, test_project):
1919
assert primary["permalink"] == f"{test_project.name}/test/root"
2020
assert len(result["results"][0]["related_results"]) > 0
2121

22-
# Verify metadata — stripped fields should be absent
22+
# Verify metadata — excluded fields should be absent
2323
meta = result["metadata"]
2424
assert meta["uri"] == f"{test_project.name}/test/root"
2525
assert meta["depth"] == 1 # default depth
@@ -28,16 +28,38 @@ async def test_get_basic_discussion_context(client, test_graph, test_project):
2828
assert "generated_at" not in meta
2929
assert "total_results" not in meta
3030

31-
# Verify entity-level stripped fields
31+
# Entity: entity_id excluded, created_at kept (needed for related results)
3232
assert "entity_id" not in primary
33-
assert "created_at" not in primary
33+
assert "created_at" in primary
3434

35-
# Verify observation-level stripped fields
35+
# Verify observation-level excluded fields
3636
if result["results"][0]["observations"]:
3737
obs = result["results"][0]["observations"][0]
3838
assert "observation_id" not in obs
3939
assert "entity_id" not in obs
4040
assert "file_path" not in obs
41+
assert "title" not in obs
42+
assert "created_at" not in obs
43+
# Kept fields
44+
assert "permalink" in obs
45+
assert "category" in obs
46+
assert "content" in obs
47+
48+
# Verify related_results item structure — entities have identifying fields
49+
for related in result["results"][0]["related_results"]:
50+
item_type = related["type"]
51+
if item_type == "entity":
52+
assert "title" in related
53+
assert "file_path" in related
54+
assert "created_at" in related
55+
assert "entity_id" not in related # excluded
56+
elif item_type == "relation":
57+
assert "relation_type" in related
58+
assert "title" in related
59+
assert "file_path" in related
60+
assert "created_at" in related
61+
assert "relation_id" not in related # excluded
62+
assert "entity_id" not in related # excluded
4163

4264

4365
@pytest.mark.asyncio

0 commit comments

Comments
 (0)