mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
1508ece2d9
948 changed files with 85189 additions and 7133 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
99
plugins/kanban/dashboard/dist/index.js
vendored
99
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -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,
|
||||
|
|
|
|||
9
plugins/kanban/dashboard/dist/style.css
vendored
9
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 '[]'}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]'")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue