diff --git a/REACT_MASTER_V2_LOOP_BUG_FIX.md b/REACT_MASTER_V2_LOOP_BUG_FIX.md new file mode 100644 index 00000000..56c7797b --- /dev/null +++ b/REACT_MASTER_V2_LOOP_BUG_FIX.md @@ -0,0 +1,190 @@ +# ReActMasterV2 循环执行工具 Bug 修复报告 + +## 问题概述 + +ReActMasterV2 Agent 在执行过程中出现循环调用同一个工具的问题,导致任务无法正常完成。 + +### 现象 + +用户询问"今天12点30是否有系统异常"时,Agent 反复执行同一个工具调用: + +``` +view {"path": "/Users/tuyang/GitHub/OpenDerisk/pilot/data/skill/open_rca_diagnosis/SKILL.md"} +``` + +这个工具被调用了 3 次以上,形成无限循环。 + +## 根本原因 + +### 问题定位 + +在 `packages/derisk-core/src/derisk/agent/core/base_agent.py` 的 `generate_reply` 方法中(line 820-827),存在一个条件判断错误: + +```python +if self.current_retry_counter > 0: + if self.run_mode != AgentRunMode.LOOP: # ❌ 问题所在 + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages( + agent_llm_out, act_outs + ) + all_tool_messages.extend(tool_messages) +``` + +### 问题分析 + +1. **ReActMasterV2 的运行模式**: + - ReActMasterV2 使用 `AgentRunMode.LOOP` 模式 + - 这意味着它会循环执行多个迭代,直到任务完成 + +2. **Bug 的影响**: + - 条件 `self.run_mode != AgentRunMode.LOOP` 导致 LOOP 模式的 Agent **不会**将工具调用结果追加到 `all_tool_messages` + - 结果:LLM 在每次迭代时都看不到之前的工具调用结果 + - LLM 认为还没有调用过工具,于是再次调用同一个工具 + - 形成无限循环 + +3. **为什么 WorkLog 没起作用**: + - WorkLog 确实记录了工具调用(通过 `_record_action_to_work_log`) + - 但 WorkLog 的注入只在循环开始前执行一次(条件 `self.current_retry_counter == 0`) + - 在 LOOP 模式的后续迭代中,WorkLog 不会被重新获取 + - 即使 WorkLog 记录了工具调用,它也不会被转换为 tool_messages 传给 LLM + +## 修复方案 + +### 代码修改 + +移除 `self.run_mode != AgentRunMode.LOOP` 条件,让所有模式的 Agent 都能接收工具调用结果: + +**修改前(BUGGY)**: +```python +if self.current_retry_counter > 0: + if self.run_mode != AgentRunMode.LOOP: # ❌ 移除这个条件 + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages( + agent_llm_out, act_outs + ) + all_tool_messages.extend(tool_messages) +``` + +**修改后(FIXED)**: +```python +if self.current_retry_counter > 0: + if self.enable_function_call: # ✅ 所有模式都执行 + tool_messages = self.function_callning_reply_messages( + agent_llm_out, act_outs + ) + all_tool_messages.extend(tool_messages) +``` + +### 修复文件 + +- **文件路径**:`packages/derisk-core/src/derisk/agent/core/base_agent.py` +- **修改行**:Line 821 +- **修改类型**:移除条件判断 + +## 修复效果 + +### 修复前的行为 + +``` +Iteration 1: + - LLM 调用 view("/path/to/SKILL.md") + - 结果:技能文件内容 + - ❌ 结果未添加到 all_tool_messages(因为是 LOOP 模式) + +Iteration 2: + - LLM prompt:与 iteration 1 相同(没有工具结果可见) + - LLM 认为:"我应该加载技能文件" + - LLM 再次调用 view("/path/to/SKILL.md") ← 相同调用 + - ❌ 结果未添加到 all_tool_messages + +Iteration 3: + - 与 iteration 2 相同 + - 无限循环! +``` + +### 修复后的行为 + +``` +Iteration 1: + - LLM 调用 view("/path/to/SKILL.md") + - 结果:技能文件内容 + - ✅ 结果添加到 all_tool_messages + +Iteration 2: + - LLM prompt:包含 iteration 1 的工具结果 + - LLM 看到:"我已经加载了技能文件,现在应该..." + - LLM 根据技能内容调用下一个工具 + - 结果:分析数据 + - ✅ 结果添加到 all_tool_messages + +Iteration 3: + - LLM prompt:包含 iterations 1 和 2 的结果 + - LLM 做出最终决策 + - 调用 terminate 完成任务 +``` + +## 验证 + +### 诊断脚本 + +创建了两个诊断脚本: + +1. **`diagnose_loop_tool_messages.py`**:检测 bug 是否存在 +2. **`verify_loop_fix.py`**:验证修复是否成功 + +### 验证结果 + +```bash +$ python3 verify_loop_fix.py + +✅ FIX APPLIED: Buggy condition has been removed +✅ CORRECT CODE: Tool messages are now appended for all modes +``` + +## 影响范围 + +### 受影响的 Agent + +- **ReActMasterV2**:主要受影响的 Agent +- **所有使用 AgentRunMode.LOOP 模式的 Agent** + +### 受益的功能 + +- ✅ 工具调用结果现在能正确传递给 LLM +- ✅ LLM 能基于历史结果做出明智决策 +- ✅ 防止因 LLM 不知道工具已调用而导致的无限循环 +- ✅ WorkLog 记录现在能通过 tool_messages 对 LLM 可见 + +## 后续步骤 + +1. **重启服务器**:应用代码修改 +2. **测试验证**: + - 使用之前导致循环的查询进行测试 + - 验证工具结果现在在 LLM prompt 中可见 + - 确认任务能正常完成 + +3. **监控**: + - 观察 ReActMasterV2 的执行日志 + - 确认不再出现重复工具调用 + - 验证任务完成效率提升 + +## 总结 + +这个 bug 是一个典型的"上下文丢失"问题: + +- **症状**:Agent 循环调用同一个工具 +- **根因**:LOOP 模式的 Agent 在迭代间丢失了工具调用结果 +- **修复**:移除错误的条件判断,让所有模式都能接收工具结果 +- **效果**:Agent 现在能基于历史结果做出正确决策,避免无限循环 + +修复后,ReActMasterV2 将能够: +- 正确执行多步骤任务 +- 基于前序工具结果做决策 +- 高效完成任务,不再陷入循环 + +--- + +**修复日期**:2026-03-09 +**修复文件**:`packages/derisk-core/src/derisk/agent/core/base_agent.py` +**修复行数**:Line 821 +**修复类型**:移除条件判断 `self.run_mode != AgentRunMode.LOOP` \ No newline at end of file diff --git a/diagnose_loop_tool_messages.py b/diagnose_loop_tool_messages.py new file mode 100644 index 00000000..355dad6f --- /dev/null +++ b/diagnose_loop_tool_messages.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Diagnostic script to verify ReActMasterV2 loop tool_messages bug. + +Issue: In AgentRunMode.LOOP mode, tool call results are NOT appended to all_tool_messages, +causing LLM to repeatedly call the same tool because it doesn't see previous results. + +Root Cause: Line 821 in base_agent.py has condition `self.run_mode != AgentRunMode.LOOP` +which skips appending tool_messages for LOOP mode agents. + +Expected Behavior: Tool call results should be appended to all_tool_messages for ALL modes. +""" + +import sys +from pathlib import Path + +# Add project paths +_project_root = Path(__file__).parent +sys.path.insert(0, str(_project_root / "packages/derisk-core/src")) + + +def check_base_agent_code(): + """Check if the bug exists in base_agent.py""" + print("=" * 80) + print("Checking base_agent.py for LOOP mode tool_messages bug") + print("=" * 80) + + base_agent_path = ( + _project_root / "packages/derisk-core/src/derisk/agent/core/base_agent.py" + ) + + if not base_agent_path.exists(): + print(f"❌ File not found: {base_agent_path}") + return False + + with open(base_agent_path, "r") as f: + lines = f.readlines() + + # Find the problematic code section (around line 820-827) + print("\n📍 Checking lines 820-827 for the bug condition:\n") + + bug_found = False + for i in range(819, min(828, len(lines))): + line = lines[i] + line_num = i + 1 + print(f" {line_num:4d}: {line.rstrip()}") + + # Check for the bug condition + if "if self.run_mode != AgentRunMode.LOOP:" in line: + bug_found = True + print( + "\n ⚠️ BUG FOUND: This condition prevents LOOP mode agents from getting tool_messages!" + ) + + print("\n" + "-" * 80) + + if bug_found: + print("❌ BUG CONFIRMED: LOOP mode agents will NOT receive tool call results") + print("\n🔧 Impact:") + print(" - ReActMasterV2 (LOOP mode) will repeatedly call the same tool") + print(" - LLM doesn't see previous tool results in next iteration") + print(" - WorkLog records tools but doesn't inject them to LLM prompt") + print("\n💡 Fix: Remove the 'self.run_mode != AgentRunMode.LOOP' condition") + print(" OR handle LOOP mode specially to inject tool messages") + else: + print("✅ No bug found in this section (may have been fixed)") + + return bug_found + + +def explain_the_bug(): + """Explain the bug in detail""" + print("\n" + "=" * 80) + print("DETAILED BUG EXPLANATION") + print("=" * 80) + + print(""" +## Problem + +ReActMasterV2 uses AgentRunMode.LOOP mode to execute multiple iterations. +In each iteration, it should: + 1. Call a tool + 2. Get result + 3. Pass result to LLM in next iteration + 4. LLM decides next action based on results + +## What Actually Happens + +In base_agent.py generate_reply() method (line 820-827): + + if self.current_retry_counter > 0: + if self.run_mode != AgentRunMode.LOOP: # ⚠️ PROBLEM: This excludes LOOP mode! + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages(agent_llm_out, act_outs) + all_tool_messages.extend(tool_messages) # ❌ NOT executed for LOOP mode + +Result: +- For LOOP mode agents, tool_messages are NEVER appended to all_tool_messages +- LLM sees the SAME context in each iteration (no tool results) +- LLM calls the same tool again → infinite loop + +## Why WorkLog Doesn't Help + +WorkLog injection happens only ONCE at the start (line 798-804): + + if self.enable_function_call and self.current_retry_counter == 0: + worklog_messages = await self._get_worklog_tool_messages() + all_tool_messages.extend(worklog_messages) + +The condition `self.current_retry_counter == 0` means WorkLog is only fetched once. +In subsequent LOOP iterations, WorkLog is NOT re-fetched. + +## Solution + +Remove the `self.run_mode != AgentRunMode.LOOP` condition to allow LOOP mode agents +to receive tool call results in each iteration: + + if self.current_retry_counter > 0: + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages(agent_llm_out, act_outs) + all_tool_messages.extend(tool_messages) +""") + + +def suggest_fix(): + """Suggest the fix""" + print("\n" + "=" * 80) + print("SUGGESTED FIX") + print("=" * 80) + + print(""" +## File: packages/derisk-core/src/derisk/agent/core/base_agent.py + +## Location: Line 820-827 + +## Current Code (BUGGY): +```python +if self.current_retry_counter > 0: + if self.run_mode != AgentRunMode.LOOP: # ❌ Remove this condition + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages(agent_llm_out, act_outs) + all_tool_messages.extend(tool_messages) +``` + +## Fixed Code: +```python +if self.current_retry_counter > 0: + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages(agent_llm_out, act_outs) + all_tool_messages.extend(tool_messages) +``` + +## Why This Works: +- Removes the LOOP mode exclusion +- All agents (including ReActMasterV2) will now receive tool call results +- LLM can see previous tool results and make informed decisions +- Prevents infinite loops caused by LLM not knowing tools were already called +""") + + +def main(): + print("\n" + "🔍" * 40) + print("ReActMasterV2 LOOP Mode Tool Messages Bug Diagnostic") + print("🔍" * 40 + "\n") + + # Check for the bug + bug_exists = check_base_agent_code() + + # Explain the bug + explain_the_bug() + + # Suggest fix + suggest_fix() + + # Summary + print("\n" + "=" * 80) + print("SUMMARY") + print("=" * 80) + + if bug_exists: + print("❌ Bug confirmed in base_agent.py line 821") + print("✅ Fix: Remove 'self.run_mode != AgentRunMode.LOOP' condition") + print( + "\nThis will resolve the issue where ReActMasterV2 repeatedly calls tools" + ) + print("without seeing previous results, causing infinite loops.") + return 1 + else: + print("✅ Bug may have been fixed or code has changed") + print("Please verify manually that LOOP mode agents receive tool messages") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/derisk-core/src/derisk/agent/core/base_agent.py b/packages/derisk-core/src/derisk/agent/core/base_agent.py index 2b7b42a9..48e60007 100644 --- a/packages/derisk-core/src/derisk/agent/core/base_agent.py +++ b/packages/derisk-core/src/derisk/agent/core/base_agent.py @@ -818,13 +818,12 @@ async def generate_reply( current_goal = received_message.current_goal observation = received_message.observation if self.current_retry_counter > 0: - if self.run_mode != AgentRunMode.LOOP: - if self.enable_function_call: - ## 基于当前action的结果,构建history_dialogue 和 tool_message - tool_messages = self.function_callning_reply_messages( - agent_llm_out, act_outs - ) - all_tool_messages.extend(tool_messages) + if self.enable_function_call: + ## 基于当前action的结果,构建history_dialogue 和 tool_message + tool_messages = self.function_callning_reply_messages( + agent_llm_out, act_outs + ) + all_tool_messages.extend(tool_messages) observation = reply_message.observation rounds = reply_message.rounds + 1 diff --git a/packages/derisk-core/src/derisk/agent/core_v2/builtin_agents/base_builtin_agent.py b/packages/derisk-core/src/derisk/agent/core_v2/builtin_agents/base_builtin_agent.py index 92b4ddc3..af56f9f0 100644 --- a/packages/derisk-core/src/derisk/agent/core_v2/builtin_agents/base_builtin_agent.py +++ b/packages/derisk-core/src/derisk/agent/core_v2/builtin_agents/base_builtin_agent.py @@ -450,8 +450,10 @@ async def execute_tool( ) try: - # 使用 ToolRegistry 的 execute 方法 - result = await self.tools.execute(tool_name, tool_args, kwargs) + context = dict(kwargs) + if self.sandbox_manager is not None: + context["sandbox_manager"] = self.sandbox_manager + result = await self.tools.execute(tool_name, tool_args, context) return result except Exception as e: logger.exception(f"[{self.__class__.__name__}] 工具执行异常: {tool_name}") diff --git a/packages/derisk-core/src/derisk/agent/expand/actions/tool_action.py b/packages/derisk-core/src/derisk/agent/expand/actions/tool_action.py index 2f2e79e9..77efee49 100644 --- a/packages/derisk-core/src/derisk/agent/expand/actions/tool_action.py +++ b/packages/derisk-core/src/derisk/agent/expand/actions/tool_action.py @@ -482,7 +482,7 @@ async def run( view=view, observations=None, ask_user=False, - state=Status.COMPLETE.value, + state=status, # 使用根据执行结果计算的 status thoughts=param.thought, terminate=isinstance(tool_info, Terminate), cost_ms=cost_ms, @@ -752,10 +752,22 @@ async def _execute_tool(self, tool_info: BaseTool, args: Any, **kwargs) -> Any: for k, v in system_args.items(): if k not in arguments: arguments[k] = v + + # Build context with sandbox_manager for sandbox tools + tool_context = None + if ( + agent + and hasattr(agent, "sandbox_manager") + and agent.sandbox_manager + ): + tool_context = {"sandbox_manager": agent.sandbox_manager} + if tool_info.is_async: - raw_content = await tool_info.async_execute(**arguments) + raw_content = await tool_info.async_execute( + **arguments, context=tool_context + ) else: - raw_content = tool_info.execute(**arguments) + raw_content = tool_info.execute(**arguments, context=tool_context) normalized_content, is_success, error_msg = self._normalize_content( raw_content diff --git a/packages/derisk-core/src/derisk/agent/expand/react_master_agent/react_master_agent.py b/packages/derisk-core/src/derisk/agent/expand/react_master_agent/react_master_agent.py index 4a380bff..57df3336 100644 --- a/packages/derisk-core/src/derisk/agent/expand/react_master_agent/react_master_agent.py +++ b/packages/derisk-core/src/derisk/agent/expand/react_master_agent/react_master_agent.py @@ -996,6 +996,36 @@ async def act( logger.warning( "⚠️ No tool call returned by LLM, will inject system reminder" ) + + # 预检查:获取工具名称并检查是否被禁止 + tool_name_to_check = None + if hasattr(real_action, "action_input") and hasattr( + real_action.action_input, "tool_name" + ): + tool_name_to_check = real_action.action_input.tool_name + elif hasattr(real_action, "name"): + tool_name_to_check = real_action.name + + if tool_name_to_check and self._is_tool_blocked(tool_name_to_check): + logger.warning( + f"🚫 Tool '{tool_name_to_check}' is blocked due to consecutive failures. Skipping execution." + ) + # 直接创建失败结果,跳过执行 + blocked_output = ActionOutput( + content=f"工具 [{tool_name_to_check}] 连续失败超过 {self._max_tool_failure_count} 次,已终止执行。请尝试使用其他工具或修改参数后重试。", + name=real_action.name + if hasattr(real_action, "name") + else tool_name_to_check, + action=tool_name_to_check, + action_name=tool_name_to_check, + is_exe_success=False, + state=Status.FAILED.value, + have_retry=False, + view=f"❌ **工具执行被阻止**\n\n工具 `{tool_name_to_check}` 已连续失败多次,系统已自动终止该工具的执行。\n\n请尝试使用其他工具或修改参数后重试。", + ) + act_outs.append(blocked_output) + continue + if hasattr(real_action, "prepare_init_msg"): init_report = await real_action.prepare_init_msg( ai_message=message.content if message.content else "", @@ -1112,6 +1142,16 @@ async def act( # 记录到 PhaseManager self.record_phase_action(tool_name, result.is_exe_success) + # 工具执行成功或失败时,重置该工具的连续失败计数 + if result.is_exe_success: + self._reset_tool_failure_count(tool_name) + else: + # 工具执行失败(非异常),也记录失败次数 + should_stop = self._check_and_record_tool_failure(tool_name) + if should_stop: + result.content = f"工具 [{tool_name}] 连续失败超过 {self._max_tool_failure_count} 次,已终止执行。\n\n{result.content or ''}" + result.view = f"❌ **工具执行失败**\n\n工具 `{tool_name}` 已连续失败多次,系统已自动终止该工具的执行。\n\n{result.view or result.content or ''}" + # ========== 集成:记录到 WorkLog ========== logger.info( f"📝 Calling _record_action_to_work_log for {tool_name}..." @@ -1278,12 +1318,73 @@ async def _attach_delivery_files( return action_out + def _check_and_record_tool_failure(self, tool_name: str) -> bool: + """ + 记录工具失败并检查是否应停止执行 + + Args: + tool_name: 工具名称 + + Returns: + bool: 是否应该停止执行该工具(失败次数超过阈值) + """ + if not tool_name: + return False + + # 增加失败计数 + self._tool_failure_counts[tool_name] = ( + self._tool_failure_counts.get(tool_name, 0) + 1 + ) + failure_count = self._tool_failure_counts[tool_name] + + logger.warning( + f"⚠️ Tool '{tool_name}' failed ({failure_count}/{self._max_tool_failure_count} consecutive failures)" + ) + + # 检查是否超过阈值 + if failure_count >= self._max_tool_failure_count: + logger.error( + f"🚫 Tool '{tool_name}' has failed {failure_count} times consecutively. " + f"Blocking further execution of this tool." + ) + return True + + return False + + def _is_tool_blocked(self, tool_name: str) -> bool: + """ + 检查工具是否已被禁止执行(失败次数超过阈值) + + Args: + tool_name: 工具名称 + + Returns: + bool: 是否已被禁止 + """ + if not tool_name: + return False + failure_count = self._tool_failure_counts.get(tool_name, 0) + return failure_count >= self._max_tool_failure_count + + def _reset_tool_failure_count(self, tool_name: str = None): + """ + 重置工具失败计数 + + Args: + tool_name: 工具名称,如果为 None 则重置所有工具 + """ + if tool_name: + self._tool_failure_counts[tool_name] = 0 + else: + self._tool_failure_counts.clear() + def get_stats(self) -> Dict[str, Any]: """获取 Agent 运行统计信息""" stats = { "tool_call_count": self._tool_call_count, "compaction_count": self._compaction_count, "prune_count": self._prune_count, + "tool_failure_counts": dict(self._tool_failure_counts), } if self._doom_loop_detector: @@ -1302,6 +1403,7 @@ def reset_stats(self): self._tool_call_count = 0 self._compaction_count = 0 self._prune_count = 0 + self._tool_failure_counts.clear() if self._doom_loop_detector: self._doom_loop_detector.reset() diff --git a/packages/derisk-core/src/derisk/agent/tools/agent_adapter.py b/packages/derisk-core/src/derisk/agent/tools/agent_adapter.py index ff2200ec..a2ff0c31 100644 --- a/packages/derisk-core/src/derisk/agent/tools/agent_adapter.py +++ b/packages/derisk-core/src/derisk/agent/tools/agent_adapter.py @@ -24,32 +24,32 @@ class AgentToolAdapter: """ Agent工具适配器 - + 提供Agent与新工具框架的集成接口: 1. 工具发现和加载 2. 工具执行 3. 权限检查 4. 结果处理 - + 使用方式: adapter = AgentToolAdapter(agent) - + # 获取可用工具 tools = adapter.get_available_tools() - + # 执行工具 result = await adapter.execute_tool("read", {"path": "/tmp/file.txt"}) """ - + def __init__( self, agent: Any = None, registry: ToolRegistry = None, - tool_ids: List[str] = None + tool_ids: List[str] = None, ): """ 初始化适配器 - + Args: agent: Agent实例(Core或CoreV2) registry: 工具注册表 @@ -59,175 +59,181 @@ def __init__( self._registry = registry or tool_registry self._tool_ids = tool_ids self._resource_manager = tool_resource_manager - + # 同步工具资源 self._resource_manager.sync_from_registry() - + # === 工具发现 === - + def get_available_tools(self) -> List[ToolBase]: """ 获取Agent可用的工具列表 - + Returns: List[ToolBase]: 可用工具列表 """ all_tools = self._registry.list_all() - + if self._tool_ids: return [t for t in all_tools if t.name in self._tool_ids] - + return all_tools - + def get_tool(self, tool_name: str) -> Optional[ToolBase]: """获取指定工具""" return self._registry.get(tool_name) - + def get_tool_metadata(self, tool_name: str) -> Optional[ToolMetadata]: """获取工具元数据""" tool = self.get_tool(tool_name) return tool.metadata if tool else None - + def get_tools_for_llm(self) -> List[Dict[str, Any]]: """ 获取给LLM使用的工具列表 - + Returns: List[Dict]: OpenAI格式的工具列表 """ tools = self.get_available_tools() return [t.to_openai_tool() for t in tools] - + # === 工具执行 === - + async def execute_tool( self, tool_name: str, args: Dict[str, Any], - context: Optional[Union[ToolContext, Dict[str, Any]]] = None + context: Optional[Union[ToolContext, Dict[str, Any]]] = None, ) -> ToolResult: """ 执行工具 - + Args: tool_name: 工具名称 args: 工具参数 context: 执行上下文 - + Returns: ToolResult: 执行结果 """ tool = self.get_tool(tool_name) if not tool: return ToolResult.fail( - error=f"Tool not found: {tool_name}", - tool_name=tool_name + error=f"Tool not found: {tool_name}", tool_name=tool_name ) - + # 检查权限 if tool.metadata.requires_permission and context: if not self._check_permission(tool, context): return ToolResult.fail( error="Permission denied", tool_name=tool_name, - error_code="PERMISSION_DENIED" + error_code="PERMISSION_DENIED", ) - + # 构建上下文 tool_context = self._build_context(context) - + # 验证参数 if not tool.validate_args(args): return ToolResult.fail( error="Invalid arguments", tool_name=tool_name, - error_code="INVALID_ARGS" + error_code="INVALID_ARGS", ) - + try: # 预处理 args = await tool.pre_execute(args) - + # 执行 result = await tool.execute(args, tool_context) - + # 后处理 result = await tool.post_execute(result) - + # 更新统计 tool_id = f"{tool.metadata.source.value}_{tool.metadata.name}" self._resource_manager.increment_call_count(tool_id, result.success) - + return result - + except asyncio.TimeoutError: return ToolResult.timeout(tool_name, tool.metadata.timeout) except Exception as e: logger.error(f"[AgentToolAdapter] 工具执行失败: {tool_name}, error: {e}") - return ToolResult.fail( - error=str(e), - tool_name=tool_name - ) - + return ToolResult.fail(error=str(e), tool_name=tool_name) + # === 权限检查 === - + def _check_permission( - self, - tool: ToolBase, - context: Union[ToolContext, Dict[str, Any]] + self, tool: ToolBase, context: Union[ToolContext, Dict[str, Any]] ) -> bool: """检查执行权限""" if isinstance(context, ToolContext): user_permissions = context.user_permissions else: user_permissions = context.get("user_permissions", []) - + required = tool.metadata.required_permissions - + for perm in required: if perm not in user_permissions and "*" not in user_permissions: return False - + return True - + # === 上下文构建 === - + def _build_context( - self, - context: Optional[Union[ToolContext, Dict[str, Any]]] = None + self, context: Optional[Union[ToolContext, Dict[str, Any]]] = None ) -> ToolContext: """构建工具上下文""" if isinstance(context, ToolContext): return context - + if context is None: context = {} - + # 从Agent提取上下文信息 if self._agent: context.setdefault("agent_id", getattr(self._agent, "agent_id", None)) context.setdefault("agent_name", getattr(self._agent, "name", None)) - + # Core Agent特有字段 if hasattr(self._agent, "agent_context"): agent_ctx = self._agent.agent_context - context.setdefault("conversation_id", getattr(agent_ctx, "conv_id", None)) + context.setdefault( + "conversation_id", getattr(agent_ctx, "conv_id", None) + ) context.setdefault("user_id", getattr(agent_ctx, "user_id", None)) - + # CoreV2 Agent特有字段 if hasattr(self._agent, "context"): agent_ctx = self._agent.context - context.setdefault("conversation_id", getattr(agent_ctx, "conversation_id", None)) - + context.setdefault( + "conversation_id", getattr(agent_ctx, "conversation_id", None) + ) + + # 注入沙箱管理器(用于沙箱工具) + if hasattr(self._agent, "sandbox_manager"): + sandbox_manager = self._agent.sandbox_manager + if sandbox_manager is not None: + # 将 sandbox_manager 注入到 config 中,供 SandboxToolBase 使用 + config = context.setdefault("config", {}) + if isinstance(config, dict): + config["sandbox_manager"] = sandbox_manager + return ToolContext(**context) - + # === Agent特定适配 === - - def adapt_for_core(self) -> 'CoreToolAdapter': + + def adapt_for_core(self) -> "CoreToolAdapter": """适配Core Agent""" return CoreToolAdapter(self) - - def adapt_for_core_v2(self) -> 'CoreV2ToolAdapter': + + def adapt_for_core_v2(self) -> "CoreV2ToolAdapter": """适配CoreV2 Agent""" return CoreV2ToolAdapter(self) @@ -235,166 +241,176 @@ def adapt_for_core_v2(self) -> 'CoreV2ToolAdapter': class CoreToolAdapter: """ Core Agent工具适配器 - + 提供Core Agent与工具框架的桥接 """ - + def __init__(self, adapter: AgentToolAdapter): self._adapter = adapter - + def to_resource(self) -> Dict[str, Any]: """ 转换为Core资源格式 - + Returns: Dict: Core Agent的资源格式 """ tools = self._adapter.get_available_tools() - + resources = [] for tool in tools: - resources.append({ - "name": tool.metadata.name, - "description": tool.metadata.description, - "parameters": tool.parameters, - "type": "function" - }) - + resources.append( + { + "name": tool.metadata.name, + "description": tool.metadata.description, + "parameters": tool.parameters, + "type": "function", + } + ) + return {"tools": resources} - + def to_action_format(self) -> List[Dict[str, Any]]: """ 转换为Core Action格式 - + Returns: List[Dict]: Core Agent的Action格式 """ tools = self._adapter.get_available_tools() - + actions = [] for tool in tools: - actions.append({ - "action": tool.metadata.name, - "description": tool.metadata.description, - "args": tool.parameters - }) - + actions.append( + { + "action": tool.metadata.name, + "description": tool.metadata.description, + "args": tool.parameters, + } + ) + return actions - + async def execute_for_core( - self, - action_input: Dict[str, Any], - agent_context: Any = None + self, action_input: Dict[str, Any], agent_context: Any = None ) -> Dict[str, Any]: """ 为Core Agent执行工具 - + Args: action_input: Core Agent的Action输入 agent_context: Core Agent的上下文 - + Returns: Dict: Core Agent的执行结果格式 """ tool_name = action_input.get("tool_name") or action_input.get("action") args = action_input.get("args", {}) - + context = {} if agent_context: context["conversation_id"] = getattr(agent_context, "conv_id", None) context["user_id"] = getattr(agent_context, "user_id", None) - + result = await self._adapter.execute_tool(tool_name, args, context) - + return { "is_exe_success": result.success, "content": result.output, "error": result.error, - "metadata": result.metadata + "metadata": result.metadata, } class CoreV2ToolAdapter: """ CoreV2 Agent工具适配器 - + 提供CoreV2 Agent与工具框架的桥接 """ - + def __init__(self, adapter: AgentToolAdapter): self._adapter = adapter - + def to_harness_format(self) -> Dict[str, Any]: """ 转换为CoreV2 Harness格式 - + Returns: Dict: CoreV2 Harness的工具配置 """ tools = self._adapter.get_available_tools() - + return { "tools": [t.to_openai_tool() for t in tools], "tool_configs": { t.metadata.name: { "timeout": t.metadata.timeout, "risk_level": t.metadata.risk_level.value, - "requires_permission": t.metadata.requires_permission + "requires_permission": t.metadata.requires_permission, } for t in tools - } + }, } - + async def execute_for_core_v2( - self, - tool_call: Dict[str, Any], - execution_context: Any = None + self, tool_call: Dict[str, Any], execution_context: Any = None ) -> Dict[str, Any]: """ 为CoreV2 Agent执行工具 - + Args: tool_call: 工具调用信息 execution_context: CoreV2执行上下文 - + Returns: Dict: CoreV2工具执行结果 """ tool_name = tool_call.get("name") or tool_call.get("function", {}).get("name") - args = tool_call.get("args") or tool_call.get("function", {}).get("arguments", {}) - + args = tool_call.get("args") or tool_call.get("function", {}).get( + "arguments", {} + ) + if isinstance(args, str): import json + args = json.loads(args) - + context = {} if execution_context: context["agent_id"] = getattr(execution_context, "agent_id", None) - context["conversation_id"] = getattr(execution_context, "conversation_id", None) + context["conversation_id"] = getattr( + execution_context, "conversation_id", None + ) context["trace_id"] = getattr(execution_context, "trace_id", None) - + result = await self._adapter.execute_tool(tool_name, args, context) - + return { "tool_call_id": tool_call.get("id"), "role": "tool", "name": tool_name, - "content": str(result.output) if result.success else f"Error: {result.error}", + "content": str(result.output) + if result.success + else f"Error: {result.error}", "success": result.success, - "metadata": result.metadata + "metadata": result.metadata, } # === 便捷函数 === -def create_tool_adapter_for_agent(agent: Any, tool_ids: List[str] = None) -> AgentToolAdapter: + +def create_tool_adapter_for_agent( + agent: Any, tool_ids: List[str] = None +) -> AgentToolAdapter: """ 为Agent创建工具适配器 - + Args: agent: Agent实例 tool_ids: 可用工具ID列表 - + Returns: AgentToolAdapter: 工具适配器 """ @@ -404,12 +420,12 @@ def create_tool_adapter_for_agent(agent: Any, tool_ids: List[str] = None) -> Age def get_tools_for_agent(agent_type: str = "core") -> List[Dict[str, Any]]: """ 获取Agent可用的工具列表 - + Args: agent_type: Agent类型 (core/core_v2) - + Returns: List[Dict]: 工具列表 """ adapter = AgentToolAdapter() - return adapter.get_tools_for_llm() \ No newline at end of file + return adapter.get_tools_for_llm() diff --git a/packages/derisk-core/src/derisk/agent/tools/base.py b/packages/derisk-core/src/derisk/agent/tools/base.py index 29e42b02..2e215f8b 100644 --- a/packages/derisk-core/src/derisk/agent/tools/base.py +++ b/packages/derisk-core/src/derisk/agent/tools/base.py @@ -197,6 +197,30 @@ async def post_execute(self, result) -> Any: """执行后钩子""" return result + async def async_execute(self, *args, **kwargs): + """ + 异步执行工具的统一入口 + + 这是执行工具的推荐方法,内部会调用 execute 方法。 + 提供此方法是为了保持与旧版 FunctionTool 接口的兼容性。 + + Args: + *args: 位置参数 + **kwargs: 关键字参数,可包含 'context' 用于传递执行上下文 + + Returns: + ToolResult: 执行结果 + """ + context = kwargs.pop("context", None) + if args and len(args) >= 1: + if isinstance(args[0], dict): + merged_args = dict(args[0]) + merged_args.update(kwargs) + return await self.execute(merged_args, context) + return await self.execute(*args, **kwargs, context=context) + else: + return await self.execute(kwargs, context) + def validate_args(self, args: Dict[str, Any]) -> bool: """ 验证参数 diff --git a/packages/derisk-core/src/derisk/agent/tools/builtin/sandbox/base.py b/packages/derisk-core/src/derisk/agent/tools/builtin/sandbox/base.py index 6b2da2fc..2f2845f4 100644 --- a/packages/derisk-core/src/derisk/agent/tools/builtin/sandbox/base.py +++ b/packages/derisk-core/src/derisk/agent/tools/builtin/sandbox/base.py @@ -30,7 +30,7 @@ def _get_sandbox_client(self, context: Optional[ToolContext]) -> Any: 从上下文获取沙箱客户端 Args: - context: 工具上下文 + context: 工具上下文(可以是 ToolContext 或普通字典) Returns: SandboxBase: 沙箱客户端实例 @@ -41,6 +41,28 @@ def _get_sandbox_client(self, context: Optional[ToolContext]) -> Any: if context is None: return None + # 支持普通字典类型的 context + if isinstance(context, dict): + sandbox_manager = context.get("sandbox_manager") + if sandbox_manager is not None: + if hasattr(sandbox_manager, "client"): + return sandbox_manager.client + if hasattr(sandbox_manager, "get_client"): + return sandbox_manager.get_client() + # 尝试直接获取 sandbox_client + client = context.get("sandbox_client") + if client is not None: + return client + # 尝试从 config 中获取 + config = context.get("config", {}) + sandbox_manager = config.get("sandbox_manager") + if sandbox_manager is not None: + if hasattr(sandbox_manager, "client"): + return sandbox_manager.client + if hasattr(sandbox_manager, "get_client"): + return sandbox_manager.get_client() + return None + # 尝试从 context 的 config 中获取 client = context.config.get("sandbox_client") if client is not None: diff --git a/packages/derisk-serve/src/derisk_serve/agent/resource/tool/mcp_utils.py b/packages/derisk-serve/src/derisk_serve/agent/resource/tool/mcp_utils.py index faca77fb..ea3c3d56 100644 --- a/packages/derisk-serve/src/derisk_serve/agent/resource/tool/mcp_utils.py +++ b/packages/derisk-serve/src/derisk_serve/agent/resource/tool/mcp_utils.py @@ -22,51 +22,34 @@ GptsToolMessages, ) +logger = logging.getLogger(__name__) +tool_cache = TTLCache(maxsize=200, ttl=300) +gpts_tool_messages_dao = GptsToolMessagesDao() +gpts_tool_dao = GptsToolDao() + +CFG = Config() + def _make_json_serializable(obj: Any) -> Any: - """将对象转换为 JSON 可序列化的格式。 + """ + 递归地将对象转换为 JSON 可序列化的格式 + + Args: + obj: 任意对象 - 处理包括 AgentFileSystem 等非序列化对象,将其转换为字符串或字典表示。 + Returns: + JSON 可序列化的对象 """ - if obj is None: - return None - if isinstance(obj, (str, int, float, bool)): + if obj is None or isinstance(obj, (bool, int, float, str)): return obj - if isinstance(obj, (list, tuple)): + elif isinstance(obj, (list, tuple)): return [_make_json_serializable(item) for item in obj] - if isinstance(obj, dict): + elif isinstance(obj, dict): return {k: _make_json_serializable(v) for k, v in obj.items()} - - # 处理 AgentFileSystem 和其他常见非序列化类型 - type_name = type(obj).__name__ - - # AgentFileSystem 类型 - 转换为字符串表示 - if type_name == "AgentFileSystem": - return f"" - - # Pydantic 模型 - 使用 model_dump 或 dict - if hasattr(obj, "model_dump"): - return _make_json_serializable(obj.model_dump()) - if hasattr(obj, "dict"): - return _make_json_serializable(obj.dict()) - - # dataclass - 使用 asdict - if hasattr(obj, "__dataclass_fields__"): - from dataclasses import asdict - return _make_json_serializable(asdict(obj)) - - # 其他对象 - 尝试转换为字符串 - try: + else: + # 对于不可序列化的对象,返回其字符串表示 + # 这包括 AgentFileSystem, SandboxClient 等复杂对象 return str(obj) - except Exception: - return f"<{type_name}: unserializable>" - -logger = logging.getLogger(__name__) -tool_cache = TTLCache(maxsize=200, ttl=300) -gpts_tool_messages_dao = GptsToolMessagesDao() -gpts_tool_dao = GptsToolDao() - -CFG = Config() def switch_mcp_input_schema(input_schema: dict): @@ -223,7 +206,7 @@ async def call_mcp_tool( tool_id = str(uuid.uuid4()) async def call_tool(server: str, arguments: dict): - # 将参数转换为 JSON 可序列化格式,处理 AgentFileSystem 等对象 + # 将 arguments 转换为 JSON 可序列化的格式 serializable_arguments = _make_json_serializable(arguments) gpts_tool_messages = GptsToolMessages( @@ -255,7 +238,10 @@ async def call_tool(server: str, arguments: dict): ) as (read, write): async with ClientSession(read, write) as session: await session.initialize() - result = await session.call_tool(tool_name, arguments=serializable_arguments) + # 使用序列化后的参数调用 MCP 工具 + result = await session.call_tool( + tool_name, arguments=serializable_arguments + ) end_time = int(datetime.now().timestamp() * 1000) LOGGER.info( f"[DIGEST][tools/call]mcp_server=[{mcp_name}],sse=[{mcp_server}],success=[Y],err_msg=[],tool=[{tool_name}],costMs=[{end_time - start_time}],result_length=[{len(str(result.json()))}],headers=[{headers}],result:[{result.json()}]" diff --git a/verify_loop_fix.py b/verify_loop_fix.py new file mode 100644 index 00000000..b7c5afee --- /dev/null +++ b/verify_loop_fix.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Verification script to test the LOOP mode tool_messages fix. + +This script verifies that: +1. The bug condition has been removed +2. LOOP mode agents now receive tool messages +""" + +import sys +from pathlib import Path + +# Add project paths +_project_root = Path(__file__).parent +sys.path.insert(0, str(_project_root / "packages/derisk-core/src")) + + +def verify_fix(): + """Verify that the bug has been fixed""" + print("=" * 80) + print("Verifying LOOP mode tool_messages fix") + print("=" * 80) + + base_agent_path = ( + _project_root / "packages/derisk-core/src/derisk/agent/core/base_agent.py" + ) + + with open(base_agent_path, "r") as f: + content = f.read() + + # Check if the buggy condition still exists + buggy_condition = "if self.run_mode != AgentRunMode.LOOP:" + + if buggy_condition in content: + print(f"❌ FIX NOT APPLIED: Buggy condition still exists") + print(f" Found: '{buggy_condition}'") + + # Find the line number + lines = content.split("\n") + for i, line in enumerate(lines, 1): + if buggy_condition in line: + print(f" Location: line {i}") + + return False + + print("✅ FIX APPLIED: Buggy condition has been removed") + + # Verify the correct code exists + correct_pattern = """if self.current_retry_counter > 0: + if self.enable_function_call: + ## 基于当前action的结果,构建history_dialogue 和 tool_message + tool_messages = self.function_callning_reply_messages( + agent_llm_out, act_outs + ) + all_tool_messages.extend(tool_messages)""" + + if correct_pattern in content: + print("✅ CORRECT CODE: Tool messages are now appended for all modes") + else: + print("⚠️ WARNING: Code structure may have changed") + print(" Please verify manually that tool messages are appended") + + return True + + +def explain_fix(): + """Explain what the fix does""" + print("\n" + "=" * 80) + print("FIX EXPLANATION") + print("=" * 80) + + print(""" +## What Was Fixed + +Removed the condition `if self.run_mode != AgentRunMode.LOOP:` from line 821. + +## Before (BUGGY): +```python +if self.current_retry_counter > 0: + if self.run_mode != AgentRunMode.LOOP: # ❌ Excluded LOOP mode + if self.enable_function_call: + tool_messages = self.function_callning_reply_messages(...) + all_tool_messages.extend(tool_messages) +``` + +## After (FIXED): +```python +if self.current_retry_counter > 0: + if self.enable_function_call: # ✅ All modes get tool messages + tool_messages = self.function_callning_reply_messages(...) + all_tool_messages.extend(tool_messages) +``` + +## Impact + +✅ ReActMasterV2 (LOOP mode) will now receive tool call results in each iteration +✅ LLM can see previous tool results and make informed decisions +✅ Prevents infinite loops caused by LLM not knowing tools were already called +✅ WorkLog records will be visible to LLM through tool_messages + +## Expected Behavior After Fix + +1. First iteration: Tool is called, result recorded to WorkLog and tool_messages +2. Second iteration: LLM sees the previous tool result in tool_messages +3. LLM decides next action based on results (instead of calling same tool again) +4. Loop continues with full context of previous tool calls +""") + + +def test_simulation(): + """Simulate the expected behavior""" + print("\n" + "=" * 80) + print("BEHAVIOR SIMULATION") + print("=" * 80) + + print(""" +## Scenario: User asks "Check if there's a system anomaly at 12:30" + +### Before Fix (BUGGY): +``` +Iteration 1: + - LLM calls view("/path/to/SKILL.md") + - Result: Skill file content + - ❌ Result NOT added to all_tool_messages (because LOOP mode) + +Iteration 2: + - LLM prompt: Same as iteration 1 (no tool results visible) + - LLM thinks: "I should load the skill file" + - LLM calls view("/path/to/SKILL.md") ← SAME CALL + - ❌ Result NOT added to all_tool_messages + +Iteration 3: + - Same as iteration 2 + - Infinite loop! +``` + +### After Fix (CORRECT): +``` +Iteration 1: + - LLM calls view("/path/to/SKILL.md") + - Result: Skill file content + - ✅ Result ADDED to all_tool_messages + +Iteration 2: + - LLM prompt: Includes tool result from iteration 1 + - LLM sees: "I already loaded the skill file, now I should..." + - LLM calls next tool based on skill content + - Result: Analysis data + - ✅ Result ADDED to all_tool_messages + +Iteration 3: + - LLM prompt: Includes results from iterations 1 and 2 + - LLM makes final decision + - Calls terminate to finish +``` +""") + + +def main(): + print("\n" + "✅" * 40) + print("LOOP Mode Tool Messages Fix Verification") + print("✅" * 40 + "\n") + + # Verify the fix + fix_applied = verify_fix() + + # Explain what was fixed + explain_fix() + + # Simulate behavior + test_simulation() + + # Summary + print("\n" + "=" * 80) + print("SUMMARY") + print("=" * 80) + + if fix_applied: + print("✅ Fix successfully applied!") + print("\nNext steps:") + print("1. Restart the Derisk server to apply changes") + print("2. Test with a query that previously caused loops") + print("3. Verify that tool results are now visible in LLM prompts") + print("\nExpected outcome:") + print(" - No more infinite loops calling the same tool") + print(" - LLM makes informed decisions based on previous tool results") + print(" - ReActMasterV2 completes tasks efficiently") + return 0 + else: + print("❌ Fix not applied or verification failed") + print("Please check the code changes manually") + return 1 + + +if __name__ == "__main__": + sys.exit(main())