Skip to content

Commit 4366cca

Browse files
committed
fix(tools): Prevent broken skill tool references when prefix is set and support tool_filter
- Dynamic prefixing for load_skill, load_skill_resource, run_skill_script, and search_skills when toolset has a tool_name_prefix configured. - Respect tool_filter in SkillToolset to correctly filter core and dynamic tools using the _is_tool_selected predicate. TAG=agy CONV=d82d2790-2c30-4d48-9d57-3c8f340280e2 Change-Id: I591065b097b03bc6dba5a9b3cb13ca95de62ed6b
1 parent 8f2c1e3 commit 4366cca

2 files changed

Lines changed: 131 additions & 32 deletions

File tree

src/google/adk/tools/skill_toolset.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ..skills import SkillRegistry
3939
from .base_tool import BaseTool
4040
from .base_toolset import BaseToolset
41+
from .base_toolset import ToolPredicate
4142
from .function_tool import FunctionTool
4243
from .tool_context import ToolContext
4344

@@ -56,34 +57,37 @@
5657
" conversation history for you to analyze."
5758
)
5859

59-
DEFAULT_SKILL_SYSTEM_INSTRUCTION = (
60-
"You can use specialized 'skills' to help you with complex tasks. "
61-
"You MUST use the skill tools to interact with these skills.\n\n"
62-
"Skills are folders of instructions and resources that extend your "
63-
"capabilities for specialized tasks. Each skill folder contains:\n"
64-
"- **SKILL.md** (required): The main instruction file with skill "
65-
"metadata and detailed markdown instructions.\n"
66-
"- **references/** (Optional): Additional documentation or examples for "
67-
"skill usage.\n"
68-
"- **assets/** (Optional): Templates, scripts or other resources used by "
69-
"the skill.\n"
70-
"- **scripts/** (Optional): Executable scripts that can be run via "
71-
"bash.\n\n"
72-
"This is very important:\n\n"
73-
"1. If a skill seems relevant to the current user query, you MUST use "
74-
'the `load_skill` tool with `skill_name="<SKILL_NAME>"` to read '
75-
"its full instructions before proceeding.\n"
76-
"2. Once you have read the instructions, follow them exactly as "
77-
"documented before replying to the user. For example, If the "
78-
"instruction lists multiple steps, please make sure you complete all "
79-
"of them in order.\n"
80-
"3. The `load_skill_resource` tool is for viewing files within a "
81-
"skill's directory (e.g., `references/*`, `assets/*`, `scripts/*`). "
82-
"Do NOT use other tools to access these files.\n"
83-
"4. Use `run_skill_script` to run scripts from a skill's `scripts/` "
84-
"directory. Use `load_skill_resource` to view script content first if "
85-
"needed.\n"
86-
)
60+
def _build_skill_system_instruction(prefix: str | None = None) -> str:
61+
p = f"{prefix}_" if prefix else ""
62+
63+
return (
64+
"You can use specialized 'skills' to help you with complex tasks. "
65+
"You MUST use the skill tools to interact with these skills.\n\n"
66+
"Skills are folders of instructions and resources that extend your "
67+
"capabilities for specialized tasks. Each skill folder contains:\n"
68+
"- **SKILL.md** (required): The main instruction file with skill "
69+
"metadata and detailed markdown instructions.\n"
70+
"- **references/** (Optional): Additional documentation or examples for "
71+
"skill usage.\n"
72+
"- **assets/** (Optional): Templates, scripts or other resources used by "
73+
"the skill.\n"
74+
"- **scripts/** (Optional): Executable scripts that can be run via "
75+
"bash.\n\n"
76+
"This is very important:\n\n"
77+
f"1. If a skill seems relevant to the current user query, you MUST use "
78+
f"the `{p}load_skill` tool with `skill_name=\"<SKILL_NAME>\"` to read "
79+
"its full instructions before proceeding.\n"
80+
"2. Once you have read the instructions, follow them exactly as "
81+
"documented before replying to the user. For example, If the "
82+
"instruction lists multiple steps, please make sure you complete all "
83+
"of them in order.\n"
84+
f"3. The `{p}load_skill_resource` tool is for viewing files within a "
85+
"skill's directory (e.g., `references/*`, `assets/*`, `scripts/*`). "
86+
"Do NOT use other tools to access these files.\n"
87+
f"4. Use `{p}run_skill_script` to run scripts from a skill's `scripts/` "
88+
f"directory. Use `{p}load_skill_resource` to view script content first if "
89+
"needed.\n"
90+
)
8791

8892

8993
class ListSkillsTool(BaseTool):
@@ -899,6 +903,8 @@ def __init__(
899903
code_executor: BaseCodeExecutor | None = None,
900904
script_timeout: int = _DEFAULT_SCRIPT_TIMEOUT,
901905
additional_tools: list[ToolUnion] | None = None,
906+
tool_name_prefix: str | None = None,
907+
tool_filter: ToolPredicate | list[str] | None = None,
902908
):
903909
"""Initializes the SkillToolset.
904910
@@ -911,8 +917,10 @@ def __init__(
911917
scripts executed via exec().
912918
additional_tools: Optional list of `BaseTool` or `BaseToolset` instances
913919
to be made available to the agent when certain skills are activated.
920+
tool_name_prefix: Optional prefix to prepend to tool names.
921+
tool_filter: Optional filter to select specific tools.
914922
"""
915-
super().__init__()
923+
super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix)
916924

917925
skills = skills or []
918926

