From 6d1adbbd4470455fb6ba7afa8c608be5b400c0ae Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:40:14 +0800 Subject: [PATCH 1/2] fix(prompts): preserve tool messages in LangChain conversion --- langfuse/model.py | 15 ++++++++++- tests/unit/test_prompt_compilation.py | 36 +++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index 30c6fe0f3..a5a376767 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -441,7 +441,8 @@ def get_langchain_prompt( Returns: List of messages in the format expected by Langchain's ChatPromptTemplate: - (role, content) tuples for regular messages or MessagesPlaceholder objects for unresolved placeholders. + (role, content) tuples for regular messages, LangChain message objects for tool calls + and tool results, or MessagesPlaceholder objects for unresolved placeholders. """ compiled_messages = self.compile(**kwargs) langchain_messages: List[Union[Tuple[str, str], Any]] = [] @@ -459,6 +460,18 @@ def get_langchain_prompt( except ImportError as e: import_error = "langchain_core is required to use get_langchain_prompt() with unresolved placeholders." raise ImportError(import_error) from e + elif isinstance(msg, dict) and ( + msg.get("tool_calls") or msg.get("tool_call_id") + ): + try: + from langchain_core.messages import convert_to_messages # noqa: PLC0415, I001 + except ImportError as e: + import_error = "langchain_core is required to use get_langchain_prompt() with tool messages." + raise ImportError(import_error) from e + + langchain_messages.extend( + convert_to_messages([msg]) # type: ignore[list-item] + ) else: if isinstance(msg, dict) and "role" in msg and "content" in msg: langchain_messages.append( diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 1b96a14dd..28dca24b0 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -1,4 +1,5 @@ import pytest +from langchain_core.messages import AIMessage, ToolMessage from langchain_core.prompts import ChatPromptTemplate, PromptTemplate from langfuse.api import ChatMessage, Prompt_Chat @@ -733,7 +734,12 @@ def test_chat_prompt_with_json_variables(self): assert formatted_messages[0].content == expected_system assert formatted_messages[1].content == expected_user - def test_chat_prompt_with_placeholders_langchain(self): + @pytest.mark.parametrize( + "assistant_fields", + [{}, {"tool_calls": None}, {"tool_calls": []}], + ids=["absent", "none", "empty"], + ) + def test_chat_prompt_with_placeholders_langchain(self, assistant_fields): """Test that chat prompts with placeholders work correctly with Langchain.""" from langfuse.api import Prompt_Chat @@ -768,7 +774,11 @@ def test_chat_prompt_with_placeholders_langchain(self): task="addition", examples=[ {"role": "user", "content": "Example: What is 2+2?"}, - {"role": "assistant", "content": "2+2 equals 4."}, + { + "role": "assistant", + "content": "2+2 equals {{answer}}.", + **assistant_fields, + }, ], ) @@ -778,7 +788,7 @@ def test_chat_prompt_with_placeholders_langchain(self): == "You are a helpful assistant with math capabilities." ) assert compiled_messages[1]["content"] == "Example: What is 2+2?" - assert compiled_messages[2]["content"] == "2+2 equals 4." + assert compiled_messages[2]["content"] == "2+2 equals {{answer}}." assert compiled_messages[3]["content"] == "Help me with addition." langchain_messages = prompt_client.get_langchain_prompt( @@ -787,11 +797,15 @@ def test_chat_prompt_with_placeholders_langchain(self): task="addition", examples=[ {"role": "user", "content": "Example: What is 2+2?"}, - {"role": "assistant", "content": "2+2 equals 4."}, + { + "role": "assistant", + "content": "2+2 equals {{answer}}.", + **assistant_fields, + }, ], ) langchain_prompt = ChatPromptTemplate.from_messages(langchain_messages) - formatted_messages = langchain_prompt.format_messages() + formatted_messages = langchain_prompt.format_messages(answer="4") assert len(formatted_messages) == 4 assert ( @@ -932,3 +946,15 @@ 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" + + langchain_messages = prompt_client.get_langchain_prompt( + task="weather inquiry", message_history=message_history_with_tool_calls + ) + formatted_messages = ChatPromptTemplate.from_messages( + langchain_messages + ).format_messages() + + assert isinstance(formatted_messages[2], AIMessage) + assert formatted_messages[2].tool_calls[0]["id"] == "call_123" + assert isinstance(formatted_messages[3], ToolMessage) + assert formatted_messages[3].tool_call_id == "call_123" From 25e20487b2fa8b873c24263b39aed3bca3eecb90 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:00:34 +0800 Subject: [PATCH 2/2] Preserve formatting for tool message templates --- langfuse/model.py | 23 +++++++++++++--- tests/unit/test_prompt_compilation.py | 39 ++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index a5a376767..303ff3552 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -441,7 +441,7 @@ def get_langchain_prompt( Returns: List of messages in the format expected by Langchain's ChatPromptTemplate: - (role, content) tuples for regular messages, LangChain message objects for tool calls + (role, content) tuples for regular messages, LangChain message templates for tool calls and tool results, or MessagesPlaceholder objects for unresolved placeholders. """ compiled_messages = self.compile(**kwargs) @@ -464,13 +464,28 @@ def get_langchain_prompt( msg.get("tool_calls") or msg.get("tool_call_id") ): try: - from langchain_core.messages import convert_to_messages # noqa: PLC0415, I001 + from langchain_core.messages import BaseMessage, convert_to_messages # noqa: PLC0415, I001 + from langchain_core.prompts.chat import ( + BaseStringMessagePromptTemplate, + ) # noqa: PLC0415, I001 except ImportError as e: import_error = "langchain_core is required to use get_langchain_prompt() with tool messages." raise ImportError(import_error) from e - langchain_messages.extend( - convert_to_messages([msg]) # type: ignore[list-item] + class ToolMessagePromptTemplate(BaseStringMessagePromptTemplate): + # LangChain has no ToolMessage template; copy metadata unchanged. + message: BaseMessage + + def format(self, **kwargs: Any) -> BaseMessage: + return self.message.model_copy( + update={"content": self.prompt.format(**kwargs)} + ) + + langchain_messages.append( + ToolMessagePromptTemplate.from_template( + self._get_langchain_prompt_string(msg["content"]), # type: ignore[typeddict-item] + message=convert_to_messages([msg])[0], # type: ignore[list-item] + ) ) else: if isinstance(msg, dict) and "role" in msg and "content" in msg: diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 28dca24b0..058bd0e4b 100644 --- a/tests/unit/test_prompt_compilation.py +++ b/tests/unit/test_prompt_compilation.py @@ -866,7 +866,22 @@ def test_get_langchain_prompt_with_unresolved_placeholders(self): assert langchain_messages[2] == ("user", "Help me with coding") -def test_tool_calls_preservation_in_message_placeholder(): +@pytest.mark.parametrize( + "assistant_content,tool_content,expected_assistant_content,expected_tool_content", + [ + ("", "It's sunny, 72°F", "", "It's sunny, 72°F"), + ( + "Checking {{location}}", + "It's sunny in {{location}}", + "Checking Paris", + "It's sunny in Paris", + ), + ], + ids=["literal-content", "templated-content"], +) +def test_tool_calls_preservation_in_message_placeholder( + assistant_content, tool_content, expected_assistant_content, expected_tool_content +): """Test that tool calls are preserved when compiling message placeholders.""" from langfuse.api import Prompt_Chat @@ -893,7 +908,7 @@ def test_tool_calls_preservation_in_message_placeholder(): {"role": "user", "content": "What's the weather like?"}, { "role": "assistant", - "content": "", + "content": assistant_content, "tool_calls": [ { "id": "call_123", @@ -907,7 +922,7 @@ def test_tool_calls_preservation_in_message_placeholder(): }, { "role": "tool", - "content": "It's sunny, 72°F", + "content": tool_content, "tool_call_id": "call_123", "name": "get_weather", }, @@ -931,7 +946,7 @@ def test_tool_calls_preservation_in_message_placeholder(): # Assistant message with TOOL CALLS assert compiled_messages[2]["role"] == "assistant" - assert compiled_messages[2]["content"] == "" + assert compiled_messages[2]["content"] == assistant_content assert "tool_calls" in compiled_messages[2] assert len(compiled_messages[2]["tool_calls"]) == 1 assert compiled_messages[2]["tool_calls"][0]["id"] == "call_123" @@ -939,7 +954,7 @@ def test_tool_calls_preservation_in_message_placeholder(): # TOOL CALL results message assert compiled_messages[3]["role"] == "tool" - assert compiled_messages[3]["content"] == "It's sunny, 72°F" + assert compiled_messages[3]["content"] == tool_content assert compiled_messages[3]["tool_call_id"] == "call_123" assert compiled_messages[3]["name"] == "get_weather" @@ -952,9 +967,19 @@ def test_tool_calls_preservation_in_message_placeholder(): ) formatted_messages = ChatPromptTemplate.from_messages( langchain_messages - ).format_messages() + ).format_messages(location="Paris") assert isinstance(formatted_messages[2], AIMessage) - assert formatted_messages[2].tool_calls[0]["id"] == "call_123" + assert formatted_messages[2].content == expected_assistant_content + assert formatted_messages[2].tool_calls == [ + { + "id": "call_123", + "type": "tool_call", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ] assert isinstance(formatted_messages[3], ToolMessage) + assert formatted_messages[3].content == expected_tool_content assert formatted_messages[3].tool_call_id == "call_123" + assert formatted_messages[3].name == "get_weather"