Skip to content

Commit c92a8ce

Browse files
lwangverizonclaude
andcommitted
feat(skills): Allow opt-in session state injection in skill instructions
Add an opt-in frontmatter metadata flag `adk_inject_session_state` (mirroring the existing `adk_additional_tools` convention). When set to true, `LoadSkillTool` routes the skill instructions through the existing `inject_session_state` utility before returning them, so a skill body can reference dynamic session state (`{var_name}`, `{artifact.file_name}`, `{optional?}`) the same way a system prompt can. Defaults to disabled so literal braces in skill markdown are preserved and existing skills are unaffected. A missing required variable returns a `STATE_INJECTION_ERROR` tool result rather than raising. Backport of upstream PR google#5974 (issue google#5973), adapted to the vzgpt-core skill activation logic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c780688 commit c92a8ce

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
@@ -258,9 +259,25 @@ async def run_async(
258259

259260
tool_context.state[state_key] = activated_skills
260261

262+
# Optionally inject session state (e.g. {var_name}, {artifact.file_name})
263+
# into the instructions. Disabled by default so that literal braces in
264+
# skill content are preserved unless the skill opts in via frontmatter.
265+
instructions = skill.instructions
266+
if skill.frontmatter.metadata.get("adk_inject_session_state"):
267+
try:
268+
instructions = await inject_session_state(instructions, tool_context)
269+
except (KeyError, ValueError) as e:
270+
return {
271+
"error": (
272+
f"Failed to inject session state into skill '{skill_name}'"
273+
f" instructions: {e}"
274+
),
275+
"error_code": "STATE_INJECTION_ERROR",
276+
}
277+
261278
return {
262279
"skill_name": skill_name,
263-
"instructions": skill.instructions,
280+
"instructions": instructions,
264281
"frontmatter": skill.frontmatter.model_dump(),
265282
}
266283

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)