Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
107 changes: 89 additions & 18 deletions packages/adapter-hermes/hermes-plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -279,6 +293,10 @@ def _scoped_session_id(raw_session_id: str, config: Optional[dict] = None) -> st
"type": "string",
"description": "Optional Working Memory owner address. Defaults to this node when view is working-memory.",
},
"sub_graph_name": {
"type": "string",
"description": "Optional sub-graph scope within context_graph_id. May be combined with view.",
},
"assertion_name": {
"type": "string",
"description": "Optional assertion name scope.",
Expand Down Expand Up @@ -804,6 +822,10 @@ def _scoped_session_id(raw_session_id: str, config: Optional[dict] = None) -> st
"query": {"type": "string", "description": "Free-text search query."},
"limit": {"type": "integer", "description": "Max results, default 20, capped at 100."},
"context_graph_id": {"type": "string", "description": "Optional context graph override. " + EXISTING_CONTEXT_GRAPH_ID_DESCRIPTION},
"sub_graph_name": {
"type": "string",
"description": "Optional project sub-graph scope. Requires a project context graph.",
},
},
"required": ["query"],
},
Expand Down Expand Up @@ -1390,10 +1412,21 @@ 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:
Comment thread
Jurij89 marked this conversation as resolved.
Outdated
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 _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():
Expand All @@ -1409,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"),
Expand All @@ -1423,7 +1456,24 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str:
if len(query) < 2:
return tool_error("query must be at least 2 characters.")
limit = _coerce_limit(args.get("limit"), default=20, maximum=100)
if "context_graph" in args:
return tool_error('"context_graph" is not a supported parameter on memory_search. Use "context_graph_id".')
project_context_graph = _normalize_context_graph_id(
_first_text(args, "context_graph_id") or self._context_graph
)
if args.get("sub_graph_name") is not None and not isinstance(args.get("sub_graph_name"), str):
return tool_error('"sub_graph_name" must be a string.')
if isinstance(args.get("sub_graph_name"), str) and not args.get("sub_graph_name", "").strip():
return tool_error('"sub_graph_name" must be a non-empty string.')
project_sub_graph_name = _first_text(args, "sub_graph_name")
if project_sub_graph_name and (not project_context_graph or _is_agent_context_graph(project_context_graph)):
Comment thread
Jurij89 marked this conversation as resolved.
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]
Expand All @@ -1432,11 +1482,8 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str:

sparql = _build_memory_search_sparql(keywords, limit)
agent_address = self._client._resolve_agent_address()
if "context_graph" in args:
return tool_error('"context_graph" is not a supported parameter on memory_search. Use "context_graph_id".')
project_context_graph = _first_text(args, "context_graph_id") or self._context_graph
context_graphs: List[str] = []
for cg in ("agent-context", project_context_graph):
for cg in (AGENT_CONTEXT_GRAPH_ID, project_context_graph):
if cg and cg not in context_graphs:
context_graphs.append(cg)

Expand All @@ -1450,13 +1497,37 @@ def _handle_memory_search(self, args: Dict[str, Any]) -> str:
):
if view == "working-memory" and not agent_address:
continue
result = self._client.query(
sparql,
cg,
view=view,
agent_address=agent_address if view == "working-memory" else None,
scoped_project_layer = bool(
project_sub_graph_name
and cg == project_context_graph
and not _is_agent_context_graph(cg)
)
query_kwargs = {
"view": view,
"agent_address": agent_address if view == "working-memory" else None,
}
if scoped_project_layer:
query_kwargs["sub_graph_name"] = project_sub_graph_name
try:
result = self._client.query(
sparql,
cg,
**query_kwargs,
)
except Exception as e:
if scoped_project_layer:
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):
Expand All @@ -1467,7 +1538,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,
Expand All @@ -1480,13 +1551,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)
Comment thread
Jurij89 marked this conversation as resolved.
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": [],
})

Expand Down Expand Up @@ -1520,7 +1591,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,
})

Expand Down Expand Up @@ -2569,7 +2640,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}"


