Summary
ADK supports dynamic tool injection at runtime — a loaded skill can make new tools available in the next GenerateContent call within the same invocation. This is powered by SkillToolset (experimental) and the BaseToolset contract, but the mechanism is not documented or demonstrated anywhere.
This issue provides a concrete demonstration and explains the internal flow for future reference.
How It Works
The flow
- Agent is configured with a
SkillToolset containing skills and an additional_tools pool
- Initially, the LLM only sees 4 core skill management tools (
list_skills, load_skill, load_skill_resource, run_skill_script)
- LLM calls
load_skill(skill_name="data_analysis") → tool writes activation to session state:
state["_adk_activated_skill_{agent_name}"] = ["data_analysis"]
- On the very next LLM call (same invocation),
SkillToolset.get_tools() re-reads state, finds the activated skill, checks its adk_additional_tools metadata, and returns matching tools from the pool
- New tools appear in the
GenerateContent tool declarations — the LLM can now call them
Why it works within a single invocation
Two cache layers exist, both handled:
| Cache |
Location |
Why it doesn't block |
BaseToolset._use_invocation_cache |
base_toolset.py:120-125 |
SkillToolset sets self._use_invocation_cache = False at line 807 |
invocation_context.canonical_tools_cache |
invocation_context.py:214 |
Only used for grounding metadata check, NOT by _process_agent_tools() |
_process_agent_tools() (base_llm_flow.py:409) calls get_tools_with_prefix() → get_tools() on every iteration of the LLM loop. With caching disabled, state changes from tool calls are visible immediately.
Key code path
LLM loop iteration N:
_run_one_step_async()
→ _preprocess_async()
→ _process_agent_tools() # line 929
→ _convert_tool_union_to_tools()
→ SkillToolset.get_tools_with_prefix() # cache disabled
→ SkillToolset.get_tools() # line 828
→ _resolve_additional_tools_from_state() # reads session state
→ returns new tools from additional_tools pool
→ tool.process_llm_request() # registers declarations
→ _call_llm_async()
→ GenerateContent(tools=[...new tools included...])
Demonstration
Skill definition with adk_additional_tools
from google.adk.tools.skill_toolset import SkillToolset
from google.adk.tools.skill_toolset import models as skill_models
# A tool that becomes available after loading the "data_analysis" skill
def analyze_csv(file_path: str, query: str) -> str:
"""Analyze a CSV file with a natural language query."""
return f"Analysis of {file_path}: {query} → 42"
# Define the skill — adk_additional_tools lists which tools to inject
data_skill = skill_models.Skill(
name="data_analysis",
display_name="Data Analysis",
description="Loads CSV analysis capabilities",
frontmatter=skill_models.SkillFrontmatter(
metadata={
"adk_additional_tools": ["analyze_csv"], # ← key field
}
),
content="This skill enables CSV data analysis.",
)
# Build the toolset
skill_toolset = SkillToolset(
skills=[data_skill],
additional_tools=[analyze_csv], # pool of injectable tools
)
agent = Agent(
name="assistant",
model="gemini-2.5-flash",
instruction=(
"You have skills you can load. Use list_skills to see available"
" skills, then load_skill to activate them. Once a skill is"
" loaded, its tools become available."
),
tools=[skill_toolset],
)
What the LLM sees
Before load_skill("data_analysis"):
Available tools: list_skills, load_skill, load_skill_resource, run_skill_script
After load_skill("data_analysis") → next GenerateContent call:
Available tools: list_skills, load_skill, load_skill_resource, run_skill_script, analyze_csv
The analyze_csv tool was dynamically injected based on the skill's adk_additional_tools metadata.
Custom dynamic toolset (without SkillToolset)
The same pattern works with any BaseToolset subclass:
from google.adk.tools.base_toolset import BaseToolset
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.function_tool import FunctionTool
class DynamicToolset(BaseToolset):
"""A toolset that exposes tools based on session state."""
def __init__(self, tool_pool):
super().__init__()
self._use_invocation_cache = False # critical
self._pool = {t.name: t for t in tool_pool}
self._activate_tool = FunctionTool(func=self._activate)
def _activate(self, tool_name: str) -> str:
"""Activate a tool by name. It becomes available on the next turn."""
if tool_name in self._pool:
return f"Tool '{tool_name}' will be available on the next turn."
return f"Unknown tool: {tool_name}"
async def get_tools(self, ctx=None):
base = [self._activate_tool]
if not ctx:
return base
active = ctx.state.get("_active_tools", [])
for name in active:
if name in self._pool:
base.append(self._pool[name])
return base
The critical detail is self._use_invocation_cache = False — without it, get_tools() is only called once per invocation and mid-invocation state changes won't take effect.
Current Limitations
- Tools must be pre-declared in the
additional_tools pool — truly arbitrary tools cannot be created at runtime
before_model_callback cannot inject new tools: by the time it runs, tool resolution is already complete
agent.tools is a Pydantic field and cannot be mutated after construction
Relevant Files
| File |
Key Lines |
What |
src/google/adk/tools/skill_toolset.py |
775-889 |
SkillToolset class and _resolve_additional_tools_from_state() |
src/google/adk/tools/base_toolset.py |
102-132 |
get_tools_with_prefix() with invocation cache |
src/google/adk/flows/llm_flows/base_llm_flow.py |
409-456 |
_process_agent_tools() — called every LLM loop iteration |
src/google/adk/agents/llm_agent.py |
139-189 |
_convert_tool_union_to_tools() — delegates to get_tools_with_prefix() |
Summary
ADK supports dynamic tool injection at runtime — a loaded skill can make new tools available in the next
GenerateContentcall within the same invocation. This is powered bySkillToolset(experimental) and theBaseToolsetcontract, but the mechanism is not documented or demonstrated anywhere.This issue provides a concrete demonstration and explains the internal flow for future reference.
How It Works
The flow
SkillToolsetcontaining skills and anadditional_toolspoollist_skills,load_skill,load_skill_resource,run_skill_script)load_skill(skill_name="data_analysis")→ tool writes activation to session state:SkillToolset.get_tools()re-reads state, finds the activated skill, checks itsadk_additional_toolsmetadata, and returns matching tools from the poolGenerateContenttool declarations — the LLM can now call themWhy it works within a single invocation
Two cache layers exist, both handled:
BaseToolset._use_invocation_cachebase_toolset.py:120-125SkillToolsetsetsself._use_invocation_cache = Falseat line 807invocation_context.canonical_tools_cacheinvocation_context.py:214_process_agent_tools()_process_agent_tools()(base_llm_flow.py:409) callsget_tools_with_prefix()→get_tools()on every iteration of the LLM loop. With caching disabled, state changes from tool calls are visible immediately.Key code path
Demonstration
Skill definition with
adk_additional_toolsWhat the LLM sees
Before
load_skill("data_analysis"):After
load_skill("data_analysis")→ next GenerateContent call:The
analyze_csvtool was dynamically injected based on the skill'sadk_additional_toolsmetadata.Custom dynamic toolset (without SkillToolset)
The same pattern works with any
BaseToolsetsubclass:The critical detail is
self._use_invocation_cache = False— without it,get_tools()is only called once per invocation and mid-invocation state changes won't take effect.Current Limitations
additional_toolspool — truly arbitrary tools cannot be created at runtimebefore_model_callbackcannot inject new tools: by the time it runs, tool resolution is already completeagent.toolsis a Pydantic field and cannot be mutated after constructionRelevant Files
src/google/adk/tools/skill_toolset.pySkillToolsetclass and_resolve_additional_tools_from_state()src/google/adk/tools/base_toolset.pyget_tools_with_prefix()with invocation cachesrc/google/adk/flows/llm_flows/base_llm_flow.py_process_agent_tools()— called every LLM loop iterationsrc/google/adk/agents/llm_agent.py_convert_tool_union_to_tools()— delegates toget_tools_with_prefix()