Skip to content

Conversation

@mewaeltsegay
Copy link

feat(backend/blocks): add ConcatenateListsBlock

Description

This PR implements a new block ConcatenateListsBlock that concatenates multiple lists into a single list. This addresses the "good first issue" for implementing a list concatenation block in the platform/blocks area.

The block takes a list of lists as input and combines all elements in order into a single concatenated list. This is useful for workflows that need to merge data from multiple sources or combine results from different operations.

Changes 🏗️

  • Added ConcatenateListsBlock class in autogpt_platform/backend/backend/blocks/data_manipulation.py

    • Input: lists: List[List[Any]] - accepts a list of lists to concatenate
    • Output: concatenated_list: List[Any] - returns a single concatenated list
    • Error output: error: str - provides clear error messages for invalid input types
    • Block ID: 3cf9298b-5817-4141-9d80-7c2cc5199c8e
    • Category: BlockCategory.BASIC (consistent with other list manipulation blocks)
  • Added comprehensive test suite in autogpt_platform/backend/test/blocks/test_concatenate_lists.py

    • Tests using built-in test_input/test_output validation
    • Manual test cases covering edge cases (empty lists, single list, empty input)
    • Error handling tests for invalid input types
    • Category consistency verification
    • All tests passing
  • Implementation details:

    • Uses extend() method for efficient list concatenation
    • Preserves element order from all input lists
    • Runtime type validation: Explicitly checks isinstance(lst, list) before calling extend() to prevent:
      • Strings being iterated character-by-character (e.g., extend("abc")['a', 'b', 'c'])
      • Non-iterable types causing TypeError (e.g., extend(1))
    • Clear error messages indicating which index has invalid input
    • Handles edge cases: empty lists, empty input, single list, None values
    • Follows existing block patterns and conventions

Checklist 📋

For code changes:

  • I have clearly listed my changes in the PR description
  • I have made a test plan
  • I have tested my changes according to the test plan:
    • Run poetry run pytest test/blocks/test_concatenate_lists.py -v - all tests pass
    • Verified block can be imported and instantiated
    • Tested with built-in test cases (4 test scenarios)
    • Tested manual edge cases (empty lists, single list, empty input)
    • Tested error handling for invalid input types
    • Verified category is BASIC for consistency
    • Verified no linting errors
    • Confirmed block follows same patterns as other blocks in data_manipulation.py

Code Quality:

  • Code follows existing patterns and conventions
  • Type hints are properly used
  • Documentation strings are clear and descriptive
  • Runtime type validation implemented
  • Error handling with clear error messages
  • No linting errors
  • Prisma client generated successfully

Testing

Test Results:

test/blocks/test_concatenate_lists.py::test_concatenate_lists_block_builtin_tests PASSED
test/blocks/test_concatenate_lists.py::test_concatenate_lists_manual PASSED

============================== 2 passed in 8.35s ==============================

Test Coverage:

  • Basic concatenation: [[1, 2, 3], [4, 5, 6]][1, 2, 3, 4, 5, 6]
  • Mixed types: [["a", "b"], ["c"], ["d", "e", "f"]]["a", "b", "c", "d", "e", "f"]
  • Empty list handling: [[1, 2], []][1, 2]
  • Empty input: [][]
  • Single list: [[1, 2, 3]][1, 2, 3]
  • Error handling: Invalid input types (strings, non-lists) produce clear error messages
  • Category verification: Confirmed BlockCategory.BASIC for consistency

Review Feedback Addressed

  • Category Consistency: Changed from BlockCategory.DATA to BlockCategory.BASIC to match other list manipulation blocks (AddToListBlock, FindInListBlock, etc.)
  • Type Robustness: Added explicit runtime validation with isinstance(lst, list) check before calling extend() to prevent:
    • Strings being iterated character-by-character
    • Non-iterable types causing TypeError
  • Error Handling: Added error output field with clear, descriptive error messages indicating which index has invalid input
  • Test Coverage: Added test case for error handling with invalid input types

Related Issues

  • Addresses: "Implement block to concatenate lists" (good first issue, platform/blocks, hacktoberfest)

Notes

  • This is a straightforward data manipulation block that doesn't require external dependencies
  • The block will be automatically discovered by the block loading system
  • No database or configuration changes required
  • Compatible with existing workflow system
  • All review feedback has been addressed and incorporated

Added type validation for None and non-list items.
All tests passing.
No linting errors.
The block is now more robust and consistent with the codebase patterns. Ready for re-review.
… extend()

