feat: expose artifact URLs to the model when available

SaveFilesAsArtifactsPlugin now resolves the canonical URI for each saved artifact. When the URI is model-accessible (`gs://`, `https://`, `http://`), we add a `Part(file_data=...)` so the LLM can fetch the filedirectly while still emitting the placeholder. If no model-accessible URI exists, we retain the original inline blob alongside the placeholder to preserve access to the uploaded bytes.

Close #2016

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 828786519
This commit is contained in:
George Weale
2025-11-05 22:17:37 -08:00
committed by Copybara-Service
parent 52db15f133
commit e3caf79139
2 changed files with 188 additions and 63 deletions
@@ -17,6 +17,7 @@ from __future__ import annotations
import copy
import logging
from typing import Optional
import urllib.parse
from google.genai import types
@@ -25,6 +26,11 @@ from .base_plugin import BasePlugin
logger = logging.getLogger('google_adk.' + __name__)
# Schemes supported by our current LLM connectors. Vertex exposes `gs://` while
# hosted endpoints use HTTPS. Expand this list when BaseLlm surfaces provider
# capabilities.
_MODEL_ACCESSIBLE_URI_SCHEMES = {'gs', 'https', 'http'}
class SaveFilesAsArtifactsPlugin(BasePlugin):
"""A plugin that saves files embedded in user messages as artifacts.
@@ -75,8 +81,9 @@ class SaveFilesAsArtifactsPlugin(BasePlugin):
continue
try:
# Use display_name if available; otherwise, generate a filename
file_name = part.inline_data.display_name
# Use display_name if available, otherwise generate a filename
inline_data = part.inline_data
file_name = inline_data.display_name
if not file_name:
file_name = f'artifact_{invocation_context.invocation_id}_{i}'
logger.info(
@@ -87,7 +94,7 @@ class SaveFilesAsArtifactsPlugin(BasePlugin):
display_name = file_name
# Create a copy to stop mutation of the saved artifact if the original part is modified
await invocation_context.artifact_service.save_artifact(
version = await invocation_context.artifact_service.save_artifact(
app_name=invocation_context.app_name,
user_id=invocation_context.user_id,
session_id=invocation_context.session.id,
@@ -95,10 +102,28 @@ class SaveFilesAsArtifactsPlugin(BasePlugin):
artifact=copy.copy(part),
)
# Replace the inline data with a placeholder text (using the clean name)
new_parts.append(
types.Part(text=f'[Uploaded Artifact: "{display_name}"]')
placeholder_part = types.Part(
text=f'[Uploaded Artifact: "{display_name}"]'
)
new_parts.append(placeholder_part)
file_part = await self._build_file_reference_part(
invocation_context=invocation_context,
filename=file_name,
version=version,
mime_type=inline_data.mime_type,
display_name=display_name,
)
if file_part:
new_parts.append(file_part)
else:
logger.debug(
'Artifact %s is not exposed via a model-accessible URI; keeping'
' inline data in user message.',
file_name,
)
new_parts.append(part)
modified = True
logger.info(f'Successfully saved artifact: {file_name}')
@@ -112,3 +137,58 @@ class SaveFilesAsArtifactsPlugin(BasePlugin):
return types.Content(role=user_message.role, parts=new_parts)
else:
return None
async def _build_file_reference_part(
self,
*,
invocation_context: InvocationContext,
filename: str,
version: int,
mime_type: Optional[str],
display_name: str,
) -> Optional[types.Part]:
"""Constructs a file reference part if the artifact URI is model-accessible."""
artifact_service = invocation_context.artifact_service
if not artifact_service:
return None
try:
artifact_version = await artifact_service.get_artifact_version(
app_name=invocation_context.app_name,
user_id=invocation_context.user_id,
session_id=invocation_context.session.id,
filename=filename,
version=version,
)
except Exception as exc: # pylint: disable=broad-except
logger.warning(
'Failed to resolve artifact version for %s: %s', filename, exc
)
return None
if (
not artifact_version
or not artifact_version.canonical_uri
or not _is_model_accessible_uri(artifact_version.canonical_uri)
):
return None
file_data = types.FileData(
file_uri=artifact_version.canonical_uri,
mime_type=mime_type or artifact_version.mime_type,
display_name=display_name,
)
return types.Part(file_data=file_data)
def _is_model_accessible_uri(uri: str) -> bool:
try:
parsed = urllib.parse.urlparse(uri)
except ValueError:
return False
if not parsed.scheme:
return False
return parsed.scheme.lower() in _MODEL_ACCESSIBLE_URI_SCHEMES