From 6f3556a10ad56188e0fbbb534d68aee59c2d31fc Mon Sep 17 00:00:00 2001 From: arpan sahu <28574248+arpansahu@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:28:01 +0530 Subject: [PATCH] fix(prompt): preserve langchain placeholder messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: arpan sahu <28574248+arpansahu@users.noreply.github.com> --- langfuse/model.py | 67 ++++++++++++++++++++++----- tests/unit/test_prompt_compilation.py | 32 +++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/langfuse/model.py b/langfuse/model.py index 69d721597..e5d01b87e 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -279,6 +279,15 @@ def get_langchain_prompt(self, **kwargs: Union[str, Any]) -> str: class ChatPromptClient(BasePromptClient): + _LANGCHAIN_MESSAGE_ROLE_MAP = { + "human": "user", + "ai": "assistant", + "system": "system", + "tool": "tool", + "function": "function", + "chat": "chat", + } + def __init__(self, prompt: Prompt_Chat, is_fallback: bool = False): super().__init__(prompt, is_fallback) self.prompt: List[ChatMessageWithPlaceholdersDict] = [] @@ -346,17 +355,12 @@ def compile( placeholder_value = kwargs[placeholder_name] if isinstance(placeholder_value, list): for msg in placeholder_value: - if isinstance(msg, dict): - # Preserve all fields from the original message, such as tool calls - compiled_msg = dict(msg) # type: ignore - # Ensure role and content are always present - compiled_msg["role"] = msg.get("role", "NOT_GIVEN") - compiled_msg["content"] = ( - TemplateParser.compile_template( - msg.get("content", ""), # type: ignore - kwargs, - ) - ) + compiled_msg = self._compile_placeholder_message( + msg=msg, + kwargs=kwargs, + ) + + if compiled_msg is not None: compiled_messages.append(compiled_msg) else: compiled_messages.append( @@ -387,6 +391,47 @@ def compile( return compiled_messages # type: ignore + def _compile_placeholder_message( + self, *, msg: Any, kwargs: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + if isinstance(msg, dict): + # Preserve all fields from the original message, such as tool calls + compiled_msg = dict(msg) + # Ensure role and content are always present + compiled_msg["role"] = msg.get("role", "NOT_GIVEN") + compiled_msg["content"] = TemplateParser.compile_template( + msg.get("content", ""), + kwargs, + ) + return compiled_msg + + if not hasattr(msg, "content"): + return None + + role = getattr(msg, "role", None) + if role is None: + role = self._LANGCHAIN_MESSAGE_ROLE_MAP.get( + str(getattr(msg, "type", "")), + ) + + if role is None: + return None + + content = getattr(msg, "content") + compiled_msg = { + "role": role, + "content": TemplateParser.compile_template(content, kwargs) + if isinstance(content, str) + else content, + } + + for key in ("name", "tool_call_id", "tool_calls", "invalid_tool_calls"): + value = getattr(msg, key, None) + if value: + compiled_msg[key] = value + + return compiled_msg + @property def variables(self) -> List[str]: """Return all the variable names in the chat prompt template.""" diff --git a/tests/unit/test_prompt_compilation.py b/tests/unit/test_prompt_compilation.py index 1b96a14dd..527225718 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, HumanMessage from langchain_core.prompts import ChatPromptTemplate, PromptTemplate from langfuse.api import ChatMessage, Prompt_Chat @@ -932,3 +933,34 @@ 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_langchain_messages_in_message_placeholder_are_preserved(): + prompt_client = ChatPromptClient( + Prompt_Chat( + type="chat", + name="langchain_message_placeholder_test", + version=1, + config={}, + tags=[], + labels=[], + prompt=[ + {"type": "placeholder", "name": "history"}, + {"role": "user", "content": "Question for {{name}}"}, + ], + ) + ) + + compiled_messages = prompt_client.compile( + name="Ada", + history=[ + HumanMessage(content="Hello {{name}}"), + AIMessage(content="Hi {{name}}"), + ], + ) + + assert compiled_messages == [ + {"role": "user", "content": "Hello Ada"}, + {"role": "assistant", "content": "Hi Ada"}, + {"role": "user", "content": "Question for Ada"}, + ]