diff --git a/gateway/run.py b/gateway/run.py index c8cfae5d00..f734238d55 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5288,7 +5288,18 @@ class GatewayRunner: if msg.get("mirror"): mirror_src = msg.get("mirror_source", "another session") content = f"[Delivered from {mirror_src}] {content}" - agent_history.append({"role": role, "content": content}) + entry = {"role": role, "content": content} + # Preserve reasoning fields on assistant messages so + # multi-turn reasoning context survives session reload. + # The agent's _build_api_kwargs converts these to the + # provider-specific format (reasoning_content, etc.). + if role == "assistant": + for _rkey in ("reasoning", "reasoning_details", + "codex_reasoning_items"): + _rval = msg.get(_rkey) + if _rval: + entry[_rkey] = _rval + agent_history.append(entry) # Collect MEDIA paths already in history so we can exclude them # from the current turn's extraction. This is compression-safe: diff --git a/gateway/session.py b/gateway/session.py index 58e8d584d5..bd065c25c8 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -891,13 +891,17 @@ class SessionStore: # Write to SQLite (unless the agent already handled it) if self._db and not skip_db: try: + _role = message.get("role", "unknown") self._db.append_message( session_id=session_id, - role=message.get("role", "unknown"), + role=_role, content=message.get("content"), tool_name=message.get("tool_name"), tool_calls=message.get("tool_calls"), tool_call_id=message.get("tool_call_id"), + reasoning=message.get("reasoning") if _role == "assistant" else None, + reasoning_details=message.get("reasoning_details") if _role == "assistant" else None, + codex_reasoning_items=message.get("codex_reasoning_items") if _role == "assistant" else None, ) except Exception as e: logger.debug("Session DB operation failed: %s", e) @@ -918,13 +922,17 @@ class SessionStore: try: self._db.clear_messages(session_id) for msg in messages: + _role = msg.get("role", "unknown") self._db.append_message( session_id=session_id, - role=msg.get("role", "unknown"), + role=_role, content=msg.get("content"), tool_name=msg.get("tool_name"), tool_calls=msg.get("tool_calls"), tool_call_id=msg.get("tool_call_id"), + reasoning=msg.get("reasoning") if _role == "assistant" else None, + reasoning_details=msg.get("reasoning_details") if _role == "assistant" else None, + codex_reasoning_items=msg.get("codex_reasoning_items") if _role == "assistant" else None, ) except Exception as e: logger.debug("Failed to rewrite transcript in DB: %s", e) diff --git a/hermes_state.py b/hermes_state.py index c8a59060c3..5043834cf6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -26,7 +26,7 @@ from typing import Dict, Any, List, Optional 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,10 @@ CREATE TABLE IF NOT EXISTS messages ( tool_name TEXT, timestamp REAL NOT NULL, token_count INTEGER, - finish_reason TEXT + finish_reason TEXT, + reasoning TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source); @@ -189,6 +192,25 @@ class SessionDB: except sqlite3.OperationalError: pass cursor.execute("UPDATE schema_version SET version = 5") + if current_version < 6: + # v6: add reasoning columns to messages table — preserves assistant + # reasoning text and structured reasoning_details across gateway + # session turns. Without these, reasoning chains are lost on + # session reload, breaking multi-turn reasoning continuity for + # providers that replay reasoning (OpenRouter, OpenAI, Nous). + for col_name, col_type in [ + ("reasoning", "TEXT"), + ("reasoning_details", "TEXT"), + ("codex_reasoning_items", "TEXT"), + ]: + try: + safe = col_name.replace('"', '""') + cursor.execute( + f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}' + ) + 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 +609,9 @@ class SessionDB: tool_call_id: str = None, token_count: int = None, finish_reason: str = None, + reasoning: str = None, + reasoning_details: Any = None, + codex_reasoning_items: Any = None, ) -> int: """ Append a message to a session. Returns the message row ID. @@ -595,10 +620,20 @@ class SessionDB: if role is 'tool' or tool_calls is present). """ with self._lock: + # Serialize structured fields to JSON for storage + reasoning_details_json = ( + json.dumps(reasoning_details) + if reasoning_details else None + ) + codex_items_json = ( + json.dumps(codex_reasoning_items) + if codex_reasoning_items else None + ) 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, reasoning_details, codex_reasoning_items) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -609,6 +644,9 @@ class SessionDB: time.time(), token_count, finish_reason, + reasoning, + reasoning_details_json, + codex_items_json, ), ) msg_id = cursor.lastrowid @@ -660,7 +698,8 @@ class SessionDB: """ 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, reasoning_details, codex_reasoning_items " "FROM messages WHERE session_id = ? ORDER BY timestamp, id", (session_id,), ) @@ -677,6 +716,22 @@ class SessionDB: msg["tool_calls"] = json.loads(row["tool_calls"]) except (json.JSONDecodeError, TypeError): pass + # Restore reasoning fields on assistant messages so providers + # that replay reasoning (OpenRouter, OpenAI, Nous) receive + # coherent multi-turn reasoning context. + if row["role"] == "assistant": + if row["reasoning"]: + msg["reasoning"] = row["reasoning"] + if row["reasoning_details"]: + try: + msg["reasoning_details"] = json.loads(row["reasoning_details"]) + except (json.JSONDecodeError, TypeError): + pass + if row["codex_reasoning_items"]: + try: + msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"]) + except (json.JSONDecodeError, TypeError): + pass messages.append(msg) return messages diff --git a/run_agent.py b/run_agent.py index 36491e6443..80c5fe3e93 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1540,6 +1540,9 @@ class AIAgent: 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, + reasoning_details=msg.get("reasoning_details") if role == "assistant" else None, + codex_reasoning_items=msg.get("codex_reasoning_items") 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 c731ccf3fc..381bb9d199 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -177,6 +177,91 @@ class TestMessageStorage: messages = db.get_messages("s1") assert messages[0]["finish_reason"] == "stop" + def test_reasoning_persisted_and_restored(self, db): + """Reasoning text is stored for assistant messages and restored by + get_messages_as_conversation() so providers receive coherent multi-turn + reasoning context.""" + 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_details_persisted_and_restored(self, db): + """reasoning_details (structured array) is round-tripped through JSON + serialization in the DB.""" + db.create_session(session_id="s1", source="telegram") + details = [ + {"type": "reasoning.summary", "summary": "Thinking about tools"}, + {"type": "reasoning.encrypted_content", "encrypted_content": "abc123"}, + ] + db.append_message( + "s1", + role="assistant", + content="Hello", + reasoning="Thinking about what to say", + reasoning_details=details, + ) + + conv = db.get_messages_as_conversation("s1") + assert len(conv) == 1 + msg = conv[0] + assert msg["reasoning"] == "Thinking about what to say" + assert msg["reasoning_details"] == details + + 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] + + def test_reasoning_empty_string_not_restored(self, db): + """Empty string reasoning is treated as absent.""" + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="assistant", content="hi", reasoning="") + + conv = db.get_messages_as_conversation("s1") + assert "reasoning" not in conv[0] + + def test_codex_reasoning_items_persisted_and_restored(self, db): + """codex_reasoning_items (encrypted blobs for Codex Responses API) are + round-tripped through JSON serialization in the DB.""" + db.create_session(session_id="s1", source="cli") + codex_items = [ + {"type": "reasoning", "id": "rs_abc", "encrypted_content": "enc_blob_123"}, + {"type": "reasoning", "id": "rs_def", "encrypted_content": "enc_blob_456"}, + ] + db.append_message( + "s1", + role="assistant", + content="Done", + codex_reasoning_items=codex_items, + ) + + conv = db.get_messages_as_conversation("s1") + assert len(conv) == 1 + assert conv[0]["codex_reasoning_items"] == codex_items + assert conv[0]["codex_reasoning_items"][0]["encrypted_content"] == "enc_blob_123" + # ========================================================================= # FTS5 search @@ -737,7 +822,7 @@ class TestSchemaInit: 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 +878,12 @@ class TestSchemaInit: 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")