Expand Down
103 changes: 100 additions & 3 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 @@ -2758,13 +2759,29 @@ for args, needle in [
({"sparql": "ASK {}", "include_shared_memory": True}, "include_shared_memory"),
({"sparql": "ASK {}", "context_graph": "old"}, "context_graph"),
({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "bad"}, "view"),
({"sparql": "ASK {}", "view": "working-memory"}, "context_graph_id"),
({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "shared-working-memory", "sub_graph_name": "scratch"}, "sub_graph_name"),
({"sparql": "ASK {}", "context_graph_id": "cg:test", "sub_graph_name": 42}, "sub_graph_name"),
({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "verified-memory", "assertion_name": "turn"}, "assertion_name"),
({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "working-memory", "assertion_name": 42}, "assertion_name"),
({"sparql": "ASK {}", "context_graph_id": "cg:test", "view": "working-memory", "agent_address": " "}, "agent_address"),
]:
result = json.loads(provider.handle_tool_call("dkg_query", args))
assert needle in result["error"], (args, result)

provider_without_default = module.DKGMemoryProvider()
provider_without_default._offline = False
provider_without_default._context_graph = None
provider_without_default._client = QueryClient()
missing_view_cg = json.loads(provider_without_default.handle_tool_call("dkg_query", {
"sparql": "ASK {}",
"view": "working-memory",
}))
missing_sub_cg = json.loads(provider_without_default.handle_tool_call("dkg_query", {
"sparql": "ASK {}",
"sub_graph_name": "scratch",
}))
assert "context_graph_id" in missing_view_cg["error"], missing_view_cg
assert "sub_graph_name" in missing_sub_cg["error"] and "context_graph_id" in missing_sub_cg["error"], missing_sub_cg

result = json.loads(provider.handle_tool_call("dkg_query", {
"sparql": "ASK {}",
"context_graph_id": "cg:test",
Expand All @@ -2782,6 +2799,24 @@ 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

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 = []
Expand Down Expand Up @@ -3108,12 +3143,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": [{
Expand Down Expand Up @@ -3156,6 +3194,65 @@ assert provider._client.calls == [
("project-cg", {"view": "shared-working-memory", "agent_address": None}),
("project-cg", {"view": "verified-memory", "agent_address": None}),
], provider._client.calls

provider._client.calls = []
scoped = json.loads(provider.handle_tool_call("memory_search", {
"query": "alpha beta",
"limit": 10,
"sub_graph_name": "skills",
}))
assert scoped["scope"] == "project-cg", scoped
assert provider._client.calls == [
("agent-context", {"view": "working-memory", "agent_address": "0xAgent"}),
("agent-context", {"view": "shared-working-memory", "agent_address": None}),
("agent-context", {"view": "verified-memory", "agent_address": None}),
("project-cg", {"view": "working-memory", "agent_address": "0xAgent", "sub_graph_name": "skills"}),
("project-cg", {"view": "shared-working-memory", "agent_address": None, "sub_graph_name": "skills"}),
("project-cg", {"view": "verified-memory", "agent_address": None, "sub_graph_name": "skills"}),
], provider._client.calls

provider._offline = True
offline_scoped = json.loads(provider.handle_tool_call("memory_search", {
"query": "alpha beta",
"limit": 10,
"sub_graph_name": "skills",
}))
assert "sub_graph_name" in offline_scoped["error"], offline_scoped
assert "offline" in offline_scoped["error"], offline_scoped
provider._offline = False

provider._client.calls = []
provider._client.fail_scoped_project = True
failed_scoped = json.loads(provider.handle_tool_call("memory_search", {
"query": "alpha beta",
"limit": 10,
"sub_graph_name": "skills",
}))
assert "sub_graph_name" in failed_scoped["error"], failed_scoped
assert "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",
"sub_graph_name": "skills",
}))
assert "sub_graph_name" in missing_project["error"], missing_project
assert "project context graph" in missing_project["error"], missing_project

provider._context_graph = "did:dkg:context-graph:agent-context"
missing_project_uri = json.loads(provider.handle_tool_call("memory_search", {
"query": "alpha beta",
"sub_graph_name": "skills",
}))
assert "sub_graph_name" in missing_project_uri["error"], missing_project_uri
assert "project context graph" in missing_project_uri["error"], missing_project_uri
`;
const result = spawnSync('python', ['-B', '-c', script], {
cwd: process.cwd(),
Expand Down
Loading
Loading