Skip to content

fix: 修复SkillToolSet中tool_filter失效的问题(#352) - #357

Merged
weimch merged 1 commit into
mainfrom
fix/issue-352-tool-filter
Oct 10, 2026
Merged

weimch merged 1 commit into
mainfrom
fix/issue-352-tool-filter

Conversation

@raychen911

Copy link
Copy Markdown
Contributor

No description provided.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围与方法

审查 02509b2..445e9e0("fix: 修复SkillToolSet中tool_filter失效的问题",4 个文件 +188/-4:trpc_agent_sdk/skills/_toolset.py、tests/skills/test_toolset.py、docs/mkdocs/{en,zh}/skill.md)。采用 5 个正确性角度 + 3 个清理角度 + 海拔/规范角度共 10 路并行查找,并对每个候选跑独立验证(1 票 3 态),最后做缺口扫描;对关键绑定链(run_tool_kwargs 嵌套传参)用签名级推演直接复现。未修改仓库任何文件。

计划符合性

核心修复成立:tool_filter(名称列表与谓词)配合 is_include_all_tools=False 现在真正生效,首次调用与缓存调用均经过 _get_selected_tools 过滤;新增测试覆盖两条路径与 include-all 覆盖行为,SkillToolSetWithDynamicTools 也被参数化覆盖;文档与实现的基本语义一致(三个示例均正确设置 is_include_all_tools=False)。核心正确性、并发(get_tools 无 await,并发首调天然串行)、filters= 透传(经 **run_tool_kwargs 到达 SkillRunTool(filters=...),非回归)均核对通过。

主要风险(本变更引入)

  1. 谓词过滤在无有效上下文(a2a 卡片构建 get_tools(None)、ag_ui、独立调用,contextvar 默认 None)下对 None 解引用崩溃,被外层吞掉后 a2a 卡片静默丢失全部 Skill 工具(MODERATE)。
  2. 谓词按每次调用求值后,子代理继承同一 SkillToolSet 却以全新空会话求值,继承的全部 Skill 工具被静默过滤为空(MODERATE)。
  3. 新增文档的 run_tool_kwargs={"require_skill_loaded": True} 写法在运行期被静默丢弃(require_skill_loaded 非 SkillRunInput.model_fields 键),文档承诺的门控不生效(MODERATE,已用参数绑定推演证实)。
  4. 默认路径从 _default_tools.copy() 改为返回内部可变列表的别名泄漏(仓库内调用方均只读,潜在回归,LOW);空列表 tool_filter=[] 白名单语义反转(LOW);DynamicSkillToolSet._tool_cache 使谓词"每次重新判断"契约在动态层失效(LOW)。
  5. 测试缺口:谓词 ctx=None、include-all 完整集合断言、tuple/set/空列表边界均未覆盖(LOW)。

测试充分性

新增 3 个参数化测试验证了主流程(列表过滤、谓词按上下文重判、include-all 覆盖),但在上述真实失败路径(None 上下文、子代理、空过滤、非 list 过滤)上存在缺口。

门禁结论

无高置信的 SEVERE 级缺陷(核心修复正确,无数据损坏与主运行路径崩溃),存在 2 个 MODERATE 运行时行为回归与 1 个 MODERATE 文档-行为不一致,以及若干 LOW 边界/契约回归。结论:PASSED,建议合入前修复谓词 None 上下文与子代理两类 MODERATE 问题并修正文档示例。

发现的问题

中等

trpc_agent_sdk/skills/_toolset.py:147-151

问题: 新增的 _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 场景补充测试。

中等

trpc_agent_sdk/skills/_toolset.py:175-176

问题: 本次变更使 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,要么在无继承会话状态时跳过谓词并显式告警,并在本次新增的文档中说明谓词过滤与子代理的交互及限制。

中等

docs/mkdocs/zh/skill.md:203-206

问题: 本次新增的中英文档示例 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)打印告警以免静默失效。

较低

trpc_agent_sdk/skills/_toolset.py:149-150

问题: _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 短路,一行表达式同时消除重复判断并天然返回新列表,一举修复别名泄漏与重复逻辑。

较低

trpc_agent_sdk/skills/_toolset.py:149-151

问题: _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,并明确空列表语义(返回空工具集或抛出校验错误)。

较低

trpc_agent_sdk/skills/_toolset.py:175-176

问题: 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 复核一次谓词,或为缓存项记录求值时上下文并按其失效。

较低

tests/skills/test_toolset.py:95-121

问题: 本次新增测试未覆盖修复行为的关键失败路径与边界: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/空列表过滤的边界用例。

Comment on lines +147 to +151
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)]

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 175 to +176
if self._default_tools:
return self._default_tools.copy()
return self._get_selected_tools(invocation_context)

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 thread docs/mkdocs/zh/skill.md
Comment on lines +203 to +206
tool_filter=["skill_load", "skill_run"],
is_include_all_tools=False,
run_tool_kwargs={"require_skill_loaded": True},
)

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)打印告警以免静默失效。

Comment on lines +149 to +150
if not self._tool_filter or self._is_include_all_tools:
return self._default_tools

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 短路,一行表达式同时消除重复判断并天然返回新列表,一举修复别名泄漏与重复逻辑。

Comment on lines +149 to +151
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)]

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,并明确空列表语义(返回空工具集或抛出校验错误)。

Comment on lines 175 to +176
if self._default_tools:
return self._default_tools.copy()
return self._get_selected_tools(invocation_context)

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 复核一次谓词,或为缓存项记录求值时上下文并按其失效。

Comment on lines +95 to +121
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

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/空列表过滤的边界用例。

@weimch weimch left a comment

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.

Approve

@weimch
weimch merged commit 8ce3612 into main Oct 10, 2026
5 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants