agent-Specialization/server/chat_flow_tool_loop.py
JOJO c2d460a706 feat: 宿主机模式网络沙箱
- macOS sandbox-exec profile 支持 network_permission 参数 (restricted/full/none)
- backend: 透传网络权限至 run_command / terminal_session / sub_agent / background_command
- 后台 run_command 接入沙箱执行
- 自动审核模式兼容网络权限报错 markers
- 运行时切换网络权限通过 pending 机制 + user 消息通知
- 提示词注入网络状态 (仅沙箱模式)
- 前端权限菜单新增网络权限组 (受限/完全开放)
- direct 模式下网络权限组变灰禁用
- settings.json 默认 HOST_SANDBOX_NETWORK_PERMISSION=restricted
2026-06-19 00:22:30 +08:00

1392 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import json
import time
import uuid
from pathlib import Path
from typing import Optional, Dict, Any, List
from .utils_common import debug_log, brief_log
from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT
from .state import tool_approval_manager, user_question_manager
from .monitor import cache_monitor_snapshot
from .security import compact_web_search_result
from .chat_flow_helpers import detect_tool_failure
from .chat_flow_runner_helpers import resolve_monitor_path, resolve_monitor_memory, capture_monitor_snapshot
from utils.tool_result_formatter import (
extract_mcp_content_for_context,
format_tool_result_for_context,
)
from utils.context_manager import AUTO_SHALLOW_PLACEHOLDER
from config import TOOL_CALL_COOLDOWN
from modules.personalization_manager import load_personalization_config, resolve_context_compression_settings
from modules.auto_approval_service import run_auto_approval
from modules.user_question_manager import format_user_question_answer
from .deep_compression import run_deep_compression
from .chat_flow_task_support import inject_runtime_user_message
def _format_numbered_lines(lines: List[str], start_line_no: int) -> List[Dict[str, Any]]:
return [
{
"line_no": start_line_no + idx,
"content": line.rstrip("\n"),
}
for idx, line in enumerate(lines)
]
def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
args = arguments or {}
preview: Dict[str, Any] = {
"type": function_name,
"tool_name": function_name,
"arguments": args,
}
if function_name == "edit_file":
file_path = args.get("file_path")
replacements = args.get("replacements")
preview["file_path"] = file_path
if not file_path:
preview["summary"] = "缺少 file_path"
return preview
if not isinstance(replacements, list) or not replacements:
preview["summary"] = "缺少 replacements必须是非空数组"
return preview
preview["replacement_groups"] = len(replacements)
try:
valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path))
if not valid or full_path is None:
preview["summary"] = err or "路径校验失败"
return preview
resolved_path = str(Path(full_path))
preview["resolved_path"] = resolved_path
if not full_path.exists() or not full_path.is_file():
preview["summary"] = "目标文件不存在,无法生成上下文预览"
return preview
content = full_path.read_text(encoding="utf-8", errors="ignore")
first = replacements[0] if isinstance(replacements[0], dict) else {}
old_text = str(first.get("old_string") or "")
new_text = str(first.get("new_string") or "")
replace_all = first.get("replace_all", False)
if not isinstance(replace_all, bool):
preview["summary"] = "第 1 组 replace_all 必须是 true 或 false"
return preview
short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3)
if short_old_text_notice:
preview["notice"] = "提示:第 1 组 old_string 少于3行允许继续执行需要批量替换的场景可以单行或不足一行"
old_lines = old_text.splitlines()
new_lines = new_text.splitlines()
idx = content.find(old_text) if old_text else -1
if idx >= 0:
prefix = content[:idx]
start_line_no = prefix.count("\n") + 1
end_line_no = start_line_no + max(1, len(old_lines)) - 1
all_lines = content.splitlines()
before_start = max(1, start_line_no - 3)
before = all_lines[before_start - 1:start_line_no - 1]
after_end = min(len(all_lines), end_line_no + 3)
after = all_lines[end_line_no:after_end]
preview["edit_context"] = {
"before": _format_numbered_lines(before, before_start),
"old": _format_numbered_lines(old_lines or [""], start_line_no),
"new": _format_numbered_lines(new_lines or [""], start_line_no),
"after": _format_numbered_lines(after, end_line_no + 1),
"old_start_line": start_line_no,
"old_end_line": end_line_no,
}
mode_text = "全部匹配" if replace_all is True else "首个匹配"
preview["summary"] = f"编辑 {file_path},共 {len(replacements)} 组;预览第 1 组第 {start_line_no}-{end_line_no} 行({mode_text}"
else:
preview["edit_context"] = {
"before": [],
"old": _format_numbered_lines(old_lines or [""], 1),
"new": _format_numbered_lines(new_lines or [""], 1),
"after": [],
"old_start_line": None,
"old_end_line": None,
}
preview["summary"] = f"{len(replacements)} 组替换;未在文件中定位到第 1 组 old_string显示原始替换内容"
if short_old_text_notice:
preview["summary"] = f"{preview['summary']}(第 1 组 old_string 少于3行已告知并继续"
except Exception as exc:
preview["summary"] = f"生成编辑预览失败: {exc}"
return preview
if function_name in {"run_command", "terminal_input"}:
preview["command"] = args.get("command")
preview["summary"] = f"执行命令: {args.get('command') or ''}"
return preview
if function_name in {"create_file", "create_folder", "delete_file"}:
preview["path"] = args.get("path")
preview["summary"] = f"{function_name}: {args.get('path') or ''}"
return preview
if function_name == "rename_file":
preview["old_path"] = args.get("old_path")
preview["new_path"] = args.get("new_path")
preview["summary"] = f"rename_file: {args.get('old_path') or ''} -> {args.get('new_path') or ''}"
return preview
if function_name == "write_file":
content = str(args.get("content") or "")
preview["file_path"] = args.get("file_path")
preview["append"] = bool(args.get("append", False))
preview["content_preview"] = content
preview["content_length"] = len(content)
preview["summary"] = f"write_file: {args.get('file_path') or ''} ({'append' if preview['append'] else 'overwrite'})"
return preview
return preview
def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
if not isinstance(result_data, dict):
return False
if result_data.get("success") is True:
return False
fragments: List[str] = []
for key in ("error", "message", "output"):
value = result_data.get(key)
if isinstance(value, str) and value.strip():
fragments.append(value.lower())
joined = "\n".join(fragments)
if not joined:
return False
markers = (
"operation not permitted",
"permission denied",
"权限不足",
"无权限",
"不允许",
"access denied",
# 网络受限常见报错
"could not resolve host",
"nodename nor servname provided, or not known",
"unknown host",
"network is unreachable",
"no route to host",
"socket.gaierror",
)
return any(marker in joined for marker in markers)
def _inject_runtime_mode_notice(
*,
web_terminal,
messages: List[Dict[str, Any]],
content: str,
source: str = "notify",
sender=None,
conversation_id: Optional[str] = None,
) -> None:
inject_runtime_user_message(
web_terminal=web_terminal,
messages=messages,
text=content,
source=source,
sender=sender,
conversation_id=conversation_id,
inline=True,
)
def _format_rejected_tool_text(reason: str) -> str:
clean_reason = str(reason or "").strip() or "未提供"
return f"工具调用被拒绝\n原因:{clean_reason}"
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
started = time.time()
while True:
row = tool_approval_manager.get(approval_id)
if not row:
return {"decision": "rejected", "reason": "审批请求不存在"}
if row.get("username") != username:
return {"decision": "rejected", "reason": "审批请求用户不匹配"}
status = row.get("status")
if status in {"approved", "rejected"}:
return {"decision": status, "item": row}
if (time.time() - started) >= timeout_seconds:
return {"decision": "rejected", "reason": "审批超时"}
await asyncio.sleep(0.2)
def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
function = tool_call.get("function") or {}
if function.get("name") != "ask_user":
return None
raw = function.get("arguments") or "{}"
if hasattr(web_terminal, 'api_client') and hasattr(web_terminal.api_client, '_safe_tool_arguments_parse'):
success, arguments, _error_msg = web_terminal.api_client._safe_tool_arguments_parse(raw, "ask_user")
if success and isinstance(arguments, dict):
return arguments
return None
parsed = json.loads(raw) if str(raw).strip() else {}
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
async def _wait_for_user_questions(*, question_ids: List[str], username: str, timeout_seconds: float = 3600.0) -> Dict[str, Dict[str, Any]]:
started = time.time()
pending = {str(qid) for qid in question_ids if qid}
answered: Dict[str, Dict[str, Any]] = {}
while pending:
for qid in list(pending):
row = user_question_manager.get(qid)
if not row:
answered[qid] = {"status": "missing", "answer_text": "用户问题不存在。"}
pending.remove(qid)
continue
if row.get("username") != username:
answered[qid] = {"status": "forbidden", "answer_text": "用户问题所属用户不匹配。"}
pending.remove(qid)
continue
if row.get("status") == "answered":
answered[qid] = {**row, "answer_text": format_user_question_answer(row)}
pending.remove(qid)
if not pending:
break
if (time.time() - started) >= timeout_seconds:
for qid in list(pending):
answered[qid] = {"status": "timeout", "answer_text": "等待用户回答超时。"}
pending.remove(qid)
break
await asyncio.sleep(0.2)
return answered
async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None):
previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False)
web_terminal._tool_loop_active = True
allowed_tool_names = set()
try:
defined_tools = web_terminal.define_tools() or []
for tool in defined_tools:
name = ((tool or {}).get("function") or {}).get("name")
if isinstance(name, str) and name:
allowed_tool_names.add(name)
except Exception as exc:
debug_log(f"构建工具白名单失败(降级继续): {exc}")
recent_tool_actions = list(getattr(web_terminal, "_recent_tool_actions", []) or [])
user_question_results_by_tool_call_id: Dict[str, str] = {}
user_question_id_by_tool_call_id: Dict[str, str] = {}
ask_user_items: List[Dict[str, Any]] = []
for idx, pending_tool_call in enumerate(tool_calls or []):
function = (pending_tool_call or {}).get("function") or {}
if function.get("name") != "ask_user":
continue
arguments = _safe_parse_tool_arguments_for_question(web_terminal, pending_tool_call)
if not isinstance(arguments, dict):
continue
question_text = str(arguments.get("question") or "").strip()
if not question_text:
continue
ask_user_items.append({
"tool_call": pending_tool_call,
"arguments": arguments,
"order": idx,
})
if ask_user_items:
batch_id = f"question_batch_{uuid.uuid4().hex}"
batch_total = len(ask_user_items)
created_questions: List[Dict[str, Any]] = []
for batch_index, item in enumerate(ask_user_items, start=1):
tool_call = item.get("tool_call") or {}
arguments = item.get("arguments") or {}
question = user_question_manager.create_question(
username=username,
conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None),
tool_call_id=tool_call.get("id"),
question=arguments.get("question"),
context=arguments.get("context"),
options=arguments.get("options"),
batch_id=batch_id,
batch_index=batch_index,
batch_total=batch_total,
)
created_questions.append(question)
if tool_call.get("id"):
user_question_id_by_tool_call_id[str(tool_call.get("id"))] = question.get("question_id")
sender('update_action', {
'preparing_id': tool_call.get("id"),
'status': 'awaiting_user_answer',
'result': {
"success": False,
"status": "awaiting_user_answer",
"question_id": question.get("question_id"),
"message": "等待用户回答"
},
'message': '等待用户回答',
'conversation_id': conversation_id
})
sender('user_questions_required', {
'batch_id': batch_id,
'questions': created_questions,
'conversation_id': conversation_id,
})
wait_answers = await _wait_for_user_questions(
question_ids=[str(q.get("question_id") or "") for q in created_questions],
username=username,
)
for question in created_questions:
qid = str(question.get("question_id") or "")
answer_row = wait_answers.get(qid) or {}
answer_text = str(answer_row.get("answer_text") or "用户未回答。").strip() or "用户未回答。"
tool_call_id = str(question.get("tool_call_id") or "")
if tool_call_id:
user_question_results_by_tool_call_id[tool_call_id] = answer_text
sender('user_questions_resolved', {
'batch_id': batch_id,
'question_ids': [q.get("question_id") for q in created_questions],
'conversation_id': conversation_id,
})
# 执行每个工具
pending_runtime_mode_notices: List[str] = []
last_completed_tool_call_id: Optional[str] = None
deep_compression_pending = False
for tool_call in tool_calls:
# 检查停止标志
client_stop_info = get_stop_flag(client_sid, username)
if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested:
debug_log("在工具调用过程中检测到停止状态")
tool_call_id = tool_call.get("id")
function_name = tool_call.get("function", {}).get("name")
# 通知前端该工具已被取消,避免界面卡住
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'cancelled',
'result': {
"success": False,
"status": "cancelled",
"message": "命令执行被用户取消",
"tool": function_name
}
})
# 在消息列表中记录取消结果,防止重新加载时仍显示运行中
if tool_call_id:
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": "命令执行被用户取消",
})
sender('task_stopped', {
'message': '命令执行被用户取消',
'reason': 'user_stop'
})
clear_stop_flag(client_sid, username)
web_terminal._tool_loop_active = previous_tool_loop_active
return {"stopped": True, "last_tool_call_time": last_tool_call_time}
# 工具调用间隔控制
current_time = time.time()
if last_tool_call_time > 0:
elapsed = current_time - last_tool_call_time
if elapsed < TOOL_CALL_COOLDOWN:
await asyncio.sleep(TOOL_CALL_COOLDOWN - elapsed)
last_tool_call_time = time.time()
function_name = tool_call["function"]["name"]
arguments_str = tool_call["function"]["arguments"]
tool_call_id = tool_call["id"]
debug_log(f"准备解析JSON工具: {function_name}, 参数长度: {len(arguments_str)}")
debug_log(f"JSON参数前200字符: {arguments_str[:200]}")
debug_log(f"JSON参数后200字符: {arguments_str[-200:]}")
# 使用改进的参数解析方法
if hasattr(web_terminal, 'api_client') and hasattr(web_terminal.api_client, '_safe_tool_arguments_parse'):
success, arguments, error_msg = web_terminal.api_client._safe_tool_arguments_parse(arguments_str, function_name)
if not success:
debug_log(f"安全解析失败: {error_msg}")
error_text = f'工具参数解析失败: {error_msg}'
error_payload = {
"success": False,
"error": error_text,
"error_type": "parameter_format_error",
"tool_name": function_name,
"tool_call_id": tool_call_id,
"message": error_text
}
sender('error', {'message': error_text})
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': error_payload,
'message': error_text
})
error_content = json.dumps(error_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
error_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": error_content
})
continue
debug_log(f"使用安全解析成功,参数键: {list(arguments.keys())}")
else:
# 回退到带有基本修复逻辑的解析
try:
arguments = json.loads(arguments_str) if arguments_str.strip() else {}
debug_log(f"直接JSON解析成功参数键: {list(arguments.keys())}")
except json.JSONDecodeError as e:
debug_log(f"原始JSON解析失败: {e}")
# 尝试基本的JSON修复
repaired_str = arguments_str.strip()
repair_attempts = []
# 修复1: 未闭合字符串
if repaired_str.count('"') % 2 == 1:
repaired_str += '"'
repair_attempts.append("添加闭合引号")
# 修复2: 未闭合JSON对象
if repaired_str.startswith('{') and not repaired_str.rstrip().endswith('}'):
repaired_str = repaired_str.rstrip() + '}'
repair_attempts.append("添加闭合括号")
# 修复3: 截断的JSON移除不完整的最后一个键值对
if not repair_attempts: # 如果前面的修复都没用上
last_comma = repaired_str.rfind(',')
if last_comma > 0:
repaired_str = repaired_str[:last_comma] + '}'
repair_attempts.append("移除不完整的键值对")
# 尝试解析修复后的JSON
try:
arguments = json.loads(repaired_str)
debug_log(f"JSON修复成功: {', '.join(repair_attempts)}")
debug_log(f"修复后参数键: {list(arguments.keys())}")
except json.JSONDecodeError as repair_error:
debug_log(f"JSON修复也失败: {repair_error}")
debug_log(f"修复尝试: {repair_attempts}")
debug_log(f"修复后内容前100字符: {repaired_str[:100]}")
error_text = f'工具参数解析失败: {e}'
error_payload = {
"success": False,
"error": error_text,
"error_type": "parameter_format_error",
"tool_name": function_name,
"tool_call_id": tool_call_id,
"message": error_text
}
sender('error', {'message': error_text})
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': error_payload,
'message': error_text
})
error_content = json.dumps(error_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
error_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": error_content
})
continue
# 严格校验:只允许执行当前回合明确下发给模型的工具。
# 某些模型会“幻觉”调用未下发工具(如 view_image必须在执行层拦截。
if allowed_tool_names and function_name not in allowed_tool_names:
if function_name == "view_image" and "vlm_analyze" in allowed_tool_names:
if not arguments.get("prompt"):
arguments["prompt"] = "请详细分析这张图片的内容,包括关键文字、主体对象与场景信息。"
debug_log(
"工具名自动纠正: view_image -> vlm_analyze (模型未获授权 view_image)"
)
function_name = "vlm_analyze"
else:
denied_message = (
f"工具 {function_name} 不在当前模型可用工具列表中,已拒绝执行。"
)
denied_payload = {
"success": False,
"status": "denied",
"code": "tool_not_allowed",
"tool": function_name,
"message": denied_message,
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': denied_payload,
'message': denied_message,
'conversation_id': conversation_id
})
denied_content = json.dumps(denied_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
denied_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": denied_content
})
continue
debug_log(f"执行工具: {function_name} (ID: {tool_call_id})")
previous_tool_actions = list(recent_tool_actions)
recent_tool_actions.append({
"tool_name": function_name,
"arguments": arguments,
})
recent_tool_actions = recent_tool_actions[-3:]
setattr(web_terminal, "_recent_tool_actions", list(recent_tool_actions))
permission_eval = web_terminal.evaluate_tool_permission(function_name, arguments)
if not permission_eval.get("allowed", True):
denied_message = permission_eval.get("message") or "当前权限模式不允许执行该工具。"
denied_payload = {
"success": False,
"status": "denied",
"code": permission_eval.get("code") or "permission_denied",
"tool": function_name,
"mode": permission_eval.get("mode"),
"message": denied_message,
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': denied_payload,
'message': denied_message,
'conversation_id': conversation_id
})
denied_content = json.dumps(denied_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
denied_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": denied_content
})
continue
if permission_eval.get("requires_approval"):
approval_preview = _build_tool_approval_preview(web_terminal, function_name, arguments)
approval_item = tool_approval_manager.create_request(
username=username,
conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None),
tool_call_id=tool_call_id,
tool_name=function_name,
arguments=arguments,
preview=approval_preview,
)
sender('tool_approval_required', {
'approval': approval_item,
'conversation_id': conversation_id,
})
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'awaiting_approval',
'result': {
"success": False,
"status": "awaiting_approval",
"approval_id": approval_item.get("approval_id"),
"message": "等待用户审批"
},
'message': '等待用户审批',
'conversation_id': conversation_id
})
wait_result = None
permission_mode = str(permission_eval.get("mode") or "")
if permission_mode == "auto_approval":
approval_id = approval_item.get("approval_id")
wait_result = await run_auto_approval(
web_terminal=web_terminal,
username=username,
approval_id=approval_id,
conversation_id=conversation_id,
recent_tool_actions=previous_tool_actions,
function_name=function_name,
arguments=arguments,
risk_markers=permission_eval.get("risk_markers") if isinstance(permission_eval, dict) else None,
sender=sender,
)
else:
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"),
'decision': wait_result.get("decision"),
'reason': ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason")),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
reason = ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason") or "").strip()
if reason:
reject_message = f"{reject_message}{reason}"
reject_payload = {
"success": False,
"status": "rejected",
"code": "approval_rejected",
"tool": function_name,
"message": reject_message,
"approval_id": approval_item.get("approval_id"),
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': reject_payload,
'message': reject_message,
'conversation_id': conversation_id
})
reject_content = _format_rejected_tool_text(reason or reject_message)
web_terminal.context_manager.add_conversation(
"tool",
reject_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": reject_content
})
if permission_mode != "auto_approval":
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
continue
# 发送工具开始事件
tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}"
monitor_snapshot = None
snapshot_path = None
memory_snapshot_type = None
if function_name in MONITOR_FILE_TOOLS:
snapshot_path = resolve_monitor_path(arguments)
monitor_snapshot = capture_monitor_snapshot(web_terminal.file_manager, snapshot_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log)
if monitor_snapshot:
cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot)
elif function_name in MONITOR_MEMORY_TOOLS:
memory_snapshot_type = (arguments.get('memory_type') or 'main').lower()
before_entries = None
try:
before_entries = resolve_monitor_memory(web_terminal.memory_manager._read_entries(memory_snapshot_type), MONITOR_MEMORY_ENTRY_LIMIT)
except Exception as exc:
debug_log(f"[MonitorSnapshot] 读取记忆失败: {memory_snapshot_type} ({exc})")
if before_entries is not None:
monitor_snapshot = {
'memory_type': memory_snapshot_type,
'entries': before_entries
}
cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot)
sender('tool_start', {
'id': tool_display_id,
'name': function_name,
'arguments': arguments,
'preparing_id': tool_call_id,
'monitor_snapshot': monitor_snapshot,
'conversation_id': conversation_id
})
brief_log(f"调用了工具: {function_name}")
await asyncio.sleep(0.3)
start_time = time.time()
# 执行工具,同时监听停止标志
debug_log(f"[停止检测] 开始执行工具: {function_name}")
tool_result = None
tool_cancelled = False
tool_task = None
check_count = 0
if function_name == "ask_user" and str(tool_call_id) in user_question_results_by_tool_call_id:
answer_text = user_question_results_by_tool_call_id.get(str(tool_call_id)) or "用户未回答。"
qid = user_question_id_by_tool_call_id.get(str(tool_call_id))
tool_result = json.dumps({
"success": True,
"status": "answered",
"message": answer_text,
"answer_text": answer_text,
"question_id": qid,
}, ensure_ascii=False)
else:
tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments))
# 在工具执行期间持续检查停止标志
while not tool_task.done():
await asyncio.sleep(0.1) # 每100ms检查一次
check_count += 1
client_stop_info = get_stop_flag(client_sid, username)
if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested:
debug_log(f"[停止检测] 工具执行过程中检测到停止请求(检查次数:{check_count}),立即取消工具")
tool_task.cancel()
tool_cancelled = True
break
debug_log(f"[停止检测] 工具执行完成cancelled={tool_cancelled}, 检查次数={check_count}")
# 获取工具结果或处理取消
if tool_cancelled:
try:
if tool_task is not None:
await tool_task
except asyncio.CancelledError:
debug_log("[停止检测] 工具任务已被取消(CancelledError)")
except Exception as e:
debug_log(f"[停止检测] 工具任务取消时发生异常: {e}")
# 返回取消消息
tool_result = json.dumps({
"success": False,
"status": "cancelled",
"message": "命令执行被用户取消"
}, ensure_ascii=False)
debug_log("[停止检测] 发送取消通知到前端")
# 通知前端工具被取消
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'cancelled',
'result': {
"success": False,
"status": "cancelled",
"message": "命令执行被用户取消",
"tool": function_name
}
})
# 记录取消结果到消息历史
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": "命令执行被用户取消",
})
# 保存取消结果
web_terminal.context_manager.add_conversation(
"tool",
"命令执行被用户取消",
tool_call_id=tool_call_id,
name=function_name,
metadata={"status": "cancelled"}
)
debug_log("[停止检测] 取消结果已保存到对话历史")
# 发送停止事件并清除标志
sender('task_stopped', {
'message': '命令执行被用户取消',
'reason': 'user_stop'
})
clear_stop_flag(client_sid, username)
debug_log("[停止检测] 返回stopped=True")
web_terminal._tool_loop_active = previous_tool_loop_active
return {"stopped": True, "last_tool_call_time": last_tool_call_time}
else:
if tool_result is None and tool_task is not None:
tool_result = await tool_task
if tool_result is None:
tool_result = json.dumps({"success": False, "error": "工具未返回结果"}, ensure_ascii=False)
debug_log(f"工具结果: {tool_result[:200]}...")
execution_time = time.time() - start_time
if execution_time < 1.5:
await asyncio.sleep(1.5 - execution_time)
# 更新工具状态
result_data = {}
try:
result_data = json.loads(tool_result)
except:
result_data = {'output': tool_result}
# 批准模式下 run_command 采用“先只读执行,触发权限拒绝后再审批并单次可写重试”。
if (
function_name == "run_command"
and permission_eval.get("mode") in {"approval", "auto_approval"}
and not bool(arguments.get("_approval_write_granted", False))
and _is_permission_denied_result(result_data)
):
approval_preview = _build_tool_approval_preview(web_terminal, function_name, arguments)
approval_item = tool_approval_manager.create_request(
username=username,
conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None),
tool_call_id=tool_call_id,
tool_name=function_name,
arguments=arguments,
preview=approval_preview,
)
sender('tool_approval_required', {
'approval': approval_item,
'conversation_id': conversation_id,
})
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'awaiting_approval',
'result': {
"success": False,
"status": "awaiting_approval",
"approval_id": approval_item.get("approval_id"),
"message": "检测到写权限受限,等待用户审批后重试"
},
'message': '检测到写权限受限,等待用户审批',
'conversation_id': conversation_id
})
wait_result = None
permission_mode = str(permission_eval.get("mode") or "")
if permission_mode == "auto_approval":
approval_id = approval_item.get("approval_id")
wait_result = await run_auto_approval(
web_terminal=web_terminal,
username=username,
approval_id=approval_id,
conversation_id=conversation_id,
recent_tool_actions=previous_tool_actions,
function_name=function_name,
arguments=arguments,
risk_markers=permission_eval.get("risk_markers") if isinstance(permission_eval, dict) else None,
sender=sender,
)
else:
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"),
'decision': wait_result.get("decision"),
'reason': ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason")),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
reason = ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason") or "").strip()
if reason:
reject_message = f"{reject_message}{reason}"
reject_payload = {
"success": False,
"status": "rejected",
"code": "approval_rejected",
"tool": function_name,
"message": reject_message,
"approval_id": approval_item.get("approval_id"),
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': reject_payload,
'message': reject_message,
'conversation_id': conversation_id
})
reject_content = _format_rejected_tool_text(reason or reject_message)
web_terminal.context_manager.add_conversation(
"tool",
reject_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": reject_content
})
if permission_mode == "approval":
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
continue
retry_arguments = dict(arguments or {})
retry_arguments["_approval_write_granted"] = True
retry_arguments["_approval_network_granted"] = True
retry_tool_result = await web_terminal.handle_tool_call(function_name, retry_arguments)
try:
result_data = json.loads(retry_tool_result)
tool_result = retry_tool_result
except Exception:
result_data = {"output": retry_tool_result}
tool_result = retry_tool_result
tool_failed = detect_tool_failure(result_data)
action_status = 'completed'
action_message = None
awaiting_flag = False
if function_name in {"write_file", "edit_file"}:
diff_path = result_data.get("path") or arguments.get("file_path")
summary = result_data.get("summary") or result_data.get("message")
if summary:
action_message = summary
debug_log(f"{function_name} 执行完成: {summary or '无摘要'}")
if function_name == "sleep":
try:
if isinstance(result_data, dict) and result_data.get("mode") == "wait_sub_agent_ids":
waited_task_ids = result_data.get("waited_task_ids") or []
if not hasattr(web_terminal, "_announced_sub_agent_tasks"):
web_terminal._announced_sub_agent_tasks = set()
for tid in waited_task_ids:
if tid:
web_terminal._announced_sub_agent_tasks.add(str(tid))
except Exception:
pass
monitor_snapshot_after = None
if function_name in MONITOR_FILE_TOOLS:
result_path = None
if isinstance(result_data, dict):
result_path = resolve_monitor_path(result_data)
if not result_path:
candidate_path = result_data.get('path')
if isinstance(candidate_path, str) and candidate_path.strip():
result_path = candidate_path.strip()
if not result_path:
result_path = resolve_monitor_path(arguments, snapshot_path) or snapshot_path
monitor_snapshot_after = capture_monitor_snapshot(web_terminal.file_manager, result_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log)
elif function_name in MONITOR_MEMORY_TOOLS:
memory_after_type = str(
arguments.get('memory_type')
or (isinstance(result_data, dict) and result_data.get('memory_type'))
or memory_snapshot_type
or 'main'
).lower()
after_entries = None
try:
after_entries = resolve_monitor_memory(web_terminal.memory_manager._read_entries(memory_after_type), MONITOR_MEMORY_ENTRY_LIMIT)
except Exception as exc:
debug_log(f"[MonitorSnapshot] 读取记忆失败(after): {memory_after_type} ({exc})")
if after_entries is not None:
monitor_snapshot_after = {
'memory_type': memory_after_type,
'entries': after_entries
}
mcp_parsed = None
update_result_data = result_data
if isinstance(function_name, str) and function_name.startswith("mcp__") and isinstance(result_data, dict):
try:
mcp_parsed = extract_mcp_content_for_context(result_data)
update_result_data = mcp_parsed.get("sanitized_payload") or result_data
except Exception:
mcp_parsed = None
update_result_data = result_data
update_payload = {
'id': tool_display_id,
'status': action_status,
'result': update_result_data,
'preparing_id': tool_call_id,
'conversation_id': conversation_id
}
if action_message:
update_payload['message'] = action_message
if awaiting_flag:
update_payload['awaiting_content'] = True
if monitor_snapshot_after:
update_payload['monitor_snapshot_after'] = monitor_snapshot_after
cache_monitor_snapshot(tool_display_id, 'after', monitor_snapshot_after)
sender('update_action', update_payload)
if function_name in ['create_file', 'delete_file', 'rename_file', 'create_folder']:
if not web_terminal.context_manager._is_host_mode_without_safety():
structure = web_terminal.context_manager.get_project_structure()
sender('file_tree_update', structure)
# ===== 增量保存:立即保存工具结果 =====
metadata_payload = None
tool_images = None
tool_videos = None
tool_media_refs = None
if isinstance(result_data, dict):
if isinstance(function_name, str) and function_name.startswith("mcp__"):
if not isinstance(mcp_parsed, dict):
mcp_parsed = extract_mcp_content_for_context(result_data)
tool_result_content = (
mcp_parsed.get("text")
or format_tool_result_for_context(function_name, result_data, tool_result)
)
mcp_media_items = mcp_parsed.get("media_items") or []
if mcp_media_items:
tool_media_refs = []
for item in mcp_media_items:
if not isinstance(item, dict):
continue
tool_media_refs.append(
{
"kind": item.get("kind"),
"mime_type": item.get("mime_type"),
"data_base64": item.get("data_base64"),
"source": "mcp_content",
"item_type": item.get("item_type"),
"label": item.get("label"),
"name": item.get("name"),
"title": item.get("title"),
"uri": item.get("uri"),
"url": item.get("url"),
"index": item.get("index"),
}
)
metadata_payload = {
"tool_payload": mcp_parsed.get("sanitized_payload") or result_data
}
else:
# 特殊处理 web_search保留可供前端渲染的精简结构以便历史记录复现搜索结果
if function_name == "web_search":
try:
tool_result_content = json.dumps(compact_web_search_result(result_data), ensure_ascii=False)
except Exception:
tool_result_content = tool_result
else:
tool_result_content = format_tool_result_for_context(function_name, result_data, tool_result)
metadata_payload = {"tool_payload": result_data}
else:
tool_result_content = tool_result
tool_message_content = tool_result_content
# view_image: 将图片直接附加到 tool 结果中(不再插入 user 消息)
if function_name == "view_image" and getattr(web_terminal, "pending_image_view", None):
inj = web_terminal.pending_image_view
web_terminal.pending_image_view = None
if (
not tool_failed
and isinstance(result_data, dict)
and result_data.get("success") is not False
):
img_path = inj.get("path") if isinstance(inj, dict) else None
if img_path:
tool_images = [img_path]
if metadata_payload is None:
metadata_payload = {}
metadata_payload["tool_image_path"] = img_path
sender('system_message', {
'content': f'系统已记录图片路径(不再附带二进制数据): {img_path}'
})
# view_video: 将视频直接附加到 tool 结果中(不再插入 user 消息)
if function_name == "view_video" and getattr(web_terminal, "pending_video_view", None):
inj = web_terminal.pending_video_view
web_terminal.pending_video_view = None
if (
not tool_failed
and isinstance(result_data, dict)
and result_data.get("success") is not False
):
video_path = inj.get("path") if isinstance(inj, dict) else None
if video_path:
tool_videos = [video_path]
if metadata_payload is None:
metadata_payload = {}
metadata_payload["tool_video_path"] = video_path
sender('system_message', {
'content': f'系统已记录视频路径(不再附带二进制数据): {video_path}'
})
# 立即保存工具结果
saved_tool_message = web_terminal.context_manager.add_conversation(
"tool",
tool_result_content,
tool_call_id=tool_call_id,
name=function_name,
metadata=metadata_payload,
images=tool_images,
videos=tool_videos,
media_refs=tool_media_refs,
)
saved_media_refs = []
if isinstance(saved_tool_message, dict):
saved_media_refs = saved_tool_message.get("media_refs") or []
# 将工具结果即时组装为多模态消息,确保同一轮 tool loop 的下一次模型请求能真正看到媒体
if tool_images or tool_videos or saved_media_refs:
try:
tool_message_content = web_terminal.context_manager._build_content_with_images(
str(tool_result_content or ""),
tool_images or [],
tool_videos or [],
media_refs=saved_media_refs,
)
except Exception:
tool_message_content = tool_result_content
# 添加到消息历史(用于 API 继续对话)。
# 必须在任何可能插入 user 消息/触发深层压缩总结之前完成,避免 assistant.tool_calls
# 与对应 tool 结果之间夹入 user尤其是同一轮并行 tool_calls 尚未全部返回时。
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": tool_message_content
})
last_completed_tool_call_id = tool_call_id
try:
workspace_data_dir = getattr(workspace, "data_dir", None) if workspace else None
personal_config = load_personalization_config(workspace_data_dir) if workspace_data_dir else {}
except Exception:
personal_config = {}
compression_settings = resolve_context_compression_settings(personal_config)
auto_shallow_enabled = bool(personal_config.get("auto_shallow_compress_enabled", False))
auto_deep_enabled = bool(personal_config.get("auto_deep_compress_enabled", False))
compressed_count = 0
try:
compressed_count = int(
web_terminal.context_manager.on_tool_call_finished(
function_name,
enable_shallow=auto_shallow_enabled,
shallow_trigger_tokens=compression_settings["shallow_trigger_tokens"],
deep_trigger_tokens=compression_settings["deep_trigger_tokens"],
shallow_batch_size=compression_settings["shallow_max_replace_per_round"],
shallow_keep_recent_tools=compression_settings["shallow_keep_recent_tools"],
shallow_trigger_tool_calls_interval=compression_settings["shallow_trigger_tool_calls_interval"],
shallow_keep_user_turn_tools=compression_settings.get("shallow_keep_user_turn_tools", 3),
) or 0
)
except Exception as exc:
debug_log(f"[ContextCompression] 工具后浅压缩检测失败: {exc}")
if compressed_count > 0:
sender('shallow_compression', {
"conversation_id": conversation_id,
"compressed_count": compressed_count,
"keep_recent_tools": compression_settings["shallow_keep_recent_tools"],
"keep_user_turn_tools": compression_settings.get("shallow_keep_user_turn_tools", 3),
})
# 关键修复同一轮工具循环里API 继续调用使用的是本地 messages不是每次重建上下文
# 因此需要把已打标的旧 tool 结果同步替换为占位符,避免日志看起来“弹窗触发但请求未替换”。
if auto_shallow_enabled and messages:
try:
marked_keys = set()
for item in (web_terminal.context_manager.conversation_history or []):
if not isinstance(item, dict) or item.get("role") != "tool":
continue
item_meta = item.get("metadata") or {}
if not item_meta.get("auto_shallow_compacted"):
continue
marked_keys.add((str(item.get("tool_call_id") or ""), str(item.get("name") or "")))
if marked_keys:
replaced_in_loop = 0
for msg in messages:
if not isinstance(msg, dict) or msg.get("role") != "tool":
continue
key = (str(msg.get("tool_call_id") or ""), str(msg.get("name") or ""))
if key in marked_keys and msg.get("content") != AUTO_SHALLOW_PLACEHOLDER:
msg["content"] = AUTO_SHALLOW_PLACEHOLDER
replaced_in_loop += 1
if replaced_in_loop > 0:
debug_log(f"[ContextCompression] 同步替换本轮 messages 中已压缩 tool 结果: {replaced_in_loop}")
except Exception as exc:
debug_log(f"[ContextCompression] 同步替换本轮 messages 失败: {exc}")
debug_log(f"💾 增量保存:工具结果 {function_name}")
system_message = result_data.get("system_message") if isinstance(result_data, dict) else None
if system_message:
inject_runtime_user_message(
web_terminal=web_terminal,
messages=messages,
text=system_message,
source="sub_agent",
sender=sender,
conversation_id=conversation_id,
after_tool_call_id=tool_call_id,
inline=False,
extra_metadata={"task_id": result_data.get("task_id")},
)
maybe_mark_failure_from_message(web_terminal, system_message)
# 自动深层压缩(工具调用后触发)
current_context_tokens = web_terminal.context_manager.get_current_context_tokens(conversation_id)
if (
auto_deep_enabled
and current_context_tokens > compression_settings["deep_trigger_tokens"]
and not web_terminal.context_manager.is_compression_in_progress()
):
# 只标记,不能在本工具刚结束时立即压缩。若本轮是并行 tool_calls
# 立即调用总结模型会在 assistant.tool_calls 与尚未补齐的 tool 结果之间插入 user prompt
# 触发 OpenAI 兼容接口的 tool message 顺序校验错误。
deep_compression_pending = True
if not pending_runtime_mode_notices:
try:
if hasattr(web_terminal, "apply_pending_runtime_mode_changes"):
pending_runtime_mode_notices = list(web_terminal.apply_pending_runtime_mode_changes() or [])
except Exception as exc:
debug_log(f"[RuntimeMode] 应用挂起模式变更失败: {exc}")
await asyncio.sleep(0.2)
if tool_failed:
mark_force_thinking(web_terminal, reason=f"{function_name}_failed")
# 自动深层压缩必须等待同一轮全部 tool_call 的 tool 消息都已写入 messages/历史后再触发。
if deep_compression_pending and not web_terminal.context_manager.is_compression_in_progress():
web_terminal.context_manager._set_meta_flag("is_ultra_long_conversation", True)
sender('compression_state', {
"conversation_id": conversation_id,
"in_progress": True,
"mode": "auto",
"stage": "queued"
})
deep_result = await run_deep_compression(
web_terminal=web_terminal,
workspace=workspace,
conversation_id=conversation_id,
mode="auto",
sender=sender,
)
if not deep_result.get("success"):
sender('error', {
"message": deep_result.get("error") or "自动深层压缩失败",
"conversation_id": conversation_id,
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"deep_compressed": True,
"deep_result": deep_result,
"last_tool_call_time": last_tool_call_time
}
# 子智能体/后台指令完成通知:必须等待同一轮全部 tool_call 都完成后再注入,
# 避免在 assistant.tool_calls 与未完成的 tool 结果之间插入 system 消息导致 API 报错。
if last_completed_tool_call_id:
debug_log(
f"[SubAgent] after all tools finished -> poll updates inline after_tool_call_id={last_completed_tool_call_id}"
)
await process_sub_agent_updates(
messages=messages,
inline=True,
after_tool_call_id=last_completed_tool_call_id,
web_terminal=web_terminal,
sender=sender,
debug_log=debug_log,
maybe_mark_failure_from_message=maybe_mark_failure_from_message,
)
debug_log(
"[BgCmdDebug] after all tools finished -> poll background command updates "
f"inline after_tool_call_id={last_completed_tool_call_id}"
)
await process_background_command_updates(
messages=messages,
inline=True,
after_tool_call_id=last_completed_tool_call_id,
web_terminal=web_terminal,
sender=sender,
debug_log=debug_log,
maybe_mark_failure_from_message=maybe_mark_failure_from_message,
)
# 运行期模式通知:必须等待同一轮全部 tool_call 都完成后再注入,
# 避免在 assistant.tool_calls 与对应 tool 消息之间插入 user 消息导致 API 报错。
if pending_runtime_mode_notices:
for notice in pending_runtime_mode_notices:
if isinstance(notice, dict):
_text = str(notice.get("text") or "").strip()
_source = str(notice.get("source") or "notify").strip() or "notify"
else:
_text = str(notice or "").strip()
_source = "notify"
if not _text:
continue
_inject_runtime_mode_notice(
web_terminal=web_terminal,
messages=messages,
content=_text,
source=_source,
sender=sender,
conversation_id=conversation_id,
)
# 运行期“引导对话”:需要等待同一轮全部工具执行结束后再注入,
# 避免一轮内并行/多工具调用时过早插入。
try:
from .tasks import task_manager
runtime_guidance_items = task_manager.consume_runtime_guidance_for_injection(
username=username, task_id=client_sid
)
except Exception as exc:
runtime_guidance_items = []
debug_log(f"[RuntimeGuidance] 读取引导队列失败: {exc}")
if runtime_guidance_items:
injected_count = 0
for raw_item in runtime_guidance_items:
runtime_guidance_source = "guidance"
if isinstance(raw_item, dict):
runtime_guidance_text = str(raw_item.get("text") or "").strip()
runtime_guidance_source = (
str(raw_item.get("source") or "guidance").strip().lower()
or "guidance"
)
else:
runtime_guidance_text = str(raw_item or "").strip()
if not runtime_guidance_text:
continue
inject_runtime_user_message(
web_terminal=web_terminal,
messages=messages,
text=runtime_guidance_text,
source=runtime_guidance_source,
sender=sender,
conversation_id=conversation_id,
inline=True,
)
injected_count += 1
if injected_count:
debug_log(
"[RuntimeGuidance] 已在工具结果批次结束后批量注入引导/通知 "
f"task_id={client_sid} count={injected_count}"
)
web_terminal._tool_loop_active = previous_tool_loop_active
return {"stopped": False, "last_tool_call_time": last_tool_call_time}