根因: 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 降级回归冒烟通过。
286 lines
12 KiB
Python
286 lines
12 KiB
Python
import sys
|
||
import time
|
||
import threading
|
||
import json
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
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:
|
||
"""工具调用相关方法"""
|
||
|
||
def _extract_tool_calls(self, json_data) -> list:
|
||
"""从 JSON 数据中提取工具调用列表(支持多种格式)"""
|
||
tool_calls = []
|
||
|
||
if isinstance(json_data, dict):
|
||
# 格式1: 小狸自有格式
|
||
if json_data.get('action') == 'use_tool':
|
||
tool_calls.append(json_data)
|
||
# 格式2: 简化格式
|
||
elif 'tool' in json_data and 'args' in json_data:
|
||
tool_calls.append(json_data)
|
||
# 格式3: OpenAI function_call 格式
|
||
elif 'function_call' in json_data:
|
||
fc = json_data['function_call']
|
||
if isinstance(fc, dict):
|
||
args = fc.get('arguments', '')
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls.append({
|
||
'tool': fc.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
})
|
||
# 格式4: MCP/Anthropic 格式
|
||
elif 'name' in json_data and 'arguments' in json_data:
|
||
args = json_data.get('arguments', '')
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls.append({
|
||
'tool': json_data.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
})
|
||
# 格式5: OpenAI tool_calls 格式
|
||
elif json_data.get('type') == 'function' and 'function' in json_data:
|
||
func = json_data['function']
|
||
args = func.get('arguments', '')
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls.append({
|
||
'tool': func.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
})
|
||
elif isinstance(json_data, list):
|
||
for item in json_data:
|
||
if isinstance(item, dict):
|
||
if item.get('action') == 'use_tool':
|
||
tool_calls.append(item)
|
||
elif 'tool' in item and 'args' in item:
|
||
tool_calls.append(item)
|
||
elif 'name' in item and 'arguments' in item:
|
||
args = item.get('arguments', '')
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls.append({
|
||
'tool': item.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
})
|
||
|
||
return tool_calls if tool_calls else None
|
||
|
||
def process_tool_call(self, tool_call_data):
|
||
"""处理工具调用(支持 Liugin 和 MCP 两种格式)"""
|
||
try:
|
||
tool_name = tool_call_data.get('tool')
|
||
args = tool_call_data.get('args', '')
|
||
arguments = tool_call_data.get('arguments')
|
||
|
||
# ── 安全检查 ──
|
||
safety = get_safety()
|
||
allowed, msg = safety.check(tool_name, str(args))
|
||
if not allowed:
|
||
return {"result": f" 已取消: {msg}"}
|
||
|
||
if UNIFIED_TOOL_MANAGER_AVAILABLE and hasattr(self.liugin_manager, 'execute'):
|
||
if arguments and isinstance(arguments, dict):
|
||
result = self.liugin_manager.execute(tool_name, args, **arguments)
|
||
else:
|
||
result = self.liugin_manager.execute(tool_name, args)
|
||
# execute() 返回 ToolResult,必须归一成 dict——
|
||
# 否则上层 result.get('result') 会报
|
||
# "'ToolResult' object has no attribute 'get'"
|
||
return normalize_tool_dict(result)
|
||
|
||
# 回退:直接调用 handler(旧逻辑,handler 可能返回 ToolResult/str)
|
||
for tool in self.liugin_manager.tools:
|
||
if tool['name'] == tool_name:
|
||
try:
|
||
return normalize_tool_dict(tool['handler'](args))
|
||
except Exception as e:
|
||
return {"result": f"工具执行错误: {e}", "success": False}
|
||
|
||
return {"result": f"未找到工具: {tool_name}"}
|
||
|
||
except Exception as e:
|
||
return {"result": f"处理工具调用时出错: {e}"}
|
||
|
||
def process_tool_calls_concurrent(self, tool_calls_list):
|
||
"""并发批量执行工具调用,按顺序返回结果"""
|
||
if not tool_calls_list:
|
||
return []
|
||
|
||
cancel_event = threading.Event()
|
||
history_lock = threading.Lock()
|
||
|
||
def execute_single_tool(tool_data, index):
|
||
if cancel_event.is_set():
|
||
return {"index": index, "result": "工具执行已取消"}
|
||
try:
|
||
result = self.process_tool_call(tool_data)
|
||
tool_result = normalize_tool_text(result)
|
||
with history_lock:
|
||
self.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": f"工具{index+1}执行结果:\n{tool_result}"
|
||
})
|
||
return {"index": index, "result": tool_result}
|
||
except Exception as e:
|
||
error_msg = f"工具执行错误: {e}"
|
||
with history_lock:
|
||
self.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": error_msg
|
||
})
|
||
return {"index": index, "result": error_msg}
|
||
|
||
def supports_esc_detection():
|
||
return sys.platform == 'win32'
|
||
|
||
def check_key_input():
|
||
try:
|
||
import msvcrt
|
||
return msvcrt.kbhit()
|
||
except:
|
||
return False
|
||
|
||
def get_key():
|
||
try:
|
||
import msvcrt
|
||
return msvcrt.getch()
|
||
except:
|
||
return None
|
||
|
||
def detect_esc_key():
|
||
if supports_esc_detection():
|
||
while not cancel_event.is_set() and not all(f.done() for f in futures):
|
||
if check_key_input():
|
||
key = get_key()
|
||
if key == b'\x1b':
|
||
cancel_event.set()
|
||
break
|
||
time.sleep(0.1)
|
||
|
||
with ThreadPoolExecutor(max_workers=min(len(tool_calls_list), 10)) as executor:
|
||
futures = {executor.submit(execute_single_tool, tool_data, i): i
|
||
for i, tool_data in enumerate(tool_calls_list)}
|
||
|
||
esc_thread = threading.Thread(target=detect_esc_key, daemon=True)
|
||
esc_thread.start()
|
||
|
||
results = []
|
||
for future in as_completed(futures):
|
||
if cancel_event.is_set():
|
||
for f in futures:
|
||
if not f.done():
|
||
f.cancel()
|
||
break
|
||
try:
|
||
result = future.result()
|
||
results.append(result)
|
||
except Exception as e:
|
||
index = futures[future]
|
||
results.append({"index": index, "result": f"工具执行错误: {e}"})
|
||
|
||
results.sort(key=lambda x: x['index'])
|
||
return [r['result'] for r in results]
|
||
|
||
def _execute_single_tool(self, tool_data):
|
||
"""执行单个工具(带延迟和ESC检测)"""
|
||
tool_name = tool_data.get('tool', '未知工具')
|
||
tool_args = tool_data.get('args', '')
|
||
if len(tool_args) > 30:
|
||
tool_args_display = tool_args[:27] + "..."
|
||
else:
|
||
tool_args_display = tool_args
|
||
|
||
# TUI 模式下不做延迟和 ESC 检测
|
||
if not self.tui_output_callback:
|
||
try:
|
||
import msvcrt
|
||
canceled = False
|
||
spinners = ['⊶', '⊷']
|
||
spinner_index = 0
|
||
for i in range(25):
|
||
if msvcrt.kbhit():
|
||
key = msvcrt.getch()
|
||
if key == b'\x1b':
|
||
canceled = True
|
||
break
|
||
spinner = spinners[spinner_index % 2]
|
||
spinner_index += 1
|
||
loading_message = f" 正在执行: {spinner} {tool_name} {tool_args_display} (按ESC取消)"
|
||
sys.stdout.write(f"\r{loading_message}")
|
||
sys.stdout.flush()
|
||
time.sleep(0.2)
|
||
sys.stdout.write("\r" + " " * 60 + "\r")
|
||
sys.stdout.flush()
|
||
if canceled:
|
||
print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}")
|
||
cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}"
|
||
self.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": cancel_message
|
||
})
|
||
return True
|
||
except ImportError:
|
||
pass
|
||
|
||
tool_results = self.process_tool_call(tool_data)
|
||
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)
|
||
current_input = f"工具调用结果 (时间: {current_time}):\n{full_result}"
|
||
self.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": f"工具调用结果 (时间: {current_time}):\n{full_result}"
|
||
})
|
||
self.process_conversation(current_input)
|
||
|
||
def _execute_concurrent_tools(self, tool_calls_list):
|
||
"""并发批量执行工具调用"""
|
||
if not tool_calls_list:
|
||
return
|
||
|
||
self._output(f" 正在批量执行 {len(tool_calls_list)} 个工具")
|
||
|
||
results = self.process_tool_calls_concurrent(tool_calls_list)
|
||
|
||
from datetime import datetime
|
||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
for i, (tool_data, result) in enumerate(zip(tool_calls_list, results)):
|
||
tool_name = tool_data.get('tool', '未知工具')
|
||
tool_args = tool_data.get('args', '')
|
||
self._display_tool_result(tool_name, tool_args, result, current_time)
|
||
|
||
all_results = "\n".join([f"工具{i+1}结果:\n{result}" for i, result in enumerate(results)])
|
||
current_input = f"批量工具调用结果 (时间: {current_time}):\n{all_results}"
|
||
|
||
self.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": f"批量工具调用结果 (时间: {current_time}):\n{all_results}"
|
||
})
|
||
|
||
self.process_conversation(current_input)
|