Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions packages/adapter-hermes/hermes-plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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"],
},
Expand Down Expand Up @@ -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.')
Comment thread
Jurij89 marked this conversation as resolved.
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():
Expand Down Expand Up @@ -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))

Expand All @@ -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:
Expand All @@ -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
Expand Down
38 changes: 36 additions & 2 deletions packages/adapter-hermes/test/hermes-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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 = []
Expand Down Expand Up @@ -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(),
Expand Down
13 changes: 13 additions & 0 deletions packages/adapter-openclaw/src/DkgMemoryPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Comment thread
Jurij89 marked this conversation as resolved.
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
Expand Down Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -319,6 +328,7 @@ export class DkgMemorySearchManager implements MemorySearchManager {
contextGraphId: projectContextGraphId,
view: 'working-memory',
sparql: permissiveSparql,
subGraphName: projectSubGraphName,
},
{
layer: 'project-swm',
Expand All @@ -327,6 +337,7 @@ export class DkgMemorySearchManager implements MemorySearchManager {
contextGraphId: projectContextGraphId,
view: 'shared-working-memory',
sparql: permissiveSparql,
subGraphName: projectSubGraphName,
},
{
layer: 'project-vm',
Expand All @@ -335,6 +346,7 @@ export class DkgMemorySearchManager implements MemorySearchManager {
contextGraphId: projectContextGraphId,
view: 'verified-memory',
sparql: permissiveSparql,
subGraphName: projectSubGraphName,
},
);
}
Expand All @@ -346,6 +358,7 @@ export class DkgMemorySearchManager implements MemorySearchManager {
contextGraphId: plan.contextGraphId,
view: plan.view,
agentAddress,
subGraphName: plan.subGraphName,
Comment thread
Jurij89 marked this conversation as resolved.
})
.then(r => ({ plan, bindings: extractBindings(r) }))
.catch(err => {
Expand Down
39 changes: 38 additions & 1 deletion packages/adapter-openclaw/src/DkgNodePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2860,6 +2896,7 @@ export class DkgNodePlugin {
contextGraphId,
view,
agentAddress,
subGraphName,
});
return this.json(result);
} catch (err: any) {
Expand Down
6 changes: 6 additions & 0 deletions packages/adapter-openclaw/src/tools/memory-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
6 changes: 6 additions & 0 deletions packages/adapter-openclaw/src/tools/query-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
5 changes: 5 additions & 0 deletions packages/adapter-openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions packages/adapter-openclaw/test/dkg-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down
22 changes: 22 additions & 0 deletions packages/adapter-openclaw/test/memory-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});
});
Loading
Loading