diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index db7b47794..5f55f6c73 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -50,10 +50,24 @@ "Target context graph. " + EXISTING_CONTEXT_GRAPH_ID_DESCRIPTION ) AGENT_CONTEXT_GRAPH_ID = "agent-context" +CONTEXT_GRAPH_URI_PREFIX = "did:dkg:context-graph:" AGENT_CONTEXT_GRAPH_NAME = "Agent Context" AGENT_CONTEXT_GRAPH_DESCRIPTION = "Chat-turn working memory for local agent integrations." +def _normalize_context_graph_id(value: Any) -> str: + if not isinstance(value, str): + return "" + trimmed = value.strip() + if trimmed.startswith(CONTEXT_GRAPH_URI_PREFIX): + return trimmed[len(CONTEXT_GRAPH_URI_PREFIX):] + return trimmed + + +def _is_agent_context_graph(value: Any) -> bool: + return _normalize_context_graph_id(value) == AGENT_CONTEXT_GRAPH_ID + + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @@ -279,6 +293,10 @@ def _scoped_session_id(raw_session_id: str, config: Optional[dict] = None) -> st "type": "string", "description": "Optional Working Memory owner address. Defaults to this node when view is working-memory.", }, + "sub_graph_name": { + "type": "string", + "description": "Optional sub-graph scope within context_graph_id. May be combined with view.", + }, "assertion_name": { "type": "string", "description": "Optional assertion name scope.", @@ -804,6 +822,10 @@ def _scoped_session_id(raw_session_id: str, config: Optional[dict] = None) -> st "query": {"type": "string", "description": "Free-text search query."}, "limit": {"type": "integer", "description": "Max results, default 20, capped at 100."}, "context_graph_id": {"type": "string", "description": "Optional context graph override. " + EXISTING_CONTEXT_GRAPH_ID_DESCRIPTION}, + "sub_graph_name": { + "type": "string", + "description": "Optional project sub-graph scope. Requires a project context graph.", + }, }, "required": ["query"], }, @@ -1386,14 +1408,26 @@ def _handle_query(self, args: Dict[str, Any]) -> str: ) if args.get("context_graph") is not None: return tool_error('"context_graph" is not a supported parameter on dkg_query. Use "context_graph_id".') - cg = _first_text(args, "context_graph_id") or self._context_graph + explicit_cg = _first_text(args, "context_graph_id") + cg = explicit_cg or self._context_graph view = _first_text(args, "view") if view and view not in ("working-memory", "shared-working-memory", "verified-memory"): return tool_error('"view" must be one of: working-memory, shared-working-memory, verified-memory.') - if view and not _first_text(args, "context_graph_id"): + if view and not explicit_cg: return tool_error(f'"view: {view}" requires "context_graph_id".') - if view and _first_text(args, "sub_graph_name"): - return tool_error('"sub_graph_name" cannot be combined with view-based dkg_query routing.') + if args.get("sub_graph_name") is not None and not isinstance(args.get("sub_graph_name"), str): + return tool_error('"sub_graph_name" must be a string.') + if isinstance(args.get("sub_graph_name"), str) and not args.get("sub_graph_name", "").strip(): + return tool_error('"sub_graph_name" must be a non-empty string.') + if _first_text(args, "sub_graph_name") and not explicit_cg: + return tool_error('"sub_graph_name" requires "context_graph_id".') + if args.get("assertion_name") is not None and not isinstance(args.get("assertion_name"), str): + return tool_error('"assertion_name" must be a string.') + if isinstance(args.get("assertion_name"), str) and not args.get("assertion_name", "").strip(): + return tool_error('"assertion_name" must be a non-empty string.') + assertion_name = _first_text(args, "assertion_name") + if assertion_name and view != "working-memory": + return tool_error('"assertion_name" is only supported with "view: working-memory".') if args.get("agent_address") is not None and not isinstance(args.get("agent_address"), str): return tool_error('"agent_address" must be a string.') if isinstance(args.get("agent_address"), str) and not args.get("agent_address", "").strip(): @@ -1409,7 +1443,7 @@ def _handle_query(self, args: Dict[str, Any]) -> str: sparql, cg, view=view, - assertion_name=_first_text(args, "assertion_name"), + assertion_name=assertion_name, agent_address=agent_address, sub_graph_name=_first_text(args, "sub_graph_name"), verified_graph=_first_text(args, "verified_graph"), @@ -1423,7 +1457,24 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: if len(query) < 2: return tool_error("query must be at least 2 characters.") limit = _coerce_limit(args.get("limit"), default=20, maximum=100) + if "context_graph" in args: + return tool_error('"context_graph" is not a supported parameter on memory_search. Use "context_graph_id".') + project_context_graph = _normalize_context_graph_id( + _first_text(args, "context_graph_id") or self._context_graph + ) + if args.get("sub_graph_name") is not None and not isinstance(args.get("sub_graph_name"), str): + return tool_error('"sub_graph_name" must be a string.') + if isinstance(args.get("sub_graph_name"), str) and not args.get("sub_graph_name", "").strip(): + return tool_error('"sub_graph_name" must be a non-empty string.') + project_sub_graph_name = _first_text(args, "sub_graph_name") + if project_sub_graph_name and (not project_context_graph or _is_agent_context_graph(project_context_graph)): + return tool_error('"sub_graph_name" requires a project context graph for memory_search.') if self._offline or not self._client: + if project_sub_graph_name: + return tool_error( + '"sub_graph_name" cannot be used for offline memory_search because the local cache ' + "does not record sub-graph scope." + ) return json.dumps(_cache_memory_search(query, self._cache, limit)) keywords = [k for k in query.lower().split() if len(k) >= 2] @@ -1432,16 +1483,15 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: sparql = _build_memory_search_sparql(keywords, limit) agent_address = self._client._resolve_agent_address() - if "context_graph" in args: - return tool_error('"context_graph" is not a supported parameter on memory_search. Use "context_graph_id".') - project_context_graph = _first_text(args, "context_graph_id") or self._context_graph context_graphs: List[str] = [] - for cg in ("agent-context", project_context_graph): + for cg in (AGENT_CONTEXT_GRAPH_ID, project_context_graph): if cg and cg not in context_graphs: context_graphs.append(cg) hits: List[Dict[str, Any]] = [] successful_queries = 0 + successful_scoped_project_queries = 0 + first_scoped_project_error: Optional[str] = None for cg in context_graphs: for view, weight in ( ("working-memory", 1.0), @@ -1450,15 +1500,49 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: ): if view == "working-memory" and not agent_address: continue - result = self._client.query( - sparql, - cg, - view=view, - agent_address=agent_address if view == "working-memory" else None, + scoped_project_layer = bool( + project_sub_graph_name + and cg == project_context_graph + and not _is_agent_context_graph(cg) ) + query_kwargs = { + "view": view, + "agent_address": agent_address if view == "working-memory" else None, + } + if scoped_project_layer: + query_kwargs["sub_graph_name"] = project_sub_graph_name + try: + result = self._client.query( + sparql, + cg, + **query_kwargs, + ) + except Exception as e: + if scoped_project_layer and _is_scoped_query_routing_error(e): + return tool_error( + f'memory_search sub_graph_name "{project_sub_graph_name}" failed for ' + f'context_graph_id "{cg}" ({view}): {e}' + ) + if scoped_project_layer: + if first_scoped_project_error is None: + first_scoped_project_error = f'{cg} ({view}): {e}' + continue + raise if _client_result_failed(result): + detail = result.get("error") if isinstance(result, dict) else "query failed" + if scoped_project_layer: + if _is_scoped_query_routing_error(detail): + return tool_error( + f'memory_search sub_graph_name "{project_sub_graph_name}" failed for ' + f'context_graph_id "{cg}" ({view}): {detail}' + ) + if first_scoped_project_error is None: + first_scoped_project_error = f'{cg} ({view}): {detail}' + continue continue successful_queries += 1 + if scoped_project_layer: + successful_scoped_project_queries += 1 for binding in _extract_query_bindings(result): text = _binding_value(binding.get("text") or binding.get("o")) uri = _binding_value(binding.get("uri") or binding.get("s")) @@ -1467,7 +1551,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: continue score = _keyword_overlap(text, keywords) layer = _memory_search_layer(cg, view) - source = "sessions" if cg == "agent-context" else "memory" + source = "sessions" if _is_agent_context_graph(cg) else "memory" hits.append({ "snippet": text[:500], "layer": layer, @@ -1478,15 +1562,20 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: "predicate": pred, }) + if project_sub_graph_name and successful_scoped_project_queries == 0 and first_scoped_project_error: + detail = first_scoped_project_error or "all live queries failed" + return tool_error( + f"memory_search failed: {detail}" + ) if not hits and successful_queries == 0: fallback = _cache_memory_search(query, self._cache, limit) - fallback["scope"] = project_context_graph if project_context_graph != "agent-context" else None + fallback["scope"] = project_context_graph if not _is_agent_context_graph(project_context_graph) else None return json.dumps(fallback) if not hits: return json.dumps({ "query": query, "count": 0, - "scope": project_context_graph if project_context_graph != "agent-context" else None, + "scope": project_context_graph if not _is_agent_context_graph(project_context_graph) else None, "hits": [], }) @@ -1520,7 +1609,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: return json.dumps({ "query": query, "count": len(public_hits), - "scope": project_context_graph if project_context_graph != "agent-context" else None, + "scope": project_context_graph if not _is_agent_context_graph(project_context_graph) else None, "hits": public_hits, }) @@ -2368,6 +2457,30 @@ def _client_result_failed(result: Any) -> bool: return result.get("success") is False or result.get("ok") is False or bool(result.get("error")) +def _is_scoped_query_routing_error(message: Any) -> bool: + text = str(message).lower() + mentions_sub_graph = ( + "sub-graph" in text + or "subgraphname" in text + or "sub_graph_name" in text + ) + return ( + "scoped query violation" in text + or "known child context graph" in text + or "unknown sub-graph" in text + or "unknown subgraph" in text + or ( + mentions_sub_graph + and ( + "registered" in text + or "invalid" in text + or "requires" in text + or "not found" in text + ) + ) + ) + + def _first_text(args: Dict[str, Any], *keys: str) -> str: for key in keys: value = args.get(key) @@ -2569,7 +2682,7 @@ def _memory_search_layer(context_graph_id: str, view: str) -> str: "shared-working-memory": "swm", "verified-memory": "vm", }.get(view, "wm") - prefix = "agent-context" if context_graph_id == "agent-context" else "project" + prefix = "agent-context" if _is_agent_context_graph(context_graph_id) else "project" return f"{prefix}-{suffix}" diff --git a/packages/adapter-hermes/test/hermes-adapter.part-09.test.ts b/packages/adapter-hermes/test/hermes-adapter.part-09.test.ts index 9dd431b29..c68c30b42 100644 --- a/packages/adapter-hermes/test/hermes-adapter.part-09.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.part-09.test.ts @@ -415,9 +415,10 @@ subscribe_schema = next(schema for schema in provider.get_tool_schemas() if sche assert "include_shared_memory" in subscribe_schema["parameters"]["properties"], subscribe_schema search_schema = next(schema for schema in provider.get_tool_schemas() if schema["name"] == "memory_search") assert "context_graph_id" in search_schema["parameters"]["properties"], search_schema +assert "sub_graph_name" in search_schema["parameters"]["properties"], search_schema assert "context_graph" not in search_schema["parameters"]["properties"], search_schema query_schema = next(schema for schema in provider.get_tool_schemas() if schema["name"] == "dkg_query") -assert "sub_graph_name" not in query_schema["parameters"]["properties"], query_schema +assert "sub_graph_name" in query_schema["parameters"]["properties"], query_schema share_schema = next(schema for schema in provider.get_tool_schemas() if schema["name"] == "dkg_share") assert "context_graph_id" in share_schema["parameters"]["properties"], share_schema assert "context_graph" not in share_schema["parameters"]["properties"], share_schema diff --git a/packages/adapter-hermes/test/hermes-adapter.part-11.test.ts b/packages/adapter-hermes/test/hermes-adapter.part-11.test.ts index 9acf6fa1c..ce75d4595 100644 --- a/packages/adapter-hermes/test/hermes-adapter.part-11.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.part-11.test.ts @@ -234,13 +234,40 @@ for args, needle in [ ({"sparql": "ASK {}", "include_shared_memory": True}, "include_shared_memory"), ({"sparql": "ASK {}", "context_graph": "old"}, "context_graph"), ({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "bad"}, "view"), - ({"sparql": "ASK {}", "view": "working-memory"}, "context_graph_id"), - ({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "shared-working-memory", "sub_graph_name": "scratch"}, "sub_graph_name"), + ({"sparql": "ASK {}", "context_graph_id": "cg:test", "sub_graph_name": 42}, "sub_graph_name"), + ({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "verified-memory", "assertion_name": "turn"}, "assertion_name"), + ({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "working-memory", "assertion_name": 42}, "assertion_name"), ({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "working-memory", "agent_address": " "}, "agent_address"), ]: result = json.loads(provider.handle_tool_call("dkg_query", args)) assert needle in result["error"], (args, result) +provider_without_default = module.DKGMemoryProvider() +provider_without_default._offline = False +provider_without_default._context_graph = None +provider_without_default._client = QueryClient() +missing_view_cg = json.loads(provider_without_default.handle_tool_call("dkg_query", { + "sparql": "ASK {}", + "view": "working-memory", +})) +missing_sub_cg = json.loads(provider_without_default.handle_tool_call("dkg_query", { + "sparql": "ASK {}", + "sub_graph_name": "scratch", +})) +assert "context_graph_id" in missing_view_cg["error"], missing_view_cg +assert "sub_graph_name" in missing_sub_cg["error"] and "context_graph_id" in missing_sub_cg["error"], missing_sub_cg + +default_view_cg = json.loads(provider.handle_tool_call("dkg_query", { + "sparql": "ASK {}", + "view": "shared-working-memory", +})) +default_sub_cg = json.loads(provider.handle_tool_call("dkg_query", { + "sparql": "ASK {}", + "sub_graph_name": "scratch", +})) +assert "context_graph_id" in default_view_cg["error"], default_view_cg +assert "sub_graph_name" in default_sub_cg["error"] and "context_graph_id" in default_sub_cg["error"], default_sub_cg + result = json.loads(provider.handle_tool_call("dkg_query", { "sparql": "ASK {}", "context_graph_id": "cg:test", @@ -258,6 +285,15 @@ result = json.loads(provider.handle_tool_call("dkg_query", { assert result["ok"] is True, result assert provider._client.queries[-1][2]["agent_address"] == "peer-default", provider._client.queries +result = json.loads(provider.handle_tool_call("dkg_query", { + "sparql": "ASK {}", + "context_graph_id": "cg:test", + "view": "shared-working-memory", + "sub_graph_name": "scratch", +})) +assert result["ok"] is True, result +assert provider._client.queries[-1][2]["sub_graph_name"] == "scratch", provider._client.queries + class ReadMarkdownClient: def __init__(self): self.calls = [] diff --git a/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts b/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts index c1c34ccc1..a63eeef53 100644 --- a/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts @@ -146,12 +146,24 @@ spec.loader.exec_module(module) class FakeClient: def __init__(self): self.calls = [] + self.fail_all = False + self.fail_scoped_project = False + self.generic_fail_scoped_project_wm = False + self.generic_fail_scoped_project = False def _resolve_agent_address(self): return "0xAgent" def query(self, sparql, context_graph_id, **kwargs): self.calls.append((context_graph_id, kwargs)) + if self.fail_all: + return {"error": "fetch failed"} + if self.fail_scoped_project and kwargs.get("sub_graph_name"): + return {"error": "Invalid subGraphName: Sub-graph names cannot contain \"/\""} + if self.generic_fail_scoped_project_wm and kwargs.get("sub_graph_name") and kwargs.get("view") == "working-memory": + return {"error": "fetch failed"} + if self.generic_fail_scoped_project and kwargs.get("sub_graph_name"): + return {"error": "fetch failed"} return { "result": { "bindings": [{ @@ -194,6 +206,126 @@ assert provider._client.calls == [ ("project-cg", {"view": "shared-working-memory", "agent_address": None}), ("project-cg", {"view": "verified-memory", "agent_address": None}), ], provider._client.calls + +provider._client.calls = [] +scoped = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "limit": 10, + "sub_graph_name": "skills", +})) +assert scoped["scope"] == "project-cg", scoped +assert provider._client.calls == [ + ("agent-context", {"view": "working-memory", "agent_address": "0xAgent"}), + ("agent-context", {"view": "shared-working-memory", "agent_address": None}), + ("agent-context", {"view": "verified-memory", "agent_address": None}), + ("project-cg", {"view": "working-memory", "agent_address": "0xAgent", "sub_graph_name": "skills"}), + ("project-cg", {"view": "shared-working-memory", "agent_address": None, "sub_graph_name": "skills"}), + ("project-cg", {"view": "verified-memory", "agent_address": None, "sub_graph_name": "skills"}), +], provider._client.calls + +provider._offline = True +offline_scoped = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "limit": 10, + "sub_graph_name": "skills", +})) +assert "sub_graph_name" in offline_scoped["error"], offline_scoped +assert "offline" in offline_scoped["error"], offline_scoped +provider._offline = False + +provider._client.calls = [] +provider._client.fail_scoped_project = True +failed_scoped = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "limit": 10, + "sub_graph_name": "skills", +})) +assert "sub_graph_name" in failed_scoped["error"], failed_scoped +assert "Invalid subGraphName" in failed_scoped["error"], failed_scoped +assert provider._client.calls == [ + ("agent-context", {"view": "working-memory", "agent_address": "0xAgent"}), + ("agent-context", {"view": "shared-working-memory", "agent_address": None}), + ("agent-context", {"view": "verified-memory", "agent_address": None}), + ("project-cg", {"view": "working-memory", "agent_address": "0xAgent", "sub_graph_name": "skills"}), +], provider._client.calls +provider._client.fail_scoped_project = False + +provider._client.calls = [] +provider._client.generic_fail_scoped_project_wm = True +generic_failed_scoped = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "limit": 10, + "sub_graph_name": "skills", +})) +assert "error" not in generic_failed_scoped, generic_failed_scoped +assert generic_failed_scoped["count"] == 5, generic_failed_scoped +assert provider._client.calls == [ + ("agent-context", {"view": "working-memory", "agent_address": "0xAgent"}), + ("agent-context", {"view": "shared-working-memory", "agent_address": None}), + ("agent-context", {"view": "verified-memory", "agent_address": None}), + ("project-cg", {"view": "working-memory", "agent_address": "0xAgent", "sub_graph_name": "skills"}), + ("project-cg", {"view": "shared-working-memory", "agent_address": None, "sub_graph_name": "skills"}), + ("project-cg", {"view": "verified-memory", "agent_address": None, "sub_graph_name": "skills"}), +], provider._client.calls +provider._client.generic_fail_scoped_project_wm = False + +provider._client.calls = [] +provider._client.generic_fail_scoped_project = True +scoped_project_all_failed = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "limit": 10, + "sub_graph_name": "skills", +})) +assert "sub_graph_name" not in scoped_project_all_failed["error"], scoped_project_all_failed +assert "memory_search failed" in scoped_project_all_failed["error"], scoped_project_all_failed +assert "fetch failed" in scoped_project_all_failed["error"], scoped_project_all_failed +assert provider._client.calls == [ + ("agent-context", {"view": "working-memory", "agent_address": "0xAgent"}), + ("agent-context", {"view": "shared-working-memory", "agent_address": None}), + ("agent-context", {"view": "verified-memory", "agent_address": None}), + ("project-cg", {"view": "working-memory", "agent_address": "0xAgent", "sub_graph_name": "skills"}), + ("project-cg", {"view": "shared-working-memory", "agent_address": None, "sub_graph_name": "skills"}), + ("project-cg", {"view": "verified-memory", "agent_address": None, "sub_graph_name": "skills"}), +], provider._client.calls +provider._client.generic_fail_scoped_project = False + +provider._client.calls = [] +provider._client.fail_all = True +provider._cache = {"memory": [{"target": "memory", "content": "alpha beta stale cache hit"}]} +scoped_all_failed = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "limit": 10, + "sub_graph_name": "skills", +})) +assert "sub_graph_name" not in scoped_all_failed["error"], scoped_all_failed +assert "memory_search failed" in scoped_all_failed["error"], scoped_all_failed +assert "fetch failed" in scoped_all_failed["error"], scoped_all_failed +assert provider._client.calls == [ + ("agent-context", {"view": "working-memory", "agent_address": "0xAgent"}), + ("agent-context", {"view": "shared-working-memory", "agent_address": None}), + ("agent-context", {"view": "verified-memory", "agent_address": None}), + ("project-cg", {"view": "working-memory", "agent_address": "0xAgent", "sub_graph_name": "skills"}), + ("project-cg", {"view": "shared-working-memory", "agent_address": None, "sub_graph_name": "skills"}), + ("project-cg", {"view": "verified-memory", "agent_address": None, "sub_graph_name": "skills"}), +], provider._client.calls +provider._client.fail_all = False +provider._cache = {} + +provider._context_graph = "agent-context" +missing_project = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "sub_graph_name": "skills", +})) +assert "sub_graph_name" in missing_project["error"], missing_project +assert "project context graph" in missing_project["error"], missing_project + +provider._context_graph = "did:dkg:context-graph:agent-context" +missing_project_uri = json.loads(provider.handle_tool_call("memory_search", { + "query": "alpha beta", + "sub_graph_name": "skills", +})) +assert "sub_graph_name" in missing_project_uri["error"], missing_project_uri +assert "project context graph" in missing_project_uri["error"], missing_project_uri `; const result = spawnSync('python', ['-B', '-c', script], { cwd: process.cwd(), diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index 1819818b0..73c56bc1e 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -29,7 +29,7 @@ * `21_TRI_MODAL_MEMORY.md §5`. */ -import type { DkgDaemonClient } from './dkg-client.js'; +import { normalizeContextGraphId, type DkgDaemonClient } from './dkg-client.js'; import type { DkgOpenClawConfig, MemoryEmbeddingProbeResult, @@ -62,6 +62,11 @@ export const AGENT_CONTEXT_GRAPH = 'agent-context'; export const CHAT_TURNS_ASSERTION = 'chat-turns'; export const PROJECT_MEMORY_ASSERTION = 'memory'; +function normalizeMemoryContextGraphId(value: string | undefined): string | undefined { + if (!value) return undefined; + return normalizeContextGraphId(value) || undefined; +} + function buildDkgMemoryPromptSections(): string[] { return [ 'DKG memory rules:', @@ -192,7 +197,15 @@ export class DkgMemorySearchManager implements MemorySearchManager { // consumption boundary. `toAgentPeerId` is a no-op on already-raw // inputs, so passing a raw peer ID through the resolver still works. const agentAddress = rawAgentAddress ? toAgentPeerId(rawAgentAddress) : undefined; - const projectContextGraphId = session?.projectContextGraphId; + const projectContextGraphId = normalizeMemoryContextGraphId(session?.projectContextGraphId); + const projectSubGraphName = options?.projectSubGraphName; + if (projectSubGraphName && (!projectContextGraphId || projectContextGraphId === AGENT_CONTEXT_GRAPH)) { + this.deps.logger?.warn?.( + `[dkg-memory] DkgMemorySearchManager.search skipped project sub-graph scope ` + + `"${projectSubGraphName}" because no project context graph is selected.`, + ); + throw new Error('projectSubGraphName requires a selected project context graph.'); + } // B28: Preflight the agent address BEFORE firing WM queries. The query // engine at `packages/query/src/dkg-query-engine.ts:47-48` throws @@ -273,9 +286,12 @@ export class DkgMemorySearchManager implements MemorySearchManager { // there is no inherent trust advantage of agent-context over // project-scoped memories at the same view tier. // - // Per-query `.catch → []` preserves partial-success semantics: + // Per-query `.catch -> []` preserves partial-success semantics: // one failing (cg, view) pair emits exactly one warn and the - // surviving layers continue to contribute results. + // surviving layers continue to contribute results. The exception is + // explicit project sub-graph scope: if the caller supplied + // `projectSubGraphName`, daemon validation/routing failures must + // surface as caller errors rather than looking like "no memories". interface LayerPlan { layer: MemoryLayer; source: MemorySource; @@ -283,6 +299,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: string; view: 'working-memory' | 'shared-working-memory' | 'verified-memory'; sparql: string; + subGraphName?: string; } const plans: LayerPlan[] = [ { @@ -319,6 +336,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: projectContextGraphId, view: 'working-memory', sparql: permissiveSparql, + subGraphName: projectSubGraphName, }, { layer: 'project-swm', @@ -327,6 +345,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: projectContextGraphId, view: 'shared-working-memory', sparql: permissiveSparql, + subGraphName: projectSubGraphName, }, { layer: 'project-vm', @@ -335,10 +354,12 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: projectContextGraphId, view: 'verified-memory', sparql: permissiveSparql, + subGraphName: projectSubGraphName, }, ); } + let firstScopedProjectError: string | undefined; const settled = await Promise.all( plans.map(plan => this.deps.client @@ -346,17 +367,33 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: plan.contextGraphId, view: plan.view, agentAddress, + subGraphName: plan.subGraphName, }) - .then(r => ({ plan, bindings: extractBindings(r) })) + .then(r => ({ plan, bindings: extractBindings(r), succeeded: true })) .catch(err => { + const message = errorMessage(err); this.deps.logger?.warn?.( - `[dkg-memory] ${plan.layer} search failed (cg=${plan.contextGraphId}, view=${plan.view}): ${errorMessage(err)}`, + `[dkg-memory] ${plan.layer} search failed (cg=${plan.contextGraphId}, view=${plan.view}): ${message}`, ); - return { plan, bindings: [] as any[] }; + if (plan.subGraphName && isScopedQueryRoutingError(message)) { + throw new Error( + `memory_search sub_graph_name "${plan.subGraphName}" failed for ` + + `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`, + ); + } + if (plan.subGraphName && firstScopedProjectError === undefined) { + firstScopedProjectError = `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`; + } + return { plan, bindings: [] as any[], succeeded: false }; }), ), ); + const scopedProjectSucceeded = settled.some(s => Boolean(s.plan.subGraphName) && s.succeeded); + if (projectSubGraphName && !scopedProjectSucceeded && firstScopedProjectError) { + throw new Error(`memory_search failed: ${firstScopedProjectError}`); + } + // Observability: one info-level log per search call showing the // query, resolved project CG, layer count, and per-layer raw hit // counts. This is the diagnostic we were missing during the @@ -1039,6 +1076,26 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function isScopedQueryRoutingError(message: string): boolean { + const text = message.toLowerCase(); + const mentionsSubGraph = text.includes('sub-graph') || + text.includes('subgraphname') || + text.includes('sub_graph_name'); + return text.includes('scoped query violation') || + text.includes('known child context graph') || + text.includes('unknown sub-graph') || + text.includes('unknown subgraph') || + ( + mentionsSubGraph && + ( + text.includes('registered') || + text.includes('invalid') || + text.includes('requires') || + text.includes('not found') + ) + ); +} + /** * The DKG V10 agent identity shows up in two representations in this * package — the daemon's working-memory view routing uses the raw peer diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index 6cd4666fa..ea26d6239 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -35,6 +35,7 @@ import { DkgChannelPlugin } from './DkgChannelPlugin.js'; import { HookSurface } from './HookSurface.js'; import { ChatTurnWriter } from './ChatTurnWriter.js'; import { + AGENT_CONTEXT_GRAPH, DkgMemoryPlugin, DkgMemorySearchManager, toAgentPeerId, @@ -2630,6 +2631,17 @@ export class DkgNodePlugin { const limit = Number.isFinite(rawLimit) ? Math.floor(Math.max(1, Math.min(100, rawLimit as number))) : 20; + if (args.sub_graph_name !== undefined) { + if (typeof args.sub_graph_name !== 'string') { + return this.error('"sub_graph_name" must be a string.'); + } + if (args.sub_graph_name.trim() === '') { + return this.error('"sub_graph_name" must be a non-empty string.'); + } + } + const projectSubGraphName = typeof args.sub_graph_name === 'string' + ? args.sub_graph_name.trim() + : undefined; // Mode-independent slot re-assertion anchor. `before_prompt_build` // (the W3 anchor) only fires in `full` registration mode, which means @@ -2655,6 +2667,15 @@ export class DkgNodePlugin { await this.ensureNodePeerId().catch(() => {}); } const session = this.memorySessionResolver.getSession(undefined); + const sessionProjectContextGraphId = session?.projectContextGraphId + ? normalizeContextGraphId(session.projectContextGraphId) + : undefined; + if ( + projectSubGraphName && + (!sessionProjectContextGraphId || sessionProjectContextGraphId === AGENT_CONTEXT_GRAPH) + ) { + return this.error('"sub_graph_name" requires a selected project context graph for memory_search.'); + } const agentAddress = session?.agentAddress ?? this.memorySessionResolver.getDefaultAgentAddress(); if (!agentAddress) { return this.error( @@ -2671,7 +2692,11 @@ export class DkgNodePlugin { resolver: this.memorySessionResolver, logger: this.memoryResolverApi?.logger, }); - const hits = await manager.search(query, { maxResults: limit, caller: 'tool' }); + const hits = await manager.search(query, { + maxResults: limit, + caller: 'tool', + projectSubGraphName, + }); return this.json({ query, count: hits.length, @@ -2756,6 +2781,31 @@ export class DkgNodePlugin { // matching a CG whose id is the literal whitespace string. const trimmed = typeof args.context_graph_id === 'string' ? args.context_graph_id.trim() : ''; const contextGraphId = trimmed || undefined; + if (args.sub_graph_name !== undefined) { + if (typeof args.sub_graph_name !== 'string') { + return this.error('"sub_graph_name" must be a string.'); + } + if (args.sub_graph_name.trim() === '') { + return this.error('"sub_graph_name" must be a non-empty string.'); + } + } + const subGraphName = typeof args.sub_graph_name === 'string' + ? args.sub_graph_name.trim() + : undefined; + if (subGraphName && contextGraphId === undefined) { + return this.error('"sub_graph_name" requires "context_graph_id".'); + } + if (args.assertion_name !== undefined) { + if (typeof args.assertion_name !== 'string') { + return this.error('"assertion_name" must be a string.'); + } + if (args.assertion_name.trim() === '') { + return this.error('"assertion_name" must be a non-empty string.'); + } + } + const assertionName = typeof args.assertion_name === 'string' + ? args.assertion_name.trim() + : undefined; // Handler-side view validation (no JSON-schema enum, so strict-schema // hosts still surface these tailored errors). Use the shared // `GET_VIEWS` constant from `@origintrail-official/dkg-core` as the @@ -2781,6 +2831,9 @@ export class DkgNodePlugin { 'single CG; omit `view` for an unscoped cross-graph query.', ); } + if (assertionName && view !== 'working-memory') { + return this.error('"assertion_name" is only supported with "view: working-memory".'); + } // For WM reads the daemon requires an agentAddress (see // `resolveViewGraphs:60`). Accept an explicit `agent_address` on the // tool and fall back to this node's agent address — the same default @@ -2860,6 +2913,8 @@ export class DkgNodePlugin { contextGraphId, view, agentAddress, + subGraphName, + assertionName, }); return this.json(result); } catch (err: any) { diff --git a/packages/adapter-openclaw/src/tools/memory-tools.ts b/packages/adapter-openclaw/src/tools/memory-tools.ts index 811fa5b2d..d78d0a74b 100644 --- a/packages/adapter-openclaw/src/tools/memory-tools.ts +++ b/packages/adapter-openclaw/src/tools/memory-tools.ts @@ -97,6 +97,12 @@ export function buildMemoryTools(ctx: DkgToolHost): OpenClawTool[] { type: ['number', 'string'], description: 'Max hits to return. Integer in [1, 100]. Default 20.', }, + sub_graph_name: { + type: 'string', + description: + 'Optional project sub-graph scope. Applies only to project context graph fan-out; ' + + 'requires a currently selected project context graph.', + }, }, required: ['query'], }, diff --git a/packages/adapter-openclaw/src/tools/query-tools.ts b/packages/adapter-openclaw/src/tools/query-tools.ts index d81d79b8f..7b90e200d 100644 --- a/packages/adapter-openclaw/src/tools/query-tools.ts +++ b/packages/adapter-openclaw/src/tools/query-tools.ts @@ -90,6 +90,18 @@ export function buildQueryTools(ctx: DkgToolHost): OpenClawTool[] { 'writer-side identity falls back to peerId. Ignored for non-WM views. Supply an ' + 'explicit value to read another local agent\'s WM namespace in multi-agent deployments.', }, + sub_graph_name: { + type: 'string', + description: + 'Optional sub-graph scope within `context_graph_id`. May be combined with `view` ' + + 'to route WM, SWM, or VM reads to a project subgraph.', + }, + assertion_name: { + type: 'string', + description: + 'Optional Working Memory assertion name. Combine with `view: "working-memory"` ' + + 'and `context_graph_id` to read exactly one assertion graph.', + }, }, required: ['sparql'], }, diff --git a/packages/adapter-openclaw/src/types.ts b/packages/adapter-openclaw/src/types.ts index 9666c1c5e..b7ae9096c 100644 --- a/packages/adapter-openclaw/src/types.ts +++ b/packages/adapter-openclaw/src/types.ts @@ -258,6 +258,11 @@ export interface MemorySearchOptions { maxResults?: number; minScore?: number; sessionKey?: string; + /** + * Optional sub-graph scope for project-context fan-out only. Agent-context + * reads stay unscoped so local session memory remains visible. + */ + projectSubGraphName?: string; /** * T74 — Observability tag. Identifies the caller in the * `[dkg-memory] search fired (caller=…)` log line so operators can diff --git a/packages/adapter-openclaw/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index 61a39f568..cb8c793d5 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1026,6 +1026,159 @@ describe('DkgMemorySearchManager', () => { ); }); + it('applies projectSubGraphName only to project CG fan-out', async () => { + const querySpy = vi.spyOn(client, 'query').mockResolvedValue({ result: { bindings: [] } }); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'research-x' }), + }); + + await manager.search('hello world', { projectSubGraphName: 'skills' }); + + expect(querySpy).toHaveBeenCalledTimes(6); + const allOpts = querySpy.mock.calls.map(c => c[1]!); + const agentContextOpts = allOpts.filter(o => o.contextGraphId === AGENT_CONTEXT_GRAPH); + expect(agentContextOpts).toHaveLength(3); + expect(agentContextOpts.every(o => o.subGraphName === undefined)).toBe(true); + + const projectOpts = allOpts.filter(o => o.contextGraphId === 'research-x'); + expect(projectOpts).toHaveLength(3); + expect(projectOpts.every(o => o.subGraphName === 'skills')).toBe(true); + }); + + it('surfaces projectSubGraphName query failures instead of returning no hits', async () => { + const warn = vi.fn(); + const querySpy = vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { + if (opts?.subGraphName) { + throw new Error('Unknown sub-graph: skills'); + } + return { result: { bindings: [] } }; + }); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'research-x' }), + logger: { warn }, + }); + + await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) + .rejects.toThrow(/sub_graph_name "skills".*Unknown sub-graph: skills/i); + expect(querySpy).toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('project-wm search failed')); + }); + + it('surfaces camel-case subGraphName daemon validation errors', async () => { + vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { + if (opts?.subGraphName) { + throw new Error('Invalid subGraphName: Sub-graph names cannot contain "/"'); + } + return { result: { bindings: [] } }; + }); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'research-x' }), + }); + + await expect(manager.search('hello world', { projectSubGraphName: 'bad/name' })) + .rejects.toThrow(/sub_graph_name "bad\/name".*Invalid subGraphName/i); + }); + + it('keeps partial-success behavior when a projectSubGraphName layer succeeds', async () => { + const warn = vi.fn(); + vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { + if (opts?.subGraphName && opts.view === 'working-memory') { + throw new Error('fetch failed'); + } + if (opts?.subGraphName && opts.view === 'verified-memory') { + return { + result: { + bindings: [{ + uri: { value: 'urn:project:note' }, + text: { value: 'hello world from project memory' }, + }], + }, + }; + } + return { + result: { + bindings: [], + }, + }; + }); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'research-x' }), + logger: { warn }, + }); + + const hits = await manager.search('hello world', { projectSubGraphName: 'skills' }); + + expect(hits).toHaveLength(1); + expect(hits[0].source).toBe('memory'); + expect(hits[0].layer).toBe('project-vm'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('fetch failed')); + }); + + it('surfaces generic projectSubGraphName failures when every project-scoped layer fails', async () => { + const warn = vi.fn(); + vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { + if (opts?.subGraphName) { + throw new Error('fetch failed'); + } + return { result: { bindings: [] } }; + }); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'research-x' }), + logger: { warn }, + }); + + await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) + .rejects.toThrow(/memory_search failed:.*fetch failed/i); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('project-wm search failed')); + }); + + it('surfaces generic projectSubGraphName failures when every live layer fails', async () => { + const warn = vi.fn(); + vi.spyOn(client, 'query').mockRejectedValue(new Error('fetch failed')); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'research-x' }), + logger: { warn }, + }); + + await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) + .rejects.toThrow(/memory_search failed:.*fetch failed/i); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent-context-wm search failed')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('project-wm search failed')); + }); + + it('rejects projectSubGraphName when no project CG is resolved', async () => { + const querySpy = vi.spyOn(client, 'query').mockResolvedValue({ result: { bindings: [] } }); + const warn = vi.fn(); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver(), + logger: { warn }, + }); + + await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) + .rejects.toThrow(/projectSubGraphName.*project context graph/i); + expect(querySpy).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no project context graph')); + }); + + it('rejects projectSubGraphName when the project CG resolves to the agent-context URI', async () => { + const querySpy = vi.spyOn(client, 'query').mockResolvedValue({ result: { bindings: [] } }); + const manager = new DkgMemorySearchManager({ + client, + resolver: makeResolver({ projectContextGraphId: 'did:dkg:context-graph:agent-context' }), + }); + + await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) + .rejects.toThrow(/projectSubGraphName.*project context graph/i); + expect(querySpy).not.toHaveBeenCalled(); + }); + it('uses a permissive SPARQL shape — no rdf:type constraint, no specific predicate, literal-length floor', async () => { const querySpy = vi.spyOn(client, 'query').mockResolvedValue({ result: { bindings: [] } }); const manager = new DkgMemorySearchManager({ client, resolver: makeResolver() }); diff --git a/packages/adapter-openclaw/test/memory-integration.test.ts b/packages/adapter-openclaw/test/memory-integration.test.ts index 6395cea2d..96b7ee87f 100644 --- a/packages/adapter-openclaw/test/memory-integration.test.ts +++ b/packages/adapter-openclaw/test/memory-integration.test.ts @@ -182,5 +182,27 @@ describe('Memory integration round-trip (issue #199 Phase 1 + Phase 2)', () => { expect(call[1].agentAddress).toBeDefined(); } }); + + it('memory_search applies sub_graph_name only to project context graph fan-out', async () => { + const tool = tools.find((t) => t.name === 'memory_search')!; + (plugin as any).memorySessionResolver.getSession = () => ({ + agentAddress: '12D3KooWProjectPeer', + projectContextGraphId: 'project-cg', + }); + (plugin as any).memorySessionResolver.getDefaultAgentAddress = () => '12D3KooWProjectPeer'; + + await tool.execute('t1', { query: 'anything at all', sub_graph_name: 'imports' }); + + const agentCalls = mockQuery.mock.calls.filter((c) => c[1].contextGraphId === 'agent-context'); + const projectCalls = mockQuery.mock.calls.filter((c) => c[1].contextGraphId === 'project-cg'); + expect(agentCalls).toHaveLength(3); + expect(projectCalls).toHaveLength(3); + for (const call of agentCalls) { + expect(call[1].subGraphName).toBeUndefined(); + } + for (const call of projectCalls) { + expect(call[1].subGraphName).toBe('imports'); + } + }); }); }); diff --git a/packages/adapter-openclaw/test/memory-search-tool.test.ts b/packages/adapter-openclaw/test/memory-search-tool.test.ts index f8c52cb3b..b2adea25d 100644 --- a/packages/adapter-openclaw/test/memory-search-tool.test.ts +++ b/packages/adapter-openclaw/test/memory-search-tool.test.ts @@ -51,6 +51,7 @@ describe('memory_search tool', () => { const tool = tools.find((t) => t.name === 'memory_search')!; const params = tool.parameters as any; expect(params.properties.query).toBeDefined(); + expect(params.properties.sub_graph_name).toBeDefined(); expect(params.required).toContain('query'); }); @@ -102,6 +103,65 @@ describe('memory_search tool', () => { expect(typeof result).toBe('object'); }); + it('returns a clear error when sub_graph_name is supplied without a project context graph', async () => { + const tool = tools.find((t) => t.name === 'memory_search')!; + const client = (plugin as any).client; + client.query = vi.fn().mockResolvedValue({ result: { bindings: [] } }); + (plugin as any).memorySessionResolver.getSession = () => ({ + agentAddress: '12D3KooWReady', + projectContextGraphId: undefined, + }); + + const result = await tool.execute('t-subgraph-no-project', { + query: 'project memories', + sub_graph_name: 'imports', + }); + + expect((result as any).content?.[0]?.text ?? '').toMatch(/sub_graph_name.*project context graph/i); + expect(client.query).not.toHaveBeenCalled(); + }); + + it('returns a clear error when sub_graph_name is supplied with an agent-context URI', async () => { + const tool = tools.find((t) => t.name === 'memory_search')!; + const client = (plugin as any).client; + client.query = vi.fn().mockResolvedValue({ result: { bindings: [] } }); + (plugin as any).memorySessionResolver.getSession = () => ({ + agentAddress: '12D3KooWReady', + projectContextGraphId: 'did:dkg:context-graph:agent-context', + }); + + const result = await tool.execute('t-subgraph-agent-context-uri', { + query: 'project memories', + sub_graph_name: 'imports', + }); + + expect((result as any).content?.[0]?.text ?? '').toMatch(/sub_graph_name.*project context graph/i); + expect(client.query).not.toHaveBeenCalled(); + }); + + it('returns a clear error when a project-scoped sub_graph_name query fails', async () => { + const tool = tools.find((t) => t.name === 'memory_search')!; + const client = (plugin as any).client; + client.query = vi.fn().mockImplementation(async (_sparql: string, opts: any) => { + if (opts?.subGraphName) { + throw new Error('Unknown sub-graph: imports'); + } + return { result: { bindings: [] } }; + }); + (plugin as any).memorySessionResolver.getSession = () => ({ + agentAddress: '12D3KooWReady', + projectContextGraphId: 'project-cg', + }); + + const result = await tool.execute('t-subgraph-query-fail', { + query: 'project memories', + sub_graph_name: 'imports', + }); + + const error = (result as any).details?.error ?? ''; + expect(error).toMatch(/sub_graph_name "imports".*Unknown sub-graph: imports/i); + }); + it('returns "not ready" error when the resolver has no agent identity yet (R7.6 / T51)', async () => { const tool = tools.find((t) => t.name === 'memory_search')!; // Force resolver to surface no agent address (neither session-bound nor default). diff --git a/packages/adapter-openclaw/test/plugin.part-03.test.ts b/packages/adapter-openclaw/test/plugin.part-03.test.ts index 54edc1a58..75fded5ac 100644 --- a/packages/adapter-openclaw/test/plugin.part-03.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-03.test.ts @@ -194,6 +194,72 @@ describe("DkgNodePlugin", () => { expect(body.agentAddress).toBe(ethChecksum); }); + it('dkg_query forwards sub_graph_name with view-based routing', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ ok: true }); + await byName.get('dkg_query')!.execute('tc', { + sparql: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1', + context_graph_id: 'my-cg', + view: 'working-memory', + sub_graph_name: 'protocols', + assertion_name: 'chat-turns', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.contextGraphId).toBe('my-cg'); + expect(body.view).toBe('working-memory'); + expect(body.subGraphName).toBe('protocols'); + expect(body.assertionName).toBe('chat-turns'); + }); + + it('dkg_query rejects non-string sub_graph_name instead of silently dropping the scope', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ ok: true }); + const result = await byName.get('dkg_query')!.execute('tc', { + sparql: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1', + context_graph_id: 'my-cg', + view: 'verified-memory', + sub_graph_name: 42, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('sub_graph_name'); + expect(result.content[0].text).toContain('string'); + }); + + it('dkg_query rejects sub_graph_name without context_graph_id instead of running unscoped', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ ok: true }); + const result = await byName.get('dkg_query')!.execute('tc', { + sparql: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1', + sub_graph_name: 'protocols', + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('sub_graph_name'); + expect(result.content[0].text).toContain('context_graph_id'); + }); + + it('dkg_query rejects non-string assertion_name instead of silently dropping the scope', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ ok: true }); + const result = await byName.get('dkg_query')!.execute('tc', { + sparql: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1', + context_graph_id: 'my-cg', + view: 'working-memory', + assertion_name: 42, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('assertion_name'); + expect(result.content[0].text).toContain('string'); + }); + + it('dkg_query rejects assertion_name outside working-memory so the scope is not ignored', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ ok: true }); + const result = await byName.get('dkg_query')!.execute('tc', { + sparql: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1', + context_graph_id: 'my-cg', + view: 'verified-memory', + assertion_name: 'chat-turns', + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('assertion_name'); + expect(result.content[0].text).toContain('working-memory'); + }); it('dkg_query rejects a whitespace-only agent_address (same silent-namespace-swap risk as non-string)', async () => { // An explicitly-supplied whitespace string is still "caller meant diff --git a/packages/adapter-openclaw/test/plugin.part-06.test.ts b/packages/adapter-openclaw/test/plugin.part-06.test.ts index 67e947f08..fc677a4a2 100644 --- a/packages/adapter-openclaw/test/plugin.part-06.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-06.test.ts @@ -437,6 +437,10 @@ describe("DkgNodePlugin", () => { // agent_address is exposed as an optional tool param for WM targeting. expect(queryProps.agent_address.type).toBe('string'); expect(queryProps.agent_address.description).toMatch(/working-memory/i); + expect(queryProps.sub_graph_name.type).toBe('string'); + expect(queryProps.sub_graph_name.description).toMatch(/sub-graph/i); + expect(queryProps.assertion_name.type).toBe('string'); + expect(queryProps.assertion_name.description).toMatch(/assertion/i); const inviteTool = byName.get('dkg_context_graph_invite')!; expect(inviteTool.description).toMatch(/primary user-facing deliverable/i); diff --git a/packages/agent/src/dkg-agent-endorse.ts b/packages/agent/src/dkg-agent-endorse.ts index 1cf231185..44d7f3248 100644 --- a/packages/agent/src/dkg-agent-endorse.ts +++ b/packages/agent/src/dkg-agent-endorse.ts @@ -18,6 +18,7 @@ import { contextGraphSharedMemoryUri, contextGraphVerifiedMemoryUri, contextGraphVerifiedMemoryMetaUri, contextGraphDataUri, contextGraphMetaUri, assertionLifecycleUri, contextGraphAssertionUri, + contextGraphSubGraphUri, deriveCuratorDidFromCgId, MemoryLayer, computeACKDigest, @@ -448,6 +449,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { signers: string[]; status: 'verified' | 'partial' | 'no_quorum'; trustLevel: TrustLevel; + subGraphName?: string; }> { const ctx = createOperationContext('verify'); @@ -459,7 +461,13 @@ export class EndorseVerifyMethods extends DKGAgentBase { for (const ns of dkgNamespaces) { for (const literal of [`"${opts.batchId}"^^`, `"${opts.batchId}"`]) { const r = await this.store.query( - `SELECT ?root WHERE { GRAPH <${metaGraph}> { ?kc <${ns}merkleRoot> ?root . ?kc <${ns}batchId> ${literal} } } LIMIT 1`, + `SELECT ?root ?sgName WHERE { + GRAPH <${metaGraph}> { + ?kc <${ns}merkleRoot> ?root . + ?kc <${ns}batchId> ${literal} . + OPTIONAL { ?kc <${ns}subGraphName> ?sgName } + } + } LIMIT 1`, ); if (r.type === 'bindings' && r.bindings.length > 0) { batchBindings = r.bindings as Record[]; @@ -476,6 +484,13 @@ export class EndorseVerifyMethods extends DKGAgentBase { const merkleRoot = ethers.getBytes( merkleRootValue.startsWith('0x') ? merkleRootValue : `0x${merkleRootValue}`, ); + const subGraphName = batchBindings[0]['sgName'] ? stripLiteral(batchBindings[0]['sgName']) : undefined; + if (subGraphName) { + await this.assertSubGraphDoesNotCollideWithKnownChildContextGraph(opts.contextGraphId, subGraphName); + } + const batchDataGraph = subGraphName + ? contextGraphSubGraphUri(opts.contextGraphId, subGraphName) + : contextGraphDataGraphUri(opts.contextGraphId); // 2. Look up context graph on-chain config const onChainId = await this.getContextGraphOnChainId(opts.contextGraphId); @@ -685,8 +700,9 @@ export class EndorseVerifyMethods extends DKGAgentBase { await this.stampBatchTrustLevel( opts.contextGraphId, opts.batchId, - contextGraphDataGraphUri(opts.contextGraphId), + batchDataGraph, trustLevel, + subGraphName, ); this.log.info( ctx, @@ -700,6 +716,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { signers: resolvedSignerAddresses, status, trustLevel, + ...(subGraphName ? { subGraphName } : {}), }; } @@ -740,6 +757,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { txResult.hash, txResult.blockNumber, resolvedSignerAddresses, + subGraphName, ); this.log.info(ctx, `Verified batch ${opts.batchId} → _verified_memory/${opts.verifiedMemoryId} (tx=${txResult.hash.slice(0, 16)}...)`); @@ -751,6 +769,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { signers: resolvedSignerAddresses, status: 'verified', trustLevel: TrustLevel.ConsensusVerified, + ...(subGraphName ? { subGraphName } : {}), }; } @@ -780,14 +799,20 @@ export class EndorseVerifyMethods extends DKGAgentBase { txHash: string, blockNumber: number, signers: string[], + subGraphName?: string, ): Promise { + if (subGraphName) { + await this.assertSubGraphDoesNotCollideWithKnownChildContextGraph(contextGraphId, subGraphName); + } // Query only the triples belonging to this batch via root entities in _meta const rootEntities = await this.getRootEntities(contextGraphId, batchId); if (rootEntities.length === 0) { this.log.warn(createOperationContext('verify'), `No root entities found for batch ${batchId} — skipping VM promotion`); return; } - const dataGraph = assertSafeIri(contextGraphDataGraphUri(contextGraphId)); + const dataGraph = assertSafeIri(subGraphName + ? contextGraphSubGraphUri(contextGraphId, subGraphName) + : contextGraphDataGraphUri(contextGraphId)); // Query root entities AND their skolemized children (subjects starting // with the root entity URI, e.g. /.well-known/genid/...). // We use FILTER with STRSTARTS to capture the full closure instead of @@ -800,7 +825,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { ); if (result.type !== 'bindings') return; - const vmGraph = assertSafeIri(contextGraphVerifiedMemoryUri(contextGraphId, verifiedMemoryId)); + const vmGraph = assertSafeIri(contextGraphVerifiedMemoryUri(contextGraphId, verifiedMemoryId, subGraphName)); const vmQuads: Quad[] = (result.bindings as Record[]) .filter(row => !isTrustLevelQuad({ predicate: row.p })) .map(row => ({ @@ -819,7 +844,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { ); // Write verification metadata - const vmMetaGraph = contextGraphVerifiedMemoryMetaUri(contextGraphId, verifiedMemoryId); + const vmMetaGraph = contextGraphVerifiedMemoryMetaUri(contextGraphId, verifiedMemoryId, subGraphName); const metaQuads = buildVerificationMetadata({ contextGraphId, verifiedMemoryId, @@ -833,19 +858,52 @@ export class EndorseVerifyMethods extends DKGAgentBase { await this.store.insert(metaQuads); } + async assertSubGraphDoesNotCollideWithKnownChildContextGraph( + this: DKGAgent, + contextGraphId: string, + subGraphName: string, + ): Promise { + const subGraphUri = assertSafeIri(contextGraphSubGraphUri(contextGraphId, subGraphName)); + const childMetaGraph = assertSafeIri(contextGraphMetaUri(`${contextGraphId}/${subGraphName}`)); + const result = await this.store.query( + `SELECT ?marker WHERE { + GRAPH <${childMetaGraph}> { + { + <${subGraphUri}> . + BIND("type" AS ?marker) + } + UNION + { + <${subGraphUri}> ?marker . + } + } + } LIMIT 1`, + ); + if (result.type !== 'bindings' || result.bindings.length === 0) return; + + throw new Error( + `subGraphName "${subGraphName}" for contextGraphId "${contextGraphId}" resolves to a known child context graph ` + + `"${contextGraphId}/${subGraphName}". Verify the child context graph directly or choose a different sub-graph name.`, + ); + } + async stampBatchTrustLevel(this: DKGAgent, contextGraphId: string, batchId: bigint, graph: string, level: TrustLevel, + subGraphName?: string, ): Promise { - const subjects = await this.getBatchSubjects(contextGraphId, batchId); + const subjects = await this.getBatchSubjects(contextGraphId, batchId, subGraphName); await this.stampTrustLevel(graph, subjects, level); } - async getBatchSubjects(this: DKGAgent, contextGraphId: string, batchId: bigint): Promise { + async getBatchSubjects(this: DKGAgent, contextGraphId: string, batchId: bigint, subGraphName?: string): Promise { const rootEntities = await this.getRootEntities(contextGraphId, batchId); - return this.getSubjectsForRoots(contextGraphDataGraphUri(contextGraphId), rootEntities); + const dataGraph = subGraphName + ? contextGraphSubGraphUri(contextGraphId, subGraphName) + : contextGraphDataGraphUri(contextGraphId); + return this.getSubjectsForRoots(dataGraph, rootEntities); } async getRootEntities(this: DKGAgent, contextGraphId: string, batchId: bigint): Promise { diff --git a/packages/agent/test/endorse.test.ts b/packages/agent/test/endorse.test.ts index 8bebf86f8..63f745b94 100644 --- a/packages/agent/test/endorse.test.ts +++ b/packages/agent/test/endorse.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { contextGraphSubGraphUri } from '@origintrail-official/dkg-core'; import { buildEndorsementQuads, DKG_ENDORSES, DKG_ENDORSED_AT } from '../src/endorse.js'; +import { EndorseVerifyMethods } from '../src/dkg-agent-endorse.js'; describe('buildEndorsementQuads', () => { it('produces correct endorsement triples', () => { @@ -44,3 +46,47 @@ describe('buildEndorsementQuads', () => { ).toThrow(/Unsafe or empty IRI value/); }); }); + +describe('verified-memory sub-graph promotion guards', () => { + it('rejects a subGraphName that aliases a known child context graph', async () => { + const fakeAgent = { + store: { + query: async () => ({ + type: 'bindings', + bindings: [{ marker: '"type"' }], + }), + }, + }; + + await expect( + EndorseVerifyMethods.prototype.assertSubGraphDoesNotCollideWithKnownChildContextGraph.call( + fakeAgent as any, + 'research', + 'code', + ), + ).rejects.toThrow(/known child context graph/); + }); + + it('allows non-colliding subGraphName values', async () => { + const queries: string[] = []; + const fakeAgent = { + store: { + query: async (sparql: string) => { + queries.push(sparql); + return { type: 'bindings', bindings: [] }; + }, + }, + }; + + await expect( + EndorseVerifyMethods.prototype.assertSubGraphDoesNotCollideWithKnownChildContextGraph.call( + fakeAgent as any, + 'research', + 'code', + ), + ).resolves.toBeUndefined(); + + expect(queries[0]).toContain(contextGraphSubGraphUri('research', 'code')); + expect(queries[0]).toContain('did:dkg:context-graph:research/code/_meta'); + }); +}); diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 53f64ae26..8a9d5f56f 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -179,7 +179,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_share` | `POST /api/shared-memory/write` | Directly write concise team-visible knowledge to SWM without staging a WM assertion. Prefer the WM assertion → promote flow for durable/canonical work. Both Hermes and OpenClaw expose the same tool schema (required `content` and `context_graph_id`, optional `sub_graph_name`), so MCP-discovered call signatures are portable. The OpenClaw implementation additionally validates content as non-whitespace, mints a unique subject per share (returned in the response), and N-Triples-quotes content; Hermes is currently looser on those points — the parallel hardening is tracked in OriginTrail/dkg#414. | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | -| `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | +| `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer, and optional `sub_graph_name` / `subGraphName` to narrow that layer to a registered sub-graph. When `view` is set, `context_graph_id` is required. For WM reads, optional `agent_address` targets another agent's WM; if omitted, the daemon uses the authenticated caller or node-default agent. Optional `assertion_name` / `assertionName` is accepted only with `view: "working-memory"` and narrows WM to one assertion. Omit `view` for a legacy cross-graph data-path query. | | `dkg_query_catalog_list` | `POST /api/profile/query-catalog/read` | List saved SPARQL queries declared in the project profile query catalog | | `dkg_query_catalog_run` | `POST /api/profile/query-catalog/read` + `POST /api/query` | Run a saved catalog query by slug or exact display name | | `dkg_query_catalog_save` | `POST /api/profile/query-catalog/write` | Save a read-only SPARQL query into the project profile query catalog | @@ -280,9 +280,9 @@ The `memory_search` tool is the recommended entry point for free-text memory rec - `sparql` (required) — the query string - `contextGraphId` — scope query to one CG (recommended) - `view` — `working-memory` | `shared-working-memory` | `verified-memory` - - `agentAddress` — required when `view: "working-memory"` (WM is per-agent) - - `assertionName` — scope to a specific WM assertion graph - - `subGraphName` — scope to a specific sub-graph + - `agentAddress` — optional for `view: "working-memory"` self reads (defaults to the authenticated caller or node-default agent); provide it when intentionally reading another local agent's WM + - `assertionName` — scope to a specific WM assertion graph; only valid with `view: "working-memory"` and may be combined with `subGraphName` + - `subGraphName` — scope the selected route to a registered sub-graph. With `view`, this targets sub-graph WM assertions, sub-graph SWM, or sub-graph VM/public data instead of the CG root. - `graphSuffix` — advanced: target a specific internal graph (e.g. `_shared_memory`, `_meta`) - `includeSharedMemory` / `includeWorkspace` — merge SWM into the result set - `verifiedGraph` — target a specific VM (on-chain) named graph @@ -527,7 +527,7 @@ A **sub-graph** is a named partition inside a context graph. Use them to organiz - `POST /api/sub-graph/create` — register a new sub-graph. Body: `{ contextGraphId, subGraphName }`. - `GET /api/sub-graph/list?contextGraphId=...` — list all sub-graphs registered in a CG. -To put an assertion in a sub-graph, pass `subGraphName` on `/api/assertion/create`, `/write`, `/query`, `/promote`, `/discard`, `/import-file`, `/history`, and on `/api/query` when scoping queries. +To put an assertion in a sub-graph, pass `subGraphName` on `/api/assertion/create`, `/write`, `/query`, `/promote`, `/discard`, `/import-file`, `/history`, and on `/api/query` when scoping queries. `/api/query` accepts `subGraphName` both on the legacy no-`view` path and with `view` routing; with `view: "working-memory"`, combine it with `assertionName` when you need exactly one assertion graph. ### Participants and join flow @@ -758,9 +758,9 @@ This entire surface was empirically driven by [PR #720](https://github.com/Origi **Query across layers:** -- Working memory: `{"sparql": "...", "view": "working-memory", "agentAddress": "...", "contextGraphId": "..."}` -- Shared memory: `{"sparql": "...", "contextGraphId": "...", "view": "shared-working-memory"}` -- Verified memory: `{"sparql": "...", "contextGraphId": "...", "view": "verified-memory"}` +- Working memory: `{"sparql": "...", "view": "working-memory", "contextGraphId": "...", "subGraphName": "...", "assertionName": "..."}` +- Shared memory: `{"sparql": "...", "contextGraphId": "...", "view": "shared-working-memory", "subGraphName": "..."}` +- Verified memory: `{"sparql": "...", "contextGraphId": "...", "view": "verified-memory", "subGraphName": "..."}` **List and inspect your assertions:** diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index b6fbf0000..01c82b911 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -406,7 +406,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { parsed.includeSharedMemory ?? parsed.includeWorkspace; const includeContextGraphPartitions = parsed.includeContextGraphPartitions === true; const view = parsed.view; - const agentAddress = parsed.agentAddress; + const requestedAgentAddress = parsed.agentAddress; // the // RFC-29 multi-agent WM isolation gate is fail-closed by default. // For cross-agent `view: 'working-memory'` reads on nodes with @@ -427,6 +427,9 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { const verifiedGraph = parsed.verifiedGraph; const assertionName = parsed.assertionName; const subGraphName = parsed.subGraphName; + if (requestedAgentAddress !== undefined && typeof requestedAgentAddress !== 'string') { + return jsonResponse(res, 400, { error: 'agentAddress must be a string when provided' }); + } // P-13: accept `minTrust` as a string ("SelfAttested"|"Endorsed"| // "PartiallyVerified"|"ConsensusVerified") or the matching integer // (0..3). Unrecognised values fail closed with a 400 rather than @@ -453,6 +456,38 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { error: `Invalid view "${view}". Supported: ${GET_VIEWS.join(", ")}`, }); } + if (assertionName !== undefined) { + if (typeof assertionName !== 'string') { + return jsonResponse(res, 400, { error: 'assertionName must be a string when provided' }); + } + if (!assertionName.trim()) { + return jsonResponse(res, 400, { error: 'assertionName must be a non-empty string when provided' }); + } + const assertionValidation = validateAssertionName(assertionName); + if (!assertionValidation.valid) { + return jsonResponse(res, 400, { error: `Invalid assertionName: ${assertionValidation.reason}` }); + } + if (view !== 'working-memory') { + return jsonResponse(res, 400, { + error: 'assertionName is only supported with view "working-memory"', + }); + } + } + if (subGraphName !== undefined) { + if (typeof subGraphName !== 'string') { + return jsonResponse(res, 400, { error: 'subGraphName must be a string when provided' }); + } + if (!subGraphName.trim()) { + return jsonResponse(res, 400, { error: 'subGraphName must be a non-empty string when provided' }); + } + const subGraphValidation = validateSubGraphName(subGraphName); + if (!subGraphValidation.valid) { + return jsonResponse(res, 400, { error: `Invalid subGraphName: ${subGraphValidation.reason}` }); + } + if (!contextGraphId) { + return jsonResponse(res, 400, { error: 'subGraphName requires contextGraphId' }); + } + } // PR #239 Codex iter-7: gate minTrust normalization/validation behind // view === 'verified-memory'. Upstream `resolveViewGraphs()` already // ignores `minTrust` outside VM, so the HTTP layer must match that — @@ -554,10 +589,11 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { && validTokens.has(requestToken) && callerAgentAddress === undefined; const hasRecognisedIdentity = isAdminToken || callerAgentAddress !== undefined; + const effectiveAgentAddress = requestedAgentAddress; if ( !hasRecognisedIdentity && view === 'working-memory' && - typeof agentAddress === 'string' + typeof effectiveAgentAddress === 'string' ) { // Codex (iteration 4): the daemon's canonical "own WM" identity is // whatever `agent.resolveAgentAddress(undefined)` returns — i.e. @@ -567,7 +603,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { // so we must accept both the default agent address *and* the bare // peerId as self, otherwise an auth-disabled self-read via the // legacy alias now 403s where it used to return the node's own WM. - const targetLower = agentAddress.toLowerCase(); + const targetLower = effectiveAgentAddress.toLowerCase(); const selfAliasesLower = new Set(); const defaultAgent = agent.getDefaultAgentAddress(); if (defaultAgent) selfAliasesLower.add(defaultAgent.toLowerCase()); @@ -575,7 +611,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { if (selfAliasesLower.size === 0 || !selfAliasesLower.has(targetLower)) { return jsonResponse(res, 403, { error: - `working-memory reads for agentAddress=${agentAddress} require authentication. ` + + `working-memory reads for agentAddress=${effectiveAgentAddress} require authentication. ` + `An unauthenticated / auth-disabled caller may only read the node-default agent's WM ` + `(accepted self-aliases: defaultAgentAddress and the node's peerId).`, }); @@ -587,7 +623,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { includeSharedMemory, includeContextGraphPartitions, view, - agentAddress, + agentAddress: effectiveAgentAddress, verifiedGraph, assertionName, subGraphName, @@ -987,6 +1023,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { emitMemoryGraphChanged?.({ contextGraphId, layers: ["vm"], + ...(result.subGraphName ? { subGraphName: result.subGraphName } : {}), operation: "verified_memory_updated", source: "api", }); @@ -994,6 +1031,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { emitMemoryGraphChanged?.({ contextGraphId, layers: ["wm"], + ...(result.subGraphName ? { subGraphName: result.subGraphName } : {}), operation: "trust_metadata_updated", source: "api", }); diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index 55f2ea393..cccccc30f 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -53,7 +53,16 @@ function makeTracker() { }; } -function makeCtx(agent: Record, body: Record, res = makeRes()): { +function makeCtx( + agent: Record, + body: Record, + res = makeRes(), + opts: { + requestToken?: string; + requestAgentAddress?: string; + validTokens?: string[]; + } = {}, +): { ctx: RequestContext; res: FakeRes; } { @@ -62,10 +71,11 @@ function makeCtx(agent: Record, body: Record, res: res as unknown as ServerResponse, agent, tracker: makeTracker(), - validTokens: new Set(), + validTokens: new Set(opts.validTokens ?? []), path: '/api/query', url: new URL('http://127.0.0.1/api/query'), - requestToken: undefined, + requestToken: opts.requestToken, + requestAgentAddress: opts.requestAgentAddress, } as unknown as RequestContext; return { ctx, res }; } @@ -129,4 +139,303 @@ describe('handleQueryRoutes /api/query', () => { await expect(handleQueryRoutes(ctx)).rejects.toThrow('Database connection lost'); expect(res.statusCode).not.toBe(400); }); + + it('leaves omitted working-memory agentAddress to the agent while forwarding the authenticated caller', async () => { + const caller = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'working-memory', + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(200); + expect(agent.query).toHaveBeenCalledTimes(1); + const queryOptions = agent.query.mock.calls[0][1]; + expect(queryOptions).toMatchObject({ + contextGraphId: 'research', + view: 'working-memory', + callerAgentAddress: caller, + }); + expect(queryOptions).toHaveProperty('agentAddress', undefined); + }); + + it('leaves omitted working-memory agentAddress to the agent for unauthenticated callers', async () => { + const defaultAgent = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn(), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(defaultAgent), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'working-memory', + }, + makeRes(), + { + requestAgentAddress: defaultAgent, + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(200); + expect(agent.query).toHaveBeenCalledTimes(1); + const queryOptions = agent.query.mock.calls[0][1]; + expect(queryOptions).toMatchObject({ + contextGraphId: 'research', + view: 'working-memory', + }); + expect(queryOptions).toHaveProperty('agentAddress', undefined); + expect(queryOptions).toHaveProperty('callerAgentAddress', undefined); + }); + + it('leaves omitted working-memory agentAddress to the agent for node-admin callers', async () => { + const defaultAgent = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(undefined), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(defaultAgent), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'working-memory', + }, + makeRes(), + { + requestToken: 'admin-token', + requestAgentAddress: defaultAgent, + validTokens: ['admin-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(200); + expect(agent.query).toHaveBeenCalledTimes(1); + const queryOptions = agent.query.mock.calls[0][1]; + expect(queryOptions).toMatchObject({ + contextGraphId: 'research', + view: 'working-memory', + }); + expect(queryOptions).toHaveProperty('agentAddress', undefined); + expect(queryOptions).toHaveProperty('callerAgentAddress', undefined); + }); + + it('rejects present non-string agentAddress instead of inferring it', async () => { + const caller = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'working-memory', + agentAddress: null, + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/agentAddress/); + expect(agent.query).not.toHaveBeenCalled(); + }); + + it('rejects present non-string subGraphName before calling the agent', async () => { + const caller = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'verified-memory', + subGraphName: 42, + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/subGraphName/); + expect(agent.query).not.toHaveBeenCalled(); + }); + + it('rejects invalid assertionName before calling the agent', async () => { + const caller = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'working-memory', + assertionName: 'probe/sibling', + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/Invalid assertionName/); + expect(agent.query).not.toHaveBeenCalled(); + }); + + it('rejects subGraphName without contextGraphId before calling the agent', async () => { + const caller = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + subGraphName: 'code', + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/subGraphName requires contextGraphId/); + expect(agent.query).not.toHaveBeenCalled(); + }); + + it('forwards view, subGraphName, and assertionName to the agent query route', async () => { + const caller = '0x2222222222222222222222222222222222222222'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'working-memory', + agentAddress: caller, + subGraphName: 'code', + assertionName: 'probe', + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(200); + expect(agent.query).toHaveBeenCalledTimes(1); + expect(agent.query.mock.calls[0][1]).toMatchObject({ + contextGraphId: 'research', + view: 'working-memory', + agentAddress: caller, + subGraphName: 'code', + assertionName: 'probe', + callerAgentAddress: caller, + }); + }); + + it('rejects assertionName outside working-memory so the scope is not ignored', async () => { + const caller = '0x2222222222222222222222222222222222222222'; + const agent = { + resolveAgentByToken: vi.fn().mockReturnValue(caller), + query: vi.fn().mockResolvedValue({ bindings: [] }), + getDefaultAgentAddress: vi.fn().mockReturnValue(caller), + peerId: '12D3KooWself', + }; + const { ctx, res } = makeCtx( + agent, + { + sparql: 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 1', + contextGraphId: 'research', + view: 'verified-memory', + assertionName: 'probe', + }, + makeRes(), + { + requestToken: 'agent-token', + requestAgentAddress: caller, + validTokens: ['agent-token'], + }, + ); + + await handleQueryRoutes(ctx); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/assertionName.*working-memory/); + expect(agent.query).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/test/memory-graph-events.test.ts b/packages/cli/test/memory-graph-events.test.ts index 01c6a0bad..f0d64ec77 100644 --- a/packages/cli/test/memory-graph-events.test.ts +++ b/packages/cli/test/memory-graph-events.test.ts @@ -699,7 +699,7 @@ describe('daemon memory_graph_changed route emissions', () => { it('emits VM refresh events after verified-memory verification', async () => { const emitMemoryGraphChanged = vi.fn(); - const verify = vi.fn().mockResolvedValue({ verified: true, status: 'verified' }); + const verify = vi.fn().mockResolvedValue({ verified: true, status: 'verified', subGraphName: 'notes' }); const ctx = createContext('/api/verify', { contextGraphId: 'project-a', verifiedMemoryId: 'vm-1', @@ -712,7 +712,7 @@ describe('daemon memory_graph_changed route emissions', () => { await handleQueryRoutes(ctx); expect((ctx.res as unknown as { statusCode: number }).statusCode).toBe(200); - expect(responseBody(ctx)).toMatchObject({ verified: true, batchId: '42' }); + expect(responseBody(ctx)).toMatchObject({ verified: true, batchId: '42', subGraphName: 'notes' }); expect(verify).toHaveBeenCalledWith({ contextGraphId: 'project-a', verifiedMemoryId: 'vm-1', @@ -723,6 +723,7 @@ describe('daemon memory_graph_changed route emissions', () => { expect(emitMemoryGraphChanged).toHaveBeenCalledWith({ contextGraphId: 'project-a', layers: ['vm'], + subGraphName: 'notes', operation: 'verified_memory_updated', source: 'api', }); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 2f7877863..f8547c3b0 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -224,11 +224,13 @@ export function contextGraphSharedMemoryMetaUri(contextGraphId: string, subGraph return `did:dkg:context-graph:${contextGraphId}/_shared_memory_meta`; } -export function contextGraphVerifiedMemoryUri(contextGraphId: string, verifiedMemoryId: string): string { +export function contextGraphVerifiedMemoryUri(contextGraphId: string, verifiedMemoryId: string, subGraphName?: string): string { + if (subGraphName) return `did:dkg:context-graph:${contextGraphId}/${subGraphName}/_verified_memory/${verifiedMemoryId}`; return `did:dkg:context-graph:${contextGraphId}/_verified_memory/${verifiedMemoryId}`; } -export function contextGraphVerifiedMemoryMetaUri(contextGraphId: string, verifiedMemoryId: string): string { +export function contextGraphVerifiedMemoryMetaUri(contextGraphId: string, verifiedMemoryId: string, subGraphName?: string): string { + if (subGraphName) return `did:dkg:context-graph:${contextGraphId}/${subGraphName}/_verified_memory/${verifiedMemoryId}/_meta`; return `did:dkg:context-graph:${contextGraphId}/_verified_memory/${verifiedMemoryId}/_meta`; } diff --git a/packages/core/test/v10-constants.test.ts b/packages/core/test/v10-constants.test.ts index 3e3741e54..128c0a581 100644 --- a/packages/core/test/v10-constants.test.ts +++ b/packages/core/test/v10-constants.test.ts @@ -147,10 +147,12 @@ describe('V10 named graph URIs', () => { it('verified memory URI', () => { expect(contextGraphVerifiedMemoryUri(id, '7')).toBe('did:dkg:context-graph:42/_verified_memory/7'); + expect(contextGraphVerifiedMemoryUri(id, '7', 'code')).toBe('did:dkg:context-graph:42/code/_verified_memory/7'); }); it('verified memory meta URI', () => { expect(contextGraphVerifiedMemoryMetaUri(id, '7')).toBe('did:dkg:context-graph:42/_verified_memory/7/_meta'); + expect(contextGraphVerifiedMemoryMetaUri(id, '7', 'code')).toBe('did:dkg:context-graph:42/code/_verified_memory/7/_meta'); }); it('assertion URI', () => { diff --git a/packages/mcp-dkg/package.json b/packages/mcp-dkg/package.json index 5a4d72bdd..4ef8998c8 100644 --- a/packages/mcp-dkg/package.json +++ b/packages/mcp-dkg/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1", + "@origintrail-official/dkg-core": "workspace:*", "zod": "^3.25", "yaml": "^2.6.0" }, diff --git a/packages/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index 5a38ded47..e38511424 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -14,6 +14,7 @@ * can see through MCP with the same canonical queries. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { toEip55Checksum, validateSubGraphName } from '@origintrail-official/dkg-core'; import { z } from 'zod'; import type { DkgClient, ProjectRow } from './client.js'; import type { DkgConfig } from './config.js'; @@ -42,6 +43,40 @@ const err = (text: string): ToolResult => ({ const formatError = (e: unknown): string => e instanceof Error ? e.message : String(e); +const AGENT_DID_PREFIX = 'did:dkg:agent:'; +const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +function normalizeAgentAddressForQuery( + agentAddress: string | undefined, + view: 'working-memory' | 'shared-working-memory' | 'verified-memory' | undefined, +): string | undefined { + if (agentAddress === undefined) return undefined; + if (view !== 'working-memory') return agentAddress; + const trimmed = agentAddress.trim(); + if (!trimmed) { + throw new Error('"agentAddress" must be a non-empty string.'); + } + const stripped = trimmed.startsWith(AGENT_DID_PREFIX) + ? trimmed.slice(AGENT_DID_PREFIX.length) + : trimmed; + return ETH_ADDRESS_RE.test(stripped) + ? toEip55Checksum(stripped) + : stripped; +} + +function normalizeSubGraphNameForQuery(subGraphName: string | undefined): string | undefined { + if (subGraphName === undefined) return undefined; + const trimmed = subGraphName.trim(); + if (!trimmed) { + throw new Error('"subGraphName" must be a non-empty string.'); + } + const validation = validateSubGraphName(trimmed); + if (!validation.valid) { + throw new Error(`Invalid subGraphName: ${validation.reason}`); + } + return trimmed; +} + /** * Resolve the contextGraphId for a tool invocation. Argument beats * config default; if neither is present we return null and the tool @@ -199,6 +234,14 @@ export function registerReadTools( .optional() .describe(`${EXISTING_CONTEXT_GRAPH_ID_DESCRIPTION} Defaults to .dkg/config.yaml.`), subGraphName: z.string().optional().describe('Limit the query to a single sub-graph'), + assertionName: z + .string() + .optional() + .describe('Optional Working Memory assertion name for view: "working-memory" reads.'), + agentAddress: z + .string() + .optional() + .describe('Optional Working Memory owner address for view: "working-memory" reads.'), view: z .enum(['working-memory', 'shared-working-memory', 'verified-memory']) .optional() @@ -210,15 +253,26 @@ export function registerReadTools( limit: z.number().optional().describe('Row cap when rendering to markdown; does NOT modify the query'), }, }, - async ({ sparql, projectId, subGraphName, view, includeSharedMemory, limit }): Promise => { + async ({ sparql, projectId, subGraphName, assertionName, agentAddress, view, includeSharedMemory, limit }): Promise => { const pid = resolveProject(projectId, config); if (!pid) return projectErr(); const fullSparql = sparql.startsWith('PREFIX') ? sparql : `${PREFIXES}\n${sparql}`; try { + const scopedAssertionName = assertionName?.trim(); + if (assertionName !== undefined && !scopedAssertionName) { + return err('"assertionName" must be a non-empty string.'); + } + if (scopedAssertionName && view !== 'working-memory') { + return err('"assertionName" is only supported with view: "working-memory".'); + } + const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress, view); + const normalizedSubGraphName = normalizeSubGraphNameForQuery(subGraphName); const result = await client.query({ sparql: fullSparql, contextGraphId: pid, - subGraphName, + subGraphName: normalizedSubGraphName, + assertionName: scopedAssertionName, + agentAddress: normalizedAgentAddress, view, includeSharedMemory, }); diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index d9cacf86a..fbb500d45 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -1,7 +1,7 @@ /** * `dkg_memory_search` — trust-weighted, multi-tier, multi-CG-fan-out * recall over agent-context WM/SWM/VM (and the project CG's matching - * layers when supplied). + * layers when supplied or pinned as the default project). * * Per parity-matrix v0.7 §4.19: re-implementation of the adapter's * `DkgMemorySearchManager` (`packages/adapter-openclaw/src/DkgMemoryPlugin.ts`). @@ -44,6 +44,26 @@ const errResult = (text: string): ToolResult => ({ const formatError = (e: unknown): string => e instanceof Error ? e.message : String(e); +function isScopedQueryRoutingError(message: string): boolean { + const text = message.toLowerCase(); + const mentionsSubGraph = text.includes('sub-graph') || + text.includes('subgraphname') || + text.includes('sub_graph_name'); + return text.includes('scoped query violation') || + text.includes('known child context graph') || + text.includes('unknown sub-graph') || + text.includes('unknown subgraph') || + ( + mentionsSubGraph && + ( + text.includes('registered') || + text.includes('invalid') || + text.includes('requires') || + text.includes('not found') + ) + ); +} + // ── Layer model ───────────────────────────────────────────────────── // Source of truth: `packages/adapter-openclaw/src/types.ts:217-223`. type MemoryLayer = @@ -82,6 +102,19 @@ const TRUST_ORDER: Record = { const AGENT_CONTEXT_GRAPH = 'agent-context'; const AGENT_DID_PREFIX = 'did:dkg:agent:'; +const CONTEXT_GRAPH_DID_PREFIX = 'did:dkg:context-graph:'; + +function normalizeContextGraphIdForMemorySearch(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return trimmed.startsWith(CONTEXT_GRAPH_DID_PREFIX) + ? trimmed.slice(CONTEXT_GRAPH_DID_PREFIX.length) + : trimmed; +} + +function isAgentContextGraphId(value: string | undefined): boolean { + return normalizeContextGraphIdForMemorySearch(value) === AGENT_CONTEXT_GRAPH; +} /** * The DKG V10 query engine routes WM reads by raw peer ID, NOT the DID @@ -99,6 +132,7 @@ interface LayerPlan { layer: MemoryLayer; contextGraphId: string; view: 'working-memory' | 'shared-working-memory' | 'verified-memory'; + subGraphName?: string; } /** @@ -198,17 +232,30 @@ export function registerMemorySearchTool( .string() .optional() .describe( - 'Optional project context-graph id. When supplied, fan-out adds ' + - "the project's WM/SWM/VM layers to the agent-context layers.", + 'Optional project context-graph id. When supplied, or when a default project is pinned, ' + + "fan-out adds the project's WM/SWM/VM layers to the agent-context layers.", ), + subGraphName: z + .string() + .optional() + .describe('Optional project sub-graph scope. Requires projectId or a pinned default project and applies only to project fan-out.'), }, }, - async ({ query, limit, projectId }): Promise => { + async ({ query, limit, projectId, subGraphName }): Promise => { const trimmed = query.trim(); if (trimmed.length < 2) { return errResult('"query" is required (non-empty string, ≥2 chars).'); } const cap = Math.floor(Math.max(1, Math.min(100, limit ?? 20))); + const explicitProjectId = projectId?.trim(); + const effectiveProjectId = normalizeContextGraphIdForMemorySearch(explicitProjectId || _config.defaultProject || undefined); + const projectSubGraphName = subGraphName?.trim(); + if (subGraphName !== undefined && !projectSubGraphName) { + return errResult('"subGraphName" must be a non-empty string.'); + } + if (projectSubGraphName && (!effectiveProjectId || isAgentContextGraphId(effectiveProjectId))) { + return errResult('"subGraphName" requires "projectId" or a pinned default project because memory search subgraph scope applies only to project context graph fan-out.'); + } // The query engine requires the agent's raw peer ID for WM view // routing. Probe the daemon's identity once per call; without this, @@ -261,37 +308,59 @@ LIMIT ${cap}`; { layer: 'agent-context-swm', contextGraphId: AGENT_CONTEXT_GRAPH, view: 'shared-working-memory' }, { layer: 'agent-context-vm', contextGraphId: AGENT_CONTEXT_GRAPH, view: 'verified-memory' }, ]; - if (projectId) { + if (effectiveProjectId) { plans.push( - { layer: 'project-wm', contextGraphId: projectId, view: 'working-memory' }, - { layer: 'project-swm', contextGraphId: projectId, view: 'shared-working-memory' }, - { layer: 'project-vm', contextGraphId: projectId, view: 'verified-memory' }, + { layer: 'project-wm', contextGraphId: effectiveProjectId, view: 'working-memory', subGraphName: projectSubGraphName }, + { layer: 'project-swm', contextGraphId: effectiveProjectId, view: 'shared-working-memory', subGraphName: projectSubGraphName }, + { layer: 'project-vm', contextGraphId: effectiveProjectId, view: 'verified-memory', subGraphName: projectSubGraphName }, ); } const searchedLayers: MemoryLayer[] = plans.map((p) => p.layer); - // Per-layer fan-out. A single layer's failure must NOT propagate — - // surface the error to stderr (callers tail daemon logs anyway) and - // continue with the surviving layers. Mirrors the partial-success - // semantics in `DkgMemoryPlugin.ts:336-352`. - const settled = await Promise.all( - plans.map((plan) => - client - .query({ - sparql, - contextGraphId: plan.contextGraphId, - view: plan.view, - agentAddress, - }) - .then((r) => ({ plan, bindings: r.bindings ?? [] })) - .catch((err) => { - process.stderr.write( - `[dkg-mcp] memory-search ${plan.layer} failed (cg=${plan.contextGraphId}, view=${plan.view}): ${formatError(err)}\n`, - ); - return { plan, bindings: [] as Array> }; - }), - ), - ); + // Per-layer fan-out. A single unscoped layer's failure must NOT + // propagate: surface the error to stderr and continue with surviving + // layers. Explicit project sub-graph scope is different; validation + // or routing failures there are caller-visible scope errors, not + // cache misses. + let settled: Array<{ plan: LayerPlan; bindings: Array>; succeeded: boolean }>; + let firstScopedProjectError: string | undefined; + try { + settled = await Promise.all( + plans.map((plan) => + client + .query({ + sparql, + contextGraphId: plan.contextGraphId, + view: plan.view, + agentAddress, + subGraphName: plan.subGraphName, + }) + .then((r) => ({ plan, bindings: r.bindings ?? [], succeeded: true })) + .catch((err) => { + const message = formatError(err); + process.stderr.write( + `[dkg-mcp] memory-search ${plan.layer} failed (cg=${plan.contextGraphId}, view=${plan.view}): ${message}\n`, + ); + if (plan.subGraphName && isScopedQueryRoutingError(message)) { + throw new Error( + `memory_search subGraphName "${plan.subGraphName}" failed for ` + + `project "${plan.contextGraphId}" (${plan.view}): ${message}`, + ); + } + if (plan.subGraphName && firstScopedProjectError === undefined) { + firstScopedProjectError = `project "${plan.contextGraphId}" (${plan.view}): ${message}`; + } + return { plan, bindings: [] as Array>, succeeded: false }; + }), + ), + ); + } catch (err) { + return errResult(formatError(err)); + } + const scopedProjectSucceeded = settled.some((s) => Boolean(s.plan.subGraphName) && s.succeeded); + if (projectSubGraphName && !scopedProjectSucceeded && firstScopedProjectError) { + return errResult(`memory_search failed: ${firstScopedProjectError}`); + } // Dedup by (contextGraphId, uri-or-text-hash). Keep the highest- // trust hit; tie-break on raw score. Source: `DkgMemoryPlugin.ts:381-433`. @@ -358,7 +427,7 @@ LIMIT ${cap}`; // level only; mcp-dkg has no log-level surface, so we drop it). process.stderr.write( `[dkg-mcp] memory-search fired ` + - `(limit=${cap}): project=${projectId ?? '∅'}, ` + + `(limit=${cap}): project=${effectiveProjectId ?? '∅'}, ` + `layers=${plans.length}, raw_hits=${totalRaw} (${breakdown})\n`, ); const header = diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index e0431bcd1..10ad5841f 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -9,7 +9,7 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { beforeEach(() => { server = new FakeServer(); client = new FakeClient(); - registerMemorySearchTool(server.asMcpServer(), client.asDkgClient(), makeConfig()); + registerMemorySearchTool(server.asMcpServer(), client.asDkgClient(), makeConfig({ defaultProject: null })); }); it('registers the dkg_memory_search tool', () => { @@ -55,6 +55,216 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/proj-x · VM/); }); + it('fan-out covers project layers when a default project is pinned', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient(); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: 'pinned-cg' })); + + const result = await localServer.call('dkg_memory_search', { query: 'tree-sitter parsers' }); + + expect(result.isError).toBeFalsy(); + expect(localClient.queryCalls).toHaveLength(6); + expect(localClient.queryCalls.filter((call) => call.contextGraphId === 'pinned-cg')).toHaveLength(3); + }); + + it('applies subGraphName only to project context graph fan-out', async () => { + const result = await server.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'proj-x', + subGraphName: 'imports', + }); + expect(result.isError).toBeFalsy(); + expect(client.queryCalls).toHaveLength(6); + + const agentCalls = client.queryCalls.filter((call) => call.contextGraphId === 'agent-context'); + const projectCalls = client.queryCalls.filter((call) => call.contextGraphId === 'proj-x'); + expect(agentCalls).toHaveLength(3); + expect(projectCalls).toHaveLength(3); + for (const call of agentCalls) { + expect(call.subGraphName).toBeUndefined(); + } + for (const call of projectCalls) { + expect(call.subGraphName).toBe('imports'); + } + }); + + it('returns a tool error when a project-scoped subGraphName query fails', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + query: async function (this: FakeClient, args: Record) { + if (args.subGraphName) { + throw new Error('Unknown sub-graph: imports'); + } + const cgId = String(args.contextGraphId ?? ''); + const view = String(args.view ?? 'working-memory'); + return { bindings: this.memoryFixtures.get(`${cgId}::${view}`) ?? [] }; + } as never, + }); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: null })); + + const result = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'proj-x', + subGraphName: 'imports', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/subGraphName "imports".*Unknown sub-graph: imports/i); + expect(localClient.queryCalls.filter((call) => call.subGraphName === 'imports').length).toBeGreaterThan(0); + }); + + it('returns a tool error for camel-case subGraphName daemon validation failures', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + query: async function (this: FakeClient, args: Record) { + if (args.subGraphName) { + throw new Error('subGraphName requires contextGraphId'); + } + const cgId = String(args.contextGraphId ?? ''); + const view = String(args.view ?? 'working-memory'); + return { bindings: this.memoryFixtures.get(`${cgId}::${view}`) ?? [] }; + } as never, + }); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: null })); + + const result = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'proj-x', + subGraphName: 'imports', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/subGraphName "imports".*subGraphName requires contextGraphId/i); + }); + + it('keeps partial-success behavior when a project-scoped subGraphName layer succeeds', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + query: async function (this: FakeClient, args: Record) { + if (args.subGraphName && args.view === 'working-memory') { + throw new Error('fetch failed'); + } + const cgId = String(args.contextGraphId ?? ''); + const view = String(args.view ?? 'working-memory'); + return { bindings: this.memoryFixtures.get(`${cgId}::${view}`) ?? [] }; + } as never, + }); + localClient.memoryFixtures.set('proj-x::verified-memory', [ + { uri: { value: 'urn:project:note' }, text: { value: 'tree-sitter parsers from project memory' } }, + ]); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: null })); + + const result = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'proj-x', + subGraphName: 'imports', + }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/1 hit\(s\)/); + expect(result.content[0].text).toMatch(/proj-x/); + }); + + it('returns a tool error for generic project-scoped subGraphName failures when all scoped layers fail', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + query: async (args: Record) => { + if (args.subGraphName) { + throw new Error('fetch failed'); + } + return { bindings: [] }; + }, + }); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: null })); + + const result = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'proj-x', + subGraphName: 'imports', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/memory_search failed:.*fetch failed/i); + expect(localClient.queryCalls.filter((call) => call.subGraphName === 'imports')).toHaveLength(3); + }); + + it('returns a tool error for generic project-scoped subGraphName failures when every layer fails', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + query: async () => { + throw new Error('fetch failed'); + }, + }); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: null })); + + const result = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'proj-x', + subGraphName: 'imports', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/memory_search failed:.*fetch failed/i); + }); + + it('applies subGraphName to the pinned default project when projectId is omitted', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient(); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: 'pinned-cg' })); + + const result = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + subGraphName: 'imports', + }); + + expect(result.isError).toBeFalsy(); + const agentCalls = localClient.queryCalls.filter((call) => call.contextGraphId === 'agent-context'); + const projectCalls = localClient.queryCalls.filter((call) => call.contextGraphId === 'pinned-cg'); + expect(agentCalls).toHaveLength(3); + expect(projectCalls).toHaveLength(3); + for (const call of agentCalls) { + expect(call.subGraphName).toBeUndefined(); + } + for (const call of projectCalls) { + expect(call.subGraphName).toBe('imports'); + } + }); + + it('returns a tool error when subGraphName is supplied without any project scope', async () => { + const result = await server.call('dkg_memory_search', { + query: 'tree-sitter parsers', + subGraphName: 'imports', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/subGraphName.*projectId.*default project/i); + expect(client.queryCalls).toHaveLength(0); + }); + + it('returns a tool error when subGraphName is supplied with an agent-context project URI', async () => { + const result = await server.call('dkg_memory_search', { + query: 'tree-sitter parsers', + projectId: 'did:dkg:context-graph:agent-context', + subGraphName: 'imports', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/subGraphName.*projectId/i); + expect(client.queryCalls).toHaveLength(0); + + const localServer = new FakeServer(); + const localClient = new FakeClient(); + registerMemorySearchTool( + localServer.asMcpServer(), + localClient.asDkgClient(), + makeConfig({ defaultProject: 'did:dkg:context-graph:agent-context' }), + ); + const pinned = await localServer.call('dkg_memory_search', { + query: 'tree-sitter parsers', + subGraphName: 'imports', + }); + expect(pinned.isError).toBe(true); + expect(localClient.queryCalls).toHaveLength(0); + }); + it('VM hit collapses an SWM hit on the same entity URI (trust tier ordering: VM > SWM > WM)', async () => { const text = 'agreed-on architectural decision about staking adapter v2'; client.memoryFixtures.set('agent-context::working-memory', [ @@ -91,7 +301,7 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { const localClient = new FakeClient({ getAgentIdentity: async () => ({}), }); - registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: null })); const result = await localServer.call('dkg_memory_search', { query: 'anything goes here' }); expect(result.isError).toBe(true); diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index bf87abe67..b881667ce 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -41,6 +41,112 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.includeSharedMemory).toBe(true); }); + it('normalizes agentAddress DID form and forwards subGraphName through dkg_query', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + agentAddress: 'did:dkg:agent:peer-explicit', + subGraphName: ' imports ', + assertionName: 'chat-turns', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.agentAddress).toBe('peer-explicit'); + expect(lastCall.subGraphName).toBe('imports'); + expect(lastCall.assertionName).toBe('chat-turns'); + }); + + it('normalizes wallet-shaped working-memory agentAddress', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + agentAddress: 'did:dkg:agent:0x52908400098527886e0f7030069857d2e4169ee7', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBe('working-memory'); + expect(lastCall.agentAddress).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); + }); + + it('forwards wallet-shaped agentAddress unchanged when view is omitted', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + agentAddress: 'did:dkg:agent:0x52908400098527886e0f7030069857d2e4169ee7', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBeUndefined(); + expect(lastCall.agentAddress).toBe('did:dkg:agent:0x52908400098527886e0f7030069857d2e4169ee7'); + }); + + it('forwards wallet-shaped agentAddress unchanged outside working-memory', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'shared-working-memory', + agentAddress: 'did:dkg:agent:0x52908400098527886e0f7030069857d2e4169ee7', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBe('shared-working-memory'); + expect(lastCall.agentAddress).toBe('did:dkg:agent:0x52908400098527886e0f7030069857d2e4169ee7'); + }); + + it('rejects blank agentAddress in dkg_query', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + agentAddress: ' ', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('agentAddress'); + expect(client.queryCalls).toHaveLength(0); + }); + + it('rejects blank subGraphName in dkg_query', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + subGraphName: ' ', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('subGraphName'); + expect(client.queryCalls).toHaveLength(0); + }); + + it('rejects invalid subGraphName in dkg_query before forwarding', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + subGraphName: 'bad/name', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid subGraphName'); + expect(client.queryCalls).toHaveLength(0); + }); + + it('rejects blank assertionName in dkg_query', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + assertionName: ' ', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('assertionName'); + expect(client.queryCalls).toHaveLength(0); + }); + + it('rejects assertionName outside working-memory so the scope is not ignored', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'verified-memory', + assertionName: 'chat-turns', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('assertionName'); + expect(result.content[0].text).toContain('working-memory'); + expect(client.queryCalls).toHaveLength(0); + }); + it.each(['working-memory', 'shared-working-memory', 'verified-memory'])( 'accepts the canonical view enum value %s', async (view) => { @@ -101,11 +207,14 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () const tool = server.get('dkg_query'); const shape = tool.config.inputSchema!; const keys = Object.keys(shape); - // Post-migration surface: sparql, projectId, subGraphName, view, - // includeSharedMemory, limit. The legacy `layer` key MUST be gone. + // Post-migration surface: sparql, projectId, subGraphName, + // assertionName, agentAddress, view, includeSharedMemory, limit. + // The legacy `layer` key MUST be gone. expect(keys).toEqual( expect.arrayContaining([ 'sparql', + 'assertionName', + 'agentAddress', 'view', 'includeSharedMemory', ]), diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index b532c189a..65d0d1f05 100644 --- a/packages/query/src/dkg-query-engine.ts +++ b/packages/query/src/dkg-query-engine.ts @@ -5,7 +5,7 @@ import { contextGraphDataUri, contextGraphSharedMemoryUri, contextGraphVerifiedMemoryUri, contextGraphAssertionUri, contextGraphSubGraphUri, contextGraphMetaUri, contextGraphSharedMemoryMetaUri, contextGraphSubGraphMetaUri, contextGraphPrivateUri, contextGraphSubGraphPrivateUri, - assertSafeIri, escapeSparqlLiteral, validateSubGraphName, + assertSafeIri, escapeSparqlLiteral, validateSubGraphName, validateAssertionName, type GetView, REMOVED_VIEWS, TrustLevel, @@ -56,6 +56,7 @@ export function resolveViewGraphs( agentAddress?: string; verifiedGraph?: string; assertionName?: string; + subGraphName?: string; /** Spec §12/§14 trust-gradient filter. Enforced after graph resolution. */ minTrust?: TrustLevel; }, @@ -71,20 +72,23 @@ export function resolveViewGraphs( if (!opts?.agentAddress) { throw new Error('agentAddress is required for the working-memory view'); } + const assertionBaseGraph = opts.subGraphName + ? contextGraphSubGraphUri(contextGraphId, opts.subGraphName) + : contextGraphDataUri(contextGraphId); if (opts.assertionName) { return { - graphs: [contextGraphAssertionUri(contextGraphId, opts.agentAddress, opts.assertionName)], + graphs: [contextGraphAssertionUri(contextGraphId, opts.agentAddress, opts.assertionName, opts.subGraphName)], graphPrefixes: [], }; } return { graphs: [], - graphPrefixes: [`did:dkg:context-graph:${contextGraphId}/assertion/${opts.agentAddress}/`], + graphPrefixes: [`${assertionBaseGraph}/assertion/${opts.agentAddress}/`], }; } case 'shared-working-memory': return { - graphs: [contextGraphSharedMemoryUri(contextGraphId)], + graphs: [contextGraphSharedMemoryUri(contextGraphId, opts?.subGraphName)], graphPrefixes: [], }; case 'verified-memory': { @@ -117,7 +121,7 @@ export function resolveViewGraphs( if (opts?.verifiedGraph) { return { - graphs: [contextGraphVerifiedMemoryUri(contextGraphId, opts.verifiedGraph)], + graphs: [contextGraphVerifiedMemoryUri(contextGraphId, opts.verifiedGraph, opts.subGraphName)], graphPrefixes: [], }; } @@ -153,9 +157,12 @@ export function resolveViewGraphs( // cross-node consensus-verified data (still stamped with // `dkg:trustLevel` ConsensusVerified by // `DKGAgent.promoteToVerifiedMemory`). + const dataGraph = opts?.subGraphName + ? contextGraphSubGraphUri(contextGraphId, opts.subGraphName) + : contextGraphDataUri(contextGraphId); return { - graphs: [`did:dkg:context-graph:${contextGraphId}`], - graphPrefixes: [`did:dkg:context-graph:${contextGraphId}/_verified_memory/`], + graphs: [dataGraph], + graphPrefixes: [`${dataGraph}/_verified_memory/`], }; } } @@ -196,6 +203,19 @@ export class DKGQueryEngine implements QueryEngine { if (!v.valid) throw new Error(`Invalid sub-graph name for query: ${v.reason}`); } + if (options?.assertionName !== undefined) { + if (typeof options.assertionName !== 'string') { + throw new Error(`Invalid assertionName for query: expected a string, got ${typeof options.assertionName}`); + } + const assertionValidation = validateAssertionName(options.assertionName); + if (!assertionValidation.valid) { + throw new Error(`Invalid assertionName for query: ${assertionValidation.reason}`); + } + if (options.view !== 'working-memory') { + throw new Error('assertionName is only supported for view "working-memory" queries'); + } + } + if (effectiveContextGraphId && !options?.view) { const dataGraph = options?.subGraphName ? contextGraphSubGraphUri(effectiveContextGraphId, options.subGraphName) @@ -298,9 +318,9 @@ export class DKGQueryEngine implements QueryEngine { ); } if (options.subGraphName) { - throw new Error( - `subGraphName cannot be combined with view-based routing (view='${options.view}'). ` + - 'Sub-graph scoping within views is deferred to V10.x.', + await this.assertViewSubGraphDoesNotCollideWithKnownChildContextGraph( + effectiveContextGraphId, + options.subGraphName, ); } return this.queryWithView(sparql, options.view, effectiveContextGraphId, options); @@ -370,6 +390,7 @@ export class DKGQueryEngine implements QueryEngine { agentAddress: options.agentAddress, verifiedGraph: options.verifiedGraph, assertionName: options.assertionName, + subGraphName: options.subGraphName, // Back-compat: accept the legacy `_minTrust` underscore form for a // deprecation window. See QueryOptions._minTrust. minTrust: options.minTrust ?? options._minTrust, @@ -615,6 +636,20 @@ export class DKGQueryEngine implements QueryEngine { return names; } + private async assertViewSubGraphDoesNotCollideWithKnownChildContextGraph( + contextGraphId: string, + subGraphName: string, + ): Promise { + const subGraphUri = contextGraphSubGraphUri(contextGraphId, subGraphName); + const knownChildContextGraphs = await this.discoverKnownChildContextGraphUris(contextGraphId); + if (!knownChildContextGraphs.has(subGraphUri)) return; + + throw new ScopedQueryViolationError( + `subGraphName "${subGraphName}" for contextGraphId "${contextGraphId}" resolves to a known child context graph ` + + `"${contextGraphId}/${subGraphName}". Query the child context graph directly or choose a different sub-graph name.`, + ); + } + private async discoverRegisteredAssertionGraphs(contextGraphId: string): Promise> { const graphs = new Set(); const metaGraph = contextGraphMetaUri(contextGraphId); diff --git a/packages/query/src/query-engine.ts b/packages/query/src/query-engine.ts index 886f7cda6..048f3b24e 100644 --- a/packages/query/src/query-engine.ts +++ b/packages/query/src/query-engine.ts @@ -45,9 +45,9 @@ export interface QueryOptions { assertionName?: string; /** * Scope the query to a specific sub-graph within the context graph. - * When set, the query targets `did:dkg:context-graph:{id}/{subGraphName}` - * instead of the root data graph. Only works with legacy routing (no `view`). - * Combining `subGraphName` with `view` throws — deferred to V10.x. + * With legacy routing, targets `did:dkg:context-graph:{id}/{subGraphName}` + * instead of the root data graph. With view-based routing, targets the + * selected memory layer inside the named sub-graph. */ subGraphName?: string; /** diff --git a/packages/query/test/sub-graph-query.test.ts b/packages/query/test/sub-graph-query.test.ts index 137ec273c..ac098fec5 100644 --- a/packages/query/test/sub-graph-query.test.ts +++ b/packages/query/test/sub-graph-query.test.ts @@ -1,11 +1,33 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; +import { + contextGraphAssertionUri, + contextGraphDataUri, + contextGraphMetaUri, + contextGraphSharedMemoryUri, + contextGraphSubGraphUri, +} from '@origintrail-official/dkg-core'; import { DKGQueryEngine } from '../src/dkg-query-engine.js'; const CG_ID = 'dkg-v10-dev'; -const ROOT_GRAPH = `did:dkg:context-graph:${CG_ID}`; -const CODE_GRAPH = `did:dkg:context-graph:${CG_ID}/code`; -const DECISIONS_GRAPH = `did:dkg:context-graph:${CG_ID}/decisions`; +const AGENT = '0xAbC0000000000000000000000000000000000001'; +const OTHER_AGENT = '0xDeAd000000000000000000000000000000000002'; +const ROOT_GRAPH = contextGraphDataUri(CG_ID); +const CODE_GRAPH = contextGraphSubGraphUri(CG_ID, 'code'); +const DECISIONS_GRAPH = contextGraphSubGraphUri(CG_ID, 'decisions'); +const ROOT_WM_GRAPH = contextGraphAssertionUri(CG_ID, AGENT, 'probe-root'); +const CODE_WM_GRAPH = contextGraphAssertionUri(CG_ID, AGENT, 'probe', 'code'); +const CODE_WM_SIBLING_GRAPH = contextGraphAssertionUri(CG_ID, AGENT, 'probe-sibling', 'code'); +const DECISIONS_WM_GRAPH = contextGraphAssertionUri(CG_ID, AGENT, 'probe', 'decisions'); +const OTHER_AGENT_CODE_WM_GRAPH = contextGraphAssertionUri(CG_ID, OTHER_AGENT, 'probe', 'code'); +const ROOT_SWM_GRAPH = contextGraphSharedMemoryUri(CG_ID); +const CODE_SWM_GRAPH = contextGraphSharedMemoryUri(CG_ID, 'code'); +const DECISIONS_SWM_GRAPH = contextGraphSharedMemoryUri(CG_ID, 'decisions'); +const VIEW_NAME = 'http://ex.org/viewName'; +const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +const CONTEXT_GRAPH_TYPE = 'https://dkg.network/ontology#ContextGraph'; +const DKG_SUB_GRAPH_TYPE = 'http://dkg.io/ontology/SubGraph'; +const SCHEMA_NAME = 'http://schema.org/name'; function q(s: string, p: string, o: string, g: string): Quad { return { subject: s, predicate: p, object: o, graph: g }; @@ -20,6 +42,11 @@ describe('sub-graph query scoping', () => { engine = new DKGQueryEngine(store); await store.insert([ + q(CODE_GRAPH, RDF_TYPE, DKG_SUB_GRAPH_TYPE, contextGraphMetaUri(CG_ID)), + q(CODE_GRAPH, SCHEMA_NAME, '"code"', contextGraphMetaUri(CG_ID)), + q(DECISIONS_GRAPH, RDF_TYPE, DKG_SUB_GRAPH_TYPE, contextGraphMetaUri(CG_ID)), + q(DECISIONS_GRAPH, SCHEMA_NAME, '"decisions"', contextGraphMetaUri(CG_ID)), + q('urn:fn:main', 'http://ex.org/type', '"Function"', ROOT_GRAPH), q('urn:fn:main', 'http://ex.org/name', '"main"', ROOT_GRAPH), @@ -28,6 +55,20 @@ describe('sub-graph query scoping', () => { q('urn:decision:1', 'http://ex.org/type', '"Decision"', DECISIONS_GRAPH), q('urn:decision:1', 'http://ex.org/title', '"Use TypeScript"', DECISIONS_GRAPH), + + q('urn:view:vm-root', VIEW_NAME, '"VMRoot"', ROOT_GRAPH), + q('urn:view:vm-code', VIEW_NAME, '"VMCode"', CODE_GRAPH), + q('urn:view:vm-decisions', VIEW_NAME, '"VMDecisions"', DECISIONS_GRAPH), + + q('urn:view:wm-root', VIEW_NAME, '"WMRoot"', ROOT_WM_GRAPH), + q('urn:view:wm-code', VIEW_NAME, '"WMCode"', CODE_WM_GRAPH), + q('urn:view:wm-code-sibling', VIEW_NAME, '"WMSiblingAssertion"', CODE_WM_SIBLING_GRAPH), + q('urn:view:wm-decisions', VIEW_NAME, '"WMDecisions"', DECISIONS_WM_GRAPH), + q('urn:view:wm-other-agent', VIEW_NAME, '"WMOtherAgent"', OTHER_AGENT_CODE_WM_GRAPH), + + q('urn:view:swm-root', VIEW_NAME, '"SWMRoot"', ROOT_SWM_GRAPH), + q('urn:view:swm-code', VIEW_NAME, '"SWMCode"', CODE_SWM_GRAPH), + q('urn:view:swm-decisions', VIEW_NAME, '"SWMDecisions"', DECISIONS_SWM_GRAPH), ]); }); @@ -86,10 +127,153 @@ describe('sub-graph query scoping', () => { expect(result.bindings).toHaveLength(0); }); - it('rejects subGraphName combined with view-based routing', async () => { + it('queries a sub-graph working-memory view without leaking root, sibling, or other-agent WM', async () => { + const result = await engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'working-memory', agentAddress: AGENT, subGraphName: 'code' }, + ); + expect(result.bindings.map((b) => b['name']).sort()).toEqual([ + '"WMCode"', + '"WMSiblingAssertion"', + ]); + }); + + it('queries a sub-graph shared-working-memory view without root or sibling sub-graph leakage', async () => { + const result = await engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'shared-working-memory', subGraphName: 'code' }, + ); + expect(result.bindings.map((b) => b['name'])).toEqual(['"SWMCode"']); + }); + + it('queries a sub-graph verified-memory view without root or sibling sub-graph leakage', async () => { + const result = await engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'verified-memory', subGraphName: 'code' }, + ); + expect(result.bindings.map((b) => b['name'])).toEqual(['"VMCode"']); + }); + + it('queries one sub-graph WM assertion when subGraphName and assertionName are both supplied', async () => { + const result = await engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { + contextGraphId: CG_ID, + view: 'working-memory', + agentAddress: AGENT, + subGraphName: 'code', + assertionName: 'probe', + }, + ); + expect(result.bindings.map((b) => b['name'])).toEqual(['"WMCode"']); + }); + + it('rejects assertionName outside working-memory so it cannot be ignored', async () => { await expect(engine.query( - 'SELECT ?s ?sig WHERE { ?s ?sig }', + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'shared-working-memory', subGraphName: 'code', assertionName: 'probe' }, + )).rejects.toThrow(/assertionName.*working-memory/); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'verified-memory', subGraphName: 'code', assertionName: 'probe' }, + )).rejects.toThrow(/assertionName.*working-memory/); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, subGraphName: 'code', assertionName: 'probe' }, + )).rejects.toThrow(/assertionName.*working-memory/); + }); + + it('rejects invalid assertionName before building assertion graph URIs', async () => { + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { + contextGraphId: CG_ID, + view: 'working-memory', + agentAddress: AGENT, + subGraphName: 'code', + assertionName: 'probe/sibling', + }, + )).rejects.toThrow(/Invalid assertionName.*cannot contain "\/"/); + }); + + it('allows view-routed subGraphName data even when the sub-graph is not registered', async () => { + const staleGraph = contextGraphSubGraphUri(CG_ID, 'stale'); + const staleWmGraph = contextGraphAssertionUri(CG_ID, AGENT, 'probe', 'stale'); + const staleSwmGraph = contextGraphSharedMemoryUri(CG_ID, 'stale'); + await store.insert([ + q('urn:view:stale-vm', VIEW_NAME, '"StaleVM"', staleGraph), + q('urn:view:stale-wm', VIEW_NAME, '"StaleWM"', staleWmGraph), + q('urn:view:stale-swm', VIEW_NAME, '"StaleSWM"', staleSwmGraph), + ]); + + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'working-memory', agentAddress: AGENT, subGraphName: 'stale' }, + )).resolves.toMatchObject({ bindings: [{ name: '"StaleWM"' }] }); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'shared-working-memory', subGraphName: 'stale' }, + )).resolves.toMatchObject({ bindings: [{ name: '"StaleSWM"' }] }); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'verified-memory', subGraphName: 'stale' }, + )).resolves.toMatchObject({ bindings: [{ name: '"StaleVM"' }] }); + }); + + it('constrains GRAPH patterns to the selected sub-graph WM assertion', async () => { + const result = await engine.query( + `SELECT ?g ?name WHERE { GRAPH ?g { ?s <${VIEW_NAME}> ?name } }`, + { + contextGraphId: CG_ID, + view: 'working-memory', + agentAddress: AGENT, + subGraphName: 'code', + assertionName: 'probe', + }, + ); + expect(result.bindings).toEqual([ + { g: CODE_WM_GRAPH, name: '"WMCode"' }, + ]); + }); + + it('constrains GRAPH patterns to the selected sub-graph SWM graph', async () => { + const result = await engine.query( + `SELECT ?g ?name WHERE { GRAPH ?g { ?s <${VIEW_NAME}> ?name } }`, + { contextGraphId: CG_ID, view: 'shared-working-memory', subGraphName: 'code' }, + ); + expect(result.bindings).toEqual([ + { g: CODE_SWM_GRAPH, name: '"SWMCode"' }, + ]); + }); + + it('constrains GRAPH patterns to the selected sub-graph VM graph', async () => { + const result = await engine.query( + `SELECT ?g ?name WHERE { GRAPH ?g { ?s <${VIEW_NAME}> ?name } }`, + { contextGraphId: CG_ID, view: 'verified-memory', subGraphName: 'code' }, + ); + expect(result.bindings).toEqual([ + { g: CODE_GRAPH, name: '"VMCode"' }, + ]); + }); + + it('rejects view-routed sub-graph scope that aliases a known child context graph', async () => { + const childContextGraphId = `${CG_ID}/code`; + const childContextGraphUri = contextGraphDataUri(childContextGraphId); + await store.insert([ + q(childContextGraphUri, RDF_TYPE, CONTEXT_GRAPH_TYPE, contextGraphMetaUri(childContextGraphId)), + ]); + + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'working-memory', agentAddress: AGENT, subGraphName: 'code' }, + )).rejects.toThrow(/known child context graph/); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'shared-working-memory', subGraphName: 'code' }, + )).rejects.toThrow(/known child context graph/); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, { contextGraphId: CG_ID, view: 'verified-memory', subGraphName: 'code' }, - )).rejects.toThrow('subGraphName cannot be combined with view-based routing'); + )).rejects.toThrow(/known child context graph/); }); }); diff --git a/packages/storage/src/graph-manager.ts b/packages/storage/src/graph-manager.ts index b9dbda30c..eeeefbb97 100644 --- a/packages/storage/src/graph-manager.ts +++ b/packages/storage/src/graph-manager.ts @@ -43,12 +43,12 @@ export class ContextGraphManager { return contextGraphSharedMemoryMetaUri(contextGraphId, subGraphName); } - verifiedMemoryUri(contextGraphId: string, verifiedMemoryId: string): string { - return contextGraphVerifiedMemoryUri(contextGraphId, verifiedMemoryId); + verifiedMemoryUri(contextGraphId: string, verifiedMemoryId: string, subGraphName?: string): string { + return contextGraphVerifiedMemoryUri(contextGraphId, verifiedMemoryId, subGraphName); } - verifiedMemoryMetaUri(contextGraphId: string, verifiedMemoryId: string): string { - return contextGraphVerifiedMemoryMetaUri(contextGraphId, verifiedMemoryId); + verifiedMemoryMetaUri(contextGraphId: string, verifiedMemoryId: string, subGraphName?: string): string { + return contextGraphVerifiedMemoryMetaUri(contextGraphId, verifiedMemoryId, subGraphName); } assertionUri(contextGraphId: string, agentAddress: string, name: string): string { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e10b10d5..503b98202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,6 +639,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1 version: 1.27.1(zod@3.25.76) + '@origintrail-official/dkg-core': + specifier: workspace:* + version: link:../core yaml: specifier: ^2.6.0 version: 2.8.3