Skip to content

Commit d09a90e

Browse files
lwangverizonclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 9670ce2 commit d09a90e

4 files changed

Lines changed: 124 additions & 2 deletions

File tree

src/google/adk/skills/models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ class Frontmatter(BaseModel):
5050
https://agentskills.io/specification#allowed-tools-field.
5151
metadata: Key-value pairs for client-specific properties (defaults to
5252
empty dict). For example, to include additional tools, use the
53-
``adk_additional_tools`` key with a list of tools.
53+
``adk_additional_tools`` key with a list of tools. To enable session
54+
state injection (e.g. ``{var_name}`` or ``{artifact.file_name}``) into
55+
the skill instructions when the skill is loaded, set the
56+
``adk_inject_session_state`` key to ``True`` (defaults to disabled so
57+
that literal braces in skill content are preserved).
5458
"""
5559

5660
model_config = ConfigDict(
@@ -76,6 +80,9 @@ def _validate_metadata(cls, v: dict[str, Any]) -> dict[str, Any]:
7680
tools = v["adk_additional_tools"]
7781
if not isinstance(tools, list):
7882
raise ValueError("adk_additional_tools must be a list of strings")
83+
if "adk_inject_session_state" in v:
84+
if not isinstance(v["adk_inject_session_state"], bool):
85+
raise ValueError("adk_inject_session_state must be a boolean")
7986
return v
8087

8188
@field_validator("name")

src/google/adk/tools/skill_toolset.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from ..skills import models
3737
from ..skills import prompt
3838
from ..skills import SkillRegistry
39+
from ..utils.instructions_utils import inject_session_state
3940
from .base_tool import BaseTool
4041
from .base_toolset import BaseToolset
4142
from .base_toolset import ToolPredicate
@@ -248,9 +249,25 @@ async def run_async(
248249
activated_skills.append(skill_name)
249250
tool_context.state[state_key] = activated_skills
250251

252+
# Optionally inject session state (e.g. {var_name}, {artifact.file_name})
253+
# into the instructions. Disabled by default so that literal braces in
254+
# skill content are preserved unless the skill opts in via frontmatter.
255+
instructions = skill.instructions
256+
if skill.frontmatter.metadata.get("adk_inject_session_state"):
257+
try:
258+
instructions = await inject_session_state(instructions, tool_context)
259+
except (KeyError, ValueError) as e:
260+
return {
261+
"error": (
262+
f"Failed to inject session state into skill '{skill_name}'"
263+
f" instructions: {e}"
264+
),
265+
"error_code": "STATE_INJECTION_ERROR",
266+
}
267+
251268
return {
252269
"skill_name": skill_name,
253-
"instructions": skill.instructions,
270+
"instructions": instructions,
254271
"frontmatter": skill.frontmatter.model_dump(),
255272
}
256273

tests/unittests/skills/test_models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,23 @@ def test_metadata_adk_additional_tools_invalid_type():
232232
"description": "desc",
233233
"metadata": {"adk_additional_tools": 123},
234234
})
235+
236+
237+
def test_metadata_adk_inject_session_state_bool():
238+
fm = models.Frontmatter.model_validate({
239+
"name": "my-skill",
240+
"description": "desc",
241+
"metadata": {"adk_inject_session_state": True},
242+
})
243+
assert fm.metadata["adk_inject_session_state"] is True
244+
245+
246+
def test_metadata_adk_inject_session_state_invalid_type():
247+
with pytest.raises(
248+
ValidationError, match="adk_inject_session_state must be a boolean"
249+
):
250+
models.Frontmatter.model_validate({
251+
"name": "my-skill",
252+
"description": "desc",
253+
"metadata": {"adk_inject_session_state": "yes"},
254+
})

tests/unittests/tools/test_skill_toolset.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _mock_skill1_frontmatter():
3838
frontmatter.name = "skill1"
3939
frontmatter.description = "Skill 1 description"
4040
frontmatter.allowed_tools = ["test_tool"]
41+
frontmatter.metadata = {}
4142
frontmatter.model_dump.return_value = {
4243
"name": "skill1",
4344
"description": "Skill 1 description",
@@ -107,6 +108,7 @@ def _mock_skill2_frontmatter():
107108
frontmatter.name = "skill2"
108109
frontmatter.description = "Skill 2 description"
109110
frontmatter.allowed_tools = []
111+
frontmatter.metadata = {}
110112
frontmatter.model_dump.return_value = {
111113
"name": "skill2",
112114
"description": "Skill 2 description",
@@ -276,6 +278,82 @@ async def test_load_skill_run_async_state_none(
276278
)
277279

278280

281+
@pytest.mark.asyncio
282+
async def test_load_skill_no_injection_by_default(
283+
mock_skill1, tool_context_instance
284+
):
285+
"""Without the opt-in flag, braces in instructions are preserved verbatim."""
286+
mock_skill1.instructions = "Greet {user_name} warmly."
287+
tool_context_instance._invocation_context.session.state = {
288+
"user_name": "Alice"
289+
}
290+
toolset = skill_toolset.SkillToolset([mock_skill1])
291+
tool = skill_toolset.LoadSkillTool(toolset)
292+
293+
result = await tool.run_async(
294+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
295+
)
296+
297+
assert result["instructions"] == "Greet {user_name} warmly."
298+
299+
300+
@pytest.mark.asyncio
301+
async def test_load_skill_injects_session_state_when_enabled(
302+
mock_skill1, tool_context_instance
303+
):
304+
"""With the opt-in flag set, state variables are substituted."""
305+
mock_skill1.instructions = "Greet {user_name} warmly."
306+
mock_skill1.frontmatter.metadata = {"adk_inject_session_state": True}
307+
tool_context_instance._invocation_context.session.state = {
308+
"user_name": "Alice"
309+
}
310+
toolset = skill_toolset.SkillToolset([mock_skill1])
311+
tool = skill_toolset.LoadSkillTool(toolset)
312+
313+
result = await tool.run_async(
314+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
315+
)
316+
317+
assert result["instructions"] == "Greet Alice warmly."
318+
319+
320+
@pytest.mark.asyncio
321+
async def test_load_skill_injection_optional_var_empty(
322+
mock_skill1, tool_context_instance
323+
):
324+
"""An optional ({var?}) missing variable is replaced with empty string."""
325+
mock_skill1.instructions = "Hello{nickname?}."
326+
mock_skill1.frontmatter.metadata = {"adk_inject_session_state": True}
327+
tool_context_instance._invocation_context.session.state = {}
328+
toolset = skill_toolset.SkillToolset([mock_skill1])
329+
tool = skill_toolset.LoadSkillTool(toolset)
330+
331+
result = await tool.run_async(
332+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
333+
)
334+
335+
assert result["instructions"] == "Hello."
336+
337+
338+
@pytest.mark.asyncio
339+
async def test_load_skill_injection_missing_required_var_returns_error(
340+
mock_skill1, tool_context_instance
341+
):
342+
"""A missing required variable returns a STATE_INJECTION_ERROR, not a crash."""
343+
mock_skill1.instructions = "Greet {user_name} warmly."
344+
mock_skill1.frontmatter.metadata = {"adk_inject_session_state": True}
345+
tool_context_instance._invocation_context.session.state = {}
346+
toolset = skill_toolset.SkillToolset([mock_skill1])
347+
tool = skill_toolset.LoadSkillTool(toolset)
348+
349+
result = await tool.run_async(
350+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
351+
)
352+
353+
assert result["error_code"] == "STATE_INJECTION_ERROR"
354+
assert "skill1" in result["error"]
355+
356+
279357
@pytest.mark.asyncio
280358
@pytest.mark.parametrize(
281359
"args, expected_result",

0 commit comments

Comments
 (0)