feat: persist reasoning across gateway session turns (schema v6) (#2974)
feat: persist reasoning across gateway session turns (schema v6) Tested against OpenAI Codex (direct), Anthropic (direct + OAI-compat), and OpenRouter → 6 backends. All reasoning field types (reasoning, reasoning_details, codex_reasoning_items) round-trip through the DB correctly.
This commit is contained in:
@@ -5288,7 +5288,18 @@ class GatewayRunner:
|
|||||||
if msg.get("mirror"):
|
if msg.get("mirror"):
|
||||||
mirror_src = msg.get("mirror_source", "another session")
|
mirror_src = msg.get("mirror_source", "another session")
|
||||||
content = f"[Delivered from {mirror_src}] {content}"
|
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
|
# Collect MEDIA paths already in history so we can exclude them
|
||||||
# from the current turn's extraction. This is compression-safe:
|
# from the current turn's extraction. This is compression-safe:
|
||||||
|
|||||||
@@ -891,13 +891,17 @@ class SessionStore:
|
|||||||
# Write to SQLite (unless the agent already handled it)
|
# Write to SQLite (unless the agent already handled it)
|
||||||
if self._db and not skip_db:
|
if self._db and not skip_db:
|
||||||
try:
|
try:
|
||||||
|
_role = message.get("role", "unknown")
|
||||||
self._db.append_message(
|
self._db.append_message(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
role=message.get("role", "unknown"),
|
role=_role,
|
||||||
content=message.get("content"),
|
content=message.get("content"),
|
||||||
tool_name=message.get("tool_name"),
|
tool_name=message.get("tool_name"),
|
||||||
tool_calls=message.get("tool_calls"),
|
tool_calls=message.get("tool_calls"),
|
||||||
tool_call_id=message.get("tool_call_id"),
|
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:
|
except Exception as e:
|
||||||
logger.debug("Session DB operation failed: %s", e)
|
logger.debug("Session DB operation failed: %s", e)
|
||||||
@@ -918,13 +922,17 @@ class SessionStore:
|
|||||||
try:
|
try:
|
||||||
self._db.clear_messages(session_id)
|
self._db.clear_messages(session_id)
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
|
_role = msg.get("role", "unknown")
|
||||||
self._db.append_message(
|
self._db.append_message(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
role=msg.get("role", "unknown"),
|
role=_role,
|
||||||
content=msg.get("content"),
|
content=msg.get("content"),
|
||||||
tool_name=msg.get("tool_name"),
|
tool_name=msg.get("tool_name"),
|
||||||
tool_calls=msg.get("tool_calls"),
|
tool_calls=msg.get("tool_calls"),
|
||||||
tool_call_id=msg.get("tool_call_id"),
|
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:
|
except Exception as e:
|
||||||
logger.debug("Failed to rewrite transcript in DB: %s", e)
|
logger.debug("Failed to rewrite transcript in DB: %s", e)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from typing import Dict, Any, List, Optional
|
|||||||
|
|
||||||
DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
||||||
|
|
||||||
SCHEMA_VERSION = 5
|
SCHEMA_VERSION = 6
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
@@ -73,7 +73,10 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
tool_name TEXT,
|
tool_name TEXT,
|
||||||
timestamp REAL NOT NULL,
|
timestamp REAL NOT NULL,
|
||||||
token_count INTEGER,
|
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);
|
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
||||||
@@ -189,6 +192,25 @@ class SessionDB:
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
cursor.execute("UPDATE schema_version SET version = 5")
|
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
|
# Unique title index — always ensure it exists (safe to run after migrations
|
||||||
# since the title column is guaranteed to exist at this point)
|
# since the title column is guaranteed to exist at this point)
|
||||||
@@ -587,6 +609,9 @@ class SessionDB:
|
|||||||
tool_call_id: str = None,
|
tool_call_id: str = None,
|
||||||
token_count: int = None,
|
token_count: int = None,
|
||||||
finish_reason: str = None,
|
finish_reason: str = None,
|
||||||
|
reasoning: str = None,
|
||||||
|
reasoning_details: Any = None,
|
||||||
|
codex_reasoning_items: Any = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Append a message to a session. Returns the message row ID.
|
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).
|
if role is 'tool' or tool_calls is present).
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
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(
|
cursor = self._conn.execute(
|
||||||
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
||||||
tool_calls, tool_name, timestamp, token_count, finish_reason)
|
tool_calls, tool_name, timestamp, token_count, finish_reason,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
reasoning, reasoning_details, codex_reasoning_items)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
session_id,
|
session_id,
|
||||||
role,
|
role,
|
||||||
@@ -609,6 +644,9 @@ class SessionDB:
|
|||||||
time.time(),
|
time.time(),
|
||||||
token_count,
|
token_count,
|
||||||
finish_reason,
|
finish_reason,
|
||||||
|
reasoning,
|
||||||
|
reasoning_details_json,
|
||||||
|
codex_items_json,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
msg_id = cursor.lastrowid
|
msg_id = cursor.lastrowid
|
||||||
@@ -660,7 +698,8 @@ class SessionDB:
|
|||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
cursor = self._conn.execute(
|
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",
|
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
)
|
)
|
||||||
@@ -677,6 +716,22 @@ class SessionDB:
|
|||||||
msg["tool_calls"] = json.loads(row["tool_calls"])
|
msg["tool_calls"] = json.loads(row["tool_calls"])
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
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)
|
messages.append(msg)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|||||||
@@ -1540,6 +1540,9 @@ class AIAgent:
|
|||||||
tool_calls=tool_calls_data,
|
tool_calls=tool_calls_data,
|
||||||
tool_call_id=msg.get("tool_call_id"),
|
tool_call_id=msg.get("tool_call_id"),
|
||||||
finish_reason=msg.get("finish_reason"),
|
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)
|
self._last_flushed_db_idx = len(messages)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -177,6 +177,91 @@ class TestMessageStorage:
|
|||||||
messages = db.get_messages("s1")
|
messages = db.get_messages("s1")
|
||||||
assert messages[0]["finish_reason"] == "stop"
|
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
|
# FTS5 search
|
||||||
@@ -737,7 +822,7 @@ class TestSchemaInit:
|
|||||||
def test_schema_version(self, db):
|
def test_schema_version(self, db):
|
||||||
cursor = db._conn.execute("SELECT version FROM schema_version")
|
cursor = db._conn.execute("SELECT version FROM schema_version")
|
||||||
version = cursor.fetchone()[0]
|
version = cursor.fetchone()[0]
|
||||||
assert version == 5
|
assert version == 6
|
||||||
|
|
||||||
def test_title_column_exists(self, db):
|
def test_title_column_exists(self, db):
|
||||||
"""Verify the title column was created in the sessions table."""
|
"""Verify the title column was created in the sessions table."""
|
||||||
@@ -793,12 +878,12 @@ class TestSchemaInit:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# Open with SessionDB — should migrate to v5
|
# Open with SessionDB — should migrate to v6
|
||||||
migrated_db = SessionDB(db_path=db_path)
|
migrated_db = SessionDB(db_path=db_path)
|
||||||
|
|
||||||
# Verify migration
|
# Verify migration
|
||||||
cursor = migrated_db._conn.execute("SELECT version FROM schema_version")
|
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
|
# Verify title column exists and is NULL for existing sessions
|
||||||
session = migrated_db.get_session("existing")
|
session = migrated_db.get_session("existing")
|
||||||
|
|||||||
Reference in New Issue
Block a user