From 81ada1d9b9aa733cca84c0f235fd2a46303fc239 Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:25:05 -0800 Subject: [PATCH] Add Google File Picker Tool (#361) ## Google File Picker The Google Picker lets users select or upload Google Drive files. Users can grant permission to your apps to access their Drive data, providing a secure and authorized way to interact with their files. The `generate_google_file_picker_url` returns a URL to a Google File Picker for the user. --- .../google/arcade_google/tools/__init__.py | 97 +++++++++++++++- .../google/arcade_google/tools/calendar.py | 8 +- toolkits/google/arcade_google/tools/drive.py | 2 +- .../google/arcade_google/tools/file_picker.py | 54 +++++++++ .../google/evals/eval_calendar_free_slots.py | 2 +- toolkits/google/evals/eval_file_picker.py | 105 ++++++++++++++++++ toolkits/google/evals/eval_google_calendar.py | 2 +- toolkits/google/evals/eval_google_contacts.py | 2 +- toolkits/google/evals/eval_google_docs.py | 2 +- toolkits/google/evals/eval_google_drive.py | 2 +- toolkits/google/evals/eval_google_gmail.py | 2 +- toolkits/google/evals/eval_google_sheets.py | 2 +- toolkits/google/pyproject.toml | 4 +- toolkits/google/tests/test_calendar.py | 2 +- toolkits/google/tests/test_contacts.py | 2 +- toolkits/google/tests/test_docs.py | 2 +- toolkits/google/tests/test_drive.py | 2 +- toolkits/google/tests/test_file_picker.py | 45 ++++++++ toolkits/google/tests/test_gmail.py | 2 +- 19 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 toolkits/google/arcade_google/tools/file_picker.py create mode 100644 toolkits/google/evals/eval_file_picker.py create mode 100644 toolkits/google/tests/test_file_picker.py diff --git a/toolkits/google/arcade_google/tools/__init__.py b/toolkits/google/arcade_google/tools/__init__.py index 60174475..da519efd 100644 --- a/toolkits/google/arcade_google/tools/__init__.py +++ b/toolkits/google/arcade_google/tools/__init__.py @@ -1 +1,96 @@ -__all__ = ["gmail", "calendar", "drive", "docs"] +from arcade_google.tools.calendar import ( + create_event, + delete_event, + find_time_slots_when_everyone_is_free, + list_calendars, + list_events, + update_event, +) +from arcade_google.tools.contacts import ( + create_contact, + search_contacts_by_email, + search_contacts_by_name, +) +from arcade_google.tools.docs import ( + create_blank_document, + create_document_from_text, + get_document_by_id, + insert_text_at_end_of_document, +) +from arcade_google.tools.drive import ( + get_file_tree_structure, + search_and_retrieve_documents, + search_documents, +) +from arcade_google.tools.file_picker import generate_google_file_picker_url +from arcade_google.tools.gmail import ( + change_email_labels, + create_label, + delete_draft_email, + get_thread, + list_draft_emails, + list_emails, + list_emails_by_header, + list_labels, + list_threads, + reply_to_email, + search_threads, + send_draft_email, + send_email, + trash_email, + update_draft_email, + write_draft_email, + write_draft_reply_email, +) +from arcade_google.tools.sheets import ( + create_spreadsheet, + get_spreadsheet, + write_to_cell, +) + +__all__ = [ + # Google Calendar + create_event, + delete_event, + find_time_slots_when_everyone_is_free, + list_calendars, + list_events, + update_event, + # Google Contacts + create_contact, + search_contacts_by_email, + search_contacts_by_name, + # Google Docs + create_blank_document, + create_document_from_text, + get_document_by_id, + insert_text_at_end_of_document, + # Google Drive + get_file_tree_structure, # type: ignore[has-type] + search_and_retrieve_documents, # type: ignore[has-type] + search_documents, # type: ignore[has-type] + # Google File Picker + generate_google_file_picker_url, + # Google Gmail + change_email_labels, + create_label, + delete_draft_email, + get_thread, + list_draft_emails, + list_emails, + list_emails_by_header, + list_labels, + list_threads, + reply_to_email, + search_threads, + send_draft_email, + send_email, + trash_email, + update_draft_email, + write_draft_email, + write_draft_reply_email, + # Google Sheets + create_spreadsheet, + get_spreadsheet, + write_to_cell, +] diff --git a/toolkits/google/arcade_google/tools/calendar.py b/toolkits/google/arcade_google/tools/calendar.py index ee0bd293..7e0bee12 100644 --- a/toolkits/google/arcade_google/tools/calendar.py +++ b/toolkits/google/arcade_google/tools/calendar.py @@ -28,12 +28,10 @@ from arcade_google.utils import ( async def list_calendars( context: ToolContext, max_results: Annotated[ - int, "The maximum number of calendars to return. " "Up to 250 calendars, defaults to 10." + int, "The maximum number of calendars to return. Up to 250 calendars, defaults to 10." ] = 10, - show_deleted: Annotated[ - bool, "Whether to show deleted calendars." " Defaults to False" - ] = False, - show_hidden: Annotated[bool, "Whether to show hidden calendars." " Defaults to False"] = False, + show_deleted: Annotated[bool, "Whether to show deleted calendars. Defaults to False"] = False, + show_hidden: Annotated[bool, "Whether to show hidden calendars. Defaults to False"] = False, next_page_token: Annotated[ str | None, "The token to retrieve the next page of calendars. Optional." ] = None, diff --git a/toolkits/google/arcade_google/tools/drive.py b/toolkits/google/arcade_google/tools/drive.py index cd8596d1..69cbc174 100644 --- a/toolkits/google/arcade_google/tools/drive.py +++ b/toolkits/google/arcade_google/tools/drive.py @@ -7,7 +7,7 @@ from googleapiclient.errors import HttpError from arcade_google.doc_to_html import convert_document_to_html from arcade_google.doc_to_markdown import convert_document_to_markdown from arcade_google.models import DocumentFormat, OrderBy -from arcade_google.tools.docs import get_document_by_id +from arcade_google.tools import get_document_by_id from arcade_google.utils import ( build_drive_service, build_file_tree, diff --git a/toolkits/google/arcade_google/tools/file_picker.py b/toolkits/google/arcade_google/tools/file_picker.py new file mode 100644 index 00000000..7ba9471c --- /dev/null +++ b/toolkits/google/arcade_google/tools/file_picker.py @@ -0,0 +1,54 @@ +import base64 +import json +from typing import Annotated + +from arcade.sdk import ToolContext, ToolMetadataKey, tool +from arcade.sdk.auth import Google +from arcade.sdk.errors import ToolExecutionError + + +@tool( + requires_auth=Google(), + requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], +) +def generate_google_file_picker_url( + context: ToolContext, +) -> Annotated[dict, "Google File Picker URL for user file selection and permission granting"]: + """Generate a Google File Picker URL for user-driven file selection and authorization. + + This tool generates a URL that directs the end-user to a Google File Picker interface where + where they can select or upload Google Drive files. Users can grant permission to access their + Drive files, providing a secure and authorized way to interact with their files. + + This is particularly useful when prior tools (e.g., those accessing or modifying + Google Docs, Google Sheets, etc.) encountered failures due to file non-existence + (Requested entity was not found) or permission errors. Once the user completes the file + picker flow, the prior tool can be retried. + """ + client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID) + client_id_parts = client_id.split("-") + if not client_id_parts: + raise ToolExecutionError( + message="Invalid Google Client ID", + developer_message=f"Google Client ID '{client_id}' is not valid", + ) + app_id = client_id_parts[0] + cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/") + + config = { + "auth": { + "client_id": client_id, + "app_id": app_id, + }, + } + config_json = json.dumps(config) + config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8") + url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}" + + return { + "url": url, + "llm_instructions": ( + "Instruct the user to click the following link to open the Google Drive File Picker. " + "This will allow them to select files and grant access permissions: {url}" + ), + } diff --git a/toolkits/google/evals/eval_calendar_free_slots.py b/toolkits/google/evals/eval_calendar_free_slots.py index 2b1bde15..f8f366ad 100644 --- a/toolkits/google/evals/eval_calendar_free_slots.py +++ b/toolkits/google/evals/eval_calendar_free_slots.py @@ -13,7 +13,7 @@ from arcade.sdk.eval import ( import arcade_google from arcade_google.critics import AnyDatetimeCritic, DatetimeOrNoneCritic -from arcade_google.tools.calendar import find_time_slots_when_everyone_is_free +from arcade_google.tools import find_time_slots_when_everyone_is_free rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/google/evals/eval_file_picker.py b/toolkits/google/evals/eval_file_picker.py new file mode 100644 index 00000000..d9dfa5d2 --- /dev/null +++ b/toolkits/google/evals/eval_file_picker.py @@ -0,0 +1,105 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) + +import arcade_google +from arcade_google.tools import generate_google_file_picker_url + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.9, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_google) + +history_1 = [ + { + "role": "user", + "content": "get the contents of my spreadsheet named 'How to teach an old dog a new trick'", + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_errutwjcpZLiHu8LKip6XKLq", + "type": "function", + "function": { + "name": "Google_SearchDocuments", + "arguments": '{"document_contains":["How to teach an old dog a new trick"],"limit":5}', + }, + } + ], + }, + { + "role": "tool", + "content": '{"documents":[],"documents_count":0}', + "tool_call_id": "call_errutwjcpZLiHu8LKip6XKLq", + "name": "Google_SearchDocuments", + }, + { + "role": "assistant", + "content": "It seems that I don't have access to your spreadsheet named \"How to teach an old dog a new trick.\" If you need help retrieving the data, you can check your cloud storage or computer files where the spreadsheet might be saved, such as Google Sheets, Microsoft Excel, or other spreadsheet software. Let me know if there's anything specific you'd like assistance with regarding the content!", + }, +] + +history_2 = [ + {"role": "system", "content": "Today is 2025-04-15, Tuesday."}, + { + "role": "user", + "content": "I got this error from you: \n❌ Arcade Chat failed with error: Error code: 500 - {'name':'non_retryable_tool_call_error', 'message': 'tool call failed - \nGoogle.InsertTextAtEndOfDocument failed: Error inexecution of GetDocumentById: Error in get_document_by_id: '}", + }, + { + "role": "assistant", + "content": "The error message indicates that there was an attempt to retrieve a document with a specific ID \\(`192394932924`) from Google Docs, but the document could not be found. The HTTP 404 error code means that the document doesn't \\exist or is not accessible with the current permissions. Here's what you can do:\n\n1. **Check Document ID:** Make sure the \\document ID (`192394932924`) is correct. A typo or incorrect ID will lead to this error.\n\n2. **Permissions:** Ensure that you \\have permission to access the document. If the document is owned by someone else or is in a shared drive with restricted access,\\you might not be able to view it.\n\n3. **Document Existence:** Double-check that the document hasn't been deleted or moved to \the trash or a location you don't have access to.\n\nLet me know if there's anything else you'd like to address or any other \assistance you need!", + }, +] + + +@tool_eval() +def file_picker_eval_suite() -> EvalSuite: + """Create an evaluation suite for Gmail tools.""" + suite = EvalSuite( + name="File Picker Tools Evaluation", + system_message="You are an AI assistant that can perform actions for the user using the provided tools.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get file picker url 1", + user_message="I am certain that the spreadsheet exists", + expected_tool_calls=[ + ExpectedToolCall( + func=generate_google_file_picker_url, + args={}, + ) + ], + additional_messages=history_1, + ) + + suite.add_case( + name="Get file picker url 2", + user_message="Please help in anyway you can. I'm literally looking at the document right now in the browser.", + expected_tool_calls=[ + ExpectedToolCall(func=generate_google_file_picker_url, args={}), + ], + additional_messages=history_2, + ) + + suite.add_case( + name="Get file picker url 3", + user_message="I want to give you permissions to more files in my Drive right now.", + expected_tool_calls=[ + ExpectedToolCall(func=generate_google_file_picker_url, args={}), + ], + ) + + return suite diff --git a/toolkits/google/evals/eval_google_calendar.py b/toolkits/google/evals/eval_google_calendar.py index f06d610b..0e21fccf 100644 --- a/toolkits/google/evals/eval_google_calendar.py +++ b/toolkits/google/evals/eval_google_calendar.py @@ -12,7 +12,7 @@ from arcade.sdk.eval import ( import arcade_google from arcade_google.models import EventVisibility, SendUpdatesOptions -from arcade_google.tools.calendar import ( +from arcade_google.tools import ( create_event, delete_event, list_calendars, diff --git a/toolkits/google/evals/eval_google_contacts.py b/toolkits/google/evals/eval_google_contacts.py index 354f45aa..d6b3305d 100644 --- a/toolkits/google/evals/eval_google_contacts.py +++ b/toolkits/google/evals/eval_google_contacts.py @@ -8,7 +8,7 @@ from arcade.sdk.eval import ( from arcade.sdk.eval.critic import BinaryCritic import arcade_google -from arcade_google.tools.contacts import ( +from arcade_google.tools import ( create_contact, search_contacts_by_email, search_contacts_by_name, diff --git a/toolkits/google/evals/eval_google_docs.py b/toolkits/google/evals/eval_google_docs.py index 54643068..28d9116e 100644 --- a/toolkits/google/evals/eval_google_docs.py +++ b/toolkits/google/evals/eval_google_docs.py @@ -9,7 +9,7 @@ from arcade.sdk.eval import ( ) import arcade_google -from arcade_google.tools.docs import ( +from arcade_google.tools import ( create_blank_document, create_document_from_text, get_document_by_id, diff --git a/toolkits/google/evals/eval_google_drive.py b/toolkits/google/evals/eval_google_drive.py index 742f0fac..d0984575 100644 --- a/toolkits/google/evals/eval_google_drive.py +++ b/toolkits/google/evals/eval_google_drive.py @@ -9,7 +9,7 @@ from arcade.sdk.eval import ( import arcade_google from arcade_google.models import DocumentFormat, OrderBy -from arcade_google.tools.drive import ( +from arcade_google.tools import ( get_file_tree_structure, search_and_retrieve_documents, search_documents, diff --git a/toolkits/google/evals/eval_google_gmail.py b/toolkits/google/evals/eval_google_gmail.py index a142e08d..ac4bb883 100644 --- a/toolkits/google/evals/eval_google_gmail.py +++ b/toolkits/google/evals/eval_google_gmail.py @@ -12,7 +12,7 @@ from arcade.sdk.eval import ( import arcade_google from arcade_google.models import GmailReplyToWhom -from arcade_google.tools.gmail import ( +from arcade_google.tools import ( get_thread, list_emails_by_header, list_threads, diff --git a/toolkits/google/evals/eval_google_sheets.py b/toolkits/google/evals/eval_google_sheets.py index d3a732f4..5e8889f1 100644 --- a/toolkits/google/evals/eval_google_sheets.py +++ b/toolkits/google/evals/eval_google_sheets.py @@ -9,7 +9,7 @@ from arcade.sdk.eval import ( ) import arcade_google -from arcade_google.tools.sheets import ( +from arcade_google.tools import ( create_spreadsheet, get_spreadsheet, ) diff --git a/toolkits/google/pyproject.toml b/toolkits/google/pyproject.toml index 7abbdb5a..77f13a46 100644 --- a/toolkits/google/pyproject.toml +++ b/toolkits/google/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "arcade_google" -version = "1.1.2" +version = "1.2.0" description = "Arcade.dev LLM tools for Google Workspace" authors = ["Arcade "] [tool.poetry.dependencies] python = "^3.10" -arcade-ai = ">=1.0.5,<2.0" +arcade-ai = ">=1.3.0,<2.0" google-api-core = "2.19.1" google-api-python-client = "2.137.0" google-auth = "2.32.0" diff --git a/toolkits/google/tests/test_calendar.py b/toolkits/google/tests/test_calendar.py index e5414350..fd11aa67 100644 --- a/toolkits/google/tests/test_calendar.py +++ b/toolkits/google/tests/test_calendar.py @@ -8,7 +8,7 @@ from arcade.sdk.errors import RetryableToolError, ToolExecutionError from googleapiclient.errors import HttpError from arcade_google.models import EventVisibility, SendUpdatesOptions -from arcade_google.tools.calendar import ( +from arcade_google.tools import ( create_event, delete_event, find_time_slots_when_everyone_is_free, diff --git a/toolkits/google/tests/test_contacts.py b/toolkits/google/tests/test_contacts.py index 9ec19b32..a84d567e 100644 --- a/toolkits/google/tests/test_contacts.py +++ b/toolkits/google/tests/test_contacts.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from arcade.sdk import ToolContext -from arcade_google.tools.contacts import create_contact +from arcade_google.tools import create_contact @pytest.fixture diff --git a/toolkits/google/tests/test_docs.py b/toolkits/google/tests/test_docs.py index eec95994..26200fdc 100644 --- a/toolkits/google/tests/test_docs.py +++ b/toolkits/google/tests/test_docs.py @@ -4,7 +4,7 @@ import pytest from arcade.sdk.errors import ToolExecutionError from googleapiclient.errors import HttpError -from arcade_google.tools.docs import ( +from arcade_google.tools import ( create_blank_document, create_document_from_text, get_document_by_id, diff --git a/toolkits/google/tests/test_drive.py b/toolkits/google/tests/test_drive.py index fce06062..48b8ecd0 100644 --- a/toolkits/google/tests/test_drive.py +++ b/toolkits/google/tests/test_drive.py @@ -5,7 +5,7 @@ from arcade.sdk.errors import ToolExecutionError from googleapiclient.errors import HttpError from arcade_google.models import Corpora, DocumentFormat, OrderBy -from arcade_google.tools.drive import ( +from arcade_google.tools import ( get_file_tree_structure, search_and_retrieve_documents, search_documents, diff --git a/toolkits/google/tests/test_file_picker.py b/toolkits/google/tests/test_file_picker.py new file mode 100644 index 00000000..bd7c995e --- /dev/null +++ b/toolkits/google/tests/test_file_picker.py @@ -0,0 +1,45 @@ +import base64 +import json +from urllib.parse import parse_qs, urlparse + +import pytest +from arcade.core.schema import ToolMetadataItem +from arcade.sdk import ToolContext, ToolMetadataKey + +from arcade_google.tools import generate_google_file_picker_url + + +@pytest.fixture +def mock_context(): + context = ToolContext( + metadata=[ + ToolMetadataItem(key=ToolMetadataKey.CLIENT_ID, value="1234-3444534323"), + ToolMetadataItem( + key=ToolMetadataKey.COORDINATOR_URL, value="https://mock_coordinator_url" + ), + ], + ) + return context + + +@pytest.mark.asyncio +async def test_generate_google_file_picker_url(mock_context): + expected_decoded_config = { + "auth": { + "client_id": "1234-3444534323", + "app_id": "1234", + }, + } + + result = generate_google_file_picker_url(mock_context) + + assert result["url"].startswith("https://mock_coordinator_url/google/drive_picker?config=") + + # Decode the config from the URL + parsed_url = urlparse(result["url"]) + query_params = parse_qs(parsed_url.query) + encoded_config = query_params.get("config", [None])[0] + assert encoded_config is not None + + decoded_config = json.loads(base64.urlsafe_b64decode(encoded_config).decode("utf-8")) + assert decoded_config == expected_decoded_config diff --git a/toolkits/google/tests/test_gmail.py b/toolkits/google/tests/test_gmail.py index 31627fe7..5414cbc1 100644 --- a/toolkits/google/tests/test_gmail.py +++ b/toolkits/google/tests/test_gmail.py @@ -8,7 +8,7 @@ from arcade.sdk.errors import ToolExecutionError from googleapiclient.errors import HttpError from arcade_google.models import GmailReplyToWhom -from arcade_google.tools.gmail import ( +from arcade_google.tools import ( delete_draft_email, get_thread, list_draft_emails,