From fc7f6c4c6cd16439d9eb3aa2da30bc72a86e78bd Mon Sep 17 00:00:00 2001 From: Rodrigo Nader Date: Fri, 24 Apr 2026 00:56:09 -0300 Subject: [PATCH 01/14] fix: Handle videos with no comments in YouTube Comments component (#10633) * fix: Handle videos with no comments in YouTube Comments component - Define column order once to avoid code duplication - Create empty DataFrame with proper schema when no comments exist - Prevents KeyError when trying to reorder columns on empty DataFrame - Returns consistent DataFrame structure regardless of comment count * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * [autofix.ci] apply automated fixes * Update component_index.json --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Hare --- .secrets.baseline | 22 +++-------------- .../starter_projects/Youtube Analysis.json | 4 ++-- src/lfx/src/lfx/_assets/component_index.json | 6 ++--- .../src/lfx/components/youtube/comments.py | 24 ++++++++++++------- 4 files changed, 23 insertions(+), 33 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 3d93a9e513..de765fa22f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2481,35 +2481,19 @@ } ], "src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json": [ - { - "type": "Hex High Entropy String", - "filename": "src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json", - "hashed_secret": "ef3435e29e3a2c5dcbbb633856c85561848cd995", - "is_verified": false, - "line_number": 268, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json", - "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", - "is_verified": false, - "line_number": 838, - "is_secret": false - }, { "type": "Hex High Entropy String", "filename": "src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json", "hashed_secret": "d6e6d7b4b115cd3b9d172623199f8c403055fecc", "is_verified": false, - "line_number": 1452 + "line_number": 1291 }, { "type": "Hex High Entropy String", "filename": "src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json", "hashed_secret": "54ed260e3bc31bc77ee06754dff850981d39a66c", "is_verified": false, - "line_number": 2223, + "line_number": 2062, "is_secret": false } ], @@ -8214,5 +8198,5 @@ } ] }, - "generated_at": "2026-04-23T21:12:19Z" + "generated_at": "2026-04-24T03:31:30Z" } diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json index 170ec2a4fb..c339173ab7 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json @@ -265,7 +265,7 @@ "legacy": false, "lf_version": "1.4.3", "metadata": { - "code_hash": "20398e0d18df", + "code_hash": "8c5296516f6c", "dependencies": { "dependencies": [ { @@ -340,7 +340,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from contextlib import contextmanager\n\nimport pandas as pd\nfrom googleapiclient.discovery import build\nfrom googleapiclient.errors import HttpError\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, IntInput, MessageTextInput, SecretStrInput\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.template.field.base import Output\n\n\nclass YouTubeCommentsComponent(Component):\n \"\"\"A component that retrieves comments from YouTube videos.\"\"\"\n\n display_name: str = \"YouTube Comments\"\n description: str = \"Retrieves and analyzes comments from YouTube videos.\"\n icon: str = \"YouTube\"\n\n # Constants\n COMMENTS_DISABLED_STATUS = 403\n NOT_FOUND_STATUS = 404\n API_MAX_RESULTS = 100\n\n inputs = [\n MessageTextInput(\n name=\"video_url\",\n display_name=\"Video URL\",\n info=\"The URL of the YouTube video to get comments from.\",\n tool_mode=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"YouTube API Key\",\n info=\"Your YouTube Data API key.\",\n required=True,\n ),\n IntInput(\n name=\"max_results\",\n display_name=\"Max Results\",\n value=20,\n info=\"The maximum number of comments to return.\",\n ),\n DropdownInput(\n name=\"sort_by\",\n display_name=\"Sort By\",\n options=[\"time\", \"relevance\"],\n value=\"relevance\",\n info=\"Sort comments by time or relevance.\",\n ),\n BoolInput(\n name=\"include_replies\",\n display_name=\"Include Replies\",\n value=False,\n info=\"Whether to include replies to comments.\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_metrics\",\n display_name=\"Include Metrics\",\n value=True,\n info=\"Include metrics like like count and reply count.\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(name=\"comments\", display_name=\"Comments\", method=\"get_video_comments\"),\n ]\n\n def _extract_video_id(self, video_url: str) -> str:\n \"\"\"Extracts the video ID from a YouTube URL.\"\"\"\n import re\n\n patterns = [\n r\"(?:youtube\\.com\\/watch\\?v=|youtu.be\\/|youtube.com\\/embed\\/)([^&\\n?#]+)\",\n r\"youtube.com\\/shorts\\/([^&\\n?#]+)\",\n ]\n\n for pattern in patterns:\n match = re.search(pattern, video_url)\n if match:\n return match.group(1)\n\n return video_url.strip()\n\n def _process_reply(self, reply: dict, parent_id: str, *, include_metrics: bool = True) -> dict:\n \"\"\"Process a single reply comment.\"\"\"\n reply_snippet = reply[\"snippet\"]\n reply_data = {\n \"comment_id\": reply[\"id\"],\n \"parent_comment_id\": parent_id,\n \"author\": reply_snippet[\"authorDisplayName\"],\n \"text\": reply_snippet[\"textDisplay\"],\n \"published_at\": reply_snippet[\"publishedAt\"],\n \"is_reply\": True,\n }\n if include_metrics:\n reply_data[\"like_count\"] = reply_snippet[\"likeCount\"]\n reply_data[\"reply_count\"] = 0 # Replies can't have replies\n\n return reply_data\n\n def _process_comment(\n self, item: dict, *, include_metrics: bool = True, include_replies: bool = False\n ) -> list[dict]:\n \"\"\"Process a single comment thread.\"\"\"\n comment = item[\"snippet\"][\"topLevelComment\"][\"snippet\"]\n comment_id = item[\"snippet\"][\"topLevelComment\"][\"id\"]\n\n # Basic comment data\n processed_comments = [\n {\n \"comment_id\": comment_id,\n \"parent_comment_id\": \"\", # Empty for top-level comments\n \"author\": comment[\"authorDisplayName\"],\n \"author_channel_url\": comment.get(\"authorChannelUrl\", \"\"),\n \"text\": comment[\"textDisplay\"],\n \"published_at\": comment[\"publishedAt\"],\n \"updated_at\": comment[\"updatedAt\"],\n \"is_reply\": False,\n }\n ]\n\n # Add metrics if requested\n if include_metrics:\n processed_comments[0].update(\n {\n \"like_count\": comment[\"likeCount\"],\n \"reply_count\": item[\"snippet\"][\"totalReplyCount\"],\n }\n )\n\n # Add replies if requested\n if include_replies and item[\"snippet\"][\"totalReplyCount\"] > 0 and \"replies\" in item:\n for reply in item[\"replies\"][\"comments\"]:\n reply_data = self._process_reply(reply, parent_id=comment_id, include_metrics=include_metrics)\n processed_comments.append(reply_data)\n\n return processed_comments\n\n @contextmanager\n def youtube_client(self):\n \"\"\"Context manager for YouTube API client.\"\"\"\n client = build(\"youtube\", \"v3\", developerKey=self.api_key)\n try:\n yield client\n finally:\n client.close()\n\n def get_video_comments(self) -> DataFrame:\n \"\"\"Retrieves comments from a YouTube video and returns as DataFrame.\"\"\"\n try:\n # Extract video ID from URL\n video_id = self._extract_video_id(self.video_url)\n\n # Use context manager for YouTube API client\n with self.youtube_client() as youtube:\n comments_data = []\n results_count = 0\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results),\n order=self.sort_by,\n textFormat=\"plainText\",\n )\n\n while request and results_count < self.max_results:\n response = request.execute()\n\n for item in response.get(\"items\", []):\n if results_count >= self.max_results:\n break\n\n comments = self._process_comment(\n item, include_metrics=self.include_metrics, include_replies=self.include_replies\n )\n comments_data.extend(comments)\n results_count += 1\n\n # Get the next page if available and needed\n if \"nextPageToken\" in response and results_count < self.max_results:\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results - results_count),\n order=self.sort_by,\n textFormat=\"plainText\",\n pageToken=response[\"nextPageToken\"],\n )\n else:\n request = None\n\n # Convert to DataFrame\n comments_df = pd.DataFrame(comments_data)\n\n # Add video metadata\n comments_df[\"video_id\"] = video_id\n comments_df[\"video_url\"] = self.video_url\n\n # Sort columns for better organization\n column_order = [\n \"video_id\",\n \"video_url\",\n \"comment_id\",\n \"parent_comment_id\",\n \"is_reply\",\n \"author\",\n \"author_channel_url\",\n \"text\",\n \"published_at\",\n \"updated_at\",\n ]\n\n if self.include_metrics:\n column_order.extend([\"like_count\", \"reply_count\"])\n\n comments_df = comments_df[column_order]\n\n return DataFrame(comments_df)\n\n except HttpError as e:\n error_message = f\"YouTube API error: {e!s}\"\n if e.resp.status == self.COMMENTS_DISABLED_STATUS:\n error_message = \"Comments are disabled for this video or API quota exceeded.\"\n elif e.resp.status == self.NOT_FOUND_STATUS:\n error_message = \"Video not found.\"\n\n return DataFrame(pd.DataFrame({\"error\": [error_message]}))\n" + "value": "from contextlib import contextmanager\n\nimport pandas as pd\nfrom googleapiclient.discovery import build\nfrom googleapiclient.errors import HttpError\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, IntInput, MessageTextInput, SecretStrInput\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.template.field.base import Output\n\n\nclass YouTubeCommentsComponent(Component):\n \"\"\"A component that retrieves comments from YouTube videos.\"\"\"\n\n display_name: str = \"YouTube Comments\"\n description: str = \"Retrieves and analyzes comments from YouTube videos.\"\n icon: str = \"YouTube\"\n\n # Constants\n COMMENTS_DISABLED_STATUS = 403\n NOT_FOUND_STATUS = 404\n API_MAX_RESULTS = 100\n\n inputs = [\n MessageTextInput(\n name=\"video_url\",\n display_name=\"Video URL\",\n info=\"The URL of the YouTube video to get comments from.\",\n tool_mode=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"YouTube API Key\",\n info=\"Your YouTube Data API key.\",\n required=True,\n ),\n IntInput(\n name=\"max_results\",\n display_name=\"Max Results\",\n value=20,\n info=\"The maximum number of comments to return.\",\n ),\n DropdownInput(\n name=\"sort_by\",\n display_name=\"Sort By\",\n options=[\"time\", \"relevance\"],\n value=\"relevance\",\n info=\"Sort comments by time or relevance.\",\n ),\n BoolInput(\n name=\"include_replies\",\n display_name=\"Include Replies\",\n value=False,\n info=\"Whether to include replies to comments.\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_metrics\",\n display_name=\"Include Metrics\",\n value=True,\n info=\"Include metrics like like count and reply count.\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(name=\"comments\", display_name=\"Comments\", method=\"get_video_comments\"),\n ]\n\n def _extract_video_id(self, video_url: str) -> str:\n \"\"\"Extracts the video ID from a YouTube URL.\"\"\"\n import re\n\n patterns = [\n r\"(?:youtube\\.com\\/watch\\?v=|youtu.be\\/|youtube.com\\/embed\\/)([^&\\n?#]+)\",\n r\"youtube.com\\/shorts\\/([^&\\n?#]+)\",\n ]\n\n for pattern in patterns:\n match = re.search(pattern, video_url)\n if match:\n return match.group(1)\n\n return video_url.strip()\n\n def _process_reply(self, reply: dict, parent_id: str, *, include_metrics: bool = True) -> dict:\n \"\"\"Process a single reply comment.\"\"\"\n reply_snippet = reply[\"snippet\"]\n reply_data = {\n \"comment_id\": reply[\"id\"],\n \"parent_comment_id\": parent_id,\n \"author\": reply_snippet[\"authorDisplayName\"],\n \"text\": reply_snippet[\"textDisplay\"],\n \"published_at\": reply_snippet[\"publishedAt\"],\n \"is_reply\": True,\n }\n if include_metrics:\n reply_data[\"like_count\"] = reply_snippet[\"likeCount\"]\n reply_data[\"reply_count\"] = 0 # Replies can't have replies\n\n return reply_data\n\n def _process_comment(\n self, item: dict, *, include_metrics: bool = True, include_replies: bool = False\n ) -> list[dict]:\n \"\"\"Process a single comment thread.\"\"\"\n comment = item[\"snippet\"][\"topLevelComment\"][\"snippet\"]\n comment_id = item[\"snippet\"][\"topLevelComment\"][\"id\"]\n\n # Basic comment data\n processed_comments = [\n {\n \"comment_id\": comment_id,\n \"parent_comment_id\": \"\", # Empty for top-level comments\n \"author\": comment[\"authorDisplayName\"],\n \"author_channel_url\": comment.get(\"authorChannelUrl\", \"\"),\n \"text\": comment[\"textDisplay\"],\n \"published_at\": comment[\"publishedAt\"],\n \"updated_at\": comment[\"updatedAt\"],\n \"is_reply\": False,\n }\n ]\n\n # Add metrics if requested\n if include_metrics:\n processed_comments[0].update(\n {\n \"like_count\": comment[\"likeCount\"],\n \"reply_count\": item[\"snippet\"][\"totalReplyCount\"],\n }\n )\n\n # Add replies if requested\n if include_replies and item[\"snippet\"][\"totalReplyCount\"] > 0 and \"replies\" in item:\n for reply in item[\"replies\"][\"comments\"]:\n reply_data = self._process_reply(reply, parent_id=comment_id, include_metrics=include_metrics)\n processed_comments.append(reply_data)\n\n return processed_comments\n\n @contextmanager\n def youtube_client(self):\n \"\"\"Context manager for YouTube API client.\"\"\"\n client = build(\"youtube\", \"v3\", developerKey=self.api_key)\n try:\n yield client\n finally:\n client.close()\n\n def get_video_comments(self) -> DataFrame:\n \"\"\"Retrieves comments from a YouTube video and returns as DataFrame.\"\"\"\n try:\n # Extract video ID from URL\n video_id = self._extract_video_id(self.video_url)\n\n # Use context manager for YouTube API client\n with self.youtube_client() as youtube:\n comments_data = []\n results_count = 0\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results),\n order=self.sort_by,\n textFormat=\"plainText\",\n )\n\n while request and results_count < self.max_results:\n response = request.execute()\n\n for item in response.get(\"items\", []):\n if results_count >= self.max_results:\n break\n\n comments = self._process_comment(\n item, include_metrics=self.include_metrics, include_replies=self.include_replies\n )\n comments_data.extend(comments)\n results_count += 1\n\n # Get the next page if available and needed\n if \"nextPageToken\" in response and results_count < self.max_results:\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results - results_count),\n order=self.sort_by,\n textFormat=\"plainText\",\n pageToken=response[\"nextPageToken\"],\n )\n else:\n request = None\n\n # Define column order\n column_order = [\n \"video_id\",\n \"video_url\",\n \"comment_id\",\n \"parent_comment_id\",\n \"is_reply\",\n \"author\",\n \"author_channel_url\",\n \"text\",\n \"published_at\",\n \"updated_at\",\n ]\n\n if self.include_metrics:\n column_order.extend([\"like_count\", \"reply_count\"])\n\n # Handle empty comments case\n if not comments_data:\n # Create empty DataFrame with proper columns\n comments_df = pd.DataFrame(columns=column_order)\n else:\n # Convert to DataFrame\n comments_df = pd.DataFrame(comments_data)\n\n # Add video metadata\n comments_df[\"video_id\"] = video_id\n comments_df[\"video_url\"] = self.video_url\n\n # Reorder columns\n comments_df = comments_df[column_order]\n\n return DataFrame(comments_df)\n\n except HttpError as e:\n error_message = f\"YouTube API error: {e!s}\"\n if e.resp.status == self.COMMENTS_DISABLED_STATUS:\n error_message = \"Comments are disabled for this video or API quota exceeded.\"\n elif e.resp.status == self.NOT_FOUND_STATUS:\n error_message = \"Video not found.\"\n\n return DataFrame(pd.DataFrame({\"error\": [error_message]}))\n" }, "include_metrics": { "_input_type": "BoolInput", diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index 505abdfb67..2af5024451 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -116743,7 +116743,7 @@ "icon": "YouTube", "legacy": false, "metadata": { - "code_hash": "20398e0d18df", + "code_hash": "8c5296516f6c", "dependencies": { "dependencies": [ { @@ -116819,7 +116819,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from contextlib import contextmanager\n\nimport pandas as pd\nfrom googleapiclient.discovery import build\nfrom googleapiclient.errors import HttpError\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, IntInput, MessageTextInput, SecretStrInput\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.template.field.base import Output\n\n\nclass YouTubeCommentsComponent(Component):\n \"\"\"A component that retrieves comments from YouTube videos.\"\"\"\n\n display_name: str = \"YouTube Comments\"\n description: str = \"Retrieves and analyzes comments from YouTube videos.\"\n icon: str = \"YouTube\"\n\n # Constants\n COMMENTS_DISABLED_STATUS = 403\n NOT_FOUND_STATUS = 404\n API_MAX_RESULTS = 100\n\n inputs = [\n MessageTextInput(\n name=\"video_url\",\n display_name=\"Video URL\",\n info=\"The URL of the YouTube video to get comments from.\",\n tool_mode=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"YouTube API Key\",\n info=\"Your YouTube Data API key.\",\n required=True,\n ),\n IntInput(\n name=\"max_results\",\n display_name=\"Max Results\",\n value=20,\n info=\"The maximum number of comments to return.\",\n ),\n DropdownInput(\n name=\"sort_by\",\n display_name=\"Sort By\",\n options=[\"time\", \"relevance\"],\n value=\"relevance\",\n info=\"Sort comments by time or relevance.\",\n ),\n BoolInput(\n name=\"include_replies\",\n display_name=\"Include Replies\",\n value=False,\n info=\"Whether to include replies to comments.\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_metrics\",\n display_name=\"Include Metrics\",\n value=True,\n info=\"Include metrics like like count and reply count.\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(name=\"comments\", display_name=\"Comments\", method=\"get_video_comments\"),\n ]\n\n def _extract_video_id(self, video_url: str) -> str:\n \"\"\"Extracts the video ID from a YouTube URL.\"\"\"\n import re\n\n patterns = [\n r\"(?:youtube\\.com\\/watch\\?v=|youtu.be\\/|youtube.com\\/embed\\/)([^&\\n?#]+)\",\n r\"youtube.com\\/shorts\\/([^&\\n?#]+)\",\n ]\n\n for pattern in patterns:\n match = re.search(pattern, video_url)\n if match:\n return match.group(1)\n\n return video_url.strip()\n\n def _process_reply(self, reply: dict, parent_id: str, *, include_metrics: bool = True) -> dict:\n \"\"\"Process a single reply comment.\"\"\"\n reply_snippet = reply[\"snippet\"]\n reply_data = {\n \"comment_id\": reply[\"id\"],\n \"parent_comment_id\": parent_id,\n \"author\": reply_snippet[\"authorDisplayName\"],\n \"text\": reply_snippet[\"textDisplay\"],\n \"published_at\": reply_snippet[\"publishedAt\"],\n \"is_reply\": True,\n }\n if include_metrics:\n reply_data[\"like_count\"] = reply_snippet[\"likeCount\"]\n reply_data[\"reply_count\"] = 0 # Replies can't have replies\n\n return reply_data\n\n def _process_comment(\n self, item: dict, *, include_metrics: bool = True, include_replies: bool = False\n ) -> list[dict]:\n \"\"\"Process a single comment thread.\"\"\"\n comment = item[\"snippet\"][\"topLevelComment\"][\"snippet\"]\n comment_id = item[\"snippet\"][\"topLevelComment\"][\"id\"]\n\n # Basic comment data\n processed_comments = [\n {\n \"comment_id\": comment_id,\n \"parent_comment_id\": \"\", # Empty for top-level comments\n \"author\": comment[\"authorDisplayName\"],\n \"author_channel_url\": comment.get(\"authorChannelUrl\", \"\"),\n \"text\": comment[\"textDisplay\"],\n \"published_at\": comment[\"publishedAt\"],\n \"updated_at\": comment[\"updatedAt\"],\n \"is_reply\": False,\n }\n ]\n\n # Add metrics if requested\n if include_metrics:\n processed_comments[0].update(\n {\n \"like_count\": comment[\"likeCount\"],\n \"reply_count\": item[\"snippet\"][\"totalReplyCount\"],\n }\n )\n\n # Add replies if requested\n if include_replies and item[\"snippet\"][\"totalReplyCount\"] > 0 and \"replies\" in item:\n for reply in item[\"replies\"][\"comments\"]:\n reply_data = self._process_reply(reply, parent_id=comment_id, include_metrics=include_metrics)\n processed_comments.append(reply_data)\n\n return processed_comments\n\n @contextmanager\n def youtube_client(self):\n \"\"\"Context manager for YouTube API client.\"\"\"\n client = build(\"youtube\", \"v3\", developerKey=self.api_key)\n try:\n yield client\n finally:\n client.close()\n\n def get_video_comments(self) -> DataFrame:\n \"\"\"Retrieves comments from a YouTube video and returns as DataFrame.\"\"\"\n try:\n # Extract video ID from URL\n video_id = self._extract_video_id(self.video_url)\n\n # Use context manager for YouTube API client\n with self.youtube_client() as youtube:\n comments_data = []\n results_count = 0\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results),\n order=self.sort_by,\n textFormat=\"plainText\",\n )\n\n while request and results_count < self.max_results:\n response = request.execute()\n\n for item in response.get(\"items\", []):\n if results_count >= self.max_results:\n break\n\n comments = self._process_comment(\n item, include_metrics=self.include_metrics, include_replies=self.include_replies\n )\n comments_data.extend(comments)\n results_count += 1\n\n # Get the next page if available and needed\n if \"nextPageToken\" in response and results_count < self.max_results:\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results - results_count),\n order=self.sort_by,\n textFormat=\"plainText\",\n pageToken=response[\"nextPageToken\"],\n )\n else:\n request = None\n\n # Convert to DataFrame\n comments_df = pd.DataFrame(comments_data)\n\n # Add video metadata\n comments_df[\"video_id\"] = video_id\n comments_df[\"video_url\"] = self.video_url\n\n # Sort columns for better organization\n column_order = [\n \"video_id\",\n \"video_url\",\n \"comment_id\",\n \"parent_comment_id\",\n \"is_reply\",\n \"author\",\n \"author_channel_url\",\n \"text\",\n \"published_at\",\n \"updated_at\",\n ]\n\n if self.include_metrics:\n column_order.extend([\"like_count\", \"reply_count\"])\n\n comments_df = comments_df[column_order]\n\n return DataFrame(comments_df)\n\n except HttpError as e:\n error_message = f\"YouTube API error: {e!s}\"\n if e.resp.status == self.COMMENTS_DISABLED_STATUS:\n error_message = \"Comments are disabled for this video or API quota exceeded.\"\n elif e.resp.status == self.NOT_FOUND_STATUS:\n error_message = \"Video not found.\"\n\n return DataFrame(pd.DataFrame({\"error\": [error_message]}))\n" + "value": "from contextlib import contextmanager\n\nimport pandas as pd\nfrom googleapiclient.discovery import build\nfrom googleapiclient.errors import HttpError\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, IntInput, MessageTextInput, SecretStrInput\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.template.field.base import Output\n\n\nclass YouTubeCommentsComponent(Component):\n \"\"\"A component that retrieves comments from YouTube videos.\"\"\"\n\n display_name: str = \"YouTube Comments\"\n description: str = \"Retrieves and analyzes comments from YouTube videos.\"\n icon: str = \"YouTube\"\n\n # Constants\n COMMENTS_DISABLED_STATUS = 403\n NOT_FOUND_STATUS = 404\n API_MAX_RESULTS = 100\n\n inputs = [\n MessageTextInput(\n name=\"video_url\",\n display_name=\"Video URL\",\n info=\"The URL of the YouTube video to get comments from.\",\n tool_mode=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"YouTube API Key\",\n info=\"Your YouTube Data API key.\",\n required=True,\n ),\n IntInput(\n name=\"max_results\",\n display_name=\"Max Results\",\n value=20,\n info=\"The maximum number of comments to return.\",\n ),\n DropdownInput(\n name=\"sort_by\",\n display_name=\"Sort By\",\n options=[\"time\", \"relevance\"],\n value=\"relevance\",\n info=\"Sort comments by time or relevance.\",\n ),\n BoolInput(\n name=\"include_replies\",\n display_name=\"Include Replies\",\n value=False,\n info=\"Whether to include replies to comments.\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_metrics\",\n display_name=\"Include Metrics\",\n value=True,\n info=\"Include metrics like like count and reply count.\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(name=\"comments\", display_name=\"Comments\", method=\"get_video_comments\"),\n ]\n\n def _extract_video_id(self, video_url: str) -> str:\n \"\"\"Extracts the video ID from a YouTube URL.\"\"\"\n import re\n\n patterns = [\n r\"(?:youtube\\.com\\/watch\\?v=|youtu.be\\/|youtube.com\\/embed\\/)([^&\\n?#]+)\",\n r\"youtube.com\\/shorts\\/([^&\\n?#]+)\",\n ]\n\n for pattern in patterns:\n match = re.search(pattern, video_url)\n if match:\n return match.group(1)\n\n return video_url.strip()\n\n def _process_reply(self, reply: dict, parent_id: str, *, include_metrics: bool = True) -> dict:\n \"\"\"Process a single reply comment.\"\"\"\n reply_snippet = reply[\"snippet\"]\n reply_data = {\n \"comment_id\": reply[\"id\"],\n \"parent_comment_id\": parent_id,\n \"author\": reply_snippet[\"authorDisplayName\"],\n \"text\": reply_snippet[\"textDisplay\"],\n \"published_at\": reply_snippet[\"publishedAt\"],\n \"is_reply\": True,\n }\n if include_metrics:\n reply_data[\"like_count\"] = reply_snippet[\"likeCount\"]\n reply_data[\"reply_count\"] = 0 # Replies can't have replies\n\n return reply_data\n\n def _process_comment(\n self, item: dict, *, include_metrics: bool = True, include_replies: bool = False\n ) -> list[dict]:\n \"\"\"Process a single comment thread.\"\"\"\n comment = item[\"snippet\"][\"topLevelComment\"][\"snippet\"]\n comment_id = item[\"snippet\"][\"topLevelComment\"][\"id\"]\n\n # Basic comment data\n processed_comments = [\n {\n \"comment_id\": comment_id,\n \"parent_comment_id\": \"\", # Empty for top-level comments\n \"author\": comment[\"authorDisplayName\"],\n \"author_channel_url\": comment.get(\"authorChannelUrl\", \"\"),\n \"text\": comment[\"textDisplay\"],\n \"published_at\": comment[\"publishedAt\"],\n \"updated_at\": comment[\"updatedAt\"],\n \"is_reply\": False,\n }\n ]\n\n # Add metrics if requested\n if include_metrics:\n processed_comments[0].update(\n {\n \"like_count\": comment[\"likeCount\"],\n \"reply_count\": item[\"snippet\"][\"totalReplyCount\"],\n }\n )\n\n # Add replies if requested\n if include_replies and item[\"snippet\"][\"totalReplyCount\"] > 0 and \"replies\" in item:\n for reply in item[\"replies\"][\"comments\"]:\n reply_data = self._process_reply(reply, parent_id=comment_id, include_metrics=include_metrics)\n processed_comments.append(reply_data)\n\n return processed_comments\n\n @contextmanager\n def youtube_client(self):\n \"\"\"Context manager for YouTube API client.\"\"\"\n client = build(\"youtube\", \"v3\", developerKey=self.api_key)\n try:\n yield client\n finally:\n client.close()\n\n def get_video_comments(self) -> DataFrame:\n \"\"\"Retrieves comments from a YouTube video and returns as DataFrame.\"\"\"\n try:\n # Extract video ID from URL\n video_id = self._extract_video_id(self.video_url)\n\n # Use context manager for YouTube API client\n with self.youtube_client() as youtube:\n comments_data = []\n results_count = 0\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results),\n order=self.sort_by,\n textFormat=\"plainText\",\n )\n\n while request and results_count < self.max_results:\n response = request.execute()\n\n for item in response.get(\"items\", []):\n if results_count >= self.max_results:\n break\n\n comments = self._process_comment(\n item, include_metrics=self.include_metrics, include_replies=self.include_replies\n )\n comments_data.extend(comments)\n results_count += 1\n\n # Get the next page if available and needed\n if \"nextPageToken\" in response and results_count < self.max_results:\n request = youtube.commentThreads().list(\n part=\"snippet,replies\",\n videoId=video_id,\n maxResults=min(self.API_MAX_RESULTS, self.max_results - results_count),\n order=self.sort_by,\n textFormat=\"plainText\",\n pageToken=response[\"nextPageToken\"],\n )\n else:\n request = None\n\n # Define column order\n column_order = [\n \"video_id\",\n \"video_url\",\n \"comment_id\",\n \"parent_comment_id\",\n \"is_reply\",\n \"author\",\n \"author_channel_url\",\n \"text\",\n \"published_at\",\n \"updated_at\",\n ]\n\n if self.include_metrics:\n column_order.extend([\"like_count\", \"reply_count\"])\n\n # Handle empty comments case\n if not comments_data:\n # Create empty DataFrame with proper columns\n comments_df = pd.DataFrame(columns=column_order)\n else:\n # Convert to DataFrame\n comments_df = pd.DataFrame(comments_data)\n\n # Add video metadata\n comments_df[\"video_id\"] = video_id\n comments_df[\"video_url\"] = self.video_url\n\n # Reorder columns\n comments_df = comments_df[column_order]\n\n return DataFrame(comments_df)\n\n except HttpError as e:\n error_message = f\"YouTube API error: {e!s}\"\n if e.resp.status == self.COMMENTS_DISABLED_STATUS:\n error_message = \"Comments are disabled for this video or API quota exceeded.\"\n elif e.resp.status == self.NOT_FOUND_STATUS:\n error_message = \"Video not found.\"\n\n return DataFrame(pd.DataFrame({\"error\": [error_message]}))\n" }, "include_metrics": { "_input_type": "BoolInput", @@ -118101,6 +118101,6 @@ "num_components": 355, "num_modules": 97 }, - "sha256": "dfdf083a2f0de7026d35e7bd05f1a301a15ce1399e2c320423626eb6ee75fef4", + "sha256": "3ac0fe9c7501608da87ed9f2ac6d3c1077c5955119c28b324689d959881f86c4", "version": "0.5.0" } diff --git a/src/lfx/src/lfx/components/youtube/comments.py b/src/lfx/src/lfx/components/youtube/comments.py index 68d3f5c215..8bc8926e2e 100644 --- a/src/lfx/src/lfx/components/youtube/comments.py +++ b/src/lfx/src/lfx/components/youtube/comments.py @@ -193,14 +193,7 @@ class YouTubeCommentsComponent(Component): else: request = None - # Convert to DataFrame - comments_df = pd.DataFrame(comments_data) - - # Add video metadata - comments_df["video_id"] = video_id - comments_df["video_url"] = self.video_url - - # Sort columns for better organization + # Define column order column_order = [ "video_id", "video_url", @@ -217,7 +210,20 @@ class YouTubeCommentsComponent(Component): if self.include_metrics: column_order.extend(["like_count", "reply_count"]) - comments_df = comments_df[column_order] + # Handle empty comments case + if not comments_data: + # Create empty DataFrame with proper columns + comments_df = pd.DataFrame(columns=column_order) + else: + # Convert to DataFrame + comments_df = pd.DataFrame(comments_data) + + # Add video metadata + comments_df["video_id"] = video_id + comments_df["video_url"] = self.video_url + + # Reorder columns + comments_df = comments_df[column_order] return DataFrame(comments_df) From 1a9ffa05c0b9d38eb866ff81c9470c545ecc6d13 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 24 Apr 2026 08:39:00 -0700 Subject: [PATCH 02/14] fix(custom): include __future__ imports in component sandbox compilation (#12865) * fix(custom): include __future__ imports in component sandbox compilation When compiling custom component code, `from __future__ import annotations` was classified as a regular ImportFrom node and excluded from the definitions module. Because it is a compiler directive (PEP 563), it must be present at compile() time to enable lazy annotation evaluation. Without this fix, any custom component using the standard `TYPE_CHECKING` + `from __future__ import annotations` pattern raised a NameError at class-definition time because the type-only imports were not available at runtime. Changes: - Separate __future__ imports from regular ImportFrom nodes in prepare_global_scope so they are NOT processed as runtime imports - Prepend the collected __future__ imports to the definitions module before compile(), restoring PEP 563 semantics for helper classes/functions - Thread the __future__ imports into compile_class_code() via a new optional parameter so the main component class also gets PEP 563 - Add a regression test reproducing the exact pattern described in #12776 Fixes #12776 * [autofix.ci] apply automated fixes * Update test_validate.py --------- Co-authored-by: octo-patch Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/lfx/src/lfx/custom/validate.py | 16 ++++++++--- .../unit/custom/component/test_validate.py | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/lfx/src/lfx/custom/validate.py b/src/lfx/src/lfx/custom/validate.py index 51183931d3..c9bce4a758 100644 --- a/src/lfx/src/lfx/custom/validate.py +++ b/src/lfx/src/lfx/custom/validate.py @@ -272,8 +272,9 @@ def create_class(code, class_name): module = ast.parse(code) exec_globals = prepare_global_scope(module) + future_imports = [n for n in module.body if isinstance(n, ast.ImportFrom) and n.module == "__future__"] class_code = extract_class_code(module, class_name) - compiled_class = compile_class_code(class_code) + compiled_class = compile_class_code(class_code, future_imports) return build_class_constructor(compiled_class, exec_globals, class_name) @@ -394,11 +395,15 @@ def prepare_global_scope(module): exec_globals = globals().copy() imports = [] import_froms = [] + future_imports = [] definitions = [] for node in module.body: if isinstance(node, ast.Import): imports.append(node) + elif isinstance(node, ast.ImportFrom) and node.module == "__future__": + # __future__ imports are compiler directives — collect separately + future_imports.append(node) elif isinstance(node, ast.ImportFrom) and node.module is not None: import_froms.append(node) elif isinstance(node, ast.ClassDef | ast.FunctionDef | ast.Assign | ast.AnnAssign): @@ -468,7 +473,8 @@ def prepare_global_scope(module): raise ModuleNotFoundError(msg) if definitions: - combined_module = ast.Module(body=definitions, type_ignores=[]) + # Prepend __future__ imports so compiler directives (e.g. PEP 563 annotations) take effect + combined_module = ast.Module(body=future_imports + definitions, type_ignores=[]) compiled_code = compile(combined_module, "", "exec") exec(compiled_code, exec_globals) @@ -491,16 +497,18 @@ def extract_class_code(module, class_name): return class_code -def compile_class_code(class_code): +def compile_class_code(class_code, future_imports=None): """Compiles the AST node of a class into a code object. Args: class_code: AST node of the class + future_imports: Optional list of __future__ ImportFrom nodes to prepend as compiler directives Returns: Compiled code object of the class """ - return compile(ast.Module(body=[class_code], type_ignores=[]), "", "exec") + body = (future_imports or []) + [class_code] + return compile(ast.Module(body=body, type_ignores=[]), "", "exec") def build_class_constructor(compiled_class, exec_globals, class_name): diff --git a/src/lfx/tests/unit/custom/component/test_validate.py b/src/lfx/tests/unit/custom/component/test_validate.py index 088cced7ba..db6aef2223 100644 --- a/src/lfx/tests/unit/custom/component/test_validate.py +++ b/src/lfx/tests/unit/custom/component/test_validate.py @@ -39,6 +39,34 @@ class TestLoggingComponent(Component): assert result.__name__ == "TestLoggingComponent" +def test_create_class_future_annotations_with_type_checking(): + """Regression test for issue #12776. + + `from __future__ import annotations` must act as a compiler directive so that TYPE_CHECKING-only + imports don't raise NameError at classdefinition time. + """ + code = dedent(""" +from __future__ import annotations +from typing import TYPE_CHECKING +from langflow.custom import Component + +if TYPE_CHECKING: + from typing import List + +class TypeCheckingComponent(Component): + display_name = "Test" + + def build(self, value: List[str]) -> str: + return str(value) + """) + result = create_class(code, "TypeCheckingComponent") + assert result.__name__ == "TypeCheckingComponent" + # With PEP 563 active, annotations should be stored as strings rather than evaluated + hints = result.build.__annotations__ + assert hints.get("value") == "List[str]" + assert hints.get("return") == "str" + + def test_execute_function_supports_aliased_dotted_imports(): code = dedent(""" import urllib.request as request From 99fc770206dfba46be7671db6fa6d70d76f075ab Mon Sep 17 00:00:00 2001 From: vjgit96 Date: Fri, 24 Apr 2026 13:37:15 -0400 Subject: [PATCH 03/14] docs: document tag format requirements and release artifacts in RELEASE.md (#12867) - Add requirement that all tags MUST start with 'v' prefix - Explain duplicate tag issue that caused v1.9.0 release notes to miss commits - Document automatic tag format validation in release workflow - Add new section documenting all release artifacts (PyPI packages and Docker images) - Note that backend/frontend/EP images are published independently Related to: - #12847 (prevent duplicate tags and validate tag format) - #12854 (allow backend/frontend Docker builds when main version exists) These changes ensure future releases follow proper tagging conventions and document what artifacts are published during a release. --- RELEASE.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index f18f02a6bd..49e0659cd1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -39,9 +39,28 @@ This step also usually lasts about a week. After QA and bugfixing are complete for both OSS and Desktop: * Final releases are cut from their respective RC branches. -* Release timing is coordinated with Langflow’s DevRel team. +* Release timing is coordinated with Langflow's DevRel team. * For at least 24 hours after release, Discord, GitHub, and other support channels should be monitored for critical bug reports. +### 4. Release Artifacts + +The release workflow automatically publishes the following artifacts: + +* **PyPI Packages:** + * `langflow` - Main package with all integrations + * `langflow-base` - Core framework without integrations + * `lfx` - Lightweight executor CLI + * `langflow-sdk` - SDK for programmatic access (when updated) + +* **Docker Images:** + * `langflowai/langflow` - Full Langflow image + * `langflowai/langflow-backend` - Backend-only image (published independently) + * `langflowai/langflow-frontend` - Frontend-only image (published independently) + * `langflowai/langflow-ep` - Enterprise edition image (published independently) + * `langflowai/langflow-base` - Base image without integrations + +**Note:** Backend, frontend, and enterprise images are published separately from the main image and will be built even if the main version already exists on Docker Hub. + ## Branch Model | Branch | Purpose | Merge Policy | @@ -101,6 +120,10 @@ git merge --ff-only release-X.Y.Z # Fast-forward main to include RC changes * Follows [Semantic Versioning](https://semver.org): `MAJOR.MINOR.PATCH`. * RC tags use `-rc.N`, e.g. `v1.8.0-rc.1`. +* **All tags MUST start with `v` prefix** (e.g., `v1.9.1`, not `1.9.1`). + * The release workflow validates this format and rejects tags without the `v` prefix. + * Duplicate tags (e.g., both `1.8.3` and `v1.8.3`) cause GitHub's release notes generation to use the wrong base comparison, resulting in incomplete changelogs. + * The workflow automatically checks for and prevents duplicate tags. ## Roles From 50e594eb1e6bd57eda8d3085267f708d12b5bd72 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 24 Apr 2026 13:04:27 -0700 Subject: [PATCH 04/14] fix: guard output logs against non-dict artifacts (#12877) * fix: guard against non-dict artifacts in output log building When a component like Chroma (using chromadb internally) stores a threading.Lock object as an artifact value, the code in ResultData.validate_model() and build_output_logs() would crash with TypeError because it tried to use the 'in' operator on a non-dict object. - ResultData.validate_model(): skip artifacts that are not dicts before checking for 'stream_url' and 'type' keys - build_output_logs(): guard STREAM case with isinstance(message, dict) check, and guard ARRAY case against None message Fixes #12591 (cherry picked from commit 4e0b4e8191b5172917a841142bc520ab39ac574f) * test: cover non-dict result artifacts --------- Co-authored-by: octo-patch --- src/lfx/src/lfx/graph/schema.py | 3 +++ src/lfx/src/lfx/schema/schema.py | 10 +++++++--- src/lfx/tests/unit/graph/test_result_data.py | 13 +++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 src/lfx/tests/unit/graph/test_result_data.py diff --git a/src/lfx/src/lfx/graph/schema.py b/src/lfx/src/lfx/graph/schema.py index 677bfcc7f2..39245e1da3 100644 --- a/src/lfx/src/lfx/graph/schema.py +++ b/src/lfx/src/lfx/graph/schema.py @@ -41,6 +41,9 @@ class ResultData(BaseModel): if message is None: continue + if not isinstance(message, dict): + continue + if "stream_url" in message and "type" in message: stream_url = StreamURL(location=message["stream_url"]) values["outputs"].update({key: OutputValue(message=stream_url, type=message["type"])}) diff --git a/src/lfx/src/lfx/schema/schema.py b/src/lfx/src/lfx/schema/schema.py index b1a2e19e20..f1d9e02232 100644 --- a/src/lfx/src/lfx/schema/schema.py +++ b/src/lfx/src/lfx/schema/schema.py @@ -110,7 +110,7 @@ def build_output_logs(vertex, result) -> dict: type_ = get_type(output_result) match type_: - case LogType.STREAM if "stream_url" in message: + case LogType.STREAM if isinstance(message, dict) and "stream_url" in message: message = StreamURL(location=message["stream_url"]) case LogType.STREAM: @@ -123,9 +123,13 @@ def build_output_logs(vertex, result) -> dict: message = "" case LogType.ARRAY: - if isinstance(message, DataFrame): + if message is None: + message = [] + elif isinstance(message, DataFrame): message = message.to_dict(orient="records") - message = [serialize(item) for item in message] + message = [serialize(item) for item in message] + else: + message = [serialize(item) for item in message] name = output.get("name", f"output_{index}") outputs |= {name: OutputValue(message=message, type=type_).model_dump()} diff --git a/src/lfx/tests/unit/graph/test_result_data.py b/src/lfx/tests/unit/graph/test_result_data.py new file mode 100644 index 0000000000..c5404051de --- /dev/null +++ b/src/lfx/tests/unit/graph/test_result_data.py @@ -0,0 +1,13 @@ +import threading + +from lfx.graph.schema import ResultData + + +def test_result_data_ignores_non_dict_artifact_values(): + """Vector DB artifacts may include non-iterable objects such as locks.""" + lock = threading.Lock() + + result = ResultData(artifacts={"vector_db": lock}) + + assert result.artifacts == {"vector_db": lock} + assert result.outputs == {} From 6934ac5a2927781f215fdf207aebe480c622d85a Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Mon, 27 Apr 2026 10:05:49 -0300 Subject: [PATCH 05/14] feat: Ship Calculator default tool + dynamic system prompt injection (#12864) * add better tool call on agent * ruff style and checker * [autofix.ci] apply automated fixes * gh suggestions --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../models_and_agents/test_agent_component.py | 194 ++++++++++++++++++ .../unit/agentDefaultToolsComponent.spec.ts | 103 ++++++++++ src/lfx/src/lfx/_assets/component_index.json | 58 +++++- .../lfx/components/models_and_agents/agent.py | 85 +++++++- 4 files changed, 425 insertions(+), 15 deletions(-) create mode 100644 src/frontend/tests/core/unit/agentDefaultToolsComponent.spec.ts diff --git a/src/backend/tests/unit/components/models_and_agents/test_agent_component.py b/src/backend/tests/unit/components/models_and_agents/test_agent_component.py index 3cb4648ea4..b55c0ac5e9 100644 --- a/src/backend/tests/unit/components/models_and_agents/test_agent_component.py +++ b/src/backend/tests/unit/components/models_and_agents/test_agent_component.py @@ -39,6 +39,7 @@ class TestAgentComponent(ComponentTestBaseWithoutClient): return { "_type": "Agent", "add_current_date_tool": True, + "add_calculator_tool": True, "agent_description": "A helpful agent", "model": MockLanguageModel(), "handle_parsing_errors": True, @@ -450,6 +451,199 @@ class TestAgentComponent(ComponentTestBaseWithoutClient): # Note: The provider-specific field name mapping happens inside get_llm, # so we just verify max_tokens is passed correctly + async def test_should_append_calculator_tool_when_add_calculator_toggle_is_true( + self, component_class, default_kwargs + ): + """Calculator tool is appended when the toggle is enabled. + + Given add_calculator_tool=True, When get_agent_requirements runs, + Then self.tools contains a StructuredTool derived from CalculatorComponent. + """ + from unittest.mock import AsyncMock + + from langchain_core.tools import StructuredTool + + default_kwargs["add_calculator_tool"] = True + default_kwargs["add_current_date_tool"] = False # isolate: only calculator + component = await self.component_setup(component_class, default_kwargs) + component.model = [{"name": "gpt-4o", "provider": "OpenAI", "metadata": {}}] + component.get_memory_data = AsyncMock(return_value=[]) + component._get_shared_callbacks = list + component.set_tools_callbacks = lambda *_: None + + with patch("lfx.components.models_and_agents.agent.get_llm") as mock_get_llm: + mock_get_llm.return_value = MockLanguageModel() + _, _, tools = await component.get_agent_requirements() + + assert len(tools) == 1 + assert isinstance(tools[0], StructuredTool) + assert "evaluate" in tools[0].name.lower(), f"Expected a Calculator-derived tool; got name={tools[0].name!r}" + + async def test_should_not_append_calculator_tool_when_add_calculator_toggle_is_false( + self, component_class, default_kwargs + ): + """Calculator tool is skipped when the toggle is disabled. + + Given add_calculator_tool=False, When get_agent_requirements runs, + Then no Calculator tool is appended to self.tools. + """ + from unittest.mock import AsyncMock + + default_kwargs["add_calculator_tool"] = False + default_kwargs["add_current_date_tool"] = False + component = await self.component_setup(component_class, default_kwargs) + component.model = [{"name": "gpt-4o", "provider": "OpenAI", "metadata": {}}] + component.get_memory_data = AsyncMock(return_value=[]) + component._get_shared_callbacks = list + component.set_tools_callbacks = lambda *_: None + + with patch("lfx.components.models_and_agents.agent.get_llm") as mock_get_llm: + mock_get_llm.return_value = MockLanguageModel() + _, _, tools = await component.get_agent_requirements() + + assert tools == [] + + def test_should_replace_current_date_and_model_name_when_both_placeholders_present(self, component_class): + """Unit test: helper replaces both placeholders with concrete values.""" + component = component_class() + component.model = [{"name": "gpt-4o", "provider": "OpenAI", "metadata": {}}] + + prompt = "Today is {current_date}. You are powered by {model_name}." + result = component._inject_dynamic_prompt_values(prompt) + + assert "{current_date}" not in result + assert "{model_name}" not in result + assert "gpt-4o" in result + + def test_should_leave_literal_braces_untouched_when_prompt_has_no_known_placeholders(self, component_class): + """Adversarial: prompts with literal JSON like {"key": 1} must not raise and must stay intact.""" + component = component_class() + component.model = [{"name": "gpt-4o", "provider": "OpenAI", "metadata": {}}] + + prompt = 'Respond with JSON: {"key": 1, "nested": {"a": [1, 2]}}.' + result = component._inject_dynamic_prompt_values(prompt) + + assert result == prompt + + def test_should_return_empty_when_prompt_is_empty(self, component_class): + """Edge case: empty/None prompt is returned as-is without raising.""" + component = component_class() + assert component._inject_dynamic_prompt_values("") == "" + assert component._inject_dynamic_prompt_values(None) is None + + async def test_should_inject_dynamic_values_into_system_prompt_when_message_response_runs( + self, component_class, default_kwargs + ): + """Integration: message_response must call self.set with the resolved system_prompt.""" + from unittest.mock import AsyncMock, MagicMock + + default_kwargs["system_prompt"] = "Powered by {model_name}." + default_kwargs["add_calculator_tool"] = False + default_kwargs["add_current_date_tool"] = False + component = await self.component_setup(component_class, default_kwargs) + component.model = [{"name": "gpt-4o", "provider": "OpenAI", "metadata": {}}] + component.get_memory_data = AsyncMock(return_value=[]) + component._get_shared_callbacks = list + component.set_tools_callbacks = lambda *_: None + + captured: dict = {} + + def fake_set(**kwargs): + captured.update(kwargs) + return component + + component.set = fake_set + component.create_agent_runnable = MagicMock(return_value=MagicMock()) + component.run_agent = AsyncMock(return_value=MagicMock()) + + with patch("lfx.components.models_and_agents.agent.get_llm") as mock_get_llm: + mock_get_llm.return_value = MockLanguageModel() + await component.message_response() + + assert captured.get("system_prompt") == "Powered by gpt-4o." + + async def test_should_not_mutate_format_instructions_when_json_response_runs(self, component_class, default_kwargs): + """Regression: injection must only touch agent_instructions, not format_instructions. + + Ensures literal {current_date}/{model_name} tokens in user-authored + format_instructions survive intact while the main system_prompt is + still replaced by the helper. + """ + from unittest.mock import AsyncMock, MagicMock + + default_kwargs["system_prompt"] = "Powered by {model_name}." + default_kwargs["format_instructions"] = "Return JSON with fields {current_date} and {model_name} preserved." + default_kwargs["add_calculator_tool"] = False + default_kwargs["add_current_date_tool"] = False + component = await self.component_setup(component_class, default_kwargs) + component.model = [{"name": "gpt-4o", "provider": "OpenAI", "metadata": {}}] + component.get_memory_data = AsyncMock(return_value=[]) + component._get_shared_callbacks = list + component.set_tools_callbacks = lambda *_: None + + captured: dict = {} + + def fake_set(**kwargs): + captured.update(kwargs) + return component + + component.set = fake_set + component.create_agent_runnable = MagicMock(return_value=MagicMock()) + component.run_agent = AsyncMock(return_value=MagicMock(content="{}")) + + with patch("lfx.components.models_and_agents.agent.get_llm") as mock_get_llm: + mock_get_llm.return_value = MockLanguageModel() + await component.json_response() + + prompt = captured.get("system_prompt") or "" + assert "Powered by gpt-4o." in prompt, "agent_instructions should have placeholders replaced" + assert "{current_date}" in prompt, "format_instructions literal braces must survive" + assert "{model_name} preserved" in prompt, "format_instructions literal braces must survive" + + async def test_should_accept_add_calculator_tool_in_default_keys(self, component_class, default_kwargs): + """update_build_config's default_keys validation must include add_calculator_tool.""" + from lfx.schema.dotdict import dotdict + + with patch("lfx.components.models_and_agents.agent.get_language_model_options") as mock_opts: + mock_opts.return_value = [ + { + "name": "gpt-4o", + "provider": "OpenAI", + "icon": "OpenAI", + "metadata": { + "model_class": "ChatOpenAI", + "model_name_param": "model", + "api_key_param": "api_key", + }, + } + ] + component = await self.component_setup(component_class, default_kwargs) + frontend_node = component.to_frontend_node() + build_config = frontend_node["data"]["node"]["template"] + + # add_calculator_tool must be present in the build_config already; if not, + # update_build_config will error listing it as missing. + assert "add_calculator_tool" in build_config + + updated_config = await component.update_build_config( + dotdict(build_config), mock_opts.return_value, field_name="model" + ) + assert "add_calculator_tool" in updated_config + + def test_should_have_placeholders_in_default_system_prompt(self, component_class): + """Default system_prompt ships with placeholders for the dynamic injection. + + Ensures {current_date} and {model_name} are visible on a fresh agent so + that the dynamic injection has an observable effect out-of-the-box. + """ + prompt_input = next( + (inp for inp in component_class.inputs if getattr(inp, "name", None) == "system_prompt"), + None, + ) + assert prompt_input is not None + assert "{current_date}" in prompt_input.value + assert "{model_name}" in prompt_input.value + class TestAgentComponentWithClient(ComponentTestBaseWithClient): @pytest.fixture diff --git a/src/frontend/tests/core/unit/agentDefaultToolsComponent.spec.ts b/src/frontend/tests/core/unit/agentDefaultToolsComponent.spec.ts new file mode 100644 index 0000000000..4ee5fe6de6 --- /dev/null +++ b/src/frontend/tests/core/unit/agentDefaultToolsComponent.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "../../fixtures"; +import { adjustScreenView } from "../../utils/adjust-screen-view"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; +import { + closeAdvancedOptions, + openAdvancedOptions, +} from "../../utils/open-advanced-options"; + +/** + * Covers the user-facing contract of the "Default Agent Tools" feature: + * see CZL/MANUAL_TEST_DEFAULT_AGENT_TOOLS.md scenarios S1, S3, S5. + * + * Backing unit tests (pytest) already cover runtime behaviour; these tests + * validate the UI wiring so a regression in the inputs list or default + * prompt value is caught in the release gate. + */ + +async function dragAgentOntoCanvas(page: import("@playwright/test").Page) { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { timeout: 30000 }); + await page.getByTestId("blank-flow").click(); + + await page.waitForSelector('[data-testid="sidebar-search-input"]', { + timeout: 30000, + }); + + await page.getByTestId("sidebar-search-input").click(); + await page.getByTestId("sidebar-search-input").fill("agent"); + + await page.waitForSelector('[data-testid="models_and_agentsAgent"]', { + timeout: 30000, + }); + + await page + .getByTestId("models_and_agentsAgent") + .dragTo(page.locator('//*[@id="react-flow-id"]')); + + await adjustScreenView(page); +} + +test( + "Agent ships with Calculator and Current Date toggles enabled by default (S1/S3)", + { tag: ["@release", "@workspace", "@components"] }, + async ({ page }) => { + await dragAgentOntoCanvas(page); + + // Focus the Agent node so its advanced-field drawer is reachable. + await page.getByTestId("div-generic-node").click(); + + await openAdvancedOptions(page); + + // Both advanced toggles exist as show-on-canvas checkboxes. + // Their default `value=True` is validated by the pytest suite + // (`test_should_have_placeholders_in_default_system_prompt` covers the + // default contract of the inputs list). + await expect( + page.locator('//*[@id="showadd_current_date_tool"]'), + ).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('//*[@id="showadd_calculator_tool"]'), + ).toBeVisible({ timeout: 10000 }); + + // Flip the Calculator field visible on canvas so we can assert the toggle + // is active and can be switched off and on (S3). + await page.locator('//*[@id="showadd_calculator_tool"]').click(); + await closeAdvancedOptions(page); + + await adjustScreenView(page); + + const calculatorToggle = page.getByTestId( + "toggle_bool_add_calculator_tool", + ); + await expect(calculatorToggle).toBeVisible({ timeout: 10000 }); + expect(await calculatorToggle.isChecked()).toBeTruthy(); + + // S3 — user disables the toggle. + await calculatorToggle.click(); + expect(await calculatorToggle.isChecked()).toBeFalsy(); + + // Re-enable to confirm the control is bi-directional. + await calculatorToggle.click(); + expect(await calculatorToggle.isChecked()).toBeTruthy(); + }, +); + +test( + "Agent default system prompt contains {current_date} and {model_name} placeholders (S5)", + { tag: ["@release", "@workspace", "@components"] }, + async ({ page }) => { + await dragAgentOntoCanvas(page); + + // The placeholders must be present inside the Agent Instructions textarea + // so the dynamic injection has a discoverable effect out of the box. + const instructionsTextarea = page.getByTestId("textarea_str_system_prompt"); + await expect(instructionsTextarea).toBeVisible({ timeout: 10000 }); + + //