Skip to content

Commit 3afdb08

Browse files
Koichi73haranrk
authored andcommitted
feat(skills): inject session state into SKILL.md via adk_inject_state
Merge #5405 ### Link to Issue or Description of Change - Closes: #5404 **Problem:** SKILL.md bodies cannot reference session state the same way `LlmAgent.instruction` can. Today the only way to surface a state value inside a skill is to register a custom getter tool through `SkillToolset(additional_tools=[...])` and tell the model to call it. That is boilerplate for a capability the agent already has. **Solution:** Reuse the existing `instructions_utils.inject_session_state` helper from `LoadSkillTool`. When a skill opts in with `metadata.adk_inject_state: true` in its frontmatter, the skill body is interpolated with the same `{var}` / `{var?}` / `{artifact.name}` syntax that `LlmAgent.instruction` supports. The feature is strictly additive and gated on an opt-in flag, so existing SKILL.md files are unaffected. ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. Added to `tests/unittests/tools/test_skill_toolset.py`: - `test_load_skill_run_async_injects_state_when_opt_in` - `test_load_skill_run_async_skips_injection_when_opt_out` - `test_load_skill_run_async_skips_injection_when_metadata_absent` Added to `tests/unittests/skills/test_models.py`: - `test_metadata_adk_inject_state_bool` - `test_metadata_adk_inject_state_rejected_as_string` Existing mock fixtures for `skill1` / `skill2` frontmatter now explicitly set `metadata = {}`, documenting the default and preventing autospec-mock leakage into the new opt-in check. `pytest` summary: ``` $ pytest tests/unittests/tools/test_skill_toolset.py tests/unittests/skills/test_models.py 111 passed, 7 warnings in 11.39s ``` **Manual End-to-End (E2E) Tests:** 1. In a skill directory, add `metadata: {adk_inject_state: true}` to `SKILL.md` frontmatter and reference a state variable in the body, e.g. `The user prefers {temperature_unit?}.` 2. Seed session state with `session.state["temperature_unit"] = "celsius"` before the agent runs. 3. Run the agent (`adk run` or `adk web`) and trigger the skill. The `load_skill` tool response now contains the instructions with `{temperature_unit}` expanded to `celsius`. 4. Remove or set `adk_inject_state: false` and re-run — the same SKILL.md returns the literal `{temperature_unit?}` string, confirming backward compatibility. ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [x] Any dependent changes have been merged and published in downstream modules. ### Additional context This is the read side of a two-part effort to connect SKILL.md with session state. A follow-up PR will propose an `output_key`-style facility for writing state from a skill. Splitting the two keeps each review small and focused. Co-authored-by: Haran Rajkumar <haranrk@google.com> COPYBARA_INTEGRATE_REVIEW=#5405 from Koichi73:feat/skill-md-inject-state 44067c2 PiperOrigin-RevId: 936248442
1 parent 431e3c2 commit 3afdb08

4 files changed

Lines changed: 106 additions & 2 deletions

File tree

