diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 0a8390a7a5fe..272d05ef825b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1294,7 +1294,7 @@ async def handle_message(self, event: MessageEvent) -> None: # session lifecycle and its cleanup races with the running task # (see PR #4926). cmd = event.get_command() - if cmd in ("approve", "deny", "status", "stop", "new", "reset"): + if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background"): logger.debug( "[%s] Command '/%s' bypassing active-session guard for %s", self.name, cmd, session_key, diff --git a/gateway/run.py b/gateway/run.py index b75b0e1f0b23..98ef8784b13f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1997,6 +1997,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return await self._handle_approve_command(event) return await self._handle_deny_command(event) + # /background must bypass the running-agent guard — it starts a + # parallel task and must never interrupt the active conversation. + if _cmd_def_inner and _cmd_def_inner.name == "background": + return await self._handle_background_command(event) + if event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) adapter = self.adapters.get(source.platform) diff --git a/plugins/memory/mnemoria/__init__.py b/plugins/memory/mnemoria/__init__.py new file mode 100644 index 000000000000..ca6c7497e9c7 --- /dev/null +++ b/plugins/memory/mnemoria/__init__.py @@ -0,0 +1,17 @@ +"""Mnemoria — cognitive memory plugin for hermes-agent. + +Wraps the mnemoria PyPI package as a pluggable MemoryProvider. + +Config env vars: + HERMES_MEMORY_MNEMORIA_ENABLED (bool) Enable this provider + HERMES_MEMORY_MNEMORIA_MODE (str) Profile: balanced (default) + HERMES_MNEMORIA_DB (path) SQLite db path (~/.hermes/mnemoria.db) + +MEMORY_SPEC notation: + C[t]: Constraint D[t]: Decision V[t]: Value + ?[t]: Unknown ✓[t]: Done ~[t]: Obsolete +""" + +from .provider import MnemoriaMemoryProvider + +__all__ = ["MnemoriaMemoryProvider"] diff --git a/plugins/memory/mnemoria/plugin.yaml b/plugins/memory/mnemoria/plugin.yaml new file mode 100644 index 000000000000..d59b7b29af16 --- /dev/null +++ b/plugins/memory/mnemoria/plugin.yaml @@ -0,0 +1,28 @@ +name: mnemoria +version: 0.1.0 +description: | + Mnemoria memory provider — cognitive memory with ACT-R activation scoring, + typed facts, Hebbian links, and RL reranking. Backed by SQLite. + + Install: pip install mnemoria + Enable: set memory.provider: mnemoria in ~/.hermes/config.yaml +provider: mnemoria +memory_provider: true +pip_dependencies: + - mnemoria>=0.1.0 + +config: + HERMES_MEMORY_MNEMORIA_ENABLED: + type: bool + default: false + description: Enable the Mnemoria memory provider. + HERMES_MEMORY_MNEMORIA_MODE: + type: string + default: balanced + description: Memory profile — balanced (default). + choices: + - balanced + HERMES_MNEMORIA_DB: + type: path + default: ~/.hermes/mnemoria.db + description: SQLite database path for the Mnemoria memory store. diff --git a/plugins/memory/mnemoria/provider.py b/plugins/memory/mnemoria/provider.py new file mode 100644 index 000000000000..fd55b4081989 --- /dev/null +++ b/plugins/memory/mnemoria/provider.py @@ -0,0 +1,571 @@ +"""MnemoriaMemoryProvider — MemoryProvider adapter for the Mnemoria cognitive memory system. + +Wraps mnemoria.store.MnemoriaStore as a pluggable hermes-agent memory provider. +Uses threading.local() for per-thread store instances. +""" + +from __future__ import annotations + +import logging +import os +import threading +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +from agent.memory_provider import MemoryProvider + +logger = logging.getLogger(__name__) + +# ----------------------------------------------------------------------- +# Per-thread store pool — mirrors pattern from mnemoria integration +# ----------------------------------------------------------------------- + +_DB_PATH = str(Path(os.getenv( + "HERMES_MNEMORIA_DB", + str(Path.home() / ".hermes" / "mnemoria.db") +))) + +_UM_AVAILABLE = False +_MnemoriaStore = None +_MnemoriaConfig = None + +try: + from mnemoria.store import MnemoriaStore + from mnemoria.config import MnemoriaConfig + _UM_AVAILABLE = True + _MnemoriaStore = MnemoriaStore + _MnemoriaConfig = MnemoriaConfig +except ImportError: + logger.warning("mnemoria package not available — MnemoriaMemoryProvider disabled") + + +_local = threading.local() + + +def _store() -> "MnemoriaStore": + """Return a per-thread MnemoriaStore (lazy init).""" + if not getattr(_local, "store", None): + config = _MnemoriaConfig.balanced() if _MnemoriaConfig else None + if config: + config.db_path = _DB_PATH + _local.store = _MnemoriaStore(config=config, db_path=_DB_PATH) + return _local.store + + +# ----------------------------------------------------------------------- +# Default session ID — one UUID per process; callers can override per call +# ----------------------------------------------------------------------- + +_SESSION_ID = str(uuid.uuid4()) + + +def _resolve_session(kwargs: dict) -> str: + return kwargs.get("session_id") or _SESSION_ID + + +# ----------------------------------------------------------------------- +# Tool schemas — Mnemoria memory tools +# ----------------------------------------------------------------------- + +_TOOL_SCHEMAS: List[Dict[str, Any]] = [ + { + "name": "mcp_umemory_write", + "description": ( + "Store a new fact/memory in the Mnemoria memory store.\n" + "Accepts plain text OR MEMORY_SPEC notation: TYPE[target]: content\n" + "Types: C=constraint D=decision V=value ?=unknown \u2713=done ~=obsolete\n" + "Examples:\n" + " C[db.id]: UUID mandatory, never autoincrement\n" + " D[auth]: JWT 7d refresh 6d\n" + " V[api.prod]: api.example.com:3005\n" + " \u2713[auth]: deployed to prod\n" + "Returns fact_id, gauge%, and superseded_id if any." + ), + "parameters": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Fact content, optionally in MEMORY_SPEC notation.", + }, + "scope": { + "type": "string", + "description": "Scope label (e.g. 'auth-refactor'). Defaults to 'global'.", + }, + "type": { + "type": "string", + "description": "Fact type: C, D, V, ?, \u2713, ~. Auto-detected from notation if omitted.", + }, + "target": { + "type": "string", + "description": "Target label (e.g. 'auth'). Auto-detected from notation if omitted.", + }, + }, + "required": ["content"], + }, + }, + { + "name": "mcp_umemory_recall", + "description": ( + "Recall memories matching a query using semantic similarity and ACT-R activation scoring.\n" + "Uses 4-signal fusion: embedding similarity + ACT-R activation + FTS5/BM25 + Q-value reranking.\n" + "Returns top-K facts ranked by relevance." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural-language query describing what to recall.", + }, + "scope": { + "type": "string", + "description": "Optional scope filter. Omit to search all memories.", + }, + "top_k": { + "type": "integer", + "description": "Maximum number of results (default 10, max 30).", + "default": 10, + }, + }, + "required": ["query"], + }, + }, + { + "name": "mcp_umemory_search", + "description": ( + "Fast FTS5 keyword search over facts (direct, no activation scoring).\n" + "Use for exact keyword lookup or when recall is too slow." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query, e.g. 'UUID database'.", + }, + "scope": { + "type": "string", + "description": "Restrict search to a specific scope label.", + }, + "limit": { + "type": "integer", + "description": "Max results (1-50, default 10).", + "default": 10, + }, + }, + "required": ["query"], + }, + }, + { + "name": "mcp_umemory_reflect", + "description": ( + "Synthesize all facts related to a topic, grouped by type.\n" + "Use before making a decision on a topic with long history. Read-only.\n" + "Returns facts grouped by type: Constraints / Decisions / Values / etc." + ), + "parameters": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic to reflect on, e.g. 'auth'.", + }, + "limit": { + "type": "integer", + "description": "Max facts to include (default 20, max 50).", + "default": 20, + }, + }, + "required": ["topic"], + }, + }, + { + "name": "mcp_umemory_reward", + "description": ( + "Give feedback on whether a retrieved memory was useful (RL reward signal).\n" + "Trains Q-value reranking: +1.0=cited +0.5=referenced -0.15=irrelevant." + ), + "parameters": { + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "Memory ID (first 8 chars from recall/explore results).", + }, + "signal": { + "type": "number", + "description": "Reward signal in range -1.0 to +1.0.", + }, + }, + "required": ["memory_id", "signal"], + }, + }, + { + "name": "mcp_umemory_explore", + "description": ( + "Multi-hop memory exploration via Personalized PageRank (PPR).\n" + "Follows link connections to discover related memories.\n" + "Use for complex questions requiring multiple memory combinations." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural-language query describing what to explore.", + }, + "scope": { + "type": "string", + "description": "Optional scope filter.", + }, + "top_k": { + "type": "integer", + "description": "Maximum number of results (default 20, max 50).", + "default": 20, + }, + }, + "required": ["query"], + }, + }, + { + "name": "mcp_umemory_stats", + "description": ( + "Return store statistics: fact count, link count, scope count, and gauge percentage.\n" + "Use for a quick health check or to decide whether to consolidate." + ), + "parameters": {"type": "object", "properties": {}}, + }, + { + "name": "mcp_umemory_consolidate", + "description": ( + "Run a consolidation cycle.\n" + "Promotes frequently accessed working memories to core, demotes low-activation\n" + "core memories to archive, prunes dead archive memories, decays Hebbian links." + ), + "parameters": {"type": "object", "properties": {}}, + }, +] + + +# ----------------------------------------------------------------------- +# Type display helpers +# ----------------------------------------------------------------------- + +_TYPE_DISPLAY = { + "C": "C", "D": "D", "V": "V", "?": "?", + "\u2713": "\u2713", "~": "~", +} + + +def _type_sym(fact_type) -> str: + if hasattr(fact_type, "value"): + return fact_type.value + return str(fact_type) if fact_type else "V" + + +# ----------------------------------------------------------------------- +# MnemoriaMemoryProvider +# ----------------------------------------------------------------------- + +class MnemoriaMemoryProvider(MemoryProvider): + """Mnemoria cognitive memory system as a hermes-agent memory provider. + + Config env vars: + HERMES_MEMORY_MNEMORIA_ENABLED (bool) Enable this provider + HERMES_MEMORY_MNEMORIA_MODE (str) Profile: balanced (default) + HERMES_MNEMORIA_DB (path) SQLite db path + """ + + def __init__(self): + self._session_id: str = "" + self._hermes_home: str = "" + + @property + def name(self) -> str: + return "mnemoria" + + def is_available(self) -> bool: + """True when the mnemoria package is importable.""" + return _UM_AVAILABLE + + def initialize(self, session_id: str, **kwargs) -> None: + self._session_id = session_id + self._hermes_home = kwargs.get("hermes_home", os.path.expanduser("~/.hermes")) + + # Warm up the per-thread store (create tables if needed) + try: + _store() + logger.info("MnemoriaMemoryProvider initialized (session=%s)", session_id) + except Exception as exc: + logger.error("MnemoriaMemoryProvider init failed: %s", exc) + + def system_prompt_block(self) -> str: + """Return empty — injection is handled via prefetch().""" + return "" + + def prefetch(self, query: str, *, session_id: str = "") -> str: + """Recall relevant memories and format as injection text.""" + if not _UM_AVAILABLE: + return "" + if not query: + return "" + + try: + resolved_session = session_id or self._session_id or _SESSION_ID + s = _store() + results = s.recall(query, top_k=8) + + if not results: + return "" + + lines = ["[MNEMORIA MEMORY]"] + for r in results: + fact = r.fact + type_sym = _type_sym(fact.fact_type) + target = fact.target or "general" + lines.append(f"{type_sym}[{target}]: {fact.content}") + + return "\n".join(lines) + except Exception as exc: + logger.warning("MnemoriaMemoryProvider prefetch failed: %s", exc) + return "" + + def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: + """No-op: fact extraction is handled by the agent loop, same as BuiltinMemoryProvider.""" + pass + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + """Return the 8 Mnemoria memory tool schemas.""" + return _TOOL_SCHEMAS + + def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: + """Dispatch a Mnemoria memory tool call.""" + import json + + if not _UM_AVAILABLE: + return json.dumps({"error": "mnemoria package not available"}) + + session_id = _resolve_session(kwargs) + + handlers = { + "mcp_umemory_write": _handle_write, + "mcp_umemory_recall": _handle_recall, + "mcp_umemory_search": _handle_search, + "mcp_umemory_reflect": _handle_reflect, + "mcp_umemory_reward": _handle_reward, + "mcp_umemory_explore": _handle_explore, + "mcp_umemory_stats": _handle_stats, + "mcp_umemory_consolidate": _handle_consolidate, + } + + handler = handlers.get(tool_name) + if not handler: + return json.dumps({"error": f"Unknown tool: {tool_name}"}) + + try: + return handler(args, session_id=session_id) + except Exception as exc: + logger.error("handle_tool_call(%s) failed: %s", tool_name, exc) + return json.dumps({"error": str(exc)}) + + def shutdown(self) -> None: + """Close the per-thread store connection.""" + try: + store = getattr(_local, "store", None) + if store is not None: + store.conn.close() + _local.store = None + logger.info("MnemoriaMemoryProvider shutdown complete") + except Exception as exc: + logger.warning("MnemoriaMemoryProvider shutdown error: %s", exc) + + +# ----------------------------------------------------------------------- +# Tool handlers +# ----------------------------------------------------------------------- + +def _handle_write(args: dict, session_id: str = "") -> str: + import json + s = _store() + content = args.get("content", "").strip() + if not content: + return json.dumps({"error": "content is required"}) + + scope = args.get("scope") or "global" + fact_type = args.get("type") or None + target = args.get("target") or None + + fact_id = s.store(content, scope=scope, fact_type=fact_type, target=target) + stats = s.get_stats() + + return json.dumps({ + "fact_id": fact_id, + "gauge_pct": stats.get("gauge_pct", 0.0), + }) + + +def _handle_recall(args: dict, session_id: str = "") -> str: + import json + s = _store() + query = args.get("query", "").strip() + if not query: + return json.dumps({"error": "query is required"}) + + scope = args.get("scope") or None + top_k = min(int(args.get("top_k", 10)), 30) + + results = s.recall(query, scope=scope, top_k=top_k) + + if not results: + return json.dumps({"results": []}) + + return json.dumps({ + "results": [ + { + "fact_id": r.fact.id, + "type": _type_sym(r.fact.fact_type), + "target": r.fact.target, + "content": r.fact.content, + "score": round(r.score, 3), + } + for r in results + ] + }) + + +def _handle_search(args: dict, session_id: str = "") -> str: + import json + from mnemoria.retrieval import fts5_search + s = _store() + + query = args.get("query", "").strip() + if not query: + return json.dumps({"error": "query is required"}) + + scope = args.get("scope") or None + limit = min(int(args.get("limit", 10)), 50) + + scope_id = None + if scope and scope.lower() not in ("global", "none", ""): + scope_id = s._resolve_scope(scope) + + fts_scores = fts5_search(s.conn, query, scope_id=scope_id, limit=limit) + if not fts_scores: + return json.dumps({"results": []}) + + ids = list(fts_scores.keys()) + placeholders = ",".join("?" * len(ids)) + rows = s.conn.execute( + f"SELECT id, type, target, content, status FROM um_facts WHERE id IN ({placeholders})", + ids, + ).fetchall() + + rows_sorted = sorted(rows, key=lambda r: fts_scores.get(r["id"], 0), reverse=True) + + return json.dumps({ + "results": [ + { + "fact_id": r["id"], + "type": r["type"], + "target": r["target"], + "content": r["content"], + "status": r["status"], + } + for r in rows_sorted + ] + }) + + +def _handle_reflect(args: dict, session_id: str = "") -> str: + import json + s = _store() + topic = args.get("topic", "").strip() + if not topic: + return json.dumps({"error": "topic is required"}) + + limit = min(int(args.get("limit", 20)), 50) + results = s.recall(topic, top_k=limit) + + groups: Dict[str, list] = {} + for r in results: + fact = r.fact + type_sym = _type_sym(fact.fact_type) + target = fact.target or "general" + groups.setdefault(type_sym, []).append(f"[{target}]: {fact.content}") + + type_order = ["C", "D", "V", "\u2713", "~", "?"] + output = {"reflection": topic, "groups": {}} + for sym in type_order: + if sym in groups: + output["groups"][sym] = groups[sym] + + return json.dumps(output) + + +def _handle_reward(args: dict, session_id: str = "") -> str: + import json + s = _store() + memory_id = args.get("memory_id", "").strip() + if not memory_id: + return json.dumps({"error": "memory_id is required"}) + + signal = max(-1.0, min(1.0, float(args.get("signal", 0.0)))) + s.reward_memory(memory_id, signal) + + return json.dumps({"memory_id": memory_id, "signal": signal}) + + +def _handle_explore(args: dict, session_id: str = "") -> str: + import json + s = _store() + query = args.get("query", "").strip() + if not query: + return json.dumps({"error": "query is required"}) + + top_k = min(int(args.get("top_k", 20)), 50) + scope = args.get("scope") or None + + results = s.explore(query, top_k=top_k, scope=scope) + + return json.dumps({ + "results": [ + { + "fact_id": r.fact.id, + "type": _type_sym(r.fact.fact_type), + "target": r.fact.target, + "content": r.fact.content, + "score": round(r.score, 3), + } + for r in results + ] + }) + + +def _handle_stats(args: dict, session_id: str = "") -> str: + import json + s = _store() + stats = s.get_stats() + return json.dumps({ + "fact_count": stats.get("fact_count", 0), + "link_count": stats.get("link_count", 0), + "scope_count": stats.get("scope_count", 0), + "gauge_pct": stats.get("gauge_pct", 0.0), + "used_chars": stats.get("used_chars", 0), + "max_chars": stats.get("max_chars", 0), + }) + + +def _handle_consolidate(args: dict, session_id: str = "") -> str: + import json + s = _store() + report = s.consolidate() + stats = s.get_stats() + return json.dumps({ + "promoted": report.get("promoted", 0), + "demoted": report.get("demoted", 0), + "pruned": report.get("pruned", 0), + "links_pruned": report.get("links_pruned", 0), + "gauge_pct": stats.get("gauge_pct", 0.0), + }) diff --git a/pyproject.toml b/pyproject.toml index 8e637d821f06..8c622e623df9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ pty = [ "pywinpty>=2.0.0,<3; sys_platform == 'win32'", ] honcho = ["honcho-ai>=2.0.1,<3"] +mnemoria = ["mnemoria>=0.1.0"] mcp = ["mcp>=1.2.0,<2"] homeassistant = ["aiohttp>=3.9.0,<4"] sms = ["aiohttp>=3.9.0,<4"] diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index e90dee69c154..318b14dd825e 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -160,6 +160,22 @@ async def test_status_bypasses_guard(self): assert sk not in adapter._pending_messages assert any("handled:status" in r for r in adapter.sent_responses) + @pytest.mark.asyncio + async def test_background_bypasses_guard(self): + """/background must bypass so it spawns a parallel task, not an interrupt.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/background summarize HN")) + + assert sk not in adapter._pending_messages, ( + "/background was queued as a pending message instead of being dispatched" + ) + assert any("handled:background" in r for r in adapter.sent_responses), ( + "/background response was not sent back to the user" + ) + # --------------------------------------------------------------------------- # Tests: non-bypass messages still get queued diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 9ad8ba48b718..f96b6cd93a0e 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -81,6 +81,7 @@ from tools.browser_providers.browser_use import BrowserUseProvider from tools.browser_providers.firecrawl import FirecrawlProvider from tools.tool_backend_helpers import normalize_browser_cloud_provider +from tools.environments.local import _sanitize_subprocess_env # Camofox local anti-detection browser backend (optional). # When CAMOFOX_URL is set, all browser operations route through the @@ -924,7 +925,22 @@ def _run_browser_command( logger.debug("browser cmd=%s task=%s socket_dir=%s (%d chars)", command, task_id, task_socket_dir, len(task_socket_dir)) - browser_env = {**os.environ} + # Sanitize provider keys out of the subprocess environment, but + # force-preserve the browser-backend credentials the agent-browser CLI + # legitimately needs when configured for cloud mode. + _browser_force = { + f"_HERMES_FORCE_{k}": os.environ[k] + for k in ( + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "BROWSER_USE_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_BROWSER_TTL", + ) + if k in os.environ + } + browser_env = _sanitize_subprocess_env(os.environ, _browser_force) # Ensure PATH includes Hermes-managed Node first, Homebrew versioned # node dirs (for macOS ``brew install node@24``), then standard system dirs. diff --git a/tools/credential_files.py b/tools/credential_files.py index 3092b75e94ef..3cfdab159588 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -341,13 +341,17 @@ def iter_skills_files( # Cache directory mounts (documents, images, audio, screenshots) # --------------------------------------------------------------------------- -# The four cache subdirectories that should be mirrored into remote backends. +# The cache subdirectories that should be mirrored into remote backends. # Each tuple is (new_subpath, old_name) matching hermes_constants.get_hermes_dir(). _CACHE_DIRS: list[tuple[str, str]] = [ ("cache/documents", "document_cache"), ("cache/images", "image_cache"), ("cache/audio", "audio_cache"), ("cache/screenshots", "browser_screenshots"), + # Clipboard images saved by cli.py are written to ~/.hermes/images/ (not + # cache/images). Without this mount, pasted screenshots are invisible to + # agents running inside the Docker sandbox. + ("images", "images"), ] diff --git a/tools/rl_training_tool.py b/tools/rl_training_tool.py index 7a6478b42c9c..58c46ffe5a78 100644 --- a/tools/rl_training_tool.py +++ b/tools/rl_training_tool.py @@ -45,6 +45,7 @@ from typing import Any, Dict, List, Optional from hermes_constants import get_hermes_home +from tools.environments.local import _sanitize_subprocess_env logger = logging.getLogger(__name__) @@ -363,7 +364,12 @@ async def _spawn_training_run(run_state: RunState, config_path: Path): stdout=trainer_log_file, stderr=subprocess.STDOUT, cwd=str(TINKER_ATROPOS_ROOT), - env={**os.environ, "TINKER_API_KEY": os.getenv("TINKER_API_KEY", "")}, + env=_sanitize_subprocess_env(os.environ, { + # Force-preserve the RL-training credentials the trainer + # subprocess requires (both are on the sanitizer blocklist). + "_HERMES_FORCE_TINKER_API_KEY": os.getenv("TINKER_API_KEY", ""), + "_HERMES_FORCE_WANDB_API_KEY": os.getenv("WANDB_API_KEY", ""), + }), ) # Wait for trainer to initialize (it starts FastAPI inference server on 8001) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 2223032c32bc..a8793650da2a 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -421,16 +421,28 @@ async def vision_analyze_tool( response = await async_call_llm(**call_kwargs) analysis = extract_content_or_reasoning(response) + # If both attempts returned empty content, fail loudly so callers + # cannot mistake an error for a successful analysis. + if not analysis: + logger.error("Vision LLM returned empty content after retry") + result = { + "success": False, + "error": "Vision LLM returned empty content after retry. The model may not support vision or the image could not be processed.", + } + debug_call_data["success"] = False + _debug.log_call("vision_analyze_tool", debug_call_data) + _debug.save() + return json.dumps(result, indent=2, ensure_ascii=False) + analysis_length = len(analysis) - + logger.info("Image analysis completed (%s characters)", analysis_length) - - # Prepare successful response + result = { "success": True, - "analysis": analysis or "There was a problem with the request and the image could not be analyzed." + "analysis": analysis, } - + debug_call_data["success"] = True debug_call_data["analysis_length"] = analysis_length