@@ -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
20812420async def test_process_llm_request_with_tool_name_prefix (
20822421 mock_skill1 , mock_skill2 , tool_context_instance , mock_registry
0 commit comments