agent-Specialization/server/chat_flow_tool_loop.py
JOJO 0f4cf1078a fix(ui,runtime,search): stabilize markdown/table rendering, runtime notices, and title/search config
This commit bundles a coordinated fix set across frontend and backend runtime flows:\n\n1) Markdown table overflow UX\n- Wrap rendered markdown tables in a dedicated horizontal-scroll container.\n- Preserve wrapper attributes through sanitize so styles always apply.\n- Keep page-level horizontal scroll disabled while allowing in-container table scroll.\n- Improve table container visual consistency (border/radius/shadow/scrollbar behavior).\n\n2) Code block horizontal jitter\n- Adjust code/pre scrollbar gutter and box model handling to remove left-right jump.\n\n3) Execution mode auto-fallback sync + toast behavior\n- Add expiry-driven frontend sync timer for direct->sandbox fallback state refresh.\n- Ensure permission/execution UI state updates at expiry time.\n- Keep fallback warning toast persistent but avoid false-positive toasts when simply switching to sandbox conversations.\n- Trigger fallback toast only when a real direct-expiry transition is detected.\n\n4) Runtime guidance/notify display consistency\n- Unify live polling rendering behavior with history replay behavior.\n- Restore real-time display of guidance/notify/sub-agent/background-command messages.\n- Keep runtime_mode_notice hidden only in idle state as requested.\n\n5) Backend runtime notice emission policy\n- Suppress runtime mode notice insertion when no task is running (idle path).\n- Preserve notice injection when task is actively running.\n\n6) Tool-call ordering safety for injected completion notices\n- Delay inline sub-agent/background-command completion message insertion until all parallel tool calls in the same assistant turn are finished.\n- Prevent invalid assistant/tool_call ordering that causes provider 400 errors for missing tool_call_id responses.\n\n7) Left panel scrolling behavior\n- Enable vertical scrolling for project files / todo / sub-agent / background-command panels.\n- Hide panel scrollbars while preserving scroll capability.\n\n8) Tavily multi-key selection support\n- Introduce config/search.py with selectable env variable name for Tavily key resolution.\n- Keep existing AGENT_TAVILY_API_KEY naming compatible.\n- Export search config through config package.\n\n9) Dedicated conversation-title model config\n- Add AGENT_TITLE_API_BASE_URL / AGENT_TITLE_API_KEY / AGENT_TITLE_MODEL_ID.\n- Route title generation calls to these dedicated credentials/model with fallback defaults.\n\n10) Supporting updates\n- Update .env.example to document new Tavily and title-generation env vars.\n- Include current custom model profile tweak (kimi-k2.6).\n\nValidated with:\n- npm run build\n- python3 -m py_compile (affected backend/config modules)\n- python3 -m unittest test.test_server_refactor_smoke
2026-05-13 15:13:52 +08:00

1294 lines
58 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 modules.auto_approval_service import run_auto_approval
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_provided = "replace_all" in args
replace_all = args.get("replace_all") if replace_all_provided else None
preview["file_path"] = file_path
preview["replace_all"] = replace_all
if not file_path:
preview["summary"] = "缺少 file_path"
return preview
if old_string is None or new_string is None:
preview["summary"] = "缺少 old_string/new_string"
return preview
if not replace_all_provided:
preview["summary"] = "缺少 replace_all必须显式传入 true 或 false"
return preview
if not isinstance(replace_all, bool):
preview["summary"] = "replace_all 必须是 true 或 false"
return preview
old_text = str(old_string or "")
short_old_text_notice = len(old_text.splitlines()) < 3
if short_old_text_notice:
preview["notice"] = "提示old_string 少于3行允许继续执行需要批量替换的场景可以单行或不足一行"
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")
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显示原始替换内容"
if short_old_text_notice:
preview["summary"] = f"{preview['summary']}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 {"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
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",
)
return any(marker in joined for marker in markers)
def _inject_runtime_mode_notice(
*,
web_terminal,
messages: List[Dict[str, Any]],
content: str,
sender=None,
conversation_id: Optional[str] = None,
) -> None:
if not isinstance(content, str) or not content.strip():
return
text = content.strip()
metadata = {
"runtime_guidance": True,
"runtime_guidance_original": text,
"runtime_mode_notice": True,
"message_source": "notify",
}
try:
web_terminal.context_manager.add_conversation(
"user",
text,
metadata=metadata,
)
except Exception:
pass
messages.append(
{
"role": "user",
"content": text,
"metadata": metadata,
}
)
if callable(sender):
try:
sender(
"user_message",
{
"message": text,
"conversation_id": conversation_id,
"runtime_guidance": True,
"runtime_guidance_original": text,
"message_source": "notify",
},
)
except Exception:
pass
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)
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 [])
# 执行每个工具
pending_runtime_mode_notices: List[str] = []
last_completed_tool_call_id: Optional[str] = None
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})")
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_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}
# 批准模式下 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_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 == "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
})
last_completed_tool_call_id = tool_call_id
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 都完成后再注入,
# 避免在 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 消息之间插入 system 消息导致 API 报错。
if pending_runtime_mode_notices:
for notice in pending_runtime_mode_notices:
_inject_runtime_mode_notice(
web_terminal=web_terminal,
messages=messages,
content=notice,
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 runtime_guidance_source not in {"guidance", "notify", "user", "presend", "sub_agent", "background_command"}:
runtime_guidance_source = "guidance"
if not runtime_guidance_text:
continue
runtime_guidance_meta = {
"runtime_guidance": True,
"runtime_guidance_original": runtime_guidance_text,
"message_source": runtime_guidance_source,
}
try:
web_terminal.context_manager.add_conversation(
"user",
runtime_guidance_text,
metadata=runtime_guidance_meta,
)
except Exception as exc:
debug_log(f"[RuntimeGuidance] 持久化引导消息失败: {exc}")
messages.append(
{
"role": "user",
"content": runtime_guidance_text,
"metadata": runtime_guidance_meta,
}
)
sender(
"user_message",
{
"message": runtime_guidance_text,
"conversation_id": conversation_id,
"runtime_guidance": True,
"runtime_guidance_original": runtime_guidance_text,
"message_source": runtime_guidance_source,
},
)
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}