Skip to content
Closed
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
63 changes: 63 additions & 0 deletions tests/skills/test_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

from unittest.mock import MagicMock

import pytest

from trpc_agent_sdk.context import reset_invocation_ctx
from trpc_agent_sdk.context import set_invocation_ctx
from trpc_agent_sdk.skills._dynamic_toolset import SkillToolSetWithDynamicTools
from trpc_agent_sdk.skills._toolset import SkillToolSet

Expand Down Expand Up @@ -74,6 +78,65 @@ async def test_get_tools_sets_metadata(self, tmp_path):
ctx.agent_context.with_metadata.assert_called()


@pytest.mark.parametrize("toolset_cls", [SkillToolSet, SkillToolSetWithDynamicTools])
class TestSkillToolSetFiltering:

async def test_name_filter_on_first_and_cached_calls(self, tmp_path, toolset_cls):
allowed = ["skill_load", "skill_list", "workspace_exec"]
ts = toolset_cls(paths=[str(tmp_path)], tool_filter=allowed, is_include_all_tools=False)

for _ in range(2):
tools = await ts.get_tools(_make_ctx())
assert {tool.name for tool in tools} == set(allowed)
assert len(tools) == len(allowed)

async def test_predicate_uses_current_context_without_filtering_cache(self, tmp_path, toolset_cls):
calls = []

def predicate(tool, ctx):
calls.append((tool.name, ctx))
return tool.name in ctx.allowed_tools

ts = toolset_cls(paths=[str(tmp_path)], tool_filter=predicate, is_include_all_tools=False)
for allowed in (set(), {"skill_run"}, {"skill_load"}):
ctx = _make_ctx()
ctx.allowed_tools = allowed
calls.clear()
tools = await ts.get_tools(ctx)
assert {tool.name for tool in tools} == allowed
assert calls
assert all(call_ctx is ctx for _, call_ctx in calls)

async def test_predicate_receives_implicit_context(self, tmp_path, toolset_cls):
predicate = MagicMock(side_effect=lambda tool, ctx: tool.name == "skill_load")
ts = toolset_cls(paths=[str(tmp_path)], tool_filter=predicate, is_include_all_tools=False)
ctx = _make_ctx()
token = set_invocation_ctx(ctx)
try:
for _ in range(2):
predicate.reset_mock()
tools = await ts.get_tools()
assert [tool.name for tool in tools] == ["skill_load"]
assert predicate.called
assert all(call.args[1] is ctx for call in predicate.call_args_list)
finally:
reset_invocation_ctx(token)

@pytest.mark.parametrize("tool_filter,is_include_all_tools", [
(None, False),
([], False),
(["skill_load"], True),
(lambda tool, ctx: False, True),
])
async def test_unfiltered_behavior_is_preserved(self, tmp_path, toolset_cls, tool_filter, is_include_all_tools):
ts = toolset_cls(paths=[str(tmp_path)], tool_filter=tool_filter, is_include_all_tools=is_include_all_tools)
first = [tool.name for tool in await ts.get_tools(_make_ctx())]
second = [tool.name for tool in await ts.get_tools(_make_ctx())]
assert {"skill_load", "skill_run", "skill_exec", "workspace_exec", "skill_list"} <= set(first)
assert first == second
assert len(first) == len(set(first))


class TestSkillToolSetWithDynamicTools:
async def test_get_tools_includes_dynamic_selection_helpers(self, tmp_path):
ts = SkillToolSetWithDynamicTools(paths=[str(tmp_path)])
Expand Down
8 changes: 4 additions & 4 deletions trpc_agent_sdk/skills/_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ async def get_tools(self, invocation_context: Optional[InvocationContext] = None
"""Get all tools from registered skills.

Args:
invocation_context: Optional invocation context (not used currently)
invocation_context: Optional invocation context for metadata and tool filtering.

Returns:
List of tools from all registered skills
List of tools selected for the current invocation
"""
if self._repo_resolver is not None:
repository = self._repo_resolver(invocation_context)
Expand All @@ -167,7 +167,7 @@ async def get_tools(self, invocation_context: Optional[InvocationContext] = None
if not is_exist_skill_config(agent_context):
set_skill_config(agent_context, self._skill_config)
if self._default_tools:
return self._default_tools.copy()
return [tool for tool in self._default_tools if self._is_tool_selected(tool, invocation_context)]
Comment on lines 169 to +170

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _default_tools 缓存路径(新增行 170)在缓存命中时按首个调用的 invocation_context 过滤后整体返回快照。_is_tool_selected 对 ToolPredicate 会调用 self._tool_filter(tool, invocation_context),因此过滤结果依赖调用时的上下文,而缓存列表本身不区分上下文——首次调用固化的过滤结果,后续请求无论传入什么上下文都读取同一份快照。

触发条件: 构造时配置了 tool_filter 为谓词(is_include_all_tools=False),且 get_tools() 首次调用发生在无显式上下文的位置(如 server/ag_ui/_core/_agui_agent.py:411 的 tool.get_tools(None)、_llm_agent.py 的工具预取),或首次调用与后续调用传入不同上下文。

实际影响: 后续所有真实请求的工具名集合与请求自身上下文无关:本应在的动词或工具(如 skill_run、workspace_exec)在 LLM 请求中缺失(convert_toolunion_to_tool_list->get_tools(context) 返回快照后,process_request 无法声明未包含的工具),LLM 无法调用;反之不应暴露的工具被固化暴露。缓存路径连 predicate 都不再被调用,违背计划"上下文感知过滤"意图。

修正方向: 将按请求上下文过滤的结果与缓存分离——缓存仅保存构建好的全量工具列表(_default_tools.copy() 原行为),每次调用都对该副本执行 _is_tool_selected;或确保首次调用带真实上下文,并为缓存结果记录生成时上下文、命中时重新过滤。


tools: List[ToolABC] = []
tools.append(self._load_tool)
Expand All @@ -184,4 +184,4 @@ async def get_tools(self, invocation_context: Optional[InvocationContext] = None
logger.warning("Failed to get tools from skill '%s': %s", skill_function.__name__, ex)
continue
self._default_tools.extend(tools)
return tools
return [tool for tool in tools if self._is_tool_selected(tool, invocation_context)]
Loading