Skip to content

Commit 583b603

Browse files
authored
Merge pull request #4 from basicmachines-co/feat/bm-project-routing
feat: per-call project/project_id routing on bm_* tools
2 parents 2a77ceb + 0057fc7 commit 583b603

3 files changed

Lines changed: 156 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.3.0] — 2026-05-12
8+
9+
### Added
10+
- **Per-call project routing on every `bm_*` tool.** All eight tools now accept optional `project` (name) and `project_id` (UUID from `bm_projects`) parameters. The agent can write or read against a project other than the Hermes-configured one — useful when the user asks to write into a different cloud project (e.g. a personal `main` project) without reconfiguring the plugin. `project_id` takes precedence over `project`; both fall back to the configured default when omitted. Workspace routing is handled transparently by BM via `project_id` — no separate workspace parameter is needed.
11+
12+
### Notes
13+
- This is the first of four planned improvements from a real-world friction note ("Hermes Basic Memory Cloud Task Experience"). Follow-ups will: promote `/bm-project` and `/bm-workspace` to agent-facing tools (`bm_projects`, `bm_workspaces`); add a `bm_import` tool for file-on-disk → BM-note in one call; and update SKILL.md with a worked cross-project workflow.
14+
715
## [0.2.0] — 2026-05-11
816

917
### Added

__init__.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,35 @@
201201
]
202202

203203

204+
# Per-call project routing. Every bm_* tool accepts these — the agent overrides
205+
# Hermes's configured project to read/write against a different Basic Memory
206+
# project (e.g. a personal "main" project on BM Cloud). project_id is the
207+
# UUID-based unambiguous form: required when the same project name exists in
208+
# multiple cloud workspaces. _translate_args sends only one of the two to BM,
209+
# with project_id winning when both are passed.
210+
_PROJECT_ROUTING_PROPS: Dict[str, Dict[str, Any]] = {
211+
"project": {
212+
"type": "string",
213+
"description": (
214+
"Optional. Override the active Basic Memory project (e.g. 'main'). "
215+
"If the same project name exists in multiple cloud workspaces, "
216+
"use project_id instead for unambiguous routing."
217+
),
218+
},
219+
"project_id": {
220+
"type": "string",
221+
"description": (
222+
"Optional. Override by project UUID (external_id from bm_projects). "
223+
"Disambiguates when a project name appears in multiple workspaces. "
224+
"Takes precedence over `project` if both are supplied."
225+
),
226+
},
227+
}
228+
229+
for _schema in TOOL_SCHEMAS:
230+
_schema["parameters"]["properties"].update(_PROJECT_ROUTING_PROPS)
231+
232+
204233
# ---------------------------------------------------------------------------
205234
# Helpers
206235
# ---------------------------------------------------------------------------
@@ -578,10 +607,26 @@ def shutdown(self, timeout: float = 5.0) -> None:
578607
# ---------------------------------------------------------------------------
579608

580609
def _translate_args(
581-
hermes_tool: str, args: Dict[str, Any], project: str
610+
hermes_tool: str, args: Dict[str, Any], default_project: str
582611
) -> Tuple[str, Dict[str, Any]]:
583612
bm_tool = _HERMES_TO_BM[hermes_tool]
584-
out: Dict[str, Any] = {"project": project}
613+
out: Dict[str, Any] = {}
614+
615+
# Project routing: project_id > project > configured default.
616+
# The agent passes one of these to operate on a project other than the
617+
# one Hermes is configured for. project_id (UUID from bm_projects) is
618+
# the unambiguous form across cloud workspaces — preferred when project
619+
# names might collide between workspaces. Only one of the two reaches
620+
# BM so server-side precedence rules don't enter the picture.
621+
project_id_override = args.get("project_id")
622+
project_name_override = args.get("project")
623+
if project_id_override:
624+
out["project_id"] = str(project_id_override)
625+
elif project_name_override:
626+
out["project"] = str(project_name_override)
627+
else:
628+
out["project"] = default_project
629+
585630
if hermes_tool == "bm_search":
586631
out["query"] = args["query"]
587632
if "limit" in args and args["limit"] is not None:
@@ -840,7 +885,12 @@ def system_prompt_block(self) -> str:
840885
"- `bm_delete(identifier)` / `bm_move(identifier, new_folder)` — "
841886
"maintenance\n"
842887
"- `bm_recent(timeframe)` — list notes updated within a window "
843-
"(default 7d) when there's no specific query yet"
888+
"(default 7d) when there's no specific query yet\n"
889+
"\n"
890+
"**Cross-project routing.** Every tool accepts optional `project` "
891+
"(name) or `project_id` (UUID). Omit both to use the active "
892+
f"project (`{self._project}`). Use `project_id` (from `bm_projects`) "
893+
"when the same project name exists in multiple cloud workspaces."
844894
)
845895

