From a3e8b40811dd72eb1650b299dccb20be7cd0c8cf Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Wed, 27 May 2026 12:25:21 -0300 Subject: [PATCH] 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. --- .../base/langflow/schema/content_types.py | 15 ++++ src/lfx/src/lfx/schema/content_types.py | 15 ++++ src/lfx/src/lfx/schema/message.py | 6 +- .../tests/unit/schema/test_content_types.py | 60 ++++++++++++++++ .../schema/test_message_content_blocks.py | 72 +++++++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/backend/base/langflow/schema/content_types.py b/src/backend/base/langflow/schema/content_types.py index 1e0c4360f3..c26282b093 100644 --- a/src/backend/base/langflow/schema/content_types.py +++ b/src/backend/base/langflow/schema/content_types.py @@ -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( diff --git a/src/lfx/src/lfx/schema/content_types.py b/src/lfx/src/lfx/schema/content_types.py index c792f1f898..9f8ee47f00 100644 --- a/src/lfx/src/lfx/schema/content_types.py +++ b/src/lfx/src/lfx/schema/content_types.py @@ -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( diff --git a/src/lfx/src/lfx/schema/message.py b/src/lfx/src/lfx/schema/message.py index 3c13fad683..268523d57f 100644 --- a/src/lfx/src/lfx/schema/message.py +++ b/src/lfx/src/lfx/schema/message.py @@ -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: diff --git a/src/lfx/tests/unit/schema/test_content_types.py b/src/lfx/tests/unit/schema/test_content_types.py index 401aeb3e6c..bb1fd76be0 100644 --- a/src/lfx/tests/unit/schema/test_content_types.py +++ b/src/lfx/tests/unit/schema/test_content_types.py @@ -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" diff --git a/src/lfx/tests/unit/schema/test_message_content_blocks.py b/src/lfx/tests/unit/schema/test_message_content_blocks.py index 48261a245d..b5a75c6e26 100644 --- a/src/lfx/tests/unit/schema/test_message_content_blocks.py +++ b/src/lfx/tests/unit/schema/test_message_content_blocks.py @@ -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"]