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:
Teknium 2026-03-25 09:47:28 -07:00 committed by GitHub
parent 5dbe2d9d73
commit 42fec19151
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 173 additions and 11 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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")