Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/google/adk/skills/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
Expand Down
10 changes: 9 additions & 1 deletion src/google/adk/tools/skill_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
}

Expand Down
18 changes: 18 additions & 0 deletions tests/unittests/skills/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
})
70 changes: 70 additions & 0 deletions tests/unittests/tools/test_skill_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down