Merge origin/main: unify declared-schema and instance-schema provider config

Main's #60569 (dashboard memory provider switching) rewrote the same
GET/PUT /api/memory/providers/{name}/config routes this PR owns — the
dashboard and desktop share one backend. Resolve by dispatching: providers
that declare a config_schema.py get the declared path (host-block storage,
locked honcho writes, profile scoping, actions); everything else keeps
main's instance get_config_schema()/save_config path unchanged, so the
dashboard's PluginsPage behavior is preserved for instance providers.
Setup manifests, the /setup endpoint, provider switching, and name
validation from #60569 apply to both paths. Instance payloads gain the
docs_url/actions keys the desktop panel expects; declared payloads gain
the setup block the dashboard expects. Main's hindsight/honcho instance-
schema tests are updated for the dispatch, and its status test gets the
HOME pin it was missing (it read the developer's real ~/.honcho).
This commit is contained in:
Erosika 2026-07-08 15:03:24 -04:00
commit 1508ece2d9
948 changed files with 85189 additions and 7133 deletions

View file

@ -107,7 +107,7 @@ class AudioBridge:
"load-module",
"module-null-sink",
f"sink_name={sink_name}",
f"sink_properties=device.description=HermesMeetSink",
"sink_properties=device.description=HermesMeetSink",
],
check=True,
capture_output=True,

View file

@ -250,10 +250,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
pip_pkgs = ["playwright", "websockets"]
print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")
try:
res = _sp.run(
[sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs],
check=False,
)
from hermes_cli.tools_config import _pip_install
res = _pip_install(["--upgrade", *pip_pkgs], capture_output=False)
if res.returncode != 0:
print(" pip install failed")
return 1
@ -347,7 +346,7 @@ def _cmd_auth() -> int:
path = _auth_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
print(f"opening Chromium — sign in to Google, then return here and press Enter.")
print("opening Chromium — sign in to Google, then return here and press Enter.")
print(f"saving storage state to: {path}")
try:
with sync_playwright() as pw:

View file

@ -71,7 +71,7 @@ def node_command(args: argparse.Namespace) -> int:
print(f"[meet-node] display_name={server.display_name}")
print(f"[meet-node] listening on ws://{args.host}:{args.port}")
print(f"[meet-node] token (copy to gateway): {token}")
print(f"[meet-node] approve with:")
print("[meet-node] approve with:")
print(f" hermes meet node approve <name> ws://<host>:{args.port} {token}")
try:
asyncio.run(server.serve())

View file

@ -146,7 +146,10 @@ def _load_image_bytes(ref: str) -> Tuple[bytes, str]:
if "image/" in header:
ext = header.split("image/", 1)[1].split(";", 1)[0] or "png"
return base64.b64decode(b64), f"image.{ext}"
# Local file path.
# Local file path — enforce the shared credential-read guard before reading.
from agent.file_safety import raise_if_read_blocked
raise_if_read_blocked(ref)
with open(ref, "rb") as fh:
data = fh.read()
name = os.path.basename(ref) or "image.png"

View file

@ -97,6 +97,10 @@ def _to_image_url_part(ref: str) -> Optional[str]:
if ref.startswith(("http://", "https://", "data:")):
return ref
path = Path(ref)
# Enforce the shared credential-read guard before inlining local bytes.
from agent.file_safety import raise_if_read_blocked
raise_if_read_blocked(ref)
try:
raw = path.read_bytes()
except OSError as exc:

View file

@ -137,6 +137,11 @@ def _xai_image_field(source: str) -> Dict[str, str]:
import base64
import os as _os
# Enforce the shared credential-read guard before reading local bytes
# (same boundary the OpenAI / OpenRouter / Codex image providers apply).
from agent.file_safety import raise_if_read_blocked
raise_if_read_blocked(source)
with open(_os.path.expanduser(source), "rb") as fh: # windows-footgun: ok
raw = fh.read()
ext = (_os.path.splitext(source)[1].lstrip(".") or "png").lower()

View file

