From d09a90e3a897ce9bd8a4c6014424d35d6718cb8e Mon Sep 17 00:00:00 2001 From: Luyang Wang Date: Thu, 4 Jun 2026 21:00:36 -0400 Subject: [PATCH] feat(skills): Allow opt-in session state injection in skill instructions Agent instructions support injecting dynamic session state via `{var_name}`, `{artifact.file_name}`, and `{optional?}` templates, but skill instructions returned by `load_skill` were emitted verbatim, so a skill body could not reference per-session state. Add an opt-in frontmatter flag `adk_inject_session_state` (mirroring the existing `adk_additional_tools` metadata convention). When set to true, `LoadSkillTool` routes the skill instructions through the existing `inject_session_state` utility before returning them. The flag defaults to disabled so that literal braces in skill markdown (code samples, JSON, templates) are preserved and existing skills are unaffected. A missing required variable surfaces a `STATE_INJECTION_ERROR` tool result rather than raising, consistent with the toolset's other error responses. Co-Authored-By: Claude Opus 4.8 --- src/google/adk/skills/models.py | 9 ++- src/google/adk/tools/skill_toolset.py | 19 ++++- tests/unittests/skills/test_models.py | 20 ++++++ tests/unittests/tools/test_skill_toolset.py | 78 +++++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) 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",