846896
def get_tool_schemas(self) -> List[Dict[str, Any]]:

tests/test_helpers.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,101 @@ def test_translate_recent_full(bm):
291291
}
292292

293293

294+
# ---- Per-call project routing ----
295+
296+
def test_translate_uses_default_project_when_no_override(bm):
297+
"""Existing behavior preserved: with no project override, the configured
298+
default flows through."""
299+
_, args = bm._translate_args("bm_search", {"query": "hi"}, "default-proj")
300+
assert args["project"] == "default-proj"
301+
assert "project_id" not in args
302+
303+
304+
def test_translate_uses_project_name_override(bm):
305+
"""Agent passes project="main" → that name reaches BM, not the default."""
306+
_, args = bm._translate_args(
307+
"bm_search", {"query": "hi", "project": "main"}, "default-proj"
308+
)
309+
assert args["project"] == "main"
310+
assert "project_id" not in args
311+
312+
313+
def test_translate_uses_project_id_override(bm):
314+
"""Agent passes project_id=<uuid> → reaches BM as project_id, with no
315+
project name in the call (would be redundant and risk server-side
316+
precedence surprises)."""
317+
uuid = "01HXYZ123ABC456DEF789GHI"
318+
_, args = bm._translate_args(
319+
"bm_search", {"query": "hi", "project_id": uuid}, "default-proj"
320+
)
321+
assert args["project_id"] == uuid
322+
assert "project" not in args
323+
324+
325+
def test_translate_project_id_wins_when_both_supplied(bm):
326+
"""If the agent passes both, project_id is the more specific identifier
327+
(UUID across workspaces) and takes precedence. Only project_id reaches BM."""
328+
uuid = "01HXYZ123ABC456DEF789GHI"
329+
_, args = bm._translate_args(
330+
"bm_search",
331+
{"query": "hi", "project": "main", "project_id": uuid},
332+
"default-proj",
333+
)
334+
assert args["project_id"] == uuid
335+
assert "project" not in args
336+
337+
338+
def test_translate_routing_coerces_to_string(bm):
339+
"""Defensive: if a model passes a non-string identifier (e.g. an int),
340+
coerce rather than crash. BM accepts strings."""
341+
_, args = bm._translate_args(
342+
"bm_search", {"query": "hi", "project_id": 12345}, "default-proj"
343+
)
344+
assert args["project_id"] == "12345"
345+
346+
347+
@pytest.mark.parametrize("tool,base_args", [
348+
("bm_search", {"query": "x"}),
349+
("bm_read", {"identifier": "x"}),
350+
("bm_write", {"title": "t", "content": "c", "folder": "f"}),
351+
("bm_edit", {"identifier": "x", "operation": "append", "content": "c"}),
352+
("bm_context", {"url": "memory://x"}),
353+
("bm_delete", {"identifier": "x"}),
354+
("bm_move", {"identifier": "x", "new_folder": "f"}),
355+
("bm_recent", {}),
356+
])
357+
def test_translate_routing_works_for_every_tool(bm, tool, base_args):
358+
"""Routing applies uniformly across all eight tools."""
359+
args_with = dict(base_args, project="main")
360+
_, out = bm._translate_args(tool, args_with, "default-proj")
361+
assert out["project"] == "main"
362+
363+
args_with_id = dict(base_args, project_id="uuid-1")
364+
_, out = bm._translate_args(tool, args_with_id, "default-proj")
365+
assert out["project_id"] == "uuid-1"
366+
assert "project" not in out
367+
368+
_, out = bm._translate_args(tool, base_args, "default-proj")
369+
assert out["project"] == "default-proj"
370+
371+
372+
# ---- TOOL_SCHEMAS routing properties ----
373+
374+
def test_every_tool_schema_advertises_project_routing(bm):
375+
"""Every bm_* tool must expose `project` and `project_id` so the agent
376+
sees them in the tool surface. Regression: forgetting to add routing
377+
props to a new tool would silently lock the agent into the active
378+
project — exactly the friction Drew's note flagged."""
379+
for schema in bm.TOOL_SCHEMAS:
380+
props = schema["parameters"]["properties"]
381+
assert "project" in props, f"{schema['name']} missing project prop"
382+
assert "project_id" in props, f"{schema['name']} missing project_id prop"
383+
# Routing is always optional — never in `required`.
384+
required = schema["parameters"].get("required", [])
385+
assert "project" not in required
386+
assert "project_id" not in required
387+
388+
294389
# ---- _default_project / _hostname ----
295390

296391
def test_default_project_format(bm):

0 commit comments

Comments
 (0)