@ -2273,6 +2273,93 @@
// -------------------------------------------------------------------------
function BoardColumns(props) {
const columnsRef = useRef(null);
const panRef = useRef({ isPanning: false, startX: 0, scrollLeft: 0 });
const [isPanning, setIsPanning] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const checkScrollable = useCallback(function () {
const el = columnsRef.current;
setIsScrollable(!!el && el.scrollWidth > el.clientWidth + 1);
}, []);
useEffect(function () {
checkScrollable();
const el = columnsRef.current;
if (!el) return undefined;
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(checkScrollable);
observer.observe(el);
return function () { observer.disconnect(); };
}
window.addEventListener("resize", checkScrollable);
return function () { window.removeEventListener("resize", checkScrollable); };
}, [checkScrollable, props.board]);
const isPanBlockedTarget = useCallback(function (target) {
if (!target) return true;
if (target.closest && target.closest(".hermes-kanban-card")) return true;
if (target.closest && target.closest(".hermes-kanban-column-add")) return true;
if (target.closest && target.closest(".hermes-kanban-col-check")) return true;
if (target.closest && target.closest("button,input,textarea,select,a,[role='button']")) return true;
return false;
}, []);
const stopPan = useCallback(function () {
const el = columnsRef.current;
if (!panRef.current.isPanning) return;
panRef.current.isPanning = false;
setIsPanning(false);
if (el) {
// Keep cursor feedback instant even before React flushes the state update.
el.classList.remove("hermes-kanban-columns--panning");
el.style.userSelect = "";
}
if (panRef.current.cleanup) panRef.current.cleanup();
panRef.current.cleanup = null;
}, []);
useEffect(function () {
return function () { stopPan(); };
}, [stopPan]);
const handleMouseDown = useCallback(function (e) {
if (e.button !== 0) return;
if (isPanBlockedTarget(e.target)) return;
const el = columnsRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Preserve the native horizontal scrollbar as a fallback; grab-pan starts above it.
if (e.clientY >= rect.bottom - 20) return;
if (el.scrollWidth <= el.clientWidth) return;
panRef.current.isPanning = true;
panRef.current.startX = e.clientX;
panRef.current.scrollLeft = el.scrollLeft;
setIsPanning(true);
el.classList.add("hermes-kanban-columns--panning");
el.style.userSelect = "none";
function onMouseMove(ev) {
if (!panRef.current.isPanning) return;
const dx = ev.clientX - panRef.current.startX;
el.scrollLeft = panRef.current.scrollLeft - dx;
ev.preventDefault();
}
function onMouseUp() { stopPan(); }
if (panRef.current.cleanup) panRef.current.cleanup();
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp, { once: true });
window.addEventListener("blur", onMouseUp, { once: true });
panRef.current.cleanup = function () {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("blur", onMouseUp);
};
e.preventDefault();
}, [isPanBlockedTarget, stopPan]);
const handleDragStart = useCallback(function (e) {
const card = e.target.closest && e.target.closest(".hermes-kanban-card");
if (!card) return;
@ -2282,7 +2369,17 @@
const handleDragEnd = useCallback(function () {
if (props.onDragEnd) props.onDragEnd();
}, [props.onDragEnd]);
return h("div", { className: "hermes-kanban-columns", onDragStart: handleDragStart, onDragEnd: handleDragEnd },
return h("div", {
ref: columnsRef,
className: cn(
"hermes-kanban-columns",
isScrollable ? "hermes-kanban-columns--scrollable" : "",
isPanning ? "hermes-kanban-columns--panning" : "",
),
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
onMouseDown: handleMouseDown,
},
props.board.columns.map(function (col) {
return h(Column, {
key: col.name,

View file

@ -69,6 +69,15 @@
overflow-x: auto;
}
.hermes-kanban-columns--scrollable {
cursor: grab;
}
.hermes-kanban-columns--panning,
.hermes-kanban-columns--panning * {
cursor: grabbing !important;
}
.hermes-kanban-column {
flex: 0 0 280px;
display: flex;

View file

@ -515,20 +515,16 @@ def _ensure_sdk_installed() -> bool:
print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
return False
import subprocess
print(" Installing honcho-ai...", flush=True)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
from hermes_cli.tools_config import _pip_install
result = _pip_install(["honcho-ai>=2.0.1"])
if result.returncode == 0:
print(" Installed.\n")
return True
else:
print(f" Install failed:\n{result.stderr.strip()}")
print(" Run manually: pip install 'honcho-ai>=2.0.1'\n")
print(f" Install failed:\n{(result.stderr or '').strip()}")
print(" Run manually: uv pip install 'honcho-ai>=2.0.1'\n")
return False
@ -1092,7 +1088,7 @@ def cmd_status(args) -> None:
if write_path != active_path:
print(f" Write to: {write_path} (profile-local)")
if active_path == global_path:
print(f" Fallback: (none — using global ~/.honcho/config.json)")
print(" Fallback: (none — using global ~/.honcho/config.json)")
elif global_path.exists():
print(f" Fallback: {global_path} (exists, cross-app interop)")
@ -1152,7 +1148,7 @@ def _show_peer_cards(hcfg, client) -> None:
if ai_text:
# Truncate to first 200 chars
display = ai_text[:200] + ("..." if len(ai_text) > 200 else "")
print(f"\n AI peer representation:")
print("\n AI peer representation:")
print(f" {display}")
if not card and not ai_text:
@ -1186,7 +1182,7 @@ def _cmd_status_all() -> None:
marker = " *" if name == active else ""
print(f" {name + marker:<14} {host:<22} {enabled_str:<9} {recall:<9} {write}")
print(f"\n * active profile\n")
print("\n * active profile\n")
def cmd_peers(args) -> None:
@ -1326,7 +1322,7 @@ def cmd_mode(args) -> None:
for m, desc in MODES.items():
marker = " <-" if m == current else ""
print(f" {m:<10} {desc}{marker}")
print(f"\n Set with: hermes honcho mode [hybrid|context|tools]\n")
print("\n Set with: hermes honcho mode [hybrid|context|tools]\n")
return
if mode_arg not in MODES:
@ -1361,7 +1357,7 @@ def cmd_strategy(args) -> None:
for s, desc in STRATEGIES.items():
marker = " <-" if s == current else ""
print(f" {s:<15} {desc}{marker}")
print(f"\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n")
print("\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n")
return
if strat_arg not in STRATEGIES:

View file

@ -25,14 +25,50 @@ Behavioral settings live in `$HERMES_HOME/mem0.json` (set them via `hermes memor
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process) |
| `host` | — | Self-hosted Mem0 server URL (the Docker dashboard). When set, connects over HTTP with `X-API-Key`. Don't combine with `mode: oss` |
| `user_id` | `hermes-user` | User identifier on Mem0 |
| `agent_id` | `hermes` | Agent identifier |
| `rerank` | `true` | Rerank search results for relevance (platform mode only) |
| `rerank` | `false` | Rerank search results for relevance (platform mode only) |
The plugin has three connection modes:
- **Platform** — Mem0's hosted cloud (`api.mem0.ai`). Set `MEM0_API_KEY`. (default)
- **Self-hosted dashboard** — a Mem0 server you run yourself via Docker. Set `host`. See below.
- **OSS** — run Mem0 in-process with your own LLM + vector store. Set `mode: oss`. See below.
## Self-Hosted Dashboard (Server) Mode
Connect the plugin to a standalone Mem0 server you run yourself — the Docker-shipped Mem0 dashboard/server with its own REST API. Unlike OSS mode (which runs `mem0ai` in-process with your own vector store), here the plugin just talks HTTP to your server.
1. Run the Mem0 server (FastAPI + pgvector) from its Docker image and note its URL and `ADMIN_API_KEY`.
2. Point the plugin at it — via the setup wizard:
```bash
hermes memory setup # select "mem0" → "Self-hosted server"
# Or non-interactive:
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key
```
or via env vars:
```bash
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
```
or in `$HERMES_HOME/mem0.json`:
```json
{
"host": "http://localhost:8888",
"api_key": "your-admin-api-key"
}
```
3. Start a fresh Hermes session and call `mem0_search` — it connects to your server.
The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. `api_key` is optional — omit it only for servers running with `AUTH_DISABLED`.
> Setting `host` routes to the self-hosted server automatically. Don't set `mode: oss` — OSS takes precedence and ignores `host`.
## OSS (Self-Hosted) Mode
Run Mem0 locally with your own LLM, embedder, and vector store.
Run Mem0 locally with your own LLM, embedder, and vector store. This is the in-process SDK mode. To instead connect to a Mem0 server you run via Docker, see [Self-Hosted Dashboard (Server) Mode](#self-hosted-dashboard-server-mode) above.
### Interactive Setup
@ -106,7 +142,6 @@ hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
| Tool | Description |
|------|-------------|
| `mem0_list` | List all stored memories (paginated) |
| `mem0_search` | Semantic search by meaning |
| `mem0_add` | Store a fact verbatim (no LLM extraction) |
| `mem0_update` | Update a memory's text by ID |

View file

@ -9,10 +9,15 @@ Configuration
-------------
Secret (lives in $HERMES_HOME/.env or the environment):
MEM0_API_KEY — Mem0 Platform API key (required for platform mode)
MEM0_HOST — Base URL of a self-hosted Mem0 server. When set, the
plugin talks to that server directly over HTTP
(X-API-Key auth) instead of the cloud API.
Behavioral settings (live in $HERMES_HOME/mem0.json, set via `hermes memory
setup`):
mode — Backend mode: "platform" (default) or "oss"
host — Self-hosted Mem0 server URL (alt: MEM0_HOST env var).
When set, routes to the self-hosted HTTP backend.
user_id — Canonical user identifier. When set, it is applied
uniformly across every gateway (CLI, Telegram, Slack,
Discord, …) so the same human gets one merged memory
@ -44,7 +49,7 @@ logger = logging.getLogger(__name__)
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
_BREAKER_THRESHOLD = 5
_BREAKER_COOLDOWN_SECS = 120
_PREFETCH_WAIT_SECS = 1.5
_PREFETCH_WAIT_SECS = 3
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
@ -81,6 +86,7 @@ def _load_config() -> dict:
config = {
"mode": os.environ.get("MEM0_MODE", "platform"),
"api_key": os.environ.get("MEM0_API_KEY", ""),
"host": os.environ.get("MEM0_HOST", ""),
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
"oss": {},
}
@ -107,41 +113,22 @@ def _load_config() -> dict:
# Tool schemas
# ---------------------------------------------------------------------------
LIST_SCHEMA = {
"name": "mem0_list",
"description": (
"List ALL stored memories about the user, unranked and paginated. "
"Use for a full overview/audit at conversation start, or to browse "
"everything when you don't have a specific query. For answering a "
"specific question, prefer mem0_search."
),
"parameters": {
"type": "object",
"properties": {
"page": {"type": "integer", "description": "Page number (default: 1)."},
"page_size": {"type": "integer", "description": "Results per page (default: 100, max: 200)."},
},
"required": [],
},
}
SEARCH_SCHEMA = {
"name": "mem0_search",
"description": (
"Search the user's memories by meaning; returns facts ranked by "
"relevance. Use this BEFORE answering any question that may depend on "
"relevance. Use this before answering any question that may depend on "
"what you know about the user (preferences, facts, history, people, "
"projects, past decisions). For multi-part or multi-hop questions, "
"call it MULTIPLE times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough. Set "
"rerank=true for higher accuracy on important queries."
"call it several times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for."},
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: true, platform mode only)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."},
},
"required": ["query"],
},
@ -169,7 +156,7 @@ UPDATE_SCHEMA = {
"name": "mem0_update",
"description": (
"Replace the text of an existing memory by its ID (take the ID from a "
"mem0_search or mem0_list result). Use when a stored fact has changed "
"mem0_search result). Use when a stored fact has changed "
"or was wrong — correct it in place instead of adding a duplicate."
),
"parameters": {
@ -185,7 +172,7 @@ UPDATE_SCHEMA = {
DELETE_SCHEMA = {
"name": "mem0_delete",
"description": (
"Delete a memory by its ID (take the ID from a mem0_search or mem0_list "
"Delete a memory by its ID (take the ID from a mem0_search "
"result). Use when a stored fact is obsolete or the user asks you to "
"forget it; prefer mem0_update if the fact merely changed."
),
@ -214,8 +201,10 @@ class Mem0MemoryProvider(MemoryProvider):
self._backend = None
self._mode = "platform"
self._api_key = ""
self._host = ""
self._user_id = _DEFAULT_USER_ID
self._agent_id = "hermes"
self._rerank_default = False
self._channel = "cli" # gateway channel name (cli/telegram/discord/...)
self._sync_thread = None
self._prefetch_thread = None
@ -239,7 +228,9 @@ class Mem0MemoryProvider(MemoryProvider):
mode = cfg.get("mode", "platform")
if mode == "oss":
return bool(cfg.get("oss", {}).get("vector_store"))
return bool(cfg.get("api_key"))
# Platform needs an api_key; self-hosted needs a host (api_key optional
# when the server runs with AUTH_DISABLED).
return bool(cfg.get("api_key") or cfg.get("host"))
def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/mem0.json."""
@ -262,9 +253,10 @@ class Mem0MemoryProvider(MemoryProvider):
api_key_required = mode != "oss"
return [
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": api_key_required, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
{"key": "host", "description": "Self-hosted Mem0 server URL (leave blank for cloud)", "required": False, "env_var": "MEM0_HOST"},
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
]
def post_setup(self, hermes_home: str, config: dict) -> None:
@ -288,6 +280,9 @@ class Mem0MemoryProvider(MemoryProvider):
if self._mode == "oss":
from ._backend import OSSBackend
return OSSBackend(self._config.get("oss", {}))
if self._host:
from ._backend import SelfHostedBackend
return SelfHostedBackend(self._api_key, self._host)
from ._backend import PlatformBackend
return PlatformBackend(self._api_key)
except Exception as e:
@ -342,6 +337,7 @@ class Mem0MemoryProvider(MemoryProvider):
self._config = _load_config()
self._mode = self._config.get("mode", "platform")
self._api_key = self._config.get("api_key", "")
self._host = self._config.get("host", "")
# Resolution order for user_id:
# 1. Operator-configured MEM0_USER_ID (env or $HERMES_HOME/mem0.json) —
# the canonical principal, applied across every gateway so the same
@ -358,6 +354,14 @@ class Mem0MemoryProvider(MemoryProvider):
configured = None
self._user_id = configured or kwargs.get("user_id") or _DEFAULT_USER_ID
self._agent_id = self._config.get("agent_id", "hermes")
# Persisted rerank preference (setup wizard / mem0.json). Used as the
# DEFAULT for mem0_search when the model doesn't pass ``rerank``
# explicitly; per-call args still win. Platform-only feature — other
# backends accept-and-ignore the flag.
_rr = self._config.get("rerank", False)
self._rerank_default = (
_rr.lower() in ("true", "1", "yes") if isinstance(_rr, str) else bool(_rr)
)
self._channel = kwargs.get("platform") or "cli"
self._backend = self._create_backend()
if self._backend and not self._atexit_registered:
@ -378,22 +382,32 @@ class Mem0MemoryProvider(MemoryProvider):
return {"channel": self._channel} if self._channel else {}
def system_prompt_block(self) -> str:
mode_label = "platform (cloud API)" if self._mode == "platform" else "OSS (self-hosted)"
rerank_note = " Rerank is available on search." if self._mode == "platform" else ""
# Mirror the precedence in _create_backend (oss > host > platform) so
# the label always names the backend that actually runs. Checking
# ``host`` first here would mislabel an ``oss``+``host`` config as
# self-hosted HTTP even though OSS wins the routing.
if self._mode == "oss":
mode_label = "OSS (self-hosted)"
elif self._host:
mode_label = "self-hosted (HTTP API)"
else:
mode_label = "platform (cloud API)"
# Rerank is a Mem0 Platform feature only.
rerank_note = " Rerank is available on search." if (self._mode == "platform" and not self._host) else ""
return (
"# Mem0 Memory\n"
f"Active. Mode: {mode_label}. User: {self._user_id}.\n"
"You have persistent memory of this user from past conversations. "
"ALWAYS call mem0_search before answering anything that could depend "
"You should call mem0_search before answering anything that could depend "
"on prior context (the user's preferences, facts, history, people, "
"projects, or earlier decisions) — do not rely on the chat window "
"alone, and do not assume you have no memory.\n"
"For multi-part or multi-hop questions, run SEVERAL searches with "
"For multi-part or multi-hop questions, run several searches with "
"different wording/angles and follow-up searches on what the first "
"results surface; one search is rarely enough. Keep searching until "
"you have every fact the question needs before you answer.\n"
"Tools: mem0_search to find memories, mem0_add to store facts, "
f"mem0_list for a full overview, mem0_update and mem0_delete to manage by ID.{rerank_note}"
f"mem0_update and mem0_delete to manage by ID.{rerank_note}"
)
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
@ -426,7 +440,7 @@ class Mem0MemoryProvider(MemoryProvider):
body = ""
try:
results = backend.search(
query, filters=self._read_filters(), top_k=10, rerank=True,
query, filters=self._read_filters(), top_k=10, rerank=False,
)
lines = [r.get("memory", "") for r in (results or []) if r.get("memory")]
if lines:
@ -497,7 +511,7 @@ class Mem0MemoryProvider(MemoryProvider):
self._sync_thread.start()
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [LIST_SCHEMA, SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
return [SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
if self._backend is None:
@ -516,36 +530,13 @@ class Mem0MemoryProvider(MemoryProvider):
msg += f" Check that your {vs.get('provider', 'vector store')} is running."
return json.dumps({"error": msg})
if tool_name == "mem0_list":
try:
page = max(1, int(args.get("page", 1)))
page_size = min(max(1, int(args.get("page_size", 100))), 200)
response = self._backend.get_all(
filters=self._read_filters(), page=page, page_size=page_size,
)
self._record_success()
results = response.get("results", [])
if not results:
return json.dumps({"result": "No memories stored yet."})
items = [{"id": m.get("id"), "memory": m.get("memory", "")}
for m in results]
return json.dumps({
"results": items,
"count": response.get("count", len(items)),
"page": page, "page_size": page_size,
})
except Exception as e:
if not _is_client_error(e):
self._record_failure()
return tool_error(self._format_error("Failed to list memories", e))
elif tool_name == "mem0_search":
if tool_name == "mem0_search":
query = args.get("query", "")
if not query:
return tool_error("Missing required parameter: query")
try:
top_k = max(1, min(int(args.get("top_k", 10)), 50))
rerank_raw = args.get("rerank", True)
rerank_raw = args.get("rerank", getattr(self, "_rerank_default", False))
if isinstance(rerank_raw, str):
rerank = rerank_raw.lower() not in ("false", "0", "no")
else:
@ -576,7 +567,8 @@ class Mem0MemoryProvider(MemoryProvider):
)
self._record_success()
event_id = result.get("event_id") if isinstance(result, dict) else None
msg = "Fact stored." if self._mode == "oss" else "Fact queued for storage."
# Cloud add is async (server-side extraction); OSS and self-hosted store synchronously.
msg = "Fact stored." if (self._mode == "oss" or self._host) else "Fact queued for storage."
return json.dumps({"result": msg, "event_id": event_id})
except Exception as e:
self._record_failure()

View file

@ -10,11 +10,7 @@ class Mem0Backend(ABC):
"""Unified interface over Platform (MemoryClient) and OSS (Memory) backends."""
@abstractmethod
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
...
@abstractmethod
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
...
@abstractmethod
@ -57,16 +53,10 @@ class PlatformBackend(Mem0Backend):
from mem0 import MemoryClient
self._client = MemoryClient(api_key=api_key)
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
response = self._client.search(query, filters=filters, top_k=top_k, rerank=rerank)
return _unwrap_results(response)
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
response = self._client.get_all(filters=filters, page=page, page_size=page_size)
results = response.get("results", []) if isinstance(response, dict) else response
count = response.get("count", len(results)) if isinstance(response, dict) else len(results)
return {"results": results, "count": count}
def add(
self,
messages: list,
@ -90,6 +80,79 @@ class PlatformBackend(Mem0Backend):
return {"result": "Memory deleted.", "memory_id": memory_id}
class SelfHostedBackend(Mem0Backend):
"""Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``).
mem0.MemoryClient can't be reused for self-hosted: it is hardwired to the
cloud API — ``Authorization: Token`` auth and a ``GET /v1/ping/`` validation
call in ``__init__`` that the self-hosted server does not expose (it would
404 before any real request). This client talks to that server directly,
using its actual contract: ``X-API-Key`` auth and the ``/memories`` /
``/search`` routes.
"""
def __init__(self, api_key: str, host: str, transport=None):
import httpx
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key # omitted only for AUTH_DISABLED servers
# Connect-level retries smooth over transient blips so a single
# dropped SYN doesn't count toward the provider failure breaker.
# ``transport`` is injectable for tests (httpx.MockTransport).
if transport is None:
transport = httpx.HTTPTransport(retries=2)
self._client = httpx.Client(
base_url=host.rstrip("/"), headers=headers, timeout=30.0,
transport=transport,
)
def _json(self, method: str, path: str, **kwargs) -> Any:
resp = self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json() if resp.content else {}
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
# rerank is a platform-only feature; the self-hosted /search ignores it.
body: dict[str, Any] = {"query": query, "top_k": top_k}
if filters:
body["filters"] = filters # user_id belongs in filters (top-level is deprecated)
return _unwrap_results(self._json("POST", "/search", json=body))
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
body: dict[str, Any] = {
"messages": messages,
"user_id": user_id,
"agent_id": agent_id,
"infer": infer,
}
if metadata:
body["metadata"] = metadata
return self._json("POST", "/memories", json=body)
def update(self, memory_id: str, text: str) -> dict:
self._json("PUT", f"/memories/{memory_id}", json={"text": text})
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._json("DELETE", f"/memories/{memory_id}")
return {"result": "Memory deleted.", "memory_id": memory_id}
def close(self) -> None:
try:
self._client.close()
except Exception:
pass
class OSSBackend(Mem0Backend):
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
@ -189,18 +252,10 @@ class OSSBackend(Mem0Backend):
except Exception:
pass
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
response = self._memory.search(query, filters=filters, top_k=top_k)
return _unwrap_results(response)
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
response = self._memory.get_all(filters=filters)
all_results = _unwrap_results(response)
total = len(all_results)
start = (page - 1) * page_size
results = all_results[start : start + page_size]
return {"results": results, "count": total}
def add(
self,
messages: list,

View file

@ -67,6 +67,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
flags: dict[str, str] = {
"mode": "",
"api_key": "",
"host": "",
"oss_llm": "openai",
"oss_llm_key": "",
"oss_llm_model": "",
@ -90,6 +91,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
flag_map = {
"--mode": "mode",
"--api-key": "api_key",
"--host": "host",
"--oss-llm": "oss_llm",
"--oss-llm-key": "oss_llm_key",
"--oss-llm-model": "oss_llm_model",
@ -230,7 +232,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
]
existing_config = {}
@ -293,6 +295,24 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
return
provider_config["mode"] = "platform"
# Clear any stale self-hosted host: routing checks ``host`` before platform
# (see _create_backend), so leaving it would silently keep routing to the
# self-hosted server even though the user just chose platform mode. Set it
# to "" rather than pop() — save_config merges into the existing mem0.json
# (existing.update), so a popped key would survive; an empty value overwrites
# it and reads as falsy at routing time.
provider_config["host"] = ""
# The json-file clear above can't help when the host comes from the
# environment: _load_config() seeds ``host`` from MEM0_HOST, and the
# docs tell self-hosted users to put MEM0_HOST in ~/.hermes/.env. Warn
# so the user knows platform mode won't take effect until it's removed.
if os.environ.get("MEM0_HOST", "").strip():
print(
"\n ⚠ MEM0_HOST is set in your environment "
f"({os.environ['MEM0_HOST']}). It overrides platform mode — "
"remove it from ~/.hermes/.env (or unset it) or Hermes will keep "
"routing to the self-hosted server."
)
from hermes_cli.config import save_config
config["memory"]["provider"] = "mem0"
@ -305,12 +325,108 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
if env_writes:
_write_env(Path(hermes_home) / ".env", env_writes)
print(f"\n Memory provider: mem0")
print(f" Activation saved to config.yaml")
print(f" Provider config saved")
print("\n Memory provider: mem0")
print(" Activation saved to config.yaml")
print(" Provider config saved")
if env_writes:
print(f" API keys saved to .env")
print(f"\n Start a new session to activate.\n")
print(" API keys saved to .env")
print("\n Start a new session to activate.\n")
def _check_selfhosted_server(host: str) -> None:
"""Best-effort reachability check for a self-hosted Mem0 server (non-fatal)."""
import urllib.error
import urllib.request as _urlreq
try:
req = _urlreq.Request(f"{host.rstrip('/')}/docs", method="GET")
_urlreq.urlopen(req, timeout=5)
print(f" ✓ Mem0 server reachable at {host}")
except urllib.error.HTTPError:
# Any HTTP response (401/403/404) still means something is listening.
print(f" ✓ Mem0 server responding at {host}")
except Exception:
print(f" ⚠ Could not reach {host} — check the URL and that the server is running.")
def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""Self-hosted mode setup — point at an existing Mem0 dashboard server.
For users already running the Dockerized Mem0 FastAPI server: stores the
server URL (behavioral -> mem0.json) and an optional API key
(secret -> .env as MEM0_API_KEY).
"""
existing_config = {}
config_path = Path(hermes_home) / "mem0.json"
if config_path.exists():
try:
existing_config = json.loads(config_path.read_text())
except Exception:
pass
provider_config = dict(existing_config)
print("\n Configuring mem0 (self-hosted server):\n")
host = flags.get("host") or _prompt(
"Mem0 server URL (e.g. http://localhost:8888)",
default=provider_config.get("host") or None,
)
if not host:
print(" Error: a server URL is required for self-hosted mode.", file=sys.stderr)
return
host = host.rstrip("/")
env_writes: dict[str, str] = {}
if flags.get("api_key"):
env_writes["MEM0_API_KEY"] = flags["api_key"]
else:
existing_key = os.environ.get("MEM0_API_KEY", "")
if existing_key:
masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set"
val = _prompt(f"Server API key (current: {masked}, blank to keep)", secret=True)
else:
val = _prompt("Server API key (blank if AUTH_DISABLED)", secret=True)
if val:
env_writes["MEM0_API_KEY"] = val
user_id = flags.get("user_id") or _prompt(
"User identifier", default=provider_config.get("user_id") or "hermes-user"
)
agent_id = _prompt("Agent identifier", default=provider_config.get("agent_id") or "hermes")
if flags.get("dry_run"):
print(f"\n [dry-run] Would save config: host={host}, user_id={user_id}, agent_id={agent_id}")
if env_writes:
print(" [dry-run] Would write API key to .env")
_check_selfhosted_server(host)
print(" [dry-run] No files written.\n")
return
provider_config["mode"] = "platform" # routing: oss > host > platform; host wins
provider_config["host"] = host
provider_config["user_id"] = user_id
provider_config["agent_id"] = agent_id
from hermes_cli.config import save_config
config["memory"]["provider"] = "mem0"
save_config(config)
from plugins.memory.mem0 import Mem0MemoryProvider
provider = Mem0MemoryProvider()
provider.save_config(provider_config, hermes_home)
if env_writes:
_write_env(Path(hermes_home) / ".env", env_writes)
_check_selfhosted_server(host)
print("\n Memory provider: mem0 (self-hosted)")
print(f" Server: {host}")
print(" Activation saved to config.yaml")
print(" Provider config saved")
if env_writes:
print(" API key saved to .env")
print("\n Start a new session to activate.\n")
def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
@ -358,14 +474,14 @@ def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
save_config(config)
_run_connectivity_checks(oss_config)
print(f"\n ✓ Mem0 configured (OSS mode)")
print("\n ✓ Mem0 configured (OSS mode)")
print(f" LLM: {oss_config['llm']['provider']} ({oss_config['llm']['config'].get('model', '')})")
print(f" Embedder: {oss_config['embedder']['provider']} ({oss_config['embedder']['config'].get('model', '')})")
print(f" Vector: {vector_id}")
if env_writes:
print(f" API keys saved to .env")
print(f" Config saved to mem0.json")
print(f" Provider set in config.yaml")
print(" API keys saved to .env")
print(" Config saved to mem0.json")
print(" Provider set in config.yaml")
print("\n Start a new session to activate.\n")
@ -417,7 +533,7 @@ def _ensure_pgvector(host: str = "localhost", port: int = 5432) -> dict | None:
_wait_for_port(host, port, timeout=15)
ok, _ = _check_pgvector(host, port)
if ok:
print(f" ✓ PostgreSQL container restarted")
print(" ✓ PostgreSQL container restarted")
return None
except Exception:
pass
@ -711,14 +827,14 @@ def _setup_oss_interactive(hermes_home: str, config: dict) -> None:
save_config(config)
_run_connectivity_checks(oss_config)
print(f"\n ✓ Mem0 configured (OSS mode)")
print("\n ✓ Mem0 configured (OSS mode)")
print(f" LLM: {oss_config['llm']['provider']} ({oss_config['llm']['config'].get('model', '')})")
print(f" Embedder: {oss_config['embedder']['provider']} ({oss_config['embedder']['config'].get('model', '')})")
print(f" Vector: {vector_id}")
if env_writes:
print(f" API keys saved to .env")
print(f" Config saved to mem0.json")
print(f" Provider set in config.yaml")
print(" API keys saved to .env")
print(" Config saved to mem0.json")
print(" Provider set in config.yaml")
print("\n Start a new session to activate.\n")
@ -828,10 +944,10 @@ def _check_min_dep_version() -> None:
def post_setup(hermes_home: str, config: dict) -> None:
"""Entry point called by hermes memory setup framework.
Only intercepts when OSS mode is requested (via --mode oss flag or
interactive picker). For platform mode, returns without action so the
framework's schema-based flow handles it (preserving the original
platform onboarding experience).
Routes on --mode (platform / selfhosted / oss); with no flag it shows an
interactive picker with all three modes. Platform keeps the framework's
original schema-based onboarding; selfhosted points at an existing Mem0
server; oss builds a local SDK config.
"""
_check_min_dep_version()
flags = parse_flags(sys.argv[1:])
@ -841,6 +957,10 @@ def post_setup(hermes_home: str, config: dict) -> None:
_setup_oss(hermes_home, config, flags)
return
if flags["mode"] in ("selfhosted", "self-hosted"):
_setup_selfhosted(hermes_home, config, flags)
return
if flags["mode"] == "platform":
_setup_platform(hermes_home, config, flags)
return
@ -848,10 +968,13 @@ def post_setup(hermes_home: str, config: dict) -> None:
# No --mode flag: show interactive picker
mode_items = [
("Platform", "Mem0 Cloud API (lightweight, just needs an API key)"),
("Self-hosted server", "Connect to an existing self-hosted Mem0 server (Docker/FastAPI)"),
("Open Source", "Run Mem0 locally (self-hosted LLM + vector store)"),
]
mode_idx = _curses_select(" Select mode", mode_items, 0)
if mode_idx == 1:
_setup_selfhosted(hermes_home, config, flags)
elif mode_idx == 2:
flags["_mode_from_flag"] = False
_setup_oss(hermes_home, config, flags)
else:

View file

@ -1,5 +1,5 @@
name: mem0
version: 1.2.0
description: "Mem0 — server-side LLM fact extraction with semantic search, reranking, and automatic deduplication."
version: 1.3.0
description: "Mem0 — server-side LLM fact extraction with semantic search, automatic deduplication, and opt-in reranking (platform mode)."
pip_dependencies:
- mem0ai>=2.0.7,<3
- mem0ai>=2.0.10,<3

View file

@ -576,6 +576,8 @@ _TOOL_STATUS_COMPLETED_ALIASES = {"completed", "complete", "success", "succeeded
def _zip_directory(dir_path: Path) -> Path:
"""Create a temporary zip file containing a directory tree."""
from agent.file_safety import raise_if_read_blocked
root = dir_path.resolve()
zip_path = Path(tempfile.gettempdir()) / f"openviking_upload_{uuid.uuid4().hex}.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
@ -584,7 +586,12 @@ def _zip_directory(dir_path: Path) -> Path:
continue
if file_path.is_file():
try:
file_path.resolve().relative_to(root)
resolved = file_path.resolve()
resolved.relative_to(root)
except ValueError:
continue
try:
raise_if_read_blocked(str(resolved))
except ValueError:
continue
arcname = str(file_path.relative_to(dir_path)).replace("\\", "/")
@ -3645,6 +3652,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
return json.dumps(payload, ensure_ascii=False)
def _tool_add_resource(self, args: dict) -> str:
from agent.file_safety import raise_if_read_blocked
url = args.get("url", "")
if not url:
return tool_error("url is required")
@ -3678,6 +3687,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
cleanup_path = _zip_directory(source_path)
upload_path = cleanup_path
elif source_path.is_file():
try:
raise_if_read_blocked(str(source_path))
except ValueError as exc:
return tool_error(str(exc))
payload["source_name"] = source_path.name
upload_path = source_path
else:

View file

@ -34,6 +34,7 @@ from typing import Any, Dict, List
from urllib.parse import quote
from agent.memory_provider import MemoryProvider
from agent.file_safety import raise_if_read_blocked
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@ -702,6 +703,10 @@ class RetainDBMemoryProvider(MemoryProvider):
path_obj = Path(local_path)
if not path_obj.exists():
return {"error": f"File not found: {local_path}"}
try:
raise_if_read_blocked(str(path_obj))
except ValueError as exc:
return {"error": str(exc)}
data = path_obj.read_bytes()
import mimetypes
mime = mimetypes.guess_type(path_obj.name)[0] or "application/octet-stream"

View file

@ -1,9 +1,13 @@
"""Custom / Ollama (local) provider profile.
Covers any endpoint registered as provider="custom", including local
Ollama instances. Key quirks:
Ollama instances and OpenAI-compatible reasoning endpoints (GLM-5.2 on
Volcengine ARK, vLLM, llama.cpp). Key quirks:
- ollama_num_ctx → extra_body.options.num_ctx (local context window)
- reasoning_config disabled → extra_body.think = False
- reasoning_config enabled + effort → top-level reasoning_effort
(the native OpenAI-compatible format GLM/ARK expect; unset omits it
so the endpoint's server default applies)
"""
from typing import Any
@ -23,6 +27,7 @@ class CustomProfile(ProviderProfile):
**ctx: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
# Ollama context window
if ollama_num_ctx:
@ -30,14 +35,29 @@ class CustomProfile(ProviderProfile):
options["num_ctx"] = ollama_num_ctx
extra_body["options"] = options
# Disable thinking when reasoning is turned off
# Reasoning / thinking control for custom OpenAI-compatible endpoints
# (GLM-5.2 on Volcengine ARK, vLLM, Ollama, llama.cpp, …).
#
# - disabled → extra_body.think = False (Ollama's thinking-off flag)
# - enabled + effort set → TOP-LEVEL reasoning_effort string, the
# format GLM-5.2/ARK and other OpenAI-compatible reasoning APIs
# expect (GLM documents "high" and "max"; "max" is its default).
# - enabled + no effort → omit both, so the endpoint applies its own
# server-side default (do NOT force a level the user didn't pick).
#
# We deliberately do NOT emit ``think=True`` on enable: it is an
# Ollama-only flag and thinking is already server-default-on for these
# backends, so forcing it risks a 400 on GLM/vLLM endpoints that don't
# recognize it. Mirrors the DeepSeek/Zai profile precedent.
if reasoning_config and isinstance(reasoning_config, dict):
_effort = (reasoning_config.get("effort") or "").strip().lower()
_enabled = reasoning_config.get("enabled", True)
if _effort == "none" or _enabled is False:
extra_body["think"] = False
elif _effort:
top_level["reasoning_effort"] = _effort
return extra_body, {}
return extra_body, top_level
def fetch_models(
self,

View file

@ -31,6 +31,12 @@ def _is_deepseek_thinking_model(model: str | None) -> bool:
return m == "deepseek-reasoner"
def _is_glm_5_2_model(model: str | None) -> bool:
"""Detect GLM-5.2 across alias spellings (glm-5.2 / glm-5-2 / glm-5p2)."""
m = _flat_model_name(model)
return any(token in m for token in ("glm-5.2", "glm-5-2", "glm-5p2"))
class OpenCodeGoProfile(ProviderProfile):
"""OpenCode Go - model-specific reasoning controls."""
@ -55,6 +61,21 @@ class OpenCodeGoProfile(ProviderProfile):
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if _is_glm_5_2_model(model):
# GLM-5.2 on OpenCode Go uses its native OpenAI-compatible
# reasoning_effort knob, which has exactly two enabled levels:
# high and max. Map Hermes' richer scale onto those; leave the
# server default alone when reasoning is disabled or unset.
if not isinstance(reasoning_config, dict):
return extra_body, top_level
if reasoning_config.get("enabled") is False:
return extra_body, top_level
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort or effort == "none":
return extra_body, top_level
top_level["reasoning_effort"] = "max" if effort in {"xhigh", "max"} else "high"
return extra_body, top_level
if _is_kimi_k2_model(model):
# Kimi K2 on OpenCode Go uses Moonshot's native wire shape:
# extra_body.thinking (binary toggle) + top-level reasoning_effort

View file

@ -16,6 +16,12 @@ When no reasoning preference is set (``reasoning_config is None``) the field
is omitted so the server default applies, matching prior behavior. GLM
models before 4.5 (e.g. ``glm-4-9b``) don't accept ``thinking`` and are left
untouched.
GLM-5.2 additionally exposes a native ``reasoning_effort`` knob with exactly
two enabled levels — ``high`` and ``max`` — on the OpenAI-compatible endpoint
(per Z.AI / BigModel docs). Hermes' richer effort scale is collapsed onto
those two so the user's effort preference actually reaches the model instead
of being silently dropped.
"""
from __future__ import annotations
@ -40,8 +46,44 @@ def _model_supports_thinking(model: str | None) -> bool:
return (major, minor) >= (4, 5)
def _is_glm_5_2(model: str | None) -> bool:
"""Detect GLM-5.2 across the alias spellings providers use.
Covers the canonical ``glm-5.2`` plus the ``glm-5-2`` / ``glm-5p2``
variants seen on relays (Fireworks ``glm-5p2``, etc.) and any
vendor-prefixed form (``z-ai/glm-5.2``, ``zai-org-glm-5-2``).
"""
m = (model or "").strip().lower()
if not m:
return False
return any(token in m for token in ("glm-5.2", "glm-5-2", "glm-5p2"))
def _glm_5_2_reasoning_effort(reasoning_config: dict | None) -> str | None:
"""Map Hermes reasoning effort onto GLM-5.2's native ``high``/``max``.
GLM-5.2 only supports two enabled effort levels. ``xhigh``/``max``
request the top tier; everything else that is enabled requests ``high``
(its minimum thinking level). When reasoning is explicitly disabled, or
no effort preference is supplied, the server default is left untouched.
"""
if not isinstance(reasoning_config, dict):
return None
if reasoning_config.get("enabled") is False:
return None
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort or effort == "none":
return None
if effort in {"xhigh", "max"}:
return "max"
# low / medium / minimal / high all clamp to GLM-5.2's minimum: high.
return "high"
class ZaiProfile(ProviderProfile):
"""Z.AI / GLM — extra_body.thinking enabled/disabled."""
"""Z.AI / GLM — extra_body.thinking on/off + GLM-5.2 reasoning_effort."""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
@ -49,7 +91,7 @@ class ZaiProfile(ProviderProfile):
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if not _model_supports_thinking(model):
if not _model_supports_thinking(model) and not _is_glm_5_2(model):
return extra_body, top_level
# Only emit when the user expressed a preference; omitting the field
@ -58,6 +100,11 @@ class ZaiProfile(ProviderProfile):
enabled = reasoning_config.get("enabled") is not False
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
if _is_glm_5_2(model):
effort = _glm_5_2_reasoning_effort(reasoning_config)
if effort is not None:
top_level["reasoning_effort"] = effort
return extra_body, top_level

File diff suppressed because it is too large Load diff

View file

@ -228,6 +228,19 @@ _APPROVAL_LABEL_MAP: Dict[str, str] = {
"always": "Approved permanently",
"deny": "Denied",
}
async def _read_limited_feishu_webhook_body(request: Any, max_bytes: int) -> bytes:
"""Read at most ``max_bytes`` from an aiohttp request body."""
try:
body = await request.content.readexactly(max_bytes + 1)
except asyncio.IncompleteReadError as exc:
body = exc.partial
if len(body) > max_bytes:
raise ValueError("payload too large")
return body
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
@ -1756,10 +1769,49 @@ class FeishuAdapter(BasePlatformAdapter):
await self._cancel_pending_tasks(self._pending_text_batch_tasks)
await self._cancel_pending_tasks(self._pending_media_batch_tasks)
self._reset_batch_buffers()
# Send a WebSocket CLOSE frame to Feishu BEFORE tearing down the
# thread loop. Without this, Feishu's server never learns the
# connection is dead and continues routing messages to the stale
# endpoint — the channel goes silent until the server-side
# CLOSE-WAIT expires (minutes to hours). See issue #10202.
#
# ``_disable_websocket_auto_reconnect()`` nils ``self._ws_client``,
# so capture the client reference first.
ws_client = self._ws_client
ws_thread_loop = self._ws_thread_loop
self._disable_websocket_auto_reconnect()
await self._stop_webhook_server()
ws_thread_loop = self._ws_thread_loop
if (
ws_client is not None
and ws_thread_loop is not None
and not ws_thread_loop.is_closed()
and hasattr(ws_client, "_disconnect")
):
try:
future = asyncio.run_coroutine_threadsafe(
ws_client._disconnect(), ws_thread_loop
)
# 5s is generous — the CLOSE frame is a single WebSocket
# control frame. If it takes longer than that the
# connection is already wedged and we gain nothing by
# waiting further.
await asyncio.wait_for(asyncio.wrap_future(future), timeout=5.0)
logger.debug("[Feishu] Sent WebSocket CLOSE frame to Feishu")
except asyncio.TimeoutError:
logger.warning(
"[Feishu] CLOSE frame not acknowledged within 5s — "
"Feishu may briefly route messages to the stale "
"connection until server-side timeout"
)
except Exception as exc:
logger.debug(
"[Feishu] Could not send WebSocket CLOSE frame: %s",
exc,
exc_info=True,
)
if ws_thread_loop is not None and not ws_thread_loop.is_closed():
logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop")
@ -3428,9 +3480,16 @@ class FeishuAdapter(BasePlatformAdapter):
try:
body_bytes: bytes = await asyncio.wait_for(
request.read(),
_read_limited_feishu_webhook_body(
request,
_FEISHU_WEBHOOK_MAX_BODY_BYTES,
),
timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS,
)
except ValueError:
logger.warning("[Feishu] Webhook body exceeds limit from %s", remote_ip)
self._record_webhook_anomaly(remote_ip, "413")
return web.Response(status=413, text="Request body too large")
except asyncio.TimeoutError:
logger.warning("[Feishu] Webhook body read timed out after %ds from %s", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip)
self._record_webhook_anomaly(remote_ip, "408")
@ -3439,11 +3498,6 @@ class FeishuAdapter(BasePlatformAdapter):
self._record_webhook_anomaly(remote_ip, "400")
return web.json_response({"code": 400, "msg": "failed to read body"}, status=400)
if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
logger.warning("[Feishu] Webhook body exceeds limit (%d bytes) from %s", len(body_bytes), remote_ip)
self._record_webhook_anomaly(remote_ip, "413")
return web.Response(status=413, text="Request body too large")
try:
payload = json.loads(body_bytes.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
@ -4693,7 +4747,10 @@ class FeishuAdapter(BasePlatformAdapter):
if self._event_handler is None:
raise RuntimeError("failed to build Feishu event handler")
await self._hydrate_bot_identity()
app = web.Application()
# client_max_size backstops the bounded reader in
# _handle_webhook_request; aiohttp then enforces the same cap on
# every read path (#58536/#58902/#59180 pattern).
app = web.Application(client_max_size=_FEISHU_WEBHOOK_MAX_BODY_BYTES)
app.router.add_post(self._webhook_path, self._handle_webhook_request)
self._webhook_runner = web.AppRunner(app)
await self._webhook_runner.setup()

View file

@ -302,7 +302,7 @@ def _print_status() -> None:
print(f"Pairing file: {PAIRING_FILE}")
print(f" exists: {PAIRING_FILE.exists()}")
print()
print(f"Top-level:")
print("Top-level:")
print(f" enabled: {cfg.enabled}")
print(f" policy: {cfg.policy}")
print(f" allow_from: {sorted(cfg.allow_from) if cfg.allow_from else '[]'}")
@ -339,7 +339,7 @@ def _do_check(doc_key: str, user_open_id: str) -> None:
allowed = is_user_allowed(rule, user_open_id)
print(f"Document: {doc_key}")
print(f"User: {user_open_id}")
print(f"Resolved rule:")
print("Resolved rule:")
print(f" enabled: {rule.enabled}")
print(f" policy: {rule.policy}")
print(f" allow_from: {sorted(rule.allow_from) if rule.allow_from else '[]'}")

View file

@ -583,7 +583,7 @@ class GoogleChatAdapter(BasePlatformAdapter):
)
if not os.path.exists(sa_path):
raise FileNotFoundError(
f"Service Account JSON file not found at configured path."
"Service Account JSON file not found at configured path."
)
# Validate file parses before handing to google-auth for nicer error.
try:

View file

@ -379,13 +379,14 @@ def install_deps() -> bool:
print("Installing Google Chat OAuth dependencies...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
stdout=subprocess.DEVNULL,
)
from hermes_cli.tools_config import _pip_install
result = _pip_install(["--quiet"] + _REQUIRED_PACKAGES)
if result.returncode != 0:
raise RuntimeError((result.stderr or "install failed").strip()[:300])
print("Dependencies installed.")
return True
except subprocess.CalledProcessError as exc:
except Exception as exc:
print(f"ERROR: Failed to install dependencies: {exc}")
print("Or install via the optional extra:")
print(" pip install 'hermes-agent[google_chat]'")

View file

@ -2351,7 +2351,18 @@ class MatrixAdapter(BasePlatformAdapter):
if inspect.isawaitable(tasks):
tasks = await tasks
if tasks:
await asyncio.gather(*tasks)
# return_exceptions=True so one failing event handler doesn't abort
# the whole gather and silently drop the SIBLING events in the same
# sync response (a bare gather re-raises the first exception, leaving
# the rest of the batch unprocessed). Mirrors the invite/redaction
# gathers above. Surface each failure instead of swallowing it.
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.warning(
"Matrix: event handler failed during sync dispatch: %s",
result,
)
def _is_self_sender(self, sender: str) -> bool:
"""Return True if the sender refers to the bot's own account.
@ -4486,23 +4497,14 @@ def interactive_setup() -> None:
__import__("mautrix")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", _sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[_sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
from hermes_cli.tools_config import _pip_install
result = _pip_install([matrix_pkg])
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install "
f"Install failed — run manually: uv pip install "
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
)

View file

@ -878,6 +878,8 @@ class MattermostAdapter(BasePlatformAdapter):
# Determine message type.
file_ids = post.get("file_ids") or []
msg_type = MessageType.TEXT
if message_text[:1].isspace() and message_text.lstrip().startswith("/"):
message_text = message_text.lstrip()
if message_text.startswith("/"):
msg_type = MessageType.COMMAND

View file

@ -85,6 +85,12 @@ _DEDUP_WINDOW_SECONDS = 48 * 3600
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
# Cap on a self-heal `npm ci`/`npm install` of the sidecar deps. A cold
# install of the pinned spectrum-ts tree normally takes well under a minute;
# a wedged npm (dead registry, network blackhole) must not stall the photon
# connect path indefinitely.
_NPM_REINSTALL_TIMEOUT = 600
# Photon / Envoy / spectrum-ts error substrings that indicate a transient
# upstream overload rather than a permanent failure. These are not in the
# core _RETRYABLE_ERROR_PATTERNS because they are specific to this adapter.
@ -137,6 +143,77 @@ def check_requirements() -> bool:
return True
def _sidecar_deps_stale() -> bool:
"""True when node_modules exists but is older than the committed lockfile.
`hermes update` rewrites ``package-lock.json`` when the spectrum-ts pin is
bumped, but does not reinstall ``node_modules``. npm records the state of
the last install in ``node_modules/.package-lock.json``; when the top-level
lockfile is newer than that marker, the install is out of date. This is the
same signal ``npm ci`` uses. Returns False (do nothing) if either file is
missing or unreadable, so a first-run or odd filesystem never blocks start.
"""
lockfile = _SIDECAR_DIR / "package-lock.json"
marker = _SIDECAR_DIR / "node_modules" / ".package-lock.json"
try:
return lockfile.stat().st_mtime > marker.stat().st_mtime
except OSError:
return False
def _reinstall_sidecar_deps() -> None:
"""Reinstall the sidecar's node_modules from the lockfile (blocking).
Mirrors ``hermes photon install-sidecar``: ``npm ci`` for an exact,
reproducible install, falling back to ``npm install`` if the lockfile is
missing or drifted. Runs the postinstall patch as part of the install.
Best-effort — a failure here just leaves the (stale) deps in place and the
normal ``_start_sidecar`` readiness check reports the real error.
"""
npm = shutil.which("npm")
if not npm:
logger.warning("[photon] cannot reinstall stale sidecar deps: npm not on PATH")
return
try:
result = subprocess.run( # noqa: S603
[npm, "ci"],
cwd=str(_SIDECAR_DIR),
capture_output=True,
text=True,
check=False,
timeout=_NPM_REINSTALL_TIMEOUT,
)
if result.returncode != 0:
logger.warning(
"[photon] sidecar `npm ci` failed; falling back to `npm install`"
)
result = subprocess.run( # noqa: S603
[npm, "install"],
cwd=str(_SIDECAR_DIR),
capture_output=True,
text=True,
check=False,
timeout=_NPM_REINSTALL_TIMEOUT,
)
except subprocess.TimeoutExpired:
# A wedged npm (dead registry, network blackhole) must not stall the
# photon connect forever — give up, leave the stale deps in place, and
# let the readiness check report the real error. Retried on the next
# reconnect tick.
logger.error(
"[photon] sidecar dependency reinstall timed out after %ss",
_NPM_REINSTALL_TIMEOUT,
)
return
if result.returncode != 0:
logger.error(
"[photon] sidecar dependency reinstall failed: %s",
(result.stderr or result.stdout or "").strip(),
)
else:
logger.info("[photon] sidecar dependencies reinstalled from lockfile")
def validate_config(cfg: PlatformConfig) -> bool:
extra = cfg.extra or {}
project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID")
@ -841,6 +918,19 @@ class PhotonAdapter(BasePlatformAdapter):
f"Photon sidecar deps not installed. Run: "
f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)"
)
# A `hermes update` that bumps the spectrum-ts pin rewrites
# package-lock.json but never reinstalls node_modules, so the sidecar
# spawns against stale deps and dies on every reconnect (the v8 patch
# script can't find @spectrum-ts/imessage/dist that only v8 ships).
# Self-heal by reinstalling when the lockfile is newer than npm's
# install marker. Runs off the event loop so a cold install can't
# freeze every other platform's traffic.
if _sidecar_deps_stale():
logger.warning(
"[photon] sidecar deps are stale (lockfile newer than install); "
"reinstalling before start"
)
await asyncio.to_thread(_reinstall_sidecar_deps)
await self._reap_stale_sidecar()
env = os.environ.copy()

View file

@ -473,7 +473,11 @@ class RaftAdapter(BasePlatformAdapter):
self._bridge_token = secrets.token_hex(32)
logger.info("[raft] Auto-generated bridge token")
app = web.Application()
# client_max_size makes aiohttp enforce the cap on every read path,
# including Transfer-Encoding: chunked bodies that carry no
# Content-Length and would otherwise bypass the header checks below
# (mirrors gateway/platforms/webhook.py's connect()).
app = web.Application(client_max_size=self._max_body_bytes)
app.router.add_get("/health", self._handle_health)
app.router.add_post(self._path, self._handle_wake)
app.router.add_post("/activity", self._handle_activity)
@ -600,8 +604,16 @@ class RaftAdapter(BasePlatformAdapter):
try:
raw_body = await request.read()
except web.HTTPRequestEntityTooLarge:
# aiohttp's client_max_size tripped — chunked or lying
# Content-Length. Same 413 as the header check above.
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
except Exception:
return web.json_response({"ok": False, "error": "bad_request"}, status=400)
if len(raw_body) > self._max_body_bytes:
# Defense in depth: enforce the cap on the actual bytes read even
# if the server-level limit was bypassed or misconfigured.
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
payload: Dict[str, Any] = {}
if raw_body.strip():
@ -646,7 +658,20 @@ class RaftAdapter(BasePlatformAdapter):
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
try:
payload = json.loads(await request.text())
raw_text = await request.text()
except web.HTTPRequestEntityTooLarge:
# aiohttp's client_max_size tripped — chunked or lying
# Content-Length. Same 413 as the header check above.
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
except Exception as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
if len(raw_text.encode("utf-8")) > self._max_body_bytes:
# Defense in depth: enforce the cap on the actual bytes read even
# if the server-level limit was bypassed or misconfigured.
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
try:
payload = json.loads(raw_text)
self._activity_queue.push(payload)
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)

View file

@ -2761,6 +2761,15 @@ class SlackAdapter(BasePlatformAdapter):
if not channel_type and channel_id.startswith("D"):
channel_type = "im"
is_dm = channel_type in {"im", "mpim"} # Both 1:1 and group DMs
# A 1:1 IM is a private conversation with a single human — mention-exempt
# and safe to react to unconditionally, like any DM. An MPIM (group DM)
# is a SHARED surface: multiple humans can see and trigger the bot, so it
# must obey the same operator controls as a channel (allowed_channels /
# require_mention / strict_mention / free_response_channels) and must not
# get reaction noise on messages that don't address the bot. Only the 1:1
# case earns the DM exemptions; session/thread scoping below still treats
# both as DM-style persistent conversations.
is_one_to_one_dm = channel_type == "im"
# Build thread_ts for session keying.
# In channels: fall back to ts so each top-level @mention starts a
@ -2823,7 +2832,7 @@ class SlackAdapter(BasePlatformAdapter):
event_thread_ts = event.get("thread_ts")
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
if not is_dm and bot_uid:
if not is_one_to_one_dm and bot_uid:
# Check allowed channels — if set, only respond in these channels (whitelist)
allowed_channels = self._slack_allowed_channels()
if allowed_channels and channel_id not in allowed_channels:
@ -3216,10 +3225,11 @@ class SlackAdapter(BasePlatformAdapter):
auto_skill=_auto_skill,
)
# Only react when bot is directly addressed (DM or @mention).
# In listen-all channels (require_mention=false), reacting to every
# casual message would be noisy.
_should_react = (is_dm or is_mentioned) and self._reactions_enabled()
# Only react when bot is directly addressed (1:1 DM or @mention).
# MPIMs are shared surfaces: reacting to every group-DM message (even
# when unmentioned) is visible noise to the whole group, so they must
# be @mentioned to earn a reaction — same as any channel.
_should_react = (is_one_to_one_dm or is_mentioned) and self._reactions_enabled()
if _should_react:
self._reacting_message_ids.add(ts)

View file

@ -55,6 +55,11 @@ _QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$")
def _is_list_line(line: str) -> bool:
"""True if ``line`` is a markdown list item (bullet or ordered)."""
return bool(_BULLET_RE.match(line) or _ORDERED_RE.match(line))
def _indent_level(spaces: str) -> int:
"""Map leading whitespace to a nesting level (2 spaces or 1 tab per level)."""
width = 0
@ -434,7 +439,7 @@ def render_blocks(
continue
# List group (bullets + ordered, with nesting)
if _BULLET_RE.match(line) or _ORDERED_RE.match(line):
if _is_list_line(line):
flush_para()
items: List[Tuple[int, bool, str]] = []
while i < n:
@ -451,6 +456,22 @@ def render_blocks(
indent, ordered, txt = items[-1]
items[-1] = (indent, ordered, txt + " " + lines[i].strip())
i += 1
elif not lines[i].strip() and items:
# Blank line inside a list run. LLM-authored ordered
# lists commonly separate items with a blank line; if
# the next non-blank line is another list item, treat
# the blank(s) as a soft separator and keep the run
# going so the items stay in one rich_text_list (Slack
# numbers each list independently, so splitting would
# restart every item at "1."). Otherwise the blank
# ends the list.
j = i + 1
while j < n and not lines[j].strip():
j += 1
if j < n and _is_list_line(lines[j]):
i = j
else:
break
else:
break
blocks.append(_list_block(items))

View file

@ -42,6 +42,7 @@ TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
DEFAULT_WEBHOOK_PORT = 8080
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
_TWILIO_WEBHOOK_MAX_BODY_BYTES = 65_536 # 64 KiB — Twilio payloads are small
def check_sms_requirements() -> bool:
@ -118,7 +119,10 @@ class SmsAdapter(BasePlatformAdapter):
self._webhook_port,
)
app = web.Application()
# client_max_size bounds every read path — including chunked bodies
# with no Content-Length — before the handler's own 413 checks run
# (#58536/#58902/#59180 pattern).
app = web.Application(client_max_size=_TWILIO_WEBHOOK_MAX_BODY_BYTES)
app.router.add_post("/webhooks/twilio", self._handle_webhook)
app.router.add_get("/health", lambda _: web.Response(text="ok"))
@ -293,7 +297,20 @@ class SmsAdapter(BasePlatformAdapter):
from aiohttp import web
try:
content_length = request.content_length
if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
return web.Response(
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
content_type="application/xml",
status=413,
)
raw = await request.read()
if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
return web.Response(
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
content_type="application/xml",
status=413,
)
# Twilio sends form-encoded data, not JSON
form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
except Exception as e:

View file

@ -102,6 +102,9 @@ from gateway.platforms.base import (
logger = logging.getLogger(__name__)
_DEFAULT_PORT = 3978
# Bot Framework activities are JSON payloads well under 1 MiB; an explicit
# aiohttp client_max_size keeps oversized/chunked request bodies bounded.
_MAX_BODY_BYTES = 1_048_576
_WEBHOOK_PATH = "/api/messages"
@ -738,8 +741,12 @@ class TeamsAdapter(BasePlatformAdapter):
return False
try:
# Set up aiohttp app first — the bridge adapter wires SDK routes into it
aiohttp_app = web.Application()
# Set up aiohttp app first — the bridge adapter wires SDK routes into it.
# client_max_size: Bot Framework activities are JSON (caps out well
# under 1 MiB); an explicit cap keeps oversized/chunked bodies from
# being buffered unbounded on a 0.0.0.0 bind (same pattern as
# webhook.py / raft, #58536/#58902).
aiohttp_app = web.Application(client_max_size=_MAX_BODY_BYTES)
aiohttp_app.router.add_get("/health", lambda _: web.Response(text="ok"))
self._app = App(

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ _IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, Optional, Any
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
from hermes_constants import (
find_node_executable,
get_hermes_dir,
@ -490,19 +491,19 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
# QR codes to its log file and never reaches status:connected,
# so every gateway restart paid the 30s timeout + queued WhatsApp
# for indefinite retries. Mark non-retryable so the user gets a
# clear "run hermes whatsapp" message instead of the watcher
# clear pairing message instead of the watcher
# silently hammering an unconfigured platform.
creds_path = self._session_path / "creds.json"
if not creds_path.exists():
logger.warning(
"[%s] WhatsApp is enabled but not paired (no creds.json at %s). "
"Run `hermes whatsapp` to pair, or remove WHATSAPP_ENABLED from "
"your .env to disable.",
"Pair from the dashboard or run `hermes whatsapp`; remove "
"WHATSAPP_ENABLED from your .env to disable.",
self.name, creds_path,
)
self._set_fatal_error(
"whatsapp_not_paired",
"WhatsApp enabled but not paired — run `hermes whatsapp` to pair.",
"WhatsApp enabled but not paired — pair from the dashboard or run `hermes whatsapp`.",
retryable=False,
)
return False
@ -648,8 +649,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
],
stdout=bridge_log_fh,
stderr=bridge_log_fh,
start_new_session=True,
env=bridge_env,
**windows_detach_popen_kwargs(),
)
_write_bridge_pidfile(self._session_path, self._bridge_process.pid)
@ -859,14 +860,16 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self._outgoing_chunk_limit())
sent_message_ids: list[str] = []
last_message_id = None
for chunk in chunks:
for idx, chunk in enumerate(chunks):
payload: Dict[str, Any] = {
"chatId": chat_id,
"message": chunk,
}
if reply_to and last_message_id is None:
# Only reply-to on the first chunk
if reply_to and idx == 0:
# Only reply-to on the first text chunk, even if the bridge
# response omits a parseable message id.
payload["replyTo"] = reply_to
async with self._http_session.post(
@ -877,6 +880,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
if resp.status == 200:
data = await resp.json()
last_message_id = data.get("messageId")
if last_message_id:
sent_message_ids.append(str(last_message_id))
else:
error = await resp.text()
return SendResult(success=False, error=error)
@ -888,6 +893,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
return SendResult(
success=True,
message_id=last_message_id,
continuation_message_ids=tuple(sent_message_ids[:-1]),
raw_response={"message_ids": sent_message_ids},
)
except Exception as e:
return SendResult(success=False, error=str(e))
@ -974,6 +981,138 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_poll(
self,
chat_id: str,
question: str,
options: list[str],
*,
selectable_count: int = 1,
) -> SendResult:
"""Send a native WhatsApp poll via the Baileys bridge.
This is a low-level transport primitive only. Gateway approval UX must
remain gateway-owned and add text fallback plus explicit confirmation
semantics before approval prompts are ever mapped onto polls.
"""
if not self._running or not self._http_session:
return SendResult(success=False, error="Not connected")
bridge_exit = await self._check_managed_bridge_exit()
if bridge_exit:
return SendResult(success=False, error=bridge_exit)
try:
import aiohttp
payload: Dict[str, Any] = {
"chatId": to_whatsapp_jid(chat_id),
"question": question,
"options": list(options or []),
"selectableCount": selectable_count,
}
async with self._http_session.post(
f"http://127.0.0.1:{self._bridge_port}/send-poll",
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
data = await resp.json()
return SendResult(
success=True,
message_id=data.get("messageId"),
raw_response=data,
)
error = await resp.text()
return SendResult(success=False, error=error)
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_clarify(
self,
chat_id: str,
question: str,
choices: Optional[list],
clarify_id: str,
session_key: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Render multiple-choice clarify as a native WhatsApp poll.
The gateway registers the pending clarify before calling this method.
When Baileys later emits a poll_update with the selected option as
message text, the normal clarify text-intercept resolves the pending
question and the blocked agent continues. Open-ended clarifies use the
text fallback so the user's next typed message is captured.
"""
clean_choices = [str(choice).strip() for choice in (choices or []) if str(choice).strip()]
if 2 <= len(clean_choices) <= 12:
result = await self.send_poll(
chat_id,
str(question or "").strip(),
clean_choices,
selectable_count=1,
)
if result.success:
return result
logger.warning(
"[%s] Native WhatsApp clarify poll failed; falling back to text: %s",
self.name,
result.error,
)
return await super().send_clarify(
chat_id=chat_id,
question=question,
choices=choices,
clarify_id=clarify_id,
session_key=session_key,
metadata=metadata,
)
async def send_location(
self,
chat_id: str,
latitude: float,
longitude: float,
*,
name: Optional[str] = None,
address: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a native WhatsApp location pin via the Baileys bridge."""
if not self._running or not self._http_session:
return SendResult(success=False, error="Not connected")
bridge_exit = await self._check_managed_bridge_exit()
if bridge_exit:
return SendResult(success=False, error=bridge_exit)
try:
import aiohttp
payload: Dict[str, Any] = {
"chatId": to_whatsapp_jid(chat_id),
"latitude": float(latitude),
"longitude": float(longitude),
}
if name:
payload["name"] = name
if address:
payload["address"] = address
async with self._http_session.post(
f"http://127.0.0.1:{self._bridge_port}/send-location",
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
data = await resp.json()
return SendResult(
success=True,
message_id=data.get("messageId"),
raw_response=data,
)
error = await resp.text()
return SendResult(success=False, error=error)
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_image(
self,
chat_id: str,
@ -1196,14 +1335,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
# Determine message type
msg_type = MessageType.TEXT
if data.get("hasMedia"):
media_type = data.get("mediaType", "")
media_type = str(data.get("mediaType", "") or "")
if media_type in {"location", "live_location"}:
msg_type = MessageType.LOCATION
elif media_type == "sticker":
msg_type = MessageType.STICKER
elif data.get("hasMedia"):
if "image" in media_type:
msg_type = MessageType.PHOTO
elif "video" in media_type:
msg_type = MessageType.VIDEO
elif "audio" in media_type or "ptt" in media_type: # ptt = voice note
elif "ptt" in media_type: # ptt = WhatsApp voice note
msg_type = MessageType.VOICE
elif "audio" in media_type:
msg_type = MessageType.AUDIO
else:
msg_type = MessageType.DOCUMENT
@ -1226,39 +1371,40 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
cached_urls = []
media_types = []
for url in raw_urls:
bridge_mime = str(data.get("mime") or "").strip()
if msg_type == MessageType.PHOTO and url.startswith(("http://", "https://")):
try:
cached_path = await cache_image_from_url(url, ext=".jpg")
cached_urls.append(cached_path)
media_types.append("image/jpeg")
media_types.append(bridge_mime or "image/jpeg")
print(f"[{self.name}] Cached user image: {cached_path}", flush=True)
except Exception as e:
print(f"[{self.name}] Failed to cache image: {e}", flush=True)
cached_urls.append(url)
media_types.append("image/jpeg")
media_types.append(bridge_mime or "image/jpeg")
elif msg_type == MessageType.PHOTO and os.path.isabs(url):
# Local file path — bridge already downloaded the image
if _is_allowed_bridge_path(url):
cached_urls.append(url)
media_types.append("image/jpeg")
media_types.append(bridge_mime or "image/jpeg")
print(f"[{self.name}] Using bridge-cached image: {url}", flush=True)
else:
print(f"[{self.name}] Rejected bridge image path outside cache dir: {url}", flush=True)
elif msg_type == MessageType.VOICE and url.startswith(("http://", "https://")):
elif msg_type in {MessageType.VOICE, MessageType.AUDIO} and url.startswith(("http://", "https://")):
try:
cached_path = await cache_audio_from_url(url, ext=".ogg")
cached_urls.append(cached_path)
media_types.append("audio/ogg")
print(f"[{self.name}] Cached user voice: {cached_path}", flush=True)
media_types.append(bridge_mime or ("audio/ogg" if msg_type == MessageType.VOICE else "audio/mpeg"))
print(f"[{self.name}] Cached user audio: {cached_path}", flush=True)
except Exception as e:
print(f"[{self.name}] Failed to cache voice: {e}", flush=True)
print(f"[{self.name}] Failed to cache audio: {e}", flush=True)
cached_urls.append(url)
media_types.append("audio/ogg")
elif msg_type == MessageType.VOICE and os.path.isabs(url):
media_types.append(bridge_mime or ("audio/ogg" if msg_type == MessageType.VOICE else "audio/mpeg"))
elif msg_type in {MessageType.VOICE, MessageType.AUDIO} and os.path.isabs(url):
# Local file path — bridge already downloaded the audio
if _is_allowed_bridge_path(url):
cached_urls.append(url)
media_types.append("audio/ogg")
media_types.append(bridge_mime or ("audio/ogg" if msg_type == MessageType.VOICE else "audio/mpeg"))
print(f"[{self.name}] Using bridge-cached audio: {url}", flush=True)
else:
print(f"[{self.name}] Rejected bridge audio path outside cache dir: {url}", flush=True)
@ -1267,7 +1413,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
if _is_allowed_bridge_path(url):
cached_urls.append(url)
ext = Path(url).suffix.lower()
mime = SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream")
mime = bridge_mime or SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream")
media_types.append(mime)
print(f"[{self.name}] Using bridge-cached document: {url}", flush=True)
else:
@ -1275,7 +1421,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
elif msg_type == MessageType.VIDEO and os.path.isabs(url):
if _is_allowed_bridge_path(url):
cached_urls.append(url)
media_types.append("video/mp4")
media_types.append(bridge_mime or "video/mp4")
print(f"[{self.name}] Using bridge-cached video: {url}", flush=True)
else:
print(f"[{self.name}] Rejected bridge video path outside cache dir: {url}", flush=True)
@ -1290,14 +1436,23 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
if data.get("isGroup"):
body = self._clean_bot_mention_text(body, data)
# If this is a reply, include the quoted message text so the agent
# knows exactly what the user is responding to (fixes "approve" context issue)
# If this is a reply, keep the quoted message in structured fields
# only. GatewayRunner._prepare_inbound_message_text owns rendering
# the `[Replying to: ...]` pointer for every platform; pre-rendering
# it here makes WhatsApp replies show the quote twice.
quoted_text = str(data.get("quotedText") or "").strip()
if quoted_text and data.get("hasQuotedMessage"):
# Truncate long quoted text to keep prompts reasonable
if len(quoted_text) > 300:
quoted_text = quoted_text[:297] + "..."
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
reply_to_text = quoted_text or None
reply_to_message_id = None
reply_to_author_id = None
reply_to_is_own_message = False
if data.get("hasQuotedMessage"):
raw_reply_id = data.get("quotedMessageId")
if raw_reply_id is not None:
reply_to_message_id = str(raw_reply_id)
quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
if quoted_participant:
reply_to_author_id = quoted_participant
reply_to_is_own_message = self._message_is_reply_to_bot(data)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if msg_type == MessageType.DOCUMENT and cached_urls:
for doc_path in cached_urls:
@ -1326,6 +1481,12 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
print(f"[{self.name}] Failed to read document text: {e}", flush=True)
metadata: Dict[str, Any] = {}
native_type = str(data.get("nativeType") or "").strip()
native_metadata = data.get("nativeMetadata")
if native_type:
metadata["whatsapp_native_type"] = native_type
if isinstance(native_metadata, dict) and native_metadata:
metadata["whatsapp_native"] = native_metadata
# The bridge sets ``fromOwner: true`` on inbound fromMe messages
# that look owner-typed (linked-device send, not echoed from our
# own /send). Surfaced under a platform-prefixed key so plugins
@ -1350,6 +1511,10 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
media_urls=cached_urls,
media_types=media_types,
metadata=metadata,
reply_to_message_id=reply_to_message_id,
reply_to_text=reply_to_text,
reply_to_author_id=reply_to_author_id,
reply_to_is_own_message=reply_to_is_own_message,
)
except Exception as e:
print(f"[{self.name}] Error building event: {e}")

View file

@ -127,6 +127,22 @@ def _xai_headers(api_key: str) -> Dict[str, str]:
}
def _raise_if_blocked_local_input(ref: str) -> None:
"""Refuse to read a local media path that Hermes' read deny-list blocks.
Thin wrapper over the shared ``agent.file_safety.raise_if_read_blocked``
chokepoint so xAI video inputs enforce the same credential-store guard as
the image providers. Fails open if the guard machinery is unavailable
(defense-in-depth, per the denylist's own framing).
"""
try:
from agent.file_safety import raise_if_read_blocked
except Exception as exc: # noqa: BLE001 - guard must never break loading
logger.debug("xAI media input read guard unavailable: %s", exc)
return
raise_if_read_blocked(ref)
def _image_ref_to_xai_url(value: str) -> str:
"""Return a URL/data URI accepted by xAI for image inputs."""
ref = (value or "").strip()
@ -140,6 +156,8 @@ def _image_ref_to_xai_url(value: str) -> str:
if not path.is_file():
return ref
_raise_if_blocked_local_input(ref)
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
if not mime.startswith("image/"):
return ref
@ -195,6 +213,8 @@ def _video_ref_to_xai_url(value: str) -> str:
if not path.is_file():
return ref
_raise_if_blocked_local_input(ref)
mime = mimetypes.guess_type(path.name)[0] or "video/mp4"
if not mime.startswith("video/"):
return ref

View file

@ -49,7 +49,9 @@ class BraveFreeWebSearchProvider(WebSearchProvider):
def is_available(self) -> bool:
"""Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value."""
return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip())
from agent.web_search_provider import get_provider_env
return bool(get_provider_env("BRAVE_SEARCH_API_KEY"))
def supports_search(self) -> bool:
return True
@ -65,7 +67,9 @@ class BraveFreeWebSearchProvider(WebSearchProvider):
"""
import httpx
api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip()
from agent.web_search_provider import get_provider_env
api_key = get_provider_env("BRAVE_SEARCH_API_KEY")
if not api_key:
return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"}

View file

@ -51,7 +51,9 @@ def _get_exa_client() -> Any:
if cached is not None:
return cached
api_key = os.getenv("EXA_API_KEY")
from agent.web_search_provider import get_provider_env
api_key = get_provider_env("EXA_API_KEY")
if not api_key:
raise ValueError(
"EXA_API_KEY environment variable not set. "
@ -100,7 +102,9 @@ class ExaWebSearchProvider(WebSearchProvider):
def is_available(self) -> bool:
"""Return True when ``EXA_API_KEY`` is set to a non-empty value."""
return bool(os.getenv("EXA_API_KEY", "").strip())
from agent.web_search_provider import get_provider_env
return bool(get_provider_env("EXA_API_KEY"))
def supports_search(self) -> bool:
return True

View file

@ -122,8 +122,10 @@ Firecrawl = _FirecrawlProxy()
def _get_direct_firecrawl_config() -> Optional[tuple]:
"""Return explicit direct Firecrawl kwargs + cache key, or None when unset."""
api_key = os.getenv("FIRECRAWL_API_KEY", "").strip()
api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/")
from hermes_cli.config import get_env_value
api_key = (get_env_value("FIRECRAWL_API_KEY") or "").strip()
api_url = (get_env_value("FIRECRAWL_API_URL") or "").strip().rstrip("/")
if not api_key and not api_url:
return None

View file

@ -73,7 +73,9 @@ def _get_sync_client() -> Any:
if cached is not None:
return cached
api_key = os.getenv("PARALLEL_API_KEY")
from agent.web_search_provider import get_provider_env
api_key = get_provider_env("PARALLEL_API_KEY")
if not api_key:
raise ValueError(
"PARALLEL_API_KEY environment variable not set. "
@ -99,7 +101,9 @@ def _get_async_client() -> Any:
if cached is not None:
return cached
api_key = os.getenv("PARALLEL_API_KEY")
from agent.web_search_provider import get_provider_env
api_key = get_provider_env("PARALLEL_API_KEY")
if not api_key:
raise ValueError(
"PARALLEL_API_KEY environment variable not set. "
@ -153,7 +157,9 @@ class ParallelWebSearchProvider(WebSearchProvider):
def is_available(self) -> bool:
"""Return True when ``PARALLEL_API_KEY`` is set to a non-empty value."""
return bool(os.getenv("PARALLEL_API_KEY", "").strip())
from agent.web_search_provider import get_provider_env
return bool(get_provider_env("PARALLEL_API_KEY"))
def supports_search(self) -> bool:
return True

View file

@ -41,14 +41,16 @@ def _tavily_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
import httpx
api_key = os.getenv("TAVILY_API_KEY")
from agent.web_search_provider import get_provider_env
api_key = get_provider_env("TAVILY_API_KEY")
if not api_key:
raise ValueError(
"TAVILY_API_KEY environment variable not set. "
"Get your API key at https://app.tavily.com/home"
)
base_url = os.getenv("TAVILY_BASE_URL", "https://api.tavily.com")
base_url = get_provider_env("TAVILY_BASE_URL") or "https://api.tavily.com"
payload = dict(payload) # don't mutate caller's dict
payload["api_key"] = api_key
url = f"{base_url}/{endpoint.lstrip('/')}"
@ -138,7 +140,9 @@ class TavilyWebSearchProvider(WebSearchProvider):
def is_available(self) -> bool:
"""Return True when ``TAVILY_API_KEY`` is set to a non-empty value."""
return bool(os.getenv("TAVILY_API_KEY", "").strip())
from agent.web_search_provider import get_provider_env
return bool(get_provider_env("TAVILY_API_KEY"))
def supports_search(self) -> bool:
return True