diff --git a/src/lfx/src/lfx/base/composio/composio_base.py b/src/lfx/src/lfx/base/composio/composio_base.py index c337e343c6..1142fd7791 100644 --- a/src/lfx/src/lfx/base/composio/composio_base.py +++ b/src/lfx/src/lfx/base/composio/composio_base.py @@ -1,4 +1,5 @@ import copy +import json import re from typing import Any @@ -6,53 +7,28 @@ from composio import Composio from composio_langchain import LangchainProvider from langchain_core.tools import Tool +from lfx.base.mcp.util import create_input_schema_from_json_schema from lfx.custom.custom_component.component import Component -from lfx.inputs.inputs import AuthInput, FileInput, InputTypes, MessageTextInput, SecretStrInput, SortableListInput +from lfx.inputs.inputs import ( + AuthInput, + DropdownInput, + FileInput, + InputTypes, + MessageTextInput, + MultilineInput, + SecretStrInput, + SortableListInput, + StrInput, + TabInput, +) from lfx.io import Output from lfx.io.schema import flatten_schema, schema_to_langflow_inputs from lfx.log.logger import logger from lfx.schema.data import Data from lfx.schema.dataframe import DataFrame -from lfx.schema.json_schema import create_input_schema_from_json_schema from lfx.schema.message import Message -def _patch_graph_clean_null_input_types() -> None: - """Monkey-patch Graph._create_vertex to clean legacy templates.""" - try: - from lfx.graph.graph.base import Graph - - if getattr(Graph, "_composio_patch_applied", False): - return - - original_create_vertex = Graph._create_vertex - - def _create_vertex_with_cleanup(self, frontend_data): - try: - node_id: str | None = frontend_data.get("id") if isinstance(frontend_data, dict) else None - if node_id and "Composio" in node_id: - template = frontend_data.get("data", {}).get("node", {}).get("template", {}) - if isinstance(template, dict): - for field_cfg in template.values(): - if isinstance(field_cfg, dict) and field_cfg.get("input_types") is None: - field_cfg["input_types"] = [] - except (AttributeError, TypeError, KeyError) as e: - logger.debug(f"Composio template cleanup encountered error: {e}") - - return original_create_vertex(self, frontend_data) - - Graph._create_vertex = _create_vertex_with_cleanup # type: ignore[method-assign] - Graph._composio_patch_applied = True # type: ignore[attr-defined] - logger.debug("Applied Composio template cleanup patch to Graph._create_vertex") - - except (AttributeError, TypeError) as e: - logger.debug(f"Failed to apply Composio Graph patch: {e}") - - -# Apply the patch at import time -_patch_graph_clean_null_input_types() - - class ComposioBaseComponent(Component): """Base class for Composio components with common functionality.""" @@ -73,6 +49,17 @@ class ComposioBaseComponent(Component): real_time_refresh=True, value="COMPOSIO_API_KEY", ), + DropdownInput( + name="auth_mode", + display_name="Auth Mode", + options=[], + placeholder="Select auth mode", + toggle=True, + toggle_disable=True, + show=False, + real_time_refresh=True, + helper_text="Choose how to authenticate with the toolkit.", + ), AuthInput( name="auth_link", value="", @@ -117,6 +104,10 @@ class ComposioBaseComponent(Component): self._key_to_display_map: dict[str, str] = {} self._sanitized_names: dict[str, str] = {} self._action_schemas: dict[str, Any] = {} + # Toolkit schema cache per instance + self._toolkit_schema: dict[str, Any] | None = None + # Track generated custom auth inputs to hide/show/reset + self._auth_dynamic_fields: set[str] = set() def as_message(self) -> Message: result = self.execute_action() @@ -130,14 +121,11 @@ class ComposioBaseComponent(Component): if isinstance(result, dict): result = [result] # Build DataFrame and avoid exposing a 'data' attribute via column access, - result_dataframe = DataFrame(result) - if hasattr(result_dataframe, "columns"): - try: - if "data" in result_dataframe.columns: - result_dataframe = result_dataframe.rename(columns={"data": "_data"}) - except (AttributeError, TypeError, ValueError, KeyError) as e: - logger.debug(f"Failed to rename 'data' column: {e}") - return result_dataframe + # which interferes with logging utilities that probe for '.data'. + df = DataFrame(result) + if "data" in df.columns: + df = df.rename(columns={"data": "_data"}) + return df def as_data(self) -> Data: result = self.execute_action() @@ -358,6 +346,13 @@ class ComposioBaseComponent(Component): # Check original schema properties for file_uploadable fields original_props = parameters_schema.get("properties", {}) + + # Determine top-level fields that should be treated as single JSON inputs + json_parent_fields = set() + for top_name, top_schema in original_props.items(): + if isinstance(top_schema, dict) and top_schema.get("type") in {"object", "array"}: + json_parent_fields.add(top_name) + for field_name, field_schema in original_props.items(): if isinstance(field_schema, dict): clean_field_name = field_name.replace("[0]", "") @@ -373,6 +368,10 @@ class ComposioBaseComponent(Component): for field in raw_action_fields: clean_field = field.replace("[0]", "") + # Skip subfields of JSON parents; we will expose the parent as a single field + top_prefix = clean_field.split(".")[0].split("[")[0] + if top_prefix in json_parent_fields and "." in clean_field: + continue # Check if this field is attachment-related if clean_field.lower().startswith("attachment."): attachment_related_found = True @@ -381,8 +380,11 @@ class ComposioBaseComponent(Component): # Handle conflicting field names - rename user_id to avoid conflicts with entity_id if clean_field == "user_id": clean_field = f"{self.app_name}_user_id" - elif clean_field == "status": - clean_field = f"{self.app_name}_status" + + # Handle reserved attribute name conflicts (e.g., 'status') by prefixing with app name + # This prevents clashes with component attributes like self.status + if clean_field in {"status"}: + clean_field = f"{self.app_name}_{clean_field}" action_fields.append(clean_field) @@ -391,6 +393,11 @@ class ComposioBaseComponent(Component): action_fields.append("attachment") file_upload_fields.add("attachment") # Attachment fields are also file upload fields + # Ensure parents for object/array are present as fields (single JSON field) + for parent in json_parent_fields: + if parent not in action_fields: + action_fields.append(parent) + # Track boolean parameters so we can coerce them later properties = flat_schema.get("properties", {}) if properties: @@ -530,17 +537,17 @@ class ComposioBaseComponent(Component): # Handle conflicting field names - rename user_id to avoid conflicts with entity_id if clean_field_name == "user_id": clean_field_name = f"{self.app_name}_user_id" - # Update + # Update the field schema description to reflect the name change field_schema_copy = field_schema.copy() field_schema_copy["description"] = ( f"User ID for {self.app_name.title()}: " + field_schema["description"] ) elif clean_field_name == "status": clean_field_name = f"{self.app_name}_status" - # Update + # Update the field schema description to reflect the name change field_schema_copy = field_schema.copy() - field_schema_copy["description"] = ( - f"Status for {self.app_name.title()}: " + field_schema["description"] + field_schema_copy["description"] = f"Status for {self.app_name.title()}: " + field_schema.get( + "description", "" ) else: # Use the original field schema for all other fields @@ -593,8 +600,20 @@ class ComposioBaseComponent(Component): if attachment_related_fields: # If we consolidated attachment fields file_upload_fields = file_upload_fields | {"attachment"} + # Identify top-level JSON parents (object/array) to render as single CodeInput + top_props_for_json = set() + props_dict = parameters_schema.get("properties", {}) if isinstance(parameters_schema, dict) else {} + for top_name, top_schema in props_dict.items(): + if isinstance(top_schema, dict) and top_schema.get("type") in {"object", "array"}: + top_props_for_json.add(top_name) + for inp in result: if hasattr(inp, "name") and inp.name is not None: + # Skip flattened subfields of JSON parents; handle array prefixes (e.g., parent[0].x) + raw_prefix = inp.name.split(".")[0] + base_prefix = raw_prefix.replace("[0]", "") + if base_prefix in top_props_for_json and ("." in inp.name or "[" in inp.name): + continue # Check if this specific field is a file upload field if inp.name.lower() in file_upload_fields or inp.name.lower() == "attachment": # Replace with FileInput for file upload fields @@ -655,6 +674,24 @@ class ComposioBaseComponent(Component): else: processed_inputs.append(inp) + # Add single CodeInput for each JSON parent field + props_dict = parameters_schema.get("properties", {}) if isinstance(parameters_schema, dict) else {} + for top_name in top_props_for_json: + # Avoid duplicates if already present + if any(getattr(i, "name", None) == top_name for i in processed_inputs): + continue + top_schema = props_dict.get(top_name, {}) + processed_inputs.append( + MultilineInput( + name=top_name, + display_name=top_schema.get("title") or top_name.replace("_", " ").title(), + info=( + top_schema.get("description") or "Provide JSON for this parameter (object or array)." + ), + required=top_name in required_fields_set, + ) + ) + return processed_inputs return result # noqa: TRY300 except ValueError as e: @@ -707,9 +744,7 @@ class ComposioBaseComponent(Component): if inp.name is not None: inp_dict = inp.to_dict() if hasattr(inp, "to_dict") else inp.__dict__.copy() - # Ensure input_types is always a list - if not isinstance(inp_dict.get("input_types"), list): - inp_dict["input_types"] = [] + # Do not mutate input_types here; keep original configuration inp_dict.setdefault("show", True) # visible once action selected # Preserve previously entered value if user already filled something @@ -773,7 +808,7 @@ class ComposioBaseComponent(Component): logger.info(f"OAuth connection initiated for {app_name}: {redirect_url} (ID: {connection_id})") return redirect_url, connection_id # noqa: TRY300 - except Exception as e: + except (ValueError, ConnectionError, TypeError, AttributeError) as e: logger.error(f"Error initiating connection for {app_name}: {e}") msg = f"Failed to initiate OAuth connection: {e}" raise ValueError(msg) from e @@ -813,6 +848,22 @@ class ComposioBaseComponent(Component): else: return None + def _get_connection_auth_info(self, connection_id: str) -> tuple[str | None, bool | None]: + """Return (auth_scheme, is_composio_managed) for a given connection id, if available.""" + try: + composio = self._build_wrapper() + connection = composio.connected_accounts.get(nanoid=connection_id) + auth_config = getattr(connection, "auth_config", None) + if auth_config is None and hasattr(connection, "__dict__"): + auth_config = getattr(connection.__dict__, "auth_config", None) + scheme = getattr(auth_config, "auth_scheme", None) if auth_config else None + is_managed = getattr(auth_config, "is_composio_managed", None) if auth_config else None + except (AttributeError, ValueError, ConnectionError, TypeError) as e: + logger.debug(f"Could not retrieve auth info for connection {connection_id}: {e}") + return None, None + else: + return scheme, is_managed + def _disconnect_specific_connection(self, connection_id: str) -> None: """Disconnect a specific Composio connection by ID.""" try: @@ -825,12 +876,227 @@ class ComposioBaseComponent(Component): msg = f"Failed to disconnect connection {connection_id}: {e}" raise ValueError(msg) from e + def _to_plain_dict(self, obj: Any) -> Any: + """Recursively convert SDK models/lists to plain Python dicts/lists for safe .get access.""" + try: + if isinstance(obj, dict): + return {k: self._to_plain_dict(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple, set)): + return [self._to_plain_dict(v) for v in obj] + if hasattr(obj, "model_dump"): + try: + return self._to_plain_dict(obj.model_dump()) + except (TypeError, AttributeError, ValueError): + pass + if hasattr(obj, "__dict__") and not isinstance(obj, (str, bytes)): + try: + return self._to_plain_dict({k: v for k, v in obj.__dict__.items() if not k.startswith("_")}) + except (TypeError, AttributeError, ValueError): + pass + except (TypeError, ValueError, AttributeError, RecursionError): + return obj + else: + return obj + + def _get_toolkit_schema(self) -> dict[str, Any] | None: + """Fetch and cache toolkit schema for auth details (modes and fields).""" + if self._toolkit_schema is not None: + return self._toolkit_schema + try: + composio = self._build_wrapper() + # The SDK typically offers a retrieve by slug; if not present, try a few fallbacks + app_slug = getattr(self, "app_name", "").lower() + if not app_slug: + return None + try: + schema = composio.toolkits.retrieve(slug=app_slug) + except (AttributeError, ValueError, ConnectionError, TypeError): + schema = None + for method_name, kwargs in ( + ("retrieve", {"toolkit_slug": app_slug}), + ("get", {"slug": app_slug}), + ("get", {"toolkit_slug": app_slug}), + ): + try: + method = getattr(composio.toolkits, method_name) + schema = method(**kwargs) + if schema: + break + except (AttributeError, ValueError, ConnectionError, TypeError): + continue + self._toolkit_schema = self._to_plain_dict(schema) + except (AttributeError, ValueError, ConnectionError, TypeError) as e: + logger.debug(f"Could not retrieve toolkit schema for {getattr(self, 'app_name', '')}: {e}") + return None + else: + return self._toolkit_schema + + def _extract_auth_modes_from_schema(self, schema: dict[str, Any] | None) -> list[str]: + """Return available auth modes (e.g., OAUTH2, API_KEY) from toolkit schema.""" + if not schema: + return [] + modes: list[str] = [] + # composio_managed_auth_schemes: list[str] + managed = schema.get("composio_managed_auth_schemes") or schema.get("composioManagedAuthSchemes") or [] + if isinstance(managed, list): + modes.extend([m for m in managed if isinstance(m, str)]) + # auth_config_details: list with entries containing mode + details = schema.get("auth_config_details") or schema.get("authConfigDetails") or [] + for item in details: + mode = item.get("mode") or item.get("auth_method") + if isinstance(mode, str) and mode not in modes: + modes.append(mode) + return modes + + def _render_auth_mode_dropdown(self, build_config: dict, modes: list[str]) -> None: + """Populate and show the auth_mode control; if only one mode, show as selected chip-style list.""" + try: + build_config.setdefault("auth_mode", {}) + auth_mode_cfg = build_config["auth_mode"] + # Prefer the connected scheme if known; otherwise use schema-provided modes as-is + stored_scheme = (build_config.get("auth_link") or {}).get("auth_scheme") + if isinstance(stored_scheme, str) and stored_scheme: + modes = [stored_scheme] + + if len(modes) <= 1: + # Single mode → show a pill in the auth_mode slot (right after API Key) + selected = modes[0] if modes else "" + try: + pill = TabInput( + name="auth_mode", + display_name="Auth Mode", + options=[selected] if selected else [], + value=selected, + ).to_dict() + pill["show"] = True + build_config["auth_mode"] = pill + except (TypeError, ValueError, AttributeError): + build_config["auth_mode"] = { + "name": "auth_mode", + "display_name": "Auth Mode", + "type": "tab", + "options": [selected], + "value": selected, + "show": True, + } + else: + # Multiple modes → normal dropdown, hide the display chip if present + auth_mode_cfg["options"] = modes + auth_mode_cfg["show"] = True + if not auth_mode_cfg.get("value") and modes: + auth_mode_cfg["value"] = modes[0] + if "auth_mode_display" in build_config: + build_config["auth_mode_display"]["show"] = False + auth_mode_cfg["helper_text"] = "Choose how to authenticate with the toolkit." + except (TypeError, ValueError, AttributeError) as e: + logger.debug(f"Failed to render auth_mode dropdown: {e}") + + def _clear_auth_dynamic_fields(self, build_config: dict) -> None: + for fname in list(self._auth_dynamic_fields): + if fname in build_config: + build_config.pop(fname, None) + self._auth_dynamic_fields.clear() + + def _add_text_field( + self, + build_config: dict, + name: str, + display_name: str, + info: str | None, + *, + required: bool, + default_value: str | None = None, + ) -> None: + """Add a simple text input (StrInput) for custom auth forms, prefilled with schema defaults when available.""" + field = StrInput( + name=name, + display_name=display_name or name.replace("_", " ").title(), + info=info or "", + required=required, + real_time_refresh=True, + show=True, + ).to_dict() + if default_value is not None and default_value != "": + field["value"] = default_value + build_config[name] = field + self._auth_dynamic_fields.add(name) + + def _render_custom_auth_fields(self, build_config: dict, schema: dict[str, Any], mode: str) -> None: + """Render fields for custom auth based on schema auth_config_details sections.""" + details = schema.get("auth_config_details") or schema.get("authConfigDetails") or [] + selected = None + for item in details: + if (item.get("mode") or item.get("auth_method")) == mode: + selected = item + break + if not selected: + return + fields = selected.get("fields") or {} + # a) AuthConfigCreation required fields for OAUTH2 custom + creation = fields.get("auth_config_creation") or fields.get("authConfigCreation") or {} + for req in creation.get("required", []): + name = req.get("name") + if not name: + continue + disp = req.get("display_name") or req.get("displayName") or name + desc = req.get("description") + default_val = req.get("default") + self._add_text_field(build_config, name, disp, desc, required=True, default_value=default_val) + # Optional auth_config_creation fields intentionally not rendered + # b) ConnectedAccountInitiation fields for API_KEY mode + initiation = fields.get("connected_account_initiation") or fields.get("connectedAccountInitiation") or {} + for req in initiation.get("required", []): + name = req.get("name") + if not name: + continue + disp = req.get("display_name") or req.get("displayName") or name + desc = req.get("description") + default_val = req.get("default") + self._add_text_field(build_config, name, disp, desc, required=True, default_value=default_val) + for opt in initiation.get("optional", []): + name = opt.get("name") + if not name: + continue + disp = opt.get("display_name") or opt.get("displayName") or name + desc = opt.get("description") + default_val = opt.get("default") + self._add_text_field(build_config, name, disp, desc, required=False, default_value=default_val) + + def _collect_all_auth_field_names(self, schema: dict[str, Any] | None) -> set[str]: + names: set[str] = set() + if not schema: + return names + details = schema.get("auth_config_details") or schema.get("authConfigDetails") or [] + for item in details: + fields = (item.get("fields") or {}) if isinstance(item, dict) else {} + for section_key in ( + "auth_config_creation", + "authConfigCreation", + "connected_account_initiation", + "connectedAccountInitiation", + ): + section = fields.get(section_key) or {} + for bucket in ("required", "optional"): + for entry in section.get(bucket, []) or []: + name = entry.get("name") if isinstance(entry, dict) else None + if name: + names.add(name) + # Only use names discovered from the toolkit schema; do not add aliases + return names + + def _clear_auth_fields_from_schema(self, build_config: dict, schema: dict[str, Any] | None) -> None: + all_names = self._collect_all_auth_field_names(schema) + for name in list(all_names): + if name in build_config and isinstance(build_config[name], dict): + # Hide and reset instead of removing to ensure UI updates immediately + build_config[name]["show"] = False + build_config[name]["value"] = "" + # Also clear any tracked dynamic fields + self._clear_auth_dynamic_fields(build_config) + def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict: """Update build config for auth and action selection.""" - # Clean any legacy None values that may still be present - for _fconfig in build_config.values(): - if isinstance(_fconfig, dict) and _fconfig.get("input_types") is None: - _fconfig["input_types"] = [] + # Avoid normalizing legacy input_types here; rely on upstream fixes # BULLETPROOF tool_mode checking - check all possible places where tool_mode could be stored instance_tool_mode = getattr(self, "tool_mode", False) if hasattr(self, "tool_mode") else False @@ -880,6 +1146,19 @@ class ComposioBaseComponent(Component): logger.info(f"Populating actions data for {getattr(self, 'app_name', 'unknown')}...") self._populate_actions_data() logger.info(f"Actions populated: {len(self._actions_data)} actions found") + # Also fetch toolkit schema to drive auth UI + schema = self._get_toolkit_schema() + modes = self._extract_auth_modes_from_schema(schema) + self._render_auth_mode_dropdown(build_config, modes) + # If a mode is selected (including auto-default), render custom fields when not managed + try: + selected_mode = (build_config.get("auth_mode") or {}).get("value") + managed = (schema or {}).get("composio_managed_auth_schemes") or [] + if selected_mode and not (isinstance(managed, list) and selected_mode in managed): + self._clear_auth_dynamic_fields(build_config) + self._render_custom_auth_fields(build_config, schema or {}, selected_mode) + except (TypeError, ValueError, AttributeError): + pass # CRITICAL: Set action options if we have actions (either from fresh population or cache) if self._actions_data: @@ -888,6 +1167,10 @@ class ComposioBaseComponent(Component): {"name": self.sanitize_action_name(action), "metadata": action} for action in self._actions_data ] logger.info(f"Action options set in build_config: {len(build_config['action_button']['options'])} options") + # Always (re)populate auth_mode as well when actions are available + schema = self._get_toolkit_schema() + modes = self._extract_auth_modes_from_schema(schema) + self._render_auth_mode_dropdown(build_config, modes) else: build_config["action_button"]["options"] = [] logger.warning("No actions found, setting empty options") @@ -902,31 +1185,127 @@ class ComposioBaseComponent(Component): logger.info(f"Cleared stored connection_id '{stored_connection_before}' due to API key change") else: logger.info("DEBUG: EARLY No stored connection_id to clear on API key change") + # Also clear any stored scheme and reset auth mode UI when API key changes + build_config.setdefault("auth_link", {}) + build_config["auth_link"].pop("auth_scheme", None) + build_config.setdefault("auth_mode", {}) + build_config["auth_mode"].pop("value", None) + build_config["auth_mode"]["show"] = True + # If auth_mode is currently a TabInput pill, convert it back to dropdown + if isinstance(build_config.get("auth_mode"), dict) and build_config["auth_mode"].get("type") == "tab": + build_config["auth_mode"].pop("type", None) + # Re-render dropdown options for the new API key context + try: + schema = self._get_toolkit_schema() + modes = self._extract_auth_modes_from_schema(schema) + # Rebuild as DropdownInput to ensure proper rendering + dd = DropdownInput( + name="auth_mode", + display_name="Auth Mode", + options=modes, + placeholder="Select auth mode", + toggle=True, + toggle_disable=True, + show=True, + real_time_refresh=True, + helper_text="Choose how to authenticate with the toolkit.", + ).to_dict() + build_config["auth_mode"] = dd + except (TypeError, ValueError, AttributeError): + pass + # NEW: Clear any selected action and hide generated fields when API key is re-entered + try: + if "action_button" in build_config and isinstance(build_config["action_button"], dict): + build_config["action_button"]["value"] = "disabled" + self._hide_all_action_fields(build_config) + except (TypeError, ValueError, AttributeError): + pass # Handle disconnect operations when tool mode is enabled if field_name == "auth_link" and field_value == "disconnect": - try: - # Get the specific connection ID that's currently being used - stored_connection_id = build_config.get("auth_link", {}).get("connection_id") - if stored_connection_id: - self._disconnect_specific_connection(stored_connection_id) - else: - # No connection ID stored - nothing to disconnect - logger.warning("No connection ID found to disconnect") - build_config["auth_link"]["value"] = "connect" - build_config["auth_link"]["auth_tooltip"] = "Connect" + # Soft disconnect: do not delete remote account; only clear local state + stored_connection_id = build_config.get("auth_link", {}).get("connection_id") + if not stored_connection_id: + logger.warning("No connection ID found to disconnect (soft)") + build_config.setdefault("auth_link", {}) + build_config["auth_link"]["value"] = "connect" + build_config["auth_link"]["auth_tooltip"] = "Connect" + build_config["auth_link"].pop("connection_id", None) + build_config["action_button"]["helper_text"] = "Please connect before selecting actions." + build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"} + return build_config + + # Handle auth mode change -> render appropriate fields based on schema + if field_name == "auth_mode": + schema = self._get_toolkit_schema() or {} + # Clear any previously rendered auth fields when switching modes + self._clear_auth_fields_from_schema(build_config, schema) + mode = field_value if isinstance(field_value, str) else (build_config.get("auth_mode", {}).get("value")) + if not mode and isinstance(build_config.get("auth_mode"), dict): + mode = build_config["auth_mode"].get("value") + # Always show auth_link for any mode + build_config.setdefault("auth_link", {}) + build_config["auth_link"]["show"] = False + # Reset connection state when switching modes + build_config["auth_link"].pop("connection_id", None) + build_config["auth_link"].pop("auth_config_id", None) + build_config["auth_link"]["value"] = "connect" + build_config["auth_link"]["auth_tooltip"] = "Connect" + # If an ACTIVE connection already exists, don't render any auth fields + existing_active = self._find_active_connection_for_app(self.app_name) + if existing_active: + connection_id, _ = existing_active + self._clear_auth_fields_from_schema(build_config, schema) + build_config.setdefault("create_auth_config", {}) + build_config["create_auth_config"]["show"] = False + build_config["auth_link"]["value"] = "validated" + build_config["auth_link"]["auth_tooltip"] = "Disconnect" + build_config["auth_link"]["connection_id"] = connection_id + # Reflect the connected auth scheme in the UI + scheme, is_managed = self._get_connection_auth_info(connection_id) + if scheme: + build_config.setdefault("auth_link", {}) + build_config["auth_link"]["auth_scheme"] = scheme + build_config.setdefault("auth_mode", {}) + build_config["auth_mode"]["value"] = scheme + build_config["auth_mode"]["options"] = [scheme] + build_config["auth_mode"]["show"] = False + try: + pill = TabInput( + name="auth_mode", + display_name="Auth Mode", + options=[scheme], + value=scheme, + ).to_dict() + pill["show"] = True + build_config["auth_mode"] = pill + except (TypeError, ValueError, AttributeError): + build_config["auth_mode"] = { + "name": "auth_mode", + "display_name": "Auth Mode", + "type": "tab", + "options": [scheme], + "value": scheme, + "show": True, + } + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} return build_config - except (ValueError, ConnectionError) as e: - logger.error(f"Error disconnecting: {e}") - build_config["auth_link"]["value"] = "error" - build_config["auth_link"]["auth_tooltip"] = f"Disconnect failed: {e!s}" - return build_config - else: - build_config["auth_link"]["value"] = "connect" - build_config["auth_link"]["auth_tooltip"] = "Connect" - build_config["auth_link"].pop("connection_id", None) # Clear stored connection ID - build_config["action_button"]["helper_text"] = "Please connect before selecting actions." - build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"} + if mode: + managed = schema.get("composio_managed_auth_schemes") or [] + # Always hide the Create Auth Config control (used internally only) + build_config.setdefault("create_auth_config", {}) + build_config["create_auth_config"]["show"] = False + build_config["create_auth_config"]["display_name"] = "" + build_config["create_auth_config"]["value"] = "" + build_config["create_auth_config"]["helper_text"] = "" + build_config["create_auth_config"]["options"] = ["create"] + if isinstance(managed, list) and mode in managed: + # Managed → no extra fields + pass + else: + # Custom → render only required fields based on the toolkit schema + self._render_custom_auth_fields(build_config, schema, mode) return build_config # Handle connection initiation when tool mode is enabled @@ -946,36 +1325,268 @@ class ComposioBaseComponent(Component): logger.info(f"Using existing ACTIVE connection {connection_id} for {toolkit_slug}") return build_config - # Check if we have a stored connection ID with INITIATED status - stored_connection_id = build_config.get("auth_link", {}).get("connection_id") - if stored_connection_id: - # Check status of existing connection - status = self._check_connection_status_by_id(stored_connection_id) - if status == "INITIATED": - # Get redirect URL from stored connection - try: - composio = self._build_wrapper() - connection = composio.connected_accounts.get(nanoid=stored_connection_id) - state = getattr(connection, "state", None) - if state and hasattr(state, "val"): - redirect_url = getattr(state.val, "redirect_url", None) + # Only reuse ACTIVE connections; otherwise create a new connection + stored_connection_id = None + + # Create new connection ONLY if we truly have no usable connection yet + if existing_active is None: + try: + # Determine auth mode + schema = self._get_toolkit_schema() + mode = None + if isinstance(build_config.get("auth_mode"), dict): + mode = build_config["auth_mode"].get("value") + # If no managed default exists (400 Default auth config), require mode selection + managed = (schema or {}).get("composio_managed_auth_schemes") or [] + if isinstance(managed, list) and "OAUTH2" in managed and (mode is None or mode == "OAUTH2"): + redirect_url, connection_id = self._initiate_connection(toolkit_slug) + build_config["auth_link"]["value"] = redirect_url + logger.info(f"New OAuth URL created for {toolkit_slug}: {redirect_url}") + return build_config + if not mode: + build_config["auth_link"]["value"] = "connect" + build_config["auth_link"]["auth_tooltip"] = "Select Auth Mode" + return build_config + # Custom modes: create auth config and/or initiate with config + # Validate required fields before creating any auth config + required_missing = [] + if mode == "OAUTH2": + req_names_pre = self._get_schema_field_names( + schema, + "OAUTH2", + "auth_config_creation", + "required", + ) + for fname in req_names_pre: + if fname in build_config: + val = build_config[fname].get("value") + if val in (None, ""): + required_missing.append(fname) + elif mode == "API_KEY": + req_names_pre = self._get_schema_field_names( + schema, + "API_KEY", + "connected_account_initiation", + "required", + ) + for fname in req_names_pre: + if fname in build_config: + val = build_config[fname].get("value") + if val in (None, ""): + required_missing.append(fname) + else: + req_names_pre = self._get_schema_field_names( + schema, + mode, + "connected_account_initiation", + "required", + ) + for fname in req_names_pre: + if fname in build_config: + val = build_config[fname].get("value") + if val in (None, ""): + required_missing.append(fname) + if required_missing: + # Surface errors on each missing field + for fname in required_missing: + if fname in build_config and isinstance(build_config[fname], dict): + build_config[fname]["helper_text"] = "This field is required" + build_config[fname]["helper_text_metadata"] = {"variant": "destructive"} + # Also reflect in info for guaranteed visibility + existing_info = build_config[fname].get("info") or "" + build_config[fname]["info"] = f"Required: {existing_info}".strip() + build_config[fname]["show"] = True + # Add a visible top-level hint near Auth Mode as well + build_config.setdefault("auth_mode", {}) + missing_joined = ", ".join(required_missing) + build_config["auth_mode"]["helper_text"] = f"Missing required: {missing_joined}" + build_config["auth_mode"]["helper_text_metadata"] = {"variant": "destructive"} + build_config["auth_link"]["value"] = "connect" + build_config["auth_link"]["auth_tooltip"] = f"Missing: {missing_joined}" + return build_config + composio = self._build_wrapper() + if mode == "OAUTH2": + # If an auth_config was already created via the button, use it and include initiation fields + stored_ac_id = (build_config.get("auth_link") or {}).get("auth_config_id") + if stored_ac_id: + # Build val from schema-declared connected_account_initiation required + rendered fields + val_payload = {} + init_req = self._get_schema_field_names( + schema, + "OAUTH2", + "connected_account_initiation", + "required", + ) + candidate_names = set(self._auth_dynamic_fields) | init_req + for fname in candidate_names: + if fname in build_config: + v = build_config[fname].get("value") + if v not in (None, ""): + val_payload[fname] = v + redirect = composio.connected_accounts.initiate( + user_id=self.entity_id, + auth_config_id=stored_ac_id, + config={"auth_scheme": "OAUTH2", "val": val_payload} if val_payload else None, + ) + redirect_url = getattr(redirect, "redirect_url", None) + connection_id = getattr(redirect, "id", None) if redirect_url: build_config["auth_link"]["value"] = redirect_url - logger.info(f"Reusing existing OAuth URL for {toolkit_slug}: {redirect_url}") - return build_config - except (AttributeError, ValueError, ConnectionError) as e: - logger.debug(f"Could not retrieve connection {stored_connection_id}: {e}") - # Continue to create new connection below - - # Create new OAuth connection ONLY if we truly have no usable connection yet - if existing_active is None and not (stored_connection_id and status in ("ACTIVE", "INITIATED")): - try: - redirect_url, connection_id = self._initiate_connection(toolkit_slug) - build_config["auth_link"]["value"] = redirect_url - build_config["auth_link"]["connection_id"] = connection_id # Store connection ID - logger.info(f"New OAuth URL created for {toolkit_slug}: {redirect_url}") - except (ValueError, ConnectionError) as e: - logger.error(f"Error creating OAuth connection: {e}") + if connection_id: + build_config["auth_link"]["connection_id"] = connection_id + # Clear action blocker text on successful initiation + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} + return build_config + # Otherwise, create custom OAuth2 auth config using schema-declared required fields + credentials = {} + missing = [] + # Collect required names from schema + req_names = self._get_schema_field_names( + schema, + "OAUTH2", + "auth_config_creation", + "required", + ) + candidate_names = set(self._auth_dynamic_fields) | req_names + for fname in candidate_names: + if fname in build_config: + val = build_config[fname].get("value") + if val not in (None, ""): + credentials[fname] = val + else: + missing.append(fname) + # proceed even if missing optional; backend will validate + ac = composio.auth_configs.create( + toolkit=toolkit_slug, + options={ + "type": "use_custom_auth", + "auth_scheme": "OAUTH2", + "credentials": credentials, + }, + ) + auth_config_id = getattr(ac, "id", None) + # If the schema declares initiation required fields, render them and defer initiation + init_req = self._get_schema_field_names( + schema, + "OAUTH2", + "connected_account_initiation", + "required", + ) + if init_req: + self._clear_auth_dynamic_fields(build_config) + for name in init_req: + self._add_text_field( + build_config, + name=name, + display_name=name.replace("_", " ").title(), + info="Provide connection parameter", + required=True, + ) + build_config.setdefault("auth_link", {}) + build_config["auth_link"]["auth_config_id"] = auth_config_id + build_config["auth_link"]["value"] = "connect" + build_config["auth_link"]["auth_tooltip"] = "Connect" + return build_config + # Otherwise initiate immediately + redirect = composio.connected_accounts.initiate( + user_id=self.entity_id, + auth_config_id=auth_config_id, + ) + redirect_url = getattr(redirect, "redirect_url", None) + connection_id = getattr(redirect, "id", None) + if redirect_url: + build_config["auth_link"]["value"] = redirect_url + if connection_id: + build_config["auth_link"]["connection_id"] = connection_id + # Hide auth fields immediately after successful initiation + schema = self._get_toolkit_schema() + self._clear_auth_fields_from_schema(build_config, schema) + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} + return build_config + if mode == "API_KEY": + ac = composio.auth_configs.create( + toolkit=toolkit_slug, + options={"type": "use_custom_auth", "auth_scheme": "API_KEY", "credentials": {}}, + ) + auth_config_id = getattr(ac, "id", None) + # Build initiation config.val from schema-declared required names and dynamic fields + val_payload = {} + missing = [] + # Collect required names from schema + req_names = self._get_schema_field_names( + schema, + "API_KEY", + "connected_account_initiation", + "required", + ) + # Merge rendered dynamic fields and schema-required names + candidate_names = set(self._auth_dynamic_fields) | req_names + for fname in candidate_names: + if fname in build_config: + val = build_config[fname].get("value") + if val not in (None, ""): + val_payload[fname] = val + else: + missing.append(fname) + initiation = composio.connected_accounts.initiate( + user_id=self.entity_id, + auth_config_id=auth_config_id, + config={"auth_scheme": "API_KEY", "val": val_payload}, + ) + connection_id = getattr(initiation, "id", None) + redirect_url = getattr(initiation, "redirect_url", None) + # Do not store connection_id on initiation; only when ACTIVE + if redirect_url: + build_config["auth_link"]["value"] = redirect_url + build_config["auth_link"]["auth_tooltip"] = "Disconnect" + else: + # No redirect for API_KEY; mark as connected + build_config["auth_link"]["value"] = "validated" + build_config["auth_link"]["auth_tooltip"] = "Disconnect" + # In both cases, hide auth fields immediately after successful initiation + schema = self._get_toolkit_schema() + self._clear_auth_fields_from_schema(build_config, schema) + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} + return build_config + # Generic custom auth flow for any other mode (treat like API_KEY) + ac = composio.auth_configs.create( + toolkit=toolkit_slug, + options={"type": "use_custom_auth", "auth_scheme": mode, "credentials": {}}, + ) + auth_config_id = getattr(ac, "id", None) + val_payload = {} + req_names = self._get_schema_field_names( + schema, + mode, + "connected_account_initiation", + "required", + ) + candidate_names = set(self._auth_dynamic_fields) | req_names + for fname in candidate_names: + if fname in build_config: + val = build_config[fname].get("value") + if val not in (None, ""): + val_payload[fname] = val + initiation = composio.connected_accounts.initiate( + user_id=self.entity_id, + auth_config_id=auth_config_id, + config={"auth_scheme": mode, "val": val_payload}, + ) + connection_id = getattr(initiation, "id", None) + redirect_url = getattr(initiation, "redirect_url", None) + # Do not store connection_id on initiation; only when ACTIVE + if redirect_url: + build_config["auth_link"]["value"] = redirect_url + build_config["auth_link"]["auth_tooltip"] = "Disconnect" + else: + build_config["auth_link"]["value"] = "validated" + build_config["auth_link"]["auth_tooltip"] = "Disconnect" + return build_config + except (ValueError, ConnectionError, TypeError) as e: + logger.error(f"Error creating connection: {e}") build_config["auth_link"]["value"] = "connect" build_config["auth_link"]["auth_tooltip"] = f"Error: {e!s}" else: @@ -1017,6 +1628,41 @@ class ComposioBaseComponent(Component): # Show validated connection status build_config["auth_link"]["value"] = "validated" build_config["auth_link"]["auth_tooltip"] = "Disconnect" + build_config["auth_link"]["show"] = False + # Update auth mode UI to reflect connected scheme + scheme, is_managed = self._get_connection_auth_info(active_connection_id) + if scheme: + build_config.setdefault("auth_link", {}) + build_config["auth_link"]["auth_scheme"] = scheme + build_config.setdefault("auth_mode", {}) + build_config["auth_mode"]["value"] = scheme + build_config["auth_mode"]["options"] = [scheme] + build_config["auth_mode"]["show"] = False + try: + pill = TabInput( + name="auth_mode", + display_name="Auth Mode", + options=[scheme], + value=scheme, + ).to_dict() + pill["show"] = True + build_config["auth_mode"] = pill + except (TypeError, ValueError, AttributeError): + build_config["auth_mode"] = { + "name": "auth_mode", + "display_name": "Auth Mode", + "type": "tab", + "options": [scheme], + "value": scheme, + "show": True, + } + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} + # Clear any auth fields since we are already connected + schema = self._get_toolkit_schema() + self._clear_auth_fields_from_schema(build_config, schema) + build_config.setdefault("create_auth_config", {}) + build_config["create_auth_config"]["show"] = False build_config["action_button"]["helper_text"] = "" build_config["action_button"]["helper_text_metadata"] = {} else: @@ -1025,20 +1671,30 @@ class ComposioBaseComponent(Component): build_config["action_button"]["helper_text"] = "Please connect before selecting actions." build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"} - # CRITICAL: If tool_mode is enabled from ANY source, immediately hide action field and return + # CRITICAL: If tool_mode is enabled from ANY source, hide action UI but keep auth flow available if current_tool_mode: build_config["action_button"]["show"] = False - # CRITICAL: Hide ALL action parameter fields when tool mode is enabled + # Hide ALL action parameter fields when tool mode is enabled for field in self._all_fields: if field in build_config: build_config[field]["show"] = False # Also hide any other action-related fields that might be in build_config for field_name_in_config in build_config: # noqa: PLC0206 - # Skip base fields like api_key, tool_mode, action, etc. + # Skip base fields like api_key, tool_mode, action, etc., and dynamic auth fields if ( - field_name_in_config not in ["api_key", "tool_mode", "action_button", "auth_link", "entity_id"] + field_name_in_config + not in [ + "api_key", + "tool_mode", + "action_button", + "auth_link", + "entity_id", + "auth_mode", + "auth_mode_pill", + ] + and field_name_in_config not in getattr(self, "_auth_dynamic_fields", set()) and isinstance(build_config[field_name_in_config], dict) and "show" in build_config[field_name_in_config] ): @@ -1049,8 +1705,23 @@ class ComposioBaseComponent(Component): build_config["tool_mode"] = {"value": True} elif isinstance(build_config["tool_mode"], dict): build_config["tool_mode"]["value"] = True - # Don't proceed with any other logic that might override this - return build_config + # Keep auth UI available and render fields if needed + build_config.setdefault("auth_link", {}) + build_config["auth_link"]["show"] = False + build_config["auth_link"]["display_name"] = "" + try: + schema = self._get_toolkit_schema() + mode = (build_config.get("auth_mode") or {}).get("value") + managed = (schema or {}).get("composio_managed_auth_schemes") or [] + if ( + mode + and not (isinstance(managed, list) and mode in managed) + and not getattr(self, "_auth_dynamic_fields", set()) + ): + self._render_custom_auth_fields(build_config, schema or {}, mode) + except (TypeError, ValueError, AttributeError): + pass + # Do NOT return here; allow auth flow to run in Tool Mode if field_name == "tool_mode": if field_value is True: @@ -1064,11 +1735,97 @@ class ComposioBaseComponent(Component): return build_config if field_name == "action_button": + # If selection is cancelled/cleared, remove generated fields + def _is_cleared(val: Any) -> bool: + return ( + not val + or ( + isinstance(val, list) + and (len(val) == 0 or (len(val) > 0 and isinstance(val[0], dict) and not val[0].get("name"))) + ) + or (isinstance(val, str) and val in ("", "disabled", "placeholder")) + ) + + if _is_cleared(field_value): + self._hide_all_action_fields(build_config) + return build_config + self._update_action_config(build_config, field_value) # Keep the existing show/hide behaviour self.show_hide_fields(build_config, field_value) return build_config + # Handle auth config button click + if field_name == "create_auth_config" and field_value == "create": + try: + composio = self._build_wrapper() + toolkit_slug = self.app_name.lower() + schema = self._get_toolkit_schema() or {} + # Collect required fields from the current build_config + credentials = {} + req_names = self._get_schema_field_names(schema, "OAUTH2", "auth_config_creation", "required") + candidate_names = set(self._auth_dynamic_fields) | req_names + for fname in candidate_names: + if fname in build_config: + val = build_config[fname].get("value") + if val not in (None, ""): + credentials[fname] = val + # Create a new auth config using the collected credentials + ac = composio.auth_configs.create( + toolkit=toolkit_slug, + options={"type": "use_custom_auth", "auth_scheme": "OAUTH2", "credentials": credentials}, + ) + auth_config_id = getattr(ac, "id", None) + build_config.setdefault("auth_link", {}) + if auth_config_id: + # Check if there are connection initiation required fields + initiation_required = self._get_schema_field_names( + schema, "OAUTH2", "connected_account_initiation", "required" + ) + if initiation_required: + # Populate those fields dynamically for the user to fill + self._clear_auth_dynamic_fields(build_config) + for name in initiation_required: + # Render as text inputs to collect connection fields + self._add_text_field( + build_config, + name=name, + display_name=name.replace("_", " ").title(), + info="Provide connection parameter", + required=True, + ) + # Store the new auth_config_id so pressing Connect will use it + build_config["auth_link"]["auth_config_id"] = auth_config_id + build_config["auth_link"]["value"] = "connect" + build_config["auth_link"]["auth_tooltip"] = "Connect" + return build_config + # If no initiation fields required, initiate immediately + connection_request = composio.connected_accounts.initiate( + user_id=self.entity_id, auth_config_id=auth_config_id + ) + redirect_url = getattr(connection_request, "redirect_url", None) + connection_id = getattr(connection_request, "id", None) + if redirect_url and redirect_url.startswith(("http://", "https://")): + build_config["auth_link"]["value"] = redirect_url + build_config["auth_link"]["auth_tooltip"] = "Disconnect" + build_config["auth_link"]["connection_id"] = connection_id + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} + logger.info(f"New OAuth URL created for {toolkit_slug}: {redirect_url}") + else: + logger.error(f"Failed to initiate connection with new auth config: {redirect_url}") + build_config["auth_link"]["value"] = "error" + build_config["auth_link"]["auth_tooltip"] = f"Error: {redirect_url}" + else: + logger.error(f"Failed to create new auth config for {toolkit_slug}") + build_config["auth_link"]["value"] = "error" + build_config["auth_link"]["auth_tooltip"] = "Create Auth Config failed" + except (ValueError, ConnectionError, TypeError) as e: + logger.error(f"Error creating new auth config: {e}") + build_config["auth_link"]["value"] = "error" + build_config["auth_link"]["auth_tooltip"] = f"Error: {e!s}" + return build_config + # Handle API key removal if field_name == "api_key" and len(field_value) == 0: build_config["auth_link"]["value"] = "" @@ -1076,7 +1833,34 @@ class ComposioBaseComponent(Component): build_config["action_button"]["options"] = [] build_config["action_button"]["helper_text"] = "Please connect before selecting actions." build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"} + build_config.setdefault("auth_link", {}) build_config["auth_link"].pop("connection_id", None) + build_config["auth_link"].pop("auth_scheme", None) + # Restore auth_mode dropdown and hide pill + try: + dd = DropdownInput( + name="auth_mode", + display_name="Auth Mode", + options=[], + placeholder="Select auth mode", + toggle=True, + toggle_disable=True, + show=True, + real_time_refresh=True, + helper_text="Choose how to authenticate with the toolkit.", + ).to_dict() + build_config["auth_mode"] = dd + except (TypeError, ValueError, AttributeError): + build_config.setdefault("auth_mode", {}) + build_config["auth_mode"]["show"] = True + build_config["auth_mode"].pop("value", None) + # NEW: Clear any selected action and hide generated fields when API key is cleared + try: + if "action_button" in build_config and isinstance(build_config["action_button"], dict): + build_config["action_button"]["value"] = "disabled" + self._hide_all_action_fields(build_config) + except (TypeError, ValueError, AttributeError): + pass return build_config # Only proceed with connection logic if we have an API key @@ -1166,7 +1950,7 @@ class ComposioBaseComponent(Component): configured_tools.append(tool) return configured_tools - async def _get_tools(self) -> list[Tool]: + def _get_tools(self) -> list[Tool]: """Get tools with cached results and optimized name sanitization.""" composio = self._build_wrapper() self.set_default_tools() @@ -1228,22 +2012,26 @@ class ComposioBaseComponent(Component): if value is None or value == "" or (isinstance(value, list) and len(value) == 0): continue + # Determine schema for this field + prop_schema = schema_properties.get(field, {}) + + # Parse JSON for object/array string inputs (applies to required and optional) + if isinstance(value, str) and prop_schema.get("type") in {"array", "object"}: + try: + value = json.loads(value) + except json.JSONDecodeError: + # Fallback for simple arrays of primitives + if prop_schema.get("type") == "array": + value = [item.strip() for item in value.split(",") if item.strip() != ""] + # For optional fields, be more strict about including them # Only include if the user has explicitly provided a meaningful value if field not in required_fields: - # Get the default value from the schema - field_schema = schema_properties.get(field, {}) - schema_default = field_schema.get("default") - - # Skip if the current value matches the schema default + # Compare against schema default after normalization + schema_default = prop_schema.get("default") if value == schema_default: continue - # Convert comma-separated to list for array parameters (heuristic) - prop_schema = schema_properties.get(field, {}) - if prop_schema.get("type") == "array" and isinstance(value, str): - value = [item.strip() for item in value.split(",")] - if field in self._bool_variables: value = bool(value) @@ -1251,8 +2039,6 @@ class ComposioBaseComponent(Component): final_field_name = field if field.endswith("_user_id") and field.startswith(self.app_name): final_field_name = "user_id" - elif field.endswith("_status") and field.startswith(self.app_name): - final_field_name = "status" arguments[final_field_name] = value @@ -1289,3 +2075,83 @@ class ComposioBaseComponent(Component): def set_default_tools(self): """Set the default tools.""" + + def _get_schema_field_names( + self, + schema: dict[str, Any] | None, + mode: str, + section_kind: str, + bucket: str, + ) -> set[str]: + names: set[str] = set() + if not schema: + return names + details = schema.get("auth_config_details") or schema.get("authConfigDetails") or [] + for item in details: + if (item.get("mode") or item.get("auth_method")) != mode: + continue + fields = item.get("fields") or {} + section = ( + fields.get(section_kind) + or fields.get( + "authConfigCreation" if section_kind == "auth_config_creation" else "connectedAccountInitiation" + ) + or {} + ) + for entry in section.get(bucket, []) or []: + name = entry.get("name") if isinstance(entry, dict) else None + if name: + names.add(name) + return names + + def _get_schema_required_entries( + self, + schema: dict[str, Any] | None, + mode: str, + section_kind: str, + ) -> list[dict[str, Any]]: + if not schema: + return [] + details = schema.get("auth_config_details") or schema.get("authConfigDetails") or [] + for item in details: + if (item.get("mode") or item.get("auth_method")) != mode: + continue + fields = item.get("fields") or {} + section = ( + fields.get(section_kind) + or fields.get( + "authConfigCreation" if section_kind == "auth_config_creation" else "connectedAccountInitiation" + ) + or {} + ) + req = section.get("required", []) or [] + # Normalize dict-like entries + return [entry for entry in req if isinstance(entry, dict)] + return [] + + def _hide_all_action_fields(self, build_config: dict) -> None: + """Hide and reset all action parameter inputs, regardless of trace flags.""" + # Hide known action fields + for fname in list(self._all_fields): + if fname in build_config and isinstance(build_config[fname], dict): + build_config[fname]["show"] = False + build_config[fname]["value"] = "" if fname not in self._bool_variables else False + # Hide any other visible, non-protected fields that look like parameters + protected = { + "code", + "entity_id", + "api_key", + "auth_link", + "action_button", + "tool_mode", + "auth_mode", + "auth_mode_pill", + "create_auth_config", + } + for key, cfg in list(build_config.items()): + if key in protected: + continue + if isinstance(cfg, dict) and "show" in cfg: + cfg["show"] = False + if "value" in cfg: + cfg["value"] = ""