{"repo":"NousResearch/hermes-agent","public_id":"2936","display_label":"Gateway session reasoning persistence and tool parsing fixes","summary":"These members address persisting and restoring assistant reasoning across gateway session turns, and fixing parsing of tool call XML from text content in the main agent loop. A related issue reports tool invocation not working via the Telegram gateway.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2974,"merged_at":"2026-03-25T16:47:29+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-03-25T16:47:29+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":2936,"pr_number":2974,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-03-25T16:47:29+00:00"}],"members":[{"kind":"issue","number":2936,"title":"Hermes-4 does not invoke tools via Telegram gateway despite tools being loaded","url":null,"state":"closed"},{"kind":"pr","number":2941,"title":"fix: persist and restore assistant reasoning across gateway session turns (#2936)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2974","number":2974}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":2974,"title":"feat: persist reasoning across gateway session turns (schema v6)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","cli-config.yaml.example","cli.py","gateway/run.py","gateway/session.py","hermes_cli/config.py","hermes_state.py","run_agent.py","tests/agent/test_context_compressor.py","tests/test_hermes_state.py","tools/browser_tool.py","tools/session_search_tool.py","tools/skills_guard.py","tools/vision_tools.py","website/docs/user-guide/features/hooks.md","website/docs/user-guide/features/plugins.md"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -40,7 +40,7 @@\n # Proportion of compressed content to allocate for summary\n _SUMMARY_RATIO = 0.20\n # Absolute ceiling for summary tokens (even on very large context windows)\n-_SUMMARY_TOKENS_CEILING = [redacted:secret-assignment]\n+_SUMMARY_TOKENS_CEILING = [redacted:secret-assignment]\n \n # Placeholder used when pruning old tool results\n _PRUNED_TOOL_PLACEHOLDER = \"[Old tool output cleared to save context space]\"","@@ -63,10 +63,10 @@ class ContextCompressor:\n     def __init__(\n         self,\n         model: str,\n-        threshold_percent: float = 0.80,\n+        threshold_percent: float = 0.50,\n         protect_first_n: int = 3,\n         protect_last_n: int = 20,\n-        summary_target_ratio: float = 0.40,\n+        summary_target_ratio: float = 0.20,\n         quiet_mode: bool = False,\n         summary_model_override: str = None,\n         base_url: str = \"\",","@@ -92,8 +92,8 @@ def __init__(\n         self.threshold_tokens = [redacted:secret-assignment] * threshold_percent)\n         self.compression_count = 0\n \n-        # Derive token budgets from the target ratio and context length\n-        target_tokens = [redacted:secret-assignment] * self.summary_target_ratio)\n+        # Derive token budgets: ratio is relative to the threshold, not total context\n+        target_tokens = [redacted:secret-assignment] * self.summary_target_ratio)\n         self.tail_token_budget = [redacted:secret-assignment]\n         self.max_summary_tokens = min(\n             int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,"]},{"file":"cli-config.yaml.example","hunks":["@@ -236,23 +236,24 @@ browser:\n # 5. Summarizes middle turns using a fast/cheap model\n # 6. Inserts summary as a user message, continues conversation seamlessly\n #\n-# Post-compression size scales with the model's context window via target_ratio:\n-#   MiniMax 200K context → ~80K post-compression (at 0.40 ratio)\n-#   GPT-5   1M   context → ~400K post-compression (at 0.40 ratio)\n+# Post-compression tail budget is target_ratio × threshold × context_length:\n+#   200K context, threshold 0.50, ratio 0.20 → 20K tokens of recent tail preserved\n+#   1M   context, threshold 0.50, ratio 0.20 → 100K tokens of recent tail preserved\n #\n compression:\n   # Enable automatic context compression (default: true)\n   # Set to false if you prefer to manage context manually or want errors on overflow\n   enabled: true\n   \n-  # Trigger compression at this % of model's context limit (default: 0.80 = 80%)\n+  # Trigger compression at this % of model's context limit (default: 0.50 = 50%)\n   # Lower values = more aggressive compression, higher values = compress later\n-  threshold: 0.80\n+  threshold: 0.50\n   \n-  # Target post-compression size as a fraction of context window (default: 0.40 = 40%)\n-  # Controls how much context survives compression. Tail token budget and summary\n-  # cap scale with this value. Range: 0.10 - 0.80\n-  target_ratio: 0.40\n+  # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%)\n+  # e.g. 20% of 50% threshold = 10% of total context kept as recent messages.\n+  # Summary output is separately capped at 12K tokens (Gemini output limit).\n+  # Range: 0.10 - 0.80\n+  target_ratio: 0.20\n \n   # Number of most-recent messages to always preserve (default: 20 ≈ 10 full turns)\n   # Higher values keep more recent conversation intact at the cost of more aggressive"]},{"file":"cli.py","hunks":["@@ -1509,10 +1509,14 @@ def _stream_reasoning_delta(self, text: str) -> None:\n \n         self._reasoning_buf = getattr(self, \"_reasoning_buf\", \"\") + text\n \n-        # Emit complete lines\n+        # Emit complete lines, and force-flush long partial lines so\n+        # reasoning is visible in real-time even without newlines.\n         while \"\\n\" in self._reasoning_buf:\n             line, self._reasoning_buf = self._reasoning_buf.split(\"\\n\", 1)\n             _cprint(f\"{_DIM}{line}{_RST}\")\n+        if len(self._reasoning_buf) > 80:\n+            _cprint(f\"{_DIM}{self._reasoning_buf}{_RST}\")\n+            self._reasoning_buf = \"\"\n \n     def _close_reasoning_box(self) -> None:\n         \"\"\"Close the live reasoning box if it's open.\"\"\""]},{"file":"gateway/run.py","hunks":["@@ -5288,7 +5288,18 @@ def run_sync():\n                         if msg.get(\"mirror\"):\n                             mirror_src = msg.get(\"mirror_source\", \"another session\")\n                             content = f\"[Delivered from {mirror_src}] {content}\"\n-                        agent_history.append({\"role\": role, \"content\": content})\n+                        entry = {\"role\": role, \"content\": content}\n+                        # Preserve reasoning fields on assistant messages so\n+                        # multi-turn reasoning context survives session reload.\n+                        # The agent's _build_api_kwargs converts these to the\n+                        # provider-specific format (reasoning_content, etc.).\n+                        if role == \"assistant\":\n+                            for _rkey in (\"reasoning\", \"reasoning_details\",\n+                                          \"codex_reasoning_items\"):\n+                                _rval = msg.get(_rkey)\n+                                if _rval:\n+                                    entry[_rkey] = _rval\n+                        agent_history.append(entry)\n             \n             # Collect MEDIA paths already in history so we can exclude them\n             # from the current turn's extraction. This is compression-safe:"]},{"file":"gateway/session.py","hunks":["@@ -891,13 +891,17 @@ def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db\n         # Write to SQLite (unless the agent already handled it)\n         if self._db and not skip_db:\n             try:\n+                _role = message.get(\"role\", \"unknown\")\n                 self._db.append_message(\n                     session_id=session_id,\n-                    role=message.get(\"role\", \"unknown\"),\n+                    role=_role,\n                     content=message.get(\"content\"),\n                     tool_name=message.get(\"tool_name\"),\n                     tool_calls=message.get(\"tool_calls\"),\n                     tool_call_id=message.get(\"tool_call_id\"),\n+                    reasoning=message.get(\"reasoning\") if _role == \"assistant\" else None,\n+                    reasoning_details=message.get(\"reasoning_details\") if _role == \"assistant\" else None,\n+                    codex_reasoning_items=message.get(\"codex_reasoning_items\") if _role == \"assistant\" else None,\n                 )\n             except Exception as e:\n                 logger.debug(\"Session DB operation failed: %s\", e)","@@ -918,13 +922,17 @@ def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) ->\n             try:\n                 self._db.clear_messages(session_id)\n                 for msg in messages:\n+                    _role = msg.get(\"role\", \"unknown\")\n                     self._db.append_message(\n                         session_id=session_id,\n-                        role=msg.get(\"role\", \"unknown\"),\n+                        role=_role,\n                         content=msg.get(\"content\"),\n                         tool_name=msg.get(\"tool_name\"),\n                         tool_calls=msg.get(\"tool_calls\"),\n                         tool_call_id=msg.get(\"tool_call_id\"),\n+                        reasoning=msg.get(\"reasoning\") if _role == \"assistant\" else None,\n+                        reasoning_details=msg.get(\"reasoning_details\") if _role == \"assistant\" else None,\n+                        codex_reasoning_items=msg.get(\"codex_reasoning_items\") if _role == \"assistant\" else None,\n                     )\n             except Exception as e:\n                 logger.debug(\"Failed to rewrite transcript in DB: %s\", e)"]},{"file":"hermes_cli/config.py","hunks":["@@ -163,8 +163,8 @@ def ensure_hermes_home():\n     \n     \"compression\": {\n         \"enabled\": True,\n-        \"threshold\": 0.80,            # compress when context usage exceeds this ratio\n-        \"target_ratio\": 0.40,         # fraction of context to preserve as recent tail\n+        \"threshold\": 0.50,            # compress when context usage exceeds this ratio\n+        \"target_ratio\": 0.20,         # fraction of threshold to preserve as recent tail\n         \"protect_last_n\": 20,         # minimum recent messages to keep uncompressed\n         \"summary_model\": \"\",          # empty = use main configured model\n         \"summary_provider\": \"auto\",","@@ -1686,8 +1686,8 @@ def show_config():\n     enabled = compression.get('enabled', True)\n     print(f\"  Enabled:      {'yes' if enabled else 'no'}\")\n     if enabled:\n-        print(f\"  Threshold:    {compression.get('threshold', 0.80) * 100:.0f}%\")\n-        print(f\"  Target ratio: {compression.get('target_ratio', 0.40) * 100:.0f}% of context preserved\")\n+        print(f\"  Threshold:    {compression.get('threshold', 0.50) * 100:.0f}%\")\n+        print(f\"  Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\")\n         print(f\"  Protect last: {compression.get('protect_last_n', 20)} messages\")\n         _sm = compression.get('summary_model', '') or '(main model)'\n         print(f\"  Model:        {_sm}\")"]},{"file":"hermes_state.py","hunks":["@@ -26,7 +26,7 @@\n \n DEFAULT_DB_PATH = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\")) / \"state.db\"\n \n-SCHEMA_VERSION = 5\n+SCHEMA_VERSION = 6\n \n SCHEMA_SQL = \"\"\"\n CREATE TABLE IF NOT EXISTS schema_version (","@@ -73,7 +73,10 @@\n     tool_name TEXT,\n     timestamp REAL NOT NULL,\n     token_count INTEGER,\n-    finish_reason TEXT\n+    finish_reason TEXT,\n+    reasoning TEXT,\n+    reasoning_details TEXT,\n+    codex_reasoning_items TEXT\n );\n \n CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);","@@ -189,6 +192,25 @@ def _init_schema(self):\n                     except sqlite3.OperationalError:\n                         pass\n                 cursor.execute(\"UPDATE schema_version SET version = 5\")\n+            if current_version < 6:\n+                # v6: add reasoning columns to messages table — preserves assistant\n+                # reasoning text and structured reasoning_details across gateway\n+                # session turns.  Without these, reasoning chains are lost on\n+                # session reload, breaking multi-turn reasoning continuity for\n+                # providers that replay reasoning (OpenRouter, OpenAI, Nous).\n+                for col_name, col_type in [\n+                    (\"reasoning\", \"TEXT\"),\n+                    (\"reasoning_details\", \"TEXT\"),\n+                    (\"codex_reasoning_items\", \"TEXT\"),\n+                ]:\n+                    try:\n+                        safe = col_name.replace('\"', '\"\"')\n+                        cursor.execute(\n+                            f'ALTER TABLE messages ADD COLUMN \"{safe}\" {col_type}'\n+                        )\n+                    except sqlite3.OperationalError:\n+                        pass  # Column already exists\n+                cursor.execute(\"UPDATE schema_version SET version = 6\")\n \n         # Unique title index — always ensure it exists (safe to run after migrations\n         # since the title column is guaranteed to exist at this point)","@@ -587,6 +609,9 @@ def append_message(\n         tool_call_id: str = None,\n         token_count: int = None,\n         finish_reason: str = None,\n+        reasoning: str = None,\n+        reasoning_details: Any = None,\n+        codex_reasoning_items: Any = None,\n     ) -> int:\n         \"\"\"\n         Append a message to a session. Returns the message row ID.","@@ -595,10 +620,20 @@ def append_message(\n         if role is 'tool' or tool_calls is present).\n         \"\"\"\n         with self._lock:\n+            # Serialize structured fields to JSON for storage\n+            reasoning_details_json = (\n+                json.dumps(reasoning_details)\n+                if reasoning_details else None\n+            )\n+            codex_items_json = (\n+                json.dumps(codex_reasoning_items)\n+                if codex_reasoning_items else None\n+            )\n             cursor = self._conn.execute(\n                 \"\"\"INSERT INTO messages (session_id, role, content, tool_call_id,\n-                   tool_calls, tool_name, timestamp, token_count, finish_reason)\n-                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\",\n+                   tool_calls, tool_name, timestamp, token_count, finish_reason,\n+                   reasoning, reasoning_details, codex_reasoning_items)\n+                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\",\n                 (\n                     session_id,\n                     role,","@@ -609,6 +644,9 @@ def append_message(\n                     time.time(),\n                     token_count,\n                     finish_reason,\n+                    reasoning,\n+                    reasoning_details_json,\n+                    codex_items_json,\n                 ),\n             )\n             msg_id = cursor.lastrowid","@@ -660,7 +698,8 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:\n         \"\"\"\n         with self._lock:\n             cursor = self._conn.execute(\n-                \"SELECT role, content, tool_call_id, tool_calls, tool_name \"\n+                \"SELECT role, content, tool_call_id, tool_calls, tool_name, \"\n+                \"reasoning, reasoning_details, codex_reasoning_items \"\n                 \"FROM messages WHERE session_id = ? ORDER BY timestamp, id\",\n                 (session_id,),\n             )","@@ -677,6 +716,22 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:\n                     msg[\"tool_calls\"] = json.loads(row[\"tool_calls\"])\n                 except (json.JSONDecodeError, TypeError):\n                     pass\n+            # Restore reasoning fields on assistant messages so providers\n+            # that replay reasoning (OpenRouter, OpenAI, Nous) receive\n+            # coherent multi-turn reasoning context.\n+            if row[\"role\"] == \"assistant\":\n+                if row[\"reasoning\"]:\n+                    msg[\"reasoning\"] = row[\"reasoning\"]\n+                if row[\"reasoning_details\"]:\n+                    try:\n+                        msg[\"reasoning_details\"] = json.loads(row[\"reasoning_details\"])\n+                    except (json.JSONDecodeError, TypeError):\n+                        pass\n+                if row[\"codex_reasoning_items\"]:\n+                    try:\n+                        msg[\"codex_reasoning_items\"] = json.loads(row[\"codex_reasoning_items\"])\n+                    except (json.JSONDecodeError, TypeError):\n+                        pass\n             messages.append(msg)\n         return messages\n "]},{"file":"run_agent.py","hunks":["@@ -1009,10 +1009,10 @@ def __init__(\n         _compression_cfg = _agent_cfg.get(\"compression\", {})\n         if not isinstance(_compression_cfg, dict):\n             _compression_cfg = {}\n-        compression_threshold = float(_compression_cfg.get(\"threshold\", 0.80))\n+        compression_threshold = float(_compression_cfg.get(\"threshold\", 0.50))\n         compression_enabled = str(_compression_cfg.get(\"enabled\", True)).lower() in (\"true\", \"1\", \"yes\")\n         compression_summary_model = _compression_cfg.get(\"summary_model\") or None\n-        compression_target_ratio = float(_compression_cfg.get(\"target_ratio\", 0.40))\n+        compression_target_ratio = float(_compression_cfg.get(\"target_ratio\", 0.20))\n         compression_protect_last = int(_compression_cfg.get(\"protect_last_n\", 20))\n \n         # Read explicit context_length override from model config","@@ -1540,6 +1540,9 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n                     tool_calls=tool_calls_data,\n                     tool_call_id=msg.get(\"tool_call_id\"),\n                     finish_reason=msg.get(\"finish_reason\"),\n+                    reasoning=msg.get(\"reasoning\") if role == \"assistant\" else None,\n+                    reasoning_details=msg.get(\"reasoning_details\") if role == \"assistant\" else None,\n+                    codex_reasoning_items=msg.get(\"codex_reasoning_items\") if role == \"assistant\" else None,\n                 )\n             self._last_flushed_db_idx = len(messages)\n         except Exception as e:"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -519,24 +519,26 @@ class TestSummaryTargetRatio:\n     \"\"\"Verify that summary_target_ratio properly scales budgets with context window.\"\"\"\n \n     def test_tail_budget_scales_with_context(self):\n-        \"\"\"Tail token budget should be context_length * summary_target_ratio.\"\"\"\n+        \"\"\"Tail token budget should be threshold_tokens * summary_target_ratio.\"\"\"\n         with patch(\"agent.context_compressor.get_model_context_length\", return_value=200_000):\n             c = ContextCompressor(model=\"test\", quiet_mode=True, summary_target_ratio=0.40)\n-        assert c.tail_token_budget == 80_000\n+        # 200K * 0.50 threshold * 0.40 ratio = 40K\n+        assert c.tail_token_budget == 40_000\n \n         with patch(\"agent.context_compressor.get_model_context_length\", return_value=1_000_000):\n             c = ContextCompressor(model=\"test\", quiet_mode=True, summary_target_ratio=0.40)\n-        assert c.tail_token_budget == 400_000\n+        # 1M * 0.50 threshold * 0.40 ratio = 200K\n+        assert c.tail_token_budget == 200_000\n \n     def test_summary_cap_scales_with_context(self):\n-        \"\"\"Max summary tokens should be 5% of context, capped at 32K.\"\"\"\n+        \"\"\"Max summary tokens should be 5% of context, capped at 12K.\"\"\"\n         with patch(\"agent.context_compressor.get_model_context_length\", return_value=200_000):\n             c = ContextCompressor(model=\"test\", quiet_mode=True)\n         assert c.max_summary_tokens == 10_000  # 200K * 0.05\n \n         with patch(\"agent.context_compressor.get_model_context_length\", return_value=1_000_000):\n             c = ContextCompressor(model=\"test\", quiet_mode=True)\n-        assert c.max_summary_tokens == 32_000  # capped at ceiling\n+        assert c.max_summary_tokens == 12_000  # capped at 12K ceiling\n \n     def test_ratio_clamped(self):\n         \"\"\"Ratio should be clamped to [0.10, 0.80].\"\"\"","@@ -548,12 +550,12 @@ def test_ratio_clamped(self):\n             c = ContextCompressor(model=\"test\", quiet_mode=True, summary_target_ratio=0.95)\n         assert c.summary_target_ratio == 0.80\n \n-    def test_default_threshold_is_80_percent(self):\n-        \"\"\"Default compression threshold should be 80%.\"\"\"\n+    def test_default_threshold_is_50_percent(self):\n+        \"\"\"Default compression threshold should be 50%.\"\"\"\n         with patch(\"agent.context_compressor.get_model_context_length\", return_value=100_000):\n             c = ContextCompressor(model=\"test\", quiet_mode=True)\n-        assert c.threshold_percent == 0.80\n-        assert c.threshold_tokens == 80_000\n+        assert c.threshold_percent == 0.50\n+        assert c.threshold_tokens == 50_000\n \n     def test_default_protect_last_n_is_20(self):\n         \"\"\"Default protect_last_n should be 20.\"\"\""]},{"file":"tests/test_hermes_state.py","hunks":["@@ -177,6 +177,91 @@ def test_finish_reason_stored(self, db):\n         messages = db.get_messages(\"s1\")\n         assert messages[0][\"finish_reason\"] == \"stop\"\n \n+    def test_reasoning_persisted_and_restored(self, db):\n+        \"\"\"Reasoning text is stored for assistant messages and restored by\n+        get_messages_as_conversation() so providers receive coherent multi-turn\n+        reasoning context.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"telegram\")\n+        db.append_message(\"s1\", role=\"user\", content=\"create a cron job\")\n+        db.append_message(\n+            \"s1\",\n+            role=\"assistant\",\n+            content=None,\n+            tool_calls=[{\"function\": {\"name\": \"cronjob\", \"arguments\": \"{}\"}, \"id\": \"c1\", \"type\": \"function\"}],\n+            reasoning=\"I should call the cronjob tool to schedule this.\",\n+        )\n+        db.append_message(\"s1\", role=\"tool\", content='{\"job_id\": \"abc\"}', tool_call_id=\"c1\")\n+\n+        conv = db.get_messages_as_conversation(\"s1\")\n+        assert len(conv) == 3\n+        # reasoning must be present on the assistant message\n+        assistant = conv[1]\n+        assert assistant[\"role\"] == \"assistant\"\n+        assert assistant.get(\"reasoning\") == \"I should call the cronjob tool to schedule this.\"\n+        # user and tool messages must NOT carry reasoning\n+        assert \"reasoning\" not in conv[0]\n+        assert \"reasoning\" not in conv[2]\n+\n+    def test_reasoning_details_persisted_and_restored(self, db):\n+        \"\"\"reasoning_details (structured array) is round-tripped through JSON\n+        serialization in the DB.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"telegram\")\n+        details = [\n+            {\"type\": \"reasoning.summary\", \"summary\": \"Thinking about tools\"},\n+            {\"type\": \"reasoning.encrypted_content\", \"encrypted_content\": \"abc123\"},\n+        ]\n+        db.append_message(\n+            \"s1\",\n+            role=\"assistant\",\n+            content=\"Hello\",\n+            reasoning=\"Thinking about what to say\",\n+            reasoning_details=details,\n+        )\n+\n+        conv = db.get_messages_as_conversation(\"s1\")\n+        assert len(conv) == 1\n+        msg = conv[0]\n+        assert msg[\"reasoning\"] == \"Thinking about what to say\"\n+        assert msg[\"reasoning_details\"] == details\n+\n+    def test_reasoning_not_set_for_non_assistant(self, db):\n+        \"\"\"reasoning is never leaked onto user or tool messages.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"telegram\")\n+        db.append_message(\"s1\", role=\"user\", content=\"hi\")\n+        db.append_message(\"s1\", role=\"assistant\", content=\"hello\", reasoning=None)\n+\n+        conv = db.get_messages_as_conversation(\"s1\")\n+        assert \"reasoning\" not in conv[0]\n+        assert \"reasoning\" not in conv[1]\n+\n+    def test_reasoning_empty_string_not_restored(self, db):\n+        \"\"\"Empty string reasoning is treated as absent.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\")\n+        db.append_message(\"s1\", role=\"assistant\", content=\"hi\", reasoning=\"\")\n+\n+        conv = db.get_messages_as_conversation(\"s1\")\n+        assert \"reasoning\" not in conv[0]\n+\n+    def test_codex_reasoning_items_persisted_and_restored(self, db):\n+        \"\"\"codex_reasoning_items (encrypted blobs for Codex Responses API) are\n+        round-tripped through JSON serialization in the DB.\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\")\n+        codex_items = [\n+            {\"type\": \"reasoning\", \"id\": \"rs_abc\", \"encrypted_content\": \"enc_blob_123\"},\n+            {\"type\": \"reasoning\", \"id\": \"rs_def\", \"encrypted_content\": \"enc_blob_456\"},\n+        ]\n+        db.append_message(\n+            \"s1\",\n+            role=\"assistant\",\n+            content=\"Done\",\n+            codex_reasoning_items=codex_items,\n+        )\n+\n+        conv = db.get_messages_as_conversation(\"s1\")\n+        assert len(conv) == 1\n+        assert conv[0][\"codex_reasoning_items\"] == codex_items\n+        assert conv[0][\"codex_reasoning_items\"][0][\"encrypted_content\"] == \"enc_blob_123\"\n+\n \n # =========================================================================\n # FTS5 search","@@ -737,7 +822,7 @@ def test_tables_exist(self, db):\n     def test_schema_version(self, db):\n         cursor = db._conn.execute(\"SELECT version FROM schema_version\")\n         version = cursor.fetchone()[0]\n-        assert version == 5\n+        assert version == 6\n \n     def test_title_column_exists(self, db):\n         \"\"\"Verify the title column was created in the sessions table.\"\"\"","@@ -793,12 +878,12 @@ def test_migration_from_v2(self, tmp_path):\n         conn.commit()\n         conn.close()\n \n-        # Open with SessionDB — should migrate to v5\n+        # Open with SessionDB — should migrate to v6\n         migrated_db = SessionDB(db_path=db_path)\n \n         # Verify migration\n         cursor = migrated_db._conn.execute(\"SELECT version FROM schema_version\")\n-        assert cursor.fetchone()[0] == 5\n+        assert cursor.fetchone()[0] == 6\n \n         # Verify title column exists and is NULL for existing sessions\n         session = migrated_db.get_session(\"existing\")"]},{"file":"tools/browser_tool.py","hunks":["@@ -1567,6 +1567,20 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]\n         vision_model = _get_vision_model()\n         logger.debug(\"browser_vision: analysing screenshot (%d bytes)\",\n                      len(image_data))\n+\n+        # Read vision timeout from config (auxiliary.vision.timeout), default 120s.\n+        # Local vision models (llama.cpp, ollama) can take well over 30s for\n+        # screenshot analysis, so the default must be generous.\n+        vision_timeout = 120.0\n+        try:\n+            from hermes_cli.config import load_config\n+            _cfg = load_config()\n+            _vt = _cfg.get(\"auxiliary\", {}).get(\"vision\", {}).get(\"timeout\")\n+            if _vt is not None:\n+                vision_timeout = float(_vt)\n+        except Exception:\n+            pass\n+\n         call_kwargs = {\n             \"task\": \"vision\",\n             \"messages\": [","@@ -1580,6 +1594,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]\n             ],\n             \"max_tokens\": 2000,\n             \"temperature\": 0.1,\n+            \"timeout\": vision_timeout,\n         }\n         if vision_model:\n             call_kwargs[\"model\"] = vision_model"]},{"file":"tools/session_search_tool.py","hunks":["@@ -179,6 +179,58 @@ async def _summarize_session(\n                 return None\n \n \n+def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:\n+    \"\"\"Return metadata for the most recent sessions (no LLM calls).\"\"\"\n+    try:\n+        sessions = db.list_sessions_rich(limit=limit + 5)  # fetch extra to skip current\n+\n+        # Resolve current session lineage to exclude it\n+        current_root = None\n+        if current_session_id:\n+            try:\n+                sid = current_session_id\n+                visited = set()\n+                while sid and sid not in visited:\n+                    visited.add(sid)\n+                    s = db.get_session(sid)\n+                    parent = s.get(\"parent_session_id\") if s else None\n+                    sid = parent if parent else None\n+                current_root = max(visited, key=len) if visited else current_session_id\n+            except Exception:\n+                current_root = current_session_id\n+\n+        results = []\n+        for s in sessions:\n+            sid = s.get(\"id\", \"\")\n+            if current_root and (sid == current_root or sid == current_session_id):\n+                continue\n+            # Skip child/delegation sessions (they have parent_session_id)\n+            if s.get(\"parent_session_id\"):\n+                continue\n+            results.append({\n+                \"session_id\": sid,\n+                \"title\": s.get(\"title\") or None,\n+                \"source\": s.get(\"source\", \"\"),\n+                \"started_at\": s.get(\"started_at\", \"\"),\n+                \"last_active\": s.get(\"last_active\", \"\"),\n+                \"message_count\": s.get(\"message_count\", 0),\n+                \"preview\": s.get(\"preview\", \"\"),\n+            })\n+            if len(results) >= limit:\n+                break\n+\n+        return json.dumps({\n+            \"success\": True,\n+            \"mode\": \"recent\",\n+            \"results\": results,\n+            \"count\": len(results),\n+            \"message\": f\"Showing {len(results)} most recent sessions. Use a keyword query to search specific topics.\",\n+        }, ensure_ascii=False)\n+    except Exception as e:\n+        logging.error(\"Error listing recent sessions: %s\", e, exc_info=True)\n+        return json.dumps({\"success\": False, \"error\": f\"Failed to list recent sessions: {e}\"}, ensure_ascii=False)\n+\n+\n def session_search(\n     query: str,\n     role_filter: str = None,","@@ -195,11 +247,14 @@ def session_search(\n     if db is None:\n         return json.dumps({\"success\": False, \"error\": \"Session database not available.\"}, ensure_ascii=False)\n \n+    limit = min(limit, 5)  # Cap at 5 sessions to avoid excessive LLM calls\n+\n+    # Recent sessions mode: when query is empty, return metadata for recent sessions.\n+    # No LLM calls — just DB queries for titles, previews, timestamps.\n     if not query or not query.strip():\n-        return json.dumps({\"success\": False, \"error\": \"Query cannot be empty.\"}, ensure_ascii=False)\n+        return _list_recent_sessions(db, limit, current_session_id)\n \n     query = query.strip()\n-    limit = min(limit, 5)  # Cap at 5 sessions to avoid excessive LLM calls\n \n     try:\n         # Parse role filter","@@ -364,8 +419,14 @@ def check_session_search_requirements() -> bool:\n SESSION_SEARCH_SCHEMA = {\n     \"name\": \"session_search\",\n     \"description\": (\n-        \"Search your long-term memory of past conversations. This is your recall -- \"\n+        \"Search your long-term memory of past conversations, or browse recent sessions. This is your recall -- \"\n         \"every past session is searchable, and this tool summarizes what happened.\\n\\n\"\n+        \"TWO MODES:\\n\"\n+        \"1. Recent sessions (no query): Call with no arguments to see what was worked on recently. \"\n+        \"Returns titles, previews, and timestamps. Zero LLM cost, instant. \"\n+        \"Start here when the user asks what were we working on or what did we do recently.\\n\"\n+        \"2. Keyword search (with query): Search for specific topics across all past sessions. \"\n+        \"Returns LLM-generated summaries of matching sessions.\\n\\n\"\n         \"USE THIS PROACTIVELY when:\\n\"\n         \"- The user says 'we did this before', 'remember when', 'last time', 'as I mentioned'\\n\"\n         \"- The user asks about a topic you worked on before but don't have in current context\\n\"","@@ -385,7 +446,7 @@ def check_session_search_requirements() -> bool:\n         \"properties\": {\n             \"query\": {\n                 \"type\": \"string\",\n-                \"description\": \"Search query — keywords, phrases, or boolean expressions to find in past sessions.\",\n+                \"description\": \"Search query — keywords, phrases, or boolean expressions to find in past sessions. Omit this parameter entirely to browse recent sessions instead (returns titles, previews, timestamps with no LLM cost).\",\n             },\n             \"role_filter\": {\n                 \"type\": \"string\",","@@ -397,7 +458,7 @@ def check_session_search_requirements() -> bool:\n                 \"default\": 3,\n             },\n         },\n-        \"required\": [\"query\"],\n+        \"required\": [],\n     },\n }\n ","@@ -410,7 +471,7 @@ def check_session_search_requirements() -> bool:\n     toolset=\"session_search\",\n     schema=SESSION_SEARCH_SCHEMA,\n     handler=lambda args, **kw: session_search(\n-        query=args.get(\"query\", \"\"),\n+        query=args.get(\"query\") or \"\",\n         role_filter=args.get(\"role_filter\"),\n         limit=args.get(\"limit\", 3),\n         db=kw.get(\"db\"),"]},{"file":"tools/skills_guard.py","hunks":["@@ -1050,6 +1050,9 @@ def _get_configured_model() -> str:\n \n def _resolve_trust_level(source: str) -> str:\n     \"\"\"Map a source identifier to a trust level.\"\"\"\n+    # Agent-created skills get their own permissive trust level\n+    if source == \"agent-created\":\n+        return \"agent-created\"\n     # Official optional skills shipped with the repo\n     if source.startswith(\"official/\") or source == \"official\":\n         return \"builtin\""]},{"file":"tools/vision_tools.py","hunks":["@@ -325,8 +325,9 @@ async def vision_analyze_tool(\n         logger.info(\"Processing image with vision model...\")\n         \n         # Call the vision API via centralized router.\n-        # Read timeout from config.yaml (auxiliary.vision.timeout), default 30s.\n-        vision_timeout = 30.0\n+        # Read timeout from config.yaml (auxiliary.vision.timeout), default 120s.\n+        # Local vision models (llama.cpp, ollama) can take well over 30s.\n+        vision_timeout = 120.0\n         try:\n             from hermes_cli.config import load_config\n             _cfg = load_config()"]},{"file":"website/docs/user-guide/features/hooks.md","hunks":["@@ -6,9 +6,20 @@ description: \"Run custom code at key lifecycle points — log activity, send ale\n \n # Event Hooks\n \n-The hooks system lets you run custom code at key points in the agent lifecycle — session creation, slash commands, each tool-calling step, and more. Hooks fire automatically during gateway operation without blocking the main agent pipeline.\n+Hermes has two hook systems that run custom code at key lifecycle points:\n \n-## Creating a Hook\n+| System | Registered via | Runs in | Use case |\n+|--------|---------------|---------|----------|\n+| **[Gateway hooks](#gateway-event-hooks)** | `HOOK.yaml` + `handler.py` in `~/.hermes/hooks/` | Gateway only | Logging, alerts, webhooks |\n+| **[Plugin hooks](#plugin-hooks)** | `ctx.register_hook()` in a [plugin](/docs/user-guide/features/plugins) | CLI + Gateway | Tool interception, metrics, guardrails |\n+\n+Both systems are non-blocking — errors in any hook are caught and logged, never crashing the agent.\n+\n+## Gateway Event Hooks\n+\n+Gateway hooks fire automatically during gateway operation (Telegram, Discord, Slack, WhatsApp) without blocking the main agent pipeline.\n+\n+### Creating a Hook\n \n Each hook is a directory under `~/.hermes/hooks/` containing two files:\n ","@@ -19,7 +30,7 @@ Each hook is a directory under `~/.hermes/hooks/` containing two files:\n     └── handler.py     # Python handler function\n ```\n \n-### HOOK.yaml\n+#### HOOK.yaml\n \n ```yaml\n name: my-hook","@@ -32,7 +43,7 @@ events:\n \n The `events` list determines which events trigger your handler. You can subscribe to any combination of events, including wildcards like `command:*`.\n \n-### handler.py\n+#### handler.py\n \n ```python\n import json","@@ -58,25 +69,26 @@ async def handle(event_type: str, context: dict):\n - Can be `async def` or regular `def` — both work\n - Errors are caught and logged, never crashing the agent\n \n-## Available Events\n+### Available Events\n \n | Event | When it fires | Context keys |\n |-------|---------------|--------------|\n | `gateway:startup` | Gateway process starts | `platforms` (list of active platform names) |\n | `session:start` | New messaging session created | `platform`, `user_id`, `session_id`, `session_key` |\n+| `session:end` | Session ended (before reset) | `platform`, `user_id`, `session_key` |\n | `session:reset` | User ran `/new` or `/reset` | `platform`, `user_id`, `session_key` |\n | `agent:start` | Agent begins processing a message | `platform`, `user_id`, `session_id`, `message` |\n | `agent:step` | Each iteration of the tool-calling loop | `platform`, `user_id`, `session_id`, `iteration`, `tool_names` |\n | `agent:end` | Agent finishes processing | `platform`, `user_id`, `session_id`, `message`, `response` |\n | `command:*` | Any slash command executed | `platform`, `user_id`, `command`, `args` |\n \n-### Wildcard Matching\n+#### Wildcard Matching\n \n Handlers registered for `command:*` fire for any `command:` event (`command:model`, `command:reset`, etc.). Monitor all slash commands with a single subscription.\n \n-## Examples\n+### Examples\n \n-### Telegram Alert on Long Tasks\n+#### Telegram Alert on Long Tasks\n \n Send yourself a message when the agent takes more than 10 steps:\n ","@@ -109,7 +121,7 @@ async def handle(event_type: str, context: dict):\n             )\n ```\n \n-### Command Usage Logger\n+#### Command Usage Logger\n \n Track which slash commands are used:\n ","@@ -142,7 +154,7 @@ def handle(event_type: str, context: dict):\n         f.write(json.dumps(entry) + \"\\n\")\n ```\n \n-### Session Start Webhook\n+#### Session Start Webhook\n \n POST to an external service on new sessions:\n ","@@ -169,7 +181,7 @@ async def handle(event_type: str, context: dict):\n         }, timeout=5)\n ```\n \n-## How It Works\n+### How It Works\n \n 1. On gateway startup, `HookRegistry.discover_and_load()` scans `~/.hermes/hooks/`\n 2. Each subdirectory with `HOOK.yaml` + `handler.py` is loaded dynamically","@@ -178,5 +190,51 @@ async def handle(event_type: str, context: dict):\n 5. Errors in any handler are caught and logged — a broken hook never crashes the agent\n \n :::info\n-Hooks only fire in the **gateway** (Telegram, Discord, Slack, WhatsApp). The CLI does not currently load hooks.\n+Gateway hooks only fire in the **gateway** (Telegram, Discord, Slack, WhatsApp). The CLI does not load gateway hooks. For hooks that work everywhere, use [plugin hooks](#plugin-hooks).\n :::\n+\n+## Plugin Hooks\n+\n+[Plugins](/docs/user-guide/features/plugins) can register hooks that fire in **both CLI and gateway** sessions. These are registered programmatically via `ctx.register_hook()` in your plugin's `register()` function.\n+\n+```python\n+def register(ctx):\n+    ctx.register_hook(\"pre_tool_call\", my_callback)\n+    ctx.register_hook(\"post_tool_call\", my_callback)\n+```\n+\n+### Available Plugin Hooks\n+\n+| Hook | Fires when | Callback receives |\n+|------|-----------|-------------------|\n+| `pre_tool_call` | Before any tool executes | `tool_name`, `args`, `task_id` |\n+| `post_tool_call` | After any tool returns | `tool_name`, `args`, `result`, `task_id` |\n+| `pre_llm_call` | Before LLM API request | *(planned — not yet wired)* |\n+| `post_llm_call` | After LLM API response | *(planned — not yet wired)* |\n+| `on_session_start` | Session begins | *(planned — not yet wired)* |\n+| `on_session_end` | Session ends | *(planned — not yet wired)* |\n+\n+Callbacks receive keyword arguments matching the columns above:\n+\n+```python\n+def my_callback(**kwargs):\n+    tool = kwargs[\"tool_name\"]\n+    args = kwargs[\"args\"]\n+    # ...\n+```\n+\n+### Example: Block Dangerous Tools\n+\n+```python\n+# ~/.hermes/plugins/tool-guard/__init__.py\n+BLOCKED = {\"terminal\", \"write_file\"}\n+\n+def guard(**kwargs):\n+    if kwargs[\"tool_name\"] in BLOCKED:\n+        print(f\"⚠ Blocked tool call: {kwargs['tool_name']}\")\n+\n+def register(ctx):\n+    ctx.register_hook(\"pre_tool_call\", guard)\n+```\n+\n+See the **[Plugins guide](/docs/user-guide/features/plugins)** for full details on creating plugins."]},{"file":"website/docs/user-guide/features/plugins.md","hunks":["@@ -46,14 +46,16 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable\n \n ## Available hooks\n \n+Plugins can register callbacks for these lifecycle events. See the **[Event Hooks page](/docs/user-guide/features/hooks#plugin-hooks)** for full details, callback signatures, and examples.\n+\n | Hook | Fires when |\n |------|-----------|\n | `pre_tool_call` | Before any tool executes |\n | `post_tool_call` | After any tool returns |\n-| `pre_llm_call` | Before LLM API request |\n-| `post_llm_call` | After LLM API response |\n-| `on_session_start` | Session begins |\n-| `on_session_end` | Session ends |\n+| `pre_llm_call` | Before LLM API request *(planned)* |\n+| `post_llm_call` | After LLM API response *(planned)* |\n+| `on_session_start` | Session begins *(planned)* |\n+| `on_session_end` | Session ends *(planned)* |\n \n ## Slash commands\n \n"]}]},{"kind":"pr","number":3518,"title":"fix: parse <tool_call> XML from text content in main agent loop","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}