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:
dymux
2026-06-16 22:55:36 +07:00
committed by GitHub
parent c4d27818a1
commit 461d3776db
5 changed files with 218 additions and 18 deletions

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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]:

View File

@ -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"])