fix(aibox): prevent tool results from corrupting conversation and breaking API requests

Root causes of API request failures after tool calls:
1. reasoning_content (an API response field) was echoed back in assistant
   messages, which could cause API format errors
2. tool_call id could be empty string when model didn't return one in a
   streaming chunk, producing invalid tool_calls that caused 400 errors
3. Tool execute() could return None or non-string types; direct string
   operations (len(), + "\n", [:200]) raised TypeError and produced
   non-string content in tool messages, breaking JSON serialization
4. Tool output (e.g. from run_command) could contain control characters
   (NULL/BEL/BS) that corrupted JSON payloads
5. When API request failed (network/4xx/5xx/parse error), messages added
   in that turn (user msg, assistant msg with tool_calls, tool responses)
   remained in self.messages, permanently poisoning conversation history
   so all subsequent requests would also fail

Fixes:
- Stop sending reasoning_content back to API in message construction;
  normalize assistant message content: null when tool_calls present, ""
  when neither content nor tool_calls exist, str otherwise
- Sanitize tool messages: ensure tool_call_id is non-empty (fallback id)
  and content is always a string
- Generate fallback tool_call id when model omits it; handle streaming id
  delivery where id arrives in a later chunk
- Add _sanitize_tool_result(): coerce None/non-string to str, strip
  control characters (preserve \n \t \r) to prevent JSON/API corruption
- Wrap execute_tool call in try/except in _handle_tool_calls for double
  safety; ensure history logging never crashes
- Truncate oversized results with an explicit note instead of replacing
  with a generic error that lost all useful output
- Add _rollback_messages(to_length) and record msgs_before_turn at start
  of think_and_respond; on any exception (RequestException, JSONDecodeError,
  or unexpected Exception), rollback self.messages to the pre-turn state
  so the conversation is not left in a broken state
- In _handle_tool_calls, wrap execute_tool in try/except and always
  produce a safe string result
