Skip to content

Commit cb1ffce

Browse files
authored
Merge branch 'main' into fix/handle-circular-refs
2 parents 95fdf8d + d0102ec commit cb1ffce

File tree

12 files changed

+3165
-2283
lines changed

12 files changed

+3165
-2283
lines changed

src/google/adk/cli/cli_deploy.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
from __future__ import annotations
1515

1616
from datetime import datetime
17+
import importlib
1718
import json
1819
import os
1920
import shutil
2021
import subprocess
22+
import sys
23+
import traceback
2124
from typing import Final
2225
from typing import Optional
2326
import warnings
@@ -465,6 +468,122 @@ def _validate_gcloud_extra_args(
465468
)
466469

467470

471+
def _validate_agent_import(
472+
agent_src_path: str,
473+
adk_app_object: str,
474+
is_config_agent: bool,
475+
) -> None:
476+
"""Validates that the agent module can be imported successfully.
477+
478+
This pre-deployment validation catches common issues like missing
479+
dependencies or import errors in custom BaseLlm implementations before
480+
the agent is deployed to Agent Engine. This provides clearer error
481+
messages and prevents deployments that would fail at runtime.
482+
483+
Args:
484+
agent_src_path: Path to the staged agent source code.
485+
adk_app_object: The Python object name to import ('root_agent' or 'app').
486+
is_config_agent: Whether this is a config-based agent.
487+
488+
Raises:
489+
click.ClickException: If the agent module cannot be imported.
490+
"""
491+
if is_config_agent:
492+
# Config agents are loaded from YAML, skip Python import validation
493+
return
494+
495+
agent_module_path = os.path.join(agent_src_path, 'agent.py')
496+
if not os.path.exists(agent_module_path):
497+
raise click.ClickException(
498+
f'Agent module not found at {agent_module_path}. '
499+
'Please ensure your agent folder contains an agent.py file.'
500+
)
501+
502+
# Add the parent directory to sys.path temporarily for import resolution
503+
parent_dir = os.path.dirname(agent_src_path)
504+
module_name = os.path.basename(agent_src_path)
505+
506+
original_sys_path = sys.path.copy()
507+
original_sys_modules_keys = set(sys.modules.keys())
508+
try:
509+
# Add parent directory to path so imports work correctly
510+
if parent_dir not in sys.path:
511+
sys.path.insert(0, parent_dir)
512+
try:
513+
module = importlib.import_module(f'{module_name}.agent')
514+
except ImportError as e:
515+
error_msg = str(e)
516+
tb = traceback.format_exc()
517+
518+
# Check for common issues
519+
if 'BaseLlm' in tb or 'base_llm' in tb.lower():
520+
raise click.ClickException(
521+
'Failed to import agent module due to a BaseLlm-related error:\n'
522+
f'{error_msg}\n\n'
523+
'This error often occurs when deploying agents with custom LLM '
524+
'implementations. Please ensure:\n'
525+
'1. All custom LLM classes are defined in files within your agent '
526+
'folder\n'
527+
'2. All required dependencies are listed in requirements.txt\n'
528+
'3. Import paths use relative imports (e.g., "from .my_llm import '
529+
'MyLlm")\n'
530+
'4. Your custom BaseLlm class and its dependencies are installed\n'
531+
'\n'
532+
'If this failure is expected (e.g., missing local dependencies), '
533+
'disable agent import validation by omitting '
534+
'--validate-agent-import (default) or passing '
535+
'--skip-agent-import-validation (or --no-validate-agent-import).'
536+
) from e
537+
else:
538+
raise click.ClickException(
539+
f'Failed to import agent module:\n{error_msg}\n\n'
540+
'Please ensure all dependencies are listed in requirements.txt '
541+
'and all imports are resolvable.\n\n'
542+
f'Full traceback:\n{tb}\n\n'
543+
'If this failure is expected (e.g., missing local dependencies), '
544+
'disable agent import validation by omitting '
545+
'--validate-agent-import (default) or passing '
546+
'--skip-agent-import-validation (or --no-validate-agent-import).'
547+
) from e
548+
except Exception as e:
549+
tb = traceback.format_exc()
550+
raise click.ClickException(
551+
f'Error while loading agent module:\n{e}\n\n'
552+
'Please check your agent code for errors.\n\n'
553+
f'Full traceback:\n{tb}\n\n'
554+
'If this failure is expected (e.g., missing local dependencies), '
555+
'disable agent import validation by omitting '
556+
'--validate-agent-import (default) or passing '
557+
'--skip-agent-import-validation (or --no-validate-agent-import).'
558+
) from e
559+
560+
# Check that the expected object exists
561+
if not hasattr(module, adk_app_object):
562+
available_attrs = [
563+
attr for attr in dir(module) if not attr.startswith('_')
564+
]
565+
raise click.ClickException(
566+
f"Agent module does not export '{adk_app_object}'. "
567+
f'Available exports: {available_attrs}\n\n'
568+
'Please ensure your agent.py exports either "root_agent" or "app".'
569+
)
570+
571+
click.echo(
572+
'Agent module validation successful: '
573+
f'found "{adk_app_object}" in agent.py'
574+
)
575+
576+
finally:
577+
# Restore original sys.path
578+
sys.path[:] = original_sys_path
579+
# Clean up modules introduced by validation.
580+
for key in list(sys.modules.keys()):
581+
if key in original_sys_modules_keys:
582+
continue
583+
if key == module_name or key.startswith(f'{module_name}.'):
584+
sys.modules.pop(key, None)
585+
586+
468587
def _get_service_option_by_adk_version(
469588
adk_version: str,
470589
session_uri: Optional[str],
@@ -702,6 +821,7 @@ def to_agent_engine(
702821
requirements_file: Optional[str] = None,
703822
env_file: Optional[str] = None,
704823
agent_engine_config_file: Optional[str] = None,
824+
skip_agent_import_validation: bool = True,
705825
):
706826
"""Deploys an agent to Vertex AI Agent Engine.
707827
@@ -761,6 +881,11 @@ def to_agent_engine(
761881
agent_engine_config_file (str): The filepath to the agent engine config file
762882
to use. If not specified, the `.agent_engine_config.json` file in the
763883
`agent_folder` will be used.
884+
skip_agent_import_validation (bool): Optional. Default is True. If True,
885+
skip the
886+
pre-deployment import validation of `agent.py`. This can be useful when
887+
the local environment does not have the same dependencies as the
888+
deployment environment.
764889
"""
765890
app_name = os.path.basename(agent_folder)
766891
display_name = display_name or app_name
@@ -953,6 +1078,11 @@ def to_agent_engine(
9531078
click.echo(f'Config agent detected: {config_root_agent_file}')
9541079
is_config_agent = True
9551080

1081+
# Validate that the agent module can be imported before deployment.
1082+
if not skip_agent_import_validation:
1083+
click.echo('Validating agent module...')
1084+
_validate_agent_import(agent_src_path, adk_app_object, is_config_agent)
1085+
9561086
adk_app_file = os.path.join(temp_folder, f'{adk_app}.py')
9571087
if adk_app_object == 'root_agent':
9581088
adk_app_type = 'agent'

src/google/adk/cli/cli_tools_click.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1867,6 +1867,25 @@ def cli_migrate_session(
18671867
" directory, if any.)"
18681868
),
18691869
)
1870+
@click.option(
1871+
"--validate-agent-import/--no-validate-agent-import",
1872+
default=False,
1873+
help=(
1874+
"Optional. Validate that the agent module can be imported before"
1875+
" deployment. This requires your local environment to have the same"
1876+
" dependencies as the deployment environment. (default: disabled)"
1877+
),
1878+
)
1879+
@click.option(
1880+
"--skip-agent-import-validation",
1881+
"skip_agent_import_validation_alias",
1882+
is_flag=True,
1883+
default=False,
1884+
help=(
1885+
"Optional. Skip pre-deployment import validation of `agent.py`. This is"
1886+
" the default; use --validate-agent-import to enable validation."
1887+
),
1888+
)
18701889
@click.argument(
18711890
"agent",
18721891
type=click.Path(
@@ -1891,6 +1910,8 @@ def cli_deploy_agent_engine(
18911910
requirements_file: str,
18921911
absolutize_imports: bool,
18931912
agent_engine_config_file: str,
1913+
validate_agent_import: bool = False,
1914+
skip_agent_import_validation_alias: bool = False,
18941915
):
18951916
"""Deploys an agent to Agent Engine.
18961917
@@ -1905,6 +1926,11 @@ def cli_deploy_agent_engine(
19051926
"""
19061927
logging.getLogger("vertexai_genai.agentengines").setLevel(logging.INFO)
19071928
try:
1929+
if validate_agent_import and skip_agent_import_validation_alias:
1930+
raise click.UsageError(
1931+
"Do not pass both --validate-agent-import and"
1932+
" --skip-agent-import-validation."
1933+
)
19081934
cli_deploy.to_agent_engine(
19091935
agent_folder=agent,
19101936
project=project,
@@ -1922,6 +1948,7 @@ def cli_deploy_agent_engine(
19221948
requirements_file=requirements_file,
19231949
absolutize_imports=absolutize_imports,
19241950
agent_engine_config_file=agent_engine_config_file,
1951+
skip_agent_import_validation=not validate_agent_import,
19251952
)
19261953
except Exception as e:
19271954
click.secho(f"Deploy failed: {e}", fg="red", err=True)

src/google/adk/models/lite_llm.py

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@
9393
_LITELLM_STRUCTURED_TYPES = {"json_object", "json_schema"}
9494
_JSON_DECODER = json.JSONDecoder()
9595

96+
# Mapping of major MIME type prefixes to LiteLLM content types for URL blocks.
97+
_MEDIA_URL_CONTENT_TYPE_BY_MAJOR_MIME_TYPE = {
98+
"image": "image_url",
99+
"video": "video_url",
100+
"audio": "audio_url",
101+
}
102+
96103
# Mapping of LiteLLM finish_reason strings to FinishReason enum values
97104
# Note: tool_calls/function_call map to STOP because:
98105
# 1. FinishReason.TOOL_CALL enum does not exist (as of google-genai 0.8.0)
@@ -264,6 +271,15 @@ def _looks_like_openai_file_id(file_uri: str) -> bool:
264271
return file_uri.startswith("file-")
265272

266273

274+
def _is_http_url(uri: str) -> bool:
275+
"""Returns True when `uri` is an HTTP(S) URL."""
276+
try:
277+
parsed = urlparse(uri)
278+
except ValueError:
279+
return False
280+
return parsed.scheme in ("http", "https")
281+
282+
267283
def _redact_file_uri_for_log(
268284
file_uri: str, *, display_name: str | None = None
269285
) -> str:
@@ -307,6 +323,17 @@ def _decode_inline_text_data(raw_bytes: bytes) -> str:
307323
return raw_bytes.decode("latin-1", errors="replace")
308324

309325

326+
def _normalize_mime_type(mime_type: str) -> str:
327+
"""Normalizes MIME types for comparisons."""
328+
return mime_type.split(";", 1)[0].strip().lower()
329+
330+
331+
def _media_url_content_type(mime_type: str) -> str | None:
332+
"""Returns the LiteLLM URL content type for known media MIME types."""
333+
major_mime_type = _normalize_mime_type(mime_type).split("/", 1)[0]
334+
return _MEDIA_URL_CONTENT_TYPE_BY_MAJOR_MIME_TYPE.get(major_mime_type)
335+
336+
310337
def _iter_reasoning_texts(reasoning_value: Any) -> Iterable[str]:
311338
"""Yields textual fragments from provider specific reasoning payloads."""
312339
if reasoning_value is None:
@@ -470,6 +497,8 @@ def _part_has_payload(part: types.Part) -> bool:
470497
return True
471498
if part.file_data and (part.file_data.file_uri or part.file_data.data):
472499
return True
500+
if part.function_response:
501+
return True
473502
return False
474503

475504

@@ -773,7 +802,7 @@ async def _get_content(
773802
part.inline_data
774803
and part.inline_data.data
775804
and part.inline_data.mime_type
776-
and part.inline_data.mime_type.startswith("text/")
805+
and _normalize_mime_type(part.inline_data.mime_type).startswith("text/")
777806
):
778807
return _decode_inline_text_data(part.inline_data.data)
779808

@@ -789,34 +818,26 @@ async def _get_content(
789818
and part.inline_data.data
790819
and part.inline_data.mime_type
791820
):
792-
if part.inline_data.mime_type.startswith("text/"):
821+
mime_type = _normalize_mime_type(part.inline_data.mime_type)
822+
if mime_type.startswith("text/"):
793823
decoded_text = _decode_inline_text_data(part.inline_data.data)
794824
content_objects.append({
795825
"type": "text",
796826
"text": decoded_text,
797827
})
798828
continue
799829
base64_string = base64.b64encode(part.inline_data.data).decode("utf-8")
800-
data_uri = f"data:{part.inline_data.mime_type};base64,{base64_string}"
830+
data_uri = f"data:{mime_type};base64,{base64_string}"
801831
# LiteLLM providers extract the MIME type from the data URI; avoid
802832
# passing a separate `format` field that some backends reject.
803833

804-
if part.inline_data.mime_type.startswith("image"):
834+
url_content_type = _media_url_content_type(mime_type)
835+
if url_content_type:
805836
content_objects.append({
806-
"type": "image_url",
807-
"image_url": {"url": data_uri},
837+
"type": url_content_type,
838+
url_content_type: {"url": data_uri},
808839
})
809-
elif part.inline_data.mime_type.startswith("video"):
810-
content_objects.append({
811-
"type": "video_url",
812-
"video_url": {"url": data_uri},
813-
})
814-
elif part.inline_data.mime_type.startswith("audio"):
815-
content_objects.append({
816-
"type": "audio_url",
817-
"audio_url": {"url": data_uri},
818-
})
819-
elif part.inline_data.mime_type in _SUPPORTED_FILE_CONTENT_MIME_TYPES:
840+
elif mime_type in _SUPPORTED_FILE_CONTENT_MIME_TYPES:
820841
# OpenAI/Azure require file_id from uploaded file, not inline data
821842
if provider in _FILE_ID_REQUIRED_PROVIDERS:
822843
file_response = await litellm.acreate_file(
@@ -849,6 +870,34 @@ async def _get_content(
849870
})
850871
continue
851872

873+
# Determine MIME type: use explicit value, infer from URI, or use default.
874+
mime_type = part.file_data.mime_type
875+
if not mime_type:
876+
mime_type = _infer_mime_type_from_uri(part.file_data.file_uri)
877+
if not mime_type and part.file_data.display_name:
878+
guessed_mime_type, _ = mimetypes.guess_type(part.file_data.display_name)
879+
mime_type = guessed_mime_type
880+
if not mime_type:
881+
# LiteLLM's Vertex AI backend requires format for GCS URIs.
882+
mime_type = _DEFAULT_MIME_TYPE
883+
logger.debug(
884+
"Could not determine MIME type for file_uri %s, using default: %s",
885+
part.file_data.file_uri,
886+
mime_type,
887+
)
888+
mime_type = _normalize_mime_type(mime_type)
889+
890+
if provider in _FILE_ID_REQUIRED_PROVIDERS and _is_http_url(
891+
part.file_data.file_uri
892+
):
893+
url_content_type = _media_url_content_type(mime_type)
894+
if url_content_type:
895+
content_objects.append({
896+
"type": url_content_type,
897+
url_content_type: {"url": part.file_data.file_uri},
898+
})
899+
continue
900+
852901
if _requires_file_uri_fallback(provider, model, part.file_data.file_uri):
853902
logger.debug(
854903
"File URI %s not supported for provider %s, using text fallback",
@@ -868,21 +917,6 @@ async def _get_content(
868917
file_object: ChatCompletionFileUrlObject = {
869918
"file_id": part.file_data.file_uri,
870919
}
871-
# Determine MIME type: use explicit value, infer from URI, or use default
872-
mime_type = part.file_data.mime_type
873-
if not mime_type:
874-
mime_type = _infer_mime_type_from_uri(part.file_data.file_uri)
875-
if not mime_type and part.file_data.display_name:
876-
guessed_mime_type, _ = mimetypes.guess_type(part.file_data.display_name)
877-
mime_type = guessed_mime_type
878-
if not mime_type:
879-
# LiteLLM's Vertex AI backend requires format for GCS URIs
880-
mime_type = _DEFAULT_MIME_TYPE
881-
logger.debug(
882-
"Could not determine MIME type for file_uri %s, using default: %s",
883-
part.file_data.file_uri,
884-
mime_type,
885-
)
886920
file_object["format"] = mime_type
887921
content_objects.append({
888922
"type": "file",

0 commit comments

Comments
 (0)