From 66ca3ab9be46898a864c2760f23c3236bafceb49 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Wed, 26 Aug 2026 05:09:52 -0300 Subject: [PATCH] fix: make memory graph results actionable --- resources/scripts/napseer_mcp_server.py | 261 ++++++++++++++++++-- tests/test_mcp_memory_surface_efficiency.py | 166 ++++++++++++- 2 files changed, 408 insertions(+), 19 deletions(-) diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 0760ed8..d714372 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -5588,11 +5588,34 @@ def rpc_error(request_id, code, message, details=None): return {"jsonrpc": "2.0", "id": request_id, "error": error} +def bounded_tool_item_preview(value): + if not isinstance(value, dict): + return value + safe_keys = ( + "id", + "node_id", + "full_path", + "name", + "title", + "type", + "status", + "updated_at", + "depth", + "source", + "target", + "relation", + ) + return {key: value[key] for key in safe_keys if key in value} + + def bounded_tool_text(value, is_error=False): if is_error: return json.dumps(value, indent=2) if not isinstance(value, dict): return json.dumps(value, ensure_ascii=False) + serialized = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if len(serialized.encode("utf-8")) <= 4096: + return serialized summary = { key: value[key] for key in ( @@ -5611,6 +5634,27 @@ def bounded_tool_text(value, is_error=False): for key in ("items", "nodes", "edges", "warnings", "frontier"): if isinstance(value.get(key), list): summary[f"{key}_count"] = len(value[key]) + for key in ("items", "nodes", "edges"): + if isinstance(value.get(key), list): + summary[f"{key}_preview"] = [ + bounded_tool_item_preview(item) for item in value[key][:5] + ] + if isinstance(value.get("warnings"), list): + summary["warnings"] = value["warnings"][:5] + if isinstance(value.get("frontier"), list): + summary["frontier_preview"] = value["frontier"][:5] + if isinstance(value.get("identity"), dict): + summary["identity"] = bounded_tool_item_preview(value["identity"]) + if isinstance(value.get("route"), dict): + summary["route"] = bounded_tool_item_preview(value["route"]) + if isinstance(value.get("content_text"), str): + content = value["content_text"] + summary["content_chars"] = len(content) + summary["content_lines"] = len(content.splitlines()) + summary["content_preview"] = content[:800] + summary["content_truncated"] = len(content) > 800 + if value.get("next_action"): + summary["next_action"] = value["next_action"] summary["structured_result"] = True return json.dumps(summary, ensure_ascii=False, separators=(",", ":")) @@ -12183,6 +12227,130 @@ def classify_memory_query(args): } +CONTEXT_MAX_SEEDS = 10 + + +def context_project_label(): + refresh_public_auth_state() + return str( + AUTH.get("project_name") + or AUTH.get("project_slug") + or current_project_id() + or "configured project" + ) + + +def context_seed_not_found(count=1): + noun = "seed" if count == 1 else "seeds" + return SafeToolError( + "context_seed_not_found", + f"nap_context could not find the requested {noun} in the active project " + f"'{context_project_label()}'. Run nap_whoami to verify the workspace binding, " + "then use nap_discover to obtain current node_id values. If memory changed " + "recently, retry nap_context with mode='server'.", + status=404, + service_code="not_found", + ) + + +def context_service_not_found(exc): + return isinstance(exc, SafeToolError) and ( + exc.service_code == "not_found" + or exc.code == "context_seed_not_found" + ) + + +def context_view(args): + view = str(args.get("view") or "titles").strip().lower() + if view not in {"titles", "paths", "summary", "metadata"}: + raise SafeToolError( + "invalid_view", + "nap_context view must be titles, paths, summary, or metadata. " + "Read bodies separately with nap_node_get.", + ) + return view + + +def project_context_node(node, view): + if not isinstance(node, dict): + return node + keys_by_view = { + "titles": ("id", "full_path", "title", "status", "depth"), + "paths": ("id", "full_path", "type", "status", "updated_at", "depth"), + "summary": ( + "id", "canonical_uri", "full_path", "name", "title", "type", + "status", "updated_at", "depth", + ), + "metadata": ( + "id", "canonical_uri", "full_path", "type", "status", "updated_at", "depth", + ), + } + return {key: node[key] for key in keys_by_view[view] if key in node} + + +def finalize_memory_context(args, result, requested_seed_ids): + result = dict(result or {}) + view = context_view(args) + raw_nodes = list(result.get("nodes") or result.get("items") or []) + nodes = [project_context_node(node, view) for node in raw_nodes] + edges = list(result.get("edges") or []) + warnings = list(result.get("warnings") or []) + reasons = list(result.get("truncation_reasons") or []) + frontier = unique_preserve_order(result.get("frontier") or []) + max_frontier = max(1, min(int(args.get("max_per_level", 20)), 50)) + if len(frontier) > max_frontier: + frontier = frontier[:max_frontier] + if "max_frontier" not in reasons: + reasons.append("max_frontier") + + present_seed_ids = list(result.get("seed_node_ids") or []) + if not present_seed_ids: + returned_ids = { + str(node.get("id") or node.get("node_id") or "") + for node in raw_nodes + if isinstance(node, dict) + } + present_seed_ids = [ + seed_id for seed_id in requested_seed_ids if seed_id in returned_ids + ] + result["seed_node_ids"] = present_seed_ids + missing_seed_ids = list(result.get("missing_seed_node_ids") or []) + if not missing_seed_ids: + missing_seed_ids = [ + seed_id for seed_id in requested_seed_ids if seed_id not in present_seed_ids + ] + if missing_seed_ids: + warnings.append( + f"{len(missing_seed_ids)} requested seed node(s) were not found in the active project; " + "run nap_whoami and rediscover their current node_id values with nap_discover." + ) + depth = max(0, min(int(args.get("depth", 1)), 3)) + if depth > 0 and nodes and not edges: + warnings.append( + "No first-class graph links matched this neighborhood. Prose mentions are not traversed; " + "create a durable relationship with nap_ln when appropriate." + ) + + result["nodes"] = nodes + result["items"] = nodes + result["edges"] = edges + result["requested_seed_node_ids"] = list(requested_seed_ids) + result["missing_seed_node_ids"] = missing_seed_ids + result["frontier"] = frontier + result["truncation_reasons"] = reasons + result["truncated"] = bool(result.get("truncated") or reasons) + result["warnings"] = unique_preserve_order(warnings) + result["view"] = view + result["node_count"] = len(nodes) + result["edge_count"] = len(edges) + result["frontier_count"] = len(frontier) + result["next_action"] = ( + "Use returned node IDs with nap_node_get to read selected bodies. " + "If expected relationships are absent, add first-class links with nap_ln." + ) + return result + + def context_seed_ids(args, mode="hybrid"): node_ids = [str(value).strip() for value in (args.get("node_ids") or []) if str(value).strip()] if args.get("node_id"): @@ -12191,7 +12359,15 @@ def context_seed_ids(args, mode="hybrid"): if args.get("uri"): uris.append(args["uri"]) for uri in uris: - node_ids.append(parse_nap_node_uri(str(uri))["node_id"]) + parsed = parse_nap_node_uri(str(uri)) + configured_project_id = current_project_id() + if configured_project_id and parsed["project_id"] != configured_project_id: + raise SafeToolError( + "context_project_mismatch", + "A nap:// context seed belongs to a different project than the active workspace. " + "Run nap_whoami and use a URI or node_id from the configured project.", + ) + node_ids.append(parsed["node_id"]) paths = list(args.get("paths") or []) if args.get("path"): paths.append(args["path"]) @@ -12211,14 +12387,27 @@ def context_seed_ids(args, mode="hybrid"): node_ids.append(locally_resolved[path]) continue if mode == "local": - raise RuntimeError("seed path is not present in the local memory index") - resolved = node_by_path({"path": path}) + raise context_seed_not_found() + try: + resolved = node_by_path({"path": path}) + except Exception as exc: + if context_service_not_found(exc): + raise context_seed_not_found() from exc + raise node_id = (resolved.get("identity") or {}).get("node_id") if node_id: node_ids.append(node_id) + else: + raise context_seed_not_found() node_ids = unique_preserve_order(node_ids) if not node_ids: raise RuntimeError("path, paths, node_id, node_ids, uri, or uris is required") + if len(node_ids) > CONTEXT_MAX_SEEDS: + raise SafeToolError( + "too_many_context_seeds", + f"nap_context accepts at most {CONTEXT_MAX_SEEDS} unique seeds per call; " + "split larger exploration into bounded calls.", + ) return node_ids @@ -12259,10 +12448,15 @@ def local_memory_context(args, seed_ids): (project_id, path), ).fetchone() - seed_rows = [node_row(node_id=node_id) for node_id in seed_ids] - seed_rows = [row for row in seed_rows if row is not None] + seed_rows_by_id = { + node_id: node_row(node_id=node_id) for node_id in seed_ids + } + missing_seed_ids = [ + node_id for node_id, row in seed_rows_by_id.items() if row is None + ] + seed_rows = [row for row in seed_rows_by_id.values() if row is not None] if not seed_rows: - raise RuntimeError("seed node is not present in the local memory index") + raise context_seed_not_found(len(missing_seed_ids)) nodes = {} edges = [] edge_keys = set() @@ -12347,11 +12541,14 @@ def local_memory_context(args, seed_ids): warnings = [] if not graph_state["graph_complete"]: warnings.append( - "Local graph index coverage is incomplete; run nap index sync before relying on local-only neighborhoods." + "Local graph index coverage is incomplete; use mode='hybrid' or mode='server' " + "until the managed local projection refresh completes." ) return { "ok": True, "seed_node_ids": [row["node_id"] for row in seed_rows], + "requested_seed_node_ids": list(seed_ids), + "missing_seed_node_ids": missing_seed_ids, "nodes": list(nodes.values()), "items": list(nodes.values()), "edges": edges, @@ -12369,6 +12566,7 @@ def local_memory_context(args, seed_ids): def server_memory_context(args, seed_ids): project_id = resolve_project_id(args) results = [] + missing_seed_ids = [] for node_id in seed_ids: query = { "depth": max(0, min(int(args.get("depth", 1)), 3)), @@ -12379,15 +12577,25 @@ def server_memory_context(args, seed_ids): } if args.get("relations"): query["relations"] = ",".join(str(value) for value in args["relations"]) - results.append(request_json( - "GET", - f"/v1/projects/{project_id}/nodes/{urllib.parse.quote(node_id, safe='')}/neighborhood?{rest_query_string(query)}", - )) + try: + results.append(request_json( + "GET", + f"/v1/projects/{project_id}/nodes/{urllib.parse.quote(node_id, safe='')}/neighborhood?{rest_query_string(query)}", + )) + except Exception as exc: + if context_service_not_found(exc): + missing_seed_ids.append(node_id) + continue + raise + if not results: + raise context_seed_not_found(len(missing_seed_ids) or len(seed_ids)) if len(results) == 1: result = dict(results[0]) result.setdefault("items", result.get("nodes") or []) result.setdefault("source", "server") result.setdefault("view", "titles") + result["requested_seed_node_ids"] = list(seed_ids) + result["missing_seed_node_ids"] = missing_seed_ids return result nodes = unique_nodes([node for result in results for node in result.get("nodes") or []]) edges = unique_preserve_order( @@ -12395,7 +12603,11 @@ def server_memory_context(args, seed_ids): ) return { "ok": True, - "seed_node_ids": seed_ids, + "seed_node_ids": [ + seed_id for seed_id in seed_ids if seed_id not in missing_seed_ids + ], + "requested_seed_node_ids": list(seed_ids), + "missing_seed_node_ids": missing_seed_ids, "nodes": nodes, "items": nodes, "edges": [json.loads(edge) for edge in edges], @@ -12420,10 +12632,13 @@ def get_memory_context(args): project_id = resolve_project_id(args) graph_state = local_graph_index_state(project_id) if not graph_state["graph_complete"]: - raise RuntimeError( - "local graph index coverage is incomplete; run nap index sync" + raise SafeToolError( + "local_graph_incomplete", + "Local graph index coverage is incomplete; server fallback is required " + "until the managed local projection refresh completes.", ) - return local_memory_context(args, seed_ids) + result = local_memory_context(args, seed_ids) + return finalize_memory_context(args, result, seed_ids) except Exception as exc: if mode == "local": raise @@ -12432,8 +12647,8 @@ def get_memory_context(args): "Local index was unavailable or incomplete; server neighborhood was used." ) result["local_fallback"] = safe_exception_payload(exc) - return result - return server_memory_context(args, seed_ids) + return finalize_memory_context(args, result, seed_ids) + return finalize_memory_context(args, server_memory_context(args, seed_ids), seed_ids) def find_related_nodes(args): @@ -15859,6 +16074,8 @@ def safe_exception_payload(exc): } if isinstance(exc, SafeToolError) and exc.status is not None: payload["http_status"] = exc.status + if isinstance(exc, SafeToolError) and exc.service_code: + payload["service_code"] = exc.service_code return payload @@ -16016,6 +16233,12 @@ def _efficient_memory_schema(name, schema): "maxItems": 4, } elif name == "nap_context": + for key in ("paths", "node_ids", "uris"): + if key in properties: + properties[key] = { + **properties[key], + "maxItems": CONTEXT_MAX_SEEDS, + } properties.update({ "depth": {"type": "integer", "minimum": 0, "maximum": 3, "default": 1}, "direction": { @@ -16039,6 +16262,7 @@ def _efficient_memory_schema(name, schema): "type": "string", "enum": ["titles", "paths", "summary", "metadata"], "default": "titles", + "description": "Controls the compact node projection; bodies always require nap_node_get.", } elif name == "nap_node_get": properties["view"] = { @@ -16470,7 +16694,8 @@ def all_tools(): item["description"] = ( "Read a bounded title-only neighborhood around one or more nodes. " "Supports depth 0-3, incoming/outgoing/both traversal, relation filters, " - "explicit budgets, and local-index/server fallback without fetching bodies." + "explicit budgets, stable missing/cross-project seed diagnostics, and " + "local-index/server fallback without fetching bodies." ) if canonical_name == "nap_discover": item["description"] = ( diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py index 422fb39..e4a38c9 100644 --- a/tests/test_mcp_memory_surface_efficiency.py +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -2,6 +2,7 @@ """Contract tests for the reduced, bounded agent-memory surface.""" import importlib.util +import json import pathlib import sys import tempfile @@ -76,6 +77,9 @@ def test_context_and_node_read_schemas_are_bounded(mod): assert "relations" in context assert "max_edges" in context assert "max_per_level" in context + assert context["node_ids"]["maxItems"] == 10 + assert context["paths"]["maxItems"] == 10 + assert context["uris"]["maxItems"] == 10 node_get = by_name["nap_node_get"]["inputSchema"]["properties"] assert node_get["view"]["enum"] == ["content", "outline", "edit"] @@ -86,10 +90,47 @@ def test_tool_text_does_not_duplicate_large_structured_payload(mod): payload = {"ok": True, "items": [{"content_text": "x" * 20_000}]} result = mod.tool_result(payload) assert result["structuredContent"] == payload - assert len(result["content"][0]["text"]) < 200 + assert len(result["content"][0]["text"]) < 2_000 assert "20000" not in result["content"][0]["text"] +def test_tool_text_keeps_small_structured_results_useful_for_text_only_clients(mod): + payload = { + "status": "current", + "full_path": "/docs/useful", + "view": "content", + "content_text": "A small useful body.", + } + + result = mod.tool_result(payload) + + assert json.loads(result["content"][0]["text"]) == payload + + +def test_large_graph_text_fallback_contains_bounded_node_and_edge_previews(mod): + payload = { + "ok": True, + "nodes": [ + {"id": f"node-{index}", "full_path": f"/docs/{index}", "title": f"Node {index}"} + for index in range(100) + ], + "edges": [ + {"source": f"node-{index}", "target": f"node-{index + 1}", "relation": "references"} + for index in range(99) + ], + "warnings": ["Use first-class links."], + } + + fallback = json.loads(mod.tool_result(payload)["content"][0]["text"]) + + assert fallback["nodes_count"] == 100 + assert fallback["edges_count"] == 99 + assert len(fallback["nodes_preview"]) == 5 + assert len(fallback["edges_preview"]) == 5 + assert fallback["warnings"] == ["Use first-class links."] + assert fallback["structured_result"] is True + + def test_local_context_returns_title_radius_without_node_bodies(mod): state = tempfile.TemporaryDirectory() mod._surface_state = state @@ -126,6 +167,129 @@ def test_local_context_returns_title_radius_without_node_bodies(mod): assert result["source"] == "local_index" +def test_local_context_reports_partial_and_total_missing_seeds_actionably(mod): + state = tempfile.TemporaryDirectory() + mod._surface_missing_seed_state = state + mod.AUTH_DIR = pathlib.Path(state.name) + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock" + mod.index_node({ + "id": "root", "project_id": "project-1", "full_path": "/docs/root", + "folder_path": "/docs", "name": "root", "type": "note", + "metadata": {"title": "Root"}, "tags": [], "links": [], + "content_text": "body", "updated_at": "2026-08-16T12:00:00Z", + }) + + partial = mod.get_memory_context({ + "node_ids": ["root", "missing"], "mode": "local", "depth": 0, + }) + + assert partial["requested_seed_node_ids"] == ["root", "missing"] + assert partial["seed_node_ids"] == ["root"] + assert partial["missing_seed_node_ids"] == ["missing"] + assert any("active project" in warning for warning in partial["warnings"]) + + with pytest.raises(mod.SafeToolError) as missing: + mod.get_memory_context({"node_id": "missing", "mode": "local"}) + assert missing.value.code == "context_seed_not_found" + assert "nap_whoami" in missing.value.safe_message + assert "nap_discover" in missing.value.safe_message + + +def test_context_translates_server_not_found_into_seed_diagnostic(mod): + def missing_request(*_args, **_kwargs): + raise mod.SafeToolError( + "http_404", + "Napseer GET request failed with HTTP 404.", + status=404, + service_code="not_found", + ) + + mod.request_json = missing_request + + with pytest.raises(mod.SafeToolError) as missing: + mod.get_memory_context({"node_id": "missing", "mode": "server"}) + + assert missing.value.code == "context_seed_not_found" + assert missing.value.status == 404 + assert mod.safe_exception_payload(missing.value)["service_code"] == "not_found" + + +def test_context_rejects_cross_project_uri_before_graph_reads(mod): + with pytest.raises(mod.SafeToolError) as mismatch: + mod.get_memory_context({ + "uri": "nap://other-project/project-2/node-1", + "mode": "server", + }) + + assert mismatch.value.code == "context_project_mismatch" + assert "nap_whoami" in mismatch.value.safe_message + + +def test_local_context_projects_requested_view_and_bounds_frontier(mod): + state = tempfile.TemporaryDirectory() + mod._surface_view_state = state + mod.AUTH_DIR = pathlib.Path(state.name) + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock" + children = [] + for index in range(5): + children.append({ + "id": f"child-{index}", "project_id": "project-1", + "full_path": f"/docs/child-{index}", "folder_path": "/docs", + "name": f"child-{index}", "type": "note", "metadata": {}, + "tags": [], "links": [], "content_text": "", + "updated_at": f"2026-08-16T12:0{index + 1}:00Z", + }) + mod.index_node({ + "id": "root", "project_id": "project-1", "full_path": "/docs/root", + "folder_path": "/docs", "name": "root", "type": "note", + "metadata": {"title": "Root"}, "tags": [], + "links": [ + {"node_id": child["id"], "relation": "references"} + for child in children + ], + "content_text": "", "updated_at": "2026-08-16T12:00:00Z", + }) + for child in children: + mod.index_node(child) + + result = mod.get_memory_context({ + "node_id": "root", "mode": "local", "depth": 1, + "max_nodes": 2, "max_per_level": 1, "view": "paths", + }) + + assert result["view"] == "paths" + assert "title" not in result["nodes"][0] + assert set(result["nodes"][0]) == { + "id", "full_path", "type", "status", "updated_at", "depth" + } + assert len(result["frontier"]) <= 1 + assert "max_frontier" in result["truncation_reasons"] + assert result["node_count"] == 2 + assert result["edge_count"] == 1 + + +def test_context_explains_empty_first_class_graph(mod): + state = tempfile.TemporaryDirectory() + mod._surface_empty_graph_state = state + mod.AUTH_DIR = pathlib.Path(state.name) + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock" + mod.index_node({ + "id": "root", "project_id": "project-1", "full_path": "/docs/root", + "folder_path": "/docs", "name": "root", "type": "note", + "metadata": {"title": "Root"}, "tags": [], "links": [], + "content_text": "mentions /docs/other only in prose", + "updated_at": "2026-08-16T12:00:00Z", + }) + + result = mod.get_memory_context({"node_id": "root", "mode": "local", "depth": 1}) + + assert any("Prose mentions are not traversed" in warning for warning in result["warnings"]) + assert "nap_ln" in result["next_action"] + + def test_hybrid_context_falls_back_until_local_graph_coverage_is_complete(mod): state = tempfile.TemporaryDirectory() mod._surface_fallback_state = state