diff --git a/src/google/adk/skills/models.py b/src/google/adk/skills/models.py index 1cd443b2ca..4abdebec23 100644 --- a/src/google/adk/skills/models.py +++ b/src/google/adk/skills/models.py @@ -50,7 +50,10 @@ class Frontmatter(BaseModel): https://agentskills.io/specification#allowed-tools-field. metadata: Key-value pairs for client-specific properties (defaults to empty dict). For example, to include additional tools, use the - ``adk_additional_tools`` key with a list of tools. + ``adk_additional_tools`` key with a list of tools. Set + ``adk_inject_state: true`` to enable ``{var}`` session state + interpolation in the SKILL.md body (same syntax as + ``LlmAgent.instruction``). """ model_config = ConfigDict( @@ -76,6 +79,8 @@ def _validate_metadata(cls, v: dict[str, Any]) -> dict[str, Any]: tools = v["adk_additional_tools"] if not isinstance(tools, list): raise ValueError("adk_additional_tools must be a list of strings") + if "adk_inject_state" in v and not isinstance(v["adk_inject_state"], bool): + raise ValueError("adk_inject_state must be a bool") return v @field_validator("name") diff --git a/src/google/adk/tools/skill_toolset.py b/src/google/adk/tools/skill_toolset.py index 08dc937d7b..0efacff9d3 100644 --- a/src/google/adk/tools/skill_toolset.py +++ b/src/google/adk/tools/skill_toolset.py @@ -37,6 +37,7 @@ from ..features import FeatureName from ..skills import models from ..skills import prompt +from ..utils import instructions_utils from .base_tool import BaseTool from .base_toolset import BaseToolset from .function_tool import FunctionTool @@ -170,9 +171,16 @@ async def run_async( activated_skills.append(skill_name) tool_context.state[state_key] = activated_skills + instructions = skill.instructions + if skill.frontmatter.metadata.get("adk_inject_state"): + instructions = await instructions_utils.inject_session_state( + instructions, + ReadonlyContext(tool_context._invocation_context), + ) + return { "skill_name": skill_name, - "instructions": skill.instructions, + "instructions": instructions, "frontmatter": skill.frontmatter.model_dump(), } diff --git a/tests/unittests/skills/test_models.py b/tests/unittests/skills/test_models.py index 73136be0b9..8fe8e54afc 100644 --- a/tests/unittests/skills/test_models.py +++ b/tests/unittests/skills/test_models.py @@ -229,3 +229,21 @@ def test_metadata_adk_additional_tools_invalid_type(): "description": "desc", "metadata": {"adk_additional_tools": 123}, }) + + +def test_metadata_adk_inject_state_bool(): + fm = models.Frontmatter.model_validate({ + "name": "my-skill", + "description": "desc", + "metadata": {"adk_inject_state": True}, + }) + assert fm.metadata["adk_inject_state"] is True + + +def test_metadata_adk_inject_state_rejected_as_string(): + with pytest.raises(ValidationError, match="adk_inject_state must be a bool"): + models.Frontmatter.model_validate({ + "name": "my-skill", + "description": "desc", + "metadata": {"adk_inject_state": "true"}, + }) diff --git a/tests/unittests/tools/test_skill_toolset.py b/tests/unittests/tools/test_skill_toolset.py index 7d60110177..36465ac87c 100644 --- a/tests/unittests/tools/test_skill_toolset.py +++ b/tests/unittests/tools/test_skill_toolset.py @@ -35,6 +35,7 @@ def _mock_skill1_frontmatter(): 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", @@ -104,6 +105,7 @@ def _mock_skill2_frontmatter(): frontmatter.name = "skill2" frontmatter.description = "Skill 2 description" frontmatter.allowed_tools = [] + frontmatter.metadata = {} frontmatter.model_dump.return_value = { "name": "skill2", "description": "Skill 2 description", @@ -274,6 +276,74 @@ async def test_load_skill_run_async_state_none( ) +@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",