This commit is contained in:
qing committed 2026-08-01 03:06:34 +08:00
1 parent 1b9e6f6b3a
commit a80bd582da
1 file changed
+101 -31
+101 -31
View File
@@ -6710,6 +6710,25 @@ class DeepSeekChat:
self.logger.error(f"保存工具结果失败: {e}")
return None
@staticmethod
def _sanitize_tool_result(result) -> str:
"""将工具返回值安全转换为字符串,清理控制字符,防止破坏 API 请求。"""
if result is None:
return ""
if not isinstance(result, str):
try:
result = str(result)
except Exception:
return f"⚠️ 工具返回了无法序列化的结果类型: {type(result).__name__}"
# 清理控制字符:保留 \n \t \r,移除其他可能导致 JSON/API 问题的控制字符
cleaned_chars = []
for ch in result:
code = ord(ch)
if ch in ('\n', '\t', '\r') or code >= 0x20:
cleaned_chars.append(ch)
# 跳过 NULL、BEL、BS 等控制字符
return ''.join(cleaned_chars)
def _handle_tool_calls(self, tool_calls: List[Dict], output_callback: Callable = None) -> List[Dict]:
"""处理工具调用 - 所有工具的输出都进行发送,并在开始和结束时发送两个回车"""
if not tool_calls:
@@ -6727,25 +6746,29 @@ class DeepSeekChat:
for tool_call in tool_calls:
function = tool_call.get("function", {})
tool_name = function.get("name")
tool_name = function.get("name", "unknown")
arguments = function.get("arguments", "{}")
tc_id = tool_call.get("id") or f"call_fallback_{self.stats['tool_calls']}_{int(time.time()*1000)}"
try:
args = json.loads(arguments) if arguments else {}
except json.JSONDecodeError:
args = {}
# 【修改】开始调用工具时发送两个回车
# 开始调用工具时发送提示
if output_callback:
output_callback("line", "\n\n")
output_callback("line", f"🔧 调用工具: {tool_name}\n")
# 直接调用 execute_tool(同步)。
# - 若工具支持流式:execute_tool 内部开线程调 execute_streaming(**args),
# 收集输出后转发到 current_output_queue(即此处的 output_queue),join 后返回。
# - 若工具不支持流式:execute_tool 直接调 execute(**args) 返回结果。
# 两种情况下 execute_tool 返回时执行均已完成,args 完整传入,不再有参数丢失。
result = self.tool_registry.execute_tool(tool_name, **args)
result_raw = None
try:
result_raw = self.tool_registry.execute_tool(tool_name, **args)
except Exception as e:
result_raw = f"❌ 工具 {tool_name} 执行异常: {str(e)}"
self.logger.error(f"工具 {tool_name} 执行异常: {e}")
# 安全转换并清理结果
result = self._sanitize_tool_result(result_raw)
# 排空 output_queue,把流式行输出转发给 output_callback
has_stream_output = False
@@ -6753,7 +6776,7 @@ class DeepSeekChat:
try:
msg_type, content = output_queue.get_nowait()
if output_callback and msg_type in ("line", "complete"):
output_callback("line", content)
output_callback("line", str(content) if content is not None else "")
has_stream_output = True
except queue.Empty:
break
@@ -6762,36 +6785,39 @@ class DeepSeekChat:
if not has_stream_output and output_callback and result:
output_callback("line", result + "\n")
# 检查结果大小,如果超过阈值,不返回结果,只返回提示信息
if result and len(result) > MAX_RESULT_LENGTH:
# 不保存文件,直接返回提示信息
result = f"⚠️ 工具 {tool_name} 执行结果过大({len(result)} 字符),超过限制({MAX_RESULT_LENGTH} 字符),已放弃返回结果。\n\n请考虑:\n1. 使用更精确的查询条件\n2. 使用其他工具获取部分数据\n3. 优化查询方式减少结果数量"
# 检查结果大小,如果超过阈值,截断返回提示信息
if len(result) > MAX_RESULT_LENGTH:
truncated_note = f"\n\n⚠️ 结果已截断(原始 {len(result)} 字符,限制 {MAX_RESULT_LENGTH} 字符)。请使用更精确的查询获取所需信息。"
result = result[:MAX_RESULT_LENGTH] + truncated_note
if output_callback:
output_callback("line", result + "\n")
output_callback("line", truncated_note + "\n")
# 【修改】结束调用工具时发送两个回车
# 结束调用工具时发送换行
if output_callback:
output_callback("line", "\n\n")
# 历史记录(安全截断)
if self.history_manager:
self.history_manager.add_tool_call(tool_name, args, result[:200] + "..." if len(result) > 200 else result)
try:
history_preview = result[:200] + "..." if len(result) > 200 else result
self.history_manager.add_tool_call(tool_name, args, history_preview)
except Exception:
pass
if tool_name == "save_memory_and_end_conversation" and "✅" in result:
# 检查是否结束对话
end_conversation = (tool_name == "save_memory_and_end_conversation" and "✅" in result)
if end_conversation:
self.logger.info("检测到保存记忆并结束对话工具调用")
self.conversation_active = False
tool_messages.append({
"role": "tool",
"tool_call_id": tool_call.get("id"),
"content": result
})
# 【修复】不直接返回,而是添加后继续处理,但设置标志让后续停止
break
tool_messages.append({
"role": "tool",
"tool_call_id": tool_call.get("id"),
"tool_call_id": tc_id,
"content": result
})
if end_conversation:
break
# 清除输出队列
self.tool_registry.set_output_queue(None)
@@ -6903,6 +6929,9 @@ class DeepSeekChat:
enhanced_input = pre_prompt + "\n\n" + input_text + post_prompt
# 记录本轮开始前的消息长度,用于 API 失败时回滚,防止坏消息污染对话历史
msgs_before_turn = len(self.messages)
self._add_message("user", enhanced_input)
if self.history_manager:
@@ -6921,19 +6950,32 @@ class DeepSeekChat:
if msg["role"] == "system":
enhanced_messages.append(msg)
# 添加非系统消息,但确保消息结构正确
# 添加非系统消息,确保消息结构正确
for msg in self.messages:
if msg["role"] != "system":
if msg["role"] == "assistant":
assistant_msg = {"role": "assistant"}
if "content" in msg and msg["content"]:
has_content = "content" in msg and msg["content"] is not None
has_tool_calls = "tool_calls" in msg and msg["tool_calls"]
if has_content:
assistant_msg["content"] = msg["content"]
if "reasoning_content" in msg:
assistant_msg["reasoning_content"] = msg["reasoning_content"]
if "tool_calls" in msg and msg["tool_calls"]:
if has_tool_calls:
assistant_msg["tool_calls"] = msg["tool_calls"]
# 有 tool_calls 时 content 必须为 null(API 要求)
assistant_msg["content"] = None
elif not has_content:
# 无 content 也无 tool_calls 时,设为空字符串避免 API 拒绝
assistant_msg["content"] = ""
# 注意:不回传 reasoning_content,它是 API 响应字段,回传可能导致请求失败
enhanced_messages.append(assistant_msg)
elif msg["role"] == "tool":
# 确保 tool 消息 content 始终是字符串
tool_msg = {
"role": "tool",
"tool_call_id": msg.get("tool_call_id", ""),
"content": str(msg.get("content", "")) if msg.get("content") is not None else ""
}
enhanced_messages.append(tool_msg)
else:
enhanced_messages.append(msg)
@@ -7036,14 +7078,22 @@ class DeepSeekChat:
index = tc.get('index', 0)
if index not in current_tool_calls:
# 确保 tool_call id 非空,空 id 会导致 API 400 错误污染对话历史
tc_id = tc.get('id', '') or f"call_{self.stats['tool_calls']}_{index}_{int(time.time()*1000)}"
current_tool_calls[index] = {
'id': tc.get('id', ''),
'id': tc_id,
'type': 'function',
'function': {
'name': '',
'arguments': ''
}
}
else:
# 流式传输中 id 可能分片到达,后续补充
existing_id = current_tool_calls[index].get('id', '')
new_id = tc.get('id', '')
if new_id and (not existing_id or existing_id.startswith('call_')):
current_tool_calls[index]['id'] = new_id
if 'function' in tc:
if 'name' in tc['function']:
@@ -7119,24 +7169,44 @@ class DeepSeekChat:
return full_response if full_response else ""
except requests.exceptions.RequestException as e:
# 网络/HTTP 错误:回滚本轮添加的消息(user 消息 + 可能的 assistant/tool 消息),
# 防止格式错误的消息污染历史导致后续请求持续失败
self._rollback_messages(msgs_before_turn)
error_msg = f"\n{i18n.get('error_prefix')}API请求失败: {str(e)}"
if output_callback:
output_callback("error", error_msg)
self.stats["errors"].append(str(e))
self.logger.error(f"API请求失败,已回滚消息到位置 {msgs_before_turn}: {e}")
return error_msg
except json.JSONDecodeError as e:
# JSON 解析错误:回滚消息
self._rollback_messages(msgs_before_turn)
error_msg = f"\n{i18n.get('error_prefix')}API响应解析失败: {str(e)}"
if output_callback:
output_callback("error", error_msg)
self.stats["errors"].append(str(e))
self.logger.error(f"API响应解析失败,已回滚消息到位置 {msgs_before_turn}: {e}")
return error_msg
except Exception as e:
# 未知错误:回滚消息,防止对话状态被破坏
self._rollback_messages(msgs_before_turn)
error_msg = f"\n{i18n.get('error_prefix')}{str(e)}"
if output_callback:
output_callback("error", error_msg)
self.stats["errors"].append(str(e))
self.logger.error(f"未知错误,已回滚消息到位置 {msgs_before_turn}: {e}")
return error_msg
def _rollback_messages(self, to_length: int):
"""回滚 self.messages 到指定长度,用于 API 失败时恢复对话状态。"""
try:
current_len = len(self.messages)
if current_len > to_length:
del self.messages[to_length:]
self.logger.info(f"消息已回滚,从 {current_len} 条回滚到 {len(self.messages)} 条")
except Exception as e:
self.logger.error(f"消息回滚失败: {e}")
def _add_assistant_message_with_reasoning(self, content: str, reasoning_content: str = None, tool_calls: List = None):
"""添加助手消息,包含 reasoning_content"""