@@ -964,7 +972,8 @@ async def get_tools(
964972
dynamic_tools = await self._resolve_additional_tools_from_state(
965973
readonly_context
966974
)
967-
return self._tools + dynamic_tools
975+
all_tools = self._tools + dynamic_tools
976+
return [t for t in all_tools if self._is_tool_selected(t, readonly_context)]
968977

969978
async def _resolve_additional_tools_from_state(
970979
self, readonly_context: ReadonlyContext | None
@@ -1076,7 +1085,7 @@ async def process_llm_request(
10761085
self, *, tool_context: ToolContext, llm_request: LlmRequest
10771086
) -> None:
10781087
"""Processes the outgoing LLM request to include available skills."""
1079-
instructions = [DEFAULT_SKILL_SYSTEM_INSTRUCTION]
1088+
instructions = [_build_skill_system_instruction(prefix=self.tool_name_prefix)]
10801089

10811090
has_list_skills = any(isinstance(t, ListSkillsTool) for t in self._tools)
10821091

@@ -1086,9 +1095,10 @@ async def process_llm_request(
10861095
instructions.append(skills_xml)
10871096

10881097
if self._registry:
1098+
p = f"{self.tool_name_prefix}_" if self.tool_name_prefix else ""
10891099
instructions.append(
10901100
"\nIf the locally available skills are not sufficient to complete "
1091-
"your task, you can use the `search_skills` tool to discover "
1101+
f"your task, you can use the `{p}search_skills` tool to discover "
10921102
"additional skills from the registry."
10931103
)
10941104

@@ -1103,3 +1113,5 @@ async def close(self) -> None:
11031113
cached.cancel()
11041114
self._fetched_skill_cache.clear()
11051115
await super().close()
1116+
1117+
DEFAULT_SKILL_SYSTEM_INSTRUCTION = _build_skill_system_instruction()

tests/unittests/tools/test_skill_toolset.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,3 +1917,90 @@ async def test_close_cancels_futures_and_clears_cache():
19171917
assert fut1.cancelled()
19181918
assert not fut2.cancelled() # Done futures shouldn't/can't be cancelled
19191919
assert not toolset._fetched_skill_cache
1920+
1921+
1922+
@pytest.mark.asyncio
1923+
async def test_process_llm_request_with_tool_name_prefix(
1924+
mock_skill1, mock_skill2, tool_context_instance, mock_registry
1925+
):
1926+
toolset = skill_toolset.SkillToolset(
1927+
[mock_skill1, mock_skill2],
1928+
registry=mock_registry,
1929+
tool_name_prefix="my_prefix",
1930+
)
1931+
1932+
# Manually remove ListSkillsTool from self._tools to simulate it not being available
1933+
# so that instructions[1] is generated with available_skills
1934+
toolset._tools = [
1935+
t
1936+
for t in toolset._tools
1937+
if not isinstance(t, skill_toolset.ListSkillsTool)
1938+
]
1939+
1940+
llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)
1941+
1942+
await toolset.process_llm_request(
1943+
tool_context=tool_context_instance, llm_request=llm_req
1944+
)
1945+
1946+
llm_req.append_instructions.assert_called_once()
1947+
args, _ = llm_req.append_instructions.call_args
1948+
instructions = args[0]
1949+
assert len(instructions) == 3
1950+
assert "`my_prefix_load_skill`" in instructions[0]
1951+
assert "`my_prefix_load_skill_resource`" in instructions[0]
1952+
assert "`my_prefix_run_skill_script`" in instructions[0]
1953+
assert "my_prefix_search_skills" in instructions[2]
1954+
1955+
1956+
@pytest.mark.asyncio
1957+
async def test_skill_toolset_with_list_tool_filter():
1958+
toolset = skill_toolset.SkillToolset(
1959+
tool_filter=["list_skills", "load_skill"]
1960+
)
1961+
tools = await toolset.get_tools()
1962+
tool_names = [t.name for t in tools]
1963+
assert "list_skills" in tool_names
1964+
assert "load_skill" in tool_names
1965+
assert "load_skill_resource" not in tool_names
1966+
assert "run_skill_script" not in tool_names
1967+
1968+
1969+
@pytest.mark.asyncio
1970+
async def test_skill_toolset_with_predicate_tool_filter():
1971+
# Filter to only tools containing 'resource' in their name
1972+
toolset = skill_toolset.SkillToolset(
1973+
tool_filter=lambda tool, ctx=None: "resource" in tool.name
1974+
)
1975+
tools = await toolset.get_tools()
1976+
tool_names = [t.name for t in tools]
1977+
assert tool_names == ["load_skill_resource"]
1978+
1979+
1980+
@pytest.mark.asyncio
1981+
async def test_skill_toolset_with_dynamic_tools_filter(
1982+
mock_skill1, tool_context_instance
1983+
):
1984+
mock_skill1.frontmatter.metadata = {
1985+
"adk_additional_tools": ["my_custom_tool"]
1986+
}
1987+
1988+
custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
1989+
custom_tool.name = "my_custom_tool"
1990+
1991+
toolset = skill_toolset.SkillToolset(
1992+
skills=[mock_skill1],
1993+
additional_tools=[custom_tool],
1994+
tool_filter=["list_skills", "my_custom_tool"],
1995+
)
1996+
1997+
state_key = "_adk_activated_skill_test_agent"
1998+
tool_context_instance.state.get.side_effect = lambda key, default=None: (
1999+
["skill1"] if key == state_key else default
2000+
)
2001+
2002+
tools = await toolset.get_tools(readonly_context=tool_context_instance)
2003+
tool_names = [t.name for t in tools]
2004+
assert "list_skills" in tool_names
2005+
assert "my_custom_tool" in tool_names
2006+
assert "load_skill" not in tool_names

0 commit comments

Comments
 (0)