Skip to content
Merged
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
68 changes: 68 additions & 0 deletions docs/mkdocs/en/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions docs/mkdocs/zh/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
Comment on lines +203 to +206

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.

问题: 本次新增的中英文档示例 run_tool_kwargs={"require_skill_loaded": True} 声称开启“必须先 skill_load 才能 skill_run”的门控,但该嵌套写法在运行期被静默丢弃:SkillToolSet.__init__ 的 **run_tool_kwargs(_toolset.py:81)把这个嵌套 dict 以名为 run_tool_kwargs 的关键字传给 SkillRunTool,落入其 **kwargs(_skill_run.py:384,436)并存入 self._run_tool_kwargs;执行期仅将 SkillRunInput.model_fields 内的键写回参数(_skill_run.py:647-650),而 require_skill_loaded 不是输入字段,构造参数 _require_skill_loaded 保持默认 False(已用签名级绑定推演验证)。只有 examples/skills_code_review_agent/agent/tools.py:78 那种把 require_skill_loaded=True 作为顶层关键字传入才生效。

触发条件: 用户按本次新增的 zh/en skill.md 文档示例原样配置 SkillToolSet。

实际影响: 文档承诺的访问约束门控不生效,skill_run 无需先 skill_load 即可直接调用,与文档“必须同时允许 skill_load”的说明矛盾且无任何警告,治理型部署按文档配置后得不到预期保护。

修正方向: 将文档示例改为顶层 require_skill_loaded=True(与 GovernedSkillToolSet 实际用法一致),或在 SkillRunTool 中对 _run_tool_kwargs 中无法识别的键(非 SkillRunInput.model_fields)打印告警以免静默失效。


# 也可以根据当前调用上下文动态判断。
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)
Expand Down
48 changes: 48 additions & 0 deletions tests/skills/test_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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
Comment on lines +95 to +121

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.

问题: 本次新增测试未覆盖修复行为的关键失败路径与边界:1) 谓词型过滤在 ctx=None(a2a 卡片、ag_ui、独立 get_tools())下崩溃的场景没有用例;2) test_include_all_tools_overrides_filter 仅断言 len(tools) > 1,未断言等于完整未过滤集合,回归到“部分工具被误过滤”也能通过;3) 空列表 tool_filter=[] 与非 list 可迭代(tuple/set,_is_tool_selected 对它们会落到底部 return False 静默返回零工具)均未覆盖。

触发条件: 谓词过滤真实部署到 a2a 卡片、子代理等路径时的回归无法被这些测试拦截。

实际影响: 测试对修复行为给出虚假信心,静默丢工具(卡片/子代理/零工具)等真实缺陷会直接发布。

修正方向: 补充 ctx=None 谓词用例、is_include_all_tools=True 下与完整集合的相等断言,以及 tuple/set/空列表过滤的边界用例。



class TestSkillToolSetWithDynamicTools:

async def test_get_tools_includes_dynamic_selection_helpers(self, tmp_path):
ts = SkillToolSetWithDynamicTools(paths=[str(tmp_path)])
ctx = _make_ctx()
Expand Down
14 changes: 10 additions & 4 deletions trpc_agent_sdk/skills/_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +149 to +150

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.

问题: _get_selected_tools 在默认路径(tool_filter=None 或 is_include_all_tools=True,即绝大多数用例)直接返回内部列表 self._default_tools 本身,取代了变更前 return self._default_tools.copy()(行 177 旧代码)的快照语义;首次调用路径(行 192-193)也从返回局部新建的 tools 列表变成返回共享缓存列表。此后所有 get_tools() 调用方拿到的是同一个可变列表对象,且过滤路径返回新列表、默认路径返回共享列表,同一实例两种语义并存。

触发条件: 任一调用方对 get_tools() 返回值做 append/pop/sort/remove/clear 等原地修改。当前仓库内 8 处调用(_tool_adapter.py:104、_claude_agent.py:461,501、_agui_agent.py:411、_mcp.py:44、_dynamic_toolset.py:193、_runner.py:59 等)经逐一核对均为只读,风险为潜在回归;但对发布版 SDK 的外部调用方,旧契约(返回快照)允许修改返回值。

实际影响: 一旦发生修改,_default_tools 缓存被永久污染,后续所有会话的工具列表缺失或错乱;此变更删除了有意为之的防御性拷贝。

修正方向: 两条路径统一为 return [tool for tool in self._default_tools if self._is_tool_selected(tool, invocation_context)]——base 的 _is_tool_selected(abc/_toolset.py:104)已自带 not self._tool_filter or self._is_include_all_tools 短路,一行表达式同时消除重复判断并天然返回新列表,一举修复别名泄漏与重复逻辑。

return [tool for tool in self._default_tools if self._is_tool_selected(tool, invocation_context)]
Comment on lines +147 to +151

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.

问题: 新增的 _get_selected_tools 把调用方传入的 invocation_context 原样传给 base 的 _is_tool_selected(abc/_toolset.py:107-108),谓词型 tool_filter 在上下文缺失时会对 None 直接解引用——本次新增文档中的谓词示例 invocation_context.session_state.get("allowed_skill_tools", []) 会抛 AttributeError。变更前 get_tools 从不执行谓词(这也是本修复的目标),此崩溃路径完全由本次变更引入。

