feat(hindsight): optional bank_id_template for per-agent / per-user banks

Adds an optional bank_id_template config that derives the bank name at
initialize() time from runtime context. Existing users with a static
bank_id keep the current behavior (template is empty by default).

Supported placeholders:
  {profile}   — active Hermes profile (agent_identity kwarg)
  {workspace} — Hermes workspace (agent_workspace kwarg)
  {platform}  — cli, telegram, discord, etc.
  {user}      — platform user id (gateway sessions)
  {session}   — session id

Unsafe characters in placeholder values are sanitized, and empty
placeholders collapse cleanly (e.g. "hermes-{user}" with no user
becomes "hermes"). If the template renders empty, the static bank_id
is used as a fallback.

Common uses:
  bank_id_template: hermes-{profile}            # isolate per Hermes profile
  bank_id_template: {workspace}-{profile}       # workspace + profile scoping
  bank_id_template: hermes-{user}               # per-user banks for gateway
This commit is contained in:
Nicolò Boschi 2026-04-10 17:13:47 +02:00 committed by Teknium
parent f9c6c5ab84
commit edff2fbe7e
3 changed files with 223 additions and 4 deletions

View file

@ -19,6 +19,8 @@ from plugins.memory.hindsight import (
RETAIN_SCHEMA,
_load_config,
_normalize_retain_tags,
_resolve_bank_id_template,
_sanitize_bank_segment,
)
@ -782,7 +784,7 @@ class TestConfigSchema:
keys = {f["key"] for f in schema}
expected_keys = {
"mode", "api_url", "api_key", "llm_provider", "llm_api_key",
"llm_model", "bank_id", "bank_mission", "bank_retain_mission",
"llm_model", "bank_id", "bank_id_template", "bank_mission", "bank_retain_mission",
"recall_budget", "memory_mode", "recall_prefetch_method",
"retain_tags", "retain_source",
"retain_user_prefix", "retain_assistant_prefix",
@ -795,6 +797,150 @@ class TestConfigSchema:
assert expected_keys.issubset(keys), f"Missing: {expected_keys - keys}"
# ---------------------------------------------------------------------------
# bank_id_template tests
# ---------------------------------------------------------------------------
class TestBankIdTemplate:
def test_sanitize_bank_segment_passthrough(self):
assert _sanitize_bank_segment("hermes") == "hermes"
assert _sanitize_bank_segment("my-agent_1") == "my-agent_1"
def test_sanitize_bank_segment_strips_unsafe(self):
assert _sanitize_bank_segment("josh@example.com") == "josh-example-com"
assert _sanitize_bank_segment("chat:#general") == "chat-general"
assert _sanitize_bank_segment(" spaces ") == "spaces"
def test_sanitize_bank_segment_empty(self):
assert _sanitize_bank_segment("") == ""
assert _sanitize_bank_segment(None) == ""
def test_resolve_empty_template_uses_fallback(self):
result = _resolve_bank_id_template(
"", fallback="hermes", profile="coder"
)
assert result == "hermes"
def test_resolve_with_profile(self):
result = _resolve_bank_id_template(
"hermes-{profile}", fallback="hermes",
profile="coder", workspace="", platform="", user="", session="",
)
assert result == "hermes-coder"
def test_resolve_with_multiple_placeholders(self):
result = _resolve_bank_id_template(
"{workspace}-{profile}-{platform}",
fallback="hermes",
profile="coder", workspace="myorg", platform="cli",
user="", session="",
)
assert result == "myorg-coder-cli"
def test_resolve_collapses_empty_placeholders(self):
# When user is empty, "hermes-{user}" becomes "hermes-" -> trimmed to "hermes"
result = _resolve_bank_id_template(
"hermes-{user}", fallback="default",
profile="", workspace="", platform="", user="", session="",
)
assert result == "hermes"
def test_resolve_collapses_double_dashes(self):
# Two empty placeholders with a dash between them should collapse
result = _resolve_bank_id_template(
"{workspace}-{profile}-{user}", fallback="fallback",
profile="coder", workspace="", platform="", user="", session="",
)
assert result == "coder"
def test_resolve_empty_rendered_falls_back(self):
result = _resolve_bank_id_template(
"{user}-{profile}", fallback="fallback",
profile="", workspace="", platform="", user="", session="",
)
assert result == "fallback"
def test_resolve_sanitizes_placeholder_values(self):
result = _resolve_bank_id_template(
"user-{user}", fallback="hermes",
profile="", workspace="", platform="",
user="josh@example.com", session="",
)
assert result == "user-josh-example-com"
def test_resolve_invalid_template_returns_fallback(self):
# Unknown placeholder should fall back without raising
result = _resolve_bank_id_template(
"hermes-{unknown}", fallback="hermes",
profile="", workspace="", platform="", user="", session="",
)
assert result == "hermes"
def test_provider_uses_bank_id_template_from_config(self, tmp_path, monkeypatch):
config = {
"mode": "cloud",
"apiKey": "k",
"api_url": "http://x",
"bank_id": "fallback-bank",
"bank_id_template": "hermes-{profile}",
}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)
p = HindsightMemoryProvider()
p.initialize(
session_id="s1",
hermes_home=str(tmp_path),
platform="cli",
agent_identity="coder",
agent_workspace="hermes",
)
assert p._bank_id == "hermes-coder"
assert p._bank_id_template == "hermes-{profile}"
def test_provider_without_template_uses_static_bank_id(self, tmp_path, monkeypatch):
config = {
"mode": "cloud",
"apiKey": "k",
"api_url": "http://x",
"bank_id": "my-static-bank",
}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)
p = HindsightMemoryProvider()
p.initialize(
session_id="s1",
hermes_home=str(tmp_path),
platform="cli",
agent_identity="coder",
)
assert p._bank_id == "my-static-bank"
def test_provider_template_with_missing_profile_falls_back(self, tmp_path, monkeypatch):
config = {
"mode": "cloud",
"apiKey": "k",
"api_url": "http://x",
"bank_id": "hermes-fallback",
"bank_id_template": "hermes-{profile}",
}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)
p = HindsightMemoryProvider()
# No agent_identity passed — template renders to "hermes-" which collapses to "hermes"
p.initialize(session_id="s1", hermes_home=str(tmp_path), platform="cli")
assert p._bank_id == "hermes"
# ---------------------------------------------------------------------------
# Availability tests
# ---------------------------------------------------------------------------