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
9 changes: 8 additions & 1 deletion src/google/adk/skills/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
Expand Down
19 changes: 18 additions & 1 deletion src/google/adk/tools/skill_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
}

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