src/google/adk/skills/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ 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. Set
54+
``adk_inject_state: true`` to enable ``{var}`` interpolation in the
55+
SKILL.md body when the skill is loaded via ``load_skill`` (same syntax
56+
as ``LlmAgent.instruction``). Each ``{var}`` is replaced with the
57+
matching value read from the invocation's session state; use ``{var?}``
58+
to substitute an empty string when the key is absent (instead of
59+
raising), and ``{artifact.name}`` to inject artifact contents.
5460
"""
5561

5662
model_config = ConfigDict(
@@ -76,6 +82,8 @@ def _validate_metadata(cls, v: dict[str, Any]) -> dict[str, Any]:
7682
tools = v["adk_additional_tools"]
7783
if not isinstance(tools, list):
7884
raise ValueError("adk_additional_tools must be a list of strings")
85+
if "adk_inject_state" in v and not isinstance(v["adk_inject_state"], bool):
86+
raise ValueError("adk_inject_state must be a bool")
7987
return v
8088

8189
@field_validator("name")

src/google/adk/tools/skill_toolset.py

Lines changed: 9 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 import instructions_utils
3940
from .base_tool import BaseTool
4041
from .base_toolset import BaseToolset
4142
from .base_toolset import ToolPredicate
@@ -251,9 +252,16 @@ async def run_async(
251252
activated_skills.append(skill_name)
252253
tool_context.state[state_key] = activated_skills
253254

255+
instructions = skill.instructions
256+
if skill.frontmatter.metadata.get("adk_inject_state"):
257+
instructions = await instructions_utils.inject_session_state(
258+
instructions,
259+
tool_context,
260+
)
261+
254262
return {
255263
"skill_name": skill_name,
256-
"instructions": skill.instructions,
264+
"instructions": instructions,
257265
"frontmatter": skill.frontmatter.model_dump(),
258266
}
259267

tests/unittests/skills/test_models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,21 @@ 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_state_bool():
238+
fm = models.Frontmatter.model_validate({
239+
"name": "my-skill",
240+
"description": "desc",
241+
"metadata": {"adk_inject_state": True},
242+
})
243+
assert fm.metadata["adk_inject_state"] is True
244+
245+
246+
def test_metadata_adk_inject_state_rejected_as_string():
247+
with pytest.raises(ValidationError, match="adk_inject_state must be a bool"):
248+
models.Frontmatter.model_validate({
249+
"name": "my-skill",
250+
"description": "desc",
251+
"metadata": {"adk_inject_state": "true"},
252+
})

tests/unittests/tools/test_skill_toolset.py

Lines changed: 70 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",
@@ -310,6 +312,74 @@ async def test_load_skill_run_async_state_none(
310312
)
311313

312314

315+
@pytest.mark.asyncio
316+
async def test_load_skill_run_async_injects_state_when_opt_in(
317+
mock_skill1, mock_skill1_frontmatter, tool_context_instance
318+
):
319+
mock_skill1.instructions = "Hello {user_name}!"
320+
mock_skill1_frontmatter.metadata = {"adk_inject_state": True}
321+
toolset = skill_toolset.SkillToolset([mock_skill1])
322+
tool = skill_toolset.LoadSkillTool(toolset)
323+
324+
with mock.patch.object(
325+
skill_toolset.instructions_utils,
326+
"inject_session_state",
327+
autospec=True,
328+
) as mock_inject:
329+
mock_inject.return_value = "Hello Alice!"
330+
result = await tool.run_async(
331+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
332+
)
333+
334+
mock_inject.assert_awaited_once()
335+
call_args = mock_inject.await_args
336+
assert call_args.args[0] == "Hello {user_name}!"
337+
assert result["instructions"] == "Hello Alice!"
338+
339+
340+
@pytest.mark.asyncio
341+
async def test_load_skill_run_async_skips_injection_when_opt_out(
342+
mock_skill1, mock_skill1_frontmatter, tool_context_instance
343+
):
344+
mock_skill1.instructions = "Hello {user_name}!"
345+
mock_skill1_frontmatter.metadata = {"adk_inject_state": False}
346+
toolset = skill_toolset.SkillToolset([mock_skill1])
347+
tool = skill_toolset.LoadSkillTool(toolset)
348+
349+
with mock.patch.object(
350+
skill_toolset.instructions_utils,
351+
"inject_session_state",
352+
autospec=True,
353+
) as mock_inject:
354+
result = await tool.run_async(
355+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
356+
)
357+
358+
mock_inject.assert_not_called()
359+
assert result["instructions"] == "Hello {user_name}!"
360+
361+
362+
@pytest.mark.asyncio
363+
async def test_load_skill_run_async_skips_injection_when_metadata_absent(
364+
mock_skill1, tool_context_instance
365+
):
366+
mock_skill1.instructions = "Hello {user_name}!"
367+
toolset = skill_toolset.SkillToolset([mock_skill1])
368+
tool = skill_toolset.LoadSkillTool(toolset)
369+
370+
with mock.patch.object(
371+
skill_toolset.instructions_utils,
372+
"inject_session_state",
373+
autospec=True,
374+
) as mock_inject:
375+
result = await tool.run_async(
376+
args={"skill_name": "skill1"}, tool_context=tool_context_instance
377+
)
378+
379+
mock_inject.assert_not_called()
380+
assert result["instructions"] == "Hello {user_name}!"
381+
382+
313383
@pytest.mark.asyncio
314384
@pytest.mark.parametrize(
315385
"args, expected_result",

0 commit comments

Comments
 (0)