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

@ -59,7 +59,8 @@ Config file: `~/.hermes/hindsight/config.json`
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `bank_id` | `hermes` | Memory bank name | | `bank_id` | `hermes` | Memory bank name (static fallback used when `bank_id_template` is unset or resolves empty) |
| `bank_id_template` | — | Optional template to derive the bank name dynamically. Placeholders: `{profile}`, `{workspace}`, `{platform}`, `{user}`, `{session}`. Example: `hermes-{profile}` isolates memory per active Hermes profile. Empty placeholders collapse cleanly (e.g. `hermes-{user}` with no user becomes `hermes`). |
| `bank_mission` | — | Reflect mission (identity/framing for reflect reasoning). Applied via Banks API. | | `bank_mission` | — | Reflect mission (identity/framing for reflect reasoning). Applied via Banks API. |
| `bank_retain_mission` | — | Retain mission (steers what gets extracted). Applied via Banks API. | | `bank_retain_mission` | — | Retain mission (steers what gets extracted). Applied via Banks API. |

View file

@ -324,6 +324,60 @@ def _materialize_embedded_profile_env(config: dict[str, Any], *, llm_api_key: st
) )
return profile_env return profile_env
def _sanitize_bank_segment(value: str) -> str:
"""Sanitize a bank_id_template placeholder value.
Bank IDs should be safe for URL paths and filesystem use. Replaces any
character that isn't alphanumeric, dash, or underscore with a dash, and
collapses runs of dashes.
"""
if not value:
return ""
out = []
prev_dash = False
for ch in str(value):
if ch.isalnum() or ch == "-" or ch == "_":
out.append(ch)
prev_dash = False
else:
if not prev_dash:
out.append("-")
prev_dash = True
return "".join(out).strip("-_")
def _resolve_bank_id_template(template: str, fallback: str, **placeholders: str) -> str:
"""Resolve a bank_id template string with the given placeholders.
Supported placeholders (each is sanitized before substitution):
{profile} active Hermes profile name (from agent_identity)
{workspace} Hermes workspace name (from agent_workspace)
{platform} "cli", "telegram", "discord", etc.
{user} platform user id (gateway sessions)
{session} current session id
Missing/empty placeholders are rendered as the empty string and then
collapsed e.g. ``hermes-{user}`` with no user becomes ``hermes``.
If the template is empty, resolution falls back to *fallback*.
Returns the sanitized bank id.
"""
if not template:
return fallback
sanitized = {k: _sanitize_bank_segment(v) for k, v in placeholders.items()}
try:
rendered = template.format(**sanitized)
except (KeyError, IndexError) as exc:
logger.warning("Invalid bank_id_template %r: %s — using fallback %r",
template, exc, fallback)
return fallback
while "--" in rendered:
rendered = rendered.replace("--", "-")
while "__" in rendered:
rendered = rendered.replace("__", "_")
rendered = rendered.strip("-_")
return rendered or fallback
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MemoryProvider implementation # MemoryProvider implementation
@ -354,6 +408,7 @@ class HindsightMemoryProvider(MemoryProvider):
self._chat_type = "" self._chat_type = ""
self._thread_id = "" self._thread_id = ""
self._agent_identity = "" self._agent_identity = ""
self._agent_workspace = ""
self._turn_index = 0 self._turn_index = 0
self._client = None self._client = None
self._timeout = _DEFAULT_TIMEOUT self._timeout = _DEFAULT_TIMEOUT
@ -388,6 +443,7 @@ class HindsightMemoryProvider(MemoryProvider):
# Bank # Bank
self._bank_mission = "" self._bank_mission = ""
self._bank_retain_mission: str | None = None self._bank_retain_mission: str | None = None
self._bank_id_template = ""
@property @property
def name(self) -> str: def name(self) -> str:
@ -615,7 +671,8 @@ class HindsightMemoryProvider(MemoryProvider):
{"key": "llm_base_url", "description": "Endpoint URL (e.g. http://192.168.1.10:8080/v1)", "default": "", "when": {"mode": "local_embedded", "llm_provider": "openai_compatible"}}, {"key": "llm_base_url", "description": "Endpoint URL (e.g. http://192.168.1.10:8080/v1)", "default": "", "when": {"mode": "local_embedded", "llm_provider": "openai_compatible"}},
{"key": "llm_api_key", "description": "LLM API key (optional for openai_compatible)", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local_embedded"}}, {"key": "llm_api_key", "description": "LLM API key (optional for openai_compatible)", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local_embedded"}},
{"key": "llm_model", "description": "LLM model", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local_embedded"}}, {"key": "llm_model", "description": "LLM model", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local_embedded"}},
{"key": "bank_id", "description": "Memory bank name", "default": "hermes"}, {"key": "bank_id", "description": "Memory bank name (static fallback when bank_id_template is unset)", "default": "hermes"},
{"key": "bank_id_template", "description": "Optional template to derive bank_id dynamically. Placeholders: {profile}, {workspace}, {platform}, {user}, {session}. Example: hermes-{profile}", "default": ""},
{"key": "bank_mission", "description": "Mission/purpose description for the memory bank"}, {"key": "bank_mission", "description": "Mission/purpose description for the memory bank"},
{"key": "bank_retain_mission", "description": "Custom extraction prompt for memory retention"}, {"key": "bank_retain_mission", "description": "Custom extraction prompt for memory retention"},
{"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, {"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]},
@ -728,6 +785,7 @@ class HindsightMemoryProvider(MemoryProvider):
self._chat_type = str(kwargs.get("chat_type") or "").strip() self._chat_type = str(kwargs.get("chat_type") or "").strip()
self._thread_id = str(kwargs.get("thread_id") or "").strip() self._thread_id = str(kwargs.get("thread_id") or "").strip()
self._agent_identity = str(kwargs.get("agent_identity") or "").strip() self._agent_identity = str(kwargs.get("agent_identity") or "").strip()
self._agent_workspace = str(kwargs.get("agent_workspace") or "").strip()
self._turn_index = 0 self._turn_index = 0
self._session_turns = [] self._session_turns = []
self._mode = self._config.get("mode", "cloud") self._mode = self._config.get("mode", "cloud")
@ -751,7 +809,17 @@ class HindsightMemoryProvider(MemoryProvider):
self._llm_base_url = self._config.get("llm_base_url", "") self._llm_base_url = self._config.get("llm_base_url", "")
banks = self._config.get("banks", {}).get("hermes", {}) banks = self._config.get("banks", {}).get("hermes", {})
self._bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes") static_bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes")
self._bank_id_template = self._config.get("bank_id_template", "") or ""
self._bank_id = _resolve_bank_id_template(
self._bank_id_template,
fallback=static_bank_id,
profile=self._agent_identity,
workspace=self._agent_workspace,
platform=self._platform,
user=self._user_id,
session=self._session_id,
)
budget = self._config.get("recall_budget") or self._config.get("budget") or banks.get("budget", "mid") budget = self._config.get("recall_budget") or self._config.get("budget") or banks.get("budget", "mid")
self._budget = budget if budget in _VALID_BUDGETS else "mid" self._budget = budget if budget in _VALID_BUDGETS else "mid"
@ -804,6 +872,10 @@ class HindsightMemoryProvider(MemoryProvider):
pass pass
logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s", logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s",
self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version) self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version)
if self._bank_id_template:
logger.debug("Hindsight bank resolved from template %r: profile=%s workspace=%s platform=%s user=%s -> bank=%s",
self._bank_id_template, self._agent_identity, self._agent_workspace,
self._platform, self._user_id, self._bank_id)
logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, " logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, "
"retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s", "retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s",
self._auto_retain, self._auto_recall, self._retain_every_n_turns, self._auto_retain, self._auto_recall, self._retain_every_n_turns,

View file

@ -19,6 +19,8 @@ from plugins.memory.hindsight import (
RETAIN_SCHEMA, RETAIN_SCHEMA,
_load_config, _load_config,
_normalize_retain_tags, _normalize_retain_tags,
_resolve_bank_id_template,
_sanitize_bank_segment,
) )
@ -782,7 +784,7 @@ class TestConfigSchema:
keys = {f["key"] for f in schema} keys = {f["key"] for f in schema}
expected_keys = { expected_keys = {
"mode", "api_url", "api_key", "llm_provider", "llm_api_key", "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", "recall_budget", "memory_mode", "recall_prefetch_method",
"retain_tags", "retain_source", "retain_tags", "retain_source",
"retain_user_prefix", "retain_assistant_prefix", "retain_user_prefix", "retain_assistant_prefix",
@ -795,6 +797,150 @@ class TestConfigSchema:
assert expected_keys.issubset(keys), f"Missing: {expected_keys - keys}" 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 # Availability tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------