mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 17:31:33 +08:00
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:
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user