diff --git a/langfuse/model.py b/langfuse/model.py index 69d721597..62e308954 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -195,9 +195,11 @@ def _escape_json_for_langchain(text: str) -> str: # ---------- opening brace ---------- if ch == "{": - # leave existing “{{ …” untouched + # Leave existing “{{ …” untouched, but push a marker so a later + # “}}” can tell a pre-escaped pair from a JSON closing brace. if i + 1 < n and text[i + 1] == "{": out.append("{{") + stack.append(None) # pre-escaped “{{ … }}” pair i += 2 continue @@ -214,13 +216,31 @@ def _escape_json_for_langchain(text: str) -> str: # ---------- closing brace ---------- elif ch == "}": - # leave existing “… }}” untouched + # A “}}” pair is pre-escaped (left untouched) unless its first + # “}” closes a JSON “{” that was doubled above (stack top True): + # each doubled “{” needs its own escaped “}}”. if i + 1 < n and text[i + 1] == "}": + top = stack.pop() if stack else False + if top is True: + # First "}" closes the doubled JSON brace: emit its + # escaped pair, then reprocess the second "}". + out.append("}}") + i += 1 + continue + # Pre-escaped pair (None), non-JSON close (False), or stray + # pair: leave untouched. out.append("}}") i += 2 continue - is_json = stack.pop() if stack else False + # A lone "}" never closes a pre-escaped "{{": skip any stale + # markers to find the nearest unmatched "{". + is_json = False + while stack: + top = stack.pop() + if top is not None: + is_json = top + break out.append("}}" if is_json else "}") i += 1 continue diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 1b96a14dd..7bdc789ea 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -932,3 +932,55 @@ def test_tool_calls_preservation_in_message_placeholder(): # Final user message with compiled variable assert compiled_messages[4]["role"] == "user" assert compiled_messages[4]["content"] == "Help me with weather inquiry" + + +def test_compact_nested_json_keeps_closing_braces(): + """Regression test: compact nested JSON must not lose closing braces. + + Covers langfuse/langfuse#18131: _escape_json_for_langchain treated every + ``}}`` as a pre-escaped pair and never popped the stack entry pushed for + the JSON ``{`` it doubled, so ``{"a": {"b": 1}}`` rendered as + ``{"a": {"b": 1}`` (one closing brace short). + """ + prompt_string = 'Example: {"a": {"b": 1}}' + + prompt = TextPromptClient( + Prompt_Text( + type="text", + name="compact_nested_json_test", + version=1, + config={}, + tags=[], + labels=[], + prompt=prompt_string, + ) + ) + + langchain_prompt_string = prompt.get_langchain_prompt() + assert langchain_prompt_string == 'Example: {{"a": {{"b": 1}}}}' + + langchain_prompt = PromptTemplate.from_template(langchain_prompt_string) + assert langchain_prompt.format() == prompt_string + + +def test_pre_escaped_placeholder_inside_nested_json(): + """A pre-escaped ``{{name}}`` placeholder next to JSON closing braces must + be left alone while each doubled JSON ``{`` still gets its own ``}}``. + """ + prompt_string = 'Reply with JSON like {"user": {"name": "{name}"}}' + + prompt = TextPromptClient( + Prompt_Text( + type="text", + name="placeholder_inside_nested_json_test", + version=1, + config={}, + tags=[], + labels=[], + prompt=prompt_string, + ) + ) + + langchain_prompt_string = prompt.get_langchain_prompt() + langchain_prompt = PromptTemplate.from_template(langchain_prompt_string) + assert langchain_prompt.format(name="Ann") == 'Reply with JSON like {"user": {"name": "Ann"}}'