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