Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 154 additions & 2 deletions src/google/adk/tools/skill_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

import asyncio
import base64
import collections
import json
import logging
Expand Down Expand Up @@ -56,6 +57,11 @@

_DEFAULT_SCRIPT_TIMEOUT = 300
_MAX_SKILL_PAYLOAD_BYTES = 16 * 1024 * 1024 # 16 MB
# Caps for files generated by skill scripts that are auto-saved as artifacts.
_MAX_GENERATED_ARTIFACT_BYTES = 5 * 1024 * 1024 # 5 MiB per file
_MAX_GENERATED_ARTIFACTS_TOTAL_BYTES = 16 * 1024 * 1024 # 16 MiB total
# Printed by the skill wrapper after execution; stripped from tool stdout.
_GENERATED_FILES_MARKER = "__ADK_SKILL_GENERATED_FILES__:"

# Message used for the "Content Injection" pattern.
_BINARY_FILE_DETECTED_MSG = (
Expand All @@ -64,6 +70,94 @@
)


def _extract_generated_files_from_stdout(
stdout: str | None,
) -> tuple[str, list[dict[str, Any]]]:
"""Strips the generated-files marker from stdout and returns file payloads."""
if not stdout or _GENERATED_FILES_MARKER not in stdout:
return stdout or "", []

cleaned_parts: list[str] = []
generated: list[dict[str, Any]] = []
for line in stdout.splitlines(keepends=True):
marker_at = line.find(_GENERATED_FILES_MARKER)
if marker_at == -1:
cleaned_parts.append(line)
continue
prefix = line[:marker_at]
if prefix:
cleaned_parts.append(prefix)
if not prefix.endswith("\n"):
cleaned_parts.append("\n")
payload = line[marker_at + len(_GENERATED_FILES_MARKER) :].strip()
if not payload:
continue
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
logger.warning("Failed to parse skill generated-files marker payload")
continue
if isinstance(parsed, list):
generated = [item for item in parsed if isinstance(item, dict)]
return "".join(cleaned_parts), generated


async def _save_generated_skill_artifacts(
tool_context: ToolContext | None,
generated_files: list[dict[str, Any]],
) -> list[str]:
"""Saves skill-generated files via tool_context and returns saved names."""
if not generated_files:
return []
if tool_context is None:
logger.warning(
"Skill script produced %d generated file(s) but no tool_context was"
" provided; skipping artifact save.",
len(generated_files),
)
return []
if tool_context._invocation_context.artifact_service is None:
logger.warning(
"Skill script produced %d generated file(s) but artifact service is"
" not initialized; skipping artifact save.",
len(generated_files),
)
return []

saved: list[str] = []
for item in generated_files:
path = item.get("path")
content_b64 = item.get("content_b64")
if (
not isinstance(path, str)
or not path
or not isinstance(content_b64, str)
):
continue
norm = PurePosixPath(path.replace("\\", "/"))
if norm.is_absolute() or ".." in norm.parts:
logger.warning("Skipping generated skill file with unsafe path: %s", path)
continue
mime_type = item.get("mime_type")
if not isinstance(mime_type, str) or not mime_type:
mime_type = "application/octet-stream"
try:
data = base64.b64decode(content_b64)
except Exception: # pylint: disable=broad-exception-caught
logger.warning("Failed to decode generated skill file: %s", path)
continue
filename = str(norm)
try:
await tool_context.save_artifact(
filename=filename,
artifact=types.Part.from_bytes(data=data, mime_type=mime_type),
)
saved.append(filename)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to save skill-generated artifact '%s'", filename)
return saved


def _build_skill_system_instruction(
prefix: str | None = None, skills_folder: Path | None = None
) -> str:
Expand Down Expand Up @@ -531,6 +625,7 @@ async def execute_script_async(
script_args: dict[str, Any] | list[str] | None,
short_options: dict[str, Any] | None = None,
positional_args: list[str] | None = None,
tool_context: ToolContext | None = None,
) -> dict[str, Any]:
"""Prepares and executes the script using the base executor.

Expand All @@ -543,9 +638,12 @@ async def execute_script_async(
long options or a list of strings.
short_options: Optional short options (single hyphen) as key-value pairs.
positional_args: Optional positional arguments.
tool_context: Optional tool context used to persist generated files as
artifacts.

Returns:
A dictionary containing execution results (stdout, stderr, status).
When generated files are saved, also includes ``saved_artifacts``.
"""
code = self._build_wrapper_code(
skill, file_path, script_args, short_options, positional_args
Expand Down Expand Up @@ -573,6 +671,7 @@ async def execute_script_async(

stdout = result.stdout
stderr = result.stderr
stdout, generated_files = _extract_generated_files_from_stdout(stdout)

# Shell scripts serialize both streams as JSON
# through stdout; parse the envelope if present.
Expand Down Expand Up @@ -606,13 +705,19 @@ async def execute_script_async(
elif stderr:
status = "warning"

return {
saved_artifacts = await _save_generated_skill_artifacts(
tool_context, generated_files
)
response: dict[str, Any] = {
"skill_name": skill.name,
"file_path": file_path,
"stdout": stdout,
"stderr": stderr,
"status": status,
}
if saved_artifacts:
response["saved_artifacts"] = saved_artifacts
return response
except SystemExit as e:
if e.code in (None, 0):
return {
Expand Down Expand Up @@ -796,8 +901,54 @@ def _build_wrapper_code(
else:
return None

# After the script runs, collect files created under the tempdir that were
# not part of the original skill payload and emit them on stdout for the
# host to persist as artifacts (tempdir is deleted when this block exits).
code_lines.extend([
" finally:",
" try:",
" import base64 as _b64",
" import mimetypes as _mt",
" _initial = {",
" os.path.normpath(_p).replace('\\\\', '/')",
" for _p in _files",
" }",
" _generated = []",
" _total = 0",
f" _max_file = {_MAX_GENERATED_ARTIFACT_BYTES!r}",
f" _max_total = {_MAX_GENERATED_ARTIFACTS_TOTAL_BYTES!r}",
" for _root, _dirs, _names in os.walk(td):",
" _dirs[:] = [d for d in _dirs if d != '__pycache__']",
" for _name in _names:",
" if _name.endswith('.pyc'):",
" continue",
" _full = os.path.join(_root, _name)",
" _rel = os.path.relpath(_full, td).replace('\\\\', '/')",
" _norm = os.path.normpath(_rel).replace('\\\\', '/')",
" if _norm in _initial or _norm.startswith('..'):",
" continue",
" try:",
" _sz = os.path.getsize(_full)",
" except OSError:",
" continue",
" if _sz > _max_file or _total + _sz > _max_total:",
" continue",
" with open(_full, 'rb') as _gf:",
" _data = _gf.read()",
" _total += len(_data)",
" _mime, _ = _mt.guess_type(_norm)",
" _generated.append({",
" 'path': _norm,",
" 'content_b64': _b64.b64encode(_data).decode('ascii'),",
" 'mime_type': _mime or 'application/octet-stream',",
" })",
" if _generated:",
(
f" print({_GENERATED_FILES_MARKER!r} +"
" _json.dumps(_generated))"
),
" except Exception:",
" pass",
" os.chdir(_orig_cwd)",
])

Expand Down Expand Up @@ -1057,7 +1208,8 @@ async def run_async(
file_path,
script_args,
short_options,
positional_args, # pylint: disable=protected-access
positional_args,
tool_context=tool_context,
)

async def _ensure_skill_materialized_in_env(
Expand Down
Loading