Skip to content

Commit 8022ecb

Browse files
committed
feat(mcp): mempalace_walk_palace — agent walks the palace via AGE Cypher
Phase 6 of /goal: AGE integration. The "agent walks into the palace finding wings, rooms, drawers" metaphor becomes a real MCP tool over the unified palace+entity graph (Wing → Room → Drawer → MENTIONS → Entity) built in Phases 1-4. Three traversal modes, exactly one anchor required: start_wing="memorypalace" → walks DOWN the structure depth=1: rooms in this wing depth=2: + drawers in those rooms depth=3: + entities those drawers mention start_room="problems" → walks DOWN from a room (across all wings) depth=1: drawers in this room (any wing) depth=2: + entities mentioned start_entity="pgvector" → walks UP from an entity (inverse walk) depth=1: drawers that mention this entity depth=2: + the rooms+wings containing those drawers Returns: { "start": {"wing": ..., "room": ..., "entity": ...}, "depth": N, "walk": [{wing, room, drawer, entity}, ...], "stats": {wings_touched, rooms_touched, drawers_touched, entities_touched} } Requires MEMPALACE_BACKEND=postgres and the AGE graph populated via mempalace.kg_writethrough or mempalace.backfill_age. Smoke-tested on sme_lme_bench: - walk_palace(start_entity='pgvector', depth=2) returns the 3 drawers mentioning it (postgres.py, CHANGELOG.md, BENCHMARKS.md), plus their containing rooms+wings (postgres+code, CHANGELOG+docs, BENCHMARKS+docs) - walk_palace(start_room='postgres', depth=2) returns the postgres.py drawer plus its 3 mentioned entities (pgvector, hnsw, Apache AGE) - walk_palace(start_wing='docs', depth=3) walks into the docs wing's rooms, drawers, and entity layer This completes the 6-phase plan that started from today's spike result (+9pp R@5 from AGE entity-overlap fused with vector). The metaphor of the AI walking the palace is now real Cypher traversal — anyone with the MCP tool can ask "where does pgvector get discussed?" or "what's in the memorypalace wing?" and get a structured answer.
1 parent b3f0206 commit 8022ecb

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

mempalace/mcp_server.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,157 @@ def tool_traverse_graph(start_room: str, max_hops: int = 2):
10541054
return traverse(start_room, col=col, max_hops=max_hops)
10551055

10561056

