Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
83 changes: 67 additions & 16 deletions integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def shutdown(self, **kwargs: Any) -> None: pass
TIMEOUT = 5
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
_plaintext_bearer_warned = False
_agentmemory_url_from_env_or_dotenv = False

# agentmemory's documented runtime config lives at ~/.agentmemory/.env.
# When agentmemory is launched as a systemd user service (or any other
Expand All @@ -68,6 +69,7 @@ def shutdown(self, **kwargs: Any) -> None: pass
# unreadable, malformed) — the plugin falls back to its existing default
# (http://localhost:3111) and Hermes status reflects that.
def _preload_agentmemory_dotenv() -> None:
global _agentmemory_url_from_env_or_dotenv
candidates: list[Path] = []
home = os.environ.get("HOME")
if home:
Expand All @@ -90,6 +92,7 @@ def _preload_agentmemory_dotenv() -> None:
os.environ.setdefault(key, value)
except (OSError, UnicodeDecodeError):
continue
_agentmemory_url_from_env_or_dotenv = "AGENTMEMORY_URL" in os.environ
# Guarantee AGENTMEMORY_URL is set so `hermes memory status` never
# reports it as Missing when a user runs agentmemory at the default
# localhost:3111 (or via systemd with the URL line commented out in
Expand All @@ -100,6 +103,48 @@ def _preload_agentmemory_dotenv() -> None:
_preload_agentmemory_dotenv()


def _hermes_config_path(hermes_home: str | None = None) -> Path | None:
if hermes_home:
return Path(hermes_home) / "agentmemory.json"
home = os.environ.get("HOME")
if not home:
return None
return Path(home) / ".hermes" / "agentmemory.json"


def _load_saved_config(hermes_home: str | None = None) -> dict:
config_path = _hermes_config_path(hermes_home)
if not config_path:
return {}
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}


def _saved_config_value(config: dict, key: str) -> str:
value = config.get(key)
return value.strip() if isinstance(value, str) else ""


def _resolve_base_url(saved_config: dict | None = None) -> str:
env_url = os.environ.get("AGENTMEMORY_URL", "")
if _agentmemory_url_from_env_or_dotenv and env_url:
return env_url
config = saved_config if saved_config is not None else _load_saved_config()
saved_url = _saved_config_value(config, "url")
return saved_url or env_url or DEFAULT_BASE_URL


def _resolve_secret(saved_config: dict | None = None) -> str:
env_secret = os.environ.get("AGENTMEMORY_SECRET", "")
if env_secret:
return env_secret
config = saved_config if saved_config is not None else _load_saved_config()
return _saved_config_value(config, "secret")


def _validate_url(base: str) -> bool:
if not base:
return False
Expand Down Expand Up @@ -169,8 +214,8 @@ def _api(base: str, path: str, body: dict | None = None, method: str = "POST", s
return None


def _api_bg(base: str, path: str, body: dict | None = None) -> None:
t = threading.Thread(target=_api, args=(base, path, body), daemon=True)
def _api_bg(base: str, path: str, body: dict | None = None, secret: str = "") -> None:
t = threading.Thread(target=_api, args=(base, path, body), kwargs={"secret": secret}, daemon=True)
t.start()


Expand All @@ -182,21 +227,26 @@ def name(self) -> str:

def is_available(self) -> bool:
# Hermes contract: no network calls in is_available.
base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
hermes_home = getattr(self, "_hermes_home", None)
saved_config = _load_saved_config(hermes_home) if hermes_home else None
base = _resolve_base_url(saved_config)
return _validate_url(base)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def initialize(self, session_id: str, **kwargs: Any) -> None:
self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
hermes_home = kwargs.get("hermes_home") or getattr(self, "_hermes_home", None)
saved_config = _load_saved_config(hermes_home)
self._base = _resolve_base_url(saved_config)
self._secret = _resolve_secret(saved_config)
self._session_id = session_id
self._project = kwargs.get("cwd", os.getcwd())
if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1":
_check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", ""))
_check_plaintext_bearer_guard(self._base, self._secret)

_api(self._base, "session/start", {
"sessionId": session_id,
"project": self._project,
"cwd": self._project,
})
}, secret=self._secret)

