Skip to content

Commit bb8a309

Browse files
authored
fix(langchain): include content attribute when assistant messages have tool calls (#3287)
1 parent 365e507 commit bb8a309

File tree

5 files changed

+212
-27
lines changed

5 files changed

+212
-27
lines changed

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ jobs:
2626
runs-on: ubuntu-latest
2727

2828
steps:
29+
- name: Free up disk space
30+
run: |
31+
sudo apt-get remove -y '^dotnet-.*' '^llvm-.*' 'php.*' '^mongodb-.*' '^mysql-.*' azure-cli google-cloud-cli google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri || true
32+
sudo apt-get autoremove -y
33+
sudo apt-get clean
34+
# Remove Docker images
35+
docker rmi $(docker images -aq) || true
36+
# Show available space
37+
df -h
2938
- uses: actions/checkout@v4
3039
with:
3140
fetch-depth: 0
@@ -55,6 +64,15 @@ jobs:
5564
python-version: ["3.11"]
5665

5766
steps:
67+
- name: Free up disk space
68+
run: |
69+
sudo apt-get remove -y '^dotnet-.*' '^llvm-.*' 'php.*' '^mongodb-.*' '^mysql-.*' azure-cli google-cloud-cli google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri || true
70+
sudo apt-get autoremove -y
71+
sudo apt-get clean
72+
# Remove Docker images
73+
docker rmi $(docker images -aq) || true
74+
# Show available space
75+
df -h
5876
- uses: actions/checkout@v4
5977
with:
6078
fetch-depth: 0
@@ -91,6 +109,15 @@ jobs:
91109
python-version: ["3.10", "3.11", "3.12"]
92110

93111
steps:
112+
- name: Free up disk space
113+
run: |
114+
sudo apt-get remove -y '^dotnet-.*' '^llvm-.*' 'php.*' '^mongodb-.*' '^mysql-.*' azure-cli google-cloud-cli google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri || true
115+
sudo apt-get autoremove -y
116+
sudo apt-get clean
117+
# Remove Docker images
118+
docker rmi $(docker images -aq) || true
119+
# Show available space
120+
df -h
94121
- uses: actions/checkout@v4
95122
with:
96123
fetch-depth: 0

packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,17 +176,17 @@ def set_chat_request(
176176
span, f"{SpanAttributes.LLM_PROMPTS}.{i}", tool_calls
177177
)
178178

179-
else:
180-
content = (
181-
msg.content
182-
if isinstance(msg.content, str)
183-
else json.dumps(msg.content, cls=CallbackFilteredJSONEncoder)
184-
)
185-
_set_span_attribute(
186-
span,
187-
f"{SpanAttributes.LLM_PROMPTS}.{i}.content",
188-
content,
189-
)
179+
# Always set content if it exists, regardless of tool_calls presence
180+
content = (
181+
msg.content
182+
if isinstance(msg.content, str)
183+
else json.dumps(msg.content, cls=CallbackFilteredJSONEncoder)
184+
)
185+
_set_span_attribute(
186+
span,
187+
f"{SpanAttributes.LLM_PROMPTS}.{i}.content",
188+
content,
189+
)
190190

191191
if msg.type == "tool" and hasattr(msg, "tool_call_id"):
192192
_set_span_attribute(

packages/opentelemetry-instrumentation-langchain/tests/conftest.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,6 @@ def environment():
144144
os.environ["COHERE_API_KEY"] = "test"
145145
if not os.environ.get("TAVILY_API_KEY"):
146146
os.environ["TAVILY_API_KEY"] = "test"
147-
if not os.environ.get("LANGSMITH_API_KEY"):
148-
os.environ["LANGSMITH_API_KEY"] = "test"
149147

150148

151149
@pytest.fixture(scope="module")

packages/opentelemetry-instrumentation-langchain/tests/test_agents.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import os
21
from typing import Tuple
32

43
import pytest
5-
from langchain import hub
64
from langchain.agents import AgentExecutor, create_tool_calling_agent
75
from langchain_community.tools.tavily_search import TavilySearchResults
6+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
87
from langchain_openai import ChatOpenAI
98
from opentelemetry.sdk._logs import LogData
109
from opentelemetry.semconv._incubating.attributes import (
@@ -14,6 +13,16 @@
1413
gen_ai_attributes as GenAIAttributes,
1514
)
1615

16+
# Constant prompt template to replace hub.pull("hwchase17/openai-functions-agent")
17+
OPENAI_FUNCTIONS_AGENT_PROMPT = ChatPromptTemplate.from_messages(
18+
[
19+
("system", "You are a helpful assistant"),
20+
MessagesPlaceholder("chat_history", optional=True),
21+
("human", "{input}"),
22+
MessagesPlaceholder("agent_scratchpad"),
23+
]
24+
)
25+
1726

1827
@pytest.mark.vcr
1928
def test_agents(instrument_legacy, span_exporter, log_exporter):
@@ -22,10 +31,7 @@ def test_agents(instrument_legacy, span_exporter, log_exporter):
2231

2332
model = ChatOpenAI(model="gpt-3.5-turbo")
2433

25-
prompt = hub.pull(
26-
"hwchase17/openai-functions-agent",
27-
api_key=os.environ["LANGSMITH_API_KEY"],
28-
)
34+
prompt = OPENAI_FUNCTIONS_AGENT_PROMPT
2935

3036
agent = create_tool_calling_agent(model, tools, prompt)
3137
agent_executor = AgentExecutor(agent=agent, tools=tools)
@@ -68,10 +74,7 @@ def test_agents_with_events_with_content(
6874

6975
model = ChatOpenAI(model="gpt-3.5-turbo")
7076

71-
prompt = hub.pull(
72-
"hwchase17/openai-functions-agent",
73-
api_key=os.environ["LANGSMITH_API_KEY"],
74-
)
77+
prompt = OPENAI_FUNCTIONS_AGENT_PROMPT
7578

7679
agent = create_tool_calling_agent(model, tools, prompt)
7780
agent_executor = AgentExecutor(agent=agent, tools=tools)
@@ -166,10 +169,7 @@ def test_agents_with_events_with_no_content(
166169

167170
model = ChatOpenAI(model="gpt-3.5-turbo")
168171

169-
prompt = hub.pull(
170-
"hwchase17/openai-functions-agent",
171-
api_key=os.environ["LANGSMITH_API_KEY"],
172-
)
172+
prompt = OPENAI_FUNCTIONS_AGENT_PROMPT
173173

174174
agent = create_tool_calling_agent(model, tools, prompt)
175175
agent_executor = AgentExecutor(agent=agent, tools=tools)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""
2+
Test for the fix of the issue where assistant message content is missing
3+
when tool calls are present in LangGraph/LangChain instrumentation.
4+
5+
This test reproduces the issue reported in GitHub where gen_ai.prompt.X.content
6+
attributes were missing for assistant messages that contained tool_calls.
7+
"""
8+
9+
from unittest.mock import Mock
10+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
11+
from opentelemetry.instrumentation.langchain.span_utils import set_chat_request
12+
from opentelemetry.semconv_ai import SpanAttributes
13+
14+
15+
def test_assistant_message_with_tool_calls_includes_content():
16+
"""
17+
Test that when an assistant message has both content and tool_calls,
18+
both the content and tool_calls are included in the span attributes.
19+
20+
This addresses the issue where content was missing when tool_calls were present.
21+
"""
22+
mock_span = Mock()
23+
mock_span.set_attribute = Mock()
24+
mock_span_holder = Mock()
25+
mock_span_holder.request_model = None
26+
messages = [
27+
[
28+
HumanMessage(content="what is the current time? First greet me."),
29+
AIMessage(
30+
content="Hello! Let me check the current time for you.",
31+
tool_calls=[
32+
{
33+
"id": "call_qU7pH3EdQvzwkPyKPOdpgaKA",
34+
"name": "get_current_time",
35+
"args": {},
36+
}
37+
],
38+
),
39+
ToolMessage(
40+
content="2025-08-15 08:15:21",
41+
tool_call_id="call_qU7pH3EdQvzwkPyKPOdpgaKA",
42+
),
43+
AIMessage(content="The current time is 2025-08-15 08:15:21"),
44+
]
45+
]
46+
47+
set_chat_request(mock_span, {}, messages, {}, mock_span_holder)
48+
49+
call_args = [call[0] for call in mock_span.set_attribute.call_args_list]
50+
attributes = {args[0]: args[1] for args in call_args}
51+
52+
assert f"{SpanAttributes.LLM_PROMPTS}.0.role" in attributes
53+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"] == "user"
54+
assert f"{SpanAttributes.LLM_PROMPTS}.0.content" in attributes
55+
assert (
56+
attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"]
57+
== "what is the current time? First greet me."
58+
)
59+
assert f"{SpanAttributes.LLM_PROMPTS}.1.role" in attributes
60+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.1.role"] == "assistant"
61+
assert f"{SpanAttributes.LLM_PROMPTS}.1.content" in attributes
62+
assert (
63+
attributes[f"{SpanAttributes.LLM_PROMPTS}.1.content"]
64+
== "Hello! Let me check the current time for you."
65+
)
66+
assert f"{SpanAttributes.LLM_PROMPTS}.1.tool_calls.0.id" in attributes
67+
assert (
68+
attributes[f"{SpanAttributes.LLM_PROMPTS}.1.tool_calls.0.id"]
69+
== "call_qU7pH3EdQvzwkPyKPOdpgaKA"
70+
)
71+
assert f"{SpanAttributes.LLM_PROMPTS}.1.tool_calls.0.name" in attributes
72+
assert (
73+
attributes[f"{SpanAttributes.LLM_PROMPTS}.1.tool_calls.0.name"]
74+
== "get_current_time"
75+
)
76+
assert f"{SpanAttributes.LLM_PROMPTS}.2.role" in attributes
77+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.2.role"] == "tool"
78+
assert f"{SpanAttributes.LLM_PROMPTS}.2.content" in attributes
79+
assert (
80+
attributes[f"{SpanAttributes.LLM_PROMPTS}.2.content"] == "2025-08-15 08:15:21"
81+
)
82+
assert f"{SpanAttributes.LLM_PROMPTS}.2.tool_call_id" in attributes
83+
assert (
84+
attributes[f"{SpanAttributes.LLM_PROMPTS}.2.tool_call_id"]
85+
== "call_qU7pH3EdQvzwkPyKPOdpgaKA"
86+
)
87+
assert f"{SpanAttributes.LLM_PROMPTS}.3.role" in attributes
88+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.3.role"] == "assistant"
89+
assert f"{SpanAttributes.LLM_PROMPTS}.3.content" in attributes
90+
assert (
91+
attributes[f"{SpanAttributes.LLM_PROMPTS}.3.content"]
92+
== "The current time is 2025-08-15 08:15:21"
93+
)
94+
95+
96+
def test_assistant_message_with_only_tool_calls_no_content():
97+
"""
98+
Test that when an assistant message has only tool_calls and no content,
99+
the tool_calls are still included and no content attribute is set.
100+
"""
101+
mock_span = Mock()
102+
mock_span.set_attribute = Mock()
103+
mock_span_holder = Mock()
104+
mock_span_holder.request_model = None
105+
106+
messages = [
107+
[
108+
AIMessage(
109+
content="",
110+
tool_calls=[
111+
{"id": "call_123", "name": "some_tool", "args": {"param": "value"}}
112+
],
113+
)
114+
]
115+
]
116+
117+
set_chat_request(mock_span, {}, messages, {}, mock_span_holder)
118+
119+
call_args = [call[0] for call in mock_span.set_attribute.call_args_list]
120+
attributes = {args[0]: args[1] for args in call_args}
121+
122+
assert f"{SpanAttributes.LLM_PROMPTS}.0.role" in attributes
123+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"] == "assistant"
124+
assert f"{SpanAttributes.LLM_PROMPTS}.0.content" not in attributes
125+
assert f"{SpanAttributes.LLM_PROMPTS}.0.tool_calls.0.id" in attributes
126+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.0.tool_calls.0.id"] == "call_123"
127+
assert f"{SpanAttributes.LLM_PROMPTS}.0.tool_calls.0.name" in attributes
128+
assert (
129+
attributes[f"{SpanAttributes.LLM_PROMPTS}.0.tool_calls.0.name"] == "some_tool"
130+
)
131+
132+
133+
def test_assistant_message_with_only_content_no_tool_calls():
134+
"""
135+
Test that when an assistant message has only content and no tool_calls,
136+
the content is included and no tool_calls attributes are set.
137+
"""
138+
mock_span = Mock()
139+
mock_span.set_attribute = Mock()
140+
mock_span_holder = Mock()
141+
mock_span_holder.request_model = None
142+
143+
messages = [[AIMessage(content="Just a regular response with no tool calls")]]
144+
145+
set_chat_request(mock_span, {}, messages, {}, mock_span_holder)
146+
147+
call_args = [call[0] for call in mock_span.set_attribute.call_args_list]
148+
149+
attributes = {args[0]: args[1] for args in call_args}
150+
151+
assert f"{SpanAttributes.LLM_PROMPTS}.0.role" in attributes
152+
assert attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"] == "assistant"
153+
assert f"{SpanAttributes.LLM_PROMPTS}.0.content" in attributes
154+
assert (
155+
attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"]
156+
== "Just a regular response with no tool calls"
157+
)
158+
159+
tool_call_attributes = [attr for attr in attributes.keys() if "tool_calls" in attr]
160+
assert len(tool_call_attributes) == 0

0 commit comments

Comments
 (0)