Skip to content

Commit 885c8b8

Browse files
authored
fix(langchain): record dict-form assistant messages on workflow and agent spans (#881)
* fix(langchain): record dict-form assistant messages on workflow and agent spans to_output_messages kept only AIMessage instances, so role/content dicts and (role, content) tuples returned in a workflow or agent's `messages` were dropped from gen_ai.output.messages, or an earlier AIMessage was reported as the final output instead. Convert entries with convert_to_messages one at a time in a shared helper used for both inputs and outputs, so a single unconvertible entry no longer drops the other convertible ones. Fixes #388 Assisted-by: Claude Opus 5.5 * Add changelog fragment for #881 Assisted-by: Claude Opus 5.5 * fix(langchain): convert messages in one batch before falling back per entry Try convert_to_messages on the whole list first and only convert entries one at a time when that fails. In the fallback, BaseMessage instances are kept as they are instead of going through convert_to_messages again. Assisted-by: Claude Opus 5.5
1 parent 0e15925 commit 885c8b8

3 files changed

Lines changed: 101 additions & 26 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Record assistant messages given as role/content dicts or `(role, content)` tuples in workflow and agent `gen_ai.output.messages` instead of dropping them, and keep convertible messages when another entry in the list cannot be converted.

‎instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py‎

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ChatMessage,
1515
FunctionMessage,
1616
HumanMessage,
17+
MessageLikeRepresentation,
1718
SystemMessage,
1819
ToolMessage,
1920
convert_to_messages,
@@ -337,25 +338,36 @@ def _message_parts(message: BaseMessage) -> list[MessagePart]:
337338
return _content_to_parts(message.content)
338339

339340

341+
def _normalize_messages(
342+
messages: Iterable[MessageLikeRepresentation],
343+
) -> list[BaseMessage]:
344+
"""Convert entries to ``BaseMessage`` s, skipping unconvertible ones."""
345+
materialized = list(messages)
346+
try:
347+
return convert_to_messages(materialized)
348+
except Exception: # pylint: disable=broad-except
349+
normalized: list[BaseMessage] = []
350+
for message in materialized:
351+
if isinstance(message, BaseMessage):
352+
normalized.append(message)
353+
continue
354+
try:
355+
normalized.extend(convert_to_messages([message]))
356+
except Exception: # pylint: disable=broad-except
357+
continue
358+
return normalized
359+
360+
340361
def to_input_messages(
341-
messages: Iterable[Any],
362+
messages: Iterable[MessageLikeRepresentation],
342363
) -> list[InputMessage]:
343364
"""Convert LangChain messages into spec-conformant ``InputMessage`` s.
344365
345366
Called only when content capture is enabled
346367
(``TelemetryHandler.should_capture_content()``).
347368
"""
348-
materialized = list(messages)
349-
try:
350-
normalized_messages: Iterable[BaseMessage] = convert_to_messages(
351-
materialized
352-
)
353-
except Exception: # pylint: disable=broad-except
354-
normalized_messages = [
355-
m for m in materialized if isinstance(m, BaseMessage)
356-
]
357369
result: list[InputMessage] = []
358-
for message in normalized_messages:
370+
for message in _normalize_messages(messages):
359371
parts = _message_parts(message)
360372
if not parts and not _has_content(message):
361373
continue
@@ -370,22 +382,21 @@ def to_input_messages(
370382

371383

372384
def to_output_messages(
373-
messages: Iterable[BaseMessage],
385+
messages: Iterable[MessageLikeRepresentation],
374386
*,
375387
finish_reason: str = "",
376388
) -> list[OutputMessage]:
377-
"""Convert LangChain ``AIMessage`` instances into ``OutputMessage`` s.
389+
"""Convert LangChain messages into ``OutputMessage`` s.
378390
379-
Non-``AIMessage`` entries are skipped: only assistant turns are recorded
380-
as ``gen_ai.output.messages``. Tool execution results belong on the
381-
*input* side of the next inference call, not the output side of the
382-
previous one.
391+
Only assistant turns are recorded as ``gen_ai.output.messages``. Tool
392+
execution results belong on the *input* side of the next inference call,
393+
not the output side of the previous one.
383394
384395
Called only when content capture is enabled
385396
(``TelemetryHandler.should_capture_content()``).
386397
"""
387398
result: list[OutputMessage] = []
388-
for message in messages:
399+
for message in _normalize_messages(messages):
389400
if not isinstance(message, AIMessage):
390401
continue
391402
parts = _ai_message_parts(message)
@@ -476,7 +487,9 @@ def make_input_message(data: Any) -> list[InputMessage]:
476487
messages, Iterable
477488
):
478489
return []
479-
return to_input_messages(cast(Iterable[BaseMessage], messages))
490+
return to_input_messages(
491+
cast(Iterable[MessageLikeRepresentation], messages)
492+
)
480493
# Fallback: serialize non-message state fields as input.
481494
# Common in LangGraph where nodes use structured state fields
482495
# (e.g., user_query) rather than a message list.
@@ -500,7 +513,7 @@ def make_input_message(data: Any) -> list[InputMessage]:
500513
def make_output_message(data: Any) -> list[OutputMessage]:
501514
"""Build ``OutputMessage`` s from a workflow/agent output mapping.
502515
503-
Only ``AIMessage`` entries become outputs. ``finish_reason`` is left
516+
Only assistant messages become outputs. ``finish_reason`` is left
504517
empty: the underlying per-LLM-call finish reasons are recorded on child
505518
inference spans, and util-genai filters empty values out of
506519
``gen_ai.response.finish_reasons``.
@@ -518,7 +531,9 @@ def make_output_message(data: Any) -> list[OutputMessage]:
518531
or not isinstance(messages, Iterable)
519532
):
520533
return []
521-
return to_output_messages(cast(Iterable[BaseMessage], messages))
534+
return to_output_messages(
535+
cast(Iterable[MessageLikeRepresentation], messages)
536+
)
522537

523538

524539
def make_last_output_message(data: Any) -> list[OutputMessage]:

‎instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py‎

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,14 @@ async def test_chain_classification_and_completion(
339339
AIMessage(content="final answer"),
340340
]
341341
},
342+
{
343+
"messages": [
344+
{"role": "user", "content": "hi"},
345+
{"role": "assistant", "content": "final answer"},
346+
]
347+
},
342348
],
343-
ids=["empty", "no-assistant", "last-assistant"],
349+
ids=["empty", "no-assistant", "last-assistant", "dict-assistant"],
344350
)
345351
async def test_chain_input_and_output_capture(
346352
asynchronous: bool,
@@ -364,10 +370,10 @@ async def test_chain_input_and_output_capture(
364370
assert json.loads(captured[0]["parts"][0]["content"]) == inputs
365371
else:
366372
assert GenAI.GEN_AI_INPUT_MESSAGES not in attrs
367-
if (
368-
content_capture == ContentCapturingMode.SPAN_ONLY
369-
and outputs.get("messages")
370-
and isinstance(outputs["messages"][-1], AIMessage)
373+
last = (outputs.get("messages") or [None])[-1]
374+
if content_capture == ContentCapturingMode.SPAN_ONLY and (
375+
isinstance(last, AIMessage)
376+
or (isinstance(last, dict) and last.get("role") == "assistant")
371377
):
372378
captured = json.loads(attrs[GenAI.GEN_AI_OUTPUT_MESSAGES])
373379
assert len(captured) == 1
@@ -1453,6 +1459,19 @@ def test_messages_key_malformed_entry_does_not_raise(self, bad_entry):
14531459
assert len(result) == 1
14541460
assert result[0].parts[0].content == "Hi"
14551461

1462+
def test_messages_key_malformed_entry_keeps_convertible_entries(self):
1463+
result = make_input_message(
1464+
{
1465+
"messages": [
1466+
{"role": "user", "content": "Hello"},
1467+
("human",),
1468+
HumanMessage(content="Hi"),
1469+
]
1470+
}
1471+
)
1472+
1473+
assert [m.parts[0].content for m in result] == ["Hello", "Hi"]
1474+
14561475
def test_messages_key_all_malformed_returns_empty_without_raising(self):
14571476
result = make_input_message(
14581477
{"messages": [("human",), None, {"content": "x"}]}
@@ -1548,6 +1567,36 @@ def test_ai_message_produces_assistant_output(self):
15481567
assert result[0].finish_reason == ""
15491568
assert result[0].parts[0].content == "The answer is 42"
15501569

1570+
@pytest.mark.parametrize(
1571+
"assistant_entry",
1572+
[
1573+
{"role": "assistant", "content": "Hello"},
1574+
("ai", "Hello"),
1575+
],
1576+
)
1577+
def test_message_like_assistant_entry_produces_assistant_output(
1578+
self, assistant_entry
1579+
):
1580+
result = make_output_message({"messages": [assistant_entry]})
1581+
1582+
assert len(result) == 1
1583+
assert result[0].role == "assistant"
1584+
assert result[0].parts[0].content == "Hello"
1585+
1586+
def test_malformed_entry_keeps_convertible_assistant_entries(self):
1587+
result = make_output_message(
1588+
{
1589+
"messages": [
1590+
{"role": "not_a_real_role", "content": "dropped"},
1591+
None,
1592+
{"role": "assistant", "content": "kept"},
1593+
]
1594+
}
1595+
)
1596+
1597+
assert len(result) == 1
1598+
assert result[0].parts[0].content == "kept"
1599+
15511600
def test_non_ai_message_skipped(self):
15521601
human_msg = HumanMessage(content="Hello")
15531602
result = make_output_message({"messages": [human_msg]})
@@ -1626,6 +1675,16 @@ def test_returns_only_last_ai_message(self):
16261675
assert len(result) == 1
16271676
assert result[0].parts[0].content == "final answer"
16281677

1678+
def test_returns_last_assistant_message_in_dict_form(self):
1679+
msgs = [
1680+
AIMessage(content="intermediate"),
1681+
{"role": "assistant", "content": "final answer"},
1682+
]
1683+
result = make_last_output_message({"messages": msgs})
1684+
1685+
assert len(result) == 1
1686+
assert result[0].parts[0].content == "final answer"
1687+
16291688
def test_returns_empty_when_no_ai_messages(self):
16301689
result = make_last_output_message(
16311690
{"messages": [HumanMessage(content="hi")]}

0 commit comments

Comments
 (0)