agent-Specialization/server/chat_flow_tool_loop.py

945 lines
44 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
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
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 .deep_compression import run_deep_compression
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")
old_string = args.get("old_string")
new_string = args.get("new_string")
replace_all = args.get("replace_all", False)
preview["file_path"] = file_path
preview["replace_all"] = replace_all
if not file_path:
preview["summary"] = "缺少 file_path"
return preview
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")
old_text = str(old_string or "")
new_text = str(new_string or "")
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}{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"] = "未在文件中定位到 old_string显示原始替换内容"
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 {"run_python", "runpython"}:
code = str(args.get("code") or "")
timeout = args.get("timeout")
preview["code"] = code
preview["timeout"] = timeout
# 兼容前端已有 command 展示分支
preview["command"] = code
preview["summary"] = f"执行 Python 代码timeout={timeout if timeout is not None else '未提供'}s"
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
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)
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}")
# 执行每个工具
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": "命令执行被用户取消",
"metadata": {"status": "cancelled"}
})
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})")
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 = 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"),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
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 = json.dumps(reject_payload, ensure_ascii=False)
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
})
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
}
# 发送工具开始事件
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_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments))
tool_result = None
tool_cancelled = False
# 在工具执行期间持续检查停止标志
check_count = 0
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:
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": "命令执行被用户取消",
"metadata": {"status": "cancelled"}
})
# 保存取消结果
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:
tool_result = await tool_task
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}
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 == "wait_sub_agent":
system_msg = result_data.get("system_message")
if system_msg:
messages.append({
"role": "system",
"content": system_msg
})
sender('system_message', {
'content': system_msg,
'inline': False
})
maybe_mark_failure_from_message(web_terminal, system_msg)
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
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}")
if function_name != "wait_sub_agent":
system_message = result_data.get("system_message") if isinstance(result_data, dict) else None
if system_message:
web_terminal._record_sub_agent_message(system_message, result_data.get("task_id"), inline=False)
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()
):
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
}
# 添加到消息历史用于API继续对话
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": tool_message_content
})
debug_log(f"[SubAgent] after tool={function_name} call_id={tool_call_id} -> poll updates")
await process_sub_agent_updates(messages=messages, inline=True, after_tool_call_id=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(f"[BgCmdDebug] after tool={function_name} call_id={tool_call_id} -> poll background command updates (inline)")
await process_background_command_updates(messages=messages, inline=True, after_tool_call_id=tool_call_id, web_terminal=web_terminal, sender=sender, debug_log=debug_log, maybe_mark_failure_from_message=maybe_mark_failure_from_message)
await asyncio.sleep(0.2)
if tool_failed:
mark_force_thinking(web_terminal, reason=f"{function_name}_failed")
web_terminal._tool_loop_active = previous_tool_loop_active
return {"stopped": False, "last_tool_call_time": last_tool_call_time}