From 4e6789d2cc5f2228c4845a8ed5c6915ef8527baa Mon Sep 17 00:00:00 2001 From: Dennis Vorobyov Date: Sun, 17 May 2026 03:10:19 +0100 Subject: [PATCH 1/2] feat(fox-overlay): agent plugin + monkey-patches + relocated mem0_oss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3b (monorepo side) of the v0.6.0 upstream-separation migration (epic #155, sub-issue #171). Companion to merged fork PRs fox-in-the-box-ai/hermes-agent#1 (revert Fox edits + remove mem0_oss + pause notify-monorepo) and #2 (bootstrap shim). ## What this does Populates packages/fox-overlay/fox_overlay/agent_plugins/ with the relocated Fox customizations so the gateway boots with Fox bug fixes restored on top of virgin upstream: 1. Three monkey-patches against upstream hermes-agent functions, applied via `inspect.getsource` + textual substitution at register-time (anchor-based; raises AssertionError at startup if upstream refactors the target — caught by Phase 9 nightly watch). - hermes_cli.runtime_provider.resolve_runtime_provider: target_model used for Bedrock api_mode routing - agent.auxiliary_client._resolve_task_provider_model: provider=auto resolves to explicit provider when cfg_model set - agent.auxiliary_client._get_auxiliary_task_config: auxiliary.default falls back when no task-specific config - cron.jobs.create_job + .mark_job_run: rolling failure_history - cron.scheduler.run_job: enriched FAILED output (diagnostics + traceback + session-log path) - cron.scheduler.tick: structured multi-line failure delivery - tools.cronjob_tools._format_job: surface last_error + failure_history + consecutive_failures when present 2. mem0_oss memory provider relocated wholesale from forks/hermes-agent/plugins/memory/mem0_oss/ (byte-identical restored via git show; SHA256 verified). plugin.yaml gains `kind: backend` for upstream v0.13.0's plugin manifest validation. 3. pyproject.toml entry-points wire fox_overlay + mem0_oss into upstream's hermes_agent.plugins group (discovery mechanism documented in plan §3 Agent 2). 4. Dockerfile inserts `COPY packages/fox-overlay /app/fox-overlay` + `pip install -e /app/fox-overlay` between the upstream pip install (line 141) and the Phase 2 static-overlay ENV block (line 143). Layer-cache friendly: static-asset edits don't invalidate the (slow) upstream install layer. 5. Submodule pin bumped to e301105ec (fork main after PR#1 + PR#2 merged). ## Validation gates - Container rebuilds clean (FITB_VERSION=v0.6.0-p3-shim-qa, 1.45 GB) - Container starts, /health 200, supervisord all RUNNING - Gateway startup log (/data/data/hermes/logs/agent.log) shows all 8 `[fox-overlay] patched ...` lines confirming each substitution - All 8 runtime sentinel attributes True after gateway boot - Zero AssertionError / Traceback / ERROR in any log ## Engineer-discovered gap (handled by fork PR #2) Upstream's PluginManager.discover_and_load() is invoked only from hermes_cli commands and tui_gateway, never from gateway/run.py. The plan assumed entry-points auto-fire at gateway boot; they don't. Resolved via the ~23-line bootstrap shim in fork PR #2 (plan deviation flagged + Dennis-approved). Net agent patch surface goes from plan's optimistic 0 files to 1 file / ~23 lines — still within Phase 8's patch-series model. ## _helpers.py textual-substitution pattern `substitute_function` reads upstream source via inspect.getsource, applies (old, new) substitutions sequentially with EXACTLY-ONCE anchor check, recompiles in a copy of the upstream module's globals, and rebinds the attribute. Anchor drift → AssertionError at register time. Idempotent via per-target sentinel. Notably does NOT call textwrap.dedent — dedent normalizes whitespace-only blank lines to bare \\n, breaking anchors that match verbatim file content. Engineer-discovered during integration smoke (cron.jobs.mark_job_run anchor with 16-space blank line failed until dedent removed); fix documented in _helpers.py docstring. ## Out of scope (deferred per architect brief) - Bootstrap.py changes — Phase 4 owns those (webui-side shim) - Webui-side patches — Phase 4-7 - Submodule re-point to virgin upstream — Phase 8 (extracts the fork-PR-#2 shim to packages/fox-overlay/patches/agent/001-*.patch at that time) Closes #171. --- forks/hermes-agent | 2 +- packages/fox-overlay/MANIFEST.toml | 11 +- .../fox_overlay/agent_plugins/__init__.py | 35 + .../fox_overlay_plugin/__init__.py | 0 .../monkey_patches/__init__.py | 0 .../monkey_patches/_helpers.py | 92 ++ .../monkey_patches/auxiliary_client.py | 80 ++ .../monkey_patches/cron_diagnostics.py | 220 ++++ .../monkey_patches/runtime_provider.py | 29 + .../fox_overlay_plugin/plugin.yaml | 4 + .../agent_plugins/mem0_oss/README.md | 201 ++++ .../agent_plugins/mem0_oss/__init__.py | 1011 +++++++++++++++++ .../agent_plugins/mem0_oss/plugin.yaml | 7 + packages/fox-overlay/pyproject.toml | 7 +- packages/integration/Dockerfile | 22 +- 15 files changed, 1709 insertions(+), 12 deletions(-) create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/__init__.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/__init__.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py create mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml diff --git a/forks/hermes-agent b/forks/hermes-agent index 44a6808..e301105 160000 --- a/forks/hermes-agent +++ b/forks/hermes-agent @@ -1 +1 @@ -Subproject commit 44a6808f506b6beb00c152f045c9e7af1d556cd5 +Subproject commit e301105ec8bcc7b32503beea12a93274507316b4 diff --git a/packages/fox-overlay/MANIFEST.toml b/packages/fox-overlay/MANIFEST.toml index da43cbb..9b288f2 100644 --- a/packages/fox-overlay/MANIFEST.toml +++ b/packages/fox-overlay/MANIFEST.toml @@ -22,4 +22,13 @@ "hostname-prompt.js" = "fox-only" [python] -# Filled in Phase 5 (additive webui modules) and Phase 6 (mid-file monkey-patches). +# Phase 3 — agent-side overlay. Three monkey-patch modules applied via +# the `hermes_agent.plugins` entry-point group; mem0_oss relocated as a +# bundled plugin (also entry-point-registered). +# Phase 5 and Phase 6 add webui-side entries below. +"fox_overlay.agent_plugins" = "fox-only-additive" # register(ctx) entry-point +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.runtime_provider" = "fox-only-additive" +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.auxiliary_client" = "fox-only-additive" +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.cron_diagnostics" = "fox-only-additive" +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches._helpers" = "fox-only-additive" +"fox_overlay.agent_plugins.mem0_oss" = "fox-relocated" # was forks/hermes-agent/plugins/memory/mem0_oss diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py index e69de29..8784de8 100644 --- a/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py +++ b/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py @@ -0,0 +1,35 @@ +"""Fox overlay entry-points for hermes-agent's plugin manager. + +Both ``fox_overlay`` (this module's :func:`register`) and ``mem0_oss`` +(`mem0_oss.register` via the manifest) are wired in ``packages/fox-overlay/ +pyproject.toml`` under the ``[project.entry-points."hermes_agent.plugins"]`` +group. + +``register(ctx)`` is called once per process by hermes-agent's +``PluginManager.discover_and_load``. We apply our three agent-side +monkey-patches here so the patched behavior is in place before any +runtime code path touches the patched symbols. + +mem0_oss has its own ``register`` (in mem0_oss/__init__.py) and is +discovered as a separate plugin — this module does not need to touch +it. + +Phase 3 of the v0.6.0 upstream-separation migration (fox-in-the-box-ai/ +fox-in-the-box#155). +""" +from .fox_overlay_plugin.monkey_patches import ( + auxiliary_client, + cron_diagnostics, + runtime_provider, +) + + +def register(ctx) -> None: + """Entry point. ``ctx`` is a ``PluginContext`` from ``hermes_cli.plugins``. + + Idempotent: each ``apply()`` no-ops on its second call via per-target + sentinel attribute. + """ + runtime_provider.apply() + auxiliary_client.apply() + cron_diagnostics.apply() diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py new file mode 100644 index 0000000..dc39681 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py @@ -0,0 +1,92 @@ +"""Anchor-based source substitution helper for agent-side monkey-patches. + +Pattern: each monkey-patch module defines its target upstream function +and a list of ``(old, new)`` substitutions where ``old`` is an EXACT +fragment of the upstream function source. ``substitute_function`` reads +the upstream source via :func:`inspect.getsource`, applies the +substitutions sequentially, and recompiles the result in the upstream +module's namespace so it can be assigned back as the new function. + +If any ``old`` anchor is missing or appears more than once, an +``AssertionError`` is raised at import time — the container fails to +boot with a precise error pointing at the upstream drift. Phase 9's +nightly upstream-watch CI catches this before users do. + +Idempotency: each patch sets a per-target sentinel attribute; re-running +``apply()`` (test setup, reload, etc.) is a no-op once the sentinel is +present. +""" +import inspect +import logging +from types import ModuleType +from typing import Any, Dict, Optional, Sequence, Tuple + +_log = logging.getLogger("fox_overlay.monkey_patch") + + +def substitute_function( + upstream_module: ModuleType, + function_name: str, + substitutions: Sequence[Tuple[str, str]], + sentinel: str, + extra_globals: Optional[Dict[str, Any]] = None, +) -> None: + """Replace ``upstream_module.`` with a patched version. + + Each ``(old, new)`` substitution is applied to the upstream source + in sequence. Each ``old`` MUST appear exactly once or this raises + ``AssertionError``. + + ``extra_globals`` injects additional names into the patched + function's ``__globals__`` so the patched body can reference modules + or symbols not imported in the upstream module (e.g., a ``traceback`` + module the Fox patch wants to call but upstream doesn't import). + These names are scoped to the patched function only — they do not + pollute the upstream module's namespace. + + Note: we deliberately do NOT call ``textwrap.dedent`` on the source — + ``dedent`` normalizes whitespace-only blank lines to a bare ``\\n``, + breaking anchors that match against the verbatim file content. For + top-level functions there is no common leading indent to remove + anyway. If a future caller needs to patch a class method (where the + body is indented under the class def), it must either dedent its + anchors itself or wrap this helper. + """ + upstream_fn = getattr(upstream_module, function_name) + if getattr(upstream_fn, sentinel, False): + return + + src = inspect.getsource(upstream_fn) + + for idx, (old, new) in enumerate(substitutions): + count = src.count(old) + if count != 1: + raise AssertionError( + "[fox-overlay] monkey-patch %s.%s substitution #%d: " + "anchor expected EXACTLY ONCE, found %dx. Upstream may have " + "refactored this function — refresh the anchor in the " + "fox-overlay monkey-patch module. Anchor:\n%s" + % (upstream_module.__name__, function_name, idx + 1, count, old) + ) + src = src.replace(old, new) + + namespace = dict(upstream_module.__dict__) + if extra_globals: + namespace.update(extra_globals) + code = compile( + src, + "" % (upstream_module.__name__, function_name), + "exec", + ) + exec(code, namespace) # noqa: S102 — namespace is the upstream module's globals + + patched_fn = namespace[function_name] + setattr(patched_fn, sentinel, True) + setattr(upstream_module, function_name, patched_fn) + + _log.info( + "[fox-overlay] patched %s.%s (%d substitutions)", + upstream_module.__name__, + function_name, + len(substitutions), + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py new file mode 100644 index 0000000..9a19eb5 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py @@ -0,0 +1,80 @@ +"""Fox patches in ``agent.auxiliary_client``. + +Two patches restoring Fox edits from hermes-agent fork commits +5e0d847cb and 026a12487: + +1. ``_resolve_task_provider_model``: when the auxiliary config provider + is ``"auto"`` (or unset) but an explicit model is specified, resolve + the provider explicitly via ``_read_main_provider()`` so the model + hint actually wins. Without this, ``_resolve_auto`` ignores the + model hint and always uses the main model. + +2. ``_get_auxiliary_task_config``: fall back to ``auxiliary.default`` + when no task-specific config exists. Task-specific keys win over + default keys (``{**default_config, **task_config}``). + +Phase 0 PR campaign candidates; if upstream accepts, retire in Phase 10. +""" +from agent import auxiliary_client as _u + +from ._helpers import substitute_function + + +def apply() -> None: + # 1) _resolve_task_provider_model: insert explicit-provider branch + substitute_function( + upstream_module=_u, + function_name="_resolve_task_provider_model", + substitutions=[ + ( + ' if cfg_provider and cfg_provider != "auto":\n' + ' return cfg_provider, resolved_model, None, None, resolved_api_mode\n' + '\n' + ' return "auto", resolved_model, None, None, resolved_api_mode\n', + ' if cfg_provider and cfg_provider != "auto":\n' + ' return cfg_provider, resolved_model, None, None, resolved_api_mode\n' + '\n' + ' # provider is "auto" (or unset) but config specified an explicit model\n' + ' # (e.g. auxiliary.default.model = us.anthropic.claude-haiku-...).\n' + ' # _resolve_auto ignores the model hint and always uses the main model,\n' + ' # so we must resolve the provider explicitly here to honour cfg_model.\n' + ' if resolved_model:\n' + ' explicit_provider = _read_main_provider() or "auto"\n' + ' if explicit_provider and explicit_provider != "auto":\n' + ' return explicit_provider, resolved_model, None, None, resolved_api_mode\n' + '\n' + ' return "auto", resolved_model, None, None, resolved_api_mode\n', + ), + ], + sentinel="_fox_patched_provider_auto_fallback", + ) + + # 2) _get_auxiliary_task_config: docstring + trailing logic + substitute_function( + upstream_module=_u, + function_name="_get_auxiliary_task_config", + substitutions=[ + ( + ' """Return the config dict for auxiliary., or {} when unavailable."""\n', + ' """Return the config dict for auxiliary., or {} when unavailable.\n' + '\n' + ' Falls back to auxiliary.default when no task-specific config exists.\n' + ' Task-specific keys win over default keys ({**default_config, **task_config}).\n' + ' """\n', + ), + ( + ' aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}\n' + ' task_config = aux.get(task, {}) if isinstance(aux, dict) else {}\n' + ' return task_config if isinstance(task_config, dict) else {}\n', + ' aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}\n' + ' if not isinstance(aux, dict):\n' + ' return {}\n' + ' default_config = aux.get("default", {})\n' + ' default_config = default_config if isinstance(default_config, dict) else {}\n' + ' task_config = aux.get(task, {}) if isinstance(aux, dict) else {}\n' + ' task_config = task_config if isinstance(task_config, dict) else {}\n' + ' return {**default_config, **task_config}\n', + ), + ], + sentinel="_fox_patched_auxiliary_default_fallback", + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py new file mode 100644 index 0000000..b44fe80 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py @@ -0,0 +1,220 @@ +"""Fox cron failure-diagnostics patches across 3 upstream modules. + +Restores the Fox edit from hermes-agent fork commit edb16600b that +relocated to this overlay during the v0.6.0 upstream-separation +migration (Phase 3). Five substitutions across three modules: + +* ``cron.jobs.create_job`` — add ``failure_history`` to the job dict. +* ``cron.jobs.mark_job_run`` — maintain rolling last-5 failure entries. +* ``cron.scheduler.run_job`` — enrich the FAILED output with agent + diagnostics + traceback + session-log pointer. +* ``cron.scheduler.tick`` — replace the one-line failure delivery + message with a structured multi-line failure notification. +* ``tools.cronjob_tools._format_job`` — surface ``last_error`` + + ``failure_history`` + ``consecutive_failures`` to the tool API when + the job has failures. + +The scheduler patches need ``traceback`` (not imported upstream); it's +injected into the patched function's ``__globals__`` via the helper's +``extra_globals`` mechanism — does NOT pollute upstream namespaces. + +Phase 0 PR campaign candidate; if upstream accepts the PR, this +monkey-patch becomes dead code and is removed in Phase 10 cleanup. +""" +import traceback + +from cron import jobs as _u_jobs +from cron import scheduler as _u_scheduler +from tools import cronjob_tools as _u_cronjob_tools + +from ._helpers import substitute_function + + +def apply() -> None: + # 1) cron.jobs.create_job — add failure_history to dict + substitute_function( + upstream_module=_u_jobs, + function_name="create_job", + substitutions=[ + ( + ' "last_delivery_error": None,\n', + ' "last_delivery_error": None,\n' + ' "failure_history": [], # Rolling last-5 failures: [{at, error, session_id}]\n', + ), + ], + sentinel="_fox_patched_create_job_failure_history", + ) + + # 2) cron.jobs.mark_job_run — maintain rolling failure history + substitute_function( + upstream_module=_u_jobs, + function_name="mark_job_run", + substitutions=[ + ( + ' # Track delivery failures separately — cleared on successful delivery\n' + ' job["last_delivery_error"] = delivery_error\n' + ' \n' + ' # Increment completed count\n', + ' # Track delivery failures separately — cleared on successful delivery\n' + ' job["last_delivery_error"] = delivery_error\n' + '\n' + ' # Maintain rolling failure history (last 5 entries).\n' + ' # On success, clear it so the counter resets.\n' + ' if not success:\n' + ' _entry = {"at": now, "error": (error or "unknown")[:200]}\n' + ' _hist = list(job.get("failure_history") or [])\n' + ' _hist.append(_entry)\n' + ' job["failure_history"] = _hist[-5:] # keep last 5\n' + ' else:\n' + ' job["failure_history"] = []\n' + ' \n' + ' # Increment completed count\n', + ), + ], + sentinel="_fox_patched_mark_job_run_failure_history", + ) + + # 3) cron.scheduler.run_job — enrich FAILED output with diagnostics + traceback + substitute_function( + upstream_module=_u_scheduler, + function_name="run_job", + substitutions=[ + ( + ' except Exception as e:\n' + ' error_msg = f"{type(e).__name__}: {str(e)}"\n' + ' logger.exception("Job \'%s\' failed: %s", job_name, error_msg)\n' + ' \n' + ' output = f"""# Cron Job: {job_name} (FAILED)\n' + '\n' + '**Job ID:** {job_id}\n' + '**Run Time:** {_hermes_now().strftime(\'%Y-%m-%d %H:%M:%S\')}\n' + '**Schedule:** {job.get(\'schedule_display\', \'N/A\')}\n' + '\n' + '## Prompt\n' + '\n' + '{prompt}\n' + '\n' + '## Error\n' + '\n' + '```\n' + '{error_msg}\n' + '```\n' + '"""\n' + ' return False, output, "", error_msg\n', + ' except Exception as e:\n' + ' error_msg = f"{type(e).__name__}: {str(e)}"\n' + ' logger.exception("Job \'%s\' failed: %s", job_name, error_msg)\n' + '\n' + ' # Collect agent activity diagnostics if available\n' + ' _diag_lines: list[str] = []\n' + ' if agent is not None:\n' + ' try:\n' + ' _act = agent.get_activity_summary() if hasattr(agent, "get_activity_summary") else {}\n' + ' _api_calls = _act.get("api_call_count", "?")\n' + ' _max_iter = _act.get("max_iterations", "?")\n' + ' _last_act = _act.get("last_activity_desc", "unknown")\n' + ' _idle = _act.get("seconds_since_activity", 0)\n' + ' _diag_lines.append(f"**Iterations:** {_api_calls}/{_max_iter}")\n' + ' _diag_lines.append(f"**Last activity:** {_last_act} ({int(_idle)}s ago)")\n' + ' if _act.get("current_tool"):\n' + ' _diag_lines.append(f"**Stuck on tool:** `{_act[\'current_tool\']}`")\n' + ' except Exception:\n' + ' pass\n' + '\n' + ' _tb = traceback.format_exc()\n' + ' _diag_block = ("\\n".join(_diag_lines) + "\\n\\n") if _diag_lines else ""\n' + '\n' + ' output = f"""# Cron Job: {job_name} (FAILED)\n' + '\n' + '**Job ID:** {job_id}\n' + '**Session:** `{_cron_session_id}`\n' + '**Run Time:** {_hermes_now().strftime(\'%Y-%m-%d %H:%M:%S\')}\n' + '**Schedule:** {job.get(\'schedule_display\', \'N/A\')}\n' + '\n' + '## Error\n' + '\n' + '```\n' + '{error_msg}\n' + '```\n' + '\n' + '## Agent Diagnostics\n' + '\n' + '{_diag_block}**Session log:** `~/.hermes/sessions/session_{_cron_session_id}.json`\n' + '\n' + '## Traceback\n' + '\n' + '```\n' + '{_tb}\n' + '```\n' + '\n' + '## Prompt\n' + '\n' + '{prompt}\n' + '"""\n' + ' return False, output, "", error_msg\n', + ), + ], + sentinel="_fox_patched_run_job_diagnostics", + extra_globals={"traceback": traceback}, + ) + + # 4) cron.scheduler.tick — structured multi-line failure delivery + substitute_function( + upstream_module=_u_scheduler, + function_name="tick", + substitutions=[ + ( + ' deliver_content = final_response if success else f"⚠️ Cron job \'{job.get(\'name\', job[\'id\'])}\' failed:\\n{error}"\n', + ' if success:\n' + ' deliver_content = final_response\n' + ' else:\n' + ' # Build a structured failure notification with enough\n' + ' # context to diagnose without opening logs manually.\n' + ' _fail_lines = [\n' + ' f"❌ **Cron job failed:** `{job.get(\'name\', job[\'id\'])}`",\n' + ' f"**Error:** {error}",\n' + ' ]\n' + ' # Surface consecutive failure count if available\n' + ' _history = job.get("failure_history") or []\n' + ' if len(_history) > 1:\n' + ' _fail_lines.append(f"**Consecutive failures:** {len(_history)} (first: {_history[0].get(\'at\', \'?\')[:16]})")\n' + ' # Include session log path for easy debugging\n' + ' _session_files = sorted(\n' + ' (f for f in (Path(_hermes_home) / "sessions").glob(f"session_cron_{job[\'id\']}_*.json") if f.is_file()),\n' + ' key=lambda p: p.stat().st_mtime,\n' + ' reverse=True,\n' + ' )\n' + ' if _session_files:\n' + ' _fail_lines.append(f"**Session log:** `{_session_files[0]}`")\n' + ' deliver_content = "\\n".join(_fail_lines)\n', + ), + ], + sentinel="_fox_patched_tick_structured_failure", + ) + + # 5) tools.cronjob_tools._format_job — surface failure history when present + substitute_function( + upstream_module=_u_cronjob_tools, + function_name="_format_job", + substitutions=[ + ( + ' "paused_at": job.get("paused_at"),\n' + ' "paused_reason": job.get("paused_reason"),\n' + ' }\n' + ' if job.get("script"):\n', + ' "paused_at": job.get("paused_at"),\n' + ' "paused_reason": job.get("paused_reason"),\n' + ' }\n' + ' # Surface failure history and last error only when there are failures —\n' + ' # keeps the list output clean for healthy jobs.\n' + ' if job.get("last_status") == "error" or job.get("failure_history"):\n' + ' result["last_error"] = job.get("last_error")\n' + ' _hist = job.get("failure_history") or []\n' + ' if _hist:\n' + ' result["failure_history"] = _hist\n' + ' result["consecutive_failures"] = len(_hist)\n' + ' if job.get("script"):\n', + ), + ], + sentinel="_fox_patched_format_job_failure_history", + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py new file mode 100644 index 0000000..b11eb96 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py @@ -0,0 +1,29 @@ +"""Fox patch: use ``target_model`` for Bedrock api_mode routing. + +Restores the Fox edit from hermes-agent fork commit f24911136 that +relocated to this overlay during the v0.6.0 upstream-separation +migration (Phase 3). Phase 0 PR campaign candidate; if upstream accepts +the PR, this monkey-patch becomes dead code and is removed in Phase 10. + +Single-line substitution inside ``hermes_cli.runtime_provider.resolve_runtime_provider`` +so the Bedrock dual-path routing honours the explicit ``target_model`` +argument instead of always falling back to the auxiliary config's +default. +""" +from hermes_cli import runtime_provider as _u + +from ._helpers import substitute_function + + +def apply() -> None: + substitute_function( + upstream_module=_u, + function_name="resolve_runtime_provider", + substitutions=[ + ( + '_current_model = str(model_cfg.get("default") or "").strip()', + '_current_model = target_model or str(model_cfg.get("default") or "").strip()', + ), + ], + sentinel="_fox_patched_target_model", + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml new file mode 100644 index 0000000..3d2acbd --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml @@ -0,0 +1,4 @@ +name: fox_overlay +version: 0.0.1 +kind: backend +description: "Fox in the Box overlay — agent-side monkey-patches that restore Fox bug fixes (target_model Bedrock fix, auxiliary.default fallback, cron failure diagnostics) on top of virgin upstream hermes-agent. v0.6.0 upstream-separation migration." diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md new file mode 100644 index 0000000..8c10bdb --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md @@ -0,0 +1,201 @@ +# Mem0 OSS Memory Plugin + +Self-hosted, privacy-first long-term memory using the open-source +[mem0ai](https://github.com/mem0ai/mem0) library. No cloud API key or +external service needed — everything runs on your machine. + +## How it works + +- **LLM fact extraction** — after each conversation turn, mem0 uses an LLM + to extract important facts, preferences, and context from the exchange. +- **Semantic search** — memories are stored in a local [Qdrant](https://qdrant.tech/) + vector database. Searches use embedding-based similarity so natural-language + queries work well. +- **Automatic deduplication** — mem0 merges new facts with existing ones to + avoid duplicate storage. +- **Built-in memory mirroring** — writes via the built-in `memory` tool are + automatically mirrored into mem0 via `on_memory_write`, so nothing is lost + whether you use the native tool or the mem0-specific tools. + +## Setup + +### 1. Install dependencies + +```bash +pip install mem0ai qdrant-client +``` + +### 2. Configure a backend + +**Zero extra config — auto-detect (recommended)** + +If you already have a Hermes provider configured (OpenRouter, Anthropic, OpenAI, +or AWS Bedrock), mem0 OSS will automatically pick it up — no `MEM0_OSS_*` vars +needed. The plugin mirrors the standard Hermes auxiliary provider priority: + +``` +OPENROUTER_API_KEY → uses OpenRouter (openrouter → openai adapter) +ANTHROPIC_API_KEY → uses Anthropic directly +OPENAI_API_KEY → uses OpenAI (+ OPENAI_BASE_URL if set) +AWS_ACCESS_KEY_ID → uses AWS Bedrock (boto3 reads creds automatically) +``` + +The first matching key wins. + +**Option A — config.yaml (preferred for per-provider control)** + +The plugin inherits from `auxiliary.default` if no `auxiliary.mem0_oss` block +exists, so if you've already set a default auxiliary provider for other tasks +you get mem0 OSS for free: + +```yaml +# ~/.hermes/config.yaml +auxiliary: + default: # inherited by mem0_oss and all other aux tasks + provider: auto + model: us.anthropic.claude-haiku-4-5-20251001-v1:0 + + # Optional — override just for mem0_oss: + mem0_oss: + provider: openrouter # or openai, anthropic, ollama, aws_bedrock, custom + model: openai/gpt-4o-mini + # api_key: ... # optional — falls back to provider's standard env var + # base_url: ... # optional — for custom/local endpoints +``` + +Supported provider values: `openrouter`, `openai`, `anthropic`, `ollama`, +`lmstudio`, `aws_bedrock` (alias: `bedrock`), `custom`, `auto`. +`auto` uses the same env-var detection order as zero-config. + +**Option B — AWS Bedrock** + +If you already use Hermes with Bedrock, no additional config is needed. +The plugin reuses `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION`. + +LLM default: `us.anthropic.claude-haiku-4-5-20251001-v1:0` +Embedder default: `amazon.titan-embed-text-v2:0` (1024-dim) + +**Option C — OpenAI** + +```bash +# If OPENAI_API_KEY is already set, nothing more is needed. +# Override model/embedder explicitly if desired: +export MEM0_OSS_LLM_MODEL=gpt-4o-mini +export MEM0_OSS_EMBEDDER_MODEL=text-embedding-3-small +export MEM0_OSS_EMBEDDER_DIMS=1536 +``` + +**Option D — Ollama (fully local, no API key)** + +```bash +export MEM0_OSS_LLM_PROVIDER=ollama +export MEM0_OSS_LLM_MODEL=llama3.2 +export MEM0_OSS_EMBEDDER_PROVIDER=ollama +export MEM0_OSS_EMBEDDER_MODEL=nomic-embed-text +export MEM0_OSS_EMBEDDER_DIMS=768 +``` + +### 3. Activate + +```yaml +# ~/.hermes/config.yaml +memory: + provider: mem0_oss +``` + +Or use the interactive setup wizard: + +```bash +hermes memory setup # select "mem0_oss" +``` + +## Storage + +| Path | Contents | +|------|----------| +| `$HERMES_HOME/mem0_oss/qdrant/` | Qdrant vector store (all memories) | +| `$HERMES_HOME/mem0_oss/history.db` | mem0 history SQLite database | + +Override with `MEM0_OSS_VECTOR_STORE_PATH` and `MEM0_OSS_HISTORY_DB_PATH`. + +Non-secret settings can also be persisted to `$HERMES_HOME/mem0_oss.json` +by the setup wizard (via `save_config`), or written manually — see +[All configuration options](#all-configuration-options). + +## Agent tools + +| Tool | Description | +|------|-------------| +| `mem0_oss_search` | Semantic search over stored memories | +| `mem0_oss_add` | Store a fact, preference, or context explicitly | + +Facts are extracted and stored automatically on every conversation turn via +`sync_turn` — no explicit save call needed. + +Writes via the built-in `memory` tool are also mirrored automatically into +mem0 via `on_memory_write`. To explicitly save something mid-session, use +`mem0_oss_add` (or the built-in `memory` tool — both propagate to mem0). + +## Concurrent access (WebUI + gateway) + +The plugin uses embedded Qdrant which normally allows only one process at a +time. To avoid conflicts when both the WebUI and the gateway run on the same +host, the plugin creates a fresh `Memory` instance per operation and releases +the Qdrant lock immediately after each call. If a brief overlap occurs the +operation is skipped gracefully (logged at DEBUG, not counted as a failure) +rather than raising an error. + +## All configuration options + +### Environment variables + +| Env var | Default | Description | +|---------|---------|-------------| +| `MEM0_OSS_LLM_PROVIDER` | auto-detected | LLM provider (`openrouter`, `openai`, `anthropic`, `ollama`, `aws_bedrock`, …) | +| `MEM0_OSS_LLM_MODEL` | provider default | LLM model id | +| `MEM0_OSS_EMBEDDER_PROVIDER` | mirrors LLM provider | Embedder provider | +| `MEM0_OSS_EMBEDDER_MODEL` | provider default | Embedder model id | +| `MEM0_OSS_EMBEDDER_DIMS` | provider default | Embedding dimensions | +| `MEM0_OSS_COLLECTION` | `hermes` | Qdrant collection name | +| `MEM0_OSS_USER_ID` | `hermes-user` | Memory namespace | +| `MEM0_OSS_TOP_K` | `10` | Default search result count | +| `MEM0_OSS_VECTOR_STORE_PATH` | `$HERMES_HOME/mem0_oss/qdrant` | On-disk Qdrant path | +| `MEM0_OSS_HISTORY_DB_PATH` | `$HERMES_HOME/mem0_oss/history.db` | SQLite history path | +| `MEM0_OSS_API_KEY` | _(auto-detected from provider env var)_ | Explicit API key for the LLM backend | +| `MEM0_OSS_OPENAI_BASE_URL` | _(none)_ | OpenAI-compatible endpoint override | + +### config.yaml (auxiliary.mem0_oss / auxiliary.default) + +`auxiliary.mem0_oss` keys take precedence; any key not set there falls back to +`auxiliary.default` (which is also used by compression, vision, and other aux tasks). + +| Key | Description | +|-----|-------------| +| `provider` | Hermes provider name (see auto-detect order above) | +| `model` | LLM model id | +| `base_url` | Custom OpenAI-compatible endpoint | +| `api_key` | Explicit API key (takes precedence over env vars) | + +### Key resolution priority + +1. `MEM0_OSS_API_KEY` env var +2. `auxiliary.mem0_oss.api_key` in `config.yaml` +3. Provider's standard env var (`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, + `OPENAI_API_KEY`, …) resolved via the Hermes provider registry +4. AWS credentials from environment (for Bedrock) + +Or put non-secret settings in `$HERMES_HOME/mem0_oss.json` (keys are the +env-var names without the `MEM0_OSS_` prefix, in snake_case): + +```json +{ + "llm_provider": "aws_bedrock", + "llm_model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "embedder_provider": "aws_bedrock", + "embedder_model": "amazon.titan-embed-text-v2:0", + "embedder_dims": 1024, + "collection": "hermes", + "user_id": "hermes-user", + "top_k": 10 +} +``` diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py new file mode 100644 index 0000000..27a4767 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py @@ -0,0 +1,1011 @@ +"""Mem0 OSS (self-hosted) memory plugin — MemoryProvider interface. + +LLM-powered fact extraction, semantic vector search, and automatic +deduplication using the open-source ``mem0ai`` library — no cloud API key +required. All data is stored locally on disk. + +Backend choices: + Vector store: Qdrant (local path, no server) — default + LLM / Embedder: resolved from ``auxiliary.mem0_oss`` in config.yaml, then + from ``MEM0_OSS_*`` env vars, then auto-detected. + +Primary config — config.yaml (auxiliary.mem0_oss): + provider — Hermes provider name: "auto", "aws_bedrock", "bedrock", + "openai", "openrouter", "ollama", "anthropic", or "custom". + "auto" follows the standard Hermes auxiliary resolution chain. + model — LLM model id (provider-specific slug). Empty = provider default. + base_url — OpenAI-compatible endpoint (forces provider="custom"). + api_key — API key for that endpoint. Falls back to MEM0_OSS_API_KEY. + +Secondary config — environment variables: + MEM0_OSS_VECTOR_STORE_PATH — on-disk path for Qdrant (default: $HERMES_HOME/mem0_oss/qdrant) + MEM0_OSS_HISTORY_DB_PATH — SQLite history path (default: $HERMES_HOME/mem0_oss/history.db) + MEM0_OSS_COLLECTION — Qdrant collection name (default: hermes) + MEM0_OSS_USER_ID — memory namespace (default: hermes-user) + MEM0_OSS_LLM_PROVIDER — override auxiliary.mem0_oss.provider + MEM0_OSS_LLM_MODEL — override auxiliary.mem0_oss.model + MEM0_OSS_EMBEDDER_PROVIDER — mem0 embedder provider (default: matches llm provider) + MEM0_OSS_EMBEDDER_MODEL — embedder model id + MEM0_OSS_EMBEDDER_DIMS — embedding dimensions (default: auto per provider) + MEM0_OSS_TOP_K — max results returned per search (default: 10) + +Secret config: + MEM0_OSS_API_KEY — dedicated API key for mem0 LLM calls; takes + precedence over auxiliary.mem0_oss.api_key. + Falls back to the provider's standard env var + (OPENAI_API_KEY, ANTHROPIC_API_KEY, + OPENROUTER_API_KEY, etc.) resolved via the + Hermes provider registry — so no extra key is + needed when a main Hermes provider is already + configured. + (AWS Bedrock uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION.) + +Optional $HERMES_HOME/mem0_oss.json for non-secret overrides: + { + "llm_provider": "aws_bedrock", + "llm_model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "embedder_provider": "aws_bedrock", + "embedder_model": "amazon.titan-embed-text-v2:0", + "embedder_dims": 1024, + "collection": "hermes", + "user_id": "hermes-user", + "top_k": 10 + } +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from typing import Any, Dict, List, Optional + +from agent.memory_provider import MemoryProvider +from hermes_constants import get_hermes_home +from tools.registry import tool_error + +logger = logging.getLogger(__name__) + +# Circuit breaker: after this many consecutive failures, pause for cooldown. +_BREAKER_THRESHOLD = 5 +_BREAKER_COOLDOWN_SECS = 120 + +# Qdrant embedded lock error substring — used to detect contention gracefully. +_QDRANT_LOCK_ERROR = "already accessed by another instance" + +# Retry parameters for Qdrant lock contention in _get_memory(). +# Two processes (WebUI + gateway) may briefly overlap; retry resolves it. +# Prefetch + sync operations hold the lock during an LLM call (~1-3s), +# so we retry for up to 15s total with jitter to avoid thundering herd. +_LOCK_RETRY_ATTEMPTS = 10 # total attempts +_LOCK_RETRY_DELAY_S = 0.8 # base seconds between retries (with jitter, up to ~0.4s extra) + + +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + +def _get_aux_config() -> dict: + """Read auxiliary.mem0_oss from config.yaml, with fallback to auxiliary.default. + + Keys not present in auxiliary.mem0_oss are inherited from auxiliary.default + (if set) so that a single default auxiliary provider covers all aux tasks. + Returns {} on any failure. + """ + try: + from hermes_cli.config import load_config + config = load_config() + except Exception: + return {} + aux = config.get("auxiliary", {}) if isinstance(config, dict) else {} + if not isinstance(aux, dict): + return {} + default = aux.get("default", {}) or {} + task = aux.get("mem0_oss", {}) or {} + # task-specific keys win; default fills in anything not set + merged = {**default, **task} + return merged + + +def _resolve_auto_credentials(aux_provider: str, aux_model: str, + aux_base_url: str, aux_api_key: str): + """When no specific provider is set, fall through to the default auxiliary chain. + + Mirrors the Hermes auxiliary auto-detection priority order so that users + with a main provider configured (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, …) + don't need to also set MEM0_OSS_API_KEY. + + If an explicit provider is already configured (aux_provider is non-empty and + not "auto"), this function is a no-op and returns the inputs unchanged. + + Returns (hermes_provider, model, base_url, api_key) — all strings, never None. + """ + # Only kick in when no explicit provider was configured + if aux_provider and aux_provider.lower() not in ("", "auto"): + return aux_provider, aux_model, aux_base_url, aux_api_key + + # If task-level config.yaml has a specific auxiliary.mem0_oss entry with a + # provider set, _resolve_task_provider_model returns that; otherwise "auto". + # Rather than creating a full OpenAI client we probe env vars directly in + # the same priority order the auxiliary auto-detect chain uses. + try: + from agent.auxiliary_client import _resolve_task_provider_model + h_provider, h_model, h_base_url, h_api_key, _api_mode = ( + _resolve_task_provider_model("mem0_oss") + ) + # If the task config actually resolved a specific non-auto provider, + # use that directly (covers auxiliary.mem0_oss.provider = "openrouter" etc.) + if h_provider and h_provider != "auto": + resolved_provider = h_provider + resolved_model = aux_model or h_model or "" + resolved_base_url = aux_base_url or h_base_url or "" + resolved_api_key = aux_api_key or h_api_key or "" + # Still try to fill missing key from provider registry + if not resolved_api_key and resolved_provider not in ( + "aws_bedrock", "bedrock", "aws", "ollama", "lmstudio"): + try: + from hermes_cli.auth import resolve_api_key_provider_credentials + creds = resolve_api_key_provider_credentials(resolved_provider) + resolved_api_key = str(creds.get("api_key", "") or "").strip() + if not resolved_base_url: + resolved_base_url = str(creds.get("base_url", "") or "").strip() + except Exception: + pass + return resolved_provider, resolved_model, resolved_base_url, resolved_api_key + except Exception: + pass + + # Full auto-detect: first try to mirror the main runtime provider so that + # mem0 uses the same provider as the rest of Hermes. Fall back to env-var + # probe only when the main provider isn't usable for aux tasks. + try: + from agent.auxiliary_client import _read_main_provider + main_provider = (_read_main_provider() or "").strip().lower() + if main_provider in ("bedrock", "aws_bedrock", "aws"): + return "aws_bedrock", aux_model, aux_base_url, aux_api_key + if main_provider == "anthropic": + anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() + if anthropic_key: + return "anthropic", aux_model, aux_base_url, aux_api_key or anthropic_key + if main_provider == "openai": + openai_key = os.environ.get("OPENAI_API_KEY", "").strip() + if openai_key: + base_url = os.environ.get("OPENAI_BASE_URL", "").strip() + return "openai", aux_model, aux_base_url or base_url, aux_api_key or openai_key + if main_provider == "openrouter": + openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip() + if openrouter_key: + base_url = os.environ.get("OPENROUTER_BASE_URL", + "https://openrouter.ai/api/v1").strip() + return "openrouter", aux_model, aux_base_url or base_url, aux_api_key or openrouter_key + except Exception: + pass + + # Fallback env-var probe (no main provider available) + openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip() + if openrouter_key: + base_url = os.environ.get("OPENROUTER_BASE_URL", + "https://openrouter.ai/api/v1").strip() + return "openrouter", aux_model, aux_base_url or base_url, aux_api_key or openrouter_key + + anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() + if anthropic_key: + return "anthropic", aux_model, aux_base_url, aux_api_key or anthropic_key + + openai_key = os.environ.get("OPENAI_API_KEY", "").strip() + if openai_key: + base_url = os.environ.get("OPENAI_BASE_URL", "").strip() + return "openai", aux_model, aux_base_url or base_url, aux_api_key or openai_key + + # Bedrock: no API key needed, boto3 reads from env/profile automatically + if os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_PROFILE"): + return "aws_bedrock", aux_model, aux_base_url, aux_api_key + + # Nothing found — return "auto" and let _load_config fall back to aws_bedrock default + return aux_provider or "auto", aux_model, aux_base_url, aux_api_key + + +def _load_config() -> dict: + """Load config from env vars, with $HERMES_HOME/mem0_oss.json overrides. + + Priority for LLM provider/model/api_key (highest → lowest): + 1. MEM0_OSS_LLM_PROVIDER / MEM0_OSS_LLM_MODEL env vars + 2. auxiliary.mem0_oss.provider / .model in config.yaml + 3. Default auxiliary chain (auto-detect from Hermes config) — uses the + provider's standard env var (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, …) + so MEM0_OSS_API_KEY is not required when a main provider is configured. + 4. Defaults (aws_bedrock) + + Priority for API key: + 1. MEM0_OSS_API_KEY env var + 2. auxiliary.mem0_oss.api_key in config.yaml + 3. Provider standard env var (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, etc.) + resolved via the Hermes provider registry. + + Environment variables are the base; the JSON file (if present) overrides + individual keys. Neither source is required — sensible defaults apply. + """ + hermes_home = get_hermes_home() + qdrant_path = str(hermes_home / "mem0_oss" / "qdrant") + history_path = str(hermes_home / "mem0_oss" / "history.db") + + aux = _get_aux_config() + aux_provider = str(aux.get("provider", "") or "").strip() + aux_model = str(aux.get("model", "") or "").strip() + aux_base_url = str(aux.get("base_url", "") or "").strip() + aux_api_key = str(aux.get("api_key", "") or "").strip() + + # MEM0_OSS_API_KEY is the dedicated key; falls back to aux config key, then + # to the provider's standard env var via _resolve_auto_credentials below. + explicit_api_key = ( + os.environ.get("MEM0_OSS_API_KEY", "").strip() + or aux_api_key + ) + # base_url: env var wins, then aux config + explicit_base_url = ( + os.environ.get("MEM0_OSS_OPENAI_BASE_URL", "").strip() + or aux_base_url + ) + + # When no specific provider is configured, fall through to the default + # auxiliary chain so we inherit the user's main Hermes provider + key. + auto_provider, auto_model, auto_base_url, auto_api_key = _resolve_auto_credentials( + aux_provider, aux_model, explicit_base_url, explicit_api_key + ) + + resolved_api_key = explicit_api_key or auto_api_key + resolved_base_url = explicit_base_url or auto_base_url + + # LLM provider: env > aux config > auto-detected > default + default_llm_provider = aux_provider or auto_provider or "openai" + llm_provider = os.environ.get("MEM0_OSS_LLM_PROVIDER", default_llm_provider).strip() + # Normalise Hermes provider aliases → mem0 provider keys + llm_provider = _normalise_provider(llm_provider) + + # LLM model: env > aux config > auto-detected > per-provider default + default_llm_model = aux_model or auto_model or _default_model_for(llm_provider) + llm_model = os.environ.get("MEM0_OSS_LLM_MODEL", default_llm_model).strip() + + # Embedder defaults mirror the LLM provider + default_emb_provider = _default_embedder_provider(llm_provider) + default_emb_model = _default_embedder_model(default_emb_provider) + default_emb_dims = _default_embedder_dims(default_emb_provider) + + config: dict = { + "vector_store_path": os.environ.get("MEM0_OSS_VECTOR_STORE_PATH", qdrant_path), + "history_db_path": os.environ.get("MEM0_OSS_HISTORY_DB_PATH", history_path), + "collection": os.environ.get("MEM0_OSS_COLLECTION", "hermes"), + "user_id": os.environ.get("MEM0_OSS_USER_ID", "hermes-user"), + "llm_provider": llm_provider, + "llm_model": llm_model, + "embedder_provider": _normalise_provider( + os.environ.get("MEM0_OSS_EMBEDDER_PROVIDER", default_emb_provider) + ), + "embedder_model": os.environ.get("MEM0_OSS_EMBEDDER_MODEL", default_emb_model), + "embedder_dims": int(os.environ.get("MEM0_OSS_EMBEDDER_DIMS", str(default_emb_dims))), + "top_k": int(os.environ.get("MEM0_OSS_TOP_K", "10")), + # Resolved credentials / endpoint + "api_key": resolved_api_key, + "base_url": resolved_base_url, + # Legacy key kept for backwards compat with tests and mem0_oss.json + "openai_api_key": resolved_api_key, + "openai_base_url": resolved_base_url, + } + + config_path = hermes_home / "mem0_oss.json" + if config_path.exists(): + try: + file_cfg = json.loads(config_path.read_text(encoding="utf-8")) + config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""}) + except Exception as exc: + logger.warning("mem0_oss: failed to read config file %s: %s", config_path, exc) + + return config + + +# --------------------------------------------------------------------------- +# Provider normalisation helpers +# --------------------------------------------------------------------------- + +# Maps Hermes provider names / aliases → mem0 LLM provider keys +_HERMES_TO_MEM0_PROVIDER: dict = { + "bedrock": "aws_bedrock", + "aws": "aws_bedrock", + "aws_bedrock": "aws_bedrock", + "openai": "openai", + "openrouter": "openai", # mem0 uses OpenAI adapter with OR base URL + "anthropic": "anthropic", + "ollama": "ollama", + "lmstudio": "lmstudio", + "custom": "openai", # custom base_url → OpenAI-compatible adapter + "auto": "aws_bedrock", # resolved later in is_available(); placeholder +} + +_PROVIDER_DEFAULTS: dict = { + "aws_bedrock": ("us.anthropic.claude-haiku-4-5-20251001-v1:0", + "aws_bedrock", "amazon.titan-embed-text-v2:0", 1024), + # --- ordering note: openai is the last-resort default (most widely available) --- + "openai": ("gpt-4o-mini", "openai", "text-embedding-3-small", 1536), + "anthropic": ("claude-haiku-4-5-20251001", "openai", "text-embedding-3-small", 1536), + "ollama": ("llama3.1", "ollama", "nomic-embed-text", 768), + "lmstudio": ("llama-3.2-1b-instruct", "openai", "text-embedding-nomic-embed-text-v1.5", 768), + "openrouter": ("openai/gpt-4o-mini", "openai", "text-embedding-3-small", 1536), +} + + +def _normalise_provider(p: str) -> str: + p = (p or "").strip().lower() + return _HERMES_TO_MEM0_PROVIDER.get(p, p) or "openai" + + +def _default_model_for(mem0_provider: str) -> str: + return _PROVIDER_DEFAULTS.get(mem0_provider, _PROVIDER_DEFAULTS["openai"])[0] + + +def _default_embedder_provider(mem0_provider: str) -> str: + return _PROVIDER_DEFAULTS.get(mem0_provider, _PROVIDER_DEFAULTS["openai"])[1] + + +def _default_embedder_model(mem0_emb_provider: str) -> str: + for _llm_p, (_, emb_p, emb_m, _) in _PROVIDER_DEFAULTS.items(): + if emb_p == mem0_emb_provider: + return emb_m + return "text-embedding-3-small" + + +def _default_embedder_dims(mem0_emb_provider: str) -> int: + for _llm_p, (_, emb_p, _, emb_d) in _PROVIDER_DEFAULTS.items(): + if emb_p == mem0_emb_provider: + return emb_d + return 1536 + + +def _build_mem0_config(cfg: dict) -> dict: + """Build a mem0 MemoryConfig-compatible dict from our flattened config. + + Translates Hermes/mem0 provider names into the provider-specific config + structures that mem0ai expects, including credentials and base URLs. + """ + llm_provider = cfg["llm_provider"] + llm_model = cfg["llm_model"] + embedder_provider = cfg["embedder_provider"] + embedder_model = cfg["embedder_model"] + embedder_dims = cfg["embedder_dims"] + api_key = cfg.get("api_key") or cfg.get("openai_api_key") or "" + base_url = cfg.get("base_url") or cfg.get("openai_base_url") or "" + + llm_cfg = _build_llm_cfg(llm_provider, llm_model, api_key, base_url) + emb_cfg = _build_embedder_cfg(embedder_provider, embedder_model, embedder_dims, api_key, base_url) + + vs_cfg = { + "collection_name": cfg["collection"], + "path": cfg["vector_store_path"], + "embedding_model_dims": embedder_dims, + "on_disk": True, + } + + return { + "vector_store": { + "provider": "qdrant", + "config": vs_cfg, + }, + "llm": { + "provider": llm_provider, + "config": llm_cfg, + }, + "embedder": { + "provider": embedder_provider, + "config": emb_cfg, + }, + "history_db_path": cfg["history_db_path"], + "version": "v1.1", + } + + +def _build_llm_cfg(provider: str, model: str, api_key: str, base_url: str) -> dict: + """Build the provider-specific LLM config dict for mem0ai.""" + cfg: dict = {"model": model} + + if provider == "aws_bedrock": + # Bedrock reads creds from env vars automatically; we don't pass them + # explicitly unless they're set (boto3 picks them up from the environment). + pass + + elif provider in ("openai", "anthropic", "lmstudio"): + if api_key: + cfg["api_key"] = api_key + if base_url and provider == "openai": + cfg["openai_base_url"] = base_url + + elif provider == "ollama": + # Ollama uses openai_base_url pointing at the local server + cfg["openai_base_url"] = base_url or "http://localhost:11434" + + # openrouter is handled as openai with OR base URL — normalised upstream, + # so if it reaches here with provider=="openai" it already has base_url set. + + return cfg + + +def _build_embedder_cfg(provider: str, model: str, dims: int, + api_key: str, base_url: str) -> dict: + """Build the provider-specific embedder config dict for mem0ai.""" + cfg: dict = {"model": model} + + if provider == "aws_bedrock": + cfg["embedding_dims"] = dims + + elif provider in ("openai",): + cfg["embedding_dims"] = dims + if api_key: + cfg["api_key"] = api_key + if base_url: + cfg["openai_base_url"] = base_url + + elif provider == "ollama": + cfg["embedding_dims"] = dims + cfg["ollama_base_url"] = base_url or "http://localhost:11434" + + elif provider == "lmstudio": + cfg["embedding_dims"] = dims + if api_key: + cfg["api_key"] = api_key + + return cfg + + +# --------------------------------------------------------------------------- +# Tool schemas +# --------------------------------------------------------------------------- + +SEARCH_SCHEMA = { + "name": "mem0_oss_search", + "description": ( + "Search long-term memory using semantic similarity. Returns facts and context " + "ranked by relevance. Use this when you need information from past sessions " + "that is not already in the current conversation." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "What to search for."}, + "top_k": { + "type": "integer", + "description": "Max results (default: 10, max: 50).", + }, + }, + "required": ["query"], + }, +} + +ADD_SCHEMA = { + "name": "mem0_oss_add", + "description": ( + "Store a fact, preference, or piece of context to long-term memory. " + "mem0 deduplicates automatically — safe to call for any important detail." + ), + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "The information to store."}, + }, + "required": ["content"], + }, +} + + +# --------------------------------------------------------------------------- +# Provider class +# --------------------------------------------------------------------------- + +class Mem0OSSMemoryProvider(MemoryProvider): + """Self-hosted mem0 memory provider backed by a local Qdrant vector store. + + No cloud account required — all data stays on disk. Uses AWS Bedrock + (or OpenAI / Ollama) for LLM fact-extraction and embedding. + """ + + def __init__(self): + # Config / identity + self._cfg: dict = {} + self._user_id: str = "hermes-user" + self._top_k: int = 10 + self._session_id: str = "" + self._agent_context: str = "primary" + # Circuit-breaker state (lock-protected) + self._lock = threading.Lock() + self._fail_count: int = 0 + self._last_fail_ts: float = 0.0 + # Background thread state + self._sync_thread: Optional[threading.Thread] = None + self._prefetch_thread: Optional[threading.Thread] = None + self._prefetch_result: str = "" + + # -- MemoryProvider identity -------------------------------------------- + + @property + def name(self) -> str: + return "mem0_oss" + + # -- Availability ------------------------------------------------------- + + def is_available(self) -> bool: + """True if mem0ai is installed and at least one LLM backend is usable. + + We only check imports and credentials — no network calls here. + """ + try: + import mem0 # noqa: F401 + except ImportError: + return False + + cfg = _load_config() + llm_provider = cfg.get("llm_provider", "openai") + + if llm_provider == "aws_bedrock": + if os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_PROFILE"): + return True + try: + from agent.bedrock_adapter import has_aws_credentials + return has_aws_credentials() + except Exception: + return False + if llm_provider == "anthropic": + return bool( + cfg.get("api_key") + or os.environ.get("ANTHROPIC_API_KEY") + ) + if llm_provider == "openai": + return bool( + cfg.get("api_key") + or cfg.get("openai_api_key") + or os.environ.get("OPENAI_API_KEY") + ) + if llm_provider in ("ollama", "lmstudio"): + return True # local, always assumed available + # Generic / custom base_url: trust the user's config + return True + + # -- Lifecycle ---------------------------------------------------------- + + def initialize(self, session_id: str, **kwargs) -> None: + """Build the mem0 Memory instance for this session.""" + self._session_id = session_id + self._agent_context = kwargs.get("agent_context", "primary") + self._cfg = _load_config() + self._user_id = self._cfg["user_id"] + self._top_k = self._cfg["top_k"] + # Reset circuit-breaker and prefetch state for this session. + # (Lock is created in __init__ and reused across sessions.) + with self._lock: + self._fail_count = 0 + self._last_fail_ts = 0.0 + self._prefetch_result = "" + import pathlib + pathlib.Path(self._cfg["vector_store_path"]).mkdir(parents=True, exist_ok=True) + pathlib.Path(self._cfg["history_db_path"]).parent.mkdir(parents=True, exist_ok=True) + + def _get_memory(self) -> Any: + """Create a fresh mem0 Memory instance for each call. + + We intentionally do NOT cache the instance. The embedded Qdrant store + uses a portalocker (fcntl) exclusive lock that is held for the lifetime + of the client object. When both the WebUI and the gateway run on the + same host they compete for this lock. + + We retry up to _LOCK_RETRY_ATTEMPTS times with _LOCK_RETRY_DELAY_S + seconds between attempts so that brief overlaps (e.g. a concurrent + prefetch in another process) are automatically resolved. + """ + import time as _time + + last_exc: Optional[Exception] = None + for attempt in range(_LOCK_RETRY_ATTEMPTS): + try: + from mem0 import Memory + from mem0.configs.base import MemoryConfig + + mem0_dict = _build_mem0_config(self._cfg) + mem_cfg = MemoryConfig(**{ + "vector_store": mem0_dict["vector_store"], + "llm": mem0_dict["llm"], + "embedder": mem0_dict["embedder"], + "history_db_path": mem0_dict["history_db_path"], + "version": mem0_dict["version"], + }) + return Memory(config=mem_cfg) + except Exception as exc: + last_exc = exc + if _QDRANT_LOCK_ERROR in str(exc): + if attempt < _LOCK_RETRY_ATTEMPTS - 1: + import random as _random + jitter = _random.uniform(0, _LOCK_RETRY_DELAY_S * 0.5) + delay = _LOCK_RETRY_DELAY_S + jitter + logger.debug( + "mem0_oss: Qdrant lock busy (attempt %d/%d), retrying in %.2fs", + attempt + 1, _LOCK_RETRY_ATTEMPTS, delay, + ) + _time.sleep(delay) + continue + # Last attempt also a lock error — fall through to raise below + else: + # Non-lock error — fail fast, no retry + logger.error("mem0_oss: failed to initialize Memory: %s", exc) + raise + logger.warning( + "mem0_oss: Qdrant lock still held after %d attempts — giving up: %s", + _LOCK_RETRY_ATTEMPTS, last_exc, + ) + raise last_exc # type: ignore[misc] + + # -- Circuit breaker helpers ------------------------------------------- + + def _is_tripped(self) -> bool: + with self._lock: + if self._fail_count < _BREAKER_THRESHOLD: + return False + if time.monotonic() - self._last_fail_ts >= _BREAKER_COOLDOWN_SECS: + self._fail_count = 0 + return False + return True + + def _record_failure(self) -> None: + with self._lock: + self._fail_count += 1 + self._last_fail_ts = time.monotonic() + + def _record_success(self) -> None: + with self._lock: + self._fail_count = 0 + + # -- System prompt block ----------------------------------------------- + + def system_prompt_block(self) -> str: + return ( + "## Mem0 OSS Memory (self-hosted)\n" + "You have access to long-term memory stored locally via mem0.\n" + "- Use `mem0_oss_search` to recall relevant facts before answering.\n" + "- Use `mem0_oss_add` to store important new facts, preferences, or context.\n" + "- Facts are extracted and deduplicated automatically on each turn.\n" + "- Search is semantic — natural-language queries work well.\n" + ) + + # -- Prefetch (background recall before each turn) --------------------- + + def queue_prefetch(self, query: str, *, session_id: str = "") -> None: + """Start a background thread to recall context for the upcoming turn.""" + if self._is_tripped(): + return + + self._prefetch_result = "" + self._prefetch_thread = threading.Thread( + target=self._do_prefetch, + args=(query,), + daemon=True, + name="mem0-oss-prefetch", + ) + self._prefetch_thread.start() + + def _do_prefetch(self, query: str) -> None: + try: + mem = self._get_memory() + results = mem.search( + query=query[:500], + top_k=self._top_k, + filters={"user_id": self._user_id}, + ) + del mem # release Qdrant lock ASAP — before any further processing + memories = _extract_results(results) + if memories: + lines = "\n".join(f"- {m}" for m in memories) + self._prefetch_result = f"Mem0 OSS Memory:\n{lines}" + self._record_success() + except Exception as exc: + if _QDRANT_LOCK_ERROR in str(exc): + logger.debug("mem0_oss: prefetch skipped — Qdrant lock held by another process") + return # not a real failure; don't trip the circuit breaker + self._record_failure() + logger.debug("mem0_oss: prefetch error: %s", exc) + + def prefetch(self, query: str, *, session_id: str = "") -> str: + """Return prefetched results (join background thread first).""" + if self._prefetch_thread is not None: + self._prefetch_thread.join(timeout=15.0) + self._prefetch_thread = None + return self._prefetch_result + + # -- Sync turn (auto-extract after each turn) -------------------------- + + def sync_turn( + self, user_content: str, assistant_content: str, *, session_id: str = "" + ) -> None: + """Spawn a background thread to extract and store facts from the turn.""" + if self._agent_context != "primary": + return + if self._is_tripped(): + return + + messages = [ + {"role": "user", "content": user_content}, + {"role": "assistant", "content": assistant_content}, + ] + self._sync_thread = threading.Thread( + target=self._do_sync, + args=(messages,), + daemon=True, + name="mem0-oss-sync", + ) + self._sync_thread.start() + + def _do_sync(self, messages: List[dict]) -> None: + try: + mem = self._get_memory() + mem.add(messages=messages, user_id=self._user_id, infer=True) + del mem # release Qdrant lock ASAP + self._record_success() + except Exception as exc: + if _QDRANT_LOCK_ERROR in str(exc): + logger.debug("mem0_oss: sync_turn skipped — Qdrant lock held by another process") + return # not a real failure; don't trip the circuit breaker + self._record_failure() + logger.debug("mem0_oss: sync_turn error: %s", exc) + + # -- Tool schemas & dispatch ------------------------------------------- + + def get_tool_schemas(self) -> List[dict]: + return [SEARCH_SCHEMA, ADD_SCHEMA] + + def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: + if tool_name == "mem0_oss_search": + return self._handle_search(args) + if tool_name == "mem0_oss_add": + return self._handle_add(args) + return tool_error(f"Unknown tool: {tool_name}") + + def _handle_search(self, args: Dict[str, Any]) -> str: + query = args.get("query", "").strip() + if not query: + return tool_error("mem0_oss_search requires 'query'") + + top_k = min(int(args.get("top_k", self._top_k)), 50) + + try: + mem = self._get_memory() + results = mem.search( + query=query, + top_k=top_k, + filters={"user_id": self._user_id}, + ) + del mem # release Qdrant lock ASAP + memories = _extract_results(results) + self._record_success() + if not memories: + return json.dumps({"result": "No relevant memories found."}) + return json.dumps({"result": "\n".join(f"- {m}" for m in memories)}) + except Exception as exc: + if _QDRANT_LOCK_ERROR in str(exc): + self._record_failure() # already handled by retry in _get_memory, but track it + logger.warning("mem0_oss: Qdrant lock held by another process — search skipped") + return json.dumps({"result": "Memory temporarily unavailable (storage locked by another process)."}) + self._record_failure() + logger.error("mem0_oss: search error: %s", exc) + return tool_error(f"mem0_oss_search failed: {exc}") + + def _handle_add(self, args: Dict[str, Any]) -> str: + content = args.get("content", "").strip() + if not content: + return tool_error("mem0_oss_add requires 'content'") + + try: + mem = self._get_memory() + mem.add( + messages=[{"role": "user", "content": content}], + user_id=self._user_id, + infer=True, + ) + del mem # release Qdrant lock ASAP + self._record_success() + return json.dumps({"result": "Memory stored successfully."}) + except Exception as exc: + if _QDRANT_LOCK_ERROR in str(exc): + self._record_failure() + logger.warning("mem0_oss: Qdrant lock held by another process — add skipped") + return json.dumps({"result": "Memory temporarily unavailable (storage locked by another process)."}) + self._record_failure() + logger.error("mem0_oss: add error: %s", exc) + return tool_error(f"mem0_oss_add failed: {exc}") + + # -- Config schema (for setup wizard) ---------------------------------- + + def get_config_schema(self) -> List[dict]: + return [ + { + "key": "llm_provider", + "label": "LLM provider", + "description": "mem0 LLM provider key (openai, aws_bedrock, ollama, ...)", + "default": "openai", + "env": "MEM0_OSS_LLM_PROVIDER", + "required": False, + }, + { + "key": "llm_model", + "label": "LLM model", + "description": "Model id passed to the LLM provider", + "default": "gpt-4o-mini", + "env": "MEM0_OSS_LLM_MODEL", + "required": False, + }, + { + "key": "embedder_provider", + "label": "Embedder provider", + "description": "mem0 embedder provider key (openai, aws_bedrock, ...)", + "default": "openai", + "env": "MEM0_OSS_EMBEDDER_PROVIDER", + "required": False, + }, + { + "key": "embedder_model", + "label": "Embedding model id", + "description": "Embedding model id", + "default": "text-embedding-3-small", + "env": "MEM0_OSS_EMBEDDER_MODEL", + "required": False, + }, + { + "key": "embedder_dims", + "label": "Embedding dimensions", + "description": "Dimensions of the embedding model (must match the model)", + "default": 1024, + "env": "MEM0_OSS_EMBEDDER_DIMS", + "required": False, + }, + { + "key": "collection", + "label": "Qdrant collection name", + "description": "Name of the Qdrant collection storing memories", + "default": "hermes", + "env": "MEM0_OSS_COLLECTION", + "required": False, + }, + { + "key": "user_id", + "label": "User ID", + "description": "Memory namespace / user identifier", + "default": "hermes-user", + "env": "MEM0_OSS_USER_ID", + "required": False, + }, + { + "key": "top_k", + "label": "Top-K results", + "description": "Default number of memories returned per search", + "default": 10, + "env": "MEM0_OSS_TOP_K", + "required": False, + }, + { + "key": "api_key", + "label": "API key (mem0 LLM)", + "description": ( + "Dedicated API key for mem0 LLM/embedder calls. " + "Takes precedence over auxiliary.mem0_oss.api_key in config.yaml " + "and over OPENAI_API_KEY / ANTHROPIC_API_KEY. " + "Not needed for AWS Bedrock (uses AWS_ACCESS_KEY_ID)." + ), + "default": "", + "env": "MEM0_OSS_API_KEY", + "secret": True, + "required": False, + }, + { + "key": "openai_api_key", + "label": "API key (legacy alias)", + "description": "Legacy alias for api_key — prefer MEM0_OSS_API_KEY.", + "default": "", + "env": "MEM0_OSS_OPENAI_API_KEY", + "secret": True, + "required": False, + }, + { + "key": "base_url", + "label": "OpenAI-compatible base URL", + "description": ( + "Custom LLM endpoint (e.g. http://localhost:11434/v1 for Ollama, " + "or an OpenRouter-compatible URL). Also settable via " + "auxiliary.mem0_oss.base_url in config.yaml." + ), + "default": "", + "env": "MEM0_OSS_OPENAI_BASE_URL", + "required": False, + }, + ] + + def save_config(self, values: dict, hermes_home) -> None: + """Write non-secret config to $HERMES_HOME/mem0_oss.json. + + Merges ``values`` into any existing file so that only the supplied keys + are overwritten. Secret keys (api_key, openai_api_key) should be stored + in ``.env`` instead; this method stores them only if explicitly passed. + """ + import json + from pathlib import Path + + config_path = Path(hermes_home) / "mem0_oss.json" + existing: dict = {} + if config_path.exists(): + try: + existing = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + pass + existing.update(values) + config_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + + # -- Shutdown ---------------------------------------------------------- + + def on_memory_write(self, action: str, target: str, content: str) -> None: + """Mirror built-in memory tool writes into mem0 store. + + Called by the framework whenever the agent uses the builtin memory tool, + so writes go to mem0 automatically without the agent needing to call + mem0_oss_add explicitly. + """ + if action != "add" or not (content or "").strip(): + return + + def _write(): + try: + mem = self._get_memory() + mem.add( + messages=[{"role": "user", "content": content.strip()}], + user_id=self._user_id, + infer=False, + metadata={"source": "hermes_memory_tool", "target": target}, + ) + except Exception as e: + if _QDRANT_LOCK_ERROR in str(e): + logger.debug("mem0_oss on_memory_write skipped — Qdrant lock held by another process") + return + logger.debug("mem0_oss on_memory_write failed: %s", e) + + t = threading.Thread(target=_write, daemon=True, name="mem0-oss-memwrite") + t.start() + + def shutdown(self) -> None: + """Wait for any in-flight background threads.""" + for thread in (self._sync_thread, self._prefetch_thread): + if thread is not None and thread.is_alive(): + thread.join(timeout=10.0) + + +# --------------------------------------------------------------------------- +# Result extraction helper +# --------------------------------------------------------------------------- + +def _extract_results(results: Any) -> List[str]: + """Normalize mem0 search results (v1 list or v2 dict) to plain strings.""" + if isinstance(results, dict) and "results" in results: + items = results["results"] + elif isinstance(results, list): + items = results + else: + return [] + + memories = [] + for item in items: + if isinstance(item, dict): + mem = item.get("memory") or item.get("text") or "" + else: + mem = str(item) + if mem: + memories.append(mem) + return memories + + +# --------------------------------------------------------------------------- +# Plugin registration +# --------------------------------------------------------------------------- + +def register(ctx) -> None: + ctx.register_memory_provider(Mem0OSSMemoryProvider()) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml new file mode 100644 index 0000000..81f59b4 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml @@ -0,0 +1,7 @@ +name: mem0_oss +version: 1.0.0 +kind: backend +description: "Mem0 OSS — self-hosted LLM fact extraction with local Qdrant vector store. No cloud account required." +pip_dependencies: + - mem0ai + - qdrant-client diff --git a/packages/fox-overlay/pyproject.toml b/packages/fox-overlay/pyproject.toml index 45d4919..e368db0 100644 --- a/packages/fox-overlay/pyproject.toml +++ b/packages/fox-overlay/pyproject.toml @@ -6,7 +6,12 @@ requires-python = ">=3.11" dependencies = [] [project.entry-points."hermes_agent.plugins"] -# fox_overlay = "fox_overlay:register" # commented until Phase 3 +# Discovered by hermes_cli.plugins.PluginManager.discover_and_load via +# importlib.metadata.entry_points(group="hermes_agent.plugins"). Each +# entry-point points at a `register(ctx: PluginContext) -> None` callable +# that hermes-agent invokes once per process at plugin-load time. +fox_overlay = "fox_overlay.agent_plugins:register" +mem0_oss = "fox_overlay.agent_plugins.mem0_oss:register" [build-system] requires = ["setuptools>=68", "wheel"] diff --git a/packages/integration/Dockerfile b/packages/integration/Dockerfile index d45881b..371f5b9 100644 --- a/packages/integration/Dockerfile +++ b/packages/integration/Dockerfile @@ -138,16 +138,20 @@ RUN set -eux; \ fi; \ chown -R foxinthebox:foxinthebox /app/hermes-agent /app/hermes-webui -# ── Fox overlay: static files (Phase 2 of v0.6.0 migration) ─────────────────── -# webui_static/ holds Fox-only assets relocated out of the fork's static/ tree. -# Upstream's api/extensions.py serves these under /extensions/ and injects the -# CSS/JS tags into index.html at request time from the ENV-configured URL lists. -# Cap is 32 URLs per env var; comma-separated. Order preserved from the pre-Phase-2 -# fork index.html script tags. +# ── Fox overlay: install package + serve static via api/extensions.py ───────── +# Phase 3 of v0.6.0 migration: COPY the whole overlay (Python modules + static +# webui assets) and pip install -e it. The pip install registers fox-overlay +# for import — the hermes_agent.plugins entry-points in fox-overlay's +# pyproject.toml wire into hermes-agent's PluginManager so register(ctx) fires +# at agent startup (applies the 3 monkey-patches: target_model fix, auxiliary +# default fallback, cron failure diagnostics). The same COPY makes +# webui_static/ available for the Phase-2 HERMES_WEBUI_EXTENSION_DIR-based +# static-asset serving. # -# Placed AFTER `pip install` so static-asset edits (frequent during Phase 7 UI -# work) do not invalidate the slow pip install layer cache. -COPY packages/fox-overlay/webui_static /app/fox-overlay/webui_static +# Placed AFTER the upstream pip install so static-asset OR overlay-code edits +# don't invalidate the (slow) upstream pip install layer cache. +COPY packages/fox-overlay /app/fox-overlay +RUN pip install --no-cache-dir -e /app/fox-overlay ENV HERMES_WEBUI_EXTENSION_DIR=/app/fox-overlay/webui_static \ HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/onboarding-preview.js,/extensions/fox-overlay.js,/extensions/hostname-prompt.js,/extensions/fallback-polish.js \ HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/fox-in-the-box.css From 52c5db3fb8246996ee186f74d7b409b4c4a836b5 Mon Sep 17 00:00:00 2001 From: Dennis Vorobyov Date: Sun, 17 May 2026 03:49:30 +0100 Subject: [PATCH 2/2] chore(fox-overlay): revert mem0_oss relocation (Phase 3 rollback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the mem0_oss-relocation portion of this PR per Reviewer #2 and Reviewer #3's HIGH finding during Phase 3b review. Architectural mismatch: upstream's memory-provider discovery is a separate directory-scan path (plugins/memory/__init__.py) with its own FakePluginContext exposing register_memory_provider — incompatible with the standard PluginContext that entry-point-discovered plugins receive. Relocated mem0_oss could not register a MemoryProvider at gateway runtime via either the upstream PluginManager OR the gateway bootstrap shim. ## File changes - removed packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/ (3 files, ~1218 lines) - reverted pyproject.toml: drop mem0ai + qdrant-client dependencies (mem0_oss is back as a bundled fork plugin; upstream's own discovery path installs its plugin.yaml deps if needed) - reverted pyproject.toml entry-points: drop mem0_oss entry; keep fox_overlay entry for the 3 monkey-patches - reverted agent_plugins/__init__.py: register() no longer imports or invokes mem0_oss; docstring updated with deferral rationale - reverted MANIFEST.toml: drop mem0_oss entry; add deferral comment - bumped hermes-agent submodule pin from e301105e to 0a278fc1 (fork main after PR #3 merge which restores plugins/memory/mem0_oss/ byte-identical to pre-PR-#1 baseline) - added packages/fox-overlay/tests/test_agent_overlay.py — 9 pytest cases verifying each of the 8 monkey-patch sentinels + idempotency of register() ## Net Phase 3 scope (after this revert) The 3 Fox monkey-patches (Bedrock target_model, auxiliary.default fallback, cron failure diagnostics) ship as planned via the fox-overlay entry-point + gateway bootstrap shim. mem0_oss continues as a bundled fork plugin, working as it did in v0.5.4 — zero user- visible regression. ## Future work (out of scope for v0.6.0) A dedicated future phase handles proper memory-provider relocation, requiring either: (a) An upstream PR to NousResearch/hermes-agent adding register_memory_provider to the standard PluginContext, OR (b) A Fox-side fake-context construction in the overlay that mirrors plugins/memory/__init__.py's discovery path. Refs Reviewer #2 + Reviewer #3 findings on PR #221; rollback decision by Dennis 2026-05-17 (Option A of three presented). --- forks/hermes-agent | 2 +- packages/fox-overlay/MANIFEST.toml | 12 +- .../fox_overlay/agent_plugins/__init__.py | 34 +- .../agent_plugins/mem0_oss/README.md | 201 ---- .../agent_plugins/mem0_oss/__init__.py | 1011 ----------------- .../agent_plugins/mem0_oss/plugin.yaml | 7 - packages/fox-overlay/pyproject.toml | 1 - .../fox-overlay/tests/test_agent_overlay.py | 74 ++ 8 files changed, 106 insertions(+), 1236 deletions(-) delete mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md delete mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py delete mode 100644 packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml create mode 100644 packages/fox-overlay/tests/test_agent_overlay.py diff --git a/forks/hermes-agent b/forks/hermes-agent index e301105..0a278fc 160000 --- a/forks/hermes-agent +++ b/forks/hermes-agent @@ -1 +1 @@ -Subproject commit e301105ec8bcc7b32503beea12a93274507316b4 +Subproject commit 0a278fc14fb41059c0a2a6784afdd599ccb8574f diff --git a/packages/fox-overlay/MANIFEST.toml b/packages/fox-overlay/MANIFEST.toml index 9b288f2..99fb6fa 100644 --- a/packages/fox-overlay/MANIFEST.toml +++ b/packages/fox-overlay/MANIFEST.toml @@ -23,12 +23,18 @@ [python] # Phase 3 — agent-side overlay. Three monkey-patch modules applied via -# the `hermes_agent.plugins` entry-point group; mem0_oss relocated as a -# bundled plugin (also entry-point-registered). +# the `hermes_agent.plugins` entry-point group. # Phase 5 and Phase 6 add webui-side entries below. +# +# mem0_oss relocation deferred: Engineer discovered during Phase 3b PR +# review that upstream's memory-provider discovery is a separate +# directory-scan path (plugins/memory/__init__.py) with its own +# FakePluginContext exposing register_memory_provider — incompatible +# with the standard PluginContext that entry-points use. mem0_oss +# remains a bundled fork plugin until a dedicated future phase +# properly relocates memory providers. "fox_overlay.agent_plugins" = "fox-only-additive" # register(ctx) entry-point "fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.runtime_provider" = "fox-only-additive" "fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.auxiliary_client" = "fox-only-additive" "fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.cron_diagnostics" = "fox-only-additive" "fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches._helpers" = "fox-only-additive" -"fox_overlay.agent_plugins.mem0_oss" = "fox-relocated" # was forks/hermes-agent/plugins/memory/mem0_oss diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py index 8784de8..6134271 100644 --- a/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py +++ b/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py @@ -1,18 +1,28 @@ -"""Fox overlay entry-points for hermes-agent's plugin manager. +"""Fox overlay entry-point for hermes-agent's plugin manager. -Both ``fox_overlay`` (this module's :func:`register`) and ``mem0_oss`` -(`mem0_oss.register` via the manifest) are wired in ``packages/fox-overlay/ -pyproject.toml`` under the ``[project.entry-points."hermes_agent.plugins"]`` -group. +``fox_overlay`` is wired in ``packages/fox-overlay/pyproject.toml`` under +the ``[project.entry-points."hermes_agent.plugins"]`` group. -``register(ctx)`` is called once per process by hermes-agent's -``PluginManager.discover_and_load``. We apply our three agent-side -monkey-patches here so the patched behavior is in place before any -runtime code path touches the patched symbols. +``register(ctx)`` is called once per process by: -mem0_oss has its own ``register`` (in mem0_oss/__init__.py) and is -discovered as a separate plugin — this module does not need to touch -it. +* hermes-agent's ``PluginManager.discover_and_load`` (only from ``hermes`` + CLI subcommands), AND +* the Fox bootstrap shim added to ``forks/hermes-agent/gateway/run.py`` for + the gateway code path (upstream's gateway never calls PluginManager + directly; the shim is a Phase-3 follow-on patch — see fork PR #2). + +We apply the three agent-side monkey-patches here so the patched +behavior is in place before any runtime code path touches the patched +symbols. + +Note on mem0_oss: the original Phase 3 plan relocated mem0_oss to this +overlay as a second entry-point plugin. During Phase 3b PR review, +Engineer discovered upstream's memory-provider discovery is a separate +system (``plugins/memory/__init__.py``) with its own ``FakePluginContext`` +exposing ``register_memory_provider`` (which standard ``PluginContext`` +lacks). The relocation was reverted (fork PR #3 + companion monorepo +changes); mem0_oss remains a bundled fork plugin until a dedicated +future phase properly relocates memory providers. Phase 3 of the v0.6.0 upstream-separation migration (fox-in-the-box-ai/ fox-in-the-box#155). diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md deleted file mode 100644 index 8c10bdb..0000000 --- a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/README.md +++ /dev/null @@ -1,201 +0,0 @@ -# Mem0 OSS Memory Plugin - -Self-hosted, privacy-first long-term memory using the open-source -[mem0ai](https://github.com/mem0ai/mem0) library. No cloud API key or -external service needed — everything runs on your machine. - -## How it works - -- **LLM fact extraction** — after each conversation turn, mem0 uses an LLM - to extract important facts, preferences, and context from the exchange. -- **Semantic search** — memories are stored in a local [Qdrant](https://qdrant.tech/) - vector database. Searches use embedding-based similarity so natural-language - queries work well. -- **Automatic deduplication** — mem0 merges new facts with existing ones to - avoid duplicate storage. -- **Built-in memory mirroring** — writes via the built-in `memory` tool are - automatically mirrored into mem0 via `on_memory_write`, so nothing is lost - whether you use the native tool or the mem0-specific tools. - -## Setup - -### 1. Install dependencies - -```bash -pip install mem0ai qdrant-client -``` - -### 2. Configure a backend - -**Zero extra config — auto-detect (recommended)** - -If you already have a Hermes provider configured (OpenRouter, Anthropic, OpenAI, -or AWS Bedrock), mem0 OSS will automatically pick it up — no `MEM0_OSS_*` vars -needed. The plugin mirrors the standard Hermes auxiliary provider priority: - -``` -OPENROUTER_API_KEY → uses OpenRouter (openrouter → openai adapter) -ANTHROPIC_API_KEY → uses Anthropic directly -OPENAI_API_KEY → uses OpenAI (+ OPENAI_BASE_URL if set) -AWS_ACCESS_KEY_ID → uses AWS Bedrock (boto3 reads creds automatically) -``` - -The first matching key wins. - -**Option A — config.yaml (preferred for per-provider control)** - -The plugin inherits from `auxiliary.default` if no `auxiliary.mem0_oss` block -exists, so if you've already set a default auxiliary provider for other tasks -you get mem0 OSS for free: - -```yaml -# ~/.hermes/config.yaml -auxiliary: - default: # inherited by mem0_oss and all other aux tasks - provider: auto - model: us.anthropic.claude-haiku-4-5-20251001-v1:0 - - # Optional — override just for mem0_oss: - mem0_oss: - provider: openrouter # or openai, anthropic, ollama, aws_bedrock, custom - model: openai/gpt-4o-mini - # api_key: ... # optional — falls back to provider's standard env var - # base_url: ... # optional — for custom/local endpoints -``` - -Supported provider values: `openrouter`, `openai`, `anthropic`, `ollama`, -`lmstudio`, `aws_bedrock` (alias: `bedrock`), `custom`, `auto`. -`auto` uses the same env-var detection order as zero-config. - -**Option B — AWS Bedrock** - -If you already use Hermes with Bedrock, no additional config is needed. -The plugin reuses `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION`. - -LLM default: `us.anthropic.claude-haiku-4-5-20251001-v1:0` -Embedder default: `amazon.titan-embed-text-v2:0` (1024-dim) - -**Option C — OpenAI** - -```bash -# If OPENAI_API_KEY is already set, nothing more is needed. -# Override model/embedder explicitly if desired: -export MEM0_OSS_LLM_MODEL=gpt-4o-mini -export MEM0_OSS_EMBEDDER_MODEL=text-embedding-3-small -export MEM0_OSS_EMBEDDER_DIMS=1536 -``` - -**Option D — Ollama (fully local, no API key)** - -```bash -export MEM0_OSS_LLM_PROVIDER=ollama -export MEM0_OSS_LLM_MODEL=llama3.2 -export MEM0_OSS_EMBEDDER_PROVIDER=ollama -export MEM0_OSS_EMBEDDER_MODEL=nomic-embed-text -export MEM0_OSS_EMBEDDER_DIMS=768 -``` - -### 3. Activate - -```yaml -# ~/.hermes/config.yaml -memory: - provider: mem0_oss -``` - -Or use the interactive setup wizard: - -```bash -hermes memory setup # select "mem0_oss" -``` - -## Storage - -| Path | Contents | -|------|----------| -| `$HERMES_HOME/mem0_oss/qdrant/` | Qdrant vector store (all memories) | -| `$HERMES_HOME/mem0_oss/history.db` | mem0 history SQLite database | - -Override with `MEM0_OSS_VECTOR_STORE_PATH` and `MEM0_OSS_HISTORY_DB_PATH`. - -Non-secret settings can also be persisted to `$HERMES_HOME/mem0_oss.json` -by the setup wizard (via `save_config`), or written manually — see -[All configuration options](#all-configuration-options). - -## Agent tools - -| Tool | Description | -|------|-------------| -| `mem0_oss_search` | Semantic search over stored memories | -| `mem0_oss_add` | Store a fact, preference, or context explicitly | - -Facts are extracted and stored automatically on every conversation turn via -`sync_turn` — no explicit save call needed. - -Writes via the built-in `memory` tool are also mirrored automatically into -mem0 via `on_memory_write`. To explicitly save something mid-session, use -`mem0_oss_add` (or the built-in `memory` tool — both propagate to mem0). - -## Concurrent access (WebUI + gateway) - -The plugin uses embedded Qdrant which normally allows only one process at a -time. To avoid conflicts when both the WebUI and the gateway run on the same -host, the plugin creates a fresh `Memory` instance per operation and releases -the Qdrant lock immediately after each call. If a brief overlap occurs the -operation is skipped gracefully (logged at DEBUG, not counted as a failure) -rather than raising an error. - -## All configuration options - -### Environment variables - -| Env var | Default | Description | -|---------|---------|-------------| -| `MEM0_OSS_LLM_PROVIDER` | auto-detected | LLM provider (`openrouter`, `openai`, `anthropic`, `ollama`, `aws_bedrock`, …) | -| `MEM0_OSS_LLM_MODEL` | provider default | LLM model id | -| `MEM0_OSS_EMBEDDER_PROVIDER` | mirrors LLM provider | Embedder provider | -| `MEM0_OSS_EMBEDDER_MODEL` | provider default | Embedder model id | -| `MEM0_OSS_EMBEDDER_DIMS` | provider default | Embedding dimensions | -| `MEM0_OSS_COLLECTION` | `hermes` | Qdrant collection name | -| `MEM0_OSS_USER_ID` | `hermes-user` | Memory namespace | -| `MEM0_OSS_TOP_K` | `10` | Default search result count | -| `MEM0_OSS_VECTOR_STORE_PATH` | `$HERMES_HOME/mem0_oss/qdrant` | On-disk Qdrant path | -| `MEM0_OSS_HISTORY_DB_PATH` | `$HERMES_HOME/mem0_oss/history.db` | SQLite history path | -| `MEM0_OSS_API_KEY` | _(auto-detected from provider env var)_ | Explicit API key for the LLM backend | -| `MEM0_OSS_OPENAI_BASE_URL` | _(none)_ | OpenAI-compatible endpoint override | - -### config.yaml (auxiliary.mem0_oss / auxiliary.default) - -`auxiliary.mem0_oss` keys take precedence; any key not set there falls back to -`auxiliary.default` (which is also used by compression, vision, and other aux tasks). - -| Key | Description | -|-----|-------------| -| `provider` | Hermes provider name (see auto-detect order above) | -| `model` | LLM model id | -| `base_url` | Custom OpenAI-compatible endpoint | -| `api_key` | Explicit API key (takes precedence over env vars) | - -### Key resolution priority - -1. `MEM0_OSS_API_KEY` env var -2. `auxiliary.mem0_oss.api_key` in `config.yaml` -3. Provider's standard env var (`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, - `OPENAI_API_KEY`, …) resolved via the Hermes provider registry -4. AWS credentials from environment (for Bedrock) - -Or put non-secret settings in `$HERMES_HOME/mem0_oss.json` (keys are the -env-var names without the `MEM0_OSS_` prefix, in snake_case): - -```json -{ - "llm_provider": "aws_bedrock", - "llm_model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", - "embedder_provider": "aws_bedrock", - "embedder_model": "amazon.titan-embed-text-v2:0", - "embedder_dims": 1024, - "collection": "hermes", - "user_id": "hermes-user", - "top_k": 10 -} -``` diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py deleted file mode 100644 index 27a4767..0000000 --- a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/__init__.py +++ /dev/null @@ -1,1011 +0,0 @@ -"""Mem0 OSS (self-hosted) memory plugin — MemoryProvider interface. - -LLM-powered fact extraction, semantic vector search, and automatic -deduplication using the open-source ``mem0ai`` library — no cloud API key -required. All data is stored locally on disk. - -Backend choices: - Vector store: Qdrant (local path, no server) — default - LLM / Embedder: resolved from ``auxiliary.mem0_oss`` in config.yaml, then - from ``MEM0_OSS_*`` env vars, then auto-detected. - -Primary config — config.yaml (auxiliary.mem0_oss): - provider — Hermes provider name: "auto", "aws_bedrock", "bedrock", - "openai", "openrouter", "ollama", "anthropic", or "custom". - "auto" follows the standard Hermes auxiliary resolution chain. - model — LLM model id (provider-specific slug). Empty = provider default. - base_url — OpenAI-compatible endpoint (forces provider="custom"). - api_key — API key for that endpoint. Falls back to MEM0_OSS_API_KEY. - -Secondary config — environment variables: - MEM0_OSS_VECTOR_STORE_PATH — on-disk path for Qdrant (default: $HERMES_HOME/mem0_oss/qdrant) - MEM0_OSS_HISTORY_DB_PATH — SQLite history path (default: $HERMES_HOME/mem0_oss/history.db) - MEM0_OSS_COLLECTION — Qdrant collection name (default: hermes) - MEM0_OSS_USER_ID — memory namespace (default: hermes-user) - MEM0_OSS_LLM_PROVIDER — override auxiliary.mem0_oss.provider - MEM0_OSS_LLM_MODEL — override auxiliary.mem0_oss.model - MEM0_OSS_EMBEDDER_PROVIDER — mem0 embedder provider (default: matches llm provider) - MEM0_OSS_EMBEDDER_MODEL — embedder model id - MEM0_OSS_EMBEDDER_DIMS — embedding dimensions (default: auto per provider) - MEM0_OSS_TOP_K — max results returned per search (default: 10) - -Secret config: - MEM0_OSS_API_KEY — dedicated API key for mem0 LLM calls; takes - precedence over auxiliary.mem0_oss.api_key. - Falls back to the provider's standard env var - (OPENAI_API_KEY, ANTHROPIC_API_KEY, - OPENROUTER_API_KEY, etc.) resolved via the - Hermes provider registry — so no extra key is - needed when a main Hermes provider is already - configured. - (AWS Bedrock uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION.) - -Optional $HERMES_HOME/mem0_oss.json for non-secret overrides: - { - "llm_provider": "aws_bedrock", - "llm_model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", - "embedder_provider": "aws_bedrock", - "embedder_model": "amazon.titan-embed-text-v2:0", - "embedder_dims": 1024, - "collection": "hermes", - "user_id": "hermes-user", - "top_k": 10 - } -""" - -from __future__ import annotations - -import json -import logging -import os -import threading -import time -from typing import Any, Dict, List, Optional - -from agent.memory_provider import MemoryProvider -from hermes_constants import get_hermes_home -from tools.registry import tool_error - -logger = logging.getLogger(__name__) - -# Circuit breaker: after this many consecutive failures, pause for cooldown. -_BREAKER_THRESHOLD = 5 -_BREAKER_COOLDOWN_SECS = 120 - -# Qdrant embedded lock error substring — used to detect contention gracefully. -_QDRANT_LOCK_ERROR = "already accessed by another instance" - -# Retry parameters for Qdrant lock contention in _get_memory(). -# Two processes (WebUI + gateway) may briefly overlap; retry resolves it. -# Prefetch + sync operations hold the lock during an LLM call (~1-3s), -# so we retry for up to 15s total with jitter to avoid thundering herd. -_LOCK_RETRY_ATTEMPTS = 10 # total attempts -_LOCK_RETRY_DELAY_S = 0.8 # base seconds between retries (with jitter, up to ~0.4s extra) - - -# --------------------------------------------------------------------------- -# Config helpers -# --------------------------------------------------------------------------- - -def _get_aux_config() -> dict: - """Read auxiliary.mem0_oss from config.yaml, with fallback to auxiliary.default. - - Keys not present in auxiliary.mem0_oss are inherited from auxiliary.default - (if set) so that a single default auxiliary provider covers all aux tasks. - Returns {} on any failure. - """ - try: - from hermes_cli.config import load_config - config = load_config() - except Exception: - return {} - aux = config.get("auxiliary", {}) if isinstance(config, dict) else {} - if not isinstance(aux, dict): - return {} - default = aux.get("default", {}) or {} - task = aux.get("mem0_oss", {}) or {} - # task-specific keys win; default fills in anything not set - merged = {**default, **task} - return merged - - -def _resolve_auto_credentials(aux_provider: str, aux_model: str, - aux_base_url: str, aux_api_key: str): - """When no specific provider is set, fall through to the default auxiliary chain. - - Mirrors the Hermes auxiliary auto-detection priority order so that users - with a main provider configured (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, …) - don't need to also set MEM0_OSS_API_KEY. - - If an explicit provider is already configured (aux_provider is non-empty and - not "auto"), this function is a no-op and returns the inputs unchanged. - - Returns (hermes_provider, model, base_url, api_key) — all strings, never None. - """ - # Only kick in when no explicit provider was configured - if aux_provider and aux_provider.lower() not in ("", "auto"): - return aux_provider, aux_model, aux_base_url, aux_api_key - - # If task-level config.yaml has a specific auxiliary.mem0_oss entry with a - # provider set, _resolve_task_provider_model returns that; otherwise "auto". - # Rather than creating a full OpenAI client we probe env vars directly in - # the same priority order the auxiliary auto-detect chain uses. - try: - from agent.auxiliary_client import _resolve_task_provider_model - h_provider, h_model, h_base_url, h_api_key, _api_mode = ( - _resolve_task_provider_model("mem0_oss") - ) - # If the task config actually resolved a specific non-auto provider, - # use that directly (covers auxiliary.mem0_oss.provider = "openrouter" etc.) - if h_provider and h_provider != "auto": - resolved_provider = h_provider - resolved_model = aux_model or h_model or "" - resolved_base_url = aux_base_url or h_base_url or "" - resolved_api_key = aux_api_key or h_api_key or "" - # Still try to fill missing key from provider registry - if not resolved_api_key and resolved_provider not in ( - "aws_bedrock", "bedrock", "aws", "ollama", "lmstudio"): - try: - from hermes_cli.auth import resolve_api_key_provider_credentials - creds = resolve_api_key_provider_credentials(resolved_provider) - resolved_api_key = str(creds.get("api_key", "") or "").strip() - if not resolved_base_url: - resolved_base_url = str(creds.get("base_url", "") or "").strip() - except Exception: - pass - return resolved_provider, resolved_model, resolved_base_url, resolved_api_key - except Exception: - pass - - # Full auto-detect: first try to mirror the main runtime provider so that - # mem0 uses the same provider as the rest of Hermes. Fall back to env-var - # probe only when the main provider isn't usable for aux tasks. - try: - from agent.auxiliary_client import _read_main_provider - main_provider = (_read_main_provider() or "").strip().lower() - if main_provider in ("bedrock", "aws_bedrock", "aws"): - return "aws_bedrock", aux_model, aux_base_url, aux_api_key - if main_provider == "anthropic": - anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() - if anthropic_key: - return "anthropic", aux_model, aux_base_url, aux_api_key or anthropic_key - if main_provider == "openai": - openai_key = os.environ.get("OPENAI_API_KEY", "").strip() - if openai_key: - base_url = os.environ.get("OPENAI_BASE_URL", "").strip() - return "openai", aux_model, aux_base_url or base_url, aux_api_key or openai_key - if main_provider == "openrouter": - openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip() - if openrouter_key: - base_url = os.environ.get("OPENROUTER_BASE_URL", - "https://openrouter.ai/api/v1").strip() - return "openrouter", aux_model, aux_base_url or base_url, aux_api_key or openrouter_key - except Exception: - pass - - # Fallback env-var probe (no main provider available) - openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip() - if openrouter_key: - base_url = os.environ.get("OPENROUTER_BASE_URL", - "https://openrouter.ai/api/v1").strip() - return "openrouter", aux_model, aux_base_url or base_url, aux_api_key or openrouter_key - - anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() - if anthropic_key: - return "anthropic", aux_model, aux_base_url, aux_api_key or anthropic_key - - openai_key = os.environ.get("OPENAI_API_KEY", "").strip() - if openai_key: - base_url = os.environ.get("OPENAI_BASE_URL", "").strip() - return "openai", aux_model, aux_base_url or base_url, aux_api_key or openai_key - - # Bedrock: no API key needed, boto3 reads from env/profile automatically - if os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_PROFILE"): - return "aws_bedrock", aux_model, aux_base_url, aux_api_key - - # Nothing found — return "auto" and let _load_config fall back to aws_bedrock default - return aux_provider or "auto", aux_model, aux_base_url, aux_api_key - - -def _load_config() -> dict: - """Load config from env vars, with $HERMES_HOME/mem0_oss.json overrides. - - Priority for LLM provider/model/api_key (highest → lowest): - 1. MEM0_OSS_LLM_PROVIDER / MEM0_OSS_LLM_MODEL env vars - 2. auxiliary.mem0_oss.provider / .model in config.yaml - 3. Default auxiliary chain (auto-detect from Hermes config) — uses the - provider's standard env var (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, …) - so MEM0_OSS_API_KEY is not required when a main provider is configured. - 4. Defaults (aws_bedrock) - - Priority for API key: - 1. MEM0_OSS_API_KEY env var - 2. auxiliary.mem0_oss.api_key in config.yaml - 3. Provider standard env var (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, etc.) - resolved via the Hermes provider registry. - - Environment variables are the base; the JSON file (if present) overrides - individual keys. Neither source is required — sensible defaults apply. - """ - hermes_home = get_hermes_home() - qdrant_path = str(hermes_home / "mem0_oss" / "qdrant") - history_path = str(hermes_home / "mem0_oss" / "history.db") - - aux = _get_aux_config() - aux_provider = str(aux.get("provider", "") or "").strip() - aux_model = str(aux.get("model", "") or "").strip() - aux_base_url = str(aux.get("base_url", "") or "").strip() - aux_api_key = str(aux.get("api_key", "") or "").strip() - - # MEM0_OSS_API_KEY is the dedicated key; falls back to aux config key, then - # to the provider's standard env var via _resolve_auto_credentials below. - explicit_api_key = ( - os.environ.get("MEM0_OSS_API_KEY", "").strip() - or aux_api_key - ) - # base_url: env var wins, then aux config - explicit_base_url = ( - os.environ.get("MEM0_OSS_OPENAI_BASE_URL", "").strip() - or aux_base_url - ) - - # When no specific provider is configured, fall through to the default - # auxiliary chain so we inherit the user's main Hermes provider + key. - auto_provider, auto_model, auto_base_url, auto_api_key = _resolve_auto_credentials( - aux_provider, aux_model, explicit_base_url, explicit_api_key - ) - - resolved_api_key = explicit_api_key or auto_api_key - resolved_base_url = explicit_base_url or auto_base_url - - # LLM provider: env > aux config > auto-detected > default - default_llm_provider = aux_provider or auto_provider or "openai" - llm_provider = os.environ.get("MEM0_OSS_LLM_PROVIDER", default_llm_provider).strip() - # Normalise Hermes provider aliases → mem0 provider keys - llm_provider = _normalise_provider(llm_provider) - - # LLM model: env > aux config > auto-detected > per-provider default - default_llm_model = aux_model or auto_model or _default_model_for(llm_provider) - llm_model = os.environ.get("MEM0_OSS_LLM_MODEL", default_llm_model).strip() - - # Embedder defaults mirror the LLM provider - default_emb_provider = _default_embedder_provider(llm_provider) - default_emb_model = _default_embedder_model(default_emb_provider) - default_emb_dims = _default_embedder_dims(default_emb_provider) - - config: dict = { - "vector_store_path": os.environ.get("MEM0_OSS_VECTOR_STORE_PATH", qdrant_path), - "history_db_path": os.environ.get("MEM0_OSS_HISTORY_DB_PATH", history_path), - "collection": os.environ.get("MEM0_OSS_COLLECTION", "hermes"), - "user_id": os.environ.get("MEM0_OSS_USER_ID", "hermes-user"), - "llm_provider": llm_provider, - "llm_model": llm_model, - "embedder_provider": _normalise_provider( - os.environ.get("MEM0_OSS_EMBEDDER_PROVIDER", default_emb_provider) - ), - "embedder_model": os.environ.get("MEM0_OSS_EMBEDDER_MODEL", default_emb_model), - "embedder_dims": int(os.environ.get("MEM0_OSS_EMBEDDER_DIMS", str(default_emb_dims))), - "top_k": int(os.environ.get("MEM0_OSS_TOP_K", "10")), - # Resolved credentials / endpoint - "api_key": resolved_api_key, - "base_url": resolved_base_url, - # Legacy key kept for backwards compat with tests and mem0_oss.json - "openai_api_key": resolved_api_key, - "openai_base_url": resolved_base_url, - } - - config_path = hermes_home / "mem0_oss.json" - if config_path.exists(): - try: - file_cfg = json.loads(config_path.read_text(encoding="utf-8")) - config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""}) - except Exception as exc: - logger.warning("mem0_oss: failed to read config file %s: %s", config_path, exc) - - return config - - -# --------------------------------------------------------------------------- -# Provider normalisation helpers -# --------------------------------------------------------------------------- - -# Maps Hermes provider names / aliases → mem0 LLM provider keys -_HERMES_TO_MEM0_PROVIDER: dict = { - "bedrock": "aws_bedrock", - "aws": "aws_bedrock", - "aws_bedrock": "aws_bedrock", - "openai": "openai", - "openrouter": "openai", # mem0 uses OpenAI adapter with OR base URL - "anthropic": "anthropic", - "ollama": "ollama", - "lmstudio": "lmstudio", - "custom": "openai", # custom base_url → OpenAI-compatible adapter - "auto": "aws_bedrock", # resolved later in is_available(); placeholder -} - -_PROVIDER_DEFAULTS: dict = { - "aws_bedrock": ("us.anthropic.claude-haiku-4-5-20251001-v1:0", - "aws_bedrock", "amazon.titan-embed-text-v2:0", 1024), - # --- ordering note: openai is the last-resort default (most widely available) --- - "openai": ("gpt-4o-mini", "openai", "text-embedding-3-small", 1536), - "anthropic": ("claude-haiku-4-5-20251001", "openai", "text-embedding-3-small", 1536), - "ollama": ("llama3.1", "ollama", "nomic-embed-text", 768), - "lmstudio": ("llama-3.2-1b-instruct", "openai", "text-embedding-nomic-embed-text-v1.5", 768), - "openrouter": ("openai/gpt-4o-mini", "openai", "text-embedding-3-small", 1536), -} - - -def _normalise_provider(p: str) -> str: - p = (p or "").strip().lower() - return _HERMES_TO_MEM0_PROVIDER.get(p, p) or "openai" - - -def _default_model_for(mem0_provider: str) -> str: - return _PROVIDER_DEFAULTS.get(mem0_provider, _PROVIDER_DEFAULTS["openai"])[0] - - -def _default_embedder_provider(mem0_provider: str) -> str: - return _PROVIDER_DEFAULTS.get(mem0_provider, _PROVIDER_DEFAULTS["openai"])[1] - - -def _default_embedder_model(mem0_emb_provider: str) -> str: - for _llm_p, (_, emb_p, emb_m, _) in _PROVIDER_DEFAULTS.items(): - if emb_p == mem0_emb_provider: - return emb_m - return "text-embedding-3-small" - - -def _default_embedder_dims(mem0_emb_provider: str) -> int: - for _llm_p, (_, emb_p, _, emb_d) in _PROVIDER_DEFAULTS.items(): - if emb_p == mem0_emb_provider: - return emb_d - return 1536 - - -def _build_mem0_config(cfg: dict) -> dict: - """Build a mem0 MemoryConfig-compatible dict from our flattened config. - - Translates Hermes/mem0 provider names into the provider-specific config - structures that mem0ai expects, including credentials and base URLs. - """ - llm_provider = cfg["llm_provider"] - llm_model = cfg["llm_model"] - embedder_provider = cfg["embedder_provider"] - embedder_model = cfg["embedder_model"] - embedder_dims = cfg["embedder_dims"] - api_key = cfg.get("api_key") or cfg.get("openai_api_key") or "" - base_url = cfg.get("base_url") or cfg.get("openai_base_url") or "" - - llm_cfg = _build_llm_cfg(llm_provider, llm_model, api_key, base_url) - emb_cfg = _build_embedder_cfg(embedder_provider, embedder_model, embedder_dims, api_key, base_url) - - vs_cfg = { - "collection_name": cfg["collection"], - "path": cfg["vector_store_path"], - "embedding_model_dims": embedder_dims, - "on_disk": True, - } - - return { - "vector_store": { - "provider": "qdrant", - "config": vs_cfg, - }, - "llm": { - "provider": llm_provider, - "config": llm_cfg, - }, - "embedder": { - "provider": embedder_provider, - "config": emb_cfg, - }, - "history_db_path": cfg["history_db_path"], - "version": "v1.1", - } - - -def _build_llm_cfg(provider: str, model: str, api_key: str, base_url: str) -> dict: - """Build the provider-specific LLM config dict for mem0ai.""" - cfg: dict = {"model": model} - - if provider == "aws_bedrock": - # Bedrock reads creds from env vars automatically; we don't pass them - # explicitly unless they're set (boto3 picks them up from the environment). - pass - - elif provider in ("openai", "anthropic", "lmstudio"): - if api_key: - cfg["api_key"] = api_key - if base_url and provider == "openai": - cfg["openai_base_url"] = base_url - - elif provider == "ollama": - # Ollama uses openai_base_url pointing at the local server - cfg["openai_base_url"] = base_url or "http://localhost:11434" - - # openrouter is handled as openai with OR base URL — normalised upstream, - # so if it reaches here with provider=="openai" it already has base_url set. - - return cfg - - -def _build_embedder_cfg(provider: str, model: str, dims: int, - api_key: str, base_url: str) -> dict: - """Build the provider-specific embedder config dict for mem0ai.""" - cfg: dict = {"model": model} - - if provider == "aws_bedrock": - cfg["embedding_dims"] = dims - - elif provider in ("openai",): - cfg["embedding_dims"] = dims - if api_key: - cfg["api_key"] = api_key - if base_url: - cfg["openai_base_url"] = base_url - - elif provider == "ollama": - cfg["embedding_dims"] = dims - cfg["ollama_base_url"] = base_url or "http://localhost:11434" - - elif provider == "lmstudio": - cfg["embedding_dims"] = dims - if api_key: - cfg["api_key"] = api_key - - return cfg - - -# --------------------------------------------------------------------------- -# Tool schemas -# --------------------------------------------------------------------------- - -SEARCH_SCHEMA = { - "name": "mem0_oss_search", - "description": ( - "Search long-term memory using semantic similarity. Returns facts and context " - "ranked by relevance. Use this when you need information from past sessions " - "that is not already in the current conversation." - ), - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "What to search for."}, - "top_k": { - "type": "integer", - "description": "Max results (default: 10, max: 50).", - }, - }, - "required": ["query"], - }, -} - -ADD_SCHEMA = { - "name": "mem0_oss_add", - "description": ( - "Store a fact, preference, or piece of context to long-term memory. " - "mem0 deduplicates automatically — safe to call for any important detail." - ), - "parameters": { - "type": "object", - "properties": { - "content": {"type": "string", "description": "The information to store."}, - }, - "required": ["content"], - }, -} - - -# --------------------------------------------------------------------------- -# Provider class -# --------------------------------------------------------------------------- - -class Mem0OSSMemoryProvider(MemoryProvider): - """Self-hosted mem0 memory provider backed by a local Qdrant vector store. - - No cloud account required — all data stays on disk. Uses AWS Bedrock - (or OpenAI / Ollama) for LLM fact-extraction and embedding. - """ - - def __init__(self): - # Config / identity - self._cfg: dict = {} - self._user_id: str = "hermes-user" - self._top_k: int = 10 - self._session_id: str = "" - self._agent_context: str = "primary" - # Circuit-breaker state (lock-protected) - self._lock = threading.Lock() - self._fail_count: int = 0 - self._last_fail_ts: float = 0.0 - # Background thread state - self._sync_thread: Optional[threading.Thread] = None - self._prefetch_thread: Optional[threading.Thread] = None - self._prefetch_result: str = "" - - # -- MemoryProvider identity -------------------------------------------- - - @property - def name(self) -> str: - return "mem0_oss" - - # -- Availability ------------------------------------------------------- - - def is_available(self) -> bool: - """True if mem0ai is installed and at least one LLM backend is usable. - - We only check imports and credentials — no network calls here. - """ - try: - import mem0 # noqa: F401 - except ImportError: - return False - - cfg = _load_config() - llm_provider = cfg.get("llm_provider", "openai") - - if llm_provider == "aws_bedrock": - if os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_PROFILE"): - return True - try: - from agent.bedrock_adapter import has_aws_credentials - return has_aws_credentials() - except Exception: - return False - if llm_provider == "anthropic": - return bool( - cfg.get("api_key") - or os.environ.get("ANTHROPIC_API_KEY") - ) - if llm_provider == "openai": - return bool( - cfg.get("api_key") - or cfg.get("openai_api_key") - or os.environ.get("OPENAI_API_KEY") - ) - if llm_provider in ("ollama", "lmstudio"): - return True # local, always assumed available - # Generic / custom base_url: trust the user's config - return True - - # -- Lifecycle ---------------------------------------------------------- - - def initialize(self, session_id: str, **kwargs) -> None: - """Build the mem0 Memory instance for this session.""" - self._session_id = session_id - self._agent_context = kwargs.get("agent_context", "primary") - self._cfg = _load_config() - self._user_id = self._cfg["user_id"] - self._top_k = self._cfg["top_k"] - # Reset circuit-breaker and prefetch state for this session. - # (Lock is created in __init__ and reused across sessions.) - with self._lock: - self._fail_count = 0 - self._last_fail_ts = 0.0 - self._prefetch_result = "" - import pathlib - pathlib.Path(self._cfg["vector_store_path"]).mkdir(parents=True, exist_ok=True) - pathlib.Path(self._cfg["history_db_path"]).parent.mkdir(parents=True, exist_ok=True) - - def _get_memory(self) -> Any: - """Create a fresh mem0 Memory instance for each call. - - We intentionally do NOT cache the instance. The embedded Qdrant store - uses a portalocker (fcntl) exclusive lock that is held for the lifetime - of the client object. When both the WebUI and the gateway run on the - same host they compete for this lock. - - We retry up to _LOCK_RETRY_ATTEMPTS times with _LOCK_RETRY_DELAY_S - seconds between attempts so that brief overlaps (e.g. a concurrent - prefetch in another process) are automatically resolved. - """ - import time as _time - - last_exc: Optional[Exception] = None - for attempt in range(_LOCK_RETRY_ATTEMPTS): - try: - from mem0 import Memory - from mem0.configs.base import MemoryConfig - - mem0_dict = _build_mem0_config(self._cfg) - mem_cfg = MemoryConfig(**{ - "vector_store": mem0_dict["vector_store"], - "llm": mem0_dict["llm"], - "embedder": mem0_dict["embedder"], - "history_db_path": mem0_dict["history_db_path"], - "version": mem0_dict["version"], - }) - return Memory(config=mem_cfg) - except Exception as exc: - last_exc = exc - if _QDRANT_LOCK_ERROR in str(exc): - if attempt < _LOCK_RETRY_ATTEMPTS - 1: - import random as _random - jitter = _random.uniform(0, _LOCK_RETRY_DELAY_S * 0.5) - delay = _LOCK_RETRY_DELAY_S + jitter - logger.debug( - "mem0_oss: Qdrant lock busy (attempt %d/%d), retrying in %.2fs", - attempt + 1, _LOCK_RETRY_ATTEMPTS, delay, - ) - _time.sleep(delay) - continue - # Last attempt also a lock error — fall through to raise below - else: - # Non-lock error — fail fast, no retry - logger.error("mem0_oss: failed to initialize Memory: %s", exc) - raise - logger.warning( - "mem0_oss: Qdrant lock still held after %d attempts — giving up: %s", - _LOCK_RETRY_ATTEMPTS, last_exc, - ) - raise last_exc # type: ignore[misc] - - # -- Circuit breaker helpers ------------------------------------------- - - def _is_tripped(self) -> bool: - with self._lock: - if self._fail_count < _BREAKER_THRESHOLD: - return False - if time.monotonic() - self._last_fail_ts >= _BREAKER_COOLDOWN_SECS: - self._fail_count = 0 - return False - return True - - def _record_failure(self) -> None: - with self._lock: - self._fail_count += 1 - self._last_fail_ts = time.monotonic() - - def _record_success(self) -> None: - with self._lock: - self._fail_count = 0 - - # -- System prompt block ----------------------------------------------- - - def system_prompt_block(self) -> str: - return ( - "## Mem0 OSS Memory (self-hosted)\n" - "You have access to long-term memory stored locally via mem0.\n" - "- Use `mem0_oss_search` to recall relevant facts before answering.\n" - "- Use `mem0_oss_add` to store important new facts, preferences, or context.\n" - "- Facts are extracted and deduplicated automatically on each turn.\n" - "- Search is semantic — natural-language queries work well.\n" - ) - - # -- Prefetch (background recall before each turn) --------------------- - - def queue_prefetch(self, query: str, *, session_id: str = "") -> None: - """Start a background thread to recall context for the upcoming turn.""" - if self._is_tripped(): - return - - self._prefetch_result = "" - self._prefetch_thread = threading.Thread( - target=self._do_prefetch, - args=(query,), - daemon=True, - name="mem0-oss-prefetch", - ) - self._prefetch_thread.start() - - def _do_prefetch(self, query: str) -> None: - try: - mem = self._get_memory() - results = mem.search( - query=query[:500], - top_k=self._top_k, - filters={"user_id": self._user_id}, - ) - del mem # release Qdrant lock ASAP — before any further processing - memories = _extract_results(results) - if memories: - lines = "\n".join(f"- {m}" for m in memories) - self._prefetch_result = f"Mem0 OSS Memory:\n{lines}" - self._record_success() - except Exception as exc: - if _QDRANT_LOCK_ERROR in str(exc): - logger.debug("mem0_oss: prefetch skipped — Qdrant lock held by another process") - return # not a real failure; don't trip the circuit breaker - self._record_failure() - logger.debug("mem0_oss: prefetch error: %s", exc) - - def prefetch(self, query: str, *, session_id: str = "") -> str: - """Return prefetched results (join background thread first).""" - if self._prefetch_thread is not None: - self._prefetch_thread.join(timeout=15.0) - self._prefetch_thread = None - return self._prefetch_result - - # -- Sync turn (auto-extract after each turn) -------------------------- - - def sync_turn( - self, user_content: str, assistant_content: str, *, session_id: str = "" - ) -> None: - """Spawn a background thread to extract and store facts from the turn.""" - if self._agent_context != "primary": - return - if self._is_tripped(): - return - - messages = [ - {"role": "user", "content": user_content}, - {"role": "assistant", "content": assistant_content}, - ] - self._sync_thread = threading.Thread( - target=self._do_sync, - args=(messages,), - daemon=True, - name="mem0-oss-sync", - ) - self._sync_thread.start() - - def _do_sync(self, messages: List[dict]) -> None: - try: - mem = self._get_memory() - mem.add(messages=messages, user_id=self._user_id, infer=True) - del mem # release Qdrant lock ASAP - self._record_success() - except Exception as exc: - if _QDRANT_LOCK_ERROR in str(exc): - logger.debug("mem0_oss: sync_turn skipped — Qdrant lock held by another process") - return # not a real failure; don't trip the circuit breaker - self._record_failure() - logger.debug("mem0_oss: sync_turn error: %s", exc) - - # -- Tool schemas & dispatch ------------------------------------------- - - def get_tool_schemas(self) -> List[dict]: - return [SEARCH_SCHEMA, ADD_SCHEMA] - - def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: - if tool_name == "mem0_oss_search": - return self._handle_search(args) - if tool_name == "mem0_oss_add": - return self._handle_add(args) - return tool_error(f"Unknown tool: {tool_name}") - - def _handle_search(self, args: Dict[str, Any]) -> str: - query = args.get("query", "").strip() - if not query: - return tool_error("mem0_oss_search requires 'query'") - - top_k = min(int(args.get("top_k", self._top_k)), 50) - - try: - mem = self._get_memory() - results = mem.search( - query=query, - top_k=top_k, - filters={"user_id": self._user_id}, - ) - del mem # release Qdrant lock ASAP - memories = _extract_results(results) - self._record_success() - if not memories: - return json.dumps({"result": "No relevant memories found."}) - return json.dumps({"result": "\n".join(f"- {m}" for m in memories)}) - except Exception as exc: - if _QDRANT_LOCK_ERROR in str(exc): - self._record_failure() # already handled by retry in _get_memory, but track it - logger.warning("mem0_oss: Qdrant lock held by another process — search skipped") - return json.dumps({"result": "Memory temporarily unavailable (storage locked by another process)."}) - self._record_failure() - logger.error("mem0_oss: search error: %s", exc) - return tool_error(f"mem0_oss_search failed: {exc}") - - def _handle_add(self, args: Dict[str, Any]) -> str: - content = args.get("content", "").strip() - if not content: - return tool_error("mem0_oss_add requires 'content'") - - try: - mem = self._get_memory() - mem.add( - messages=[{"role": "user", "content": content}], - user_id=self._user_id, - infer=True, - ) - del mem # release Qdrant lock ASAP - self._record_success() - return json.dumps({"result": "Memory stored successfully."}) - except Exception as exc: - if _QDRANT_LOCK_ERROR in str(exc): - self._record_failure() - logger.warning("mem0_oss: Qdrant lock held by another process — add skipped") - return json.dumps({"result": "Memory temporarily unavailable (storage locked by another process)."}) - self._record_failure() - logger.error("mem0_oss: add error: %s", exc) - return tool_error(f"mem0_oss_add failed: {exc}") - - # -- Config schema (for setup wizard) ---------------------------------- - - def get_config_schema(self) -> List[dict]: - return [ - { - "key": "llm_provider", - "label": "LLM provider", - "description": "mem0 LLM provider key (openai, aws_bedrock, ollama, ...)", - "default": "openai", - "env": "MEM0_OSS_LLM_PROVIDER", - "required": False, - }, - { - "key": "llm_model", - "label": "LLM model", - "description": "Model id passed to the LLM provider", - "default": "gpt-4o-mini", - "env": "MEM0_OSS_LLM_MODEL", - "required": False, - }, - { - "key": "embedder_provider", - "label": "Embedder provider", - "description": "mem0 embedder provider key (openai, aws_bedrock, ...)", - "default": "openai", - "env": "MEM0_OSS_EMBEDDER_PROVIDER", - "required": False, - }, - { - "key": "embedder_model", - "label": "Embedding model id", - "description": "Embedding model id", - "default": "text-embedding-3-small", - "env": "MEM0_OSS_EMBEDDER_MODEL", - "required": False, - }, - { - "key": "embedder_dims", - "label": "Embedding dimensions", - "description": "Dimensions of the embedding model (must match the model)", - "default": 1024, - "env": "MEM0_OSS_EMBEDDER_DIMS", - "required": False, - }, - { - "key": "collection", - "label": "Qdrant collection name", - "description": "Name of the Qdrant collection storing memories", - "default": "hermes", - "env": "MEM0_OSS_COLLECTION", - "required": False, - }, - { - "key": "user_id", - "label": "User ID", - "description": "Memory namespace / user identifier", - "default": "hermes-user", - "env": "MEM0_OSS_USER_ID", - "required": False, - }, - { - "key": "top_k", - "label": "Top-K results", - "description": "Default number of memories returned per search", - "default": 10, - "env": "MEM0_OSS_TOP_K", - "required": False, - }, - { - "key": "api_key", - "label": "API key (mem0 LLM)", - "description": ( - "Dedicated API key for mem0 LLM/embedder calls. " - "Takes precedence over auxiliary.mem0_oss.api_key in config.yaml " - "and over OPENAI_API_KEY / ANTHROPIC_API_KEY. " - "Not needed for AWS Bedrock (uses AWS_ACCESS_KEY_ID)." - ), - "default": "", - "env": "MEM0_OSS_API_KEY", - "secret": True, - "required": False, - }, - { - "key": "openai_api_key", - "label": "API key (legacy alias)", - "description": "Legacy alias for api_key — prefer MEM0_OSS_API_KEY.", - "default": "", - "env": "MEM0_OSS_OPENAI_API_KEY", - "secret": True, - "required": False, - }, - { - "key": "base_url", - "label": "OpenAI-compatible base URL", - "description": ( - "Custom LLM endpoint (e.g. http://localhost:11434/v1 for Ollama, " - "or an OpenRouter-compatible URL). Also settable via " - "auxiliary.mem0_oss.base_url in config.yaml." - ), - "default": "", - "env": "MEM0_OSS_OPENAI_BASE_URL", - "required": False, - }, - ] - - def save_config(self, values: dict, hermes_home) -> None: - """Write non-secret config to $HERMES_HOME/mem0_oss.json. - - Merges ``values`` into any existing file so that only the supplied keys - are overwritten. Secret keys (api_key, openai_api_key) should be stored - in ``.env`` instead; this method stores them only if explicitly passed. - """ - import json - from pathlib import Path - - config_path = Path(hermes_home) / "mem0_oss.json" - existing: dict = {} - if config_path.exists(): - try: - existing = json.loads(config_path.read_text(encoding="utf-8")) - except Exception: - pass - existing.update(values) - config_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") - - # -- Shutdown ---------------------------------------------------------- - - def on_memory_write(self, action: str, target: str, content: str) -> None: - """Mirror built-in memory tool writes into mem0 store. - - Called by the framework whenever the agent uses the builtin memory tool, - so writes go to mem0 automatically without the agent needing to call - mem0_oss_add explicitly. - """ - if action != "add" or not (content or "").strip(): - return - - def _write(): - try: - mem = self._get_memory() - mem.add( - messages=[{"role": "user", "content": content.strip()}], - user_id=self._user_id, - infer=False, - metadata={"source": "hermes_memory_tool", "target": target}, - ) - except Exception as e: - if _QDRANT_LOCK_ERROR in str(e): - logger.debug("mem0_oss on_memory_write skipped — Qdrant lock held by another process") - return - logger.debug("mem0_oss on_memory_write failed: %s", e) - - t = threading.Thread(target=_write, daemon=True, name="mem0-oss-memwrite") - t.start() - - def shutdown(self) -> None: - """Wait for any in-flight background threads.""" - for thread in (self._sync_thread, self._prefetch_thread): - if thread is not None and thread.is_alive(): - thread.join(timeout=10.0) - - -# --------------------------------------------------------------------------- -# Result extraction helper -# --------------------------------------------------------------------------- - -def _extract_results(results: Any) -> List[str]: - """Normalize mem0 search results (v1 list or v2 dict) to plain strings.""" - if isinstance(results, dict) and "results" in results: - items = results["results"] - elif isinstance(results, list): - items = results - else: - return [] - - memories = [] - for item in items: - if isinstance(item, dict): - mem = item.get("memory") or item.get("text") or "" - else: - mem = str(item) - if mem: - memories.append(mem) - return memories - - -# --------------------------------------------------------------------------- -# Plugin registration -# --------------------------------------------------------------------------- - -def register(ctx) -> None: - ctx.register_memory_provider(Mem0OSSMemoryProvider()) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml b/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml deleted file mode 100644 index 81f59b4..0000000 --- a/packages/fox-overlay/fox_overlay/agent_plugins/mem0_oss/plugin.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: mem0_oss -version: 1.0.0 -kind: backend -description: "Mem0 OSS — self-hosted LLM fact extraction with local Qdrant vector store. No cloud account required." -pip_dependencies: - - mem0ai - - qdrant-client diff --git a/packages/fox-overlay/pyproject.toml b/packages/fox-overlay/pyproject.toml index e368db0..eb53de9 100644 --- a/packages/fox-overlay/pyproject.toml +++ b/packages/fox-overlay/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [] # entry-point points at a `register(ctx: PluginContext) -> None` callable # that hermes-agent invokes once per process at plugin-load time. fox_overlay = "fox_overlay.agent_plugins:register" -mem0_oss = "fox_overlay.agent_plugins.mem0_oss:register" [build-system] requires = ["setuptools>=68", "wheel"] diff --git a/packages/fox-overlay/tests/test_agent_overlay.py b/packages/fox-overlay/tests/test_agent_overlay.py new file mode 100644 index 0000000..1dd439d --- /dev/null +++ b/packages/fox-overlay/tests/test_agent_overlay.py @@ -0,0 +1,74 @@ +"""Phase 3 monkey-patch regression tests. + +Each test verifies that the corresponding upstream symbol carries its +fox-overlay sentinel attribute after ``register()`` runs. Anchor drift +in upstream → ``substitute_function`` raises AssertionError at register +time → fixture fails → these tests fail loudly with the offending +target named in the traceback. + +Requires hermes-agent installed (so upstream modules are importable). +The container CI/QA smoke environment satisfies this; the local +development venv typically does not — those runs are skipped via +``pytest.importorskip``. + +Anti-regression Rule 3: this file is the seed of the agent-side +regression suite for Phase 3. Phases 4-6 add webui-side tests +alongside. +""" +import pytest + +# Skip the whole module if hermes-agent isn't installed (local dev without +# the full stack). Container CI/QA invokes this with hermes-agent present. +pytest.importorskip("hermes_cli.runtime_provider") +pytest.importorskip("agent.auxiliary_client") +pytest.importorskip("cron.jobs") +pytest.importorskip("cron.scheduler") +pytest.importorskip("tools.cronjob_tools") + +from agent import auxiliary_client # noqa: E402 +from cron import jobs, scheduler # noqa: E402 +from hermes_cli import runtime_provider # noqa: E402 +from tools import cronjob_tools # noqa: E402 + +from fox_overlay.agent_plugins import register # noqa: E402 + + +SENTINELS = [ + (runtime_provider, "resolve_runtime_provider", "_fox_patched_target_model"), + (auxiliary_client, "_resolve_task_provider_model", "_fox_patched_provider_auto_fallback"), + (auxiliary_client, "_get_auxiliary_task_config", "_fox_patched_auxiliary_default_fallback"), + (jobs, "create_job", "_fox_patched_create_job_failure_history"), + (jobs, "mark_job_run", "_fox_patched_mark_job_run_failure_history"), + (scheduler, "run_job", "_fox_patched_run_job_diagnostics"), + (scheduler, "tick", "_fox_patched_tick_structured_failure"), + (cronjob_tools, "_format_job", "_fox_patched_format_job_failure_history"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _apply_patches(): + """Invoke fox-overlay register() once for the whole module run.""" + register(None) + + +@pytest.mark.parametrize( + "module, function_name, sentinel", + SENTINELS, + ids=[f"{m.__name__}.{fn}" for m, fn, _ in SENTINELS], +) +def test_monkey_patch_sentinel_set(module, function_name, sentinel): + """Each monkey-patch sets its sentinel attribute on the upstream symbol.""" + fn = getattr(module, function_name) + assert getattr(fn, sentinel, False) is True, ( + f"{module.__name__}.{function_name} missing {sentinel} sentinel — " + "monkey-patch did not apply (anchor may have drifted in upstream)" + ) + + +def test_register_is_idempotent(): + """Repeat register() calls are safe — sentinel short-circuits each apply().""" + register(None) + register(None) + for module, function_name, sentinel in SENTINELS: + fn = getattr(module, function_name) + assert getattr(fn, sentinel, False) is True