Skip to content

Commit 72946e2

Browse files
committed
Merge feat/skill-rolling-window-clean into vzgpt-core
2 parents bae5b1a + 03a565a commit 72946e2

2 files changed

Lines changed: 356 additions & 1 deletion

File tree

src/google/adk/tools/skill_toolset.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,17 @@ async def run_async(
246246
activated_skills = list(tool_context.state.get(state_key) or [])
247247
if skill_name not in activated_skills:
248248
activated_skills.append(skill_name)
249-
tool_context.state[state_key] = activated_skills
249+
else:
250+
# Move to the end (most recently used) for rolling window.
251+
activated_skills.remove(skill_name)
252+
activated_skills.append(skill_name)
253+
254+
# Enforce rolling window: evict oldest skills if limit is exceeded.
255+
max_skills = self._toolset._max_activated_skills
256+
if max_skills is not None and len(activated_skills) > max_skills:
257+
activated_skills = activated_skills[-max_skills:]
258+
259+
tool_context.state[state_key] = activated_skills
250260

251261
return {
252262
"skill_name": skill_name,
@@ -941,6 +951,7 @@ def __init__(
941951
code_executor: BaseCodeExecutor | None = None,
942952
script_timeout: int = _DEFAULT_SCRIPT_TIMEOUT,
943953
additional_tools: list[ToolUnion] | None = None,
954+
max_activated_skills: int | None = None,
944955
tool_name_prefix: str | None = None,
945956
tool_filter: ToolPredicate | list[str] | None = None,
946957
):
@@ -955,6 +966,10 @@ def __init__(
955966
scripts executed via exec().
956967
additional_tools: Optional list of `BaseTool` or `BaseToolset` instances
957968
to be made available to the agent when certain skills are activated.
969+
max_activated_skills: Optional maximum number of skills to keep activated
970+
simultaneously. When set, only the most recently activated skills are
971+
retained (rolling window). Older skills and their associated tools are
972+
evicted from the session state. Defaults to None (no limit).
958973
tool_name_prefix: Optional prefix to prepend to tool names.
959974
tool_filter: Optional filter to select specific tools.
960975
"""
@@ -973,6 +988,7 @@ def __init__(
973988
self._registry = registry
974989
self._code_executor = code_executor
975990
self._script_timeout = script_timeout
991+
self._max_activated_skills = max_activated_skills
976992
# Needed for mid-turn reloading of skill tools.
977993
self._use_invocation_cache = False
978994
# Cache fetched remote skill definitions per turn to reduce requests to registry

tests/unittests/tools/test_skill_toolset.py

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2077,6 +2077,345 @@ async def test_close_cancels_futures_and_clears_cache():
20772077
assert not toolset._fetched_skill_cache
20782078

20792079

2080+
# --- Tests for max_activated_skills (rolling window) ---
2081+
2082+
2083+
@pytest.mark.asyncio
2084+
async def test_max_activated_skills_evicts_oldest(
2085+
mock_skill1, mock_skill2, tool_context_instance
2086+
):
2087+
"""When max_activated_skills is exceeded, the oldest skill is evicted."""
2088+
toolset = skill_toolset.SkillToolset(
2089+
[mock_skill1, mock_skill2], max_activated_skills=1
2090+
)
2091+
tool = skill_toolset.LoadSkillTool(toolset)
2092+
2093+
state_key = "_adk_activated_skill_test_agent"
2094+
2095+
# Load skill1
2096+
tool_context_instance.state.get.return_value = None
2097+
await tool.run_async(
2098+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
2099+
)
2100+
tool_context_instance.state.__setitem__.assert_called_with(
2101+
state_key, ["skill1"]
2102+
)
2103+
2104+
# Load skill2 — skill1 should be evicted
2105+
tool_context_instance.state.get.return_value = ["skill1"]
2106+
await tool.run_async(
2107+
args={"skill_name": "skill2"}, tool_context=tool_context_instance
2108+
)
2109+
tool_context_instance.state.__setitem__.assert_called_with(
2110+
state_key, ["skill2"]
2111+
)
2112+
2113+
2114+
@pytest.mark.asyncio
2115+
async def test_max_activated_skills_keeps_recent_n(
2116+
tool_context_instance,
2117+
):
2118+
"""Rolling window keeps the N most recently activated skills."""
2119+
skills = []
2120+
for i in range(5):
2121+
skill = mock.create_autospec(models.Skill, instance=True)
2122+
skill.name = f"skill_{i}"
2123+
skill.instructions = f"instructions for skill_{i}"
2124+
frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
2125+
frontmatter.name = f"skill_{i}"
2126+
frontmatter.metadata = {}
2127+
frontmatter.model_dump.return_value = {"name": f"skill_{i}"}
2128+
skill.frontmatter = frontmatter
2129+
skills.append(skill)
2130+
2131+
toolset = skill_toolset.SkillToolset(skills, max_activated_skills=3)
2132+
tool = skill_toolset.LoadSkillTool(toolset)
2133+
2134+
state_key = "_adk_activated_skill_test_agent"
2135+
2136+
# Sequentially load skills 0 through 4
2137+
current_state = []
2138+
for i in range(5):
2139+
tool_context_instance.state.get.return_value = list(current_state)
2140+
await tool.run_async(
2141+
args={"skill_name": f"skill_{i}"}, tool_context=tool_context_instance
2142+
)
2143+
# Capture what was set
2144+
call_args = tool_context_instance.state.__setitem__.call_args
2145+
current_state = call_args[0][1]
2146+
2147+
# After loading 5 skills with max=3, only the last 3 should remain
2148+
assert current_state == ["skill_2", "skill_3", "skill_4"]
2149+
2150+
2151+
@pytest.mark.asyncio
2152+
async def test_max_activated_skills_reloading_moves_to_end(
2153+
mock_skill1, mock_skill2, tool_context_instance
2154+
):
2155+
"""Re-loading an already active skill moves it to the end (most recent)."""
2156+
# Create a third skill
2157+
mock_skill3 = mock.create_autospec(models.Skill, instance=True)
2158+
mock_skill3.name = "skill3"
2159+
mock_skill3.instructions = "instructions for skill3"
2160+
frontmatter3 = mock.create_autospec(models.Frontmatter, instance=True)
2161+
frontmatter3.name = "skill3"
2162+
frontmatter3.metadata = {}
2163+
frontmatter3.model_dump.return_value = {"name": "skill3"}
2164+
mock_skill3.frontmatter = frontmatter3
2165+
2166+
toolset = skill_toolset.SkillToolset(
2167+
[mock_skill1, mock_skill2, mock_skill3], max_activated_skills=3
2168+
)
2169+
tool = skill_toolset.LoadSkillTool(toolset)
2170+
2171+
state_key = "_adk_activated_skill_test_agent"
2172+
2173+
# Start with all 3 loaded
2174+
tool_context_instance.state.get.return_value = [
2175+
"skill1",
2176+
"skill2",
2177+
"skill3",
2178+
]
2179+
2180+
# Reload skill1 — should move to end
2181+
await tool.run_async(
2182+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
2183+
)
2184+
call_args = tool_context_instance.state.__setitem__.call_args
2185+
assert call_args[0][1] == ["skill2", "skill3", "skill1"]
2186+
2187+
2188+
@pytest.mark.asyncio
2189+
async def test_no_max_activated_skills_unlimited(
2190+
tool_context_instance,
2191+
):
2192+
"""Without max_activated_skills, all skills are retained (no eviction)."""
2193+
skills = []
2194+
for i in range(10):
2195+
skill = mock.create_autospec(models.Skill, instance=True)
2196+
skill.name = f"skill_{i}"
2197+
skill.instructions = f"instructions for skill_{i}"
2198+
frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
2199+
frontmatter.name = f"skill_{i}"
2200+
frontmatter.metadata = {}
2201+
frontmatter.model_dump.return_value = {"name": f"skill_{i}"}
2202+
skill.frontmatter = frontmatter
2203+
skills.append(skill)
2204+
2205+
toolset = skill_toolset.SkillToolset(skills) # No max_activated_skills
2206+
tool = skill_toolset.LoadSkillTool(toolset)
2207+
2208+
state_key = "_adk_activated_skill_test_agent"
2209+
2210+
current_state = []
2211+
for i in range(10):
2212+
tool_context_instance.state.get.return_value = list(current_state)
2213+
await tool.run_async(
2214+
args={"skill_name": f"skill_{i}"}, tool_context=tool_context_instance
2215+
)
2216+
call_args = tool_context_instance.state.__setitem__.call_args
2217+
current_state = call_args[0][1]
2218+
2219+
# All 10 skills should be retained
2220+
assert len(current_state) == 10
2221+
assert current_state == [f"skill_{i}" for i in range(10)]
2222+
2223+
2224+
@pytest.mark.asyncio
2225+
async def test_evicted_skill_tools_not_resolved():
2226+
"""After a skill is evicted, its additional tools are no longer resolved."""
2227+
# Create two skills, each declaring different additional tools
2228+
skill_a = mock.create_autospec(models.Skill, instance=True)
2229+
skill_a.name = "skill_a"
2230+
skill_a.instructions = "instructions A"
2231+
frontmatter_a = mock.create_autospec(models.Frontmatter, instance=True)
2232+
frontmatter_a.name = "skill_a"
2233+
frontmatter_a.metadata = {"adk_additional_tools": ["tool_alpha"]}
2234+
frontmatter_a.model_dump.return_value = {"name": "skill_a"}
2235+
skill_a.frontmatter = frontmatter_a
2236+
2237+
skill_b = mock.create_autospec(models.Skill, instance=True)
2238+
skill_b.name = "skill_b"
2239+
skill_b.instructions = "instructions B"
2240+
frontmatter_b = mock.create_autospec(models.Frontmatter, instance=True)
2241+
frontmatter_b.name = "skill_b"
2242+
frontmatter_b.metadata = {"adk_additional_tools": ["tool_beta"]}
2243+
frontmatter_b.model_dump.return_value = {"name": "skill_b"}
2244+
skill_b.frontmatter = frontmatter_b
2245+
2246+
# Create mock additional tools
2247+
tool_alpha = mock.create_autospec(skill_toolset.BaseTool, instance=True)
2248+
tool_alpha.name = "tool_alpha"
2249+
tool_beta = mock.create_autospec(skill_toolset.BaseTool, instance=True)
2250+
tool_beta.name = "tool_beta"
2251+
2252+
toolset = skill_toolset.SkillToolset(
2253+
[skill_a, skill_b],
2254+
additional_tools=[tool_alpha, tool_beta],
2255+
max_activated_skills=1,
2256+
)
2257+
2258+
# Simulate: skill_a was activated, then skill_b was activated (evicting A)
2259+
readonly_context = mock.create_autospec(ReadonlyContext, instance=True)
2260+
readonly_context.agent_name = "test_agent"
2261+
readonly_context.invocation_id = "inv-1"
2262+
# After eviction, only skill_b is in state
2263+
readonly_context.state.get.return_value = ["skill_b"]
2264+
2265+
tools = await toolset.get_tools(readonly_context=readonly_context)
2266+
tool_names = {t.name for t in tools}
2267+
2268+
# tool_beta should be present (skill_b is active)
2269+
assert "tool_beta" in tool_names
2270+
# tool_alpha should NOT be present (skill_a was evicted)
2271+
assert "tool_alpha" not in tool_names
2272+
2273+
2274+
@pytest.mark.asyncio
2275+
async def test_evicted_tool_call_raises_value_error():
2276+
"""Simulates the LLM calling a tool from an evicted skill.
2277+
2278+
This mirrors what happens in the real flow: after skill eviction, the
2279+
tool is removed from tools_dict, so _get_tool raises ValueError.
2280+
This test validates that the tools_dict correctly excludes evicted tools.
2281+
"""
2282+
# Skill with additional tool
2283+
skill_a = mock.create_autospec(models.Skill, instance=True)
2284+
skill_a.name = "skill_a"
2285+
skill_a.instructions = "instructions A"
2286+
frontmatter_a = mock.create_autospec(models.Frontmatter, instance=True)
2287+
frontmatter_a.name = "skill_a"
2288+
frontmatter_a.metadata = {"adk_additional_tools": ["expensive_api_tool"]}
2289+
frontmatter_a.model_dump.return_value = {"name": "skill_a"}
2290+
skill_a.frontmatter = frontmatter_a
2291+
2292+
skill_b = mock.create_autospec(models.Skill, instance=True)
2293+
skill_b.name = "skill_b"
2294+
skill_b.instructions = "instructions B"
2295+
frontmatter_b = mock.create_autospec(models.Frontmatter, instance=True)
2296+
frontmatter_b.name = "skill_b"
2297+
frontmatter_b.metadata = {}
2298+
frontmatter_b.model_dump.return_value = {"name": "skill_b"}
2299+
skill_b.frontmatter = frontmatter_b
2300+
2301+
expensive_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
2302+
expensive_tool.name = "expensive_api_tool"
2303+
2304+
toolset = skill_toolset.SkillToolset(
2305+
[skill_a, skill_b],
2306+
additional_tools=[expensive_tool],
2307+
max_activated_skills=1,
2308+
)
2309+
2310+
# After eviction: only skill_b remains
2311+
readonly_context = mock.create_autospec(ReadonlyContext, instance=True)
2312+
readonly_context.agent_name = "test_agent"
2313+
readonly_context.invocation_id = "inv-2"
2314+
readonly_context.state.get.return_value = ["skill_b"]
2315+
2316+
tools = await toolset.get_tools(readonly_context=readonly_context)
2317+
tools_dict = {t.name: t for t in tools}
2318+
2319+
# The LLM might still try to call "expensive_api_tool" from history
2320+
assert "expensive_api_tool" not in tools_dict
2321+
2322+
# Simulate what _get_tool in functions.py would do
2323+
from google.genai import types as genai_types
2324+
2325+
fake_function_call = mock.MagicMock()
2326+
fake_function_call.name = "expensive_api_tool"
2327+
2328+
# This mirrors the behavior in flows/llm_flows/functions.py:_get_tool
2329+
with pytest.raises(ValueError, match="Tool 'expensive_api_tool' not found"):
2330+
from google.adk.flows.llm_flows.functions import _get_tool
2331+
2332+
_get_tool(fake_function_call, tools_dict)
2333+
2334+
2335+
@pytest.mark.asyncio
2336+
async def test_rolling_window_full_lifecycle_with_tool_resolution():
2337+
"""End-to-end: load skills, evict, verify tool availability at each step."""
2338+
skills = []
2339+
additional_tools = []
2340+
for i in range(4):
2341+
skill = mock.create_autospec(models.Skill, instance=True)
2342+
skill.name = f"skill_{i}"
2343+
skill.instructions = f"instructions {i}"
2344+
frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
2345+
frontmatter.name = f"skill_{i}"
2346+
frontmatter.metadata = {"adk_additional_tools": [f"tool_{i}"]}
2347+
frontmatter.model_dump.return_value = {"name": f"skill_{i}"}
2348+
skill.frontmatter = frontmatter
2349+
skills.append(skill)
2350+
2351+
tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
2352+
tool.name = f"tool_{i}"
2353+
additional_tools.append(tool)
2354+
2355+
toolset = skill_toolset.SkillToolset(
2356+
skills,
2357+
additional_tools=additional_tools,
2358+
max_activated_skills=2,
2359+
)
2360+
2361+
load_tool = skill_toolset.LoadSkillTool(toolset)
2362+
ctx = mock.create_autospec(skill_toolset.ToolContext, instance=True)
2363+
ctx._invocation_context = mock.MagicMock()
2364+
ctx.agent_name = "test_agent"
2365+
ctx.invocation_id = "inv-1"
2366+
2367+
readonly_context = mock.create_autospec(ReadonlyContext, instance=True)
2368+
readonly_context.agent_name = "test_agent"
2369+
readonly_context.invocation_id = "inv-1"
2370+
2371+
# Step 1: Load skill_0 and skill_1
2372+
current_state = []
2373+
for i in range(2):
2374+
ctx.state.get.return_value = list(current_state)
2375+
await load_tool.run_async(
2376+
args={"skill_name": f"skill_{i}"}, tool_context=ctx
2377+
)
2378+
current_state = ctx.state.__setitem__.call_args[0][1]
2379+
2380+
assert current_state == ["skill_0", "skill_1"]
2381+
2382+
readonly_context.state.get.return_value = list(current_state)
2383+
tools = await toolset.get_tools(readonly_context=readonly_context)
2384+
tool_names = {t.name for t in tools}
2385+
assert "tool_0" in tool_names
2386+
assert "tool_1" in tool_names
2387+
assert "tool_2" not in tool_names
2388+
2389+
# Step 2: Load skill_2 — should evict skill_0
2390+
ctx.state.get.return_value = list(current_state)
2391+
await load_tool.run_async(args={"skill_name": "skill_2"}, tool_context=ctx)
2392+
current_state = ctx.state.__setitem__.call_args[0][1]
2393+
2394+
assert current_state == ["skill_1", "skill_2"]
2395+
2396+
readonly_context.state.get.return_value = list(current_state)
2397+
tools = await toolset.get_tools(readonly_context=readonly_context)
2398+
tool_names = {t.name for t in tools}
2399+
assert "tool_0" not in tool_names # evicted!
2400+
assert "tool_1" in tool_names
2401+
assert "tool_2" in tool_names
2402+
2403+
# Step 3: Load skill_3 — should evict skill_1
2404+
ctx.state.get.return_value = list(current_state)
2405+
await load_tool.run_async(args={"skill_name": "skill_3"}, tool_context=ctx)
2406+
current_state = ctx.state.__setitem__.call_args[0][1]
2407+
2408+
assert current_state == ["skill_2", "skill_3"]
2409+
2410+
readonly_context.state.get.return_value = list(current_state)
2411+
tools = await toolset.get_tools(readonly_context=readonly_context)
2412+
tool_names = {t.name for t in tools}
2413+
assert "tool_0" not in tool_names
2414+
assert "tool_1" not in tool_names # evicted!
2415+
assert "tool_2" in tool_names
2416+
assert "tool_3" in tool_names
2417+
2418+
20802419
@pytest.mark.asyncio
20812420
async def test_process_llm_request_with_tool_name_prefix(
20822421
mock_skill1, mock_skill2, tool_context_instance, mock_registry

0 commit comments

Comments
 (0)