"""Dify Agent default runner implementation (Phase 1). Real Dify Service API integration supporting chat, agent, and workflow app types. """ from __future__ import annotations import base64 import hashlib import json import logging import re import time import typing import uuid from contextlib import aclosing from langbot_plugin.api.definition.components.runner.runner import Runner from langbot_plugin.api.entities.builtin.provider.message import MessageChunk from langbot_plugin.api.entities.builtin.runner import ( InteractionAction, InteractionField, InteractionOption, InteractionRequest, RunnerContext, RunnerResult, ) from langbot_plugin.entities.io.errors import ActionCallError from pkg.asset_gateway import register_assets from pkg.dify_client import ( AsyncDifyClient, DifyAPIError, DifyConfigError, extract_text_from_output, process_thinking_content, ) from pkg.scoped_identity import scoped_identity logger = logging.getLogger(__name__) DEFAULT_LANGBOT_ASSET_TOKEN_INPUT = "langbot_asset_run_token" INTERACTION_STORAGE_PREFIX = "dify.interactions." 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 def _get_adapter_params(ctx: RunnerContext) -> dict[str, typing.Any]: """Read single-run business params from adapter.extra.params.""" if ctx.adapter is None: return {} params = (ctx.adapter.extra or {}).get("params") return dict(params) if isinstance(params, dict) else {} def _is_uuid_string(value: typing.Any) -> bool: if not isinstance(value, str) or not value: return False try: uuid.UUID(value) except ValueError: return False return True def _attachment_get(attachment: typing.Any, key: str, default: typing.Any = None) -> typing.Any: if isinstance(attachment, dict): return attachment.get(key, default) return getattr(attachment, key, default) def _content_get(content: typing.Any, key: str, default: typing.Any = None) -> typing.Any: if isinstance(content, dict): return content.get(key, default) return getattr(content, key, default) def _content_type_from_base64(value: typing.Any, default: str) -> str: if isinstance(value, str) and value.startswith("data:") and ";base64," in value: return value[5 : value.find(";base64,")] or default return default def _decode_content(value: typing.Any) -> bytes | None: max_bytes = 10 * 1024 * 1024 def check_size(size: int) -> None: if size > max_bytes: raise DifyAPIError("Input attachment exceeds the 10 MiB size limit", code="dify.input_error") if isinstance(value, (bytes, bytearray)): check_size(len(value)) return bytes(value) if isinstance(value, str): start = value.find(",") + 1 if value.startswith("data:") else 0 # Check before slicing or decoding: decoding itself allocates memory. if len(value) - start > 4 * ((max_bytes + 2) // 3) + 4: check_size(max_bytes + 1) payload = value[start:] try: decoded = base64.b64decode(payload, validate=True) except (ValueError, base64.binascii.Error): # Retain the existing plain-text attachment fallback, also bounded. check_size(len(value)) decoded = value.encode("utf-8") check_size(len(decoded)) return decoded return None def _attachments_from_contents(contents: list[typing.Any]) -> list[dict[str, typing.Any]]: attachments: list[dict[str, typing.Any]] = [] for item in contents or []: item_type = _content_get(item, "type") if item_type == "image_base64": content = _content_get(item, "image_base64") attachments.append( { "type": "image", "name": "image", "content": content, "content_type": _content_type_from_base64(content, "image/jpeg"), } ) elif item_type == "file_url": attachments.append( { "type": "file", "name": _content_get(item, "file_name") or "file", "url": _content_get(item, "file_url"), } ) elif item_type == "file_base64": content = _content_get(item, "file_base64") attachments.append( { "type": "file", "name": _content_get(item, "file_name") or "file", "content": content, "content_type": _content_type_from_base64(content, "application/octet-stream"), } ) return attachments def _int_or_none(value: typing.Any) -> int | None: if value is None or isinstance(value, bool): return None try: return int(value) except (TypeError, ValueError): return None def _first_int(mapping: dict[str, typing.Any], *keys: str) -> int | None: for key in keys: value = _int_or_none(mapping.get(key)) if value is not None: return value return None def _usage_from_payload(payload: typing.Any) -> dict[str, typing.Any] | None: if not isinstance(payload, dict): return None usage = payload.get("usage") if not isinstance(usage, dict): metadata = payload.get("metadata") if isinstance(metadata, dict): usage = metadata.get("usage") if not isinstance(usage, dict): data = payload.get("data") if isinstance(data, dict): usage = data.get("usage") if not isinstance(usage, dict): token_keys = { "prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens", "input_count", "output_count", "token_count", "total_count", } if token_keys.intersection(data): usage = data token_keys = { "prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens", "input_count", "output_count", "token_count", "total_count", } if not isinstance(usage, dict) and token_keys.intersection(payload): usage = payload if not isinstance(usage, dict): return None normalized = dict(usage) prompt_tokens = _first_int(usage, "prompt_tokens", "input_tokens", "input_count") completion_tokens = _first_int(usage, "completion_tokens", "output_tokens", "output_count") total_tokens = _first_int(usage, "total_tokens", "token_count", "total_count") if prompt_tokens is not None: normalized["prompt_tokens"] = prompt_tokens if completion_tokens is not None: normalized["completion_tokens"] = completion_tokens if total_tokens is None and (prompt_tokens is not None or completion_tokens is not None): total_tokens = (prompt_tokens or 0) + (completion_tokens or 0) if total_tokens is not None: normalized["total_tokens"] = total_tokens return normalized or None def _mapping_value(mapping: typing.Any, *keys: str) -> typing.Any: """Return a protocol value using explicit aliases with case-insensitive matching.""" if not isinstance(mapping, dict): return None for key in keys: if key in mapping: return mapping[key] lowered = {str(key).lower(): value for key, value in mapping.items()} for key in keys: if key.lower() in lowered: return lowered[key.lower()] return None def _protocol_name(value: typing.Any) -> str: if not isinstance(value, str): return "" return re.sub(r"[-\s]+", "_", value.strip().lower()) def _workflow_run_id(event: dict[str, typing.Any], fallback: str = "") -> str: data = event.get("data") if isinstance(event.get("data"), dict) else {} value = _mapping_value(data, "workflow_run_id", "workflowRunId") if value in (None, ""): value = _mapping_value(event, "workflow_run_id", "workflowRunId") return str(value or fallback).strip() def _human_input_reason(event: dict[str, typing.Any]) -> dict[str, typing.Any] | None: data = event.get("data") if isinstance(event.get("data"), dict) else {} if _protocol_name(event.get("event")) == "human_input_required": return data raw_reasons = _mapping_value(data, "reasons", "pause_reasons", "pauseReasons") if isinstance(raw_reasons, dict): reasons = [raw_reasons] elif isinstance(raw_reasons, list): reasons = raw_reasons else: raw_reason = _mapping_value(data, "reason", "pause_reason", "pauseReason") reasons = [raw_reason] if isinstance(raw_reason, dict) else [] for reason in reasons: if not isinstance(reason, dict): continue reason_type = _mapping_value(reason, "TYPE", "type", "reason_type", "reasonType") if _protocol_name(reason_type) == "human_input_required": return reason return None def _form_field_name(field: dict[str, typing.Any]) -> str: return str( _mapping_value(field, "output_variable_name", "outputVariableName", "variable", "name", "id") or "" ).strip() def _form_field_options(field: dict[str, typing.Any]) -> list[str]: source = _mapping_value(field, "option_source", "optionSource") raw_options = _mapping_value(source, "value") if isinstance(source, dict) else None if raw_options is None: raw_options = _mapping_value(field, "options") if isinstance(raw_options, str): raw_options = [line.strip() for line in raw_options.splitlines() if line.strip()] if not isinstance(raw_options, list): return [] options: list[str] = [] for option in raw_options: if isinstance(option, dict): value = _mapping_value(option, "value", "label", "name") else: value = option if value is not None and str(value).strip(): options.append(str(value).strip()) return options def _form_field_default(field: dict[str, typing.Any]) -> typing.Any: default = _mapping_value(field, "default", "default_value", "defaultValue") if isinstance(default, dict) and _protocol_name(_mapping_value(default, "type")) == "constant": return _mapping_value(default, "value") return default def _strip_form_placeholders(content: str) -> str: cleaned = re.sub(r"^\s*\{\{#\$output\.[^#{}]+#\}\}\s*$", "", content, flags=re.MULTILINE) return re.sub(r"\n{3,}", "\n\n", cleaned).strip() def _form_step_descriptions( content: str, provider_names: list[str], ) -> tuple[dict[str, str], str | None]: placeholder_pattern = re.compile( r"^[ \t]*\{\{#\$output\.([^#{}\r\n]+)#\}\}[ \t]*$", flags=re.MULTILINE, ) matches = list(placeholder_pattern.finditer(content)) if not matches: description = _strip_form_placeholders(content) return ({provider_names[0]: description} if provider_names and description else {}), None descriptions: dict[str, str] = {} segment_start = 0 known_names = set(provider_names) for match in matches: provider_name = match.group(1).strip() description = content[segment_start : match.start()].strip() if provider_name in known_names and description: descriptions[provider_name] = description segment_start = match.end() action_description = content[segment_start:].strip() or None return descriptions, action_description def _interaction_storage_key(interaction_id: str) -> str: return f"{INTERACTION_STORAGE_PREFIX}{interaction_id}.json" class DefaultRunner(Runner): """Real Runner for Dify Service API. Supports three app types: - chat: Chat assistant (including Chatflow) - agent: Agent with tool calls - workflow: Workflow execution Configuration (static, from ctx.config): - base-url: Dify API base URL (default: https://api.dify.ai/v1) - app-type: Application type (chat/agent/workflow) - api-key: Dify API key - base-prompt: Default prompt when input is empty - timeout: Request timeout in seconds - remove-think: Whether to remove thinking tags from output Runtime state (from ctx.state): - external.conversation_id: Dify conversation ID for stateful sessions Runtime params (from ctx.adapter.extra.params): - Workflow inputs passed to Dify workflow endpoint - Custom variables passed to Dify chat-messages inputs """ def _validate_config(self, ctx: RunnerContext) -> dict[str, typing.Any]: """Validate and return static configuration. Raises DifyConfigError on missing required fields. """ config = ctx.config or {} self._get_user_tag(ctx) # Validate identity before constructing any upstream client. base_url = config.get("base-url", "https://api.dify.ai/v1") if not base_url: raise DifyConfigError("base-url is required", code="dify.config_invalid") api_key = config.get("api-key", "") if not api_key: raise DifyConfigError("api-key is required", code="dify.config_invalid") app_type = config.get("app-type", "chat") valid_types = ["chat", "chatflow", "agent", "workflow"] if app_type not in valid_types: raise DifyConfigError( f"Invalid app-type: {app_type}. Must be one of {valid_types}", code="dify.config_invalid", ) remove_think = config.get("remove-think", False) if not isinstance(remove_think, bool): raise DifyConfigError("remove-think must be a boolean", code="dify.config_invalid") return { "base_url": base_url, "api_key": api_key, "app_type": app_type, "base_prompt": config.get("base-prompt", ""), "timeout": float(config.get("timeout", 30)), "remove_think": remove_think, "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 ), } def _get_user_tag(self, ctx: RunnerContext) -> str: """Get user identifier for Dify API.""" source = ctx.config.get("user-id-source", "sender") if source not in ("sender", "legacy-session"): raise DifyConfigError("user-id-source is invalid", code="dify.config_invalid") if source == "legacy-session": conversation = ctx.conversation if conversation and conversation.launcher_type in ("group", "person"): launcher_id = conversation.launcher_id if isinstance(launcher_id, str) and launcher_id: return scoped_identity(self, ctx, f"{conversation.launcher_type}_{launcher_id}") raise DifyConfigError("user-id-source requires trusted Host identity", code="dify.identity_unavailable") actor = ctx.actor if actor and actor.actor_id: return scoped_identity(self, ctx, f"{actor.actor_type}_{actor.actor_id}") raise DifyConfigError( "user-id-source=sender requires a trusted Host actor identity", code="dify.identity_unavailable", ) def _get_external_conversation_id(self, ctx: RunnerContext) -> str: """Get external conversation ID from state or context. Priority: 1. ctx.state.conversation["external.conversation_id"] 2. Empty string (start new conversation) """ # Priority 1: State (persistent external conversation ID) external_conv_id = ctx.state.conversation.get("external.conversation_id") if _is_uuid_string(external_conv_id): return external_conv_id # Host conversation ids are LangBot-local and are not guaranteed to be # Dify UUIDs. Passing them to Dify makes first-turn Debug Chat fail. return "" def _get_or_create_state_id( self, ctx: RunnerContext, key: str, prefix: str, ) -> tuple[str, bool]: """Return a runner-owned external id stored under conversation state.""" state_value = ctx.state.conversation.get(key) if state_value: return str(state_value), False return f"{prefix}_{uuid.uuid4().hex}", True def _get_dify_inputs(self, ctx: RunnerContext) -> dict[str, typing.Any]: """Get inputs for Dify API from adapter params. Does NOT modify adapter params. """ return _get_adapter_params(ctx) async def _create_asset_gateway_registration( self, ctx: RunnerContext, config: dict[str, typing.Any], ): return await register_assets(self, self.get_run_api(ctx), ctx, config) async def _prepare_dify_inputs( self, ctx: RunnerContext, config: dict[str, typing.Any], ): inputs = self._get_dify_inputs(ctx) if not config["langbot_assets_enabled"]: return None, inputs registration = await self._create_asset_gateway_registration(ctx, config) inputs = dict(inputs) inputs[config["asset_gateway_input_name"]] = registration.token return registration, inputs async def _upload_input_files( self, ctx: RunnerContext, client: AsyncDifyClient, user: str, ) -> list[dict[str, typing.Any]]: """Upload files from input attachments to Dify. Returns list of Dify file references. """ uploaded_files: list[dict[str, typing.Any]] = [] attachments = list(ctx.input.attachments or []) if not any(_attachment_get(attachment, "content") for attachment in attachments): attachments.extend(_attachments_from_contents(ctx.input.contents)) for attachment in attachments: try: file_bytes = _decode_content(_attachment_get(attachment, "content")) downloaded_type = None if file_bytes is None and _attachment_get(attachment, "url"): file_bytes, downloaded_type = await client.download_file(_attachment_get(attachment, "url")) if not file_bytes: raise DifyAPIError( f"Input attachment {_attachment_get(attachment, 'name', 'file')} has no uploadable content", code="dify.input_error", ) file_name = _attachment_get(attachment, "name") or "file" content_type = ( _attachment_get(attachment, "content_type") or _attachment_get(attachment, "mime_type") or downloaded_type or "application/octet-stream" ) # Determine Dify file type from content type if content_type.startswith("image/"): file_type = "image" elif content_type.startswith("audio/"): file_type = "audio" elif content_type.startswith("video/"): file_type = "video" else: file_type = "document" result = await client.upload_file(file_name, file_bytes, content_type, user) file_id = result.get("id") if file_id: uploaded_files.append( { "type": file_type, "transfer_method": "local_file", "upload_file_id": file_id, } ) else: raise DifyAPIError( f"Dify file upload response missing file id for {file_name}", code="dify.input_error", ) except DifyAPIError as e: if e.code == "dify.input_error": raise raise DifyAPIError( f"Failed to upload input attachment {_attachment_get(attachment, 'name', 'file')}: {e.message}", code="dify.input_error", ) from None except Exception as e: raise DifyAPIError( f"Failed to upload input attachment {_attachment_get(attachment, 'name', 'file')}: {e}", code="dify.input_error", ) from None return uploaded_files def _get_input_text(self, ctx: RunnerContext, base_prompt: str) -> str: """Get text input, fallback to base_prompt if empty.""" text = ctx.input.to_text() if not text: return base_prompt return text async def _store_interaction_continuation( self, ctx: RunnerContext, continuation: dict[str, typing.Any], ) -> None: interaction_id = str(continuation["interaction_id"]) continuation = dict(continuation, owner=self._continuation_owner(ctx)) continuation.setdefault("expires_at", time.time() + 3600) payload = json.dumps(continuation, ensure_ascii=False, separators=(",", ":")).encode("utf-8") await self.get_run_api(ctx).set_plugin_storage(_interaction_storage_key(interaction_id), payload) async def _load_interaction_continuation( self, ctx: RunnerContext, interaction_id: str, ) -> dict[str, typing.Any]: key = _interaction_storage_key(interaction_id) try: payload = await self.get_run_api(ctx).get_plugin_storage(key) except (KeyError, ActionCallError) as exc: if isinstance(exc, ActionCallError): message = str(exc) while message.startswith("ActionCallError: "): message = message.removeprefix("ActionCallError: ") if message != f"Storage with key {key} not found": raise raise DifyAPIError( "The Dify human-input request is no longer pending", code="dify.interaction_not_found", ) from exc continuation = json.loads(payload.decode("utf-8")) if not isinstance(continuation, dict) or continuation.get("interaction_id") != interaction_id: raise DifyAPIError( "The Dify human-input continuation is invalid", code="dify.interaction_invalid", ) if continuation.get("version") != 1: raise DifyAPIError( "The Dify human-input continuation version is unsupported", code="dify.interaction_invalid", ) if continuation.get("consumed") or continuation.get("expires_at", 0) < time.time(): raise DifyAPIError("Dify continuation expired or already consumed", code="dify.interaction_not_found") owner = self._continuation_owner(ctx) stored_owner = continuation.get("owner") if stored_owner != owner: if stored_owner != self._legacy_continuation_owner(ctx): raise DifyAPIError("Dify continuation belongs to another context", code="dify.interaction_invalid") # One-time migration: a continuation stored before identity resolution moved # to task-local bindings is accepted and re-written with the current owner. # No long-term dual-rule compatibility branch beyond this upgrade path. continuation = dict(continuation, owner=owner) return continuation def _continuation_subject(self, ctx): conversation = ctx.conversation return json.dumps( [ getattr(conversation, "conversation_id", None), getattr(conversation, "launcher_type", None), getattr(conversation, "launcher_id", None), getattr(ctx.actor, "actor_id", None), (ctx.config or {}).get("base-url"), ], separators=(",", ":"), ) def _continuation_owner(self, ctx): return scoped_identity(self, ctx, self._continuation_subject(ctx)) def _legacy_continuation_owner(self, ctx): """Owner computed before identity resolution moved to task-local bindings. Used only to accept continuations persisted by the previous release so an in-flight human-input request survives the upgrade; the record is re-written with the current owner on the next store. """ binding = getattr(getattr(self, "_plugin_runtime_handler", None), "bound_action_context", None) subject = self._continuation_subject(ctx) if binding is not None: scope = [binding.instance_uuid, binding.workspace_uuid, binding.installation_uuid] else: workspace = getattr(ctx.conversation, "workspace_id", None) if not workspace: return subject scope = [workspace] encoded = json.dumps([scope, subject], ensure_ascii=False, separators=(",", ":")).encode() return "lb_" + hashlib.sha256(encoded).hexdigest() async def _delete_interaction_continuation(self, ctx: RunnerContext, interaction_id: str) -> None: await self.get_run_api(ctx).delete_plugin_storage(_interaction_storage_key(interaction_id)) @staticmethod def _request_from_continuation(continuation: dict[str, typing.Any]) -> InteractionRequest: fields = [InteractionField.model_validate(item) for item in continuation.get("current_fields") or []] actions = [InteractionAction.model_validate(item) for item in continuation.get("interaction_actions") or []] if continuation.get("phase") == "field": actions = [] kind = ( "confirmation" if actions and not fields else "choice" if fields and fields[0].type == "select" else "form" ) if "field_descriptions" in continuation: field_descriptions = ( continuation.get("field_descriptions") if isinstance(continuation.get("field_descriptions"), dict) else {} ) description = ( field_descriptions.get(fields[0].id) if continuation.get("phase") == "field" and fields else continuation.get("action_description") ) else: description = continuation.get("description") fallback_text = str(continuation.get("fallback_text") or "Human input is required.") if fields: field = fields[0] field_lines = [str(continuation.get("title") or "Human input required"), field.label] if description: field_lines.insert(1, str(description)) if field.options: field_lines.append("Options: " + ", ".join(option.label for option in field.options)) fallback_text = "\n".join(field_lines) return InteractionRequest( interaction_id=str(continuation["interaction_id"]), kind=kind, title=str(continuation.get("title") or "Human input required"), description=description, fields=fields, actions=actions, fallback_text=fallback_text, ) async def _advance_field_interaction( self, ctx: RunnerContext, continuation: dict[str, typing.Any], submission: typing.Any, ) -> RunnerResult: field_map = continuation.get("field_map") if isinstance(continuation.get("field_map"), dict) else {} inputs = dict(continuation.get("default_inputs") or {}) current_fields = [InteractionField.model_validate(item) for item in continuation.get("current_fields") or []] for field_id, value in submission.values.items(): provider_name = field_map.get(field_id) if provider_name: inputs[str(provider_name)] = value for field in current_fields: provider_name = field_map.get(field.id) if field.required and provider_name and inputs.get(str(provider_name)) in (None, "", []): raise DifyAPIError( f"Dify human-input field is required: {field.label}", code="dify.interaction_invalid", ) old_interaction_id = str(continuation["interaction_id"]) remaining_fields = list(continuation.get("remaining_fields") or []) continuation = dict(continuation) continuation["interaction_id"] = f"dify-{uuid.uuid4().hex}" continuation["default_inputs"] = inputs if remaining_fields: continuation["phase"] = "field" continuation["current_fields"] = [remaining_fields.pop(0)] continuation["remaining_fields"] = remaining_fields else: continuation["phase"] = "action" continuation["current_fields"] = [] continuation["remaining_fields"] = [] request = self._request_from_continuation(continuation) await self._store_interaction_continuation(ctx, continuation) await self._delete_interaction_continuation(ctx, old_interaction_id) return RunnerResult.interaction_requested(ctx.run_id, request) def _build_interaction_request( self, reason: dict[str, typing.Any], workflow_run_id: str, user: str, ) -> tuple[InteractionRequest, dict[str, typing.Any]]: form_token = str(_mapping_value(reason, "form_token", "formToken") or "").strip() if not form_token or not workflow_run_id: raise DifyAPIError( "Dify human-input event is missing continuation identifiers", code="dify.response_invalid", ) interaction_id = f"dify-{uuid.uuid4().hex}" fields: list[InteractionField] = [] field_map: dict[str, str] = {} resolved_defaults = _mapping_value(reason, "resolved_default_values", "resolvedDefaultValues") default_inputs = dict(resolved_defaults) if isinstance(resolved_defaults, dict) else {} reason_inputs = _mapping_value(reason, "inputs", "fields") raw_fields = reason_inputs if isinstance(reason_inputs, list) else [] for index, raw_field in enumerate(raw_fields): if not isinstance(raw_field, dict): continue provider_name = _form_field_name(raw_field) if not provider_name: continue field_id = f"field_{index + 1}" field_map[field_id] = provider_name provider_type = _protocol_name(_mapping_value(raw_field, "type") or "text") field_type = { "paragraph": "textarea", "textarea": "textarea", "select": "select", "number": "number", "checkbox": "boolean", "boolean": "boolean", "file": "file", "file_list": "file", }.get(provider_type, "text") option_values = _form_field_options(raw_field) if field_type == "select" else [] if field_type == "select" and not option_values: field_type = "text" default = default_inputs.get(provider_name, _form_field_default(raw_field)) if default not in (None, ""): default_inputs[provider_name] = default fields.append( InteractionField( id=field_id, label=str(_mapping_value(raw_field, "label", "title") or provider_name), type=field_type, required=_to_bool(_mapping_value(raw_field, "required", "is_required", "isRequired"), True), options=[InteractionOption(value=value, label=value) for value in option_values], placeholder=_mapping_value(raw_field, "placeholder"), default=default, ) ) actions: list[InteractionAction] = [] action_map: dict[str, str] = {} reason_actions = _mapping_value(reason, "actions", "user_actions", "userActions") raw_actions = reason_actions if isinstance(reason_actions, list) else [] for index, raw_action in enumerate(raw_actions): if not isinstance(raw_action, dict): continue provider_action_id = str(_mapping_value(raw_action, "id", "action_id", "actionId") or "").strip() if not provider_action_id: continue action_id = f"action_{index + 1}" action_map[action_id] = provider_action_id actions.append( InteractionAction( id=action_id, label=str(_mapping_value(raw_action, "title", "label", "text") or provider_action_id), ) ) if not actions: action_map["continue"] = "" actions.append(InteractionAction(id="continue", label="Continue", style="primary")) node_title = str(_mapping_value(reason, "node_title", "nodeTitle", "title") or "Human input required") form_content = str(_mapping_value(reason, "form_content", "formContent", "content") or "") provider_names = [field_map[field.id] for field in fields if field.id in field_map] provider_descriptions, action_description = _form_step_descriptions(form_content, provider_names) field_descriptions = { field_id: provider_descriptions[provider_name] for field_id, provider_name in field_map.items() if provider_name in provider_descriptions } description = _strip_form_placeholders(form_content) fallback_lines = [f"[Human Input Required] {node_title}"] if description: fallback_lines.extend(["", description]) for field in fields: if field.options: choices = ", ".join(option.label for option in field.options) fallback_lines.append(f"{field.label}: {choices}") else: fallback_lines.append(f"{field.label}: required input") if actions: fallback_lines.append("Actions: " + ", ".join(action.label for action in actions)) continuation = { "version": 1, "interaction_id": interaction_id, "form_token": form_token, "workflow_run_id": workflow_run_id, "user": user, "field_map": field_map, "action_map": action_map, "default_inputs": default_inputs, "title": node_title, "description": description or None, "field_descriptions": field_descriptions, "action_description": action_description, "fallback_text": "\n".join(fallback_lines), "phase": "field" if fields else "action", "current_fields": [fields[0].model_dump(mode="json")] if fields else [], "remaining_fields": [field.model_dump(mode="json") for field in fields[1:]], "interaction_actions": [action.model_dump(mode="json") for action in actions], } request = self._request_from_continuation(continuation) return request, continuation async def _interaction_result_for_pause( self, ctx: RunnerContext, event: dict[str, typing.Any], user: str, ) -> RunnerResult | None: workflow_run_id = _workflow_run_id(event) reason = _human_input_reason(event) if reason is None: return None request, continuation = self._build_interaction_request(reason, workflow_run_id, user) await self._store_interaction_continuation(ctx, continuation) return RunnerResult.interaction_requested(ctx.run_id, request) async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]: """Run the Dify agent. Streams RunnerResult.message_delta chunks and final run_completed. """ try: config = self._validate_config(ctx) except DifyConfigError as e: yield RunnerResult.run_failed( ctx.run_id, error=e.message, code=e.code, ) return client = AsyncDifyClient( api_key=config["api_key"], base_url=config["base_url"], timeout=config["timeout"], ) user = self._get_user_tag(ctx) input_text = self._get_input_text(ctx, config["base_prompt"]) remove_think = config["remove_think"] asset_registration = None try: if ctx.event.event_type == "interaction.submitted": if ctx.input.interaction is None: raise DifyAPIError( "interaction.submitted event is missing its validated submission", code="dify.interaction_invalid", ) async for result in self._resume_workflow(ctx, client, ctx.input.interaction, remove_think): yield result return if ctx.input.interaction is not None: raise DifyAPIError( "Interaction submission is only valid for interaction.submitted events", code="dify.interaction_invalid", ) # Upload files if present. Multimodal inputs must not silently # degrade to text-only when the provider upload path fails. files = await self._upload_input_files(ctx, client, user) # Get inputs from params (read-only, do not modify), optionally adding # the run-scoped LangBot asset token expected by Dify MCP tools. asset_registration, inputs = await self._prepare_dify_inputs(ctx, config) # Get conversation_id from state (not from config!) conversation_id = self._get_external_conversation_id(ctx) app_type = config["app_type"] if app_type == "workflow": # Workflow mode - uses different endpoint async for result in self._run_workflow(ctx, client, inputs, input_text, user, files, remove_think): yield result else: # Chat or Agent mode - uses chat-messages endpoint async for result in self._run_chat_or_agent( ctx, client, inputs, input_text, user, conversation_id, files, app_type, remove_think ): yield result except DifyAPIError as e: yield RunnerResult.run_failed( ctx.run_id, error=e.message, code=e.code, ) return except Exception as e: logger.exception(f"Dify runner unexpected error: {e}") yield RunnerResult.run_failed( ctx.run_id, error=f"Dify runner error: {e}", code="dify.unexpected_error", ) return finally: if asset_registration is not None: await asset_registration.stop() async def _run_chat_or_agent( self, ctx: RunnerContext, client: AsyncDifyClient, inputs: dict[str, typing.Any], input_text: str, user: str, conversation_id: str, files: list[dict[str, typing.Any]], app_type: str, remove_think: bool, ) -> typing.AsyncGenerator[RunnerResult, None]: """Run chat or agent mode. Streams message_delta chunks and handles agent-specific events. """ pending_content = "" mode = "basic" # basic or workflow mode in chat message_count = 0 received_message = False has_response = False final_conversation_id = conversation_id usage: dict[str, typing.Any] | None = None workflow_run_id = "" async for event in client.chat_messages( inputs=inputs, query=input_text, user=user, conversation_id=conversation_id, files=files, ): event_type = event.get("event", "") logger.debug(f"Dify {app_type} event: {event_type}") usage = _usage_from_payload(event) or usage if event_type in { "workflow_started", "node_started", "node_finished", "workflow_finished", "workflow_paused", "human_input_required", }: mode = "workflow" if event_type == "workflow_started": workflow_run_id = _workflow_run_id(event, workflow_run_id) if event_type == "error": raise DifyAPIError( f"Dify API error: {event.get('message', 'Unknown error')}", code="dify.api_error", ) # Track conversation_id for stateful session if event.get("conversation_id"): final_conversation_id = event["conversation_id"] # Handle different event types based on app_type and mode if mode == "workflow" and event_type == "node_finished": if event.get("data", {}).get("node_type") == "answer": answer = extract_text_from_output(event.get("data", {}).get("outputs", {}).get("answer")) pending_content = answer content, _ = process_thinking_content(answer, remove_think) if content: has_response = True yield RunnerResult.message_delta(ctx.run_id, MessageChunk(role="assistant", content=content)) elif mode == "workflow" and event_type in {"workflow_paused", "human_input_required"}: data = event.get("data") if isinstance(event.get("data"), dict) else {} workflow_run_id = _workflow_run_id(event, workflow_run_id) if workflow_run_id and not _workflow_run_id(event): event = dict(event) event["data"] = {**data, "workflow_run_id": workflow_run_id} if pending_content: content, _ = process_thinking_content(pending_content, remove_think) if content: yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) if final_conversation_id: yield RunnerResult.state_updated( ctx.run_id, "external.conversation_id", final_conversation_id, scope="conversation", ) interaction_result = await self._interaction_result_for_pause(ctx, event, user) if interaction_result is None: raise DifyAPIError( "Dify paused the workflow without a supported reason", code="dify.response_invalid", ) yield interaction_result return elif event_type == "message" or event_type == "agent_message": if event_type == "message" and not received_message: pending_content = "" message_count = 0 received_message = True elif event_type == "agent_message" and received_message: continue # Accumulate text chunks answer = event.get("answer", "") # Dify deployments send either deltas or cumulative snapshots. if pending_content and len(answer) > len(pending_content) and answer.startswith(pending_content): pending_content = answer else: pending_content += answer message_count += 1 if message_count % 8 == 0: content, _ = process_thinking_content(pending_content, remove_think) if content: has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=False) ) elif event_type == "message_end": # Final message for chat mode if pending_content: content, _ = process_thinking_content(pending_content, remove_think) has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True) ) pending_content = "" elif event_type == "agent_thought" and app_type == "agent": # Agent thought events - handle tool calls tool = event.get("tool", "") observation = event.get("observation", "") # Skip tool result observations if not tool or observation: continue # Yield accumulated content before tool call if pending_content: content, _ = process_thinking_content(pending_content, remove_think) if content: has_response = True yield RunnerResult.message_delta(ctx.run_id, MessageChunk(role="assistant", content=content)) pending_content = "" # Report tool call as message_delta with tool_calls if tool: yield RunnerResult.message_delta( ctx.run_id, MessageChunk( role="assistant", content="", tool_calls=[ { "id": event.get("id", str(uuid.uuid4())), "type": "function", "function": { "name": tool, "arguments": json.dumps({}), }, } ], ), ) elif event_type == "message_file": # Handle image/file output from agent if event.get("type") == "image" and event.get("belongs_to") == "assistant": image_url = event.get("url", "") if image_url: # Handle relative URLs if not image_url.startswith("http"): base_url = client.base_url if base_url.endswith("/v1"): base_url = base_url[:-3] image_url = base_url + image_url has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk( role="assistant", content=[{"type": "image_url", "image_url": {"url": image_url}}], ), ) elif event_type == "workflow_finished": mode = "workflow" data = event.get("data", {}) if data.get("error"): raise DifyAPIError(f"Dify workflow error: {data['error']}", code="dify.api_error") # Handle any remaining pending content if pending_content: content, _ = process_thinking_content(pending_content, remove_think) if content: has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True) ) if not has_response: raise DifyAPIError( "Dify API returned no response", code="dify.api_error", ) # Update state with conversation_id for next run (scoped state) if final_conversation_id: yield RunnerResult.state_updated( ctx.run_id, "external.conversation_id", final_conversation_id, scope="conversation", ) yield RunnerResult.run_completed(ctx.run_id, usage=usage) async def _run_workflow( self, ctx: RunnerContext, client: AsyncDifyClient, inputs: dict[str, typing.Any], input_text: str, user: str, files: list[dict[str, typing.Any]], remove_think: bool, ) -> typing.AsyncGenerator[RunnerResult, None]: """Run workflow mode. Streams message_delta chunks and handles workflow-specific events. Workflow legacy inputs are derived from context: - langbot_user_message_text: input_text - langbot_session_id: ctx.conversation.session_id or ctx.run_id - langbot_conversation_id: from state or ctx.conversation - langbot_msg_create_time: adapter params msg_create_time """ # Derive Dify workflow compatibility variables from runner-owned state, # not LangBot's conversation/session ids. session_id, session_created = self._get_or_create_state_id( ctx, "external.workflow_session_id", "dify_session", ) external_conv_id, conversation_created = self._get_or_create_state_id( ctx, "external.workflow_conversation_id", "dify_conversation", ) msg_create_time = inputs.get("msg_create_time") workflow_inputs = { "langbot_user_message_text": input_text, "langbot_session_id": session_id, "langbot_conversation_id": external_conv_id, } if msg_create_time: workflow_inputs["langbot_msg_create_time"] = msg_create_time # Merge with user params (user params take precedence) workflow_inputs.update(inputs) pending_content = "" has_response = False ignored_events = ["workflow_started"] usage: dict[str, typing.Any] | None = None workflow_run_id = "" async for event in client.workflow_run( inputs=workflow_inputs, user=user, files=files, ): event_type = event.get("event", "") logger.debug(f"Dify workflow event: {event_type}") usage = _usage_from_payload(event) or usage if event_type == "workflow_started": workflow_run_id = _workflow_run_id(event, workflow_run_id) if event_type == "error": raise DifyAPIError( f"Dify workflow error: {event.get('message', 'Unknown error')}", code="dify.api_error", ) if event_type in ignored_events: continue if event_type == "node_started": data = event.get("data", {}) node_type = data.get("node_type", "") if node_type in ["start", "end"]: continue # Node progress is telemetry, not user-visible message content. yield RunnerResult.tool_call_started( ctx.run_id, tool_call_id=str(data.get("node_id") or uuid.uuid4()), tool_name=str(data.get("title") or node_type), parameters={}, ) elif event_type == "text_chunk": # Streaming text output from workflow text = event.get("data", {}).get("text", "") pending_content += text elif event_type == "node_finished": data = event.get("data", {}) if data.get("node_type") == "answer": answer = extract_text_from_output(data.get("outputs", {}).get("answer")) if answer: content, _ = process_thinking_content(answer, remove_think) has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) elif event_type in {"workflow_paused", "human_input_required"}: data = event.get("data") if isinstance(event.get("data"), dict) else {} workflow_run_id = _workflow_run_id(event, workflow_run_id) if workflow_run_id and not _workflow_run_id(event): event = dict(event) event["data"] = {**data, "workflow_run_id": workflow_run_id} if pending_content: content, _ = process_thinking_content(pending_content, remove_think) if content: yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) if session_created: yield RunnerResult.state_updated( ctx.run_id, "external.workflow_session_id", session_id, scope="conversation", ) if conversation_created: yield RunnerResult.state_updated( ctx.run_id, "external.workflow_conversation_id", external_conv_id, scope="conversation", ) interaction_result = await self._interaction_result_for_pause(ctx, event, user) if interaction_result is None: raise DifyAPIError( "Dify paused the workflow without a supported reason", code="dify.response_invalid", ) yield interaction_result return elif event_type == "workflow_finished": data = event.get("data", {}) if data.get("error"): raise DifyAPIError(f"Dify workflow error: {data['error']}", code="dify.api_error") # Get final output summary = extract_text_from_output(data.get("outputs", {}).get("summary", "")) if summary: content, _ = process_thinking_content(summary, remove_think) has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True) ) # Handle remaining pending content if pending_content and not has_response: content, _ = process_thinking_content(pending_content, remove_think) if content: has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True) ) if not has_response: raise DifyAPIError( "Dify workflow returned no response", code="dify.api_error", ) if session_created: yield RunnerResult.state_updated( ctx.run_id, "external.workflow_session_id", session_id, scope="conversation", ) if conversation_created: yield RunnerResult.state_updated( ctx.run_id, "external.workflow_conversation_id", external_conv_id, scope="conversation", ) yield RunnerResult.run_completed(ctx.run_id, usage=usage) async def _resume_workflow(self, ctx, client, submission, remove_think): active = getattr(self, "_active_resumes", None) if active is None: active = self._active_resumes = set() from langbot_plugin.api.proxies.invocation import current_binding handler = getattr(self, "_plugin_runtime_handler", None) # Same binding chain as scoped_identity, so the owner and the concurrency # key can never disagree within one invocation. binding = current_binding(handler) if handler is not None else None if binding is None: binding = getattr(handler, "bound_action_context", None) key = (binding, self._continuation_owner(ctx), str(submission.interaction_id)) if key in active: raise DifyAPIError("Dify continuation is already being resumed", code="dify.interaction_invalid") active.add(key) try: async with aclosing(self._resume_workflow_once(ctx, client, submission, remove_think)) as stream: async for result in stream: yield result finally: active.remove(key) async def _resume_workflow_once( self, ctx: RunnerContext, client: AsyncDifyClient, submission: typing.Any, remove_think: bool, ) -> typing.AsyncGenerator[RunnerResult, None]: interaction_id = str(submission.interaction_id) continuation = await self._load_interaction_continuation(ctx, interaction_id) if continuation.get("phase") == "field": yield await self._advance_field_interaction(ctx, continuation, submission) return field_map = continuation.get("field_map") if isinstance(continuation.get("field_map"), dict) else {} action_map = continuation.get("action_map") if isinstance(continuation.get("action_map"), dict) else {} inputs = dict(continuation.get("default_inputs") or {}) for field_id, value in submission.values.items(): provider_name = field_map.get(field_id) if provider_name: inputs[str(provider_name)] = value action_id = submission.action_id or "" if action_id not in action_map: raise DifyAPIError( "Dify human-input action is missing or invalid", code="dify.interaction_invalid", ) action = action_map[action_id] # Persist consumption BEFORE the external effect. An ambiguous failure # remains consumed, never silently retries an approval at the vendor. continuation["consumed"] = True await self._store_interaction_continuation(ctx, continuation) pending_content = "" streamed_content = "" text_event_count = 0 has_response = False final_response = False terminal = False usage: dict[str, typing.Any] | None = None async for event in client.workflow_submit( form_token=str(continuation["form_token"]), workflow_run_id=str(continuation["workflow_run_id"]), inputs=inputs, user=str(continuation["user"]), action=str(action), ): event_type = event.get("event", "") usage = _usage_from_payload(event) or usage if event_type == "error": raise DifyAPIError( f"Dify workflow error: {event.get('message', 'Unknown error')}", code="dify.api_error", ) if event_type in {"message", "agent_message", "text_chunk"}: chunk = ( str(event.get("answer") or "") if event_type != "text_chunk" else str(event.get("data", {}).get("text") or "") ) if event_type != "text_chunk" and pending_content and chunk.startswith(pending_content): next_content = chunk else: next_content = pending_content + chunk if len(next_content) > 1024 * 1024: raise DifyAPIError("Dify response exceeds the runtime limit", code="dify.response_limit") pending_content = next_content text_event_count += 1 if text_event_count % 8 == 0: content, _ = process_thinking_content(pending_content, remove_think) if content and content != streamed_content: streamed_content = content has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=False), ) elif event_type == "node_finished": data = event.get("data", {}) if data.get("node_type") == "answer": answer = extract_text_from_output(data.get("outputs", {}).get("answer")) if answer: content, _ = process_thinking_content(answer, remove_think) final_response = bool(content) if content and content != streamed_content: has_response = True streamed_content = content yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) elif event_type in {"workflow_paused", "human_input_required"}: if pending_content: content, _ = process_thinking_content(pending_content, remove_think) if content and content != streamed_content: has_response = True streamed_content = content yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) data = event.get("data") if isinstance(event.get("data"), dict) else {} if not _workflow_run_id(event): event = dict(event) event["data"] = { **data, "workflow_run_id": str(continuation["workflow_run_id"]), } interaction_result = await self._interaction_result_for_pause( ctx, event, str(continuation["user"]), ) if interaction_result is None: raise DifyAPIError( "Dify paused the workflow without a supported reason", code="dify.response_invalid", ) await self._delete_interaction_continuation(ctx, interaction_id) yield interaction_result return elif event_type == "workflow_finished": data = event.get("data", {}) if data.get("error"): raise DifyAPIError(f"Dify workflow error: {data['error']}", code="dify.api_error") summary = extract_text_from_output(data.get("outputs", {}).get("summary", "")) if summary: content, _ = process_thinking_content(summary, remove_think) final_response = bool(content) if content and content != streamed_content: has_response = True streamed_content = content yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) terminal = True if not terminal: raise DifyAPIError( "Dify workflow resume ended before a terminal event", code="dify.response_invalid", ) if pending_content and not final_response: content, _ = process_thinking_content(pending_content, remove_think) if content and content != streamed_content: has_response = True yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) if not has_response: raise DifyAPIError( "Dify workflow returned no response after human input", code="dify.api_error", ) await self._delete_interaction_continuation(ctx, interaction_id) yield RunnerResult.run_completed(ctx.run_id, usage=usage)