feat: 新增工具调用拦截确认机制#103
Open
PersonalViolet wants to merge 2 commits into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
该 PR 为 HelloAgents 的工具执行链路新增可插拔的 ToolInterceptor(Human-in-the-Loop) 能力,使工具在真正执行前可以被统一拦截、确认或拒绝,并覆盖同步/异步两条执行路径;同时补充了拦截器相关的测试、示例与可选依赖(anthropic / gemini)。
Changes:
- 新增
hello_agents.tools.interceptor:提供 AlwaysAllow / ConsoleConfirm / SessionConfirm / Callback 等拦截策略与异步支持。 - 在
Agent、ToolRegistry、ReActAgent的工具执行路径中接入拦截器;并将拦截器导出到包入口与hello_agents.tools。 - 增加拦截器测试与演示脚本;更新
pyproject.toml的 pytest asyncio 配置;更新 lockfile 与可选依赖。
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | 锁定新增可选依赖(anthropic、google-genai 等)及其传递依赖版本。 |
| pyproject.toml | pytest 配置新增 asyncio_mode = "auto"。 |
| hello_agents/tools/interceptor.py | 新增拦截器抽象与多种实现,支持同步/异步与会话缓存。 |
| hello_agents/tools/registry.py | ToolRegistry 执行前增加拦截器检查,并新增异步执行入口。 |
| hello_agents/core/agent.py | Agent 层工具执行前增加拦截器检查,并提供异步工具执行方法。 |
| hello_agents/agents/react_agent.py | ReActAgent 并行工具执行前增加异步拦截器检查。 |
| hello_agents/tools/init.py | 导出拦截器相关类型与实现。 |
| hello_agents/init.py | 顶层包导出拦截器相关类型与实现。 |
| tests/test_tool_interceptor.py | 新增拦截器系统与异步路径的单测、以及与 ToolRegistry 的集成测试。 |
| examples/tool_interceptor_demo.py | 新增拦截器演示脚本(控制台确认、会话缓存、回调等)。 |
| hello_agents/core/config.py | 新增拦截器相关配置字段(当前仅定义)。 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+175
to
+199
| # 检查拦截器(Human-in-the-loop) | ||
| import json as _json | ||
| params_for_intercept = input_text | ||
| if isinstance(input_text, str): | ||
| try: | ||
| params_for_intercept = _json.loads(input_text) | ||
| except _json.JSONDecodeError: | ||
| params_for_intercept = {"input": input_text} | ||
| elif isinstance(input_text, dict): | ||
| params_for_intercept = input_text | ||
| else: | ||
| params_for_intercept = {"input": str(input_text)} | ||
|
|
||
| interceptor_result = self.interceptor.intercept( | ||
| tool_name=name, | ||
| parameters=params_for_intercept, | ||
| context={"tool_registry": type(self).__name__} | ||
| ) | ||
| if interceptor_result.is_denied: | ||
| return ToolResponse.error( | ||
| code=ToolErrorCode.PERMISSION_DENIED, | ||
| message=f"工具 '{name}' 被拦截器拒绝: {interceptor_result.reason}", | ||
| context={"tool_name": name, "interceptor_reason": interceptor_result.reason} | ||
| ) | ||
|
|
Comment on lines
+229
to
+253
| # 检查拦截器(异步版本 - Human-in-the-loop) | ||
| import json as _json | ||
| params_for_intercept = input_text | ||
| if isinstance(input_text, str): | ||
| try: | ||
| params_for_intercept = _json.loads(input_text) | ||
| except _json.JSONDecodeError: | ||
| params_for_intercept = {"input": input_text} | ||
| elif isinstance(input_text, dict): | ||
| params_for_intercept = input_text | ||
| else: | ||
| params_for_intercept = {"input": str(input_text)} | ||
|
|
||
| interceptor_result = await self.interceptor.aintercept( | ||
| tool_name=name, | ||
| parameters=params_for_intercept, | ||
| context={"tool_registry": type(self).__name__} | ||
| ) | ||
| if interceptor_result.is_denied: | ||
| return ToolResponse.error( | ||
| code=ToolErrorCode.PERMISSION_DENIED, | ||
| message=f"工具 '{name}' 被拦截器拒绝: {interceptor_result.reason}", | ||
| context={"tool_name": name, "interceptor_reason": interceptor_result.reason} | ||
| ) | ||
|
|
Comment on lines
+700
to
+710
| # 0. 检查拦截器(Human-in-the-loop) | ||
| interceptor_result = self._tool_interceptor.intercept( | ||
| tool_name=tool_name, | ||
| parameters=arguments, | ||
| context={ | ||
| "agent_name": self.name, | ||
| "agent_type": self.__class__.__name__, | ||
| } | ||
| ) | ||
| if interceptor_result.is_denied: | ||
| return f"❌ 工具调用被拦截: {interceptor_result.reason}" |
Comment on lines
+768
to
+778
| # 0. 异步检查拦截器(Human-in-the-loop - 不阻塞事件循环) | ||
| interceptor_result = await self._tool_interceptor.aintercept( | ||
| tool_name=tool_name, | ||
| parameters=arguments, | ||
| context={ | ||
| "agent_name": self.name, | ||
| "agent_type": self.__class__.__name__, | ||
| } | ||
| ) | ||
| if interceptor_result.is_denied: | ||
| return f"❌ 工具调用被拦截: {interceptor_result.reason}" |
Comment on lines
+145
to
+149
| loop = asyncio.get_event_loop() | ||
| return await loop.run_in_executor( | ||
| None, | ||
| lambda: self.intercept(tool_name, parameters, context) | ||
| ) |
Comment on lines
+331
to
+335
| loop = asyncio.get_event_loop() | ||
| return await loop.run_in_executor( | ||
| None, | ||
| lambda: self._callback(tool_name, parameters, context) | ||
| ) |
Comment on lines
+342
to
+360
| def test_registry_whitelist_tool_bypasses(self): | ||
| """白名单工具应该绕过拦截器""" | ||
| from hello_agents.tools.registry import ToolRegistry | ||
|
|
||
| registry = ToolRegistry( | ||
| interceptor=ConsoleConfirmInterceptor( | ||
| whitelist={"CalculatorTool"} | ||
| ) | ||
| ) | ||
| # 白名单工具不应被拦截(在拦截前应返回 allow) | ||
| # 但这里工具不存在,所以最终会返回 NOT_FOUND | ||
| # 我们需要验证拦截器没有拒绝它 | ||
| # 实际上 calculator 在注册表中不存在,所以会走到 NOT_FOUND | ||
| # 但拦截器会先被检查 | ||
| result = registry.execute_tool("NonExistentTool", "test") | ||
| # 如果不在白名单中,console interceptor 会在 input() 上阻塞 | ||
| # 所以这里只测白名单路径 | ||
| pass | ||
|
|
Comment on lines
+93
to
+95
| # 注意:SessionConfirm 内部使用 ConsoleConfirm 作为 delegate, | ||
| # 在实际运行中第二次不会弹确认框,但这里 delegate 会再次触发 input() | ||
| print(f" 缓存条目数: {len(agent_interceptor._cache)}") |
Comment on lines
+700
to
+710
| # 0. 检查拦截器(Human-in-the-loop) | ||
| interceptor_result = self._tool_interceptor.intercept( | ||
| tool_name=tool_name, | ||
| parameters=arguments, | ||
| context={ | ||
| "agent_name": self.name, | ||
| "agent_type": self.__class__.__name__, | ||
| } | ||
| ) | ||
| if interceptor_result.is_denied: | ||
| return f"❌ 工具调用被拦截: {interceptor_result.reason}" |
Comment on lines
+420
to
+424
| def _make_key(self, tool_name: str, parameters: Dict[str, Any]) -> str: | ||
| """生成缓存键:(工具名, 参数哈希)""" | ||
| params_json = json.dumps(parameters, sort_keys=True, ensure_ascii=False) | ||
| params_hash = hashlib.sha256(params_json.encode()).hexdigest()[:16] | ||
| return f"{tool_name}:{params_hash}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
工具调用拦截器 — Human-in-the-Loop 确认机制
概述
工具调用拦截器(
ToolInterceptor)在工具实际执行之前插入一个可配置的确认节点,允许开发者在运行时决定是否放行某次工具调用。适用于文件写入确认、高危操作审批、付费 API 次数控制等场景。核心价值:
AlwaysAllowInterceptor直接放行,完全向后兼容快速开始
架构
Closes #102