mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""Langflow Agent default runner implementation.
|
|
|
|
Real Langflow API integration supporting flow execution with streaming and non-streaming modes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import typing
|
|
import uuid
|
|
|
|
from langbot_plugin.api.definition.components.runner.runner import Runner
|
|
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
|
from langbot_plugin.api.entities.builtin.runner import (
|
|
RunnerContext,
|
|
RunnerResult,
|
|
)
|
|
from pkg.asset_gateway import register_assets
|
|
from pkg.langflow_client import (
|
|
AsyncLangflowClient,
|
|
LangflowAPIError,
|
|
LangflowConfigError,
|
|
extract_message_from_response,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_LANGBOT_ASSET_TOKEN_INPUT = "langbot_asset_run_token"
|
|
|
|
|
|
def _to_bool(value: typing.Any, default: bool = False) -> bool:
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int | float):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return default
|
|
|
|
|
|
def _to_int(value: typing.Any, default: int) -> int:
|
|
if value is None or isinstance(value, bool):
|
|
return default
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _to_float(value: typing.Any, default: float) -> float:
|
|
if value is None or isinstance(value, bool):
|
|
return default
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
class DefaultRunner(Runner):
|
|
"""Real Runner for Langflow API.
|
|
|
|
Supports running Langflow flows via the /api/v1/run/{flow_id} endpoint.
|
|
|
|
Configuration (static, from ctx.config):
|
|
- base-url: Langflow API base URL (default: http://localhost:7860)
|
|
- api-key: Langflow API key
|
|
- flow-id: The flow ID to run
|
|
- input-type: Input type for the flow (default: chat)
|
|
- output-type: Output type for the flow (default: chat)
|
|
- tweaks: JSON tweaks configuration (default: {})
|
|
|
|
Runtime state (from ctx.state):
|
|
- external.session_id: Langflow session ID for stateful sessions
|
|
"""
|
|
|
|
def _validate_config(self, ctx: RunnerContext) -> dict[str, typing.Any]:
|
|
"""Validate and return static configuration.
|
|
|
|
Raises LangflowConfigError on missing required fields.
|
|
"""
|
|
config = ctx.config or {}
|
|
|
|
base_url = config.get("base-url", "http://localhost:7860")
|
|
if not base_url:
|
|
raise LangflowConfigError("base-url is required", code="langflow.config_invalid")
|
|
|
|
api_key = config.get("api-key", "")
|
|
if not api_key:
|
|
raise LangflowConfigError("api-key is required", code="langflow.config_invalid")
|
|
|
|
flow_id = config.get("flow-id", "")
|
|
if not flow_id:
|
|
raise LangflowConfigError("flow-id is required", code="langflow.config_invalid")
|
|
|
|
# Parse tweaks from JSON string if needed
|
|
tweaks_raw = config.get("tweaks", "{}")
|
|
if isinstance(tweaks_raw, str):
|
|
try:
|
|
tweaks = json.loads(tweaks_raw) if tweaks_raw.strip() else {}
|
|
except json.JSONDecodeError:
|
|
# Tweaks may contain credentials; never echo the value or parser context.
|
|
raise LangflowConfigError("tweaks must be a JSON object", code="langflow.config_invalid") from None
|
|
else:
|
|
tweaks = tweaks_raw
|
|
# Missing/null/blank remain an empty override. Whitespace-only strings
|
|
# are a plugin convenience, not a claim that native json.loads accepted them.
|
|
if tweaks is None:
|
|
tweaks = {}
|
|
if not isinstance(tweaks, dict):
|
|
raise LangflowConfigError("tweaks must be a JSON object", code="langflow.config_invalid")
|
|
|
|
return {
|
|
"base_url": base_url,
|
|
"api_key": api_key,
|
|
"flow_id": flow_id,
|
|
"input_type": config.get("input-type", "chat"),
|
|
"output_type": config.get("output-type", "chat"),
|
|
"tweaks": tweaks,
|
|
"timeout": float(config.get("timeout", 120)),
|
|
"langbot_assets_enabled": _to_bool(config.get("langbot-assets-enabled"), False),
|
|
"asset_gateway_host": str(config.get("langbot-assets-gateway-host") or "0.0.0.0"),
|
|
"asset_gateway_port": _to_int(config.get("langbot-assets-gateway-port"), 8765),
|
|
"asset_gateway_request_timeout": _to_float(config.get("langbot-assets-gateway-request-timeout"), 60.0),
|
|
"asset_gateway_token_ttl": _to_float(config.get("langbot-assets-token-ttl"), 3600.0),
|
|
"asset_gateway_input_name": str(
|
|
config.get("langbot-assets-input-name") or DEFAULT_LANGBOT_ASSET_TOKEN_INPUT
|
|
),
|
|
}
|
|
|
|
async def _create_asset_gateway_registration(
|
|
self,
|
|
ctx: RunnerContext,
|
|
config: dict[str, typing.Any],
|
|
):
|
|
"""Register a run-scoped LangBot asset token in the shared MCP gateway.
|
|
|
|
The token is injected into the flow via a ``tweaks`` override that sets
|
|
the ``input_value`` of the component named by ``langbot-assets-input-name``.
|
|
The flow wires that component into the Agent so it passes the token as the
|
|
``run_token`` argument on LangBot Asset Gateway MCP tool calls. The
|
|
registration must be stopped when the run ends.
|
|
"""
|
|
return await register_assets(self, self.get_run_api(ctx), ctx, config)
|
|
|
|
def _get_session_id(self, ctx: RunnerContext) -> str:
|
|
"""Get or generate session ID for Langflow.
|
|
|
|
Priority:
|
|
1. ctx.state.conversation["external.session_id"]
|
|
2. Generate new UUID
|
|
|
|
Returns:
|
|
Session ID string
|
|
"""
|
|
# Check for existing session in state
|
|
session_id = ctx.state.conversation.get("external.session_id")
|
|
if session_id:
|
|
return session_id
|
|
|
|
# Generate new session ID
|
|
return str(uuid.uuid4())
|
|
|
|
def _should_stream(self, ctx: RunnerContext) -> bool:
|
|
"""Decide whether to request streaming from Langflow."""
|
|
configured = ctx.config.get("streaming")
|
|
if configured is not None:
|
|
return bool(configured)
|
|
return bool(ctx.runtime.metadata.get("streaming_supported", True))
|
|
|
|
async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]:
|
|
"""Run the Langflow flow.
|
|
|
|
Streams RunnerResult.message_delta chunks for streaming,
|
|
or yields message_completed for non-streaming.
|
|
"""
|
|
try:
|
|
config = self._validate_config(ctx)
|
|
except LangflowConfigError as e:
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=e.message,
|
|
code=e.code,
|
|
)
|
|
return
|
|
|
|
client = AsyncLangflowClient(
|
|
api_key=config["api_key"],
|
|
base_url=config["base_url"],
|
|
timeout=config["timeout"],
|
|
)
|
|
|
|
input_text = ctx.input.to_text()
|
|
session_id = self._get_session_id(ctx)
|
|
|
|
is_stream = self._should_stream(ctx)
|
|
|
|
# Optionally register a run-scoped LangBot asset token and inject it into
|
|
# the flow via a tweak that sets the target component's input_value. The
|
|
# flow wires that component into the Agent so it passes the token as the
|
|
# run_token argument on LangBot Asset Gateway MCP tool calls. The token is
|
|
# stopped in finally when the run ends.
|
|
tweaks = config["tweaks"]
|
|
asset_registration = None
|
|
if config["langbot_assets_enabled"]:
|
|
asset_registration = await self._create_asset_gateway_registration(ctx, config)
|
|
tweaks = {
|
|
**tweaks,
|
|
config["asset_gateway_input_name"]: {"input_value": asset_registration.token},
|
|
}
|
|
|
|
try:
|
|
accumulated_content = ""
|
|
message_count = 0
|
|
has_response = False
|
|
final_session_id = session_id
|
|
|
|
async for data in client.run_flow(
|
|
flow_id=config["flow_id"],
|
|
input_value=input_text,
|
|
input_type=config["input_type"],
|
|
output_type=config["output_type"],
|
|
tweaks=tweaks,
|
|
session_id=session_id,
|
|
stream=is_stream,
|
|
):
|
|
# Extract message content from response
|
|
message_text = extract_message_from_response(data)
|
|
if not message_text and not is_stream:
|
|
# Native non-streaming flows also expose structured outputs.
|
|
message_text = json.dumps(data, ensure_ascii=False, indent=2)
|
|
|
|
if message_text:
|
|
if is_stream:
|
|
# For streaming, accumulate and yield chunks
|
|
accumulated_content = message_text
|
|
message_count += 1
|
|
|
|
# Yield chunks periodically (every 8 events or when content changes significantly)
|
|
if message_count % 8 == 0 or len(message_text) > 0:
|
|
yield RunnerResult.message_delta(
|
|
ctx.run_id,
|
|
MessageChunk(
|
|
role="assistant",
|
|
content=accumulated_content,
|
|
is_final=False,
|
|
),
|
|
)
|
|
has_response = True
|
|
else:
|
|
# For non-streaming, just accumulate
|
|
accumulated_content = message_text
|
|
|
|
# Track session_id from response if present
|
|
if "session_id" in data:
|
|
final_session_id = data["session_id"]
|
|
|
|
# Final output
|
|
if accumulated_content:
|
|
if is_stream:
|
|
yield RunnerResult.message_delta(
|
|
ctx.run_id,
|
|
MessageChunk(
|
|
role="assistant",
|
|
content=accumulated_content,
|
|
is_final=True,
|
|
),
|
|
)
|
|
else:
|
|
# Non-streaming: return complete message
|
|
message = Message(
|
|
role="assistant",
|
|
content=accumulated_content,
|
|
)
|
|
yield RunnerResult.message_completed(ctx.run_id, message)
|
|
has_response = True
|
|
|
|
if not has_response:
|
|
raise LangflowAPIError(
|
|
"Langflow API returned no response",
|
|
code="langflow.api_error",
|
|
)
|
|
|
|
# Update state with session_id for next run
|
|
if final_session_id:
|
|
yield RunnerResult.state_updated(
|
|
ctx.run_id,
|
|
"external.session_id",
|
|
final_session_id,
|
|
scope="conversation",
|
|
)
|
|
|
|
yield RunnerResult.run_completed(ctx.run_id)
|
|
|
|
except LangflowAPIError as e:
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=e.message,
|
|
code=e.code,
|
|
)
|
|
return
|
|
except Exception as e:
|
|
logger.exception(f"Langflow runner unexpected error: {e}")
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=f"Langflow runner error: {e}",
|
|
code="langflow.unexpected_error",
|
|
)
|
|
return
|
|
finally:
|
|
if asset_registration is not None:
|
|
await asset_registration.stop()
|