From a84ffb1ba85a2563a546422cddc8b1fd42f30dde Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 17:33:38 -0400 Subject: [PATCH] fix(state): parse tool_calls JSON string before re-serializing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _insert_message_rows and append_message both do json.dumps(tool_calls) to serialize the field for SQLite storage. But when tool_calls arrives as a JSON string (from import_sessions / export_session, which store it as TEXT), json.dumps double-encodes it — wrapping the already-serialized string in quotes and escaping the inner quotes. When _rows_to_conversation later does json.loads(row['tool_calls']), the double-encoded string parses back to a plain string (not a list). _history_to_messages then iterates this string character-by-character, calling tc.get('function', {}) on each char — 'str' object has no attribute 'get'. This was a pre-existing bug (on main), but only triggered by the import_sessions path (the live agent always passes tool_calls as a Python list). The e2e error-banner guard caught it via the 'Resume failed' notification toast. Fix: in both append_message and _insert_message_rows, parse tool_calls with json.loads first if it's a string, then re-serialize. --- hermes_state.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/hermes_state.py b/hermes_state.py index 8d8ff6bc021..7978eb94e2f 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4203,6 +4203,14 @@ class SessionDB: json.dumps(codex_message_items) if codex_message_items else None ) + # tool_calls may arrive as a Python list (from the live agent) or + # as a JSON string (from import/export). Parse first to avoid + # double-encoding. + if isinstance(tool_calls, str): + try: + tool_calls = json.loads(tool_calls) + except (json.JSONDecodeError, TypeError): + tool_calls = [] tool_calls_json = json.dumps(tool_calls) if tool_calls else None # Multimodal content (list of parts) must be JSON-encoded: sqlite3 # cannot bind list/dict parameters directly. @@ -4311,6 +4319,15 @@ class SessionDB: codex_message_items_json = ( json.dumps(codex_message_items) if codex_message_items else None ) + # tool_calls may arrive as a Python list (from the live agent) + # or as a JSON string (from import_sessions / export_session, + # which store it as TEXT). json.dumps on an already-serialized + # string double-encodes it, so parse first. + if isinstance(tool_calls, str): + try: + tool_calls = json.loads(tool_calls) + except (json.JSONDecodeError, TypeError): + tool_calls = [] tool_calls_json = json.dumps(tool_calls) if tool_calls else None # Accept either `platform_message_id` (new explicit name) or # `message_id` (yuanbao's existing convention on message dicts).