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.
This commit is contained in:
Eric Gustin 2025-04-18 17:25:05 -08:00 committed by GitHub
parent edac6ab48d
commit 81ada1d9b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 318 additions and 21 deletions

View file

@ -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,
]

View file

@ -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,

View file

@ -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,

View file

@ -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}"
),
}

View file

@ -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,

View file

@ -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: <HttpError 404 when\nrequestinghttps://docs.googleapis.com/v1/documents/192394932924?alt=json returned \"Requested entity was not found.\". Details: \n\"Requestedentity was not found.\">'}",
},
{
"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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,
)

View file

@ -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 <dev@arcade.dev>"]
[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"

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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,