触发条件: 配置 tool_filter=<谓词函数> 且 is_include_all_tools=False,随后 get_tools 在无有效上下文处被调用:server/a2a/_agent_card_builder.py:197 与 a2a_v1/_agent_card_builder.py:212 显式传 None(卡片构建发生在 agent.run 之外,而 get_invocation_ctx() 的 contextvar 默认值也是 None,行 167-168 的回退无效);server/ag_ui/_core/_agui_agent.py:411 同样传 None;或用户按 skills/__init__.py:25 的文档示例单独调用 await toolset.get_tools()(contextvar 未设置)。

实际影响: a2a 卡片构建与 ag_ui 长时运行检测的 try/except(_agent_card_builder.py:149-154、_agui_agent.py:410-414)吞掉异常后,Agent 卡片上全部 Skill 工具被静默丢弃(运行时缓存已填充、单独跑正常,卡片与实际能力长期不一致);独立调用场景则直接向调用方抛出 AttributeError。

修正方向: 在 _get_selected_tools 中对 invocation_context is None 跳过谓词求值(符合 base ToolSetABC.get_tools 文档 'If None, all tools are returned' 的契约),或改为先取 get_invocation_ctx() 且保证非 None 后再调用谓词,并为 ctx=None 场景补充测试。

Comment on lines +149 to +151

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.

问题: _get_selected_tools 行 149 用 if not self._tool_filter or self._is_include_all_tools 判断,空列表 [] 是 falsy,tool_filter=[] + is_include_all_tools=False 时会走 return self._default_tools 暴露全部工具,与本次文档宣传的“过滤器采用白名单语义、用于权限控制”相反。

触发条件: 运维以空列表表达“临时禁用全部工具 / 零白名单”(在访问控制场景下是合理意图)。

实际影响: 静默暴露全部内置工具(含 skill_exec、workspace_exec 等执行类工具),权限控制意图被反转且无任何告警。

修正方向: 将判断改为 if self._tool_filter is None or self._is_include_all_tools,并明确空列表语义(返回空工具集或抛出校验错误)。


@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)
Expand All @@ -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)
Comment on lines 175 to +176

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.

问题: 本次变更使 tool_filter 谓词在每次 get_tools() 时以“当前” InvocationContext 重新求值;而子代理通过 _BorrowedToolSet(agents/sub_agent/_runner.py:58-59,168)继承父级 SkillToolSet 实例,子代理会话是全新空状态(_runner.py:376 state={})。父级配置的会话相关谓词(正是文档示例的模式)在子代理上下文中求值恒返回 false,全部继承的 Skill 工具被过滤为空。变更前过滤是死代码,子代理始终继承完整工具集,属本次修复引入的行为回归。

触发条件: 父级 SkillToolSet 配置依赖 session_state 的谓词过滤(is_include_all_tools=False),并以默认方式(archetype.tools is None 继承父级工具)派生子代理。

实际影响: 子代理在无任何报错的情况下丢失全部 Skill 工具(ToolsProcessor 仅记录 'No valid tools to add to request'),能力被静默削弱;若谓词还会崩溃,子代理请求直接失败。父代理与子代理看到的工具能力不一致。

修正方向: 明确子代理场景下谓词的求值上下文——要么让 _BorrowedToolSet/子代理透传父级 parent_ctx,要么在无继承会话状态时跳过谓词并显式告警,并在本次新增的文档中说明谓词过滤与子代理的交互及限制。

Comment on lines 175 to +176

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.

问题: DynamicSkillToolSet._resolve_tool(skills/_dynamic_toolset.py:181-198)把 toolset.get_tools(ctx) 返回的每个工具永久缓存在 _tool_cache 且优先命中(行 181-182)。当该动态工具集的 available_toolsets 中含有本次新启用过滤的 SkillToolSet 时,工具名一旦在宽松上下文下首次解析,之后的收紧上下文调用直接命中缓存返回,谓词不再重新求值——与本次新增文档“Predicate 会在每次 get_tools() 时使用当前 InvocationContext 重新判断”的承诺冲突。

触发条件: DynamicSkillToolSet(其 docstring 行 76-86 明确支持 ToolSet 混合配置)与带谓词过滤的 SkillToolSet 组合使用,且技能选中的工具名此前已被解析过;或 a2a/ag_ui 的 preflight 空结果先被缓存。

实际影响: 按会话/租户收紧的访问控制在动态工具层被静默绕过(已缓存工具直接返回),或 preflight 的截断结果被永久缓存导致工具持续缺失,与权限控制类部署场景直接相关。

修正方向: 在 _resolve_tool 中区分“工具实例缓存”与“每次按上下文重新做的谓词判定”——缓存命中也应每次用当前 ctx 复核一次谓词,或为缓存项记录求值时上下文并按其失效。


tools: List[ToolABC] = []
tools.append(self._load_tool)
Expand All @@ -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)
Loading