mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
f9c6c5ab84
commit
edff2fbe7e
3 changed files with 223 additions and 4 deletions
|
|
@ -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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue