fix: accept Data and Message inputs in LoopComponent (#13636)

LoopComponent only declared input_types=["DataFrame", "Table"] on its
`data` handle, even though the class is documented to iterate over Data or
Message objects and ships a `_convert_message_to_data` helper. Any
Message-producing component (ChatInput, Agent, ...) was therefore rejected
at connect time, which made agent-assisted flow builders retry until they
exhausted the LangGraph recursion limit and crashed.

Changes:
- Add "Data" and "Message" to the `data` handle input_types (covers
  Data/JSON via TYPE_MIGRATIONS) and clarify the info text.
- Declare explicit output types: item -> ["Data"], done ->
  ["DataFrame", "Table"], so describe_component and the registry surface
  type metadata for downstream planning.
- Convert Message inputs to a clean Data object in `_validate_data` before
  validation. Message subclasses Data, so without this it was accepted
  verbatim as a single Data carrying the whole message envelope instead of
  its payload.
- Regenerate the component index, the "Research Translation Loop" starter
  project, and the en.json locale string to match.

Add lfx regression tests covering the input/output type metadata, the
connect-time compatibility check, and the Message -> Data conversion.
This commit is contained in:
Eric Hare
2026-06-16 07:32:44 -07:00
parent 11f2591f44
commit 305a85a0fb
5 changed files with 127 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,16 @@ 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 return a list of Data objects.
Message inputs (e.g. from ChatInput or Agent) are converted to a clean
Data object first so iteration sees the message payload rather than the
full Message envelope. `Message` subclasses `Data`, so without this it
would be accepted verbatim as a single Data carrying all of the
message metadata. `validate_data_input` handles DataFrame/Data/list.
"""
if isinstance(data, Message):
data = self._convert_message_to_data(data)
return validate_data_input(data)
def get_loop_body_vertices(self) -> set[str]:

View File

@ -0,0 +1,81 @@
"""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.
"""
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)
assert validated[0].data == {"text": "hello world"}
def test_validate_data_still_handles_dataframe():
"""The existing DataFrame path is preserved."""
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)