mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 23:13:58 +08:00
fix: accept Data and Message inputs in LoopComponent (#13646)
* fix: accept Data and Message inputs in LoopComponent LoopComponent.input_types only listed DataFrame and Table, rejecting connections from Data- and Message-producing components at build time. The class description and _convert_message_to_data method already intended to support these types. - Add Data and Message to input_types on the data HandleInput - Add types=["Data"] to Item output, types=["DataFrame", "Table"] to Done output - Convert Message to Data in _validate_data before calling validate_data_input Fixes #13636 * fix: normalize mixed Message/DataFrame lists in Loop._validate_data Addresses the CodeRabbit review on #13646. The list branch converted Message items to Data but left DataFrame items untouched, so a mixed [Message, DataFrame] input became [Data, DataFrame] — which validate_data_input rejects because DataFrame is not a Data subclass. Normalize each list item: convert Message via _convert_message_to_data and expand DataFrame/Table to its rows via to_data_list(), so any accepted input shape (including a mixed list) yields a homogeneous list[Data]. Add lfx regression tests covering the input/output type metadata, the connect-time type-compatibility check, and _validate_data across single Message/Data/DataFrame, list[Message], the mixed-list case, and rejection of lists with non-coercible items. * chore: regenerate Loop component index, starter project, and locale Rebuild the component index, the "Research Translation Loop" starter project, and the en.json locale string for the Loop input_types / output types / info changes on release-1.10.1. * chore: trigger CI re-run --------- Co-authored-by: dymux <putramkti@users.noreply.github.com> Co-authored-by: Eric Hare <ericrhare@gmail.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@ -4411,7 +4411,7 @@
|
||||
"components.loopcomponent.description.dc14755d": "Iterates through Data or Message objects, processing items individually and aggregating results from loop inputs.",
|
||||
"components.loopcomponent.display_name.f2f6a018": "Loop",
|
||||
"components.loopcomponent.inputs.data.display_name.7abc49df": "Inputs",
|
||||
"components.loopcomponent.inputs.data.info.bb8c0783": "The initial DataFrame to iterate over.",
|
||||
"components.loopcomponent.inputs.data.info.cb3819f9": "Data to iterate over. Accepts DataFrame, Table, Data, or Message.",
|
||||
"components.loopcomponent.outputs.done.display_name.11a6767d": "Done",
|
||||
"components.loopcomponent.outputs.item.display_name.652bcc3a": "Item",
|
||||
"components.maritalk.description.f2ad2e6b": "Generates text using MariTalk LLMs.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -28,8 +28,8 @@ class LoopComponent(Component):
|
||||
HandleInput(
|
||||
name="data",
|
||||
display_name="Inputs",
|
||||
info="The initial DataFrame to iterate over.",
|
||||
input_types=["DataFrame", "Table"],
|
||||
info="Data to iterate over. Accepts DataFrame, Table, Data, or Message.",
|
||||
input_types=["DataFrame", "Table", "Data", "Message"],
|
||||
),
|
||||
]
|
||||
|
||||
@ -38,11 +38,18 @@ class LoopComponent(Component):
|
||||
display_name="Item",
|
||||
name="item",
|
||||
method="item_output",
|
||||
types=["Data"],
|
||||
allows_loop=True,
|
||||
loop_types=["Message"],
|
||||
group_outputs=True,
|
||||
),
|
||||
Output(display_name="Done", name="done", method="done_output", group_outputs=True),
|
||||
Output(
|
||||
display_name="Done",
|
||||
name="done",
|
||||
method="done_output",
|
||||
types=["DataFrame", "Table"],
|
||||
group_outputs=True,
|
||||
),
|
||||
]
|
||||
|
||||
def initialize_data(self) -> None:
|
||||
@ -71,7 +78,34 @@ class LoopComponent(Component):
|
||||
return convert_to_data(message, auto_parse=False)
|
||||
|
||||
def _validate_data(self, data):
|
||||
"""Validate and return a list of Data objects."""
|
||||
"""Validate and normalize the input to a flat list of Data objects.
|
||||
|
||||
Coerces each accepted input type into Data so `validate_data_input`
|
||||
always receives a homogeneous payload:
|
||||
- Message -> a clean Data via `_convert_message_to_data` (just the
|
||||
message payload, not the full envelope). `Message` subclasses
|
||||
`Data`, so without this it would slip through as a single Data
|
||||
carrying all of the message metadata.
|
||||
- DataFrame/Table -> expanded to its rows.
|
||||
- Data / list[Data] -> passed through unchanged.
|
||||
|
||||
Lists are normalized item-by-item so a mixed `[Message, DataFrame,
|
||||
Data]` still yields `list[Data]` instead of being rejected as a
|
||||
mixed-type list (`validate_data_input` requires every list item to be
|
||||
a Data, and DataFrame is not a Data subclass).
|
||||
"""
|
||||
if isinstance(data, Message):
|
||||
data = self._convert_message_to_data(data)
|
||||
elif isinstance(data, list):
|
||||
normalized: list = []
|
||||
for item in data:
|
||||
if isinstance(item, Message):
|
||||
normalized.append(self._convert_message_to_data(item))
|
||||
elif isinstance(item, DataFrame):
|
||||
normalized.extend(item.to_data_list())
|
||||
else:
|
||||
normalized.append(item)
|
||||
data = normalized
|
||||
return validate_data_input(data)
|
||||
|
||||
def get_loop_body_vertices(self) -> set[str]:
|
||||
|
||||
@ -0,0 +1,154 @@
|
||||
"""Regression tests for LoopComponent input/output type metadata.
|
||||
|
||||
Covers the bug where the Loop's ``data`` handle only declared
|
||||
``input_types=["DataFrame", "Table"]`` even though the component is
|
||||
documented to iterate over ``Data`` or ``Message`` objects (and ships a
|
||||
``_convert_message_to_data`` helper). The missing types caused any
|
||||
Message/Data-producing component (ChatInput, Agent, ...) to be rejected at
|
||||
connect time, which made agent-assisted flow builders retry until they hit
|
||||
the LangGraph recursion limit. See issue #13636.
|
||||
|
||||
Also covers normalization of mixed-type lists in ``_validate_data`` so a
|
||||
``[Message, DataFrame, Data]`` input still yields a homogeneous ``list[Data]``
|
||||
instead of being rejected by ``validate_data_input``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from lfx.components.flow_controls.loop import LoopComponent
|
||||
from lfx.graph.edge.base import types_compatible
|
||||
from lfx.schema.data import Data
|
||||
from lfx.schema.dataframe import DataFrame
|
||||
from lfx.schema.message import Message
|
||||
|
||||
|
||||
def _data_input(loop: LoopComponent):
|
||||
return next(i for i in loop.inputs if i.name == "data")
|
||||
|
||||
|
||||
def _output(loop: LoopComponent, name: str):
|
||||
return next(o for o in loop.outputs if o.name == name)
|
||||
|
||||
|
||||
def test_data_input_accepts_data_and_message():
|
||||
"""The data handle must accept Data and Message alongside DataFrame/Table."""
|
||||
loop = LoopComponent()
|
||||
input_types = _data_input(loop).input_types
|
||||
assert "DataFrame" in input_types
|
||||
assert "Table" in input_types
|
||||
assert "Data" in input_types
|
||||
assert "Message" in input_types
|
||||
|
||||
|
||||
def test_outputs_declare_types():
|
||||
"""Both outputs must advertise their types so agents can plan downstream."""
|
||||
loop = LoopComponent()
|
||||
assert _output(loop, "item").types == ["Data"]
|
||||
assert _output(loop, "done").types == ["DataFrame", "Table"]
|
||||
|
||||
|
||||
def test_message_outputs_are_connectable_to_loop():
|
||||
"""A Message- or Data-producing output must be compatible with Loop.data.
|
||||
|
||||
This is the connect-time check that previously failed and triggered the
|
||||
agent-builder retry loop.
|
||||
"""
|
||||
loop_input = _data_input(LoopComponent()).input_types
|
||||
# ChatInput / Agent emit Message; Agent.structured_response emits Data/JSON.
|
||||
assert types_compatible(["Message"], loop_input)
|
||||
assert types_compatible(["Data", "JSON"], loop_input)
|
||||
assert types_compatible(["JSON"], loop_input)
|
||||
assert types_compatible(["DataFrame"], loop_input)
|
||||
|
||||
|
||||
def test_validate_data_converts_message_to_clean_data():
|
||||
"""A Message input is converted to a Data carrying just its payload.
|
||||
|
||||
Message subclasses Data, so without the explicit conversion it would be
|
||||
accepted verbatim as a single Data object holding the whole message
|
||||
envelope (sender, session_id, timestamp, ...) instead of the payload.
|
||||
"""
|
||||
loop = LoopComponent()
|
||||
validated = loop._validate_data(Message(text="hello world"))
|
||||
|
||||
assert isinstance(validated, list)
|
||||
assert len(validated) == 1
|
||||
assert isinstance(validated[0], Data)
|
||||
# Must be a plain Data, not the Message passed through verbatim (Message
|
||||
# subclasses Data, so the isinstance check above is not enough on its own).
|
||||
assert not isinstance(validated[0], Message)
|
||||
assert validated[0].data == {"text": "hello world"}
|
||||
|
||||
|
||||
def test_validate_data_still_handles_dataframe():
|
||||
"""The existing DataFrame path is preserved (expanded to its rows)."""
|
||||
loop = LoopComponent()
|
||||
validated = loop._validate_data(DataFrame([Data(text="a"), Data(text="b")]))
|
||||
|
||||
assert isinstance(validated, list)
|
||||
assert len(validated) == 2
|
||||
assert all(isinstance(item, Data) for item in validated)
|
||||
|
||||
|
||||
def test_validate_data_still_handles_single_data():
|
||||
"""A single Data input yields a one-item list."""
|
||||
loop = LoopComponent()
|
||||
single = Data(text="single")
|
||||
validated = loop._validate_data(single)
|
||||
|
||||
assert validated == [single]
|
||||
|
||||
|
||||
def test_validate_data_handles_list_of_messages():
|
||||
"""A list of Message objects is converted item-by-item to clean Data."""
|
||||
loop = LoopComponent()
|
||||
validated = loop._validate_data([Message(text="m1"), Message(text="m2")])
|
||||
|
||||
assert len(validated) == 2
|
||||
assert all(isinstance(item, Data) for item in validated)
|
||||
assert [d.data for d in validated] == [{"text": "m1"}, {"text": "m2"}]
|
||||
|
||||
|
||||
def test_validate_data_normalizes_mixed_list():
|
||||
"""A mixed [Message, DataFrame, Data] list yields a flat list[Data].
|
||||
|
||||
Regression for the CodeRabbit review on #13646: converting only the
|
||||
Message items produced ``[Data, DataFrame, Data]``, which
|
||||
``validate_data_input`` rejects because DataFrame is not a Data subclass.
|
||||
DataFrame items must be expanded to their rows so the list stays
|
||||
homogeneous.
|
||||
"""
|
||||
loop = LoopComponent()
|
||||
mixed = [
|
||||
Message(text="m"),
|
||||
DataFrame([Data(text="r1"), Data(text="r2")]),
|
||||
Data(text="d"),
|
||||
]
|
||||
validated = loop._validate_data(mixed)
|
||||
|
||||
assert len(validated) == 4
|
||||
assert all(isinstance(item, Data) for item in validated)
|
||||
assert [d.data for d in validated] == [
|
||||
{"text": "m"},
|
||||
{"text": "r1"},
|
||||
{"text": "r2"},
|
||||
{"text": "d"},
|
||||
]
|
||||
|
||||
|
||||
def test_validate_data_rejects_invalid_input():
|
||||
"""Invalid input types still raise a clear TypeError."""
|
||||
loop = LoopComponent()
|
||||
with pytest.raises(TypeError, match="must be a DataFrame"):
|
||||
loop._validate_data("not data")
|
||||
|
||||
|
||||
def test_validate_data_rejects_list_with_non_coercible_item():
|
||||
"""A list containing a non-Data/Message/DataFrame item is still rejected.
|
||||
|
||||
The list branch only coerces Message and DataFrame items; anything else
|
||||
falls through unchanged and must be caught by validate_data_input so the
|
||||
documented contract (every item resolves to Data) is preserved.
|
||||
"""
|
||||
loop = LoopComponent()
|
||||
with pytest.raises(TypeError, match="must be a DataFrame"):
|
||||
loop._validate_data([Data(text="ok"), "bad"])
|
||||
Reference in New Issue
Block a user