def get_config_schema(self) -> list[dict]:
return [
Expand All @@ -216,14 +266,15 @@ def get_config_schema(self) -> list[dict]:
]

def save_config(self, values: dict, hermes_home: str) -> None:
self._hermes_home = hermes_home
config_path = Path(hermes_home) / "agentmemory.json"
config_path.write_text(json.dumps(values, indent=2))

def system_prompt_block(self) -> str:
result = _api(self._base, "context", {
"sessionId": self._session_id,
"project": self._project,
})
}, secret=self._secret)
if result and result.get("context"):
return result["context"]
return ""
Expand All @@ -232,7 +283,7 @@ def prefetch(self, query: str, **kwargs: Any) -> str:
result = _api(self._base, "smart-search", {
"query": query,
"limit": 5,
})
}, secret=self._secret)
if not result or not result.get("results"):
return ""

Expand All @@ -246,7 +297,7 @@ def prefetch(self, query: str, **kwargs: Any) -> str:
return "\n".join(lines) if lines else ""

def queue_prefetch(self, query: str, **kwargs: Any) -> None:
_api_bg(self._base, "smart-search", {"query": query, "limit": 3})
_api_bg(self._base, "smart-search", {"query": query, "limit": 3}, secret=self._secret)

def get_tool_schemas(self) -> list[dict]:
return [
Expand Down Expand Up @@ -302,7 +353,7 @@ def handle_tool_call(self, name: str, args: dict) -> str:
result = _api(self._base, "search", {
"query": args["query"],
"limit": args.get("limit", 10),
})
}, secret=self._secret)
if not result:
return json.dumps({"results": []})
items = []
Expand All @@ -321,14 +372,14 @@ def handle_tool_call(self, name: str, args: dict) -> str:
result = _api(self._base, "remember", {
"content": args["content"],
"type": args.get("type", "fact"),
})
}, secret=self._secret)
return json.dumps(result or {"success": False})

if name == "memory_search":
result = _api(self._base, "smart-search", {
"query": args["query"],
"limit": args.get("limit", 5),
})
}, secret=self._secret)
if not result:
return json.dumps({"results": []})
items = []
Expand All @@ -355,18 +406,18 @@ def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None:
"tool_input": user[:500],
"tool_output": assistant[:2000],
},
})
}, secret=self._secret)

def on_session_end(self, messages: list, **kwargs: Any) -> None:
_api(self._base, "session/end", {
"sessionId": kwargs.get("session_id", self._session_id),
})
}, secret=self._secret)

def on_pre_compress(self, messages: list, **kwargs: Any) -> None:
result = _api(self._base, "context", {
"sessionId": kwargs.get("session_id", self._session_id),
"project": self._project,
})
}, secret=self._secret)
if result and result.get("context"):
messages.insert(0, {
"role": "user",
Expand All @@ -378,7 +429,7 @@ def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any)
_api_bg(self._base, "remember", {
"content": content,
"type": "fact",
})
}, secret=self._secret)

def shutdown(self, **kwargs: Any) -> None:
pass
Expand Down
159 changes: 159 additions & 0 deletions test/integration-plaintext-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,165 @@ except RuntimeError as exc:
else:
raise AssertionError("expected RuntimeError")
assert calls == [], calls
`;
const result = spawnSync("python3", ["-c", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: home },
encoding: "utf8",
});
expect(result.status, result.stderr || result.stdout).toBe(0);
});

it("uses saved Hermes config for runtime URL and secret when env vars are absent", () => {
const script = String.raw`
import importlib.util
import os
from pathlib import Path

for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"):
os.environ.pop(key, None)

spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py")
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)

provider = mod.AgentMemoryProvider()
hermes_home = Path(os.environ["HOME"]) / "custom-hermes"
hermes_home.mkdir()
provider.save_config({"url": "http://10.0.0.5:3111", "secret": "saved-secret"}, str(hermes_home))

