diff --git a/.github/workflows/notify-monorepo.yml b/.github/workflows/notify-monorepo.yml index 0a6edb945d22..80316c0616ba 100644 --- a/.github/workflows/notify-monorepo.yml +++ b/.github/workflows/notify-monorepo.yml @@ -1,9 +1,15 @@ name: Notify Monorepo on Push +# Push trigger PAUSED for the Fox in the Box v0.6.0 upstream-separation +# migration (fox-in-the-box-ai/fox-in-the-box#155). The companion monorepo +# workflow `.github/workflows/sync-submodules.yml` is paused symmetrically +# for the same reason — auto-bumping the submodule pointer mid-migration +# would whipsaw the pin while phases 2-7 are in flight. +# +# Restored when v0.6.0 ships and the submodules re-point at virgin upstream +# (Phase 8). For manual dispatch during the migration, use workflow_dispatch. on: - push: - branches: - - main + workflow_dispatch: jobs: notify: diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 57808f64750d..6826476fdc60 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3046,15 +3046,6 @@ def _resolve_task_provider_model( if cfg_provider and cfg_provider != "auto": return cfg_provider, resolved_model, None, None, resolved_api_mode - # provider is "auto" (or unset) but config specified an explicit model - # (e.g. auxiliary.default.model = us.anthropic.claude-haiku-...). - # _resolve_auto ignores the model hint and always uses the main model, - # so we must resolve the provider explicitly here to honour cfg_model. - if resolved_model: - explicit_provider = _read_main_provider() or "auto" - if explicit_provider and explicit_provider != "auto": - return explicit_provider, resolved_model, None, None, resolved_api_mode - return "auto", resolved_model, None, None, resolved_api_mode return "auto", resolved_model, None, None, resolved_api_mode @@ -3064,11 +3055,7 @@ def _resolve_task_provider_model( def _get_auxiliary_task_config(task: str) -> Dict[str, Any]: - """Return the config dict for auxiliary., or {} when unavailable. - - Falls back to auxiliary.default when no task-specific config exists. - Task-specific keys win over default keys ({**default_config, **task_config}). - """ + """Return the config dict for auxiliary., or {} when unavailable.""" if not task: return {} try: @@ -3077,13 +3064,8 @@ def _get_auxiliary_task_config(task: str) -> Dict[str, Any]: except ImportError: return {} aux = config.get("auxiliary", {}) if isinstance(config, dict) else {} - if not isinstance(aux, dict): - return {} - default_config = aux.get("default", {}) - default_config = default_config if isinstance(default_config, dict) else {} task_config = aux.get(task, {}) if isinstance(aux, dict) else {} - task_config = task_config if isinstance(task_config, dict) else {} - return {**default_config, **task_config} + return task_config if isinstance(task_config, dict) else {} def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float: diff --git a/cron/jobs.py b/cron/jobs.py index 4b1518a4a916..6376260828cd 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -536,7 +536,6 @@ def create_job( "last_status": None, "last_error": None, "last_delivery_error": None, - "failure_history": [], # Rolling last-5 failures: [{at, error, session_id}] # Delivery configuration "deliver": deliver, "origin": origin, # Tracks where job was created for "origin" delivery @@ -697,16 +696,6 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, job["last_error"] = error if not success else None # Track delivery failures separately — cleared on successful delivery job["last_delivery_error"] = delivery_error - - # Maintain rolling failure history (last 5 entries). - # On success, clear it so the counter resets. - if not success: - _entry = {"at": now, "error": (error or "unknown")[:200]} - _hist = list(job.get("failure_history") or []) - _hist.append(_entry) - job["failure_history"] = _hist[-5:] # keep last 5 - else: - job["failure_history"] = [] # Increment completed count if job.get("repeat"): diff --git a/cron/scheduler.py b/cron/scheduler.py index 4248a2ecfb31..4672b24ba782 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -16,7 +16,6 @@ import os import subprocess import sys -import traceback # fcntl is Unix-only; on Windows use msvcrt for file locking try: @@ -1195,52 +1194,22 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: except Exception as e: error_msg = f"{type(e).__name__}: {str(e)}" logger.exception("Job '%s' failed: %s", job_name, error_msg) - - # Collect agent activity diagnostics if available - _diag_lines: list[str] = [] - if agent is not None: - try: - _act = agent.get_activity_summary() if hasattr(agent, "get_activity_summary") else {} - _api_calls = _act.get("api_call_count", "?") - _max_iter = _act.get("max_iterations", "?") - _last_act = _act.get("last_activity_desc", "unknown") - _idle = _act.get("seconds_since_activity", 0) - _diag_lines.append(f"**Iterations:** {_api_calls}/{_max_iter}") - _diag_lines.append(f"**Last activity:** {_last_act} ({int(_idle)}s ago)") - if _act.get("current_tool"): - _diag_lines.append(f"**Stuck on tool:** `{_act['current_tool']}`") - except Exception: - pass - - _tb = traceback.format_exc() - _diag_block = ("\n".join(_diag_lines) + "\n\n") if _diag_lines else "" - + output = f"""# Cron Job: {job_name} (FAILED) **Job ID:** {job_id} -**Session:** `{_cron_session_id}` **Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')} **Schedule:** {job.get('schedule_display', 'N/A')} -## Error - -``` -{error_msg} -``` - -## Agent Diagnostics +## Prompt -{_diag_block}**Session log:** `~/.hermes/sessions/session_{_cron_session_id}.json` +{prompt} -## Traceback +## Error ``` -{_tb} +{error_msg} ``` - -## Prompt - -{prompt} """ return False, output, "", error_msg @@ -1371,28 +1340,7 @@ def _process_job(job: dict) -> bool: # Deliver the final response to the origin/target chat. # If the agent responded with [SILENT], skip delivery (but # output is already saved above). Failed jobs always deliver. - if success: - deliver_content = final_response - else: - # Build a structured failure notification with enough - # context to diagnose without opening logs manually. - _fail_lines = [ - f"❌ **Cron job failed:** `{job.get('name', job['id'])}`", - f"**Error:** {error}", - ] - # Surface consecutive failure count if available - _history = job.get("failure_history") or [] - if len(_history) > 1: - _fail_lines.append(f"**Consecutive failures:** {len(_history)} (first: {_history[0].get('at', '?')[:16]})") - # Include session log path for easy debugging - _session_files = sorted( - (f for f in (Path(_hermes_home) / "sessions").glob(f"session_cron_{job['id']}_*.json") if f.is_file()), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - if _session_files: - _fail_lines.append(f"**Session log:** `{_session_files[0]}`") - deliver_content = "\n".join(_fail_lines) + deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" should_deliver = bool(deliver_content) if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper(): logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ce91635d122b..3afd67e1cc60 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1233,7 +1233,7 @@ def resolve_runtime_provider( # Dual-path routing: Claude models use AnthropicBedrock SDK for full # feature parity (prompt caching, thinking budgets, adaptive thinking). # Non-Claude models use the Converse API for multi-model support. - _current_model = target_model or str(model_cfg.get("default") or "").strip() + _current_model = str(model_cfg.get("default") or "").strip() if is_anthropic_bedrock_model(_current_model): # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path runtime = { diff --git a/plugins/memory/mem0_oss/README.md b/plugins/memory/mem0_oss/README.md deleted file mode 100644 index 8c10bdb6a683..000000000000 --- a/plugins/memory/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/plugins/memory/mem0_oss/__init__.py b/plugins/memory/mem0_oss/__init__.py deleted file mode 100644 index 27a476734bbd..000000000000 --- a/plugins/memory/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/plugins/memory/mem0_oss/plugin.yaml b/plugins/memory/mem0_oss/plugin.yaml deleted file mode 100644 index 7bdee95ca170..000000000000 --- a/plugins/memory/mem0_oss/plugin.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: mem0_oss -version: 1.0.0 -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/scripts/release.py b/scripts/release.py index 863123652401..46c117b9f245 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -39,6 +39,8 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + # Fox in the Box maintainer (this fork — v0.6.0 upstream-separation migration) + "roadhero@gmail.com": "roadhero", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "qiyin.zuo@pcitc.com": "qiyin-code", diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 486b156b1f66..53e778a7dbf2 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -234,14 +234,6 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: "paused_at": job.get("paused_at"), "paused_reason": job.get("paused_reason"), } - # Surface failure history and last error only when there are failures — - # keeps the list output clean for healthy jobs. - if job.get("last_status") == "error" or job.get("failure_history"): - result["last_error"] = job.get("last_error") - _hist = job.get("failure_history") or [] - if _hist: - result["failure_history"] = _hist - result["consecutive_failures"] = len(_hist) if job.get("script"): result["script"] = job["script"] if job.get("enabled_toolsets"):