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:
parent
5dbe2d9d73
commit
42fec19151
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user