calls = []
def fake_api(base, path, body=None, method="POST", secret=""):
calls.append({"base": base, "path": path, "secret": secret})
return {}

mod._api = fake_api
assert provider.is_available() is True
provider.initialize("session-658", hermes_home=str(hermes_home), cwd="/tmp/project")

assert provider._base == "http://10.0.0.5:3111", provider._base
assert provider._secret == "saved-secret", provider._secret
assert calls[0]["base"] == "http://10.0.0.5:3111", calls
assert calls[0]["secret"] == "saved-secret", calls
`;
const result = spawnSync("python3", ["-c", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: home },
encoding: "utf8",
});
expect(result.status, result.stderr || result.stdout).toBe(0);
});

it("uses the provider Hermes home when checking availability", () => {
const script = String.raw`
import importlib.util
import os
from pathlib import Path

for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"):
os.environ.pop(key, None)

spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py")
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)

provider = mod.AgentMemoryProvider()
default_hermes_home = Path(os.environ["HOME"]) / ".hermes"
default_hermes_home.mkdir()
(default_hermes_home / "agentmemory.json").write_text('{"url":"http://localhost:3111"}', encoding="utf-8")

hermes_home = Path(os.environ["HOME"]) / "custom-hermes"
hermes_home.mkdir()
provider.save_config({"url": "not a url"}, str(hermes_home))

assert provider.is_available() is False
`;
const result = spawnSync("python3", ["-c", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: home },
encoding: "utf8",
});
expect(result.status, result.stderr || result.stdout).toBe(0);
});

it("lets env URL and secret override saved Hermes config", () => {
const script = String.raw`
import importlib.util
import os
from pathlib import Path

for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"):
os.environ.pop(key, None)
os.environ["AGENTMEMORY_URL"] = "https://env.example"
os.environ["AGENTMEMORY_SECRET"] = "env-secret"

spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py")
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)

provider = mod.AgentMemoryProvider()
hermes_home = Path(os.environ["HOME"]) / "custom-hermes"
hermes_home.mkdir()
provider.save_config({"url": "http://10.0.0.5:3111", "secret": "saved-secret"}, str(hermes_home))

calls = []
def fake_api(base, path, body=None, method="POST", secret=""):
calls.append({"base": base, "path": path, "secret": secret})
return {}

mod._api = fake_api
provider.initialize("session-658", hermes_home=str(hermes_home), cwd="/tmp/project")

assert provider._base == "https://env.example", provider._base
assert provider._secret == "env-secret", provider._secret
assert calls[0]["base"] == "https://env.example", calls
assert calls[0]["secret"] == "env-secret", calls
`;
const result = spawnSync("python3", ["-c", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: home },
encoding: "utf8",
});
expect(result.status, result.stderr || result.stdout).toBe(0);
});

it("falls back safely when saved Hermes config is malformed", () => {
const script = String.raw`
import importlib.util
import os
from pathlib import Path

for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"):
os.environ.pop(key, None)

spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py")
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)

provider = mod.AgentMemoryProvider()
default_hermes_home = Path(os.environ["HOME"]) / ".hermes"
default_hermes_home.mkdir()
provider.save_config({"url": "http://10.0.0.5:3111", "secret": "saved-secret"}, str(default_hermes_home))
hermes_home = Path(os.environ["HOME"]) / "custom-hermes"
hermes_home.mkdir()
(hermes_home / "agentmemory.json").write_text("{not json", encoding="utf-8")

calls = []
def fake_api(base, path, body=None, method="POST", secret=""):
calls.append({"base": base, "path": path, "secret": secret})
return {}

mod._api = fake_api
provider.initialize("session-658", hermes_home=str(hermes_home), cwd="/tmp/project")

assert provider._base == "http://localhost:3111", provider._base
assert provider._secret == "", provider._secret
assert calls[0]["base"] == "http://localhost:3111", calls
assert calls[0]["secret"] == "", calls
`;
const result = spawnSync("python3", ["-c", script], {
cwd: process.cwd(),
Expand Down