Introduce workspace-level goal state persistence, goal prompt injection, and after-turn review handling so an active task can continue until the configured completion conditions are met. Add a dedicated goal review agent with readonly and active evidence modes, configurable model settings, review prompt, token/turn boundaries, idle-no-tool protection, and progress/completed/stopped events. Wire goal_mode through task creation, task restoration, compression handoff, runtime user messages, API message sanitization, and tool-call ordering so goal continuations survive long-running tasks and deep compression. Add Vue UI for arming goal mode from the quick menu, showing running/completed banners, displaying progress metrics, restoring running goal state, and exposing personalization settings for review mode and stop limits. Include goal mode research notes and default goal review configuration.
1388 lines
63 KiB
Python
1388 lines
63 KiB
Python
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")
|
||
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:
|
||
inject_runtime_user_message(
|
||
web_terminal=web_terminal,
|
||
messages=messages,
|
||
text=content,
|
||
source="notify",
|
||
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_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:
|
||
_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 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}
|