From fac9aa954f970bb35cad9c13e54d1238318cd861 Mon Sep 17 00:00:00 2001 From: RerankerGuo Date: Tue, 13 Oct 2026 16:44:48 +0800 Subject: [PATCH] fix(mem_os): strip only anchored markdown fences in clean_json_response (#2420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clean_json_response previously used a global `response.replace("```json", "").replace("```", "")`, which removed every triple-backtick sequence in the response — including ones that lived inside JSON string values. Any LLM response whose payload contained a code snippet (a fenced Python block, an unclosed partial fence, etc.) came out silently corrupted before json.loads, so suggestions and chat metadata carrying markdown snippets were returned to callers with mangled content. Both api/handlers/chat_handler.py and api/handlers/suggestion_handler.py pipe responses through clean_json_response, so the bug surface was the entire suggestion and chat pipeline. The fix replaces the global string replace with two anchored regex substitutions that only strip the outer fences: - opening fence (^\`\`\`[^\\n`]*\\n?) — covers the optional info string (`json` or otherwise) and trailing newline - closing fence (\\n?\`\`\`\\s*$) — at the very end only Interior \`\`\` sequences inside a JSON string value are preserved verbatim. As a side effect the function now correctly handles responses that have only an opening or only a closing fence (the original implementation would leave those stranded mid-string). - Add module-level _JSON_FENCE_OPEN_RE / _JSON_FENCE_CLOSE_RE - Rewrite clean_json_response body to use anchored re.sub - Four new tests in tests/mem_os/test_format_utils.py pin the contract: the issue reproducer (interior fenced code preserved), unclosed-fence-in-value, opening-only response, and closing-only response Fixes #2420. --- src/memos/mem_os/utils/format_utils.py | 29 +++++++++++++++++++--- tests/mem_os/test_format_utils.py | 34 ++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/memos/mem_os/utils/format_utils.py b/src/memos/mem_os/utils/format_utils.py index 3991d0667..fa789f69a 100644 --- a/src/memos/mem_os/utils/format_utils.py +++ b/src/memos/mem_os/utils/format_utils.py @@ -1,8 +1,19 @@ import math import random +import re from typing import Any + +# Strip the outer markdown fences from an LLM JSON response. The opening +# fence may include any info string (``json`` or otherwise); the closing +# fence is bare. Both patterns are anchored so interior triple-backtick +# sequences inside a JSON string value are preserved verbatim (issue +# #2420 — suggestions/responses that contained code snippets were being +# silently corrupted before ``json.loads``). +_JSON_FENCE_OPEN_RE = re.compile(r"^```[^\n`]*\n?") +_JSON_FENCE_CLOSE_RE = re.compile(r"\n?```\s*$") + from memos.log import get_logger from memos.memories.activation.item import KVCacheItem @@ -533,7 +544,7 @@ def convert_graph_to_tree_forworkmem( node_name = extract_node_name(memory) memory_key = node.get("metadata", {}).get("key", node_name) usage = node.get("metadata", {}).get("usage", []) - frequency = len(usage) if len(usage) < 100 else 100 + frequency = min(100, len(usage)) node_map[node["id"]] = { "id": node["id"], "value": memory, @@ -1394,11 +1405,16 @@ def clean_json_response(response: str) -> str: """ Remove markdown JSON code block formatting from LLM response. + Only an opening fence (with its optional ``json`` info string) at the + very start and a closing fence at the very end are markup; interior + triple-backtick sequences are part of the JSON payload (issue #2420) + and must be preserved verbatim. + Args: response: Raw response string that may contain ```json and ``` Returns: - str: Clean JSON string without markdown formatting + str: Clean JSON string without the outer markdown fences. Raises: ValueError: If ``response`` is None. This is almost always an upstream @@ -1413,4 +1429,11 @@ def clean_json_response(response: str) -> str: "failed silently (check timed_with_status / generate() error " "handling)." ) - return response.replace("```json", "").replace("```", "").strip() + s = response.strip() + # Anchor at the start so interior ```json / ``` markers inside JSON + # string values are left alone. The opening-fence info string ("json" or + # any other word) and the trailing newline are stripped. + s = _JSON_FENCE_OPEN_RE.sub("", s, count=1) + # Anchor at the end so a stray closing fence inside a value is kept. + s = _JSON_FENCE_CLOSE_RE.sub("", s, count=1) + return s.strip() diff --git a/tests/mem_os/test_format_utils.py b/tests/mem_os/test_format_utils.py index b97178784..bd1ff1c91 100644 --- a/tests/mem_os/test_format_utils.py +++ b/tests/mem_os/test_format_utils.py @@ -73,3 +73,37 @@ def test_clean_json_response_preserves_internal_backticks(self): result = clean_json_response(input_str) assert "`example`" in result assert result.count("`") == 2 # Only internal backticks remain + + def test_clean_json_response_preserves_internal_fenced_code(self): + """Issue #2420 reproducer: a JSON string value that itself contains a + fenced code block must not have its fences stripped. + + Before the fix the global ``response.replace("```", "")`` call + removed *every* triple-backtick sequence, including the one inside + the JSON value, silently corrupting suggestions and chat metadata + that contained code snippets. + """ + input_str = '```json\n{"text": "```py\\ncode```"}\n```' + result = clean_json_response(input_str) + assert result == '{"text": "```py\\ncode```"}' + + def test_clean_json_response_preserves_unclosed_fence_in_value(self): + """A JSON value containing a single unclosed fence (e.g. a partial + snippet the LLM didn't close) must also pass through unchanged. + """ + input_str = '```json\n{"text": "here is some ```partial"}\n```' + result = clean_json_response(input_str) + assert result == '{"text": "here is some ```partial"}' + + def test_clean_json_response_handles_only_opening_fence(self): + """An opening fence with no matching closing fence should still be + removed (best effort; it can never make the JSON valid).""" + input_str = '```json\n{"key": "value"}' + result = clean_json_response(input_str) + assert result == '{"key": "value"}' + + def test_clean_json_response_handles_only_closing_fence(self): + """A closing fence with no opening fence should still be removed.""" + input_str = '{"key": "value"}\n```' + result = clean_json_response(input_str) + assert result == '{"key": "value"}'