diff --git a/constraints-3.10.txt b/constraints-3.10.txt index abbf7267..25cb0463 100644 --- a/constraints-3.10.txt +++ b/constraints-3.10.txt @@ -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 diff --git a/constraints-3.11.txt b/constraints-3.11.txt index d62fc0ab..14358a25 100644 --- a/constraints-3.11.txt +++ b/constraints-3.11.txt @@ -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 diff --git a/constraints-3.12.txt b/constraints-3.12.txt index e2df46b5..d0a37eb4 100644 --- a/constraints-3.12.txt +++ b/constraints-3.12.txt @@ -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 diff --git a/constraints-3.13.txt b/constraints-3.13.txt index 5f47eb27..5dcb9247 100644 --- a/constraints-3.13.txt +++ b/constraints-3.13.txt @@ -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 diff --git a/constraints-3.14.txt b/constraints-3.14.txt index daa23e7a..2f6b1b5e 100644 --- a/constraints-3.14.txt +++ b/constraints-3.14.txt @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 53335128..056cfd42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/google/adk/tools/load_artifacts_tool.py b/src/google/adk/tools/load_artifacts_tool.py index 9e1e00e9..c193aa28 100644 --- a/src/google/adk/tools/load_artifacts_tool.py +++ b/src/google/adk/tools/load_artifacts_tool.py @@ -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 diff --git a/tests/unittests/tools/test_load_artifacts_tool.py b/tests/unittests/tools/test_load_artifacts_tool.py index 5f4b2084..a12675dd 100644 --- a/tests/unittests/tools/test_load_artifacts_tool.py +++ b/tests/unittests/tools/test_load_artifacts_tool.py @@ -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