feat: add spreadsheet mime types to load_artifact_tool

PiperOrigin-RevId: 964508802
This commit is contained in:
Google Team Member
2026-08-13 23:20:59 -07:00
committed by Copybara-Service
parent d7adc1ce3e
commit 370027a770
8 changed files with 423 additions and 7 deletions
+14 -2
View File
@@ -1,6 +1,6 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml --all-extras --python-version 3.10 --no-emit-package google-adk --exclude-newer 2026-08-09 --index-url https://pypi.org/simple -o constraints-3.10.txt
a2a-sdk==1.1.1
# uv pip compile pyproject.toml --all-extras --python-version 3.10 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.10.txt
a2a-sdk==1.1.2
# via
# -c constraints-3.10.txt.stable.tmp
# google-adk (pyproject.toml)
@@ -315,6 +315,10 @@ e2b==2.34.0
# via
# -c constraints-3.10.txt.stable.tmp
# google-adk (pyproject.toml)
et-xmlfile==2.0.0
# via
# -c constraints-3.10.txt.stable.tmp
# openpyxl
exceptiongroup==1.3.1
# via
# -c constraints-3.10.txt.stable.tmp
@@ -1024,6 +1028,10 @@ openai==2.46.0
# -c constraints-3.10.txt.stable.tmp
# google-adk (pyproject.toml)
# litellm
openpyxl==3.1.5
# via
# -c constraints-3.10.txt.stable.tmp
# google-adk (pyproject.toml)
opentelemetry-api==1.42.1
# via
# -c constraints-3.10.txt.stable.tmp
@@ -1940,6 +1948,10 @@ wsproto==1.3.2
# daytona
# httpx-ws
# simple-websocket
xlrd==2.0.2
# via
# -c constraints-3.10.txt.stable.tmp
# google-adk (pyproject.toml)
xxhash==3.8.1
# via
# -c constraints-3.10.txt.stable.tmp
+5
View File
@@ -1168,6 +1168,7 @@ openai==2.46.0
openpyxl==3.1.5
# via
# -c constraints-3.11.txt.stable.tmp
# google-adk (pyproject.toml)
# crewai
opentelemetry-api==1.42.1
# via
@@ -2227,6 +2228,10 @@ wsproto==1.3.2
# daytona
# httpx-ws
# simple-websocket
xlrd==2.0.2
# via
# -c constraints-3.11.txt.stable.tmp
# google-adk (pyproject.toml)
xxhash==3.8.1
# via
# -c constraints-3.11.txt.stable.tmp
+13 -1
View File
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml --all-extras --python-version 3.12 --no-emit-package google-adk --exclude-newer 2026-08-09 --index-url https://pypi.org/simple -o constraints-3.12.txt
# uv pip compile pyproject.toml --all-extras --python-version 3.12 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.12.txt
a2a-sdk==1.1.1
# via
# -c constraints-3.12.txt.stable.tmp
@@ -305,6 +305,10 @@ e2b==2.34.0
# via
# -c constraints-3.12.txt.stable.tmp
# google-adk (pyproject.toml)
et-xmlfile==2.0.0
# via
# -c constraints-3.12.txt.stable.tmp
# openpyxl
execnet==2.1.2
# via
# -c constraints-3.12.txt.stable.tmp
@@ -1012,6 +1016,10 @@ openai==2.46.0
# -c constraints-3.12.txt.stable.tmp
# google-adk (pyproject.toml)
# litellm
openpyxl==3.1.5
# via
# -c constraints-3.12.txt.stable.tmp
# google-adk (pyproject.toml)
opentelemetry-api==1.42.1
# via
# -c constraints-3.12.txt.stable.tmp
@@ -1907,6 +1915,10 @@ wsproto==1.3.2
# daytona
# httpx-ws
# simple-websocket
xlrd==2.0.2
# via
# -c constraints-3.12.txt.stable.tmp
# google-adk (pyproject.toml)
xxhash==3.8.1
# via
# -c constraints-3.12.txt.stable.tmp
+13 -1
View File
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml --all-extras --python-version 3.13 --no-emit-package google-adk --exclude-newer 2026-08-09 --index-url https://pypi.org/simple -o constraints-3.13.txt
# uv pip compile pyproject.toml --all-extras --python-version 3.13 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.13.txt
a2a-sdk==1.1.1
# via
# -c constraints-3.13.txt.stable.tmp
@@ -297,6 +297,10 @@ e2b==2.34.0
# via
# -c constraints-3.13.txt.stable.tmp
# google-adk (pyproject.toml)
et-xmlfile==2.0.0
# via
# -c constraints-3.13.txt.stable.tmp
# openpyxl
execnet==2.1.2
# via
# -c constraints-3.13.txt.stable.tmp
@@ -1004,6 +1008,10 @@ openai==2.46.0
# -c constraints-3.13.txt.stable.tmp
# google-adk (pyproject.toml)
# litellm
openpyxl==3.1.5
# via
# -c constraints-3.13.txt.stable.tmp
# google-adk (pyproject.toml)
opentelemetry-api==1.42.1
# via
# -c constraints-3.13.txt.stable.tmp
@@ -1887,6 +1895,10 @@ wsproto==1.3.2
# daytona
# httpx-ws
# simple-websocket
xlrd==2.0.2
# via
# -c constraints-3.13.txt.stable.tmp
# google-adk (pyproject.toml)
xxhash==3.8.1
# via
# -c constraints-3.13.txt.stable.tmp
+13 -1
View File
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml --all-extras --python-version 3.14 --no-emit-package google-adk --exclude-newer 2026-08-09 --index-url https://pypi.org/simple -o constraints-3.14.txt
# uv pip compile pyproject.toml --all-extras --python-version 3.14 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.14.txt
a2a-sdk==1.1.1
# via
# -c constraints-3.14.txt.stable.tmp
@@ -297,6 +297,10 @@ e2b==2.34.0
# via
# -c constraints-3.14.txt.stable.tmp
# google-adk (pyproject.toml)
et-xmlfile==2.0.0
# via
# -c constraints-3.14.txt.stable.tmp
# openpyxl
execnet==2.1.2
# via
# -c constraints-3.14.txt.stable.tmp
@@ -1004,6 +1008,10 @@ openai==2.46.0
# -c constraints-3.14.txt.stable.tmp
# google-adk (pyproject.toml)
# litellm
openpyxl==3.1.5
# via
# -c constraints-3.14.txt.stable.tmp
# google-adk (pyproject.toml)
opentelemetry-api==1.42.1
# via
# -c constraints-3.14.txt.stable.tmp
@@ -1887,6 +1895,10 @@ wsproto==1.3.2
# daytona
# httpx-ws
# simple-websocket
xlrd==2.0.2
# via
# -c constraints-3.14.txt.stable.tmp
# google-adk (pyproject.toml)
xxhash==3.8.1
# via
# -c constraints-3.14.txt.stable.tmp
+6
View File
@@ -112,6 +112,7 @@ optional-dependencies.all = [
"nltk!=3.10.1",
"oci>=2.126",
"openai>=2.20,<3",
"openpyxl>=3.1.5,<4",
"opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0",
"opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2",
"opentelemetry-exporter-gcp-trace>=1.9,<2",
@@ -132,6 +133,7 @@ optional-dependencies.all = [
"sqlalchemy-spanner>=1.14",
"tabulate>=0.9",
"toolbox-adk>=1,<2",
"xlrd>=2,<3",
]
optional-dependencies.antigravity = [
"google-antigravity>=0.1,<0.2",
@@ -196,9 +198,11 @@ optional-dependencies.eval = [
"google-cloud-texttospeech>=2.37",
"jinja2>=3.1.4,<4", # For eval template rendering
"nltk!=3.10.1", # Transitive via rouge-score; 3.10.1's import hook breaks any venv living inside the working directory (reverted upstream in nltk/nltk#3732).
"openpyxl>=3.1.5,<4",
"pandas>=2.2.3",
"rouge-score>=0.1.2",
"tabulate>=0.9",
"xlrd>=2,<3",
]
optional-dependencies.extensions = [
"anthropic>=0.78", # For anthropic model support; 0.78 introduced ThinkingConfigAdaptiveParam (required for Claude Opus 4.7).
@@ -298,6 +302,7 @@ optional-dependencies.test = [
"mcp>=1.24,<2",
"nltk!=3.10.1", # Transitive via rouge-score and llama-index-core; 3.10.1's import hook breaks any venv living inside the working directory (reverted upstream in nltk/nltk#3732).
"openai>=2.20,<3",
"openpyxl>=3.1.5,<4",
"opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0",
"opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2",
"opentelemetry-exporter-gcp-trace>=1.9,<2",
@@ -320,6 +325,7 @@ optional-dependencies.test = [
"sqlalchemy-spanner>=1.14",
"tabulate>=0.9",
"tomli>=2,<3; python_version<'3.11'",
"xlrd>=2,<3",
]
optional-dependencies.toolbox = [ "toolbox-adk>=1,<2" ]
optional-dependencies.tools = [
+88 -2
View File
@@ -66,6 +66,10 @@ _TEXT_LIKE_MIME_TYPES = frozenset({
'image/svg+xml',
'image/xml',
})
_SPREADSHEET_MIME_TYPES = frozenset({
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
})
if TYPE_CHECKING:
from ..models.llm_request import LlmRequest
@@ -140,8 +144,78 @@ def _try_extract_docx_text(data: bytes) -> str | None:
return None
def _parse_spreadsheet(data: bytes) -> str:
"""Parses a spreadsheet into a markdown representation.
Args:
data: The bytes content of the spreadsheet file (e.g., XLSX).
Returns:
A markdown string representing the spreadsheet, capped at 100 rows.
Each sheet is rendered as a separate markdown table with a heading.
Returns "[Empty Spreadsheet]" if the spreadsheet contains no data.
Returns "[Error parsing spreadsheet: {e}]" if an error occurs during
parsing, including details of the exception.
"""
try:
import pandas as pd
with pd.ExcelFile(io.BytesIO(data)) as xl:
output = []
# Process each sheet
for sheet_name in xl.sheet_names:
df = xl.parse(sheet_name)
if df.empty:
continue
# Cap rows to avoid exceeding context window limits
max_rows = 100
total_rows = len(df)
if total_rows > max_rows:
df_display = df.head(max_rows)
truncation_notice = (
f'\n\n[Output is limited to the first {max_rows} rows. Total '
f'rows: {total_rows}]'
)
else:
df_display = df
truncation_notice = ''
# Convert to markdown table
markdown_table = df_display.to_markdown(
index=False, numalign='left', stralign='left'
)
if markdown_table:
markdown_table += truncation_notice
output.append(f'### Sheet: {sheet_name}\n\n{markdown_table}')
if not output:
return '[Empty Spreadsheet]'
return '\n\n'.join(output)
except ImportError as e:
logger.warning(f'Missing dependency for spreadsheet parsing: {e!r}')
return (
f'[Missing dependency: {e!r}. Pandas and its support libraries are'
' required to parse spreadsheets. Please install them using `pip'
' install pandas openpyxl tabulate xlrd`.]'
)
except ValueError as e:
logger.warning(f'Invalid spreadsheet format or data: {e!r}')
return f'[Invalid spreadsheet format: {e!r}]'
except Exception as e:
logger.warning(f'Failed to parse spreadsheet: {e!r}')
return f'[Error parsing spreadsheet: {e!r}]'
def as_safe_part_for_llm(
artifact: types.Part, artifact_name: str
artifact: types.Part,
artifact_name: str,
enable_spreadsheet_parsing: bool = False,
) -> types.Part:
"""Returns a Part that is safe to send to an LLM.
@@ -201,6 +275,12 @@ def as_safe_part_for_llm(
except UnicodeDecodeError:
return types.Part.from_text(text=data.decode('utf-8', errors='replace'))
if enable_spreadsheet_parsing and (
mime_type in _SPREADSHEET_MIME_TYPES
or artifact_name.lower().endswith(('.xlsx', '.xls'))
):
return types.Part.from_text(text=_parse_spreadsheet(data))
size_kb = len(data) / 1024
return types.Part.from_text(
text=(
@@ -224,6 +304,7 @@ class LoadArtifactsTool(BaseTool):
self,
*,
process_artifact: ProcessArtifactCallback | None = None,
enable_spreadsheet_parsing: bool = False,
):
"""Initializes the tool.
@@ -239,6 +320,8 @@ class LoadArtifactsTool(BaseTool):
returning `None` skips the artifact so it is omitted from the request.
If a custom callback raises an exception, the error is logged and the
artifact is skipped.
enable_spreadsheet_parsing: Whether to enable spreadsheet parsing
files (e.g., .xlsx, .xls) into text. Defaults to False.
"""
super().__init__(
name='load_artifacts',
@@ -248,6 +331,7 @@ NOTE: Call when you need access to artifacts (for example, uploads saved by the
web UI)."""),
)
self._process_artifact: ProcessArtifactCallback | None = process_artifact
self._enable_spreadsheet_parsing: bool = enable_spreadsheet_parsing
def _get_declaration(self) -> types.FunctionDeclaration | None:
if is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL):
@@ -355,7 +439,9 @@ web UI)."""),
)
continue
else:
artifact_part = as_safe_part_for_llm(artifact, artifact_name)
artifact_part = as_safe_part_for_llm(
artifact, artifact_name, self._enable_spreadsheet_parsing
)
if artifact_part is None:
continue
@@ -15,6 +15,7 @@
import base64
import io
from typing import Any
from unittest import mock
import zipfile
from google.adk.features import FeatureName
@@ -24,6 +25,7 @@ from google.adk.tools.load_artifacts_tool import _maybe_base64_to_bytes
from google.adk.tools.load_artifacts_tool import load_artifacts_tool
from google.adk.tools.load_artifacts_tool import LoadArtifactsTool
from google.genai import types
import pandas as pd
import pytest
@@ -881,3 +883,272 @@ async def test_load_artifacts_custom_callback_returns_non_part_raises():
await tool.process_llm_request(
tool_context=tool_context, llm_request=llm_request
)
@pytest.mark.asyncio
async def test_load_artifacts_parses_spreadsheet():
"""Spreadsheet artifacts are parsed into markdown."""
artifact_name = 'test.xlsx'
df = pd.DataFrame({'col1': [1, 2], 'col2': ['a', 'b']})
output = io.BytesIO()
# Use openpyxl as engine since it is in deps
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False)
xlsx_bytes = output.getvalue()
artifact = types.Part(
inline_data=types.Blob(
data=xlsx_bytes,
mime_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
)
tool_context = _StubToolContext({artifact_name: artifact})
llm_request = LlmRequest(
contents=[
types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse(
name='load_artifacts',
response={'artifact_names': [artifact_name]},
)
)
],
)
]
)
custom_tool = LoadArtifactsTool(enable_spreadsheet_parsing=True)
await custom_tool.process_llm_request(
tool_context=tool_context, llm_request=llm_request
)
_, artifact_part = llm_request.contents[-1].parts
assert artifact_part.inline_data is None
# Check for Markdown table content
# We expect something like:
# ### Sheet: Sheet1
#
# | col1 | col2 |
# | :--- | :--- |
# | 1 | a |
# | 2 | b |
markdown_output = artifact_part.text
assert 'Sheet1' in markdown_output
assert '| col1' in markdown_output
assert '| col2' in markdown_output
assert '| 1' in markdown_output
assert '| a' in markdown_output
assert '| 2' in markdown_output
assert '| b' in markdown_output
@pytest.mark.asyncio
@mock.patch('pandas.ExcelFile')
async def test_load_artifacts_parses_xls_spreadsheet(mock_excel_file_cls):
"""Spreadsheet artifacts (.xls) are parsed into markdown."""
artifact_name = 'test.xls'
xls_bytes = b'dummy_xls_content'
# Mock ExcelFile context manager
mock_xl = mock.MagicMock()
mock_xl.__enter__.return_value = mock_xl
mock_xl.sheet_names = ['Sheet1']
mock_xl.parse.return_value = pd.DataFrame(
{'col1': [1, 2], 'col2': ['a', 'b']}
)
mock_excel_file_cls.return_value = mock_xl
artifact = types.Part(
inline_data=types.Blob(
data=xls_bytes,
mime_type='application/vnd.ms-excel',
)
)
tool_context = _StubToolContext({artifact_name: artifact})
llm_request = LlmRequest(
contents=[
types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse(
name='load_artifacts',
response={'artifact_names': [artifact_name]},
)
)
],
)
]
)
custom_tool = LoadArtifactsTool(enable_spreadsheet_parsing=True)
await custom_tool.process_llm_request(
tool_context=tool_context, llm_request=llm_request
)
_, artifact_part = llm_request.contents[-1].parts
assert artifact_part.inline_data is None
markdown_output = artifact_part.text
assert 'Sheet1' in markdown_output
assert '| col1' in markdown_output
assert '| col2' in markdown_output
assert '| 1' in markdown_output
assert '| a' in markdown_output
assert '| 2' in markdown_output
assert '| b' in markdown_output
@pytest.mark.asyncio
async def test_load_artifacts_handles_invalid_spreadsheet():
"""Invalid spreadsheet artifacts return an error message."""
artifact_name = 'invalid.xlsx'
invalid_bytes = b'not a valid excel file'
artifact = types.Part(
inline_data=types.Blob(
data=invalid_bytes,
mime_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
)
tool_context = _StubToolContext({artifact_name: artifact})
llm_request = LlmRequest(
contents=[
types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse(
name='load_artifacts',
response={'artifact_names': [artifact_name]},
)
)
],
)
]
)
custom_tool = LoadArtifactsTool(enable_spreadsheet_parsing=True)
await custom_tool.process_llm_request(
tool_context=tool_context, llm_request=llm_request
)
_, artifact_part = llm_request.contents[-1].parts
assert artifact_part.inline_data is None
assert '[Invalid spreadsheet format' in artifact_part.text
@pytest.mark.asyncio
async def test_load_artifacts_spreadsheet_truncation():
"""Spreadsheet artifacts with > 100 rows are truncated."""
artifact_name = 'large.xlsx'
# Create a DataFrame with 101 rows
df = pd.DataFrame({'col1': list(range(101))})
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False)
xlsx_bytes = output.getvalue()
artifact = types.Part(
inline_data=types.Blob(
data=xlsx_bytes,
mime_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
)
tool_context = _StubToolContext({artifact_name: artifact})
llm_request = LlmRequest(
contents=[
types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse(
name='load_artifacts',
response={'artifact_names': [artifact_name]},
)
)
],
)
]
)
custom_tool = LoadArtifactsTool(enable_spreadsheet_parsing=True)
await custom_tool.process_llm_request(
tool_context=tool_context, llm_request=llm_request
)
_, artifact_part = llm_request.contents[-1].parts
assert artifact_part.inline_data is None
markdown_output = artifact_part.text
assert 'Output is limited to the first 100 rows' in markdown_output
assert 'Total rows: 101' in markdown_output
# Verify that the table itself is truncated
# A 100-row table (plus header and separator) should have 102 lines starting with '|'
table_lines = [
line
for line in markdown_output.splitlines()
if line.strip().startswith('|')
]
assert len(table_lines) == 102
# The 101st row (value 100) should NOT appear in the table body
# This checks that '| 100' does not appear as a trailing row.
# Note: '100' appears in the notice, so it checks specifically for table row format.
assert '| 100' not in markdown_output.split('Output is limited')[0]
@pytest.mark.asyncio
async def test_load_artifacts_spreadsheet_unparsed_by_default():
"""Spreadsheet artifacts remain unparsed if enable_spreadsheet_parsing is False."""
artifact_name = 'test.xlsx'
df = pd.DataFrame({'col1': [1, 2]})
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False)
xlsx_bytes = output.getvalue()
artifact = types.Part(
inline_data=types.Blob(
data=xlsx_bytes,
mime_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
)
tool_context = _StubToolContext({artifact_name: artifact})
llm_request = LlmRequest(
contents=[
types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse(
name='load_artifacts',
response={'artifact_names': [artifact_name]},
)
)
],
)
]
)
# Use default LoadArtifactsTool (enable_spreadsheet_parsing=False)
await load_artifacts_tool.process_llm_request(
tool_context=tool_context, llm_request=llm_request
)
_, artifact_part = llm_request.contents[-1].parts
assert artifact_part.inline_data is None
assert '[Binary artifact: test.xlsx' in artifact_part.text
assert 'Content cannot be displayed inline' in artifact_part.text