Skip to content

fix(tracing): reconstruct flattened LLM tool_calls as a list#1771

Draft
cotovanu-cristian wants to merge 1 commit into
mainfrom
fix/otel-tool-calls-list
Draft

fix(tracing): reconstruct flattened LLM tool_calls as a list#1771
cotovanu-cristian wants to merge 1 commit into
mainfrom
fix/otel-tool-calls-list

Conversation

@cotovanu-cristian

Copy link
Copy Markdown
Collaborator

Summary

_get_llm_messages reconstructs nested Python structures from flattened OpenInference span
attributes. Its list-vs-dict heuristic decided a segment was a list index only when the segment
two positions ahead was also a digit (parts[i+2].isdigit()). For the standard tool-call key
shape:

llm.output_messages.0.message.tool_calls.0.tool_call.function.name

the index after tool_calls (0) is followed by tool_call (non-digit), so 0 stayed a string
key and tool_calls was built as {"0": {...}} — a dict. The consumer
(_map_llm_call_attributes) iterates message.tool_calls as a list, so it found nothing and
every tool call was dropped from the exported span.

The contract

_map_llm_call_attributes reads message.tool_calls as a list of {id, name, arguments}, and the
LLMOps span schema expects toolCalls to be a list. Per OpenInference flattened-attribute
semantics, a digit segment denotes a list index. So a list-valued message field (notably
tool_calls) must be reconstructed as a list.

Root cause & fix

tracing/_otel_exporters.py::_get_llm_messages — decide each child container's type from the
next segment, not two ahead: a following digit means the child is a list (indexed by int,
grown with None placeholders), otherwise a dict; a digit segment indexes into a list parent. This
rebuilds tool_calls — and any other list-valued field like message.contents — as a list. The
fix lands once in the shared producer; both input and output message paths are fixed together. The
list-iterating consumer already skips non-dict entries, so placeholder growth is safe.

Tests

Added test_llm_span_tool_calls_reconstructed_as_list, which feeds the real flattened key shape
for an LLM span emitting two tool calls and asserts message.tool_calls is a list and
toolCalls == [{id, name, arguments}, …] in order. It fails on current code (the value is a dict)
and passes with the fix. Full tests/tracing suite: 27 passed; ruff, mypy, and the
lint_httpx_client.py (UIPATH001) check all pass.

_get_llm_messages rebuilds nested structures from flattened OpenInference
attributes, but its container-type heuristic looked two segments ahead
(parts[i+2]) to decide list-vs-dict. For the key shape
llm.output_messages.0.message.tool_calls.0.tool_call.function.name the index
after tool_calls is followed by a non-digit ("tool_call"), so tool_calls was
built as a {"0": ...} dict. The toolCalls mapping iterates the value as a
list, so every tool call was silently dropped from the exported span.

Decide each child container's type from the next segment instead: a following
digit means the child is a list (indexed by int), otherwise a dict. This
rebuilds tool_calls — and any other list-valued message field — as a list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added test:uipath-langchain Triggers tests in the uipath-langchain-python repository test:uipath-integrations labels Jun 29, 2026
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:uipath-integrations test:uipath-langchain Triggers tests in the uipath-langchain-python repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant