feat: stable id on content blocks + plumb LangChain tool_call_id

Adds an optional 'id: str | None' field to BaseContent for stable
identity across re-emissions of the same logical block. Producers
that have a natural id (LangChain tool_call_id, external API id, a
UUID stamped before the first emission) set it; consumers use it for
dedup and cross-frame correlation. Without an id, consumers fall back
to position-derived dedup, which assumes content_blocks is append-only
within a message lifetime.

Plumbs LangChain's 'tool_call_id' through 'Message.from_lc_message'
into 'ToolContent.id'. The same logical tool call across start, args
streaming, and result lifecycle now carries the same id, so a
re-fired add_message dedups to one ToolContent instead of producing
duplicates.

Tests cover id default/round-trip/inheritance across every concrete
content type, plus tool_call_id stability across repeated conversion,
multiple tool calls each keeping their own id, and tool_calls
alongside string content.
This commit is contained in:
ogabrielluiz
2026-05-27 12:25:21 -03:00
parent 3b92500349
commit a3e8b40811
5 changed files with 167 additions and 1 deletions

View File

@ -42,6 +42,13 @@ class HeaderDict(TypedDict, total=False):
class BaseContent(BaseModel):
"""Base class for all content types.
The optional ``id`` field carries stable identity across re-emissions of
the same logical block. Producers that have a natural id (LangChain
``tool_call_id``, an external API id, a UUID stamped before the first
emission) set it; consumers use it for dedup and cross-frame correlation.
Without an id, consumers fall back to position-derived dedup, which
assumes ``content_blocks`` is append-only within a message lifetime.
The optional ``contents`` field lets any content type nest more content
underneath. Leaf types (``TextContent``, ``ErrorContent``, ``UsageContent``)
leave it empty; container-shaped types (``ContentBlock``, multimodal
@ -49,6 +56,14 @@ class BaseContent(BaseModel):
"""
type: str = Field(..., description="Type of the content")
id: str | None = Field(
default=None,
description=(
"Optional stable identity for this content block across "
"re-emissions. Set by producers that have a natural id "
"(e.g. LangChain tool_call_id)."
),
)
duration: int | None = None
header: HeaderDict | None = Field(default_factory=dict)
contents: list[ContentType] = Field(

View File

@ -42,6 +42,13 @@ class HeaderDict(TypedDict, total=False):
class BaseContent(BaseModel):
"""Base class for all content types.
The optional ``id`` field carries stable identity across re-emissions of
the same logical block. Producers that have a natural id (LangChain
``tool_call_id``, an external API id, a UUID stamped before the first
emission) set it; consumers use it for dedup and cross-frame correlation.
Without an id, consumers fall back to position-derived dedup, which
assumes ``content_blocks`` is append-only within a message lifetime.
The optional ``contents`` field lets any content type nest more content
underneath. Leaf types (``TextContent``, ``ErrorContent``, ``UsageContent``)
leave it empty; container-shaped types (``ContentBlock``, multimodal
@ -49,6 +56,14 @@ class BaseContent(BaseModel):
"""
type: str = Field(..., description="Type of the content")
id: str | None = Field(
default=None,
description=(
"Optional stable identity for this content block across "
"re-emissions. Set by producers that have a natural id "
"(e.g. LangChain tool_call_id)."
),
)
duration: int | None = None
header: HeaderDict | None = Field(default_factory=dict)
contents: list[ContentType] = Field(

View File

@ -399,8 +399,12 @@ class Message(Data):
if hasattr(lc_message, "tool_calls") and lc_message.tool_calls:
from lfx.schema.content_types import ToolContent
# ``tc["id"]`` is LangChain's stable ``tool_call_id``: same value
# at start, during args streaming, and on the result, so the same
# logical tool call dedups to one ``ToolContent`` across re-fires.
blocks.extend(
ToolContent(name=tc.get("name", ""), tool_input=tc.get("args", {})) for tc in lc_message.tool_calls
ToolContent(name=tc.get("name", ""), tool_input=tc.get("args", {}), id=tc.get("id"))
for tc in lc_message.tool_calls
)
if hasattr(lc_message, "usage_metadata") and lc_message.usage_metadata:

View File

@ -713,3 +713,63 @@ def test_content_block_serialization_round_trip_with_new_types():
assert restored.contents[4].text == "thinking"
assert isinstance(restored.contents[6], CitationContent)
assert restored.contents[6].title == "Ref"
# ---------------------------------------------------------------------------
# BaseContent.id (stable identity across re-emissions)
# ---------------------------------------------------------------------------
#
# Streaming emitters can re-fire the same logical content block as it grows
# (e.g. a ToolContent first without output, then with output). Consumers
# dedup these re-emissions; a stable id is the only safe key for that, since
# position changes on insert and a content hash collides on legitimate
# duplicates (two identical "ok" reasoning steps).
#
# The id is optional: producers that have a natural id (LangChain
# tool_call_id, external API id, a UUID the component stamped before the
# first emission) set it. Consumers that don't see one fall back to
# position-derived dedup. The id flows through the wire as just another
# field; nothing on the server mutates it.
class TestBaseContentId:
def test_default_id_is_none(self):
"""An id is optional; producers opt in by setting it."""
assert BaseContent(type="anything").id is None
def test_id_is_settable_on_construction(self):
assert BaseContent(type="anything", id="abc-123").id == "abc-123"
def test_id_round_trips_through_model_dump(self):
c = TextContent(text="hello", id="msg-7")
restored = TextContent.model_validate(c.model_dump())
assert restored.id == "msg-7"
def test_id_round_trips_through_json(self):
c = ToolContent(name="search", id="call_abc")
restored = ToolContent.model_validate_json(c.model_dump_json())
assert restored.id == "call_abc"
@pytest.mark.parametrize(
("content_cls", "kwargs"),
[
(TextContent, {"text": "hi"}),
(CodeContent, {"code": "x=1", "language": "python"}),
(JSONContent, {"data": {"k": "v"}}),
(MediaContent, {"urls": ["http://example.com/a.png"]}),
(ToolContent, {"name": "tool"}),
(ErrorContent, {"reason": "boom"}),
(ImageContent, {"urls": ["http://example.com/a.png"]}),
(AudioContent, {"urls": ["http://example.com/a.mp3"]}),
(VideoContent, {"urls": ["http://example.com/a.mp4"]}),
(FileContent, {"urls": ["http://example.com/a.pdf"]}),
(ReasoningContent, {"text": "thinking"}),
(UsageContent, {"input_tokens": 10}),
(CitationContent, {"url": "http://example.com"}),
],
)
def test_id_inherited_by_every_subclass(self, content_cls, kwargs):
"""Every concrete content type carries the optional id."""
c = content_cls(id="stable-id", **kwargs)
assert c.id == "stable-id"
assert content_cls.model_validate(c.model_dump()).id == "stable-id"

View File

@ -417,3 +417,75 @@ class TestBackwardsCompatibility:
data = Data(text="data text")
msg = Message.from_data(data)
assert msg.text == "data text"
class TestFromLcMessageToolCallId:
"""LangChain plumbs ``tool_call_id`` into ToolContent.id.
LangChain stamps a stable ``tool_call_id`` on every AIMessage.tool_calls
entry. That id is the gold standard for cross-frame correlation: it is
stable across the whole tool lifecycle (start, args streaming, result),
so a ``ToolContent`` re-emitted with output populated keeps the same id
as when it was first emitted without output.
``id`` is required on the LangChain ``ToolCall`` TypedDict, so there is
no "missing id" path to test from real LangChain usage; ``tc.get("id")``
is defensive only.
"""
def test_tool_call_id_lands_on_tool_content(self):
lc_msg = AIMessage(
content="",
tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_abc", "type": "tool_call"}],
)
msg = Message.from_lc_message(lc_msg)
tool_blocks = [b for b in msg.content_blocks if isinstance(b, ToolContent)]
assert len(tool_blocks) == 1
assert tool_blocks[0].id == "call_abc"
assert tool_blocks[0].name == "search"
assert tool_blocks[0].tool_input == {"q": "x"}
def test_id_is_stable_across_repeated_conversion(self):
"""Replaying from_lc_message produces matching ids.
This is what makes the id useful for dedup: a re-fired add_message
that carries the same logical tool call lands at the same id, not a
new one.
"""
lc_msg = AIMessage(
content="",
tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_abc", "type": "tool_call"}],
)
first = Message.from_lc_message(lc_msg)
second = Message.from_lc_message(lc_msg)
first_tool = next(b for b in first.content_blocks if isinstance(b, ToolContent))
second_tool = next(b for b in second.content_blocks if isinstance(b, ToolContent))
assert first_tool.id == second_tool.id == "call_abc"
def test_multiple_tool_calls_each_get_their_own_id(self):
lc_msg = AIMessage(
content="",
tool_calls=[
{"name": "search", "args": {}, "id": "call_a", "type": "tool_call"},
{"name": "calc", "args": {}, "id": "call_b", "type": "tool_call"},
],
)
msg = Message.from_lc_message(lc_msg)
tool_blocks = [b for b in msg.content_blocks if isinstance(b, ToolContent)]
assert [b.id for b in tool_blocks] == ["call_a", "call_b"]
def test_tool_calls_alongside_string_content(self):
"""An AIMessage with both text content and tool_calls keeps both.
Tool-calling agents commonly emit ``content="thinking..."`` plus a
``tool_calls`` list. Both should land on ``content_blocks``.
"""
lc_msg = AIMessage(
content="I'll search for that.",
tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_abc", "type": "tool_call"}],
)
msg = Message.from_lc_message(lc_msg)
text_blocks = [b for b in msg.content_blocks if isinstance(b, TextContent)]
tool_blocks = [b for b in msg.content_blocks if isinstance(b, ToolContent)]
assert [b.text for b in text_blocks] == ["I'll search for that."]
assert [b.id for b in tool_blocks] == ["call_abc"]