Clear error messages: Indicates which index has the invalid type
Early return: Stops processing on first invalid input
Error output: Follows the pattern of other blocks (e.g., CreateDictionaryBlock)
Test coverage: Validates error handling behavior
@mewaeltsegay mewaeltsegay requested review from a team as code owners December 5, 2025 21:30
@mewaeltsegay mewaeltsegay requested review from Pwuts and majdyz and removed request for a team December 5, 2025 21:30
@github-project-automation github-project-automation bot moved this to 🆕 Needs initial review in AutoGPT development kanban Dec 5, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 5, 2025

Important

Review skipped

Auto reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Dec 5, 2025

This PR targets the master branch but does not come from dev or a hotfix/* branch.

Automatically setting the base branch to dev.

@CLAassistant
Copy link

CLAassistant commented Dec 5, 2025

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot changed the base branch from master to dev December 5, 2025 21:30
@github-actions github-actions bot added platform/frontend AutoGPT Platform - Front end platform/backend AutoGPT Platform - Back end platform/blocks labels Dec 5, 2025
@netlify
Copy link

netlify bot commented Dec 5, 2025

Deploy Preview for auto-gpt-docs canceled.

Name Link
🔨 Latest commit 5076be1
🔍 Latest deploy log https://app.netlify.com/projects/auto-gpt-docs/deploys/6933545c0349b40008c59047

@qodo-code-review
Copy link

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 Security concerns

Sensitive information handling:
The new GoogleDriveFile includes a hidden _credentials_id propagated through block outputs and API; ensure this identifier is not exposed to unauthorized clients, is scoped per-user, and cannot be used to escalate access. Also review the new load-store-agents script for safe handling of test data and that no production credentials/emails are inadvertently created in non-test environments.

⚡ Recommended focus areas for review

API Contract Change

Replaced GoogleDrivePickerField with GoogleDriveFileField and introduced embedded auto_credentials; verify all existing blocks/frontends consuming the old picker format still function, and ensure backward compatibility or appropriate migrations for json_schema_extra keys and field shapes.

    *,
    title: str,
    description: str | None = None,
    credentials_kwarg: str = "credentials",
    credentials_scopes: list[str] | None = None,
    allowed_views: list[AttachmentView] | None = None,
    allowed_mime_types: list[str] | None = None,
    placeholder: str | None = None,
    **kwargs: Any,
) -> Any:
    """
    Creates a Google Drive file input field with auto-generated credentials.

    This field type produces a single UI element that handles both:
    1. Google OAuth authentication
    2. File selection via Google Drive Picker

    The system automatically generates a credentials field, and the credentials
    are passed to the run() method using the specified kwarg name.

    Args:
        title: Field title shown in UI
        description: Field description/help text
        credentials_kwarg: Name of the kwarg that will receive GoogleCredentials
                          in the run() method (default: "credentials")
        credentials_scopes: OAuth scopes required (default: drive.file)
        allowed_views: List of view types to show in picker (default: ["DOCS"])
        allowed_mime_types: Filter by MIME types
        placeholder: Placeholder text for the button
        **kwargs: Additional SchemaField arguments

    Returns:
        Field definition that produces GoogleDriveFile

    Example:
        >>> class MyBlock(Block):
        ...     class Input(BlockSchemaInput):
        ...         spreadsheet: GoogleDriveFile = GoogleDriveFileField(
        ...             title="Select Spreadsheet",
        ...             credentials_kwarg="creds",
        ...             allowed_views=["SPREADSHEETS"],
        ...         )
        ...
        ...     async def run(
        ...         self, input_data: Input, *, creds: GoogleCredentials, **kwargs
        ...     ):
        ...         # creds is automatically populated
        ...         file = input_data.spreadsheet
    """

    # Determine scopes - drive.file is sufficient for picker-selected files
    scopes = credentials_scopes or ["https://www.googleapis.com/auth/drive.file"]

    # Build picker configuration with auto_credentials embedded
    picker_config = {
        "multiselect": False,
        "allow_folder_selection": False,
        "allowed_views": list(allowed_views) if allowed_views else ["DOCS"],
        "scopes": scopes,
        # Auto-credentials config tells frontend to include _credentials_id in output
        "auto_credentials": {
            "provider": "google",
            "type": "oauth2",
            "scopes": scopes,
            "kwarg_name": credentials_kwarg,
        },
    }

    if allowed_mime_types:
        picker_config["allowed_mime_types"] = list(allowed_mime_types)

    return SchemaField(
        default=None,
        title=title,
        description=description,
        placeholder=placeholder or "Select from Google Drive",
        # Use google-drive-picker format so frontend renders existing component
        format="google-drive-picker",
        advanced=False,
        json_schema_extra={
            "google_drive_picker_config": picker_config,
            # Also keep auto_credentials at top level for backend detection
            "auto_credentials": {
                "provider": "google",
                "type": "oauth2",
                "scopes": scopes,
                "kwarg_name": credentials_kwarg,
            },
            **kwargs,
        },
    )
Credentials Handling

New auto-credentials acquisition loop infers _credentials_id from input dicts; validate behavior when inputs are pydantic models vs dicts, and confirm locks are always released and no duplicate acquisitions occur when both explicit credentials and auto-credentials exist.

    field_name = info["field_name"]
    field_data = input_data.get(field_name)
    if field_data and isinstance(field_data, dict):
        # Check if _credentials_id key exists in the field data
        if "_credentials_id" in field_data:
            cred_id = field_data["_credentials_id"]
            if cred_id:
                # Credential ID provided - acquire credentials
                provider = info.get("config", {}).get(
                    "provider", "external service"
                )
                file_name = field_data.get("name", "selected file")
                try:
                    credentials, lock = await creds_manager.acquire(
                        user_id, cred_id
                    )
                    creds_locks.append(lock)
                    extra_exec_kwargs[kwarg_name] = credentials
                except ValueError:
                    # Credential was deleted or doesn't exist
                    raise ValueError(
                        f"Authentication expired for '{file_name}' in field '{field_name}'. "
                        f"The saved {provider.capitalize()} credentials no longer exist. "
                        f"Please re-select the file to re-authenticate."
                    )
            # else: _credentials_id is explicitly None, skip credentials (for chained data)
        else:
            # _credentials_id key missing entirely - this is an error
            provider = info.get("config", {}).get("provider", "external service")
            file_name = field_data.get("name", "selected file")
            raise ValueError(
                f"Authentication missing for '{file_name}' in field '{field_name}'. "
                f"Please re-select the file to authenticate with {provider.capitalize()}."
            )

output_size = 0
Scope/Permissions

GoogleSheetsCreateSpreadsheetBlock now uses Drive API with drive.file scope and builds both Drive and Sheets services; confirm scopes are sufficient for create + subsequent Sheets batchUpdate, and that preserving _credentials_id in outputs does not leak or mismatch credential references.

class GoogleSheetsCreateSpreadsheetBlock(Block):
    class Input(BlockSchemaInput):
        # Explicit credentials since this block creates a file (no file picker)
        credentials: GoogleCredentialsInput = GoogleCredentialsField(
            ["https://www.googleapis.com/auth/drive.file"]
        )
        title: str = SchemaField(
            description="The title of the new spreadsheet",
        )
        sheet_names: list[str] = SchemaField(
            description="List of sheet names to create (optional, defaults to single 'Sheet1')",
            default=["Sheet1"],
        )

    class Output(BlockSchemaOutput):
        result: dict = SchemaField(
            description="The result containing spreadsheet ID and URL",
        )
        spreadsheet: GoogleDriveFile = SchemaField(
            description="The created spreadsheet as a GoogleDriveFile (for chaining to other blocks)",
        )
        spreadsheet_id: str = SchemaField(
            description="The ID of the created spreadsheet",
        )
        spreadsheet_url: str = SchemaField(
            description="The URL of the created spreadsheet",
        )
        error: str = SchemaField(
            description="Error message if any",
        )

    def __init__(self):
        super().__init__(
            id="c8d4c0d3-c76e-4c2a-8c66-4119817ea3d1",
            description="This block creates a new Google Sheets spreadsheet with specified sheets.",
            categories={BlockCategory.DATA},
            input_schema=GoogleSheetsCreateSpreadsheetBlock.Input,
            output_schema=GoogleSheetsCreateSpreadsheetBlock.Output,
            disabled=GOOGLE_SHEETS_DISABLED,
            test_input={
                "credentials": TEST_CREDENTIALS_INPUT,
                "title": "Test Spreadsheet",
                "sheet_names": ["Sheet1", "Data", "Summary"],
            },
            test_credentials=TEST_CREDENTIALS,
            test_output=[
                (
                    "spreadsheet",
                    GoogleDriveFile(
                        id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
                        name="Test Spreadsheet",
                        mimeType="application/vnd.google-apps.spreadsheet",
                        url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
                        iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
                        isFolder=False,
                        _credentials_id=TEST_CREDENTIALS_INPUT[
                            "id"
                        ],  # Preserves credential ID for chaining
                    ),
                ),
                ("spreadsheet_id", "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"),
                (
                    "spreadsheet_url",
                    "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
                ),
                ("result", {"success": True}),
            ],
            test_mock={
                "_create_spreadsheet": lambda *args, **kwargs: {
                    "spreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
                    "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit",
                    "title": "Test Spreadsheet",
                },
            },
        )

    async def run(
        self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
    ) -> BlockOutput:
        drive_service = _build_drive_service(credentials)
        sheets_service = _build_sheets_service(credentials)
        result = await asyncio.to_thread(
            self._create_spreadsheet,
            drive_service,
            sheets_service,
            input_data.title,
            input_data.sheet_names,
        )

        if "error" in result:
            yield "error", result["error"]
        else:
            spreadsheet_id = result["spreadsheetId"]
            spreadsheet_url = result["spreadsheetUrl"]
            # Output the GoogleDriveFile for chaining (includes credentials_id)
            yield "spreadsheet", GoogleDriveFile(
                id=spreadsheet_id,
                name=result.get("title", input_data.title),
                mimeType="application/vnd.google-apps.spreadsheet",
                url=spreadsheet_url,
                iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png",
                isFolder=False,
                _credentials_id=input_data.credentials.id,  # Preserve credentials for chaining
            )
            yield "spreadsheet_id", spreadsheet_id
            yield "spreadsheet_url", spreadsheet_url
            yield "result", {"success": True}

    def _create_spreadsheet(
        self, drive_service, sheets_service, title: str, sheet_names: list[str]
    ) -> dict:
        try:
            # Create blank spreadsheet using Drive API
            file_metadata = {
                "name": title,
                "mimeType": "application/vnd.google-apps.spreadsheet",
            }
            result = (
                drive_service.files()
                .create(body=file_metadata, fields="id, webViewLink")
                .execute()
            )

            spreadsheet_id = result["id"]
            spreadsheet_url = result.get(
                "webViewLink",
                f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit",
            )

            # Rename first sheet if custom name provided (default is "Sheet1")
            if sheet_names and sheet_names[0] != "Sheet1":
                # Get first sheet ID and rename it
                meta = (
                    sheets_service.spreadsheets()
                    .get(spreadsheetId=spreadsheet_id)
                    .execute()
                )
                first_sheet_id = meta["sheets"][0]["properties"]["sheetId"]
                sheets_service.spreadsheets().batchUpdate(
                    spreadsheetId=spreadsheet_id,
                    body={
                        "requests": [
                            {
                                "updateSheetProperties": {
                                    "properties": {
                                        "sheetId": first_sheet_id,
                                        "title": sheet_names[0],
                                    },
                                    "fields": "title",
                                }
                            }
                        ]
                    },
                ).execute()

            # Add additional sheets if requested
            if len(sheet_names) > 1:
                requests = [
                    {"addSheet": {"properties": {"title": name}}}
                    for name in sheet_names[1:]
                ]
                sheets_service.spreadsheets().batchUpdate(
                    spreadsheetId=spreadsheet_id, body={"requests": requests}
                ).execute()

@deepsource-io
Copy link

deepsource-io bot commented Dec 5, 2025

Here's the code health analysis summary for commits 8be3c88..5076be1. View details on DeepSource ↗.

Analysis Summary

AnalyzerStatusSummaryLink
DeepSource JavaScript LogoJavaScript✅ SuccessView Check ↗
DeepSource Python LogoPython✅ SuccessView Check ↗

💡 If you’re a repository administrator, you can configure the quality gates from the settings.

- Add ConcatenateListsBlock to data_manipulation.py
- Add comprehensive test suite for the block
- Block concatenates multiple lists into a single list
- Handles edge cases: empty lists, single list, empty input
- Runtime type validation with clear error messages
- Category set to BASIC for consistency with other list utilities
- All tests passing and linting checks pass
@github-actions github-actions bot removed the platform/frontend AutoGPT Platform - Front end label Dec 5, 2025
@netlify
Copy link

netlify bot commented Dec 5, 2025

Deploy Preview for auto-gpt-docs-dev canceled.

Name Link
🔨 Latest commit 5076be1
🔍 Latest deploy log https://app.netlify.com/projects/auto-gpt-docs-dev/deploys/6933545c10c1c6000811d766

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: 🆕 Needs initial review
Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants