Summary
SkillToolset(tool_filter=[...]) correctly filters which tools are exposed to the model via get_tools(), but process_llm_request always injects a system instruction built by _build_skill_system_instruction() that unconditionally advertises run_skill_script and load_skill_resource.
When those tools are filtered out (e.g. skills that only have SKILL.md and no scripts/ / references/), the model still tries to call them. In ADK 2.5 this becomes ValueError: Tool 'run_skill_script' not found. and can crash the entire workflow invocation unless on_tool_error_callback recovers it.
Skills are marked experimental in the docs (adk.dev/skills), but this looks like a clear inconsistency between tool_filter and the injected instruction.
Environment
google-adk: 2.5.0
- Python: 3.12
- Runtime: Vertex AI Agent Runtime / Reasoning Engine
- Model:
gemini-flash-latest
Minimal reproduction
from google.adk import Agent
from google.adk.skills import models
from google.adk.tools.skill_toolset import SkillToolset
skill = models.Skill(
frontmatter=models.Frontmatter(
name="whatsapp-formatting",
description="Format messages for WhatsApp native rendering.",
),
instructions="Use *bold* and _italic_ WhatsApp syntax. No scripts.",
)
toolset = SkillToolset(
skills=[skill],
tool_filter=["list_skills", "load_skill"], # intentionally hide script/resource tools
)
agent = Agent(
model="gemini-flash-latest",
name="demo",
instruction="Help the user. Load the formatting skill when needed, then reply as text.",
tools=[toolset],
)
Ask the agent to produce a formatted WhatsApp reply. Observed model behavior:
- Calls
load_skill(skill_name="whatsapp-formatting") (correct).
- Then often wraps the already-written user reply in:
run_skill_script(skill_name="whatsapp-formatting", file_path="scripts/format_text.py", args={...})
- Vertex marks
finish_reason: UNEXPECTED_TOOL_CALL (tool not declared).
- ADK raises:
ValueError: Tool 'run_skill_script' not found.
Root cause (in installed 2.5.0 sources)
In google/adk/tools/skill_toolset.py:
get_tools() respects tool_filter via _is_tool_selected.
process_llm_request() always does:
instructions = [
_build_skill_system_instruction(prefix=self.tool_name_prefix)
]
llm_request.append_instructions(instructions)
And _build_skill_system_instruction() hardcodes steps that tell the model to use `{p}run_skill_script` and `{p}load_skill_resource`, with no awareness of tool_filter.
So the model is instructed to call tools that are not present in tools_dict.
Expected behavior
When tool_filter excludes run_skill_script / load_skill_resource, the injected system instruction should:
- Only mention tools that remain available after filtering, and/or
- Explicitly tell the model those tools are unavailable and to emit the final reply as normal model text (never wrap the user-facing answer in a tool call).
Ideally tool_filter and the skill system instruction stay consistent by construction.
Actual behavior
- Tools are filtered out of declarations.
- Instruction still advertises filtered tools.
- Model hallucinates the call →
UNEXPECTED_TOOL_CALL / ValueError: Tool '...' not found.
- Without
on_tool_error_callback, the workflow invocation dies and upstream clients see an empty text response.
Workaround
Subclass SkillToolset and override process_llm_request to inject an instruction built only from the allowed tool names; plus register on_tool_error_callback so unknown-tool errors become recoverable tool responses instead of crashing the run.
Happy to contribute a PR if maintainers agree on the desired API (e.g. make _build_skill_system_instruction take the active tool names / filter).
Related evidence
Telemetry / stream snippet after applying on_tool_error_callback (defense in depth; root cause still present):
{
"role": "user",
"parts": [{
"functionResponse": {
"name": "run_skill_script",
"response": {
"error": "La herramienta 'run_skill_script' no existe. No la reintentes; escribe tu respuesta final directamente como texto.",
"error_code": "TOOL_NOT_FOUND"
}
}
}]
}
Summary
SkillToolset(tool_filter=[...])correctly filters which tools are exposed to the model viaget_tools(), butprocess_llm_requestalways injects a system instruction built by_build_skill_system_instruction()that unconditionally advertisesrun_skill_scriptandload_skill_resource.When those tools are filtered out (e.g. skills that only have
SKILL.mdand noscripts//references/), the model still tries to call them. In ADK 2.5 this becomesValueError: Tool 'run_skill_script' not found.and can crash the entire workflow invocation unlesson_tool_error_callbackrecovers it.Skills are marked experimental in the docs (adk.dev/skills), but this looks like a clear inconsistency between
tool_filterand the injected instruction.Environment
google-adk: 2.5.0gemini-flash-latestMinimal reproduction
Ask the agent to produce a formatted WhatsApp reply. Observed model behavior:
load_skill(skill_name="whatsapp-formatting")(correct).run_skill_script(skill_name="whatsapp-formatting", file_path="scripts/format_text.py", args={...})finish_reason: UNEXPECTED_TOOL_CALL(tool not declared).ValueError: Tool 'run_skill_script' not found.Root cause (in installed 2.5.0 sources)
In
google/adk/tools/skill_toolset.py:get_tools()respectstool_filtervia_is_tool_selected.process_llm_request()always does:And
_build_skill_system_instruction()hardcodes steps that tell the model to use`{p}run_skill_script`and`{p}load_skill_resource`, with no awareness oftool_filter.So the model is instructed to call tools that are not present in
tools_dict.Expected behavior
When
tool_filterexcludesrun_skill_script/load_skill_resource, the injected system instruction should:Ideally
tool_filterand the skill system instruction stay consistent by construction.Actual behavior
UNEXPECTED_TOOL_CALL/ValueError: Tool '...' not found.on_tool_error_callback, the workflow invocation dies and upstream clients see an empty text response.Workaround
Subclass
SkillToolsetand overrideprocess_llm_requestto inject an instruction built only from the allowed tool names; plus registeron_tool_error_callbackso unknown-tool errors become recoverable tool responses instead of crashing the run.Happy to contribute a PR if maintainers agree on the desired API (e.g. make
_build_skill_system_instructiontake the active tool names / filter).Related evidence
Telemetry / stream snippet after applying
on_tool_error_callback(defense in depth; root cause still present):{ "role": "user", "parts": [{ "functionResponse": { "name": "run_skill_script", "response": { "error": "La herramienta 'run_skill_script' no existe. No la reintentes; escribe tu respuesta final directamente como texto.", "error_code": "TOOL_NOT_FOUND" } } }] }