Skip to content

Commit 9865e5e

Browse files
committed
feat(stepflow): bridge the executor seam to a Stepflow backend
Add StepflowExecutor, an lfx Executor that translates a Langflow flow to a Stepflow Flow, runs it via stepflow_py (local orchestrator by default, or a remote endpoint), and streams results back through the seam. Registered under the `lfx.executors` entry-point as kind "stepflow". Map the lfx run-path contract on both edges: inputs in {INPUT_FIELD_NAME: ...} shape plus session_id become the flow's $.message / $.session_id; the run's per-item result (fetched via get_run_items after completion) is assembled into list[RunOutputs] so /run serializers find the output. A non-success terminal status raises, matching Graph.arun's fail-loud behavior.
1 parent e613388 commit 9865e5e

5 files changed

Lines changed: 585 additions & 0 deletions

File tree

src/langflow-stepflow/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ dev = [
5555
"httpx>=0.25.0",
5656
]
5757

58+
[project.entry-points."lfx.executors"]
59+
stepflow = "langflow_stepflow.executor:StepflowExecutor"
60+
5861
[build-system]
5962
requires = ["hatchling"]
6063
build-backend = "hatchling.build"

src/langflow-stepflow/src/langflow_stepflow/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
providing translation and execution capabilities.
55
"""
66

7+
from .executor import StepflowExecutor
78
from .translation.translator import LangflowConverter
89

910
__version__ = "0.1.0"
1011
__all__ = [
1112
"LangflowConverter",
13+
"StepflowExecutor",
1214
]
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
"""StepflowExecutor: bridge between the lfx execution seam and a Stepflow orchestrator.
2+
3+
The lfx ``Executor`` ABC accepts a ``Unit`` (graph + inputs + runtime options) and yields
4+
``StepResult`` items terminated by a ``RunComplete``. This adapter:
5+
6+
1. Translates the Langflow flow into a Stepflow ``Flow`` (preferred input:
7+
``runtime_options['langflow_json']``; fallback: ``graph.dump()``/``to_dict()``).
8+
2. Boots a local Stepflow orchestrator (default) or talks to one at ``STEPFLOW_ENDPOINT``,
9+
stores the translated flow, kicks off a run, and streams status events back as
10+
``StepResult`` payloads.
11+
3. Yields a final ``RunComplete`` with whatever the orchestrator considered the run's
12+
outputs.
13+
14+
The default orchestrator config wires ``/builtin`` to the in-process builtin plugin and
15+
``/langflow`` to a gRPC worker launched as ``python -m langflow_stepflow.worker``. Both
16+
are overridable: pass a prebuilt ``StepflowConfig`` to ``__init__`` if you want a
17+
different topology (e.g. an externally managed worker pool).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
import logging
24+
import os
25+
import sys
26+
from typing import TYPE_CHECKING, Any
27+
28+
from lfx.execution.executor import Executor
29+
from lfx.execution.types import RunComplete, StepResult
30+
31+
if TYPE_CHECKING:
32+
from collections.abc import AsyncIterator
33+
34+
from lfx.execution.types import Unit
35+
36+
logger = logging.getLogger(__name__)
37+
38+
39+
def _value_to_python(result_value: Any) -> Any:
40+
"""Convert a protobuf Struct ``Value`` (or already-native value) to a plain object."""
41+
if result_value is None:
42+
return None
43+
if isinstance(result_value, str | int | float | bool | dict | list):
44+
return result_value
45+
try:
46+
from google.protobuf.json_format import MessageToDict
47+
48+
return MessageToDict(result_value)
49+
except Exception: # noqa: BLE001 - any non-Value payload degrades to its repr below
50+
return result_value
51+
52+
53+
def _coerce_text(output: Any) -> str:
54+
"""Best-effort extraction of a chat message string from a flow output blob."""
55+
if output is None:
56+
return ""
57+
if isinstance(output, str):
58+
return output
59+
if isinstance(output, dict):
60+
for key in ("text", "message", "result"):
61+
value = output.get(key)
62+
if isinstance(value, str):
63+
return value
64+
return str(output)
65+
66+
67+
def _build_messages(text: str) -> list[Any]:
68+
"""Wrap extracted text in a ChatOutputResponse so text-extraction paths find it."""
69+
if not text:
70+
return []
71+
from lfx.utils.schemas import ChatOutputResponse
72+
73+
return [ChatOutputResponse(message=text, type="text")]
74+
75+
76+
class StepflowExecutor(Executor):
77+
"""Execute a Langflow flow by translating it to Stepflow and running it via stepflow_py.
78+
79+
Args:
80+
endpoint: If set, connect to an already-running orchestrator at this address.
81+
Otherwise boot a local orchestrator via ``StepflowClient.local`` using
82+
``config`` (or the default config below).
83+
config: Optional ``StepflowConfig`` controlling plugins/routes for local mode.
84+
If omitted, a default is built that registers the langflow worker.
85+
worker_command: Command used by the default config to launch the worker.
86+
Defaults to ``[sys.executable, "-m", "langflow_stepflow.worker"]``.
87+
"""
88+
89+
kind = "stepflow"
90+
91+
def __init__(
92+
self,
93+
*,
94+
endpoint: str | None = None,
95+
config: Any = None,
96+
worker_command: list[str] | None = None,
97+
) -> None:
98+
self._endpoint = endpoint or os.environ.get("STEPFLOW_ENDPOINT")
99+
self._config = config
100+
self._worker_command = worker_command
101+
102+
async def execute(self, unit: Unit) -> AsyncIterator[StepResult | RunComplete]:
103+
flow_dict = self._translate_to_dict(unit)
104+
# The seam delivers inputs in the lfx run-path shape ({INPUT_FIELD_NAME: ...});
105+
# the translated flow reads $.message / $.session_id, so map before submitting.
106+
# The original lfx input is preserved separately for RunOutputs.inputs.
107+
run_input = self._build_run_input(unit, 0)
108+
original_input = unit.inputs[0] if unit.inputs else {}
109+
110+
if self._endpoint:
111+
# Remote-orchestrator path. Kept narrow on purpose: the connect/auth surface
112+
# is still moving upstream, so this hands off to whatever ``connect`` looks
113+
# like at runtime rather than baking in assumptions.
114+
from stepflow_py.client import StepflowClient
115+
116+
async with await StepflowClient.connect(self._endpoint) as client:
117+
async for item in self._run_through(client, flow_dict, run_input, original_input):
118+
yield item
119+
return
120+
121+
from stepflow_py.client import StepflowClient
122+
123+
config = self._config or self._default_config()
124+
async with StepflowClient.local(config) as client:
125+
async for item in self._run_through(client, flow_dict, run_input, original_input):
126+
yield item
127+
128+
def _build_run_input(self, unit: Unit, index: int = 0) -> dict[str, Any]:
129+
"""Map an lfx run-path input dict to the Stepflow run input the flow expects.
130+
131+
The seam delivers inputs as ``[{INPUT_FIELD_NAME: <value>}]`` and carries
132+
``session_id`` separately in ``runtime_options`` (set by ``Graph.arun``). The
133+
translated flow reads ``$.message`` (ChatInput/passthrough) and ``$.session_id``
134+
(Memory/Agent), so both must be surfaced at the top level of the submitted input.
135+
"""
136+
from lfx.schema.schema import INPUT_FIELD_NAME
137+
138+
raw = unit.inputs[index] if unit.inputs and index < len(unit.inputs) else {}
139+
payload = {key: value for key, value in raw.items() if key != INPUT_FIELD_NAME}
140+
if INPUT_FIELD_NAME in raw:
141+
payload["message"] = raw[INPUT_FIELD_NAME]
142+
# Mirror process.py's effective_session_id semantics: always present, "" when unset.
143+
payload["session_id"] = unit.runtime_options.get("session_id") or ""
144+
return payload
145+
146+
def translate(self, unit: Unit) -> Any:
147+
"""Public translation hook so callers (and tests) can drive the converter alone."""
148+
return self._translate(unit)
149+
150+
def _translate(self, unit: Unit) -> Any:
151+
from langflow_stepflow.translation.translator import LangflowConverter
152+
153+
langflow_json = unit.runtime_options.get("langflow_json")
154+
if langflow_json is None:
155+
langflow_json = self._dump_graph(unit.graph)
156+
if isinstance(langflow_json, str):
157+
langflow_json = json.loads(langflow_json)
158+
return LangflowConverter().convert(langflow_json)
159+
160+
def _translate_to_dict(self, unit: Unit) -> dict[str, Any]:
161+
import msgspec
162+
163+
return msgspec.to_builtins(self._translate(unit))
164+
165+
async def _run_through(
166+
self,
167+
client: Any,
168+
flow_dict: dict[str, Any],
169+
run_input: dict[str, Any],
170+
original_input: dict[str, Any],
171+
) -> AsyncIterator[StepResult | RunComplete]:
172+
store_resp = await client.store_flow(flow_dict)
173+
run_resp = await client.submit(store_resp.flow_id, run_input)
174+
run_id = run_resp.summary.run_id
175+
176+
# Raw events are streamed as StepResult for the event_manager/streaming path; we
177+
# only need the terminal status here to decide success vs failure.
178+
status: Any = None
179+
async for event in client.status_events(run_id, include_results=True):
180+
yield StepResult(payload=event)
181+
if event.HasField("run_completed"):
182+
status = event.run_completed.status
183+
break
184+
185+
# A failed/cancelled run must fail loud, like the in-process seam; otherwise the
186+
# caller can't tell an empty success from a failure.
187+
self._raise_for_failed_status(status, run_id)
188+
189+
# The run's output rides on the per-item results, not the status stream: a single
190+
# submit() run emits no item_completed event, so fetch the results explicitly once
191+
# the run is terminal (see StepflowClient.submit / get_run_items). get_run_items
192+
# already returns native dicts (MessageToDict), so each item's "output" is plain.
193+
items = await client.get_run_items(run_id)
194+
if len(items) > 1:
195+
# execute() submits a single input today, so only item 0 is expected. Surface
196+
# the drop rather than silently swallowing extra items if that ever changes.
197+
logger.warning(
198+
"Stepflow run %s produced %d item results; only index 0 is mapped "
199+
"(multi-item submission is not yet supported).",
200+
run_id,
201+
len(items),
202+
)
203+
204+
result_value = items[0].get("output") if items else None
205+
yield RunComplete(outputs=[self._to_run_outputs(result_value, original_input)])
206+
207+
@staticmethod
208+
def _raise_for_failed_status(status: Any, run_id: str) -> None:
209+
"""Raise if the run reached a terminal non-success state.
210+
211+
``Graph.arun`` is expected to fail loud on a failed run; the stepflow backend must
212+
match that rather than returning a success-shaped empty ``RunOutputs``. A ``None``
213+
status (stream ended without a terminal event) is left to the coordinator.
214+
"""
215+
from stepflow_py.proto import common_pb2
216+
217+
completed = common_pb2.ExecutionStatus.EXECUTION_STATUS_COMPLETED
218+
if status is not None and status != completed:
219+
from langflow_stepflow.exceptions import ExecutionError
220+
221+
name = common_pb2.ExecutionStatus.Name(status)
222+
msg = f"Stepflow run {run_id} ended with non-success status: {name}"
223+
raise ExecutionError(msg)
224+
225+
@staticmethod
226+
def _to_run_outputs(result_value: Any, run_input: dict[str, Any]) -> Any:
227+
"""Map a Stepflow item result (protobuf Value) into the lfx RunOutputs contract.
228+
229+
``Graph.arun`` returns ``list[RunOutputs]`` and downstream /run serializers read
230+
``RunOutputs.outputs[i]`` as ``ResultData``. We surface the run's output value
231+
under the ChatOutput "message" output so the existing extraction finds it, and keep
232+
the original lfx input dict on ``RunOutputs.inputs`` (matching _arun_legacy).
233+
"""
234+
from lfx.graph.schema import ResultData, RunOutputs
235+
from lfx.schema.schema import OutputValue
236+
237+
output = _value_to_python(result_value)
238+
text = _coerce_text(output)
239+
message = output if isinstance(output, dict | list | str) else text
240+
241+
result_data = ResultData(
242+
results={"message": output},
243+
outputs={"message": OutputValue(message=message, type="text")},
244+
messages=_build_messages(text),
245+
)
246+
return RunOutputs(inputs=run_input, outputs=[result_data])
247+
248+
def _default_config(self) -> Any:
249+
from stepflow_py.config import (
250+
BuiltinPluginConfig,
251+
GrpcPluginConfig,
252+
InMemoryStoreConfig,
253+
RouteRule,
254+
StepflowConfig,
255+
)
256+
257+
command, args = self._worker_invocation()
258+
return StepflowConfig(
259+
plugins={
260+
"builtin": BuiltinPluginConfig(),
261+
"langflow": GrpcPluginConfig(command=command, args=args, queueName="langflow"),
262+
},
263+
routes={
264+
"/builtin": [RouteRule(plugin="builtin")],
265+
"/langflow": [RouteRule(plugin="langflow")],
266+
},
267+
storageConfig=InMemoryStoreConfig(),
268+
)
269+
270+
def _worker_invocation(self) -> tuple[str, list[str]]:
271+
if self._worker_command:
272+
return self._worker_command[0], list(self._worker_command[1:])
273+
return sys.executable, ["-m", "langflow_stepflow.worker"]
274+
275+
@staticmethod
276+
def _dump_graph(graph: Any) -> dict[str, Any]:
277+
for attr in ("dump", "to_dict", "to_json"):
278+
fn = getattr(graph, attr, None)
279+
if callable(fn):
280+
result = fn()
281+
return json.loads(result) if isinstance(result, str) else result
282+
msg = (
283+
"StepflowExecutor needs a Langflow JSON workflow. Pass it explicitly via "
284+
"runtime_options['langflow_json'] or expose dump()/to_dict() on the graph."
285+
)
286+
raise TypeError(msg)

0 commit comments

Comments
 (0)