1057+
def tool_walk_palace(
1058+
start_wing: str = None,
1059+
start_room: str = None,
1060+
start_entity: str = None,
1061+
depth: int = 2,
1062+
limit: int = 50,
1063+
):
1064+
"""Agent-facing palace walk via AGE Cypher traversal.
1065+
1066+
Phase 6 of the AGE-integration goal. Exposes the "agent walks into the
1067+
palace" metaphor as a single tool: pass a starting node (wing OR room
1068+
OR entity) and a depth, get back the navigable subgraph it touches.
1069+
1070+
Three traversal modes by starting node:
1071+
1072+
- **start_wing="memorypalace"**: enumerate rooms in this wing
1073+
(depth=1), plus drawers in those rooms (depth=2), plus mentioned
1074+
entities (depth=3). The "walking into a wing" pattern.
1075+
- **start_room="problems"**: enumerate drawers in this room across
1076+
all wings (depth=1), plus their mentioned entities (depth=2). The
1077+
"walking into a specific room" pattern.
1078+
- **start_entity="pgvector"**: enumerate drawers mentioning this
1079+
entity (depth=1), plus the rooms+wings containing them (depth=2).
1080+
The "find where in the palace X is discussed" pattern (inverse
1081+
walk — entity → drawer → room → wing).
1082+
1083+
Exactly one of (start_wing, start_room, start_entity) must be given.
1084+
1085+
Returns a structured walk result with:
1086+
- ``start``: the input anchor
1087+
- ``walk``: list of {wing, room, drawer, entity} rows, one per
1088+
leaf reached
1089+
- ``stats``: {wings_touched, rooms_touched, drawers_touched,
1090+
entities_touched}
1091+
1092+
Requires MEMPALACE_BACKEND=postgres and the AGE graph populated via
1093+
kg_writethrough or backfill_age.
1094+
"""
1095+
# Validate inputs
1096+
anchors_set = sum(bool(x) for x in (start_wing, start_room, start_entity))
1097+
if anchors_set != 1:
1098+
return {
1099+
"error": "exactly one of start_wing, start_room, start_entity must be provided",
1100+
"got": {"start_wing": start_wing, "start_room": start_room, "start_entity": start_entity},
1101+
}
1102+
depth = max(1, min(depth, 5))
1103+
limit = max(1, min(limit, 500))
1104+
1105+
# Require postgres + AGE
1106+
if _config.backend != "postgres":
1107+
return {"error": "tool_walk_palace requires MEMPALACE_BACKEND=postgres"}
1108+
dsn = _config.postgres_dsn
1109+
if not dsn:
1110+
return {"error": "MEMPALACE_POSTGRES_DSN not set"}
1111+
1112+
try:
1113+
from .knowledge_graph_age import KnowledgeGraphAGE
1114+
kg = KnowledgeGraphAGE(dsn)
1115+
except Exception as e:
1116+
return {"error": f"could not connect to AGE: {e}"}
1117+
1118+
walk_rows: list[dict] = []
1119+
# Map result-column-name → stats-key-name so 'entity' goes to 'entities_touched'
1120+
# (not 'entitys_touched' if we naively pluralized).
1121+
col_to_stat = {
1122+
"wing": "wings_touched",
1123+
"room": "rooms_touched",
1124+
"drawer": "drawers_touched",
1125+
"entity": "entities_touched",
1126+
}
1127+
stats = {v: set() for v in col_to_stat.values()}
1128+
1129+
try:
1130+
if start_wing:
1131+
# Wing → Room → Drawer → MENTIONS → Entity
1132+
cypher_by_depth = {
1133+
1: """
1134+
MATCH (w:Wing {name: $anchor})-[:CONTAINS]->(r:Room)
1135+
RETURN w.name AS wing, r.name AS room LIMIT $limit
1136+
""",
1137+
2: """
1138+
MATCH (w:Wing {name: $anchor})-[:CONTAINS]->(r:Room)-[:CONTAINS]->(d:Drawer)
1139+
RETURN w.name AS wing, r.name AS room, d.id AS drawer LIMIT $limit
1140+
""",
1141+
3: """
1142+
MATCH (w:Wing {name: $anchor})-[:CONTAINS]->(r:Room)-[:CONTAINS]->(d:Drawer)
1143+
OPTIONAL MATCH (d)-[m:MENTIONS]->(e:Entity)
1144+
RETURN w.name AS wing, r.name AS room, d.id AS drawer, e.name AS entity LIMIT $limit
1145+
""",
1146+
}
1147+
anchor = start_wing
1148+
elif start_room:
1149+
# Room (across wings) → Drawer → MENTIONS → Entity
1150+
cypher_by_depth = {
1151+
1: """
1152+
MATCH (w:Wing)-[:CONTAINS]->(r:Room {name: $anchor})-[:CONTAINS]->(d:Drawer)
1153+
RETURN w.name AS wing, r.name AS room, d.id AS drawer LIMIT $limit
1154+
""",
1155+
2: """
1156+
MATCH (w:Wing)-[:CONTAINS]->(r:Room {name: $anchor})-[:CONTAINS]->(d:Drawer)
1157+
OPTIONAL MATCH (d)-[m:MENTIONS]->(e:Entity)
1158+
RETURN w.name AS wing, r.name AS room, d.id AS drawer, e.name AS entity LIMIT $limit
1159+
""",
1160+
}
1161+
anchor = start_room
1162+
else: # start_entity
1163+
# Inverse walk: Entity → Drawer → Room → Wing
1164+
cypher_by_depth = {
1165+
1: """
1166+
MATCH (e:Entity {name: $anchor})<-[m:MENTIONS]-(d:Drawer)
1167+
RETURN d.id AS drawer, e.name AS entity LIMIT $limit
1168+
""",
1169+
2: """
1170+
MATCH (e:Entity {name: $anchor})<-[m:MENTIONS]-(d:Drawer)
1171+
OPTIONAL MATCH (w:Wing)-[:CONTAINS]->(r:Room)-[:CONTAINS]->(d)
1172+
RETURN w.name AS wing, r.name AS room, d.id AS drawer, e.name AS entity LIMIT $limit
1173+
""",
1174+
}
1175+
anchor = start_entity
1176+
1177+
effective_depth = min(depth, max(cypher_by_depth.keys()))
1178+
cypher = cypher_by_depth[effective_depth]
1179+
1180+
rows = kg._run_cypher(cypher, {"anchor": anchor, "limit": limit}, fetch=True)
1181+
for r in rows:
1182+
entry: dict = {}
1183+
# Map columns based on what the chosen cypher returned.
1184+
# Use aliases from the RETURN clause: wing, room, drawer, entity.
1185+
# The order matches: wing first, then room, then drawer, then entity
1186+
# (skipping any not present in the result).
1187+
for i, key in enumerate(("wing", "room", "drawer", "entity")):
1188+
if i >= len(r):
1189+
break
1190+
v = kg._unwrap_agtype(r[i])
1191+
if v is not None:
1192+
entry[key] = v
1193+
stat_key = col_to_stat.get(key)
1194+
if stat_key:
1195+
stats[stat_key].add(v)
1196+
walk_rows.append(entry)
1197+
finally:
1198+
kg.close()
1199+
1200+
return {
1201+
"start": {"wing": start_wing, "room": start_room, "entity": start_entity},
1202+
"depth": depth,
1203+
"walk": walk_rows,
1204+
"stats": {k: len(v) for k, v in stats.items()},
1205+
}
1206+
1207+
10571208
def tool_find_tunnels(wing_a: str = None, wing_b: str = None):
10581209
"""Find rooms that bridge two wings — the hallways connecting domains."""
10591210
try:
@@ -2091,6 +2242,28 @@ def tool_reconnect():
20912242
},
20922243
"handler": tool_traverse_graph,
20932244
},
2245+
"mempalace_walk_palace": {
2246+
"description": (
2247+
"Walk the palace via AGE Cypher traversal — the 'agent walks into the palace' "
2248+
"primitive. Start at exactly one of {wing, room, entity} and enumerate the "
2249+
"navigable subgraph at the given depth. "
2250+
"start_wing='memorypalace' → rooms (d=1), drawers (d=2), entities (d=3). "
2251+
"start_room='problems' → drawers across wings (d=1), entities (d=2). "
2252+
"start_entity='pgvector' → drawers mentioning it (d=1), rooms+wings containing them (d=2 — inverse walk). "
2253+
"Requires MEMPALACE_BACKEND=postgres + the AGE knowledge graph populated via kg_writethrough or backfill_age."
2254+
),
2255+
"input_schema": {
2256+
"type": "object",
2257+
"properties": {
2258+
"start_wing": {"type": "string", "description": "Wing name to walk from (mutually exclusive with start_room/start_entity)"},
2259+
"start_room": {"type": "string", "description": "Room name to walk from"},
2260+
"start_entity": {"type": "string", "description": "Entity name to walk from (inverse walk)"},
2261+
"depth": {"type": "integer", "description": "Walk depth (1..5, default 2)"},
2262+
"limit": {"type": "integer", "description": "Max rows to return (1..500, default 50)"},
2263+
},
2264+
},
2265+
"handler": tool_walk_palace,
2266+
},
20942267
"mempalace_find_tunnels": {
20952268
"description": "Find rooms that bridge two wings — the hallways connecting different domains. E.g. what topics connect wing_code to wing_team?",
20962269
"input_schema": {

0 commit comments

Comments
 (0)