Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions langfuse/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,21 @@ def compile(
compiled_msg = dict(msg) # type: ignore
# Ensure role and content are always present
compiled_msg["role"] = msg.get("role", "NOT_GIVEN")
# Only string content is templated. An
# injected history message can legitimately
# carry non-string content -- content=None
# for an assistant tool-call turn, or a list
# of content parts for a multimodal message
# -- which must be passed through untouched
# rather than fed to the string parser.
msg_content = msg.get("content", "")
compiled_msg["content"] = (
TemplateParser.compile_template(
msg.get("content", ""), # type: ignore
msg_content, # type: ignore
kwargs,
)
if isinstance(msg_content, str)
else msg_content
)
Comment on lines +364 to 369

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 LangChain conversion still crashes

Non-string placeholder content now survives compile(), but get_langchain_prompt() then passes every compiled message’s content to the string-only _get_langchain_prompt_string(). When the history contains an assistant tool-call turn with content=None or multimodal list content, this public path raises TypeError, so the fix remains incomplete for LangChain callers.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/model.py
Line: 364-369

Comment:
**LangChain conversion still crashes**

Non-string placeholder content now survives `compile()`, but `get_langchain_prompt()` then passes every compiled message’s content to the string-only `_get_langchain_prompt_string()`. When the history contains an assistant tool-call turn with `content=None` or multimodal list content, this public path raises `TypeError`, so the fix remains incomplete for LangChain callers.

**Knowledge Base Used:**
- [Prompts and framework integrations](https://app.greptile.com/personal-org-4986/-/custom-context/knowledge-base/langfuse/langfuse-python/-/docs/prompts-and-framework-integrations.md)
- [Prompt retrieval, compilation, and caching](https://app.greptile.com/personal-org-4986/-/custom-context/knowledge-base/langfuse/langfuse-python/-/docs/prompt-retrieval-compilation-and-cache.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — get_langchain_prompt() hits the same string-only path (_escape_json_for_langchain does len(text)), so guarding compile() alone wasn't enough. Pushed a fix that applies the same isinstance-str guard there too: multimodal list content passes through (LangChain accepts it), and a tool-call turn's None is normalized to "" — LangChain rejects a (role, None) template tuple, and "" is its convention for a tool-call message. Added a get_langchain_prompt regression test that also asserts the result is consumable by ChatPromptTemplate.from_messages(...).

compiled_messages.append(compiled_msg)
else:
Expand Down Expand Up @@ -464,10 +474,21 @@ def get_langchain_prompt(
raise ImportError(import_error) from e
else:
if isinstance(msg, dict) and "role" in msg and "content" in msg:
content = msg["content"] # type: ignore
if isinstance(content, str):
lc_content: Any = self._get_langchain_prompt_string(content)
else:
# Non-string content (a tool-call turn's None, or
# multimodal content parts) has no mustache syntax to
# convert. LangChain (role, content) tuples accept a
# list but not None, so normalize None to "" (its
# convention for a tool-call message) and pass a list
# through unchanged.
lc_content = "" if content is None else content
langchain_messages.append(
(
msg["role"], # type: ignore
self._get_langchain_prompt_string(msg["content"]), # type: ignore
lc_content,
),
)

Expand Down
144 changes: 144 additions & 0 deletions tests/unit/test_prompt_compilation.py
Original file line number Diff line number Diff line change
Expand Up @@ -932,3 +932,147 @@ 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_placeholder_message_with_non_string_content():
"""Placeholder history messages may carry non-string content.

An assistant tool-call turn has content=None and a multimodal message has a
list of content parts. Both are legitimate OpenAI message shapes and must be
preserved instead of being fed to the string template parser (which would
raise TypeError on None and AttributeError on a list).
"""
prompt_client = ChatPromptClient(
Prompt_Chat(
type="chat",
name="placeholder_non_string_content",
version=1,
config={},
tags=[],
labels=[],
prompt=[
{"role": "system", "content": "You are a helpful assistant."},
{"type": "placeholder", "name": "message_history"},
{"role": "user", "content": "Help me with {{task}}"},
],
),
)

message_history = [
{"role": "user", "content": "What is the weather in SF?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "SF"}',
},
}
],
},
{"role": "tool", "content": "72F sunny", "tool_call_id": "call_1"},
{
"role": "user",
"content": [
{"type": "text", "text": "And this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
},
],
},
]

compiled = prompt_client.compile(task="weather", message_history=message_history)

assert len(compiled) == 6

# Assistant tool-call turn: None content preserved, tool_calls intact
assert compiled[2]["role"] == "assistant"
assert compiled[2]["content"] is None
assert compiled[2]["tool_calls"][0]["id"] == "call_1"
assert compiled[2]["tool_calls"][0]["function"]["name"] == "get_weather"

# Multimodal list content preserved untouched
assert compiled[4]["role"] == "user"
assert compiled[4]["content"] == [
{"type": "text", "text": "And this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]

# String content in the injected history and the trailing message still templated
assert compiled[1]["content"] == "What is the weather in SF?"
assert compiled[5]["content"] == "Help me with weather"


def test_get_langchain_prompt_with_non_string_placeholder_content():
"""get_langchain_prompt() must not crash on non-string placeholder content.

It compiles first (which preserves None / list content) and then converts
each message to a LangChain (role, content) tuple. The string-only converter
would raise TypeError on a tool-call turn (content=None) or a multimodal list.
"""
from langchain_core.prompts import ChatPromptTemplate

prompt_client = ChatPromptClient(
Prompt_Chat(
type="chat",
name="langchain_non_string_content",
version=1,
config={},
tags=[],
labels=[],
prompt=[
{"role": "system", "content": "You are a helpful assistant."},
{"type": "placeholder", "name": "message_history"},
{"role": "user", "content": "Help me with {{task}}"},
],
),
)

message_history = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
}
],
},
{
"role": "user",
"content": [
{"type": "text", "text": "And this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
},
],
},
]

lc_messages = prompt_client.get_langchain_prompt(
task="weather", message_history=message_history
)

# None normalized to "" (LangChain's tool-call convention), list preserved
assert ("assistant", "") in lc_messages
assert (
"user",
[
{"type": "text", "text": "And this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
],
) in lc_messages
# Trailing string message: {{task}} compiled from kwargs, then converted
assert ("user", "Help me with weather") in lc_messages

# The result must be consumable by LangChain without raising
ChatPromptTemplate.from_messages(lc_messages).format_messages()