# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import asyncio import collections import logging import sys from unittest import mock from google.adk.agents.readonly_context import ReadonlyContext from google.adk.code_executors.base_code_executor import BaseCodeExecutor from google.adk.code_executors.code_execution_utils import CodeExecutionResult from google.adk.code_executors.unsafe_local_code_executor import UnsafeLocalCodeExecutor from google.adk.models import llm_request as llm_request_model from google.adk.skills import models from google.adk.tools import skill_toolset from google.adk.tools import tool_context from google.genai import types import pytest @pytest.fixture(name="mock_skill1_frontmatter") def _mock_skill1_frontmatter(): """Fixture for skill1 frontmatter.""" frontmatter = mock.create_autospec(models.Frontmatter, instance=True) frontmatter.name = "skill1" frontmatter.description = "Skill 1 description" frontmatter.allowed_tools = ["test_tool"] frontmatter.metadata = {} frontmatter.model_dump.return_value = { "name": "skill1", "description": "Skill 1 description", } return frontmatter @pytest.fixture(name="mock_skill1") def _mock_skill1(mock_skill1_frontmatter): """Fixture for skill1.""" skill = mock.create_autospec(models.Skill, instance=True) skill.name = "skill1" skill.description = "Skill 1 description" skill.instructions = "instructions for skill1" skill.frontmatter = mock_skill1_frontmatter skill.resources = mock.MagicMock( spec=[ "get_reference", "get_asset", "get_script", "list_references", "list_assets", "list_scripts", ] ) def get_ref(name): if name == "ref1.md": return "ref content 1" if name == "doc.pdf": return b"fake pdf content" return None def get_asset(name): if name == "asset1.txt": return "asset content 1" if name == "image.png": return b"fake image content" return None def get_script(name): if name == "setup.sh": return models.Script(src="echo setup") if name == "run.py": return models.Script(src="print('hello')") if name == "build.rb": return models.Script(src="puts 'hello'") return None skill.resources.get_reference.side_effect = get_ref skill.resources.get_asset.side_effect = get_asset skill.resources.get_script.side_effect = get_script skill.resources.list_references.return_value = ["ref1.md", "doc.pdf"] skill.resources.list_assets.return_value = ["asset1.txt", "image.png"] skill.resources.list_scripts.return_value = [ "setup.sh", "run.py", "build.rb", ] return skill @pytest.fixture(name="mock_skill2_frontmatter") def _mock_skill2_frontmatter(): """Fixture for skill2 frontmatter.""" frontmatter = mock.create_autospec(models.Frontmatter, instance=True) frontmatter.name = "skill2" frontmatter.description = "Skill 2 description" frontmatter.allowed_tools = [] frontmatter.metadata = {} frontmatter.model_dump.return_value = { "name": "skill2", "description": "Skill 2 description", } return frontmatter @pytest.fixture(name="mock_skill2") def _mock_skill2(mock_skill2_frontmatter): """Fixture for skill2.""" skill = mock.create_autospec(models.Skill, instance=True) skill.name = "skill2" skill.description = "Skill 2 description" skill.instructions = "instructions for skill2" skill.frontmatter = mock_skill2_frontmatter skill.resources = mock.MagicMock( spec=[ "get_reference", "get_asset", "get_script", "list_references", "list_assets", "list_scripts", ] ) def get_ref(name): if name == "ref2.md": return "ref content 2" return None def get_asset(name): if name == "asset2.txt": return "asset content 2" return None skill.resources.get_reference.side_effect = get_ref skill.resources.get_asset.side_effect = get_asset skill.resources.list_references.return_value = ["ref2.md"] skill.resources.list_assets.return_value = ["asset2.txt"] skill.resources.list_scripts.return_value = [] return skill @pytest.fixture def tool_context_instance(): """Fixture for tool context.""" ctx = mock.create_autospec(tool_context.ToolContext, instance=True) ctx._invocation_context = mock.MagicMock() ctx._invocation_context.agent = mock.MagicMock() ctx._invocation_context.agent.name = "test_agent" ctx._invocation_context.agent_states = {} ctx.agent_name = "test_agent" return ctx # SkillToolset tests def test_get_skill(mock_skill1, mock_skill2): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) assert toolset._get_skill("skill1") == mock_skill1 assert toolset._get_skill("nonexistent") is None def test_list_skills(mock_skill1, mock_skill2): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) skills = toolset._list_skills() assert len(skills) == 2 assert mock_skill1 in skills assert mock_skill2 in skills def test_clone_with_updated_skills(mock_skill1, mock_skill2): """Tests that the skills are updated but other properties are retained.""" mock_skill3 = mock.create_autospec(models.Skill, instance=True) mock_skill3.name = "skill3" mock_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) mock_tool.name = "my_tool" registry = mock.create_autospec(skill_toolset.SkillRegistry, instance=True) executor = _make_mock_executor() toolset = skill_toolset.SkillToolset( [mock_skill1, mock_skill2], registry=registry, code_executor=executor, script_timeout=42, additional_tools=[mock_tool], ) new_toolset = toolset.clone_with_updated_skills([mock_skill3]) # Verify new skill is present and old ones are gone skills = new_toolset._list_skills() assert len(skills) == 1 assert skills[0] == mock_skill3 # Verify properties are retained assert new_toolset._registry is registry assert new_toolset._code_executor is executor assert new_toolset._script_timeout == 42 assert "my_tool" in new_toolset._provided_tools_by_name @pytest.mark.asyncio async def test_get_tools(mock_skill1, mock_skill2): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) tools = await toolset.get_tools() assert len(tools) == 4 assert isinstance(tools[0], skill_toolset.ListSkillsTool) assert isinstance(tools[1], skill_toolset.LoadSkillTool) assert isinstance(tools[2], skill_toolset.LoadSkillResourceTool) assert isinstance(tools[3], skill_toolset.RunSkillScriptTool) @pytest.mark.asyncio async def test_resolve_additional_tools_from_state_none(mock_skill1): toolset = skill_toolset.SkillToolset([mock_skill1]) # Mock ReadonlyContext readonly_context = mock.create_autospec(ReadonlyContext, instance=True) readonly_context.agent_name = "test_agent" readonly_context.state.get.return_value = None result = await toolset._resolve_additional_tools_from_state(readonly_context) assert not result @pytest.mark.asyncio async def test_list_skills_tool( mock_skill1, mock_skill2, tool_context_instance ): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) tool = skill_toolset.ListSkillsTool(toolset) result = await tool.run_async(args={}, tool_context=tool_context_instance) assert "" in result assert "skill1" in result assert "skill2" in result @pytest.mark.asyncio @pytest.mark.parametrize( "args, expected_result", [ ( {"skill_name": "skill1"}, { "skill_name": "skill1", "instructions": "instructions for skill1", "frontmatter": { "name": "skill1", "description": "Skill 1 description", }, }, ), ( {"skill_name": "nonexistent"}, { "error": "Skill 'nonexistent' not found.", "error_code": "SKILL_NOT_FOUND", }, ), ( {}, { "error": "Argument 'skill_name' is required.", "error_code": "INVALID_ARGUMENTS", }, ), ], ) async def test_load_skill_run_async( mock_skill1, tool_context_instance, args, expected_result ): toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillTool(toolset) result = await tool.run_async(args=args, tool_context=tool_context_instance) assert result == expected_result @pytest.mark.asyncio async def test_load_skill_run_async_state_none( mock_skill1, tool_context_instance ): toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillTool(toolset) # Mock state to return None for the key state_key = "_adk_activated_skill_test_agent" tool_context_instance.state.get.return_value = None result = await tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) assert result["skill_name"] == "skill1" tool_context_instance.state.__setitem__.assert_called_with( state_key, ["skill1"] ) @pytest.mark.asyncio async def test_load_skill_run_async_injects_state_when_opt_in( mock_skill1, mock_skill1_frontmatter, tool_context_instance ): mock_skill1.instructions = "Hello {user_name}!" mock_skill1_frontmatter.metadata = {"adk_inject_state": True} toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillTool(toolset) with mock.patch.object( skill_toolset.instructions_utils, "inject_session_state", autospec=True, ) as mock_inject: mock_inject.return_value = "Hello Alice!" result = await tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) mock_inject.assert_awaited_once() call_args = mock_inject.await_args assert call_args.args[0] == "Hello {user_name}!" assert result["instructions"] == "Hello Alice!" @pytest.mark.asyncio async def test_load_skill_run_async_skips_injection_when_opt_out( mock_skill1, mock_skill1_frontmatter, tool_context_instance ): mock_skill1.instructions = "Hello {user_name}!" mock_skill1_frontmatter.metadata = {"adk_inject_state": False} toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillTool(toolset) with mock.patch.object( skill_toolset.instructions_utils, "inject_session_state", autospec=True, ) as mock_inject: result = await tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) mock_inject.assert_not_called() assert result["instructions"] == "Hello {user_name}!" @pytest.mark.asyncio async def test_load_skill_run_async_skips_injection_when_metadata_absent( mock_skill1, tool_context_instance ): mock_skill1.instructions = "Hello {user_name}!" toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillTool(toolset) with mock.patch.object( skill_toolset.instructions_utils, "inject_session_state", autospec=True, ) as mock_inject: result = await tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) mock_inject.assert_not_called() assert result["instructions"] == "Hello {user_name}!" @pytest.mark.asyncio @pytest.mark.parametrize( "args, expected_result", [ ( {"skill_name": "skill1", "file_path": "references/ref1.md"}, { "skill_name": "skill1", "file_path": "references/ref1.md", "content": "ref content 1", }, ), ( {"skill_name": "skill1", "file_path": "assets/asset1.txt"}, { "skill_name": "skill1", "file_path": "assets/asset1.txt", "content": "asset content 1", }, ), ( {"skill_name": "skill1", "file_path": "references/doc.pdf"}, { "skill_name": "skill1", "file_path": "references/doc.pdf", "status": ( "Binary file detected. The content has been injected into" " the conversation history for you to analyze." ), }, ), ( {"skill_name": "skill1", "file_path": "assets/image.png"}, { "skill_name": "skill1", "file_path": "assets/image.png", "status": ( "Binary file detected. The content has been injected into" " the conversation history for you to analyze." ), }, ), ( {"skill_name": "skill1", "file_path": "scripts/setup.sh"}, { "skill_name": "skill1", "file_path": "scripts/setup.sh", "content": "echo setup", }, ), ( {"skill_name": "nonexistent", "file_path": "references/ref1.md"}, { "error": "Skill 'nonexistent' not found.", "error_code": "SKILL_NOT_FOUND", }, ), # RESOURCE_NOT_FOUND is tested separately in # test_load_resource_first_missing_returns_soft_error because the # counter guard requires a real state dict (mock state.get() returns a # truthy MagicMock that int() coerces to 1, skipping the soft path). ( {"skill_name": "skill1", "file_path": "invalid/path.txt"}, { "error": ( "Path must start with 'references/', 'assets/'," " or 'scripts/'." ), "error_code": "INVALID_RESOURCE_PATH", }, ), ( {"file_path": "references/ref1.md"}, { "error": "Argument 'skill_name' is required.", "error_code": "INVALID_ARGUMENTS", }, ), ( {"skill_name": "skill1"}, { "error": "Argument 'file_path' is required.", "error_code": "INVALID_ARGUMENTS", }, ), ], ) async def test_load_resource_run_async( mock_skill1, tool_context_instance, args, expected_result ): toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) result = await tool.run_async(args=args, tool_context=tool_context_instance) assert result == expected_result @pytest.mark.asyncio @pytest.mark.parametrize( "resource_path, expected_mime, fake_content", [ ("references/doc.pdf", "application/pdf", b"fake pdf content"), ("assets/image.png", "image/png", b"fake image content"), ], ) async def test_load_resource_process_llm_request_binary( mock_skill1, tool_context_instance, resource_path, expected_mime, fake_content, ): toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True) part = types.Part.from_function_response( name=tool.name, response={ "skill_name": "skill1", "file_path": resource_path, "status": ( "Binary file detected. The content has been injected into the" " conversation history for you to analyze." ), }, ) content = types.Content(role="model", parts=[part]) llm_req.contents = [content] await tool.process_llm_request( tool_context=tool_context_instance, llm_request=llm_req ) assert len(llm_req.contents) == 2 injected_content = llm_req.contents[1] assert injected_content.role == "user" assert len(injected_content.parts) == 2 assert ( f"The content of binary file '{resource_path}' is:" in injected_content.parts[0].text ) assert injected_content.parts[1].inline_data.data == fake_content assert injected_content.parts[1].inline_data.mime_type == expected_mime @pytest.mark.asyncio async def test_process_llm_request_with_list_skills_tool( mock_skill1, mock_skill2, tool_context_instance ): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True) await toolset.process_llm_request( tool_context=tool_context_instance, llm_request=llm_req ) llm_req.append_instructions.assert_called_once_with( [skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION] ) @pytest.mark.asyncio async def test_process_llm_request_without_list_skills_tool( mock_skill1, mock_skill2, tool_context_instance ): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) # Manually remove ListSkillsTool from self._tools to simulate it not being available toolset._tools = [ t for t in toolset._tools if not isinstance(t, skill_toolset.ListSkillsTool) ] llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True) await toolset.process_llm_request( tool_context=tool_context_instance, llm_request=llm_req ) llm_req.append_instructions.assert_called_once() args, _ = llm_req.append_instructions.call_args instructions = args[0] assert len(instructions) == 2 assert instructions[0] == skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION assert "" in instructions[1] assert "skill1" in instructions[1] assert "skill2" in instructions[1] def test_duplicate_skill_name_raises(mock_skill1): skill_dup = mock.create_autospec(models.Skill, instance=True) skill_dup.name = "skill1" with pytest.raises(ValueError, match="Duplicate skill name"): skill_toolset.SkillToolset([mock_skill1, skill_dup]) @pytest.mark.asyncio async def test_scripts_resource_not_found(mock_skill1): toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/nonexistent.sh"}, tool_context=ctx, ) assert result["error_code"] == "RESOURCE_NOT_FOUND" @pytest.mark.asyncio async def test_load_resource_first_missing_returns_soft_error(mock_skill1): """First RESOURCE_NOT_FOUND in an invocation returns the soft error code.""" toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "references/other.md"}, tool_context=ctx, ) assert result["error_code"] == "RESOURCE_NOT_FOUND" assert result["error"] == ( "Resource 'references/other.md' not found in skill 'skill1'." ) @pytest.mark.asyncio async def test_load_resource_repeated_failure_escalates_to_fatal(mock_skill1): """Any second RESOURCE_NOT_FOUND within an invocation returns RESOURCE_NOT_FOUND_FATAL.""" toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) ctx = _make_tool_context_with_agent() args = {"skill_name": "skill1", "file_path": "references/nonexistent.md"} result1 = await tool.run_async(args=args, tool_context=ctx) assert result1["error_code"] == "RESOURCE_NOT_FOUND" result2 = await tool.run_async(args=args, tool_context=ctx) assert result2["error_code"] == "RESOURCE_NOT_FOUND_FATAL" assert "Do not retry" in result2["error"] assert "stop" in result2["error"].lower() assert "failure #2" in result2["error"] @pytest.mark.asyncio async def test_load_resource_different_path_also_escalates_to_fatal( mock_skill1, ): """A different missing path on the second call still escalates to RESOURCE_NOT_FOUND_FATAL. The counter is path-agnostic: any second not-found within the same invocation is fatal, even when the LLM hallucinates a different path on each retry. """ toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) ctx = _make_tool_context_with_agent() result1 = await tool.run_async( args={"skill_name": "skill1", "file_path": "references/missing_a.md"}, tool_context=ctx, ) assert result1["error_code"] == "RESOURCE_NOT_FOUND" result2 = await tool.run_async( args={"skill_name": "skill1", "file_path": "references/missing_b.md"}, tool_context=ctx, ) assert result2["error_code"] == "RESOURCE_NOT_FOUND_FATAL" assert "Do not retry" in result2["error"] @pytest.mark.asyncio async def test_load_resource_failures_isolated_per_invocation(mock_skill1): """Failure counter does not leak across invocations. A RESOURCE_NOT_FOUND in invocation A must not increment invocation B's counter; invocation B's first missing-resource call must still return the soft error, even when both invocations share the same session state dict. """ toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) shared_state = {} ctx_a = _make_tool_context_with_agent(invocation_id="inv_a") ctx_a.state = shared_state ctx_b = _make_tool_context_with_agent(invocation_id="inv_b") ctx_b.state = shared_state # invocation A: one failure — counter for inv_a reaches 1 (soft). result_a = await tool.run_async( args={"skill_name": "skill1", "file_path": "references/typo.md"}, tool_context=ctx_a, ) assert result_a["error_code"] == "RESOURCE_NOT_FOUND" # invocation B, first attempt (different path) — counter for inv_b = 1 (soft). result_b1 = await tool.run_async( args={"skill_name": "skill1", "file_path": "references/typo.md"}, tool_context=ctx_b, ) assert result_b1["error_code"] == "RESOURCE_NOT_FOUND" # invocation B, second attempt (different path) — counter for inv_b = 2 (fatal). result_b2 = await tool.run_async( args={"skill_name": "skill1", "file_path": "references/other.md"}, tool_context=ctx_b, ) assert result_b2["error_code"] == "RESOURCE_NOT_FOUND_FATAL" @pytest.mark.asyncio async def test_load_resource_counter_uses_temp_prefix(mock_skill1): """Failure-counter key uses the `temp:` prefix so it is not persisted.""" toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.LoadSkillResourceTool(toolset) ctx = _make_tool_context_with_agent() await tool.run_async( args={"skill_name": "skill1", "file_path": "references/missing.md"}, tool_context=ctx, ) # The counter key must start with `temp:` so it is trimmed from the event # delta and never reaches durable storage. guard_keys = [k for k in ctx.state if "skill_resource_not_found_count" in k] assert guard_keys, "Failure counter did not write a tracking key." assert all(k.startswith("temp:") for k in guard_keys) # RunSkillScriptTool tests def _make_tool_context_with_agent(agent=None, invocation_id="test_invocation"): """Creates a mock ToolContext with _invocation_context.agent.""" ctx = mock.MagicMock(spec=tool_context.ToolContext) ctx._invocation_context = mock.MagicMock() ctx._invocation_context.agent = agent or mock.MagicMock() ctx._invocation_context.agent.name = "test_agent" ctx._invocation_context.agent_states = {} ctx.agent_name = "test_agent" ctx.invocation_id = invocation_id ctx.state = {} return ctx def _make_mock_executor(stdout="", stderr=""): """Creates a mock code executor that returns the given output.""" executor = mock.create_autospec(BaseCodeExecutor, instance=True) executor.execute_code.return_value = CodeExecutionResult( stdout=stdout, stderr=stderr ) return executor @pytest.mark.asyncio @pytest.mark.parametrize( "args, expected_error_code", [ ( {"file_path": "setup.sh"}, "INVALID_ARGUMENTS", ), ( {"skill_name": "skill1"}, "INVALID_ARGUMENTS", ), ( {"skill_name": "", "file_path": "setup.sh"}, "INVALID_ARGUMENTS", ), ( {"skill_name": "skill1", "file_path": ""}, "INVALID_ARGUMENTS", ), ], ) async def test_execute_script_missing_params( mock_skill1, args, expected_error_code ): executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async(args=args, tool_context=ctx) assert result["error_code"] == expected_error_code @pytest.mark.asyncio async def test_execute_script_skill_not_found(mock_skill1): executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "nonexistent", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["error_code"] == "SKILL_NOT_FOUND" @pytest.mark.asyncio async def test_execute_script_script_not_found(mock_skill1): executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "nonexistent.py"}, tool_context=ctx, ) assert result["error_code"] == "SCRIPT_NOT_FOUND" assert result["error"] == ( "Script 'nonexistent.py' not found in skill 'skill1'." ) @pytest.mark.asyncio async def test_execute_script_repeated_failure_escalates_to_fatal(mock_skill1): """Any second SCRIPT_NOT_FOUND within an invocation returns SCRIPT_NOT_FOUND_FATAL.""" executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() args = {"skill_name": "skill1", "file_path": "scripts/nonexistent.py"} result1 = await tool.run_async(args=args, tool_context=ctx) assert result1["error_code"] == "SCRIPT_NOT_FOUND" result2 = await tool.run_async(args=args, tool_context=ctx) assert result2["error_code"] == "SCRIPT_NOT_FOUND_FATAL" assert "Do not retry" in result2["error"] assert "stop" in result2["error"].lower() assert "failure #2" in result2["error"] @pytest.mark.asyncio async def test_execute_script_different_path_also_escalates_to_fatal( mock_skill1, ): """A different missing script on the second call still escalates to SCRIPT_NOT_FOUND_FATAL. The counter is path-agnostic: any second not-found within the same invocation is fatal, even when the LLM hallucinates a different script path on each retry. """ executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result1 = await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/missing_a.py"}, tool_context=ctx, ) assert result1["error_code"] == "SCRIPT_NOT_FOUND" result2 = await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/missing_b.py"}, tool_context=ctx, ) assert result2["error_code"] == "SCRIPT_NOT_FOUND_FATAL" assert "Do not retry" in result2["error"] @pytest.mark.asyncio async def test_execute_script_failures_isolated_per_invocation(mock_skill1): """Failure counter does not leak across invocations. A SCRIPT_NOT_FOUND in invocation A must not increment invocation B's counter; invocation B's first missing-script call must still return the soft error, even when both invocations share the same session state dict. """ executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) shared_state = {} ctx_a = _make_tool_context_with_agent(invocation_id="inv_a") ctx_a.state = shared_state ctx_b = _make_tool_context_with_agent(invocation_id="inv_b") ctx_b.state = shared_state # invocation A: one failure — counter for inv_a reaches 1 (soft). result_a = await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/typo.py"}, tool_context=ctx_a, ) assert result_a["error_code"] == "SCRIPT_NOT_FOUND" # invocation B, first attempt (same path) — counter for inv_b = 1 (soft). result_b1 = await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/typo.py"}, tool_context=ctx_b, ) assert result_b1["error_code"] == "SCRIPT_NOT_FOUND" # invocation B, second attempt (different path) — counter for inv_b = 2 (fatal). result_b2 = await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/other.py"}, tool_context=ctx_b, ) assert result_b2["error_code"] == "SCRIPT_NOT_FOUND_FATAL" @pytest.mark.asyncio async def test_execute_script_counter_uses_temp_prefix(mock_skill1): """Failure-counter key uses the `temp:` prefix so it is not persisted.""" executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() await tool.run_async( args={"skill_name": "skill1", "file_path": "scripts/missing.py"}, tool_context=ctx, ) # The counter key must start with `temp:` so it is trimmed from the event # delta and never reaches durable storage. guard_keys = [k for k in ctx.state if "skill_script_not_found_count" in k] assert guard_keys, "Failure counter did not write a tracking key." assert all(k.startswith("temp:") for k in guard_keys) @pytest.mark.asyncio async def test_execute_script_no_code_executor(mock_skill1): toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.RunSkillScriptTool(toolset) # Agent without code_executor attribute agent = mock.MagicMock(spec=[]) ctx = _make_tool_context_with_agent(agent=agent) result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["error_code"] == "NO_CODE_EXECUTOR" @pytest.mark.asyncio async def test_execute_script_agent_code_executor_none(mock_skill1): """Agent has code_executor attr but it's None.""" toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.RunSkillScriptTool(toolset) agent = mock.MagicMock() agent.code_executor = None ctx = _make_tool_context_with_agent(agent=agent) result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["error_code"] == "NO_CODE_EXECUTOR" @pytest.mark.asyncio async def test_execute_script_unsupported_type(mock_skill1): executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "build.rb"}, tool_context=ctx, ) assert result["error_code"] == "UNSUPPORTED_SCRIPT_TYPE" @pytest.mark.asyncio async def test_execute_script_python_success(mock_skill1): executor = _make_mock_executor(stdout="hello\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["status"] == "success" assert result["stdout"] == "hello\n" assert result["stderr"] == "" assert result["skill_name"] == "skill1" assert result["file_path"] == "run.py" # Verify the code passed to executor runs the python scripts call_args = executor.execute_code.call_args code_input = call_args[0][1] assert "_materialize_and_run()" in code_input.code assert "import runpy" in code_input.code assert "sys.argv = ['scripts/run.py']" in code_input.code assert ( "sys.path.insert(0, os.path.dirname(os.path.abspath('scripts/run.py')))" in code_input.code ) assert ( "runpy.run_path('scripts/run.py', run_name='__main__')" in code_input.code ) @pytest.mark.asyncio async def test_execute_script_shell_success(mock_skill1): executor = _make_mock_executor(stdout="setup\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "success" assert result["stdout"] == "setup\n" # Verify the code wraps in subprocess.run with JSON envelope call_args = executor.execute_code.call_args code_input = call_args[0][1] assert "subprocess.run" in code_input.code assert "bash" in code_input.code assert "__shell_result__" in code_input.code @pytest.mark.asyncio async def test_build_wrapper_code_with_unicode(mock_skill1): """Verify that generated code uses utf-8 encoding for materializing files.""" # Add unicode content to mock_skill1 resources unicode_content = "你好" mock_skill1.resources.list_references.return_value = ["unicode.txt"] mock_skill1.resources.get_reference.side_effect = lambda name: ( unicode_content if name == "unicode.txt" else None ) executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) call_args = executor.execute_code.call_args code_input = call_args[0][1] assert "encoding='utf-8' if mode == 'w' else None" in code_input.code assert unicode_content in code_input.code @pytest.mark.asyncio async def test_execute_script_with_input_args_python(mock_skill1): executor = _make_mock_executor(stdout="done\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "run.py", "args": {"verbose": True, "count": "3"}, }, tool_context=ctx, ) assert result["status"] == "success" call_args = executor.execute_code.call_args code_input = call_args[0][1] assert ( "['scripts/run.py', '--verbose', 'True', '--count', '3']" in code_input.code ) @pytest.mark.asyncio async def test_execute_script_with_input_args_shell(mock_skill1): executor = _make_mock_executor(stdout="done\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "setup.sh", "args": {"force": True}, }, tool_context=ctx, ) assert result["status"] == "success" call_args = executor.execute_code.call_args code_input = call_args[0][1] assert "['bash', 'scripts/setup.sh', '--force', 'True']" in code_input.code @pytest.mark.asyncio async def test_execute_script_with_list_args_python( mock_skill1, ): """Verifies that python scripts can be executed with list arguments.""" executor = _make_mock_executor(stdout="done\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "run.py", "args": ["--verbose", "True", "-n", "5", "input.txt"], }, tool_context=ctx, ) assert result["status"] == "success" call_args = executor.execute_code.call_args code_input = call_args[0][1] assert ( "['scripts/run.py', '--verbose', 'True', '-n', '5', 'input.txt']" in code_input.code ) @pytest.mark.asyncio async def test_execute_script_with_list_args_shell( mock_skill1, ): """Verifies that shell scripts can be executed with list arguments.""" executor = _make_mock_executor(stdout="done\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "setup.sh", "args": ["-n", "5", "input.txt"], }, tool_context=ctx, ) assert result["status"] == "success" call_args = executor.execute_code.call_args code_input = call_args[0][1] assert ( "['bash', 'scripts/setup.sh', '-n', '5', 'input.txt']" in code_input.code ) @pytest.mark.asyncio async def test_execute_script_with_list_args_rejects_others_python( mock_skill1, # pylint: disable=redefined-outer-name ): """Verifies that short_options and positional_args are rejected when args is a list for Python scripts.""" executor = _make_mock_executor(stdout="done\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "run.py", "args": ["arg1", "arg2"], "short_options": {"v": True}, "positional_args": ["pos1"], }, tool_context=ctx, ) assert result["error_code"] == "INVALID_ARGUMENTS" assert ( "Cannot specify 'short_options' or 'positional_args'" in result["error"] ) @pytest.mark.asyncio async def test_execute_script_with_list_args_rejects_others_shell( mock_skill1, # pylint: disable=redefined-outer-name ): """Verifies that short_options and positional_args are rejected when args is a list for shell scripts.""" executor = _make_mock_executor(stdout="done\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "setup.sh", "args": ["arg1", "arg2"], "short_options": {"v": True}, "positional_args": ["pos1"], }, tool_context=ctx, ) assert result["error_code"] == "INVALID_ARGUMENTS" assert ( "Cannot specify 'short_options' or 'positional_args'" in result["error"] ) @pytest.mark.asyncio async def test_execute_script_scripts_prefix_stripping(mock_skill1): executor = _make_mock_executor(stdout="setup\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "scripts/setup.sh", }, tool_context=ctx, ) assert result["status"] == "success" assert result["file_path"] == "scripts/setup.sh" @pytest.mark.asyncio async def test_execute_script_toolset_executor_priority(mock_skill1): """Toolset-level executor takes priority over agent's.""" toolset_executor = _make_mock_executor(stdout="from toolset\n") agent_executor = _make_mock_executor(stdout="from agent\n") toolset = skill_toolset.SkillToolset( [mock_skill1], code_executor=toolset_executor ) tool = skill_toolset.RunSkillScriptTool(toolset) agent = mock.MagicMock() agent.code_executor = agent_executor ctx = _make_tool_context_with_agent(agent=agent) result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["stdout"] == "from toolset\n" toolset_executor.execute_code.assert_called_once() agent_executor.execute_code.assert_not_called() @pytest.mark.asyncio async def test_execute_script_agent_executor_fallback(mock_skill1): """Falls back to agent's code executor when toolset has none.""" agent_executor = _make_mock_executor(stdout="from agent\n") toolset = skill_toolset.SkillToolset([mock_skill1]) tool = skill_toolset.RunSkillScriptTool(toolset) agent = mock.MagicMock() agent.code_executor = agent_executor ctx = _make_tool_context_with_agent(agent=agent) result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["stdout"] == "from agent\n" agent_executor.execute_code.assert_called_once() @pytest.mark.asyncio async def test_execute_script_execution_error(mock_skill1): executor = _make_mock_executor() executor.execute_code.side_effect = RuntimeError("boom") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["error_code"] == "EXECUTION_ERROR" assert "boom" in result["error"] assert result["error"].startswith("Failed to execute script 'run.py':") @pytest.mark.asyncio async def test_execute_script_stderr_only_sets_error_status(mock_skill1): """stderr with no stdout should report error status.""" executor = _make_mock_executor(stdout="", stderr="fatal error\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["status"] == "error" assert result["stderr"] == "fatal error\n" @pytest.mark.asyncio async def test_execute_script_stderr_with_stdout_sets_warning(mock_skill1): """stderr alongside stdout should report warning status.""" executor = _make_mock_executor(stdout="output\n", stderr="deprecation\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["status"] == "warning" assert result["stdout"] == "output\n" assert result["stderr"] == "deprecation\n" @pytest.mark.asyncio async def test_execute_script_execution_error_truncated(mock_skill1): """Long exception messages are truncated to avoid wasting LLM tokens.""" executor = _make_mock_executor() executor.execute_code.side_effect = RuntimeError("x" * 300) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["error_code"] == "EXECUTION_ERROR" # 200 chars of the message + "..." suffix + the prefix assert result["error"].endswith("...") assert len(result["error"]) < 300 @pytest.mark.asyncio async def test_execute_script_system_exit_caught(mock_skill1): """sys.exit() in a script should not terminate the process.""" executor = _make_mock_executor() executor.execute_code.side_effect = SystemExit(1) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["error_code"] == "EXECUTION_ERROR" assert "exited with code 1" in result["error"] @pytest.mark.asyncio async def test_execute_script_system_exit_zero_is_success(mock_skill1): """sys.exit(0) is a normal termination and should report success.""" executor = _make_mock_executor() executor.execute_code.side_effect = SystemExit(0) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["status"] == "success" @pytest.mark.asyncio async def test_execute_script_system_exit_none_is_success(mock_skill1): """sys.exit() with no arg (None) should report success.""" executor = _make_mock_executor() executor.execute_code.side_effect = SystemExit(None) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) assert result["status"] == "success" @pytest.mark.asyncio async def test_execute_script_shell_includes_timeout(mock_skill1): """Shell wrapper includes timeout in subprocess.run.""" executor = _make_mock_executor(stdout="ok\n") toolset = skill_toolset.SkillToolset( [mock_skill1], code_executor=executor, script_timeout=60 ) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "success" call_args = executor.execute_code.call_args code_input = call_args[0][1] assert "timeout=60" in code_input.code @pytest.mark.asyncio async def test_execute_script_extensionless_unsupported(mock_skill1): """Files without extensions should return UNSUPPORTED_SCRIPT_TYPE.""" # Add a script with no extension to the mock original_side_effect = mock_skill1.resources.get_script.side_effect def get_script_extended(name): if name == "noext": return models.Script(src="print('hi')") return original_side_effect(name) mock_skill1.resources.get_script.side_effect = get_script_extended executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "noext"}, tool_context=ctx, ) assert result["error_code"] == "UNSUPPORTED_SCRIPT_TYPE" # ── Integration tests using real UnsafeLocalCodeExecutor ── def _make_skill_with_script( skill_name: str, script_name: str, script: models.Script ) -> models.Skill: """Creates a minimal mock Skill with a single script.""" return _make_skill_with_scripts(skill_name, {script_name: script}) def _make_skill_with_scripts( skill_name: str, scripts: dict[str, models.Script] ) -> models.Skill: """Creates a minimal mock Skill with scripts.""" skill = mock.create_autospec(models.Skill, instance=True) skill.name = skill_name skill.description = f"Test skill {skill_name}" skill.instructions = "test instructions" fm = mock.create_autospec(models.Frontmatter, instance=True) fm.name = skill_name fm.description = f"Test skill {skill_name}" skill.frontmatter = fm skill.resources = mock.MagicMock( spec=[ "get_reference", "get_asset", "get_script", "list_references", "list_assets", "list_scripts", ] ) def get_script(name): return scripts.get(name) skill.resources.get_script.side_effect = get_script skill.resources.get_reference.return_value = None skill.resources.get_asset.return_value = None skill.resources.list_references.return_value = [] skill.resources.list_assets.return_value = [] skill.resources.list_scripts.return_value = list(scripts) return skill def _make_real_executor_toolset(skills, **kwargs): """Creates a SkillToolset with a real UnsafeLocalCodeExecutor.""" if sys.executable is None: sys.executable = "/usr/bin/python3" executor = UnsafeLocalCodeExecutor(timeout_seconds=60) return skill_toolset.SkillToolset(skills, code_executor=executor, **kwargs) @pytest.mark.asyncio async def test_integration_python_stdout(): """Real executor: Python script stdout is captured.""" script = models.Script(src="print('hello world')") skill = _make_skill_with_script("test_skill", "hello.py", script) toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "hello.py", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "success" assert result["stdout"] == "hello world\n" assert result["stderr"] == "" @pytest.mark.asyncio async def test_integration_python_unicode_materialization(): """Real executor: Python script with unicode resources.""" script = models.Script( src=( "with open('references/unicode.txt', 'r', encoding='utf-8') as f:" " print(f.read())" ) ) skill = _make_skill_with_script("test_skill", "unicode.py", script) skill.resources.get_reference.side_effect = lambda n: ( "你好,世界" if n == "unicode.txt" else None ) skill.resources.list_references.return_value = ["unicode.txt"] toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "unicode.py", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "success" assert "你好,世界" in result["stdout"] @pytest.mark.asyncio async def test_integration_python_imports_sibling_script_module(): """Real executor: Python scripts can import helpers from scripts/.""" skill = _make_skill_with_scripts( "test_skill", { "run.py": models.Script( src="from helper import message\nprint(message())" ), "helper.py": models.Script( src="def message():\n return 'hello from helper'" ), }, ) toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "run.py", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "success" assert result["stdout"] == "hello from helper\n" assert result["stderr"] == "" @pytest.mark.asyncio async def test_integration_python_sys_exit_zero(): """Real executor: sys.exit(0) is treated as success.""" script = models.Script(src="import sys; sys.exit(0)") skill = _make_skill_with_script("test_skill", "exit_zero.py", script) toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "exit_zero.py", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "success" @pytest.mark.asyncio async def test_integration_shell_stdout_and_stderr(): """Real executor: shell script preserves both stdout and stderr.""" script = models.Script(src="echo output; echo warning >&2") skill = _make_skill_with_script("test_skill", "both.sh", script) toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "both.sh", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "warning" assert "output" in result["stdout"] assert "warning" in result["stderr"] @pytest.mark.asyncio async def test_integration_shell_stderr_only(): """Real executor: shell script with only stderr reports error.""" script = models.Script(src="echo failure >&2") skill = _make_skill_with_script("test_skill", "err.sh", script) toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "err.sh", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "error" assert "failure" in result["stderr"] # ── Shell JSON envelope parsing (unit tests with mock executor) ── @pytest.mark.asyncio async def test_shell_json_envelope_parsed(mock_skill1): """Shell JSON envelope is correctly unpacked by run_async.""" import json envelope = json.dumps({ "__shell_result__": True, "stdout": "hello from shell\n", "stderr": "", "returncode": 0, }) executor = _make_mock_executor(stdout=envelope) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "success" assert result["stdout"] == "hello from shell\n" assert result["stderr"] == "" @pytest.mark.asyncio async def test_shell_json_envelope_nonzero_returncode(mock_skill1): """Non-zero returncode in shell envelope sets stderr.""" import json envelope = json.dumps({ "__shell_result__": True, "stdout": "", "stderr": "", "returncode": 2, }) executor = _make_mock_executor(stdout=envelope) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "error" assert "Exit code 2" in result["stderr"] @pytest.mark.asyncio async def test_shell_json_envelope_with_stderr(mock_skill1): """Shell envelope with both stdout and stderr reports warning.""" import json envelope = json.dumps({ "__shell_result__": True, "stdout": "data\n", "stderr": "deprecation warning\n", "returncode": 0, }) executor = _make_mock_executor(stdout=envelope) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "warning" assert result["stdout"] == "data\n" assert result["stderr"] == "deprecation warning\n" @pytest.mark.asyncio async def test_shell_json_envelope_timeout(mock_skill1): """Shell envelope from TimeoutExpired reports error status.""" import json envelope = json.dumps({ "__shell_result__": True, "stdout": "partial output\n", "stderr": "Timed out after 300s", "returncode": -1, }) executor = _make_mock_executor(stdout=envelope) toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "error" assert result["stdout"] == "partial output\n" assert "Timed out" in result["stderr"] @pytest.mark.asyncio async def test_shell_non_json_stdout_passthrough(mock_skill1): """Non-JSON shell stdout is passed through without parsing.""" executor = _make_mock_executor(stdout="plain text output\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={"skill_name": "skill1", "file_path": "setup.sh"}, tool_context=ctx, ) assert result["status"] == "success" assert result["stdout"] == "plain text output\n" # ── input_files packaging ── @pytest.mark.asyncio async def test_execute_script_input_files_packaged(mock_skill1): """Verify references, assets, and scripts are packaged inside the wrapper code.""" executor = _make_mock_executor(stdout="ok\n") toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() await tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=ctx, ) call_args = executor.execute_code.call_args code_input = call_args[0][1] # input_files is no longer populated; it's serialized inside the script assert code_input.input_files is None or len(code_input.input_files) == 0 # Ensure the extracted literal contains our fake files assert "references/ref1.md" in code_input.code assert "assets/asset1.txt" in code_input.code assert "scripts/setup.sh" in code_input.code assert "scripts/run.py" in code_input.code assert "scripts/build.rb" in code_input.code # Verify content mappings exist in the string assert "'references/ref1.md': 'ref content 1'" in code_input.code assert "'assets/asset1.txt': 'asset content 1'" in code_input.code # ── Integration: shell non-zero exit ── @pytest.mark.asyncio async def test_integration_shell_nonzero_exit(): """Real executor: shell script with non-zero exit via JSON envelope.""" script = models.Script(src="exit 42") skill = _make_skill_with_script("test_skill", "fail.sh", script) toolset = _make_real_executor_toolset([skill]) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "test_skill", "file_path": "fail.sh", }, tool_context=ctx, ) assert "status" in result, f"Result missing status: {result}" assert result["status"] == "error" assert "42" in result["stderr"] # ── Finding 1: system instruction references correct tool name ── def test_system_instruction_references_run_skill_script(): """System instruction must reference the actual tool name.""" assert "run_skill_script" in skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION assert ( "execute_skill_script" not in skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION ) def test_system_instruction_marks_load_skill_as_non_terminal(): """Rule 7 must tell the model load_skill does not complete the turn. Without it, some models (notably Gemini) treat the load_skill tool call as the entire turn and stop with no visible output, producing empty responses. """ instruction = skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION assert "does NOT complete your turn" in instruction assert "empty response" in instruction def test_prefixed_system_instruction_includes_continue_after_load_rule(): """The prefixed builder variant must also carry rule 7 (with the prefix).""" instruction = skill_toolset._build_skill_system_instruction(prefix="my") assert "does NOT complete your turn" in instruction assert "my_load_skill" in instruction # ── Finding 2: empty files are mounted (not silently dropped) ── @pytest.mark.asyncio async def test_execute_script_empty_files_mounted(): """Verify empty files are included in wrapper code, not dropped.""" skill = mock.create_autospec(models.Skill, instance=True) skill.name = "skill_empty" skill.resources = mock.MagicMock( spec=[ "get_reference", "get_asset", "get_script", "list_references", "list_assets", "list_scripts", ] ) skill.resources.get_reference.side_effect = ( lambda n: "" if n == "empty.md" else None ) skill.resources.get_asset.side_effect = ( lambda n: "" if n == "empty.cfg" else None ) skill.resources.get_script.side_effect = ( lambda n: models.Script(src="") if n == "run.py" else None ) skill.resources.list_references.return_value = ["empty.md"] skill.resources.list_assets.return_value = ["empty.cfg"] skill.resources.list_scripts.return_value = ["run.py"] executor = _make_mock_executor(stdout="ok\n") toolset = skill_toolset.SkillToolset([skill], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() await tool.run_async( args={"skill_name": "skill_empty", "file_path": "run.py"}, tool_context=ctx, ) call_args = executor.execute_code.call_args code_input = call_args[0][1] assert "'references/empty.md': ''" in code_input.code assert "'assets/empty.cfg': ''" in code_input.code assert "'scripts/run.py': ''" in code_input.code # ── Finding 3: invalid args type returns clear error ── @pytest.mark.asyncio @pytest.mark.parametrize( "bad_args", [ "not a dict", 42, True, ], ) async def test_execute_script_invalid_args_type(mock_skill1, bad_args): """Non-dict args should return INVALID_ARGS_TYPE, not crash.""" executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "run.py", "args": bad_args, }, tool_context=ctx, ) assert result["error_code"] == "INVALID_ARGUMENTS" executor.execute_code.assert_not_called() @pytest.mark.parametrize( "bad_short_options", [ "not a dict", 42, True, ["list"], ], ) @pytest.mark.asyncio async def test_execute_script_invalid_short_options_type( mock_skill1, bad_short_options ): """Non-dict short_options should return INVALID_SHORT_OPTIONS_TYPE, not crash.""" executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "run.py", "short_options": bad_short_options, }, tool_context=ctx, ) assert result["error_code"] == "INVALID_ARGUMENTS" executor.execute_code.assert_not_called() @pytest.mark.parametrize( "bad_positional_args", [ "not a list", 42, True, {"dict": 1}, ], ) @pytest.mark.asyncio async def test_execute_script_invalid_positional_args_type( mock_skill1, bad_positional_args ): """Non-list positional_args should return INVALID_POSITIONAL_ARGS_TYPE, not crash.""" executor = _make_mock_executor() toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() result = await tool.run_async( args={ "skill_name": "skill1", "file_path": "run.py", "positional_args": bad_positional_args, }, tool_context=ctx, ) assert result["error_code"] == "INVALID_ARGUMENTS" executor.execute_code.assert_not_called() # ── Finding 4: binary file content is handled in wrapper ── @pytest.mark.asyncio async def test_execute_script_binary_content_packaged(): """Verify binary asset content uses 'wb' mode in wrapper code.""" skill = mock.create_autospec(models.Skill, instance=True) skill.name = "skill_bin" skill.resources = mock.MagicMock( spec=[ "get_reference", "get_asset", "get_script", "list_references", "list_assets", "list_scripts", ] ) skill.resources.get_reference.side_effect = ( lambda n: b"\x00\x01\x02" if n == "data.bin" else None ) skill.resources.get_asset.return_value = None skill.resources.get_script.side_effect = lambda n: ( models.Script(src="print('ok')") if n == "run.py" else None ) skill.resources.list_references.return_value = ["data.bin"] skill.resources.list_assets.return_value = [] skill.resources.list_scripts.return_value = ["run.py"] executor = _make_mock_executor(stdout="ok\n") toolset = skill_toolset.SkillToolset([skill], code_executor=executor) tool = skill_toolset.RunSkillScriptTool(toolset) ctx = _make_tool_context_with_agent() await tool.run_async( args={"skill_name": "skill_bin", "file_path": "run.py"}, tool_context=ctx, ) call_args = executor.execute_code.call_args code_input = call_args[0][1] # Binary content should appear as bytes literal assert "b'\\x00\\x01\\x02'" in code_input.code # Wrapper code handles binary with 'wb' mode assert "'wb' if isinstance(content, bytes)" in code_input.code @pytest.mark.asyncio async def test_skill_toolset_dynamic_tool_resolution(mock_skill1, mock_skill2): # Set up skills with additional_tools in metadata mock_skill1.frontmatter.metadata = { "adk_additional_tools": ["my_custom_tool", "my_func", "shared_tool"] } mock_skill1.name = "skill1" mock_skill2.frontmatter.metadata = { "adk_additional_tools": [ "skill2_tool", "shared_tool", "prefixed_mock_tool", ] } mock_skill2.name = "skill2" # Prepare additional tools custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) custom_tool.name = "my_custom_tool" skill2_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) skill2_tool.name = "skill2_tool" shared_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) shared_tool.name = "shared_tool" def my_func(): """My function description.""" pass # Setup prefixed toolset mock_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) mock_tool.name = "prefixed_mock_tool" prefixed_set = mock.create_autospec(skill_toolset.BaseToolset, instance=True) prefixed_set.get_tools_with_prefix.return_value = [mock_tool] toolset = skill_toolset.SkillToolset( [mock_skill1, mock_skill2], additional_tools=[ custom_tool, skill2_tool, shared_tool, my_func, prefixed_set, ], ) ctx = _make_tool_context_with_agent() ctx.invocation_id = "turn-1" # Initial tools (only core) tools1 = await toolset.get_tools_with_prefix(readonly_context=ctx) assert len(tools1) == 4 # Activate skills load_tool = skill_toolset.LoadSkillTool(toolset) await load_tool.run_async(args={"skill_name": "skill1"}, tool_context=ctx) await load_tool.run_async(args={"skill_name": "skill2"}, tool_context=ctx) # Dynamic tools should now be resolved ctx.invocation_id = "turn-2" tools = await toolset.get_tools_with_prefix(readonly_context=ctx) assert tools is not tools1 tool_names = {t.name for t in tools} # Core tools assert "list_skills" in tool_names assert "load_skill" in tool_names assert "load_skill_resource" in tool_names assert "run_skill_script" in tool_names # Skill 1 tools assert "my_custom_tool" in tool_names assert "my_func" in tool_names # Skill 2 tools assert "skill2_tool" in tool_names # Shared tool (should only appear once) assert "shared_tool" in tool_names assert len([t for t in tools if t.name == "shared_tool"]) == 1 # Prefixed toolset tool assert "prefixed_mock_tool" in tool_names # Check specific tool resolution details my_func_tool = next(t for t in tools if t.name == "my_func") assert isinstance(my_func_tool, skill_toolset.FunctionTool) assert my_func_tool.description == "My function description." @pytest.mark.asyncio async def test_skill_toolset_resolution_error_handling(mock_skill1, caplog): mock_skill1.frontmatter.metadata = { "adk_additional_tools": ["nonexistent_tool"] } mock_skill1.name = "skill1" toolset = skill_toolset.SkillToolset([mock_skill1]) ctx = _make_tool_context_with_agent() # Activate skill load_tool = skill_toolset.LoadSkillTool(toolset) await load_tool.run_async(args={"skill_name": "skill1"}, tool_context=ctx) with caplog.at_level(logging.WARNING): tools = await toolset.get_tools(readonly_context=ctx) # Should still return basic skill tools assert len(tools) == 4 @pytest.fixture(name="mock_registry") def _mock_registry(): """Fixture for mock SkillRegistry.""" registry = mock.create_autospec(skill_toolset.SkillRegistry, instance=True) registry.search_tool_description.return_value = None return registry @pytest.mark.asyncio async def test_skill_toolset_init_with_registry(mock_registry): # Verify toolset initializes with empty skills list and registers SearchSkillsTool toolset = skill_toolset.SkillToolset(registry=mock_registry) assert toolset._registry == mock_registry assert len(toolset._skills) == 0 tools = await toolset.get_tools() assert len(tools) == 5 assert isinstance(tools[4], skill_toolset.SearchSkillsTool) def test_search_skills_tool_init_without_registry(): toolset = skill_toolset.SkillToolset() with pytest.raises( ValueError, match="SearchSkillsTool requires a configured skill registry.", ): skill_toolset.SearchSkillsTool(toolset) @pytest.mark.asyncio async def test_search_skills_tool_run_async( mock_registry, mock_skill1, tool_context_instance ): # Verify search_skills tool works, filters out local naming conflicts mock_frontmatter1 = mock.create_autospec(models.Frontmatter, instance=True) mock_frontmatter1.name = "skill1" mock_frontmatter1.model_dump.return_value = {"name": "skill1"} mock_frontmatter2 = mock.create_autospec(models.Frontmatter, instance=True) mock_frontmatter2.name = "skill2" mock_frontmatter2.model_dump.return_value = {"name": "skill2"} mock_registry.search_skills.return_value = [ mock_frontmatter1, mock_frontmatter2, ] # skill1 exists locally, skill2 does not toolset = skill_toolset.SkillToolset([mock_skill1], registry=mock_registry) tool = skill_toolset.SearchSkillsTool(toolset) result = await tool.run_async( args={"query": "test"}, tool_context=tool_context_instance ) mock_registry.search_skills.assert_called_once_with(query="test") # skill1 should be filtered out due to naming conflict with local mock_skill1 assert result == [{"name": "skill2"}] @pytest.mark.asyncio async def test_load_skill_tool_fallback_to_registry( mock_registry, mock_skill1, tool_context_instance ): # Verify LoadSkillTool falls back to registry, fetching on-demand without cache mock_registry.get_skill.return_value = mock_skill1 toolset = skill_toolset.SkillToolset(registry=mock_registry) tool = skill_toolset.LoadSkillTool(toolset) # Mock state state_key = "_adk_activated_skill_test_agent" tool_context_instance.state.get.return_value = None # First load: goes to registry tool_context_instance.invocation_id = "inv-1" result = await tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) assert result["skill_name"] == "skill1" assert result["instructions"] == "instructions for skill1" mock_registry.get_skill.assert_called_once_with(name="skill1") # Verify that the Skill frontmatter was cached in the unified state dictionary tool_context_instance.state.__setitem__.assert_called_once_with( state_key, ["skill1"] ) # Mock state.get to return the cached skill list tool_context_instance.state.get.side_effect = lambda key, default=None: ( ["skill1"] if key == state_key else default ) # Second load on a new turn: should fetch from registry on-demand as only frontmatter is in state tool_context_instance.invocation_id = "inv-2" mock_registry.get_skill.reset_mock() result2 = await tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) assert result2["skill_name"] == "skill1" mock_registry.get_skill.assert_called_once_with(name="skill1") @pytest.mark.asyncio async def test_registry_skill_resources_and_tools_resolved( mock_registry, tool_context_instance ): # Create a mock registry skill that declares local additional tools mock_skill = mock.create_autospec(models.Skill, instance=True) mock_skill.name = "registry_skill" mock_skill.instructions = "registry instructions" mock_skill.frontmatter = mock.create_autospec( models.Frontmatter, instance=True ) mock_skill.frontmatter.name = "registry_skill" mock_skill.frontmatter.metadata = {"adk_additional_tools": ["my_custom_tool"]} mock_skill.resources = mock.MagicMock() mock_skill.resources.get_reference.return_value = "reference content" mock_registry.get_skill.return_value = mock_skill # Setup toolset with the registry and the local implementation of the tool custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) custom_tool.name = "my_custom_tool" toolset = skill_toolset.SkillToolset( registry=mock_registry, additional_tools=[custom_tool] ) # Load the skill via LoadSkillTool load_tool = skill_toolset.LoadSkillTool(toolset) state_key = "_adk_activated_skill_test_agent" tool_context_instance.state.get.side_effect = lambda key, default=None: ( ["registry_skill"] if key == state_key else default ) result = await load_tool.run_async( args={"skill_name": "registry_skill"}, tool_context=tool_context_instance ) assert result["skill_name"] == "registry_skill" # 1. Verify dynamic tools from the registry are resolved tools = await toolset.get_tools(readonly_context=tool_context_instance) tool_names = {t.name for t in tools} assert "my_custom_tool" in tool_names # 2. Verify resource loading resolves registry skill correctly resource_tool = skill_toolset.LoadSkillResourceTool(toolset) res_result = await resource_tool.run_async( args={ "skill_name": "registry_skill", "file_path": "references/ref.md", }, tool_context=tool_context_instance, ) assert res_result["content"] == "reference content" @pytest.mark.asyncio async def test_process_llm_request_with_registry( mock_registry, tool_context_instance ): toolset = skill_toolset.SkillToolset(registry=mock_registry) llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True) await toolset.process_llm_request( tool_context=tool_context_instance, llm_request=llm_req ) llm_req.append_instructions.assert_called_once() args, _ = llm_req.append_instructions.call_args instructions = args[0] assert len(instructions) == 2 assert instructions[0] == skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION assert "search_skills" in instructions[1] @pytest.mark.asyncio async def test_turn_scoped_skill_cache( mock_registry, mock_skill1, tool_context_instance ): # Verify that multiple tool calls on the same registry-provided skill in the same turn # use the turn-scoped cache and only call the registry once. mock_registry.get_skill.return_value = mock_skill1 toolset = skill_toolset.SkillToolset(registry=mock_registry) load_tool = skill_toolset.LoadSkillTool(toolset) script_tool = skill_toolset.RunSkillScriptTool(toolset) tool_context_instance.invocation_id = "same-turn-id" tool_context_instance.state.get.return_value = None # Call LoadSkillTool res1 = await load_tool.run_async( args={"skill_name": "skill1"}, tool_context=tool_context_instance ) assert res1["skill_name"] == "skill1" mock_registry.get_skill.assert_called_once_with(name="skill1") # Setup executor for script tool executor = mock.create_autospec(skill_toolset.BaseCodeExecutor, instance=True) executor.execute_code.return_value = mock.MagicMock( stdout="hello\n", stderr="" ) toolset._code_executor = executor # Call RunSkillScriptTool in the same turn res2 = await script_tool.run_async( args={"skill_name": "skill1", "file_path": "run.py"}, tool_context=tool_context_instance, ) assert res2["status"] == "success" # Registry should NOT be called again mock_registry.get_skill.assert_called_once_with(name="skill1") @pytest.mark.asyncio async def test_turn_scoped_skill_cache_eviction(mock_registry, mock_skill1): mock_registry.get_skill.return_value = mock_skill1 toolset = skill_toolset.SkillToolset(registry=mock_registry) # Fill cache up to limit for i in range(16): await toolset._get_or_fetch_skill("skill1", f"turn-{i}") assert len(toolset._fetched_skill_cache) == 16 assert "turn-0" in toolset._fetched_skill_cache # Next turn should evict oldest (turn-0) await toolset._get_or_fetch_skill("skill1", "turn-16") assert len(toolset._fetched_skill_cache) == 16 assert "turn-0" not in toolset._fetched_skill_cache assert "turn-1" in toolset._fetched_skill_cache @pytest.mark.asyncio async def test_turn_scoped_skill_cache_concurrency(mock_registry, mock_skill1): # Delay registry fetch to simulate async I/O and force race condition async def delayed_get_skill(name): await asyncio.sleep(0.1) return mock_skill1 mock_registry.get_skill.side_effect = delayed_get_skill toolset = skill_toolset.SkillToolset(registry=mock_registry) # Trigger concurrent calls for the same skill in the same turn results = await asyncio.gather( toolset._get_or_fetch_skill("skill1", "concurrent-turn"), toolset._get_or_fetch_skill("skill1", "concurrent-turn"), toolset._get_or_fetch_skill("skill1", "concurrent-turn"), ) for res in results: assert res is mock_skill1 # Registry should have been called exactly once mock_registry.get_skill.assert_called_once_with(name="skill1") def test_skill_toolset_disables_invocation_cache(): """Verify SkillToolset disables tool invocation caching to allow dynamic tools.""" toolset = skill_toolset.SkillToolset() assert toolset._use_invocation_cache is False @pytest.mark.asyncio async def test_close_cancels_futures_and_clears_cache(): # pylint: disable=protected-access toolset = skill_toolset.SkillToolset() # Create mock futures for testing close() behavior loop = asyncio.get_running_loop() fut1 = loop.create_future() fut2 = loop.create_future() fut2.set_result(None) # Already done future toolset._fetched_skill_cache = collections.OrderedDict( { "turn1": { "skill1": fut1, "skill2": fut2, } } ) await toolset.close() assert fut1.cancelled() assert not fut2.cancelled() # Done futures shouldn't/can't be cancelled assert not toolset._fetched_skill_cache @pytest.mark.asyncio async def test_process_llm_request_with_tool_name_prefix( mock_skill1, mock_skill2, tool_context_instance, mock_registry ): toolset = skill_toolset.SkillToolset( [mock_skill1, mock_skill2], registry=mock_registry, tool_name_prefix="my_prefix", ) # Manually remove ListSkillsTool from self._tools to simulate it not being available # so that instructions[1] is generated with available_skills toolset._tools = [ t for t in toolset._tools if not isinstance(t, skill_toolset.ListSkillsTool) ] llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True) await toolset.process_llm_request( tool_context=tool_context_instance, llm_request=llm_req ) llm_req.append_instructions.assert_called_once() args, _ = llm_req.append_instructions.call_args instructions = args[0] assert len(instructions) == 3 assert "`my_prefix_load_skill`" in instructions[0] assert "`my_prefix_load_skill_resource`" in instructions[0] assert "`my_prefix_run_skill_script`" in instructions[0] assert "my_prefix_search_skills" in instructions[2] @pytest.mark.asyncio async def test_skill_toolset_with_list_tool_filter(): toolset = skill_toolset.SkillToolset( tool_filter=["list_skills", "load_skill"] ) tools = await toolset.get_tools() tool_names = [t.name for t in tools] assert "list_skills" in tool_names assert "load_skill" in tool_names assert "load_skill_resource" not in tool_names assert "run_skill_script" not in tool_names @pytest.mark.asyncio async def test_skill_toolset_with_predicate_tool_filter(): # Filter to only tools containing 'resource' in their name toolset = skill_toolset.SkillToolset( tool_filter=lambda tool, ctx=None: "resource" in tool.name ) tools = await toolset.get_tools() tool_names = [t.name for t in tools] assert tool_names == ["load_skill_resource"] @pytest.mark.asyncio async def test_skill_toolset_with_dynamic_tools_filter( mock_skill1, tool_context_instance ): mock_skill1.frontmatter.metadata = { "adk_additional_tools": ["my_custom_tool"] } custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) custom_tool.name = "my_custom_tool" toolset = skill_toolset.SkillToolset( skills=[mock_skill1], additional_tools=[custom_tool], tool_filter=["list_skills", "my_custom_tool"], ) state_key = "_adk_activated_skill_test_agent" tool_context_instance.state.get.side_effect = lambda key, default=None: ( ["skill1"] if key == state_key else default ) tools = await toolset.get_tools(readonly_context=tool_context_instance) tool_names = [t.name for t in tools] assert "list_skills" in tool_names assert "my_custom_tool" in tool_names assert "load_skill" not in tool_names