From 445e9e0b02372edc494612ccd45e12986863b43c Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Fri, 9 Oct 2026 11:36:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DSkillToolSet=E4=B8=ADt?= =?UTF-8?q?ool=5Ffilter=E5=A4=B1=E6=95=88=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/skill.md | 68 +++++++++++++++++++++++++++++++ docs/mkdocs/zh/skill.md | 62 ++++++++++++++++++++++++++++ tests/skills/test_toolset.py | 48 ++++++++++++++++++++++ trpc_agent_sdk/skills/_toolset.py | 14 +++++-- 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/docs/mkdocs/en/skill.md b/docs/mkdocs/en/skill.md index 07b137de..05375e7d 100644 --- a/docs/mkdocs/en/skill.md +++ b/docs/mkdocs/en/skill.md @@ -183,6 +183,74 @@ Key points: - Package entry (aggregated exports): [trpc_agent_sdk/skills/tools/__init__.py](../../../trpc_agent_sdk/skills/tools/__init__.py) - `skill_run` implementation: [trpc_agent_sdk/skills/tools/_skill_run.py](../../../trpc_agent_sdk/skills/tools/_skill_run.py) (for other tools, see **Declaration location** in each section below) +#### Restricting Skill Tools by Use Case + +`SkillToolSet` exposes all built-in tools by default. You normally do not need +to configure a filter. To reduce the tools visible to the LLM, enforce access +control, or enable only a particular type of skill, configure `tool_filter` +together with `is_include_all_tools=False`: + +```python +# Instruction-only skill: load SKILL.md and docs without running its scripts. +skill_tool_set = SkillToolSet( + repository=repository, + tool_filter=["skill_load"], + is_include_all_tools=False, +) + +# Script-based skill: load its instructions, then run a one-shot command. +skill_tool_set = SkillToolSet( + repository=repository, + tool_filter=["skill_load", "skill_run"], + is_include_all_tools=False, + run_tool_kwargs={"require_skill_loaded": True}, +) + +# You can also make the decision dynamically for each invocation. +def select_skill_tool(tool, invocation_context): + allowed_tools = invocation_context.session_state.get( + "allowed_skill_tools", [] + ) + return tool.name in allowed_tools + +skill_tool_set = SkillToolSet( + repository=repository, + tool_filter=select_skill_tool, + is_include_all_tools=False, +) +``` + +`tool_filter` accepts either a list of tool names or a predicate function. The +predicate is evaluated by `get_tools()` against the current +`InvocationContext` on every invocation, so it can expose tools based on user +permissions, session state, or tenant configuration. The default +`is_include_all_tools=True` ignores the filter for backward compatibility; the +filter takes effect only when this option is set to `False`. + +The tools serve the following purposes: + +- `skill_load`: Loads the skill body and documentation. An instruction-only + skill that provides guidance, prompts, or domain knowledge usually needs only + this tool. +- `skill_run`: Runs a one-shot script or command from a skill. It can run + directly by default. If `run_tool_kwargs={"require_skill_loaded": True}` is + set, `skill_load` must also be allowed. +- `skill_exec`: Starts an interactive or long-running skill command. +- `skill_list`, `skill_list_docs`, and `skill_select_docs`: Optional helpers + for skill discovery and on-demand documentation selection. +- `workspace_exec`, `workspace_write_stdin`, and `workspace_kill_session`: + Run, provide input to, or terminate workspace commands directly. +- `workspace_save_artifact`: Saves workspace files as artifacts when needed. +- `skill_list_tools` and `skill_select_tools`: Available only from + `SkillToolSetWithDynamicTools` for dynamic business-tool selection. + +The filter uses allowlist semantics and does not automatically add dependencies. +For example, if only `skill_load` is allowed, the LLM cannot call `skill_run`. +An instruction-only skill can keep only `skill_load`; a typical script-based +skill should keep at least `skill_load` and `skill_run`. Add the corresponding +tools when documentation selection, interactive execution, workspace +operations, or artifacts are required. + ### 3) Running the Example Full interactive demo: [examples/skills/run_agent.py](../../../examples/skills/run_agent.py) diff --git a/docs/mkdocs/zh/skill.md b/docs/mkdocs/zh/skill.md index 566877f5..8bd8c092 100644 --- a/docs/mkdocs/zh/skill.md +++ b/docs/mkdocs/zh/skill.md @@ -182,6 +182,68 @@ Always use environment variables in commands: - **代码位置**: - 工具包入口(聚合导出):[trpc_agent_sdk/skills/tools/__init__.py](../../../trpc_agent_sdk/skills/tools/__init__.py) - `skill_run` 实现:[trpc_agent_sdk/skills/tools/_skill_run.py](../../../trpc_agent_sdk/skills/tools/_skill_run.py)(其余工具见下文各节「声明位置」) + +#### 按场景限制 Skill 工具 + +`SkillToolSet` 默认暴露全部内置工具。通常不需要配置过滤器;如果需要减少 +LLM 可见工具、实施权限控制,或者只启用某类 Skill,可以同时配置 +`tool_filter` 和 `is_include_all_tools=False`: + +```python +# 指导型 Skill:只加载 SKILL.md 和文档,不执行其中的脚本。 +skill_tool_set = SkillToolSet( + repository=repository, + tool_filter=["skill_load"], + is_include_all_tools=False, +) + +# 脚本型 Skill:先加载说明,再执行一次性命令。 +skill_tool_set = SkillToolSet( + repository=repository, + tool_filter=["skill_load", "skill_run"], + is_include_all_tools=False, + run_tool_kwargs={"require_skill_loaded": True}, +) + +# 也可以根据当前调用上下文动态判断。 +def select_skill_tool(tool, invocation_context): + allowed_tools = invocation_context.session_state.get( + "allowed_skill_tools", [] + ) + return tool.name in allowed_tools + +skill_tool_set = SkillToolSet( + repository=repository, + tool_filter=select_skill_tool, + is_include_all_tools=False, +) +``` + +`tool_filter` 支持工具名称列表或 Predicate 函数。Predicate 会在每次 +`get_tools()` 时使用当前 `InvocationContext` 重新判断,因此可以根据用户权限、 +会话状态或租户配置动态暴露工具。默认的 `is_include_all_tools=True` 会忽略过滤器, +保持向后兼容;只有设置为 `False` 时过滤器才会生效。 + +按用途可以将工具分为: + +- `skill_load`:加载 Skill 主体和文档。仅提供操作指导、Prompt 或领域知识的 + Skill 通常只需要该工具。 +- `skill_run`:执行 Skill 中的一次性脚本或命令。默认允许直接运行;如果设置 + `run_tool_kwargs={"require_skill_loaded": True}`,必须同时允许 `skill_load`。 +- `skill_exec`:启动需要交互或长时间运行的 Skill 命令。 +- `skill_list`、`skill_list_docs`、`skill_select_docs`:用于发现 Skill 和按需 + 选择文档,均为辅助工具。 +- `workspace_exec`、`workspace_write_stdin`、`workspace_kill_session`:直接执行、 + 输入或终止工作区命令。 +- `workspace_save_artifact`:需要将工作区文件保存为 Artifact 时使用。 +- `skill_list_tools`、`skill_select_tools`:仅由 + `SkillToolSetWithDynamicTools` 提供,用于动态业务工具选择。 + +过滤器采用白名单语义,不会自动补齐依赖。例如仅允许 `skill_load` 后,LLM +不能再调用 `skill_run`。因此,指导型 Skill 可以只保留 `skill_load`;常规脚本型 +Skill 建议至少保留 `skill_load` 和 `skill_run`;需要文档选择、交互执行、工作区 +操作或 Artifact 时,再加入对应工具。 + ### 3) 运行示例 完整示例交互式演示:[examples/skills/run_agent.py](../../../examples/skills/run_agent.py) diff --git a/tests/skills/test_toolset.py b/tests/skills/test_toolset.py index d630d26b..c669dbaa 100644 --- a/tests/skills/test_toolset.py +++ b/tests/skills/test_toolset.py @@ -16,6 +16,8 @@ from unittest.mock import MagicMock +import pytest + from trpc_agent_sdk.skills._dynamic_toolset import SkillToolSetWithDynamicTools from trpc_agent_sdk.skills._toolset import SkillToolSet @@ -28,6 +30,7 @@ def _make_ctx(): class TestSkillToolSetInit: + def test_default_init(self, tmp_path): ts = SkillToolSet(paths=[str(tmp_path)]) assert ts.name == "skill_toolset" @@ -41,6 +44,7 @@ def test_custom_repository(self): class TestSkillToolSetGetTools: + async def test_get_tools_returns_tools(self, tmp_path): ts = SkillToolSet(paths=[str(tmp_path)]) ctx = _make_ctx() @@ -74,7 +78,51 @@ 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_applies_to_first_and_cached_calls(self, tmp_path, toolset_cls): + ts = toolset_cls( + paths=[str(tmp_path)], + tool_filter=["skill_load"], + is_include_all_tools=False, + ) + + for _ in range(2): + tools = await ts.get_tools(_make_ctx()) + assert [tool.name for tool in tools] == ["skill_load"] + + async def test_predicate_rechecks_current_context_without_filtering_cache(self, tmp_path, toolset_cls): + + def predicate(tool, invocation_context): + return tool.name in invocation_context.allowed_tools + + ts = toolset_cls( + paths=[str(tmp_path)], + tool_filter=predicate, + is_include_all_tools=False, + ) + + for allowed_tools in ({"skill_run"}, {"skill_load"}): + ctx = _make_ctx() + ctx.allowed_tools = allowed_tools + tools = await ts.get_tools(ctx) + assert {tool.name for tool in tools} == allowed_tools + + async def test_include_all_tools_overrides_filter(self, tmp_path, toolset_cls): + ts = toolset_cls( + paths=[str(tmp_path)], + tool_filter=["skill_load"], + is_include_all_tools=True, + ) + + tools = await ts.get_tools(_make_ctx()) + assert "skill_load" in {tool.name for tool in tools} + assert len(tools) > 1 + + class TestSkillToolSetWithDynamicTools: + async def test_get_tools_includes_dynamic_selection_helpers(self, tmp_path): ts = SkillToolSetWithDynamicTools(paths=[str(tmp_path)]) ctx = _make_ctx() diff --git a/trpc_agent_sdk/skills/_toolset.py b/trpc_agent_sdk/skills/_toolset.py index a975df05..689ab8c5 100644 --- a/trpc_agent_sdk/skills/_toolset.py +++ b/trpc_agent_sdk/skills/_toolset.py @@ -144,15 +144,21 @@ def repository(self) -> BaseSkillRepository: """Get the skill repository.""" return self._repository + def _get_selected_tools(self, invocation_context: Optional[InvocationContext]) -> List[ToolABC]: + """Return tools selected for the current invocation.""" + if not self._tool_filter or self._is_include_all_tools: + return self._default_tools + return [tool for tool in self._default_tools if self._is_tool_selected(tool, invocation_context)] + @override async def get_tools(self, invocation_context: Optional[InvocationContext] = None) -> List[ToolABC]: """Get all tools from registered skills. Args: - invocation_context: Optional invocation context (not used currently) + invocation_context: Optional invocation context used for 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) @@ -167,7 +173,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 self._get_selected_tools(invocation_context) tools: List[ToolABC] = [] tools.append(self._load_tool) @@ -184,4 +190,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 self._get_selected_tools(invocation_context)