From a63b9d2f9718ee78f0046b9256572b1a3fe9aeb6 Mon Sep 17 00:00:00 2001 From: shuiyu67 Date: Wed, 5 Aug 2026 10:54:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20'ToolResult'=20obje?= =?UTF-8?q?ct=20has=20no=20attribute=20'get'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: cli_tools.process_tool_call 在启用 UnifiedToolManager 时直接返回 ToolResult 对象,而 7 个调用点全部按 dict 用 result.get('result'),触发 AttributeError;FC 路径 _execute_tool_by_name 也可能把 ToolResult 透传出去, 后续 result[:5000] 会再炸 TypeError。 - tool_result.py: 新增 normalize_tool_text / normalize_tool_dict, 统一收敛 ToolResult / dict / str / None 三种历史返回形态。 - cli_tools.process_tool_call: 统一返回 dict(附带 success/error_code/ tool_result),回退 handler 路径同样归一。 - cli_core._execute_tool_by_name: handler / memory / scheduler 返回值 归一成 str,保证 FC 路径可安全切片。 - openai/ollama/manual 引擎、websocket_server、cli_clawli: 取工具结果改用 normalize_tool_text(带本地兜底实现,避免循环导入)。 - cli_clawli: 顺带修复只回传最后一个工具结果的 bug,改为汇总全部。 - 测试: 23 项全过;纯格式/讯飞 500 降级回归冒烟通过。 --- ai_engines/manual_engine.py | 13 ++++++++- ai_engines/ollama_engine.py | 13 ++++++++- ai_engines/openai_engine.py | 13 ++++++++- websocket_server.py | 15 +++++++++-- xcli_core/cli_clawli.py | 8 ++++-- xcli_core/cli_core.py | 14 +++++++--- xcli_core/cli_tools.py | 19 ++++++------- xcli_core/tool_result.py | 54 +++++++++++++++++++++++++++++++++++++ 8 files changed, 129 insertions(+), 20 deletions(-) diff --git a/ai_engines/manual_engine.py b/ai_engines/manual_engine.py index ae8feed..08bd3d2 100644 --- a/ai_engines/manual_engine.py +++ b/ai_engines/manual_engine.py @@ -18,6 +18,17 @@ current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) sys.path.insert(0, project_root) +try: + from xcli_core.tool_result import normalize_tool_text +except Exception: + def normalize_tool_text(result, default="无结果"): + """兜底归一化:ToolResult / dict / str 统一成字符串""" + if result is None: + return default + if isinstance(result, dict): + return str(result.get("result", default)) + return str(result) + # 通信用的临时目录 COMM_DIR = os.path.join(tempfile.gettempdir(), "xiaoli_manual_engine") @@ -246,7 +257,7 @@ while True: # 把用户消息写入文件 msg = user_input if tool_results: - msg = f"[工具结果] {tool_results.get('result', '无结果')}\n\n{user_input}" + msg = f"[工具结果] {normalize_tool_text(tool_results)}\n\n{user_input}" to_human = paths["msg_to_human"] with open(to_human, 'w', encoding='utf-8') as f: diff --git a/ai_engines/ollama_engine.py b/ai_engines/ollama_engine.py index 978ed09..79d2037 100644 --- a/ai_engines/ollama_engine.py +++ b/ai_engines/ollama_engine.py @@ -29,6 +29,17 @@ except Exception: def resolve_input_value(value, field_name="", default_prompt=None): return value +try: + from xcli_core.tool_result import normalize_tool_text +except Exception: + def normalize_tool_text(result, default="无结果"): + """兜底归一化:ToolResult / dict / str 统一成字符串""" + if result is None: + return default + if isinstance(result, dict): + return str(result.get("result", default)) + return str(result) + class OllamaAI: """基于Ollama的本地AI引擎""" @@ -164,7 +175,7 @@ Ollama AI引擎插件帮助信息 # 如果有工具结果,将其作为上下文返回给AI处理 if tool_results: - tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}" messages = self._build_messages_with_history(system_prompt, tool_result_text) else: messages = self._build_messages_with_history(system_prompt, user_input) diff --git a/ai_engines/openai_engine.py b/ai_engines/openai_engine.py index 26dc9b2..04ee819 100644 --- a/ai_engines/openai_engine.py +++ b/ai_engines/openai_engine.py @@ -19,6 +19,17 @@ except Exception: def get_system_config(key, default=None): return default +try: + from xcli_core.tool_result import normalize_tool_text +except Exception: + def normalize_tool_text(result, default="无结果"): + """兜底归一化:ToolResult / dict / str 统一成字符串""" + if result is None: + return default + if isinstance(result, dict): + return str(result.get("result", default)) + return str(result) + # ── 常见模型的上下文窗口(token 数)── # OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。 MODEL_CONTEXT_WINDOWS = { @@ -172,7 +183,7 @@ OpenAI 兼容格式引擎帮助信息 # 处理工具结果 if tool_results: - tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}" messages = self._build_messages_with_history(system_prompt, tool_result_text) else: messages = self._build_messages_with_history(system_prompt, user_input) diff --git a/websocket_server.py b/websocket_server.py index c20ae7e..7345f64 100644 --- a/websocket_server.py +++ b/websocket_server.py @@ -24,6 +24,17 @@ import os from datetime import datetime from typing import Optional, Dict, List +try: + from xcli_core.tool_result import normalize_tool_text +except Exception: + def normalize_tool_text(result, default="无结果"): + """兜底归一化:ToolResult / dict / str 统一成字符串""" + if result is None: + return default + if isinstance(result, dict): + return str(result.get("result", default)) + return str(result) + # 全局变量 websocket_server = None # 当前活动的服务器实例(供外部访问) proxy_client = None @@ -583,7 +594,7 @@ class LocalServer: await self._send_tool_status(websocket, tool_name, tool_args, "calling") result = self.cli.process_tool_call(tool_data) - result_text = result.get('result', '无结果') + result_text = normalize_tool_text(result) results.append(result_text) if websocket: @@ -957,7 +968,7 @@ class ProxyClient: results = [] for tool_data in tool_calls: result = self.cli.process_tool_call(tool_data) - results.append(result.get('result', '无结果')) + results.append(normalize_tool_text(result)) self.cli.shared_conversation_history.append({ "role": "assistant", diff --git a/xcli_core/cli_clawli.py b/xcli_core/cli_clawli.py index 3e9daa1..c2eefb1 100644 --- a/xcli_core/cli_clawli.py +++ b/xcli_core/cli_clawli.py @@ -3,6 +3,7 @@ import time from colorama import Fore, Style from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE +from .tool_result import normalize_tool_text try: import clawli_server @@ -96,6 +97,7 @@ class ClawliMixin: tool_calls = self._extract_tool_calls(json_data) if tool_calls: + result_texts = [] for tool_data in tool_calls: tool_name = tool_data.get('tool', '未知工具') tool_args = tool_data.get('args', '') @@ -104,7 +106,8 @@ class ClawliMixin: clawli_server.send_tool_status(tool_name, tool_args, "calling") result = self.process_tool_call(tool_data) - result_text = result.get('result', '无结果') + result_text = normalize_tool_text(result) + result_texts.append(result_text) if CLAWLI_SERVER_AVAILABLE and clawli_server: clawli_server.send_tool_status(tool_name, tool_args, "success", result_text) @@ -114,7 +117,8 @@ class ClawliMixin: "content": response }) - tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]]) + # 汇总全部工具结果(此前只回传了最后一个) + tool_result_str = "\n".join([f"工具执行结果: {r}" for r in result_texts]) self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。") return diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py index 082b2ae..f411fd7 100644 --- a/xcli_core/cli_core.py +++ b/xcli_core/cli_core.py @@ -812,7 +812,13 @@ multi 操作支持一次修改多处: return None def _execute_tool_by_name(self, tool_name: str, tool_args: str) -> str: - """根据名称执行工具(FC 调用路径)""" + """根据名称执行工具(FC 调用路径) + + 注意:插件 handler 可能返回 ToolResult / dict / str, + 这里统一归一成 str,保证调用方能安全做切片与拼接。 + """ + from .tool_result import normalize_tool_text + # 安全检查 safety = get_safety() allowed, msg = safety.check(tool_name, tool_args) @@ -823,20 +829,20 @@ multi 操作支持一次修改多处: tool = self.liugin_manager.get_tool_by_name(tool_name) if tool and 'handler' in tool: try: - return tool['handler'](tool_args) + return normalize_tool_text(tool['handler'](tool_args), default="执行完成") except Exception as e: return f"工具执行失败: {e}" # memory 内置工具 if tool_name == 'memory' and hasattr(self, 'memory_manager'): - return self.memory_manager.handle_tool(tool_args) + return normalize_tool_text(self.memory_manager.handle_tool(tool_args), default="执行完成") # scheduler 内置工具 if tool_name == 'scheduler': for t in self.liugin_manager.tools: if t.get('name') == 'scheduler' and 'handler' in t: try: - return t['handler'](tool_args) + return normalize_tool_text(t['handler'](tool_args), default="执行完成") except Exception as e: return f"工具执行失败: {e}" diff --git a/xcli_core/cli_tools.py b/xcli_core/cli_tools.py index 1cc1524..b2a7817 100644 --- a/xcli_core/cli_tools.py +++ b/xcli_core/cli_tools.py @@ -7,6 +7,7 @@ from colorama import Fore, Style from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE from .safety import get_safety +from .tool_result import normalize_tool_dict, normalize_tool_text class ToolMixin: @@ -105,18 +106,18 @@ class ToolMixin: result = self.liugin_manager.execute(tool_name, args, **arguments) else: result = self.liugin_manager.execute(tool_name, args) - return result + # execute() 返回 ToolResult,必须归一成 dict—— + # 否则上层 result.get('result') 会报 + # "'ToolResult' object has no attribute 'get'" + return normalize_tool_dict(result) - # 回退:直接调用 handler(旧逻辑) + # 回退:直接调用 handler(旧逻辑,handler 可能返回 ToolResult/str) for tool in self.liugin_manager.tools: if tool['name'] == tool_name: try: - result = tool['handler'](args) - if result is None: - result = "无结果" - return {"result": result} + return normalize_tool_dict(tool['handler'](args)) except Exception as e: - return {"result": f"工具执行错误: {e}"} + return {"result": f"工具执行错误: {e}", "success": False} return {"result": f"未找到工具: {tool_name}"} @@ -136,7 +137,7 @@ class ToolMixin: return {"index": index, "result": "工具执行已取消"} try: result = self.process_tool_call(tool_data) - tool_result = result.get('result', '无结果') + tool_result = normalize_tool_text(result) with history_lock: self.shared_conversation_history.append({ "role": "assistant", @@ -245,7 +246,7 @@ class ToolMixin: pass tool_results = self.process_tool_call(tool_data) - full_result = tool_results.get('result', '无结果') + full_result = normalize_tool_text(tool_results) from datetime import datetime current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self._display_tool_result(tool_name, tool_args, full_result, current_time) diff --git a/xcli_core/tool_result.py b/xcli_core/tool_result.py index 0d507a3..3905ee7 100644 --- a/xcli_core/tool_result.py +++ b/xcli_core/tool_result.py @@ -117,3 +117,57 @@ class ToolResult: def to_string(self) -> str: """转为字符串(兼容旧插件 handle() 返回值)""" return str(self) + + +# ── 归一化辅助(跨层兼容:ToolResult / dict / str / None)── + +def normalize_tool_text(result: Any, default: str = "无结果") -> str: + """把任意工具返回值统一成字符串。 + + 工具链里三种返回形态都可能出现: + - ToolResult(新插件 / UnifiedToolManager.execute) + - {"result": ...}(旧插件 / to_dict) + - 纯字符串(最老的 handler) + 统一收敛成字符串,避免上层对 ToolResult 调用 .get() 报 + "'ToolResult' object has no attribute 'get'"。 + """ + if result is None: + return default + if isinstance(result, ToolResult): + text = str(result) + elif isinstance(result, dict): + text = result.get("result", result.get("content", "")) + if isinstance(text, (dict, list)): + import json as _json + try: + text = _json.dumps(text, ensure_ascii=False) + except Exception: + text = str(text) + elif not isinstance(text, str): + text = str(text) if text is not None else "" + else: + text = str(result) + text = text.strip() if isinstance(text, str) else str(text) + return text if text else default + + +def normalize_tool_dict(result: Any, default: str = "无结果") -> dict: + """把任意工具返回值统一成 {"result": str, ...} 结构。 + + 额外附带 success / error_code / tool_result,方便需要细节的调用方, + 同时保证 .get('result') 一定可用。 + """ + text = normalize_tool_text(result, default) + if isinstance(result, ToolResult): + return { + "result": text, + "success": result.success, + "error_code": result.error_code, + "tool_result": result, + } + if isinstance(result, dict): + out = dict(result) + out["result"] = text + out.setdefault("success", True) + return out + return {"result": text, "success": True}