diff --git a/src/google/adk/skills/models.py b/src/google/adk/skills/models.py index 9e9b378a97..e3d27a2dd9 100644 --- a/src/google/adk/skills/models.py +++ b/src/google/adk/skills/models.py @@ -50,7 +50,11 @@ 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. To enable session + state injection (e.g. ``{var_name}`` or ``{artifact.file_name}``) into + the skill instructions when the skill is loaded, set the + ``adk_inject_session_state`` key to ``True`` (defaults to disabled so + that literal braces in skill content are preserved). """ model_config = ConfigDict( @@ -76,6 +80,9 @@ 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_session_state" in v: + if not isinstance(v["adk_inject_session_state"], bool): + raise ValueError("adk_inject_session_state must be a boolean") return v @field_validator("name") diff --git a/src/google/adk/tools/skill_toolset.py b/src/google/adk/tools/skill_toolset.py index ccb5890f46..4b0f6c8c6f 100644 --- a/src/google/adk/tools/skill_toolset.py +++ b/src/google/adk/tools/skill_toolset.py @@ -36,6 +36,7 @@ from ..skills import models from ..skills import prompt from ..skills import SkillRegistry +from ..utils.instructions_utils import inject_session_state from .base_tool import BaseTool from .base_toolset import BaseToolset from .base_toolset import ToolPredicate @@ -248,9 +249,25 @@ async def run_async( activated_skills.append(skill_name) tool_context.state[state_key] = activated_skills + # Optionally inject session state (e.g. {var_name}, {artifact.file_name}) + # into the instructions. Disabled by default so that literal braces in + # skill content are preserved unless the skill opts in via frontmatter. + instructions = skill.instructions + if skill.frontmatter.metadata.get("adk_inject_session_state"): + try: + instructions = await inject_session_state(instructions, tool_context) + except (KeyError, ValueError) as e: + return { + "error": ( + f"Failed to inject session state into skill '{skill_name}'" + f" instructions: {e}" + ), + "error_code": "STATE_INJECTION_ERROR", + } + 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 ffbbb2dd50..23423fcc2e 100644 --- a/tests/unittests/skills/test_models.py +++ b/tests/unittests/skills/test_models.py @@ -232,3 +232,23 @@ def test_metadata_adk_additional_tools_invalid_type(): "description": "desc", "metadata": {"adk_additional_tools": 123}, }) + + +def test_metadata_adk_inject_session_state_bool(): + fm = models.Frontmatter.model_validate({ + "name": "my-skill", + "description": "desc", + "metadata": {"adk_inject_session_state": True}, + }) + assert fm.metadata["adk_inject_session_state"] is True + + +def test_metadata_adk_inject_session_state_invalid_type(): + with pytest.raises( + ValidationError, match="adk_inject_session_state must be a boolean" + ): + models.Frontmatter.model_validate({ + "name": "my-skill", + "description": "desc", + "metadata": {"adk_inject_session_state": "yes"}, + }) diff --git a/tests/unittests/tools/test_skill_toolset.py b/tests/unittests/tools/test_skill_toolset.py index 218050e7e0..1305c78b3d 100644 --- a/tests/unittests/tools/test_skill_toolset.py +++ b/tests/unittests/tools/test_skill_toolset.py @@ -38,6 +38,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", @@ -107,6 +108,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", @@ -276,6 +278,82 @@ async def test_load_skill_run_async_state_none( ) +@pytest.mark.asyncio +async def test_load_skill_no_injection_by_default( + mock_skill1, tool_context_instance +): + """Without the opt-in flag, braces in instructions are preserved verbatim.""" + mock_skill1.instructions = "Greet {user_name} warmly." + tool_context_instance._invocation_context.session.state = { + "user_name": "Alice" + } + toolset = skill_toolset.SkillToolset([mock_skill1]) + tool = skill_toolset.LoadSkillTool(toolset) + + result = await tool.run_async( + args={"skill_name": "skill1"}, tool_context=tool_context_instance + ) + + assert result["instructions"] == "Greet {user_name} warmly." + + +@pytest.mark.asyncio +async def test_load_skill_injects_session_state_when_enabled( + mock_skill1, tool_context_instance +): + """With the opt-in flag set, state variables are substituted.""" + mock_skill1.instructions = "Greet {user_name} warmly." + mock_skill1.frontmatter.metadata = {"adk_inject_session_state": True} + tool_context_instance._invocation_context.session.state = { + "user_name": "Alice" + } + toolset = skill_toolset.SkillToolset([mock_skill1]) + tool = skill_toolset.LoadSkillTool(toolset) + + result = await tool.run_async( + args={"skill_name": "skill1"}, tool_context=tool_context_instance + ) + + assert result["instructions"] == "Greet Alice warmly." + + +@pytest.mark.asyncio +async def test_load_skill_injection_optional_var_empty( + mock_skill1, tool_context_instance +): + """An optional ({var?}) missing variable is replaced with empty string.""" + mock_skill1.instructions = "Hello{nickname?}." + mock_skill1.frontmatter.metadata = {"adk_inject_session_state": True} + tool_context_instance._invocation_context.session.state = {} + toolset = skill_toolset.SkillToolset([mock_skill1]) + tool = skill_toolset.LoadSkillTool(toolset) + + result = await tool.run_async( + args={"skill_name": "skill1"}, tool_context=tool_context_instance + ) + + assert result["instructions"] == "Hello." + + +@pytest.mark.asyncio +async def test_load_skill_injection_missing_required_var_returns_error( + mock_skill1, tool_context_instance +): + """A missing required variable returns a STATE_INJECTION_ERROR, not a crash.""" + mock_skill1.instructions = "Greet {user_name} warmly." + mock_skill1.frontmatter.metadata = {"adk_inject_session_state": True} + tool_context_instance._invocation_context.session.state = {} + toolset = skill_toolset.SkillToolset([mock_skill1]) + tool = skill_toolset.LoadSkillTool(toolset) + + result = await tool.run_async( + args={"skill_name": "skill1"}, tool_context=tool_context_instance + ) + + assert result["error_code"] == "STATE_INJECTION_ERROR" + assert "skill1" in result["error"] + + @pytest.mark.asyncio @pytest.mark.parametrize( "args, expected_result",