feat: add spreadsheet mime types to load_artifact_tool
PiperOrigin-RevId: 964508802
This commit is contained in:
committed by
Copybara-Service
parent
d7adc1ce3e
commit
370027a770
+14
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user