Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -609,6 +622,7 @@ def append_message(
time.time(),
token_count,
finish_reason,
reasoning,
),
)
msg_id = cursor.lastrowid
Expand Down Expand Up @@ -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,),
)
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 38 additions & 3 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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")
Expand Down
Loading