From 9790b8f30578df43947d241c30e921878cbed9c1 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 03:27:51 +0200 Subject: [PATCH 01/22] fix(query): support subgraph view routing --- .../adapter-hermes/hermes-plugin/__init__.py | 36 ++++- .../test/hermes-adapter.test.ts | 38 ++++- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 13 ++ .../adapter-openclaw/src/DkgNodePlugin.ts | 39 ++++- .../src/tools/memory-tools.ts | 6 + .../adapter-openclaw/src/tools/query-tools.ts | 6 + packages/adapter-openclaw/src/types.ts | 5 + .../adapter-openclaw/test/dkg-memory.test.ts | 36 +++++ .../test/memory-integration.test.ts | 22 +++ .../test/memory-search-tool.test.ts | 19 +++ .../test/plugin.part-03.test.ts | 39 +++++ .../test/plugin.part-06.test.ts | 2 + packages/cli/skills/dkg-node/SKILL.md | 16 +- packages/cli/src/daemon/routes/query.ts | 13 +- packages/cli/test/daemon/routes/query.test.ts | 91 +++++++++++- packages/core/src/constants.ts | 3 +- packages/mcp-dkg/src/tools.ts | 7 +- packages/mcp-dkg/src/tools/memory-search.ts | 21 ++- packages/mcp-dkg/test/memory-search.test.ts | 31 ++++ packages/mcp-dkg/test/query-schema.test.ts | 19 ++- packages/query/src/dkg-query-engine.ts | 40 +++-- packages/query/src/query-engine.ts | 6 +- packages/query/test/sub-graph-query.test.ts | 137 +++++++++++++++++- 23 files changed, 593 insertions(+), 52 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index db7b47794..7df1d5db6 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -279,6 +279,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 +808,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"], }, @@ -1392,8 +1400,10 @@ def _handle_query(self, args: Dict[str, Any]) -> str: 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"): 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 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(): @@ -1423,6 +1433,16 @@ 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 = _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 project_context_graph == "agent-context"): + return tool_error('"sub_graph_name" requires a project context graph for memory_search.') if self._offline or not self._client: return json.dumps(_cache_memory_search(query, self._cache, limit)) @@ -1432,9 +1452,6 @@ 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): if cg and cg not in context_graphs: @@ -1450,11 +1467,16 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: ): if view == "working-memory" and not agent_address: continue + query_kwargs = { + "view": view, + "agent_address": agent_address if view == "working-memory" else None, + } + if project_sub_graph_name and cg == project_context_graph and cg != "agent-context": + query_kwargs["sub_graph_name"] = project_sub_graph_name result = self._client.query( sparql, cg, - view=view, - agent_address=agent_address if view == "working-memory" else None, + **query_kwargs, ) if _client_result_failed(result): continue diff --git a/packages/adapter-hermes/test/hermes-adapter.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index 04a9f288e..ac4d86636 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -2307,9 +2307,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 @@ -2759,7 +2760,7 @@ for args, needle in [ ({"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": "working-memory", "agent_address": " "}, "agent_address"), ]: result = json.loads(provider.handle_tool_call("dkg_query", args)) @@ -2782,6 +2783,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 = [] @@ -3156,6 +3166,30 @@ 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._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 `; 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..080cc59b0 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -193,6 +193,14 @@ export class DkgMemorySearchManager implements MemorySearchManager { // inputs, so passing a raw peer ID through the resolver still works. const agentAddress = rawAgentAddress ? toAgentPeerId(rawAgentAddress) : undefined; const projectContextGraphId = 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.`, + ); + return []; + } // B28: Preflight the agent address BEFORE firing WM queries. The query // engine at `packages/query/src/dkg-query-engine.ts:47-48` throws @@ -283,6 +291,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 +328,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: projectContextGraphId, view: 'working-memory', sparql: permissiveSparql, + subGraphName: projectSubGraphName, }, { layer: 'project-swm', @@ -327,6 +337,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: projectContextGraphId, view: 'shared-working-memory', sparql: permissiveSparql, + subGraphName: projectSubGraphName, }, { layer: 'project-vm', @@ -335,6 +346,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: projectContextGraphId, view: 'verified-memory', sparql: permissiveSparql, + subGraphName: projectSubGraphName, }, ); } @@ -346,6 +358,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { contextGraphId: plan.contextGraphId, view: plan.view, agentAddress, + subGraphName: plan.subGraphName, }) .then(r => ({ plan, bindings: extractBindings(r) })) .catch(err => { diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index 6cd4666fa..c91316a9a 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,12 @@ export class DkgNodePlugin { await this.ensureNodePeerId().catch(() => {}); } const session = this.memorySessionResolver.getSession(undefined); + if ( + projectSubGraphName && + (!session?.projectContextGraphId || session.projectContextGraphId === 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 +2689,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 +2778,20 @@ 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".'); + } // 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 @@ -2860,6 +2896,7 @@ export class DkgNodePlugin { contextGraphId, view, agentAddress, + subGraphName, }); 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..c4b7798c5 100644 --- a/packages/adapter-openclaw/src/tools/query-tools.ts +++ b/packages/adapter-openclaw/src/tools/query-tools.ts @@ -90,6 +90,12 @@ 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.', + }, }, 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..ed9025edd 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1026,6 +1026,42 @@ 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('does not apply projectSubGraphName to agent-context 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 }, + }); + + const result = await manager.search('hello world', { projectSubGraphName: 'skills' }); + + expect(result).toEqual([]); + expect(querySpy).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no project context graph')); + }); + 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..9f988a708 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,24 @@ 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 "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..635b6f581 100644 --- a/packages/adapter-openclaw/test/plugin.part-03.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-03.test.ts @@ -194,6 +194,45 @@ 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: 'shared-working-memory', + sub_graph_name: 'protocols', + }); + 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('shared-working-memory'); + expect(body.subGraphName).toBe('protocols'); + }); + + 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 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..330260f43 100644 --- a/packages/adapter-openclaw/test/plugin.part-06.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-06.test.ts @@ -437,6 +437,8 @@ 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); const inviteTool = byName.get('dkg_context_graph_invite')!; expect(inviteTool.description).toMatch(/primary user-facing deliverable/i); diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 53f64ae26..5b8cec530 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` 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; 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..22b643544 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 @@ -554,10 +554,13 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { && validTokens.has(requestToken) && callerAgentAddress === undefined; const hasRecognisedIdentity = isAdminToken || callerAgentAddress !== undefined; + const effectiveAgentAddress = + requestedAgentAddress + ?? (view === 'working-memory' ? requestAgentAddress : undefined); 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 +570,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 +578,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 +590,7 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { includeSharedMemory, includeContextGraphPartitions, view, - agentAddress, + agentAddress: effectiveAgentAddress, verifiedGraph, assertionName, subGraphName, diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index 55f2ea393..9a5adb22a 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,79 @@ describe('handleQueryRoutes /api/query', () => { await expect(handleQueryRoutes(ctx)).rejects.toThrow('Database connection lost'); expect(res.statusCode).not.toBe(400); }); + + it('infers omitted working-memory agentAddress from 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); + expect(agent.query.mock.calls[0][1]).toMatchObject({ + contextGraphId: 'research', + view: 'working-memory', + agentAddress: caller, + callerAgentAddress: caller, + }); + }); + + 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, + }); + }); }); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 2f7877863..6528a72aa 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -224,7 +224,8 @@ 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}`; } diff --git a/packages/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index 5a38ded47..d12135c25 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -199,6 +199,10 @@ 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'), + 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,7 +214,7 @@ 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, agentAddress, view, includeSharedMemory, limit }): Promise => { const pid = resolveProject(projectId, config); if (!pid) return projectErr(); const fullSparql = sparql.startsWith('PREFIX') ? sparql : `${PREFIXES}\n${sparql}`; @@ -219,6 +223,7 @@ export function registerReadTools( sparql: fullSparql, contextGraphId: pid, subGraphName, + agentAddress, view, includeSharedMemory, }); diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index d9cacf86a..3a3980441 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -99,6 +99,7 @@ interface LayerPlan { layer: MemoryLayer; contextGraphId: string; view: 'working-memory' | 'shared-working-memory' | 'verified-memory'; + subGraphName?: string; } /** @@ -201,14 +202,25 @@ export function registerMemorySearchTool( 'Optional project context-graph id. When supplied, 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 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 projectSubGraphName = subGraphName?.trim(); + if (subGraphName !== undefined && !projectSubGraphName) { + return errResult('"subGraphName" must be a non-empty string.'); + } + if (projectSubGraphName && !projectId?.trim()) { + return errResult('"subGraphName" requires "projectId" 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, @@ -263,9 +275,9 @@ LIMIT ${cap}`; ]; if (projectId) { 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: projectId, view: 'working-memory', subGraphName: projectSubGraphName }, + { layer: 'project-swm', contextGraphId: projectId, view: 'shared-working-memory', subGraphName: projectSubGraphName }, + { layer: 'project-vm', contextGraphId: projectId, view: 'verified-memory', subGraphName: projectSubGraphName }, ); } const searchedLayers: MemoryLayer[] = plans.map((p) => p.layer); @@ -282,6 +294,7 @@ LIMIT ${cap}`; contextGraphId: plan.contextGraphId, view: plan.view, agentAddress, + subGraphName: plan.subGraphName, }) .then((r) => ({ plan, bindings: r.bindings ?? [] })) .catch((err) => { diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index e0431bcd1..dd5ebf286 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -55,6 +55,37 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/proj-x · VM/); }); + 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 subGraphName is supplied without projectId', 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/i); + expect(client.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', [ diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index bf87abe67..bf92b00c4 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -41,6 +41,19 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.includeSharedMemory).toBe(true); }); + it('forwards agentAddress and subGraphName through dkg_query', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + agentAddress: 'peer-explicit', + subGraphName: 'imports', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.agentAddress).toBe('peer-explicit'); + expect(lastCall.subGraphName).toBe('imports'); + }); + it.each(['working-memory', 'shared-working-memory', 'verified-memory'])( 'accepts the canonical view enum value %s', async (view) => { @@ -101,11 +114,13 @@ 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, + // agentAddress, view, includeSharedMemory, limit. The legacy `layer` + // key MUST be gone. expect(keys).toEqual( expect.arrayContaining([ 'sparql', + 'agentAddress', 'view', 'includeSharedMemory', ]), diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index b532c189a..a6a5ec7d6 100644 --- a/packages/query/src/dkg-query-engine.ts +++ b/packages/query/src/dkg-query-engine.ts @@ -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/`], }; } } @@ -298,9 +305,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 +377,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 +623,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..9a23f7f64 100644 --- a/packages/query/test/sub-graph-query.test.ts +++ b/packages/query/test/sub-graph-query.test.ts @@ -1,11 +1,31 @@ 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'; function q(s: string, p: string, o: string, g: string): Quad { return { subject: s, predicate: p, object: o, graph: g }; @@ -28,6 +48,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 +120,101 @@ 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('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 ?s ?sig WHERE { ?s ?sig }', + `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/); }); }); From a760ac2534844861bd5edbb9cc4ac82fc7d8a0e7 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 03:49:43 +0200 Subject: [PATCH 02/22] fix(query): finish subgraph view review fixes --- packages/agent/src/dkg-agent-endorse.ts | 36 ++++++++++++++----- packages/cli/src/daemon/routes/query.ts | 3 ++ packages/cli/test/daemon/routes/query.test.ts | 31 ++++++++++++++++ packages/core/src/constants.ts | 3 +- packages/core/test/v10-constants.test.ts | 2 ++ packages/mcp-dkg/src/tools.ts | 16 ++++++++- packages/mcp-dkg/test/query-schema.test.ts | 15 ++++++-- packages/storage/src/graph-manager.ts | 8 ++--- 8 files changed, 98 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/dkg-agent-endorse.ts b/packages/agent/src/dkg-agent-endorse.ts index 1cf231185..b848ff70a 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, @@ -459,7 +460,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 +483,10 @@ 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; + 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 +696,9 @@ export class EndorseVerifyMethods extends DKGAgentBase { await this.stampBatchTrustLevel( opts.contextGraphId, opts.batchId, - contextGraphDataGraphUri(opts.contextGraphId), + batchDataGraph, trustLevel, + subGraphName, ); this.log.info( ctx, @@ -740,6 +752,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)}...)`); @@ -780,6 +793,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { txHash: string, blockNumber: number, signers: string[], + subGraphName?: string, ): Promise { // Query only the triples belonging to this batch via root entities in _meta const rootEntities = await this.getRootEntities(contextGraphId, batchId); @@ -787,7 +801,9 @@ export class EndorseVerifyMethods extends DKGAgentBase { 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 +816,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 +835,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, @@ -838,14 +854,18 @@ export class EndorseVerifyMethods extends DKGAgentBase { 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/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index 22b643544..f830dc56a 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -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 diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index 9a5adb22a..1bb039c02 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -175,6 +175,37 @@ describe('handleQueryRoutes /api/query', () => { }); }); + 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('forwards view, subGraphName, and assertionName to the agent query route', async () => { const caller = '0x2222222222222222222222222222222222222222'; const agent = { diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 6528a72aa..f8547c3b0 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -229,7 +229,8 @@ export function contextGraphVerifiedMemoryUri(contextGraphId: string, verifiedMe 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/src/tools.ts b/packages/mcp-dkg/src/tools.ts index d12135c25..e2bb27597 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -42,6 +42,19 @@ const err = (text: string): ToolResult => ({ const formatError = (e: unknown): string => e instanceof Error ? e.message : String(e); +const AGENT_DID_PREFIX = 'did:dkg:agent:'; + +function normalizeAgentAddressForQuery(agentAddress: string | undefined): string | undefined { + if (agentAddress === undefined) return undefined; + const trimmed = agentAddress.trim(); + if (!trimmed) { + throw new Error('"agentAddress" must be a non-empty string.'); + } + return trimmed.startsWith(AGENT_DID_PREFIX) + ? trimmed.slice(AGENT_DID_PREFIX.length) + : trimmed; +} + /** * Resolve the contextGraphId for a tool invocation. Argument beats * config default; if neither is present we return null and the tool @@ -219,11 +232,12 @@ export function registerReadTools( if (!pid) return projectErr(); const fullSparql = sparql.startsWith('PREFIX') ? sparql : `${PREFIXES}\n${sparql}`; try { + const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress); const result = await client.query({ sparql: fullSparql, contextGraphId: pid, subGraphName, - agentAddress, + agentAddress: normalizedAgentAddress, view, includeSharedMemory, }); diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index bf92b00c4..df11699cd 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -41,11 +41,11 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.includeSharedMemory).toBe(true); }); - it('forwards agentAddress and subGraphName through dkg_query', async () => { + 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: 'peer-explicit', + agentAddress: 'did:dkg:agent:peer-explicit', subGraphName: 'imports', }); expect(result.isError).toBeFalsy(); @@ -54,6 +54,17 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.subGraphName).toBe('imports'); }); + 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.each(['working-memory', 'shared-working-memory', 'verified-memory'])( 'accepts the canonical view enum value %s', async (view) => { 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 { From 384b44264057ede789dbdf2bc08822e1f4a45b81 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 04:01:42 +0200 Subject: [PATCH 03/22] fix(tools): harden subgraph memory recall --- packages/adapter-openclaw/src/DkgMemoryPlugin.ts | 2 +- packages/adapter-openclaw/test/dkg-memory.test.ts | 7 +++---- packages/mcp-dkg/package.json | 1 + packages/mcp-dkg/src/tools.ts | 7 ++++++- packages/mcp-dkg/test/query-schema.test.ts | 11 +++++++++++ pnpm-lock.yaml | 3 +++ 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index 080cc59b0..b55d7ea70 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -199,7 +199,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { `[dkg-memory] DkgMemorySearchManager.search skipped project sub-graph scope ` + `"${projectSubGraphName}" because no project context graph is selected.`, ); - return []; + throw new Error('projectSubGraphName requires a selected project context graph.'); } // B28: Preflight the agent address BEFORE firing WM queries. The query diff --git a/packages/adapter-openclaw/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index ed9025edd..cb3c6fdbb 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1046,7 +1046,7 @@ describe('DkgMemorySearchManager', () => { expect(projectOpts.every(o => o.subGraphName === 'skills')).toBe(true); }); - it('does not apply projectSubGraphName to agent-context when no project CG is resolved', async () => { + 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({ @@ -1055,9 +1055,8 @@ describe('DkgMemorySearchManager', () => { logger: { warn }, }); - const result = await manager.search('hello world', { projectSubGraphName: 'skills' }); - - expect(result).toEqual([]); + 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')); }); 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 e2bb27597..4d7a4609f 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 } from '@origintrail-official/dkg-core'; import { z } from 'zod'; import type { DkgClient, ProjectRow } from './client.js'; import type { DkgConfig } from './config.js'; @@ -43,6 +44,7 @@ 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): string | undefined { if (agentAddress === undefined) return undefined; @@ -50,9 +52,12 @@ function normalizeAgentAddressForQuery(agentAddress: string | undefined): string if (!trimmed) { throw new Error('"agentAddress" must be a non-empty string.'); } - return trimmed.startsWith(AGENT_DID_PREFIX) + const stripped = trimmed.startsWith(AGENT_DID_PREFIX) ? trimmed.slice(AGENT_DID_PREFIX.length) : trimmed; + return ETH_ADDRESS_RE.test(stripped) + ? toEip55Checksum(stripped) + : stripped; } /** diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index df11699cd..79fee390b 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -54,6 +54,17 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.subGraphName).toBe('imports'); }); + it('normalizes DID-prefixed wallet agentAddress to checksum form for dkg_query', 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.agentAddress).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); + }); + it('rejects blank agentAddress in dkg_query', async () => { const result = await server.call('dkg_query', { sparql: 'SELECT ?s WHERE { ?s ?p ?o }', 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 From 80336305b4c4869ae4c210bf914972dbb86ad816 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 04:37:01 +0200 Subject: [PATCH 04/22] fix(query): tighten subgraph review edges --- packages/agent/src/dkg-agent-endorse.ts | 35 ++++++++++ packages/agent/test/endorse.test.ts | 46 +++++++++++++ packages/cli/src/daemon/routes/query.ts | 5 +- packages/cli/test/daemon/routes/query.test.ts | 65 +++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/dkg-agent-endorse.ts b/packages/agent/src/dkg-agent-endorse.ts index b848ff70a..da7d80cc6 100644 --- a/packages/agent/src/dkg-agent-endorse.ts +++ b/packages/agent/src/dkg-agent-endorse.ts @@ -484,6 +484,9 @@ export class EndorseVerifyMethods extends DKGAgentBase { 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); @@ -795,6 +798,9 @@ export class EndorseVerifyMethods extends DKGAgentBase { 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) { @@ -849,6 +855,35 @@ 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, 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/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index f830dc56a..eeb9adc09 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -557,9 +557,12 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { && validTokens.has(requestToken) && callerAgentAddress === undefined; const hasRecognisedIdentity = isAdminToken || callerAgentAddress !== undefined; + const inferredWorkingMemoryAgentAddress = view === 'working-memory' + ? callerAgentAddress ?? (isAdminToken ? requestAgentAddress : undefined) + : undefined; const effectiveAgentAddress = requestedAgentAddress - ?? (view === 'working-memory' ? requestAgentAddress : undefined); + ?? inferredWorkingMemoryAgentAddress; if ( !hasRecognisedIdentity && view === 'working-memory' && diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index 1bb039c02..6c0b23c18 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -175,6 +175,71 @@ describe('handleQueryRoutes /api/query', () => { }); }); + it('does not infer omitted working-memory agentAddress for unauthenticated callers', async () => { + const defaultAgent = '0x1111111111111111111111111111111111111111'; + const agent = { + resolveAgentByToken: vi.fn(), + query: vi.fn().mockRejectedValue(new Error('agentAddress is required for working-memory view')), + 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(400); + expect(JSON.parse(res.body).error).toMatch(/agentAddress is required/); + expect(agent.query).toHaveBeenCalledTimes(1); + expect(agent.query.mock.calls[0][1]).toHaveProperty('agentAddress', undefined); + expect(agent.query.mock.calls[0][1]).toHaveProperty('callerAgentAddress', undefined); + }); + + it('infers omitted working-memory agentAddress 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); + expect(agent.query.mock.calls[0][1]).toMatchObject({ + contextGraphId: 'research', + view: 'working-memory', + agentAddress: defaultAgent, + }); + expect(agent.query.mock.calls[0][1]).toHaveProperty('callerAgentAddress', undefined); + }); + it('rejects present non-string agentAddress instead of inferring it', async () => { const caller = '0x1111111111111111111111111111111111111111'; const agent = { From d3cc79cef567bcb7a28001973d38569ba278e157 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 04:52:54 +0200 Subject: [PATCH 05/22] fix(tools): preserve default-scoped query behavior --- .../adapter-hermes/hermes-plugin/__init__.py | 4 +- .../test/hermes-adapter.test.ts | 25 ++++++++++- packages/cli/src/daemon/routes/query.ts | 18 +++++--- packages/cli/test/daemon/routes/query.test.ts | 26 +++++------ packages/mcp-dkg/src/tools/memory-search.ts | 24 ++++++----- packages/mcp-dkg/test/memory-search.test.ts | 43 +++++++++++++++++-- 6 files changed, 104 insertions(+), 36 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index 7df1d5db6..b633f4daf 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1398,12 +1398,14 @@ def _handle_query(self, args: Dict[str, Any]) -> str: 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 cg: return tool_error(f'"view: {view}" requires "context_graph_id".') 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 cg: + return tool_error('"sub_graph_name" requires "context_graph_id" or a configured default context graph.') 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(): diff --git a/packages/adapter-hermes/test/hermes-adapter.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index ac4d86636..2bd6030db 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -2759,13 +2759,27 @@ 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", "sub_graph_name": 42}, "sub_graph_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 + result = json.loads(provider.handle_tool_call("dkg_query", { "sparql": "ASK {}", "context_graph_id": "cg:test", @@ -2792,6 +2806,15 @@ result = json.loads(provider.handle_tool_call("dkg_query", { assert result["ok"] is True, result assert provider._client.queries[-1][2]["sub_graph_name"] == "scratch", provider._client.queries +result = json.loads(provider.handle_tool_call("dkg_query", { + "sparql": "ASK {}", + "view": "shared-working-memory", + "sub_graph_name": "scratch", +})) +assert result["ok"] is True, result +assert provider._client.queries[-1][1] == "default-cg", provider._client.queries +assert provider._client.queries[-1][2]["sub_graph_name"] == "scratch", provider._client.queries + class ReadMarkdownClient: def __init__(self): self.calls = [] diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index eeb9adc09..1786f1fc0 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -557,12 +557,18 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { && validTokens.has(requestToken) && callerAgentAddress === undefined; const hasRecognisedIdentity = isAdminToken || callerAgentAddress !== undefined; - const inferredWorkingMemoryAgentAddress = view === 'working-memory' - ? callerAgentAddress ?? (isAdminToken ? requestAgentAddress : undefined) - : undefined; - const effectiveAgentAddress = - requestedAgentAddress - ?? inferredWorkingMemoryAgentAddress; + const effectiveAgentAddress = requestedAgentAddress; + if ( + !hasRecognisedIdentity && + view === 'working-memory' && + requestedAgentAddress === undefined + ) { + return jsonResponse(res, 403, { + error: + 'working-memory reads without agentAddress require authentication. ' + + 'Provide an agent-scoped bearer token, a node-admin token, or an explicit self agentAddress.', + }); + } if ( !hasRecognisedIdentity && view === 'working-memory' && diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index 6c0b23c18..4bcdc7bf4 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -140,7 +140,7 @@ describe('handleQueryRoutes /api/query', () => { expect(res.statusCode).not.toBe(400); }); - it('infers omitted working-memory agentAddress from the authenticated caller', async () => { + 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), @@ -167,19 +167,20 @@ describe('handleQueryRoutes /api/query', () => { expect(res.statusCode).toBe(200); expect(agent.query).toHaveBeenCalledTimes(1); - expect(agent.query.mock.calls[0][1]).toMatchObject({ + const queryOptions = agent.query.mock.calls[0][1]; + expect(queryOptions).toMatchObject({ contextGraphId: 'research', view: 'working-memory', - agentAddress: caller, callerAgentAddress: caller, }); + expect(queryOptions).toHaveProperty('agentAddress', undefined); }); it('does not infer omitted working-memory agentAddress for unauthenticated callers', async () => { const defaultAgent = '0x1111111111111111111111111111111111111111'; const agent = { resolveAgentByToken: vi.fn(), - query: vi.fn().mockRejectedValue(new Error('agentAddress is required for working-memory view')), + query: vi.fn().mockResolvedValue({ bindings: [] }), getDefaultAgentAddress: vi.fn().mockReturnValue(defaultAgent), peerId: '12D3KooWself', }; @@ -198,14 +199,12 @@ describe('handleQueryRoutes /api/query', () => { await handleQueryRoutes(ctx); - expect(res.statusCode).toBe(400); - expect(JSON.parse(res.body).error).toMatch(/agentAddress is required/); - expect(agent.query).toHaveBeenCalledTimes(1); - expect(agent.query.mock.calls[0][1]).toHaveProperty('agentAddress', undefined); - expect(agent.query.mock.calls[0][1]).toHaveProperty('callerAgentAddress', undefined); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toMatch(/without agentAddress require authentication/); + expect(agent.query).not.toHaveBeenCalled(); }); - it('infers omitted working-memory agentAddress for node-admin callers', async () => { + it('leaves omitted working-memory agentAddress to the agent for node-admin callers', async () => { const defaultAgent = '0x1111111111111111111111111111111111111111'; const agent = { resolveAgentByToken: vi.fn().mockReturnValue(undefined), @@ -232,12 +231,13 @@ describe('handleQueryRoutes /api/query', () => { expect(res.statusCode).toBe(200); expect(agent.query).toHaveBeenCalledTimes(1); - expect(agent.query.mock.calls[0][1]).toMatchObject({ + const queryOptions = agent.query.mock.calls[0][1]; + expect(queryOptions).toMatchObject({ contextGraphId: 'research', view: 'working-memory', - agentAddress: defaultAgent, }); - expect(agent.query.mock.calls[0][1]).toHaveProperty('callerAgentAddress', undefined); + expect(queryOptions).toHaveProperty('agentAddress', undefined); + expect(queryOptions).toHaveProperty('callerAgentAddress', undefined); }); it('rejects present non-string agentAddress instead of inferring it', async () => { diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index 3a3980441..4c17746db 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`). @@ -199,13 +199,13 @@ 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 and applies only to project fan-out.'), + .describe('Optional project sub-graph scope. Requires projectId or a pinned default project and applies only to project fan-out.'), }, }, async ({ query, limit, projectId, subGraphName }): Promise => { @@ -214,12 +214,14 @@ export function registerMemorySearchTool( 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 = explicitProjectId || _config.defaultProject || undefined; const projectSubGraphName = subGraphName?.trim(); if (subGraphName !== undefined && !projectSubGraphName) { return errResult('"subGraphName" must be a non-empty string.'); } - if (projectSubGraphName && !projectId?.trim()) { - return errResult('"subGraphName" requires "projectId" because memory search subgraph scope applies only to project context graph fan-out.'); + if (projectSubGraphName && !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 @@ -273,11 +275,11 @@ 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', subGraphName: projectSubGraphName }, - { layer: 'project-swm', contextGraphId: projectId, view: 'shared-working-memory', subGraphName: projectSubGraphName }, - { layer: 'project-vm', contextGraphId: projectId, view: 'verified-memory', subGraphName: projectSubGraphName }, + { 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); @@ -371,7 +373,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 dd5ebf286..89aabba63 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,18 @@ 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', @@ -76,13 +88,36 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { } }); - it('returns a tool error when subGraphName is supplied without projectId', async () => { + 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/i); + expect(result.content[0].text).toMatch(/subGraphName.*projectId.*default project/i); expect(client.queryCalls).toHaveLength(0); }); @@ -122,7 +157,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); From 9e9f7b1de3be58597ac5d3106b5cffa3c5942a4c Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 05:09:15 +0200 Subject: [PATCH 06/22] fix(tools): surface scoped recall failures --- .../adapter-hermes/hermes-plugin/__init__.py | 31 ++++++++-- .../test/hermes-adapter.test.ts | 20 +++++++ .../adapter-openclaw/src/DkgMemoryPlugin.ts | 16 ++++- .../adapter-openclaw/test/dkg-memory.test.ts | 20 +++++++ .../test/memory-search-tool.test.ts | 23 ++++++++ packages/mcp-dkg/src/tools/memory-search.ts | 59 +++++++++++-------- packages/mcp-dkg/test/memory-search.test.ts | 25 ++++++++ 7 files changed, 162 insertions(+), 32 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index b633f4daf..23f7c64b7 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1469,18 +1469,37 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: ): if view == "working-memory" and not agent_address: continue + scoped_project_layer = bool( + project_sub_graph_name + and cg == project_context_graph + and cg != "agent-context" + ) query_kwargs = { "view": view, "agent_address": agent_address if view == "working-memory" else None, } - if project_sub_graph_name and cg == project_context_graph and cg != "agent-context": + if scoped_project_layer: query_kwargs["sub_graph_name"] = project_sub_graph_name - result = self._client.query( - sparql, - cg, - **query_kwargs, - ) + try: + result = self._client.query( + sparql, + cg, + **query_kwargs, + ) + except Exception as e: + if scoped_project_layer: + return tool_error( + f'memory_search sub_graph_name "{project_sub_graph_name}" failed for ' + f'context_graph_id "{cg}" ({view}): {e}' + ) + raise if _client_result_failed(result): + if scoped_project_layer: + detail = result.get("error") if isinstance(result, dict) else "query failed" + return tool_error( + f'memory_search sub_graph_name "{project_sub_graph_name}" failed for ' + f'context_graph_id "{cg}" ({view}): {detail}' + ) continue successful_queries += 1 for binding in _extract_query_bindings(result): diff --git a/packages/adapter-hermes/test/hermes-adapter.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index 2bd6030db..56c37ac8d 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -3141,12 +3141,15 @@ spec.loader.exec_module(module) class FakeClient: def __init__(self): self.calls = [] + self.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_scoped_project and kwargs.get("sub_graph_name"): + return {"error": "Unknown sub-graph: skills"} return { "result": { "bindings": [{ @@ -3206,6 +3209,23 @@ assert provider._client.calls == [ ("project-cg", {"view": "verified-memory", "agent_address": None, "sub_graph_name": "skills"}), ], provider._client.calls +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 "Unknown sub-graph: skills" 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._context_graph = "agent-context" missing_project = json.loads(provider.handle_tool_call("memory_search", { "query": "alpha beta", diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index b55d7ea70..128a823ed 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -281,9 +281,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; @@ -362,9 +365,16 @@ export class DkgMemorySearchManager implements MemorySearchManager { }) .then(r => ({ plan, bindings: extractBindings(r) })) .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}`, ); + if (plan.subGraphName) { + throw new Error( + `memory_search sub_graph_name "${plan.subGraphName}" failed for ` + + `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`, + ); + } return { plan, bindings: [] as any[] }; }), ), diff --git a/packages/adapter-openclaw/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index cb3c6fdbb..dbf293222 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1046,6 +1046,26 @@ describe('DkgMemorySearchManager', () => { 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('rejects projectSubGraphName when no project CG is resolved', async () => { const querySpy = vi.spyOn(client, 'query').mockResolvedValue({ result: { bindings: [] } }); const warn = vi.fn(); diff --git a/packages/adapter-openclaw/test/memory-search-tool.test.ts b/packages/adapter-openclaw/test/memory-search-tool.test.ts index 9f988a708..ab1463d73 100644 --- a/packages/adapter-openclaw/test/memory-search-tool.test.ts +++ b/packages/adapter-openclaw/test/memory-search-tool.test.ts @@ -121,6 +121,29 @@ describe('memory_search tool', () => { 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/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index 4c17746db..dd7f397f0 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -284,29 +284,42 @@ LIMIT ${cap}`; } 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, - subGraphName: plan.subGraphName, - }) - .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> }>; + 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 ?? [] })) + .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) { + throw new Error( + `memory_search subGraphName "${plan.subGraphName}" failed for ` + + `project "${plan.contextGraphId}" (${plan.view}): ${message}`, + ); + } + return { plan, bindings: [] as Array> }; + }), + ), + ); + } catch (err) { + return errResult(formatError(err)); + } // Dedup by (contextGraphId, uri-or-text-hash). Keep the highest- // trust hit; tie-break on raw score. Source: `DkgMemoryPlugin.ts:381-433`. diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index 89aabba63..d6ffb0d5f 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -88,6 +88,31 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { } }); + 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('applies subGraphName to the pinned default project when projectId is omitted', async () => { const localServer = new FakeServer(); const localClient = new FakeClient(); From 70e319727746b840b8973d1b5a06d657cbe053c2 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 05:25:32 +0200 Subject: [PATCH 07/22] fix(tools): align scoped query surfaces --- .../adapter-hermes/hermes-plugin/__init__.py | 34 ++++++++++++++----- .../test/hermes-adapter.test.ts | 8 +++++ .../adapter-openclaw/src/DkgMemoryPlugin.ts | 9 +++-- .../adapter-openclaw/src/DkgNodePlugin.ts | 17 +++++++++- .../adapter-openclaw/src/tools/query-tools.ts | 6 ++++ .../adapter-openclaw/test/dkg-memory.test.ts | 12 +++++++ .../test/memory-search-tool.test.ts | 18 ++++++++++ .../test/plugin.part-03.test.ts | 18 ++++++++-- .../test/plugin.part-06.test.ts | 2 ++ packages/mcp-dkg/src/tools.ts | 11 +++++- packages/mcp-dkg/src/tools/memory-search.ts | 17 ++++++++-- packages/mcp-dkg/test/memory-search.test.ts | 25 ++++++++++++++ packages/mcp-dkg/test/query-schema.test.ts | 18 ++++++++-- 13 files changed, 176 insertions(+), 19 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index 23f7c64b7..106b50328 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 # --------------------------------------------------------------------------- @@ -1437,13 +1451,15 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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 = _first_text(args, "context_graph_id") or self._context_graph + 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 project_context_graph == "agent-context"): + 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: return json.dumps(_cache_memory_search(query, self._cache, limit)) @@ -1455,7 +1471,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: sparql = _build_memory_search_sparql(keywords, limit) agent_address = self._client._resolve_agent_address() 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) @@ -1472,7 +1488,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: scoped_project_layer = bool( project_sub_graph_name and cg == project_context_graph - and cg != "agent-context" + and not _is_agent_context_graph(cg) ) query_kwargs = { "view": view, @@ -1510,7 +1526,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, @@ -1523,13 +1539,13 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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": [], }) @@ -1563,7 +1579,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, }) @@ -2612,7 +2628,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.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index 56c37ac8d..67149db98 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -3233,6 +3233,14 @@ missing_project = json.loads(provider.handle_tool_call("memory_search", { })) 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 128a823ed..2ac1f069b 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,7 @@ 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?.( diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index c91316a9a..4764149cc 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -2667,9 +2667,12 @@ export class DkgNodePlugin { await this.ensureNodePeerId().catch(() => {}); } const session = this.memorySessionResolver.getSession(undefined); + const sessionProjectContextGraphId = session?.projectContextGraphId + ? normalizeContextGraphId(session.projectContextGraphId) + : undefined; if ( projectSubGraphName && - (!session?.projectContextGraphId || session.projectContextGraphId === AGENT_CONTEXT_GRAPH) + (!sessionProjectContextGraphId || sessionProjectContextGraphId === AGENT_CONTEXT_GRAPH) ) { return this.error('"sub_graph_name" requires a selected project context graph for memory_search.'); } @@ -2792,6 +2795,17 @@ export class DkgNodePlugin { 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 @@ -2897,6 +2911,7 @@ export class DkgNodePlugin { view, agentAddress, subGraphName, + assertionName, }); return this.json(result); } catch (err: any) { diff --git a/packages/adapter-openclaw/src/tools/query-tools.ts b/packages/adapter-openclaw/src/tools/query-tools.ts index c4b7798c5..7b90e200d 100644 --- a/packages/adapter-openclaw/src/tools/query-tools.ts +++ b/packages/adapter-openclaw/src/tools/query-tools.ts @@ -96,6 +96,12 @@ export function buildQueryTools(ctx: DkgToolHost): OpenClawTool[] { '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/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index dbf293222..d3429b063 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1081,6 +1081,18 @@ describe('DkgMemorySearchManager', () => { 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-search-tool.test.ts b/packages/adapter-openclaw/test/memory-search-tool.test.ts index ab1463d73..b2adea25d 100644 --- a/packages/adapter-openclaw/test/memory-search-tool.test.ts +++ b/packages/adapter-openclaw/test/memory-search-tool.test.ts @@ -121,6 +121,24 @@ describe('memory_search tool', () => { 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; diff --git a/packages/adapter-openclaw/test/plugin.part-03.test.ts b/packages/adapter-openclaw/test/plugin.part-03.test.ts index 635b6f581..815a68400 100644 --- a/packages/adapter-openclaw/test/plugin.part-03.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-03.test.ts @@ -199,14 +199,16 @@ describe("DkgNodePlugin", () => { await byName.get('dkg_query')!.execute('tc', { sparql: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1', context_graph_id: 'my-cg', - view: 'shared-working-memory', + 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('shared-working-memory'); + 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 () => { @@ -233,6 +235,18 @@ describe("DkgNodePlugin", () => { 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 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 330260f43..fc677a4a2 100644 --- a/packages/adapter-openclaw/test/plugin.part-06.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-06.test.ts @@ -439,6 +439,8 @@ describe("DkgNodePlugin", () => { 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/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index 4d7a4609f..58c2bea71 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -217,6 +217,10 @@ 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() @@ -232,16 +236,21 @@ export function registerReadTools( limit: z.number().optional().describe('Row cap when rendering to markdown; does NOT modify the query'), }, }, - async ({ sparql, projectId, subGraphName, agentAddress, 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.'); + } const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress); const result = await client.query({ sparql: fullSparql, contextGraphId: pid, subGraphName, + 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 dd7f397f0..1d0afab0d 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -82,6 +82,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 @@ -215,12 +228,12 @@ export function registerMemorySearchTool( } const cap = Math.floor(Math.max(1, Math.min(100, limit ?? 20))); const explicitProjectId = projectId?.trim(); - const effectiveProjectId = explicitProjectId || _config.defaultProject || undefined; + 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) { + 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.'); } diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index d6ffb0d5f..a9970d846 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -146,6 +146,31 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { 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.*default project/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', [ diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index 79fee390b..4387fe6dc 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -47,11 +47,13 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () 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 DID-prefixed wallet agentAddress to checksum form for dkg_query', async () => { @@ -76,6 +78,17 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () 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.each(['working-memory', 'shared-working-memory', 'verified-memory'])( 'accepts the canonical view enum value %s', async (view) => { @@ -137,11 +150,12 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () const shape = tool.config.inputSchema!; const keys = Object.keys(shape); // Post-migration surface: sparql, projectId, subGraphName, - // agentAddress, view, includeSharedMemory, limit. The legacy `layer` - // key MUST be gone. + // assertionName, agentAddress, view, includeSharedMemory, limit. + // The legacy `layer` key MUST be gone. expect(keys).toEqual( expect.arrayContaining([ 'sparql', + 'assertionName', 'agentAddress', 'view', 'includeSharedMemory', From 0bb3ab3e1d845edcf22115b326c97cfdf4881faf Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 05:36:22 +0200 Subject: [PATCH 08/22] fix(query): reject ignored scoped options --- .../adapter-hermes/hermes-plugin/__init__.py | 14 ++++++++- .../test/hermes-adapter.test.ts | 12 +++++++ .../adapter-openclaw/src/DkgNodePlugin.ts | 3 ++ .../test/plugin.part-03.test.ts | 13 ++++++++ packages/cli/skills/dkg-node/SKILL.md | 4 +-- packages/cli/src/daemon/routes/query.ts | 13 ++++++++ packages/cli/test/daemon/routes/query.test.ts | 31 +++++++++++++++++++ packages/mcp-dkg/src/tools.ts | 3 ++ packages/mcp-dkg/test/query-schema.test.ts | 12 +++++++ packages/query/src/dkg-query-engine.ts | 4 +++ packages/query/test/sub-graph-query.test.ts | 15 +++++++++ 11 files changed, 121 insertions(+), 3 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index 106b50328..35296634a 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1420,6 +1420,13 @@ def _handle_query(self, args: Dict[str, Any]) -> str: return tool_error('"sub_graph_name" must be a non-empty string.') if _first_text(args, "sub_graph_name") and not cg: return tool_error('"sub_graph_name" requires "context_graph_id" or a configured default context graph.') + 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(): @@ -1435,7 +1442,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"), @@ -1462,6 +1469,11 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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] diff --git a/packages/adapter-hermes/test/hermes-adapter.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index 67149db98..41a90ec93 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -2760,6 +2760,8 @@ for args, needle in [ ({"sparql": "ASK {}", "context_graph": "old"}, "context_graph"), ({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "bad"}, "view"), ({"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)) @@ -3209,6 +3211,16 @@ assert provider._client.calls == [ ("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", { diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index 4764149cc..ea26d6239 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -2831,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 diff --git a/packages/adapter-openclaw/test/plugin.part-03.test.ts b/packages/adapter-openclaw/test/plugin.part-03.test.ts index 815a68400..75fded5ac 100644 --- a/packages/adapter-openclaw/test/plugin.part-03.test.ts +++ b/packages/adapter-openclaw/test/plugin.part-03.test.ts @@ -248,6 +248,19 @@ describe("DkgNodePlugin", () => { 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 // something here" — treating `" "` as "missing" and defaulting diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 5b8cec530..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, 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` narrows WM to one assertion. 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 | @@ -281,7 +281,7 @@ The `memory_search` tool is the recommended entry point for free-text memory rec - `contextGraphId` — scope query to one CG (recommended) - `view` — `working-memory` | `shared-working-memory` | `verified-memory` - `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; may be combined with `subGraphName` + - `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 diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index 1786f1fc0..ed8d50564 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -456,6 +456,19 @@ 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' }); + } + if (view !== 'working-memory') { + return jsonResponse(res, 400, { + error: 'assertionName is only supported with view "working-memory"', + }); + } + } // 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 — diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index 4bcdc7bf4..dabe3dc7b 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -310,4 +310,35 @@ describe('handleQueryRoutes /api/query', () => { 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/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index 58c2bea71..f4f729b50 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -245,6 +245,9 @@ export function registerReadTools( 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); const result = await client.query({ sparql: fullSparql, diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index 4387fe6dc..eb686f2be 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -89,6 +89,18 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () 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) => { diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index a6a5ec7d6..fd4f68ffc 100644 --- a/packages/query/src/dkg-query-engine.ts +++ b/packages/query/src/dkg-query-engine.ts @@ -203,6 +203,10 @@ export class DKGQueryEngine implements QueryEngine { if (!v.valid) throw new Error(`Invalid sub-graph name for query: ${v.reason}`); } + if (options?.assertionName && 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) diff --git a/packages/query/test/sub-graph-query.test.ts b/packages/query/test/sub-graph-query.test.ts index 9a23f7f64..9bb838e5f 100644 --- a/packages/query/test/sub-graph-query.test.ts +++ b/packages/query/test/sub-graph-query.test.ts @@ -161,6 +161,21 @@ describe('sub-graph query scoping', () => { 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 ?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('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 } }`, From 4d2ae388891fc59c69196e834a684adc4b24dbcd Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 05:48:08 +0200 Subject: [PATCH 09/22] fix(tools): preserve memory search partial success --- .../adapter-hermes/hermes-plugin/__init__.py | 43 ++++++++++++++----- .../test/hermes-adapter.test.ts | 42 ++++++++++++++---- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 18 +++++++- .../adapter-openclaw/test/dkg-memory.test.ts | 28 ++++++++++++ packages/mcp-dkg/src/tools/memory-search.ts | 18 +++++++- packages/mcp-dkg/test/memory-search.test.ts | 28 ++++++++++++ 6 files changed, 156 insertions(+), 21 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index 35296634a..f5a368044 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1408,18 +1408,19 @@ 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 cg: + if view and not explicit_cg: return tool_error(f'"view: {view}" requires "context_graph_id".') 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 cg: - return tool_error('"sub_graph_name" requires "context_graph_id" or a configured default context graph.') + 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(): @@ -1515,19 +1516,23 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: **query_kwargs, ) except Exception as e: - if scoped_project_layer: + 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: + continue raise if _client_result_failed(result): + detail = result.get("error") if isinstance(result, dict) else "query failed" if scoped_project_layer: - detail = result.get("error") if isinstance(result, dict) else "query failed" - return tool_error( - f'memory_search sub_graph_name "{project_sub_graph_name}" failed for ' - f'context_graph_id "{cg}" ({view}): {detail}' - ) + 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}' + ) + continue continue successful_queries += 1 for binding in _extract_query_bindings(result): @@ -2439,6 +2444,24 @@ 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() + return ( + "scoped query violation" in text + or "known child context graph" in text + or "unknown sub-graph" in text + or ( + "sub-graph" in text + 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) diff --git a/packages/adapter-hermes/test/hermes-adapter.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index 41a90ec93..45e998f0c 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -2782,6 +2782,17 @@ missing_sub_cg = json.loads(provider_without_default.handle_tool_call("dkg_query 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", @@ -2808,15 +2819,6 @@ result = json.loads(provider.handle_tool_call("dkg_query", { assert result["ok"] is True, result assert provider._client.queries[-1][2]["sub_graph_name"] == "scratch", provider._client.queries -result = json.loads(provider.handle_tool_call("dkg_query", { - "sparql": "ASK {}", - "view": "shared-working-memory", - "sub_graph_name": "scratch", -})) -assert result["ok"] is True, result -assert provider._client.queries[-1][1] == "default-cg", provider._client.queries -assert provider._client.queries[-1][2]["sub_graph_name"] == "scratch", provider._client.queries - class ReadMarkdownClient: def __init__(self): self.calls = [] @@ -3144,6 +3146,7 @@ class FakeClient: def __init__(self): self.calls = [] self.fail_scoped_project = False + self.generic_fail_scoped_project = False def _resolve_agent_address(self): return "0xAgent" @@ -3152,6 +3155,8 @@ class FakeClient: self.calls.append((context_graph_id, kwargs)) if self.fail_scoped_project and kwargs.get("sub_graph_name"): return {"error": "Unknown sub-graph: skills"} + if self.generic_fail_scoped_project and kwargs.get("sub_graph_name"): + return {"error": "fetch failed"} return { "result": { "bindings": [{ @@ -3238,6 +3243,25 @@ assert provider._client.calls == [ ], provider._client.calls provider._client.fail_scoped_project = False +provider._client.calls = [] +provider._client.generic_fail_scoped_project = 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"] == 3, 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 = False + provider._context_graph = "agent-context" missing_project = json.loads(provider.handle_tool_call("memory_search", { "query": "alpha beta", diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index 2ac1f069b..b1e14186e 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -374,7 +374,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { this.deps.logger?.warn?.( `[dkg-memory] ${plan.layer} search failed (cg=${plan.contextGraphId}, view=${plan.view}): ${message}`, ); - if (plan.subGraphName) { + if (plan.subGraphName && isScopedQueryRoutingError(message)) { throw new Error( `memory_search sub_graph_name "${plan.subGraphName}" failed for ` + `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`, @@ -1067,6 +1067,22 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function isScopedQueryRoutingError(message: string): boolean { + const text = message.toLowerCase(); + return text.includes('scoped query violation') || + text.includes('known child context graph') || + text.includes('unknown sub-graph') || + ( + text.includes('sub-graph') && + ( + 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/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index d3429b063..8ed7726cf 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1066,6 +1066,34 @@ describe('DkgMemorySearchManager', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('project-wm search failed')); }); + it('keeps partial-success behavior for generic projectSubGraphName layer failures', async () => { + const warn = vi.fn(); + vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { + if (opts?.subGraphName) { + throw new Error('fetch failed'); + } + return { + result: { + bindings: [{ + uri: { value: 'urn:agent:note' }, + text: { value: 'hello world from agent memory' }, + }], + }, + }; + }); + 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('sessions'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('fetch failed')); + }); + it('rejects projectSubGraphName when no project CG is resolved', async () => { const querySpy = vi.spyOn(client, 'query').mockResolvedValue({ result: { bindings: [] } }); const warn = vi.fn(); diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index 1d0afab0d..c050c83db 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -44,6 +44,22 @@ 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(); + return text.includes('scoped query violation') || + text.includes('known child context graph') || + text.includes('unknown sub-graph') || + ( + text.includes('sub-graph') && + ( + 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 = @@ -320,7 +336,7 @@ LIMIT ${cap}`; process.stderr.write( `[dkg-mcp] memory-search ${plan.layer} failed (cg=${plan.contextGraphId}, view=${plan.view}): ${message}\n`, ); - if (plan.subGraphName) { + if (plan.subGraphName && isScopedQueryRoutingError(message)) { throw new Error( `memory_search subGraphName "${plan.subGraphName}" failed for ` + `project "${plan.contextGraphId}" (${plan.view}): ${message}`, diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index a9970d846..f28caa526 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -113,6 +113,34 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(localClient.queryCalls.filter((call) => call.subGraphName === 'imports').length).toBeGreaterThan(0); }); + it('keeps partial-success behavior for generic project-scoped subGraphName failures', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + query: async function (this: FakeClient, args: Record) { + if (args.subGraphName) { + 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('agent-context::shared-working-memory', [ + { uri: { value: 'urn:agent:note' }, text: { value: 'tree-sitter parsers from agent 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(/agent-context/); + }); + it('applies subGraphName to the pinned default project when projectId is omitted', async () => { const localServer = new FakeServer(); const localClient = new FakeClient(); From 67ce28f68f2d2795d71d3626646efa479a19311e Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 05:57:25 +0200 Subject: [PATCH 10/22] fix(query): validate scoped query names --- packages/cli/src/daemon/routes/query.ts | 12 +++++++ packages/cli/test/daemon/routes/query.test.ts | 31 +++++++++++++++++++ packages/query/src/dkg-query-engine.ts | 15 +++++++-- packages/query/test/sub-graph-query.test.ts | 13 ++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index ed8d50564..97a1d59b9 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -469,6 +469,18 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { }); } } + 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}` }); + } + } // 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 — diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index dabe3dc7b..fc77e3ca4 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -271,6 +271,37 @@ describe('handleQueryRoutes /api/query', () => { 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('forwards view, subGraphName, and assertionName to the agent query route', async () => { const caller = '0x2222222222222222222222222222222222222222'; const agent = { diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index fd4f68ffc..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, @@ -203,8 +203,17 @@ export class DKGQueryEngine implements QueryEngine { if (!v.valid) throw new Error(`Invalid sub-graph name for query: ${v.reason}`); } - if (options?.assertionName && options.view !== 'working-memory') { - throw new Error('assertionName is only supported for view "working-memory" queries'); + 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) { diff --git a/packages/query/test/sub-graph-query.test.ts b/packages/query/test/sub-graph-query.test.ts index 9bb838e5f..9721b001b 100644 --- a/packages/query/test/sub-graph-query.test.ts +++ b/packages/query/test/sub-graph-query.test.ts @@ -176,6 +176,19 @@ describe('sub-graph query scoping', () => { )).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('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 } }`, From 13d8931b9262cfd1f8901fbcb701378b7afa4009 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 06:08:15 +0200 Subject: [PATCH 11/22] fix(api): reject unanchored scoped queries --- packages/cli/src/daemon/routes/query.ts | 7 +++ packages/cli/test/daemon/routes/query.test.ts | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index 97a1d59b9..16297d9c4 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -463,6 +463,10 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { 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"', @@ -480,6 +484,9 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { 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 diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index fc77e3ca4..d08b80b53 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -302,6 +302,66 @@ describe('handleQueryRoutes /api/query', () => { 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 = { From cb409b1e1ec7a747ccdc5359be6f35b31d516a3f Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 06:19:44 +0200 Subject: [PATCH 12/22] fix(api): preserve explicit recall scope --- packages/cli/src/daemon/routes/query.ts | 11 ----- packages/cli/test/daemon/routes/query.test.ts | 14 +++++-- packages/mcp-dkg/src/tools/memory-search.ts | 9 ++-- packages/mcp-dkg/test/memory-search.test.ts | 41 ++++++++++++------- 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index 16297d9c4..45e16f50e 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -590,17 +590,6 @@ export async function handleQueryRoutes(ctx: RequestContext): Promise { && callerAgentAddress === undefined; const hasRecognisedIdentity = isAdminToken || callerAgentAddress !== undefined; const effectiveAgentAddress = requestedAgentAddress; - if ( - !hasRecognisedIdentity && - view === 'working-memory' && - requestedAgentAddress === undefined - ) { - return jsonResponse(res, 403, { - error: - 'working-memory reads without agentAddress require authentication. ' + - 'Provide an agent-scoped bearer token, a node-admin token, or an explicit self agentAddress.', - }); - } if ( !hasRecognisedIdentity && view === 'working-memory' && diff --git a/packages/cli/test/daemon/routes/query.test.ts b/packages/cli/test/daemon/routes/query.test.ts index d08b80b53..cccccc30f 100644 --- a/packages/cli/test/daemon/routes/query.test.ts +++ b/packages/cli/test/daemon/routes/query.test.ts @@ -176,7 +176,7 @@ describe('handleQueryRoutes /api/query', () => { expect(queryOptions).toHaveProperty('agentAddress', undefined); }); - it('does not infer omitted working-memory agentAddress for unauthenticated callers', async () => { + it('leaves omitted working-memory agentAddress to the agent for unauthenticated callers', async () => { const defaultAgent = '0x1111111111111111111111111111111111111111'; const agent = { resolveAgentByToken: vi.fn(), @@ -199,9 +199,15 @@ describe('handleQueryRoutes /api/query', () => { await handleQueryRoutes(ctx); - expect(res.statusCode).toBe(403); - expect(JSON.parse(res.body).error).toMatch(/without agentAddress require authentication/); - expect(agent.query).not.toHaveBeenCalled(); + 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 () => { diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index c050c83db..50b4da6ae 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -228,13 +228,12 @@ export function registerMemorySearchTool( .string() .optional() .describe( - '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.", + 'Optional project context-graph id. When supplied, fan-out adds the project 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.'), + .describe('Optional project sub-graph scope. Requires projectId and applies only to project fan-out.'), }, }, async ({ query, limit, projectId, subGraphName }): Promise => { @@ -244,13 +243,13 @@ export function registerMemorySearchTool( } const cap = Math.floor(Math.max(1, Math.min(100, limit ?? 20))); const explicitProjectId = projectId?.trim(); - const effectiveProjectId = normalizeContextGraphIdForMemorySearch(explicitProjectId || _config.defaultProject || undefined); + const effectiveProjectId = normalizeContextGraphIdForMemorySearch(explicitProjectId || 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.'); + return errResult('"subGraphName" requires "projectId" 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 diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index f28caa526..cccb1f241 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -55,7 +55,7 @@ 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 () => { + it('does not add project layers when only a default project is pinned', async () => { const localServer = new FakeServer(); const localClient = new FakeClient(); registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig({ defaultProject: 'pinned-cg' })); @@ -63,8 +63,9 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { 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); + expect(localClient.queryCalls).toHaveLength(3); + expect(localClient.queryCalls.filter((call) => call.contextGraphId === 'agent-context')).toHaveLength(3); + expect(localClient.queryCalls.filter((call) => call.contextGraphId === 'pinned-cg')).toHaveLength(0); }); it('applies subGraphName only to project context graph fan-out', async () => { @@ -141,27 +142,20 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/agent-context/); }); - it('applies subGraphName to the pinned default project when projectId is omitted', async () => { + it('does not fan out to a 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'); - } + expect(projectCalls).toHaveLength(0); }); it('returns a tool error when subGraphName is supplied without any project scope', async () => { @@ -170,7 +164,7 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { subGraphName: 'imports', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/subGraphName.*projectId.*default project/i); + expect(result.content[0].text).toMatch(/subGraphName.*projectId/i); expect(client.queryCalls).toHaveLength(0); }); @@ -181,7 +175,7 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { subGraphName: 'imports', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/subGraphName.*projectId.*default project/i); + expect(result.content[0].text).toMatch(/subGraphName.*projectId/i); expect(client.queryCalls).toHaveLength(0); const localServer = new FakeServer(); @@ -199,6 +193,25 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(localClient.queryCalls).toHaveLength(0); }); + it('returns a tool error when subGraphName is supplied with only a pinned default project', 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).toBe(true); + expect(result.content[0].text).toMatch(/subGraphName.*projectId/i); + 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', [ From b0b1e65f264204e2e9f9f368ab38404c32e8b532 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 06:30:15 +0200 Subject: [PATCH 13/22] fix(hermes): avoid scoped cache fallback --- .../adapter-hermes/hermes-plugin/__init__.py | 7 +++++ .../test/hermes-adapter.test.ts | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index f5a368044..b31576cbf 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1554,6 +1554,13 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: "predicate": pred, }) + if not hits and successful_queries == 0 and project_sub_graph_name: + return json.dumps({ + "query": query, + "count": 0, + "scope": project_context_graph if not _is_agent_context_graph(project_context_graph) else None, + "hits": [], + }) if not hits and successful_queries == 0: fallback = _cache_memory_search(query, self._cache, limit) fallback["scope"] = project_context_graph if not _is_agent_context_graph(project_context_graph) else None diff --git a/packages/adapter-hermes/test/hermes-adapter.test.ts b/packages/adapter-hermes/test/hermes-adapter.test.ts index 45e998f0c..e1ac97f3e 100644 --- a/packages/adapter-hermes/test/hermes-adapter.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.test.ts @@ -3145,6 +3145,7 @@ 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 = False @@ -3153,6 +3154,8 @@ class FakeClient: 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": "Unknown sub-graph: skills"} if self.generic_fail_scoped_project and kwargs.get("sub_graph_name"): @@ -3262,6 +3265,29 @@ assert provider._client.calls == [ ], 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 "error" not in scoped_all_failed, scoped_all_failed +assert scoped_all_failed["scope"] == "project-cg", scoped_all_failed +assert scoped_all_failed["count"] == 0, scoped_all_failed +assert scoped_all_failed["hits"] == [], 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", From 6440d41e1cdce4eb796e88df40bdae733b24e640 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 06:40:20 +0200 Subject: [PATCH 14/22] fix(mcp): align scoped query contracts --- packages/mcp-dkg/src/tools.ts | 13 +++++-- packages/mcp-dkg/src/tools/memory-search.ts | 9 ++--- packages/mcp-dkg/test/memory-search.test.ts | 39 +++++++-------------- packages/mcp-dkg/test/query-schema.test.ts | 9 ++--- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/packages/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index f4f729b50..d278f3d7f 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -46,7 +46,10 @@ const formatError = (e: unknown): string => const AGENT_DID_PREFIX = 'did:dkg:agent:'; const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; -function normalizeAgentAddressForQuery(agentAddress: string | undefined): string | undefined { +function normalizeAgentAddressForQuery( + agentAddress: string | undefined, + view: 'working-memory' | 'shared-working-memory' | 'verified-memory' | undefined, +): string | undefined { if (agentAddress === undefined) return undefined; const trimmed = agentAddress.trim(); if (!trimmed) { @@ -55,6 +58,12 @@ function normalizeAgentAddressForQuery(agentAddress: string | undefined): string const stripped = trimmed.startsWith(AGENT_DID_PREFIX) ? trimmed.slice(AGENT_DID_PREFIX.length) : trimmed; + if (ETH_ADDRESS_RE.test(stripped) && (view === undefined || view === 'working-memory')) { + throw new Error( + '"agentAddress" for working-memory must be a raw peer ID. ' + + 'Wallet addresses cannot be mapped to the working-memory peer namespace by this tool.', + ); + } return ETH_ADDRESS_RE.test(stripped) ? toEip55Checksum(stripped) : stripped; @@ -248,7 +257,7 @@ export function registerReadTools( if (scopedAssertionName && view !== 'working-memory') { return err('"assertionName" is only supported with view: "working-memory".'); } - const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress); + const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress, view); const result = await client.query({ sparql: fullSparql, contextGraphId: pid, diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index 50b4da6ae..c050c83db 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -228,12 +228,13 @@ export function registerMemorySearchTool( .string() .optional() .describe( - 'Optional project context-graph id. When supplied, fan-out adds the project 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 and applies only to project fan-out.'), + .describe('Optional project sub-graph scope. Requires projectId or a pinned default project and applies only to project fan-out.'), }, }, async ({ query, limit, projectId, subGraphName }): Promise => { @@ -243,13 +244,13 @@ export function registerMemorySearchTool( } const cap = Math.floor(Math.max(1, Math.min(100, limit ?? 20))); const explicitProjectId = projectId?.trim(); - const effectiveProjectId = normalizeContextGraphIdForMemorySearch(explicitProjectId || undefined); + 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" because memory search subgraph scope applies only to project context graph fan-out.'); + 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 diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index cccb1f241..ba207bea7 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -55,7 +55,7 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/proj-x · VM/); }); - it('does not add project layers when only a default project is pinned', async () => { + 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' })); @@ -63,9 +63,8 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { const result = await localServer.call('dkg_memory_search', { query: 'tree-sitter parsers' }); expect(result.isError).toBeFalsy(); - expect(localClient.queryCalls).toHaveLength(3); - expect(localClient.queryCalls.filter((call) => call.contextGraphId === 'agent-context')).toHaveLength(3); - expect(localClient.queryCalls.filter((call) => call.contextGraphId === 'pinned-cg')).toHaveLength(0); + 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 () => { @@ -142,20 +141,27 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/agent-context/); }); - it('does not fan out to a pinned default project when projectId is omitted', async () => { + 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(0); + 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 () => { @@ -164,7 +170,7 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { subGraphName: 'imports', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/subGraphName.*projectId/i); + expect(result.content[0].text).toMatch(/subGraphName.*projectId.*default project/i); expect(client.queryCalls).toHaveLength(0); }); @@ -193,25 +199,6 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(localClient.queryCalls).toHaveLength(0); }); - it('returns a tool error when subGraphName is supplied with only a pinned default project', 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).toBe(true); - expect(result.content[0].text).toMatch(/subGraphName.*projectId/i); - 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', [ diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index eb686f2be..843dfdfec 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -56,15 +56,16 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.assertionName).toBe('chat-turns'); }); - it('normalizes DID-prefixed wallet agentAddress to checksum form for dkg_query', async () => { + it('rejects wallet-shaped working-memory agentAddress because MCP cannot map it to a peer ID', 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.agentAddress).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('agentAddress'); + expect(result.content[0].text).toContain('raw peer ID'); + expect(client.queryCalls).toHaveLength(0); }); it('rejects blank agentAddress in dkg_query', async () => { From def0226489c771a2f0b5087f2260473d074415ad Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 06:51:53 +0200 Subject: [PATCH 15/22] fix(query): validate scoped view targets --- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 18 +------------ .../adapter-openclaw/test/dkg-memory.test.ts | 8 +++--- packages/query/src/dkg-query-engine.ts | 17 ++++++++++++ packages/query/test/sub-graph-query.test.ts | 27 +++++++++++++++++++ 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index b1e14186e..2ac1f069b 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -374,7 +374,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { this.deps.logger?.warn?.( `[dkg-memory] ${plan.layer} search failed (cg=${plan.contextGraphId}, view=${plan.view}): ${message}`, ); - if (plan.subGraphName && isScopedQueryRoutingError(message)) { + if (plan.subGraphName) { throw new Error( `memory_search sub_graph_name "${plan.subGraphName}" failed for ` + `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`, @@ -1067,22 +1067,6 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function isScopedQueryRoutingError(message: string): boolean { - const text = message.toLowerCase(); - return text.includes('scoped query violation') || - text.includes('known child context graph') || - text.includes('unknown sub-graph') || - ( - text.includes('sub-graph') && - ( - 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/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index 8ed7726cf..d1768c333 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1066,7 +1066,7 @@ describe('DkgMemorySearchManager', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('project-wm search failed')); }); - it('keeps partial-success behavior for generic projectSubGraphName layer failures', async () => { + it('surfaces generic projectSubGraphName layer failures instead of returning agent-context-only hits', async () => { const warn = vi.fn(); vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { if (opts?.subGraphName) { @@ -1087,10 +1087,8 @@ describe('DkgMemorySearchManager', () => { logger: { warn }, }); - const hits = await manager.search('hello world', { projectSubGraphName: 'skills' }); - - expect(hits).toHaveLength(1); - expect(hits[0].source).toBe('sessions'); + await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) + .rejects.toThrow(/sub_graph_name "skills".*fetch failed/i); expect(warn).toHaveBeenCalledWith(expect.stringContaining('fetch failed')); }); diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index 65d0d1f05..9431d07b3 100644 --- a/packages/query/src/dkg-query-engine.ts +++ b/packages/query/src/dkg-query-engine.ts @@ -322,6 +322,10 @@ export class DKGQueryEngine implements QueryEngine { effectiveContextGraphId, options.subGraphName, ); + await this.assertViewSubGraphIsRegistered( + effectiveContextGraphId, + options.subGraphName, + ); } return this.queryWithView(sparql, options.view, effectiveContextGraphId, options); } @@ -650,6 +654,19 @@ export class DKGQueryEngine implements QueryEngine { ); } + private async assertViewSubGraphIsRegistered( + contextGraphId: string, + subGraphName: string, + ): Promise { + const registeredSubGraphs = await this.discoverRegisteredSubGraphNames(contextGraphId); + if (registeredSubGraphs.has(subGraphName)) return; + + throw new ScopedQueryViolationError( + `Unknown sub-graph "${subGraphName}" for contextGraphId "${contextGraphId}". ` + + 'Register the sub-graph before querying it.', + ); + } + private async discoverRegisteredAssertionGraphs(contextGraphId: string): Promise> { const graphs = new Set(); const metaGraph = contextGraphMetaUri(contextGraphId); diff --git a/packages/query/test/sub-graph-query.test.ts b/packages/query/test/sub-graph-query.test.ts index 9721b001b..a24d66626 100644 --- a/packages/query/test/sub-graph-query.test.ts +++ b/packages/query/test/sub-graph-query.test.ts @@ -26,6 +26,8 @@ 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 }; @@ -40,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), @@ -189,6 +196,26 @@ describe('sub-graph query scoping', () => { )).rejects.toThrow(/Invalid assertionName.*cannot contain "\/"/); }); + it('rejects view-routed subGraphName when the sub-graph is not registered', async () => { + const staleGraph = contextGraphSubGraphUri(CG_ID, 'stale'); + await store.insert([ + q('urn:view:stale', VIEW_NAME, '"Stale"', staleGraph), + ]); + + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'working-memory', agentAddress: AGENT, subGraphName: 'stale' }, + )).rejects.toThrow(/Unknown sub-graph "stale"/); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'shared-working-memory', subGraphName: 'stale' }, + )).rejects.toThrow(/Unknown sub-graph "stale"/); + await expect(engine.query( + `SELECT ?name WHERE { ?s <${VIEW_NAME}> ?name }`, + { contextGraphId: CG_ID, view: 'verified-memory', subGraphName: 'stale' }, + )).rejects.toThrow(/Unknown sub-graph "stale"/); + }); + 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 } }`, From ea9755b49c2198be4e6519e9f73393931dc9b825 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 07:01:50 +0200 Subject: [PATCH 16/22] fix(tools): respect routed scope semantics --- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 18 +++++++++++++++++- .../adapter-openclaw/test/dkg-memory.test.ts | 8 +++++--- packages/mcp-dkg/src/tools.ts | 2 +- packages/mcp-dkg/test/query-schema.test.ts | 11 +++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index 2ac1f069b..b1e14186e 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -374,7 +374,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { this.deps.logger?.warn?.( `[dkg-memory] ${plan.layer} search failed (cg=${plan.contextGraphId}, view=${plan.view}): ${message}`, ); - if (plan.subGraphName) { + if (plan.subGraphName && isScopedQueryRoutingError(message)) { throw new Error( `memory_search sub_graph_name "${plan.subGraphName}" failed for ` + `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`, @@ -1067,6 +1067,22 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function isScopedQueryRoutingError(message: string): boolean { + const text = message.toLowerCase(); + return text.includes('scoped query violation') || + text.includes('known child context graph') || + text.includes('unknown sub-graph') || + ( + text.includes('sub-graph') && + ( + 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/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index d1768c333..8ed7726cf 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1066,7 +1066,7 @@ describe('DkgMemorySearchManager', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('project-wm search failed')); }); - it('surfaces generic projectSubGraphName layer failures instead of returning agent-context-only hits', async () => { + it('keeps partial-success behavior for generic projectSubGraphName layer failures', async () => { const warn = vi.fn(); vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { if (opts?.subGraphName) { @@ -1087,8 +1087,10 @@ describe('DkgMemorySearchManager', () => { logger: { warn }, }); - await expect(manager.search('hello world', { projectSubGraphName: 'skills' })) - .rejects.toThrow(/sub_graph_name "skills".*fetch failed/i); + const hits = await manager.search('hello world', { projectSubGraphName: 'skills' }); + + expect(hits).toHaveLength(1); + expect(hits[0].source).toBe('sessions'); expect(warn).toHaveBeenCalledWith(expect.stringContaining('fetch failed')); }); diff --git a/packages/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index d278f3d7f..d6b287710 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -58,7 +58,7 @@ function normalizeAgentAddressForQuery( const stripped = trimmed.startsWith(AGENT_DID_PREFIX) ? trimmed.slice(AGENT_DID_PREFIX.length) : trimmed; - if (ETH_ADDRESS_RE.test(stripped) && (view === undefined || view === 'working-memory')) { + if (ETH_ADDRESS_RE.test(stripped) && view === 'working-memory') { throw new Error( '"agentAddress" for working-memory must be a raw peer ID. ' + 'Wallet addresses cannot be mapped to the working-memory peer namespace by this tool.', diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index 843dfdfec..7a9b0931d 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -68,6 +68,17 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(client.queryCalls).toHaveLength(0); }); + it('does not reject wallet-shaped agentAddress 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('0x52908400098527886E0F7030069857D2E4169EE7'); + }); + it('rejects blank agentAddress in dkg_query', async () => { const result = await server.call('dkg_query', { sparql: 'SELECT ?s WHERE { ?s ?p ?o }', From ad727ea8125b1941f7bd0045eef416b078c99e35 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 14:20:43 +0200 Subject: [PATCH 17/22] fix(query): address post-merge review feedback --- .../adapter-hermes/hermes-plugin/__init__.py | 15 +++++++++------ .../test/hermes-adapter.part-12.test.ts | 6 ++---- packages/agent/src/dkg-agent-endorse.ts | 3 +++ packages/cli/test/memory-graph-events.test.ts | 4 ++-- packages/query/src/dkg-query-engine.ts | 17 ----------------- packages/query/test/sub-graph-query.test.ts | 14 +++++++++----- 6 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index b31576cbf..8214a7076 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1490,6 +1490,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: hits: List[Dict[str, Any]] = [] successful_queries = 0 + first_scoped_project_error: Optional[str] = None for cg in context_graphs: for view, weight in ( ("working-memory", 1.0), @@ -1522,6 +1523,8 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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): @@ -1532,6 +1535,8 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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 @@ -1555,12 +1560,10 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: }) if not hits and successful_queries == 0 and project_sub_graph_name: - return json.dumps({ - "query": query, - "count": 0, - "scope": project_context_graph if not _is_agent_context_graph(project_context_graph) else None, - "hits": [], - }) + detail = first_scoped_project_error or "all scoped project queries failed" + return tool_error( + f'memory_search sub_graph_name "{project_sub_graph_name}" failed: {detail}' + ) if not hits and successful_queries == 0: fallback = _cache_memory_search(query, self._cache, limit) fallback["scope"] = project_context_graph if not _is_agent_context_graph(project_context_graph) else None 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 7d2ad3a25..1c194fb3a 100644 --- a/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts @@ -274,10 +274,8 @@ scoped_all_failed = json.loads(provider.handle_tool_call("memory_search", { "limit": 10, "sub_graph_name": "skills", })) -assert "error" not in scoped_all_failed, scoped_all_failed -assert scoped_all_failed["scope"] == "project-cg", scoped_all_failed -assert scoped_all_failed["count"] == 0, scoped_all_failed -assert scoped_all_failed["hits"] == [], scoped_all_failed +assert "sub_graph_name" 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}), diff --git a/packages/agent/src/dkg-agent-endorse.ts b/packages/agent/src/dkg-agent-endorse.ts index da7d80cc6..44d7f3248 100644 --- a/packages/agent/src/dkg-agent-endorse.ts +++ b/packages/agent/src/dkg-agent-endorse.ts @@ -449,6 +449,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { signers: string[]; status: 'verified' | 'partial' | 'no_quorum'; trustLevel: TrustLevel; + subGraphName?: string; }> { const ctx = createOperationContext('verify'); @@ -715,6 +716,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { signers: resolvedSignerAddresses, status, trustLevel, + ...(subGraphName ? { subGraphName } : {}), }; } @@ -767,6 +769,7 @@ export class EndorseVerifyMethods extends DKGAgentBase { signers: resolvedSignerAddresses, status: 'verified', trustLevel: TrustLevel.ConsensusVerified, + ...(subGraphName ? { subGraphName } : {}), }; } diff --git a/packages/cli/test/memory-graph-events.test.ts b/packages/cli/test/memory-graph-events.test.ts index 01c6a0bad..b5c9894a0 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', diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index 9431d07b3..65d0d1f05 100644 --- a/packages/query/src/dkg-query-engine.ts +++ b/packages/query/src/dkg-query-engine.ts @@ -322,10 +322,6 @@ export class DKGQueryEngine implements QueryEngine { effectiveContextGraphId, options.subGraphName, ); - await this.assertViewSubGraphIsRegistered( - effectiveContextGraphId, - options.subGraphName, - ); } return this.queryWithView(sparql, options.view, effectiveContextGraphId, options); } @@ -654,19 +650,6 @@ export class DKGQueryEngine implements QueryEngine { ); } - private async assertViewSubGraphIsRegistered( - contextGraphId: string, - subGraphName: string, - ): Promise { - const registeredSubGraphs = await this.discoverRegisteredSubGraphNames(contextGraphId); - if (registeredSubGraphs.has(subGraphName)) return; - - throw new ScopedQueryViolationError( - `Unknown sub-graph "${subGraphName}" for contextGraphId "${contextGraphId}". ` + - 'Register the sub-graph before querying it.', - ); - } - private async discoverRegisteredAssertionGraphs(contextGraphId: string): Promise> { const graphs = new Set(); const metaGraph = contextGraphMetaUri(contextGraphId); diff --git a/packages/query/test/sub-graph-query.test.ts b/packages/query/test/sub-graph-query.test.ts index a24d66626..ac098fec5 100644 --- a/packages/query/test/sub-graph-query.test.ts +++ b/packages/query/test/sub-graph-query.test.ts @@ -196,24 +196,28 @@ describe('sub-graph query scoping', () => { )).rejects.toThrow(/Invalid assertionName.*cannot contain "\/"/); }); - it('rejects view-routed subGraphName when the sub-graph is not registered', async () => { + 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', VIEW_NAME, '"Stale"', staleGraph), + 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' }, - )).rejects.toThrow(/Unknown sub-graph "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' }, - )).rejects.toThrow(/Unknown sub-graph "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' }, - )).rejects.toThrow(/Unknown sub-graph "stale"/); + )).resolves.toMatchObject({ bindings: [{ name: '"StaleVM"' }] }); }); it('constrains GRAPH patterns to the selected sub-graph WM assertion', async () => { From d36da727d95ea2477b3a0f264f56733b8c358d2e Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 14:34:37 +0200 Subject: [PATCH 18/22] fix(mcp): preserve scoped query identity compatibility --- packages/cli/src/daemon/routes/query.ts | 2 ++ packages/cli/test/memory-graph-events.test.ts | 1 + packages/mcp-dkg/src/tools.ts | 13 ++----------- packages/mcp-dkg/test/query-schema.test.ts | 10 +++++----- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/daemon/routes/query.ts b/packages/cli/src/daemon/routes/query.ts index 45e16f50e..01c82b911 100644 --- a/packages/cli/src/daemon/routes/query.ts +++ b/packages/cli/src/daemon/routes/query.ts @@ -1023,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", }); @@ -1030,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/memory-graph-events.test.ts b/packages/cli/test/memory-graph-events.test.ts index b5c9894a0..f0d64ec77 100644 --- a/packages/cli/test/memory-graph-events.test.ts +++ b/packages/cli/test/memory-graph-events.test.ts @@ -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/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index d6b287710..f4f729b50 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -46,10 +46,7 @@ const formatError = (e: unknown): string => 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 { +function normalizeAgentAddressForQuery(agentAddress: string | undefined): string | undefined { if (agentAddress === undefined) return undefined; const trimmed = agentAddress.trim(); if (!trimmed) { @@ -58,12 +55,6 @@ function normalizeAgentAddressForQuery( const stripped = trimmed.startsWith(AGENT_DID_PREFIX) ? trimmed.slice(AGENT_DID_PREFIX.length) : trimmed; - if (ETH_ADDRESS_RE.test(stripped) && view === 'working-memory') { - throw new Error( - '"agentAddress" for working-memory must be a raw peer ID. ' + - 'Wallet addresses cannot be mapped to the working-memory peer namespace by this tool.', - ); - } return ETH_ADDRESS_RE.test(stripped) ? toEip55Checksum(stripped) : stripped; @@ -257,7 +248,7 @@ export function registerReadTools( if (scopedAssertionName && view !== 'working-memory') { return err('"assertionName" is only supported with view: "working-memory".'); } - const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress, view); + const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress); const result = await client.query({ sparql: fullSparql, contextGraphId: pid, diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index 7a9b0931d..3356682aa 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -56,16 +56,16 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.assertionName).toBe('chat-turns'); }); - it('rejects wallet-shaped working-memory agentAddress because MCP cannot map it to a peer ID', async () => { + 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).toBe(true); - expect(result.content[0].text).toContain('agentAddress'); - expect(result.content[0].text).toContain('raw peer ID'); - expect(client.queryCalls).toHaveLength(0); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBe('working-memory'); + expect(lastCall.agentAddress).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); }); it('does not reject wallet-shaped agentAddress when view is omitted', async () => { From d04f246378ac16ce5a76a58d9291e61ec42688c2 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 14:45:51 +0200 Subject: [PATCH 19/22] fix(mcp): align query scope validation --- packages/mcp-dkg/src/tools.ts | 26 +++++++++++--- packages/mcp-dkg/test/query-schema.test.ts | 40 ++++++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index f4f729b50..e38511424 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -14,7 +14,7 @@ * can see through MCP with the same canonical queries. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { toEip55Checksum } from '@origintrail-official/dkg-core'; +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'; @@ -46,8 +46,12 @@ const formatError = (e: unknown): string => const AGENT_DID_PREFIX = 'did:dkg:agent:'; const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; -function normalizeAgentAddressForQuery(agentAddress: string | undefined): string | undefined { +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.'); @@ -60,6 +64,19 @@ function normalizeAgentAddressForQuery(agentAddress: string | undefined): string : 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 @@ -248,11 +265,12 @@ export function registerReadTools( if (scopedAssertionName && view !== 'working-memory') { return err('"assertionName" is only supported with view: "working-memory".'); } - const normalizedAgentAddress = normalizeAgentAddressForQuery(agentAddress); + 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, diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts index 3356682aa..b881667ce 100644 --- a/packages/mcp-dkg/test/query-schema.test.ts +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -46,7 +46,7 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () sparql: 'SELECT ?s WHERE { ?s ?p ?o }', view: 'working-memory', agentAddress: 'did:dkg:agent:peer-explicit', - subGraphName: 'imports', + subGraphName: ' imports ', assertionName: 'chat-turns', }); expect(result.isError).toBeFalsy(); @@ -68,7 +68,7 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(lastCall.agentAddress).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); }); - it('does not reject wallet-shaped agentAddress when view is omitted', async () => { + 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', @@ -76,7 +76,19 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () expect(result.isError).toBeFalsy(); const lastCall = client.queryCalls.at(-1)!; expect(lastCall.view).toBeUndefined(); - expect(lastCall.agentAddress).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); + 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 () => { @@ -90,6 +102,28 @@ describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () 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 }', From c9b654545d1c8e2d7db800c88d58a6ba2e59d8ca Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 14:58:08 +0200 Subject: [PATCH 20/22] fix(adapters): align scoped query error classification --- .../adapter-hermes/hermes-plugin/__init__.py | 12 +++++++--- .../test/hermes-adapter.part-12.test.ts | 7 +++--- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 6 ++++- .../adapter-openclaw/test/dkg-memory.test.ts | 16 +++++++++++++ packages/mcp-dkg/src/tools/memory-search.ts | 6 ++++- packages/mcp-dkg/test/memory-search.test.ts | 24 +++++++++++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index 8214a7076..1fe8c36ec 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1560,9 +1560,9 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: }) if not hits and successful_queries == 0 and project_sub_graph_name: - detail = first_scoped_project_error or "all scoped project queries failed" + detail = first_scoped_project_error or "all live queries failed" return tool_error( - f'memory_search sub_graph_name "{project_sub_graph_name}" failed: {detail}' + f"memory_search failed: {detail}" ) if not hits and successful_queries == 0: fallback = _cache_memory_search(query, self._cache, limit) @@ -2456,12 +2456,18 @@ def _client_result_failed(result: Any) -> bool: 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 ( - "sub-graph" in text + mentions_sub_graph and ( "registered" in text or "invalid" in text 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 1c194fb3a..9ef9cd9d7 100644 --- a/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts @@ -158,7 +158,7 @@ class FakeClient: if self.fail_all: return {"error": "fetch failed"} if self.fail_scoped_project and kwargs.get("sub_graph_name"): - return {"error": "Unknown sub-graph: skills"} + return {"error": "Invalid subGraphName: Sub-graph names cannot contain \"/\""} if self.generic_fail_scoped_project and kwargs.get("sub_graph_name"): return {"error": "fetch failed"} return { @@ -238,7 +238,7 @@ failed_scoped = json.loads(provider.handle_tool_call("memory_search", { "sub_graph_name": "skills", })) assert "sub_graph_name" in failed_scoped["error"], failed_scoped -assert "Unknown sub-graph: skills" 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}), @@ -274,7 +274,8 @@ scoped_all_failed = json.loads(provider.handle_tool_call("memory_search", { "limit": 10, "sub_graph_name": "skills", })) -assert "sub_graph_name" in scoped_all_failed["error"], scoped_all_failed +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"}), diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index b1e14186e..9c97950a7 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -1069,11 +1069,15 @@ function errorMessage(err: unknown): string { 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') || ( - text.includes('sub-graph') && + mentionsSubGraph && ( text.includes('registered') || text.includes('invalid') || diff --git a/packages/adapter-openclaw/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index 8ed7726cf..90b4e9a77 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1066,6 +1066,22 @@ describe('DkgMemorySearchManager', () => { 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 for generic projectSubGraphName layer failures', async () => { const warn = vi.fn(); vi.spyOn(client, 'query').mockImplementation(async (_sparql, opts) => { diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index c050c83db..9c0699514 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -46,11 +46,15 @@ const formatError = (e: unknown): string => 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') || ( - text.includes('sub-graph') && + mentionsSubGraph && ( text.includes('registered') || text.includes('invalid') || diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index ba207bea7..bbaac6a64 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -113,6 +113,30 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { 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 for generic project-scoped subGraphName failures', async () => { const localServer = new FakeServer(); const localClient = new FakeClient({ From a65237cb9057acdc99247e1354831634db7623ea Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 15:09:07 +0200 Subject: [PATCH 21/22] fix(memory): fail closed on total scoped search outages --- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 12 ++++++++++-- .../adapter-openclaw/test/dkg-memory.test.ts | 15 +++++++++++++++ packages/mcp-dkg/src/tools/memory-search.ts | 13 ++++++++++--- packages/mcp-dkg/test/memory-search.test.ts | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index 9c97950a7..95b68c61b 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -359,6 +359,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { ); } + let firstScopedProjectError: string | undefined; const settled = await Promise.all( plans.map(plan => this.deps.client @@ -368,7 +369,7 @@ export class DkgMemorySearchManager implements MemorySearchManager { 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?.( @@ -380,11 +381,18 @@ export class DkgMemorySearchManager implements MemorySearchManager { `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`, ); } - return { plan, bindings: [] as any[] }; + if (plan.subGraphName && firstScopedProjectError === undefined) { + firstScopedProjectError = `context graph "${plan.contextGraphId}" (${plan.view}): ${message}`; + } + return { plan, bindings: [] as any[], succeeded: false }; }), ), ); + if (projectSubGraphName && !settled.some(s => s.succeeded) && 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 diff --git a/packages/adapter-openclaw/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index 90b4e9a77..c280bc79a 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1110,6 +1110,21 @@ describe('DkgMemorySearchManager', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('fetch 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(); diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index 9c0699514..dacd34415 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -322,7 +322,8 @@ LIMIT ${cap}`; // 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> }>; + let settled: Array<{ plan: LayerPlan; bindings: Array>; succeeded: boolean }>; + let firstScopedProjectError: string | undefined; try { settled = await Promise.all( plans.map((plan) => @@ -334,7 +335,7 @@ LIMIT ${cap}`; agentAddress, subGraphName: plan.subGraphName, }) - .then((r) => ({ plan, bindings: r.bindings ?? [] })) + .then((r) => ({ plan, bindings: r.bindings ?? [], succeeded: true })) .catch((err) => { const message = formatError(err); process.stderr.write( @@ -346,13 +347,19 @@ LIMIT ${cap}`; `project "${plan.contextGraphId}" (${plan.view}): ${message}`, ); } - return { plan, bindings: [] as Array> }; + 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)); } + if (projectSubGraphName && !settled.some((s) => s.succeeded) && 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`. diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index bbaac6a64..46c509382 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -165,6 +165,25 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/agent-context/); }); + 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(); From bfc144c74459adc752797ec57f891f28b4720779 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 6 Jun 2026 15:24:24 +0200 Subject: [PATCH 22/22] fix(memory): require scoped project search success --- .../adapter-hermes/hermes-plugin/__init__.py | 5 ++- .../test/hermes-adapter.part-12.test.ts | 27 +++++++++++- .../adapter-openclaw/src/DkgMemoryPlugin.ts | 3 +- .../adapter-openclaw/test/dkg-memory.test.ts | 41 +++++++++++++++---- packages/mcp-dkg/src/tools/memory-search.ts | 3 +- packages/mcp-dkg/test/memory-search.test.ts | 33 ++++++++++++--- 6 files changed, 95 insertions(+), 17 deletions(-) diff --git a/packages/adapter-hermes/hermes-plugin/__init__.py b/packages/adapter-hermes/hermes-plugin/__init__.py index 1fe8c36ec..5f55f6c73 100644 --- a/packages/adapter-hermes/hermes-plugin/__init__.py +++ b/packages/adapter-hermes/hermes-plugin/__init__.py @@ -1490,6 +1490,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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 ( @@ -1540,6 +1541,8 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: 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")) @@ -1559,7 +1562,7 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str: "predicate": pred, }) - if not hits and successful_queries == 0 and project_sub_graph_name: + 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}" 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 9ef9cd9d7..a63eeef53 100644 --- a/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts +++ b/packages/adapter-hermes/test/hermes-adapter.part-12.test.ts @@ -148,6 +148,7 @@ class FakeClient: 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): @@ -159,6 +160,8 @@ class FakeClient: 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 { @@ -248,14 +251,34 @@ assert provider._client.calls == [ provider._client.fail_scoped_project = False provider._client.calls = [] -provider._client.generic_fail_scoped_project = True +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"] == 3, 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}), diff --git a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts index 95b68c61b..73c56bc1e 100644 --- a/packages/adapter-openclaw/src/DkgMemoryPlugin.ts +++ b/packages/adapter-openclaw/src/DkgMemoryPlugin.ts @@ -389,7 +389,8 @@ export class DkgMemorySearchManager implements MemorySearchManager { ), ); - if (projectSubGraphName && !settled.some(s => s.succeeded) && firstScopedProjectError) { + const scopedProjectSucceeded = settled.some(s => Boolean(s.plan.subGraphName) && s.succeeded); + if (projectSubGraphName && !scopedProjectSucceeded && firstScopedProjectError) { throw new Error(`memory_search failed: ${firstScopedProjectError}`); } diff --git a/packages/adapter-openclaw/test/dkg-memory.test.ts b/packages/adapter-openclaw/test/dkg-memory.test.ts index c280bc79a..cb8c793d5 100644 --- a/packages/adapter-openclaw/test/dkg-memory.test.ts +++ b/packages/adapter-openclaw/test/dkg-memory.test.ts @@ -1082,18 +1082,25 @@ describe('DkgMemorySearchManager', () => { .rejects.toThrow(/sub_graph_name "bad\/name".*Invalid subGraphName/i); }); - it('keeps partial-success behavior for generic projectSubGraphName layer failures', async () => { + 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) { + 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: [{ - uri: { value: 'urn:agent:note' }, - text: { value: 'hello world from agent memory' }, - }], + bindings: [], }, }; }); @@ -1106,10 +1113,30 @@ describe('DkgMemorySearchManager', () => { const hits = await manager.search('hello world', { projectSubGraphName: 'skills' }); expect(hits).toHaveLength(1); - expect(hits[0].source).toBe('sessions'); + 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')); diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts index dacd34415..fbb500d45 100644 --- a/packages/mcp-dkg/src/tools/memory-search.ts +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -357,7 +357,8 @@ LIMIT ${cap}`; } catch (err) { return errResult(formatError(err)); } - if (projectSubGraphName && !settled.some((s) => s.succeeded) && firstScopedProjectError) { + const scopedProjectSucceeded = settled.some((s) => Boolean(s.plan.subGraphName) && s.succeeded); + if (projectSubGraphName && !scopedProjectSucceeded && firstScopedProjectError) { return errResult(`memory_search failed: ${firstScopedProjectError}`); } diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts index 46c509382..10ad5841f 100644 --- a/packages/mcp-dkg/test/memory-search.test.ts +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -137,11 +137,11 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.content[0].text).toMatch(/subGraphName "imports".*subGraphName requires contextGraphId/i); }); - it('keeps partial-success behavior for generic project-scoped subGraphName failures', async () => { + 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) { + if (args.subGraphName && args.view === 'working-memory') { throw new Error('fetch failed'); } const cgId = String(args.contextGraphId ?? ''); @@ -149,8 +149,8 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { return { bindings: this.memoryFixtures.get(`${cgId}::${view}`) ?? [] }; } as never, }); - localClient.memoryFixtures.set('agent-context::shared-working-memory', [ - { uri: { value: 'urn:agent:note' }, text: { value: 'tree-sitter parsers from agent memory' } }, + 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 })); @@ -162,7 +162,30 @@ describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { expect(result.isError).toBeFalsy(); expect(result.content[0].text).toMatch(/1 hit\(s\)/); - expect(result.content[0].text).toMatch(/agent-context/); + 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 () => {