From 240af807e6b564c5a9cdd2ed5334a71e5669f6d0 Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:14:14 +0800 Subject: [PATCH 1/4] fix(prompts): preserve literals when precompiling LangChain prompts --- langfuse/model.py | 34 +++++++---- tests/unit/test_prompt_compilation.py | 84 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index 30c6fe0f3..e382bec12 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -157,10 +157,21 @@ def get_langchain_prompt(self) -> Any: pass @staticmethod - def _get_langchain_prompt_string(content: str) -> str: + def _get_langchain_prompt_string( + content: str, data: Optional[Dict[str, Any]] = None + ) -> str: json_escaped_content = BasePromptClient._escape_json_for_langchain(content) - return re.sub(r"{{\s*(\w+)\s*}}", r"{\g<1>}", json_escaped_content) + def replace_variable(match: re.Match[str]) -> str: + name = match.group(1).strip() + if data is not None and name in data: + value = "" if data[name] is None else str(data[name]) + return value.replace("{", "{{").replace("}", "}}") + if re.fullmatch(r"\w+", name): + return "{" + name + "}" + return match.group(0) + + return re.sub(r"{{\s*([^{}]+?)\s*}}", replace_variable, json_escaped_content) @staticmethod def _escape_json_for_langchain(text: str) -> str: @@ -266,13 +277,7 @@ def get_langchain_prompt(self, **kwargs: Union[str, Any]) -> str: Returns: str: The string that can be plugged into Langchain's PromptTemplate. """ - prompt = ( - TemplateParser.compile_template(self.prompt, kwargs) - if kwargs - else self.prompt - ) - - return self._get_langchain_prompt_string(prompt) + return self._get_langchain_prompt_string(self.prompt, kwargs) class ChatPromptClient(BasePromptClient): @@ -443,7 +448,14 @@ def get_langchain_prompt( List of messages in the format expected by Langchain's ChatPromptTemplate: (role, content) tuples for regular messages or MessagesPlaceholder objects for unresolved placeholders. """ - compiled_messages = self.compile(**kwargs) + placeholder_names = { + message["name"] + for message in self.prompt + if message["type"] == "placeholder" + } + compiled_messages = self.compile( + **{key: value for key, value in kwargs.items() if key in placeholder_names} + ) langchain_messages: List[Union[Tuple[str, str], Any]] = [] for msg in compiled_messages: @@ -464,7 +476,7 @@ def get_langchain_prompt( langchain_messages.append( ( msg["role"], # type: ignore - self._get_langchain_prompt_string(msg["content"]), # type: ignore + self._get_langchain_prompt_string(msg["content"], kwargs), # type: ignore ), ) diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 1b96a14dd..856201959 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -932,3 +932,87 @@ 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" + + +@pytest.mark.parametrize("prompt_type", ["text", "chat"]) +@pytest.mark.parametrize( + "context", + [ + "Use {name}", + "Use {{name}}", + "Compare {question}", + "An unmatched { brace", + 'JSON: {"name": "Ada"}', + "", + None, + 42, + ], +) +def test_precompiled_values_remain_literal_in_langchain(prompt_type, context): + source = "Context: {{context}}\nQuestion: {{question}}" + fields = dict(name="literal_context", version=1, config={}, tags=[], labels=[]) + if prompt_type == "text": + client = TextPromptClient(Prompt_Text(type="text", prompt=source, **fields)) + template = PromptTemplate.from_template( + client.get_langchain_prompt(context=context) + ) + rendered = template.format(question="Hello") + else: + client = ChatPromptClient( + Prompt_Chat( + type="chat", prompt=[ChatMessage(role="user", content=source)], **fields + ) + ) + template = ChatPromptTemplate.from_messages( + client.get_langchain_prompt(context=context) + ) + rendered = template.format_messages(question="Hello")[0].content + assert template.input_variables == ["question"] + literal_context = "" if context is None else str(context) + assert rendered == f"Context: {literal_context}\nQuestion: Hello" + + +def test_precompiled_history_values_remain_literal_in_langchain(): + client = ChatPromptClient( + Prompt_Chat( + type="chat", + name="history_literals", + version=1, + config={}, + tags=[], + labels=[], + prompt=[ + {"type": "placeholder", "name": "history"}, + ChatMessage(role="user", content="{{question}}"), + ], + ) + ) + history = [{"role": "system", "content": "Context: {{context}}"}] + messages = client.get_langchain_prompt(history=history, context="Keep {{question}}") + template = ChatPromptTemplate.from_messages(messages) + rendered = template.format_messages(question="Hello") + assert rendered[0].content == "Context: Keep {{question}}" + assert rendered[1].content == "Hello" + assert history == [{"role": "system", "content": "Context: {{context}}"}] + + +@pytest.mark.parametrize("prompt_type", ["text", "chat"]) +def test_precompiled_non_word_names_keep_working(prompt_type): + fields = dict(name="known_names", version=1, config={}, tags=[], labels=[]) + source = "Context: {{context-data}} / {{user name}} / {{question}}" + values = {"context-data": "Keep {name}", "user name": "Ada"} + if prompt_type == "text": + client = TextPromptClient(Prompt_Text(type="text", prompt=source, **fields)) + template = PromptTemplate.from_template(client.get_langchain_prompt(**values)) + rendered = template.format(question="Hello") + else: + client = ChatPromptClient( + Prompt_Chat( + type="chat", prompt=[ChatMessage(role="user", content=source)], **fields + ) + ) + template = ChatPromptTemplate.from_messages( + client.get_langchain_prompt(**values) + ) + rendered = template.format_messages(question="Hello")[0].content + assert rendered == "Context: Keep {name} / Ada / Hello" From 85a9fc4a17f18c2115a9c16e87462f68f7a499f2 Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:24:57 +0800 Subject: [PATCH 2/4] fix(prompts): retain JSON examples completed by supplied values --- langfuse/model.py | 5 ++--- tests/unit/test_prompt_compilation.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index e382bec12..39d8c8f0d 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -160,8 +160,6 @@ def get_langchain_prompt(self) -> Any: def _get_langchain_prompt_string( content: str, data: Optional[Dict[str, Any]] = None ) -> str: - json_escaped_content = BasePromptClient._escape_json_for_langchain(content) - def replace_variable(match: re.Match[str]) -> str: name = match.group(1).strip() if data is not None and name in data: @@ -171,7 +169,8 @@ def replace_variable(match: re.Match[str]) -> str: return "{" + name + "}" return match.group(0) - return re.sub(r"{{\s*([^{}]+?)\s*}}", replace_variable, json_escaped_content) + compiled = re.sub(r"{{\s*([^{}]+?)\s*}}", replace_variable, content) + return BasePromptClient._escape_json_for_langchain(compiled) @staticmethod def _escape_json_for_langchain(text: str) -> str: diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 856201959..699bc50fe 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -1016,3 +1016,26 @@ def test_precompiled_non_word_names_keep_working(prompt_type): ) rendered = template.format_messages(question="Hello")[0].content assert rendered == "Context: Keep {name} / Ada / Hello" + + +@pytest.mark.parametrize("prompt_type", ["text", "chat"]) +def test_precompiled_values_can_complete_json_example(prompt_type): + fields = dict(name="json_context", version=1, config={}, tags=[], labels=[]) + source = "{ {{context}} }" + if prompt_type == "text": + client = TextPromptClient(Prompt_Text(type="text", prompt=source, **fields)) + template = PromptTemplate.from_template( + client.get_langchain_prompt(context='"name": "Ada"') + ) + rendered = template.format() + else: + client = ChatPromptClient( + Prompt_Chat( + type="chat", prompt=[ChatMessage(role="user", content=source)], **fields + ) + ) + template = ChatPromptTemplate.from_messages( + client.get_langchain_prompt(context='"name": "Ada"') + ) + rendered = template.format_messages()[0].content + assert rendered == '{ "name": "Ada" }' From 6c1a18b0ac0dc1ed040a097aaac868fedbc4738e Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:36:30 +0800 Subject: [PATCH 3/4] fix(prompts): protect literal braces through template conversion --- langfuse/model.py | 28 ++++++++++++++++----------- tests/unit/test_prompt_compilation.py | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index 39d8c8f0d..ca34b40d1 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -160,17 +160,23 @@ def get_langchain_prompt(self) -> Any: def _get_langchain_prompt_string( content: str, data: Optional[Dict[str, Any]] = None ) -> str: - def replace_variable(match: re.Match[str]) -> str: - name = match.group(1).strip() - if data is not None and name in data: - value = "" if data[name] is None else str(data[name]) - return value.replace("{", "{{").replace("}", "}}") - if re.fullmatch(r"\w+", name): - return "{" + name + "}" - return match.group(0) - - compiled = re.sub(r"{{\s*([^{}]+?)\s*}}", replace_variable, content) - return BasePromptClient._escape_json_for_langchain(compiled) + values = { + name: "" if value is None else str(value) + for name, value in (data or {}).items() + } + marker = "\ue000" + while marker in content or any(marker in value for value in values.values()): + marker = chr(ord(marker) + 1) + # Keep supplied braces opaque to template parsing, while leaving quotes + # visible for JSON examples completed by a supplied value. + protected_values = { + name: value.replace("{", marker + "open").replace("}", marker + "close") + for name, value in values.items() + } + compiled = TemplateParser.compile_template(content, protected_values) + json_escaped_content = BasePromptClient._escape_json_for_langchain(compiled) + converted = re.sub(r"{{\s*(\w+)\s*}}", r"{\g<1>}", json_escaped_content) + return converted.replace(marker + "open", "{{").replace(marker + "close", "}}") @staticmethod def _escape_json_for_langchain(text: str) -> str: diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 699bc50fe..bb79839b9 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -942,6 +942,7 @@ def test_tool_calls_preservation_in_message_placeholder(): "Use {{name}}", "Compare {question}", "An unmatched { brace", + "Private \ue000 and NUL \0 with {name}", 'JSON: {"name": "Ada"}', "", None, @@ -1039,3 +1040,22 @@ def test_precompiled_values_can_complete_json_example(prompt_type): ) rendered = template.format_messages()[0].content assert rendered == '{ "name": "Ada" }' + + +@pytest.mark.parametrize("prompt_type", ["text", "chat"]) +def test_unfilled_variable_next_to_json_close_keeps_working(prompt_type): + fields = dict(name="json_variable", version=1, config={}, tags=[], labels=[]) + source = '{"answer": {{question}}}' + if prompt_type == "text": + client = TextPromptClient(Prompt_Text(type="text", prompt=source, **fields)) + template = PromptTemplate.from_template(client.get_langchain_prompt()) + rendered = template.format(question=42) + else: + client = ChatPromptClient( + Prompt_Chat( + type="chat", prompt=[ChatMessage(role="user", content=source)], **fields + ) + ) + template = ChatPromptTemplate.from_messages(client.get_langchain_prompt()) + rendered = template.format_messages(question=42)[0].content + assert rendered == '{"answer": 42}' From df353572a87f2089086af3a04394ec7b22ff3b72 Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:48:35 +0800 Subject: [PATCH 4/4] fix(prompts): avoid evaluating unused conversion arguments --- langfuse/model.py | 6 ++++-- tests/unit/test_prompt_compilation.py | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index ca34b40d1..fa4d8e5d0 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -160,9 +160,11 @@ def get_langchain_prompt(self) -> Any: def _get_langchain_prompt_string( content: str, data: Optional[Dict[str, Any]] = None ) -> str: + supplied = data or {} values = { - name: "" if value is None else str(value) - for name, value in (data or {}).items() + name: "" if supplied[name] is None else str(supplied[name]) + for name in TemplateParser.find_variable_names(content) + if name in supplied } marker = "\ue000" while marker in content or any(marker in value for value in values.values()): diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index bb79839b9..0c3fae821 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -1059,3 +1059,30 @@ def test_unfilled_variable_next_to_json_close_keeps_working(prompt_type): template = ChatPromptTemplate.from_messages(client.get_langchain_prompt()) rendered = template.format_messages(question=42)[0].content assert rendered == '{"answer": 42}' + + +@pytest.mark.parametrize("prompt_type", ["text", "chat"]) +def test_unused_precompiled_values_are_not_stringified(prompt_type): + class Unused: + def __str__(self): + raise AssertionError("an unused value must not be evaluated") + + fields = dict(name="unused_context", version=1, config={}, tags=[], labels=[]) + source = "Question: {{question}}" + if prompt_type == "text": + client = TextPromptClient(Prompt_Text(type="text", prompt=source, **fields)) + template = PromptTemplate.from_template( + client.get_langchain_prompt(unused=Unused()) + ) + rendered = template.format(question="Hello") + else: + client = ChatPromptClient( + Prompt_Chat( + type="chat", prompt=[ChatMessage(role="user", content=source)], **fields + ) + ) + template = ChatPromptTemplate.from_messages( + client.get_langchain_prompt(unused=Unused()) + ) + rendered = template.format_messages(question="Hello")[0].content + assert rendered == "Question: Hello"