diff --git a/hermes_state.py b/hermes_state.py index c8a59060c3e5..ea8e28d810dd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -26,7 +26,7 @@ DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db" -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( @@ -73,7 +73,8 @@ tool_name TEXT, timestamp REAL NOT NULL, token_count INTEGER, - finish_reason TEXT + finish_reason TEXT, + reasoning TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source); @@ -189,6 +190,17 @@ def _init_schema(self): except sqlite3.OperationalError: pass cursor.execute("UPDATE schema_version SET version = 5") + if current_version < 6: + # v6: add reasoning column to messages — preserves assistant reasoning + # tokens across gateway session turns for Hermes-4 and other reasoning + # models. Without this, the reasoning chain is lost on reload and the + # API receives an inconsistent history, causing the model to fall back + # to text generation instead of tool calls. + try: + cursor.execute("ALTER TABLE messages ADD COLUMN reasoning TEXT") + except sqlite3.OperationalError: + pass # Column already exists + cursor.execute("UPDATE schema_version SET version = 6") # Unique title index — always ensure it exists (safe to run after migrations # since the title column is guaranteed to exist at this point) @@ -587,6 +599,7 @@ def append_message( tool_call_id: str = None, token_count: int = None, finish_reason: str = None, + reasoning: str = None, ) -> int: """ Append a message to a session. Returns the message row ID. @@ -597,8 +610,8 @@ def append_message( with self._lock: cursor = self._conn.execute( """INSERT INTO messages (session_id, role, content, tool_call_id, - tool_calls, tool_name, timestamp, token_count, finish_reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -609,6 +622,7 @@ def append_message( time.time(), token_count, finish_reason, + reasoning, ), ) msg_id = cursor.lastrowid @@ -657,10 +671,16 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: """ Load messages in the OpenAI conversation format (role + content dicts). Used by the gateway to restore conversation history. + + The ``reasoning`` column is restored on assistant messages so that + reasoning-capable models (Hermes-4, DeepSeek-R1, etc.) receive a + coherent multi-turn history. Without it, the API sees assistant + messages that called tools with no prior reasoning, which causes the + model to fall back to text generation instead of tool calls (#2936). """ with self._lock: cursor = self._conn.execute( - "SELECT role, content, tool_call_id, tool_calls, tool_name " + "SELECT role, content, tool_call_id, tool_calls, tool_name, reasoning " "FROM messages WHERE session_id = ? ORDER BY timestamp, id", (session_id,), ) @@ -677,6 +697,10 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: msg["tool_calls"] = json.loads(row["tool_calls"]) except (json.JSONDecodeError, TypeError): pass + # Restore reasoning for assistant messages — used by _build_api_kwargs + # to inject reasoning_content into outgoing API payloads. + if row["role"] == "assistant" and row["reasoning"]: + msg["reasoning"] = row["reasoning"] messages.append(msg) return messages diff --git a/run_agent.py b/run_agent.py index 25cfd3674540..0744fbdb9a7b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1540,6 +1540,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo tool_calls=tool_calls_data, tool_call_id=msg.get("tool_call_id"), finish_reason=msg.get("finish_reason"), + reasoning=msg.get("reasoning") if role == "assistant" else None, ) self._last_flushed_db_idx = len(messages) except Exception as e: diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index c731ccf3fcda..2d7cedc36c2e 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -177,6 +177,41 @@ def test_finish_reason_stored(self, db): messages = db.get_messages("s1") assert messages[0]["finish_reason"] == "stop" + def test_reasoning_persisted_and_restored(self, db): + """Reasoning field is stored for assistant messages and restored by + get_messages_as_conversation() so the API receives coherent multi-turn + history for reasoning models (fix for #2936).""" + db.create_session(session_id="s1", source="telegram") + db.append_message("s1", role="user", content="create a cron job") + db.append_message( + "s1", + role="assistant", + content=None, + tool_calls=[{"function": {"name": "cronjob", "arguments": "{}"}, "id": "c1", "type": "function"}], + reasoning="I should call the cronjob tool to schedule this.", + ) + db.append_message("s1", role="tool", content='{"job_id": "abc"}', tool_call_id="c1") + + conv = db.get_messages_as_conversation("s1") + assert len(conv) == 3 + # reasoning must be present on the assistant message + assistant = conv[1] + assert assistant["role"] == "assistant" + assert assistant.get("reasoning") == "I should call the cronjob tool to schedule this." + # user and tool messages must NOT carry reasoning + assert "reasoning" not in conv[0] + assert "reasoning" not in conv[2] + + def test_reasoning_not_set_for_non_assistant(self, db): + """reasoning is never leaked onto user or tool messages.""" + db.create_session(session_id="s1", source="telegram") + db.append_message("s1", role="user", content="hi") + db.append_message("s1", role="assistant", content="hello", reasoning=None) + + conv = db.get_messages_as_conversation("s1") + assert "reasoning" not in conv[0] + assert "reasoning" not in conv[1] + # ========================================================================= # FTS5 search @@ -737,7 +772,7 @@ def test_tables_exist(self, db): def test_schema_version(self, db): cursor = db._conn.execute("SELECT version FROM schema_version") version = cursor.fetchone()[0] - assert version == 5 + assert version == 6 def test_title_column_exists(self, db): """Verify the title column was created in the sessions table.""" @@ -793,12 +828,12 @@ def test_migration_from_v2(self, tmp_path): conn.commit() conn.close() - # Open with SessionDB — should migrate to v5 + # Open with SessionDB — should migrate to v6 migrated_db = SessionDB(db_path=db_path) # Verify migration cursor = migrated_db._conn.execute("SELECT version FROM schema_version") - assert cursor.fetchone()[0] == 5 + assert cursor.fetchone()[0] == 6 # Verify title column exists and is NULL for existing sessions session = migrated_db.get_session("existing")