feat: add conversation-bound permission modes and tool approval flow

This commit is contained in:
JOJO 2026-04-11 04:07:28 +08:00
parent 3f40aa949f
commit e814e89e32
15 changed files with 692 additions and 7 deletions

View File

@ -41,6 +41,45 @@ READ_TOOL_MAX_MATCHES = 50
PROJECT_MAX_STORAGE_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048"))
PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024
# 只读权限模式run_command 白名单配置
READONLY_RUN_COMMAND_ALLOWED = (
"grep",
"find",
"ls",
"pwd",
"tree",
"cat",
"head",
"tail",
"less",
"rg",
"wc",
"du",
"stat",
"file",
"sed",
"awk",
"git",
)
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS = (
"status",
"log",
"diff",
"show",
"branch",
"rev-parse",
)
READONLY_RUN_COMMAND_BLOCKED_TOKENS = (
"&&",
"||",
";",
"|",
">",
"<",
"$(",
"`",
)
__all__ = [
"MAX_CONTEXT_SIZE",
"MAX_FILE_SIZE",
@ -70,4 +109,7 @@ __all__ = [
"READ_TOOL_MAX_MATCHES",
"PROJECT_MAX_STORAGE_MB",
"PROJECT_MAX_STORAGE_BYTES",
"READONLY_RUN_COMMAND_ALLOWED",
"READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS",
"READONLY_RUN_COMMAND_BLOCKED_TOKENS",
]

View File

@ -176,6 +176,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.skill_strict_sub_agent_enabled: bool = False
self.skill_strict_run_command_foreground_enabled: bool = False
self.skill_strict_run_command_background_enabled: bool = False
self.default_permission_mode: str = "unrestricted"
self.current_permission_mode: str = "unrestricted"
# 当前生效的启用 skills用于强约束判定
self.enabled_skill_ids: Set[str] = set()
self.apply_personalization_preferences()
@ -283,6 +285,9 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path,
thinking_mode=self.thinking_mode,
run_mode=self.run_mode
run_mode=self.run_mode,
metadata_overrides={
"permission_mode": self.default_permission_mode or "unrestricted"
},
)
print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}")

View File

@ -19,6 +19,8 @@ try:
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
)
except ImportError:
import sys
@ -39,6 +41,8 @@ except ImportError:
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
)
from modules.file_manager import FileManager
@ -295,6 +299,43 @@ class MainTerminalContextMixin:
"role": "system",
"content": disabled_notice
})
permission_mode = "unrestricted"
try:
permission_mode = self.get_permission_mode()
except Exception:
permission_mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted")
permission_label_map = {
"readonly": "只读",
"approval": "批准",
"unrestricted": "无限制",
}
permission_rules_map = {
"readonly": "",
"approval": "可调用全部工具,但对工作区文件进行修改的工具需经用户批准后执行;若 run_command/terminal_input 属于只读白名单命令则可直接执行;若用户拒绝,工具循环会停止。",
"unrestricted": "工具按常规流程执行,不额外拦截。",
}
readonly_cmds = [cmd for cmd in (READONLY_RUN_COMMAND_ALLOWED or ()) if cmd not in {"git"}]
readonly_cmds_text = "/".join(readonly_cmds) if readonly_cmds else "grep/find"
git_readonly_text = "/".join(READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS or ())
permission_rules_map["readonly"] = (
"只允许读取/检索类工具;修改工作区的工具将被拒绝。"
f"run_command 仅允许只读命令({readonly_cmds_text}"
f"以及 git {git_readonly_text}),且禁止多指令拼接。"
)
permission_prompt = self.load_prompt("permission_mode")
if permission_prompt:
try:
rendered_permission = permission_prompt.format(
permission_mode=permission_mode,
permission_mode_label=permission_label_map.get(permission_mode, permission_mode),
permission_rules=permission_rules_map.get(permission_mode, permission_rules_map["unrestricted"]),
)
messages.append({
"role": "system",
"content": rendered_permission,
})
except Exception:
pass
if shallow_replace_enabled:
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}")
return messages

View File

@ -20,6 +20,9 @@ try:
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
READONLY_RUN_COMMAND_BLOCKED_TOKENS,
)
except ImportError:
import sys
@ -40,6 +43,9 @@ except ImportError:
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
READONLY_RUN_COMMAND_BLOCKED_TOKENS,
)
from modules.file_manager import FileManager
@ -98,6 +104,33 @@ class MainTerminalToolsExecutionMixin:
"terminate_sub_agent",
"get_sub_agent_status",
}
_READONLY_ALLOWED_TOOLS = {
"web_search",
"extract_webpage",
"read_file",
"view_image",
"view_video",
"vlm_analyze",
"ocr_image",
"update_memory",
"todo_create",
"todo_update_task",
"todo_get",
"sleep",
}
_APPROVAL_REQUIRED_TOOLS = {
"run_command",
"run_python",
"terminal_input",
"write_file",
"edit_file",
"create_file",
"create_folder",
"delete_file",
"rename_file",
"save_webpage",
"terminal_session",
}
def _record_sub_agent_message(self, message: Optional[str], task_id: Optional[str] = None, inline: bool = False):
"""以 system 消息记录子智能体状态。"""
@ -119,6 +152,79 @@ class MainTerminalToolsExecutionMixin:
self.context_manager.add_conversation("system", message, metadata=metadata)
print(f"{OUTPUT_FORMATS['info']} {message}")
def _is_readonly_run_command_allowed(self, command: Any) -> bool:
cmd = str(command or "").strip()
if not cmd:
return False
lowered = cmd.lower()
forbidden = list(READONLY_RUN_COMMAND_BLOCKED_TOKENS)
if any(token in cmd for token in forbidden):
return False
parts = cmd.split()
if not parts:
return False
executable = parts[0].lower()
basic_allowed = set(READONLY_RUN_COMMAND_ALLOWED or ())
if executable in basic_allowed and executable not in {"git", "sed", "awk"}:
return True
if executable == "git":
if len(parts) < 2:
return False
git_sub = parts[1].lower()
return git_sub in set(READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS or ())
if executable == "sed":
return len(parts) >= 2 and parts[1] == "-n"
if executable == "awk":
# 只读模式下允许基础文本处理,但拒绝 awk 的命令执行能力
return "system(" not in lowered
return False
def evaluate_tool_permission(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
mode = "unrestricted"
try:
mode = self.get_permission_mode()
except Exception:
mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted")
args = arguments or {}
if mode == "readonly":
if tool_name == "run_command":
if self._is_readonly_run_command_allowed(args.get("command")):
return {"allowed": True, "mode": mode}
return {
"allowed": False,
"mode": mode,
"code": "readonly_denied",
"message": (
"当前处于只读模式run_command 仅允许只读命令"
"grep/find/ls/cat/rg/git status 等)且禁止多指令拼接。"
)
}
if tool_name not in self._READONLY_ALLOWED_TOOLS:
return {
"allowed": False,
"mode": mode,
"code": "readonly_denied",
"message": "当前处于只读模式,已拒绝会修改工作区或执行高风险操作的工具调用。"
}
return {"allowed": True, "mode": mode}
if mode == "approval":
if tool_name in {"run_command", "terminal_input"}:
command_text = args.get("command") or args.get("input")
if self._is_readonly_run_command_allowed(command_text):
return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name in self._APPROVAL_REQUIRED_TOOLS:
return {"allowed": True, "mode": mode, "requires_approval": True}
return {"allowed": True, "mode": mode}
return {"allowed": True, "mode": mode}
@staticmethod
def _skill_meta_key(skill_id: str) -> str:
return f"skill_read::{skill_id}"
@ -664,6 +770,10 @@ class MainTerminalToolsExecutionMixin:
path=arguments["path"],
file_type=arguments["file_type"]
)
if isinstance(result, dict) and result.get("success"):
# create_file 新建文件后,视为当前会话已“接触”该文件,
# 避免后续 write_file/edit_file 被“先 read_file 再编辑”误拦截。
self._mark_file_as_read_visited(result.get("path") or arguments.get("path"))
# 添加备注
if result["success"] and arguments.get("annotation"):
self.context_manager.update_annotation(

View File

@ -83,6 +83,7 @@ from config.model_profiles import (
logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
class MainTerminalToolsPolicyMixin:
def apply_personalization_preferences(self, config: Optional[Dict[str, Any]] = None):
@ -160,6 +161,40 @@ class MainTerminalToolsPolicyMixin:
# 静默禁用工具提示
self.silent_tool_disable = bool(effective_config.get("silent_tool_disable"))
permission_mode = effective_config.get("default_permission_mode")
if isinstance(permission_mode, str) and permission_mode in PERMISSION_MODES:
self.default_permission_mode = permission_mode
else:
self.default_permission_mode = "unrestricted"
if not getattr(self, "current_permission_mode", None):
self.current_permission_mode = self.default_permission_mode
def get_permission_mode(self) -> str:
mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted")
if mode not in PERMISSION_MODES:
return "unrestricted"
return mode
def set_permission_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> str:
normalized = str(mode or "").strip().lower()
if normalized not in PERMISSION_MODES:
raise ValueError("无效权限模式,仅支持 readonly / approval / unrestricted")
self.current_permission_mode = normalized
if not persist:
return normalized
conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None)
if conv_id and getattr(self, "context_manager", None):
try:
self.context_manager.conversation_manager.update_conversation_metadata(
conv_id,
{"permission_mode": normalized},
)
if self.context_manager.current_conversation_id == conv_id:
self.context_manager.conversation_metadata["permission_mode"] = normalized
except Exception:
pass
return normalized
def set_tool_category_enabled(self, category: str, enabled: bool) -> None:
"""设置工具类别的启用状态 / Toggle tool category enablement."""

View File

@ -130,6 +130,7 @@ class WebTerminal(MainTerminal):
logger.warning("忽略无效默认模型 %s: %s", preferred_model, exc)
preferred_mode = prefs.get("default_run_mode")
preferred_permission_mode = prefs.get("default_permission_mode") or "unrestricted"
if isinstance(preferred_mode, str) and preferred_mode.lower() in {"fast", "thinking", "deep"}:
try:
self.set_run_mode(preferred_mode.lower())
@ -138,6 +139,10 @@ class WebTerminal(MainTerminal):
else:
# 未配置默认模式时回到快速模式
self.set_run_mode("fast")
try:
self.set_permission_mode(preferred_permission_mode, persist=False)
except Exception:
self.set_permission_mode("unrestricted", persist=False)
thinking_mode = self.thinking_mode
if isinstance(run_mode, str):
@ -160,7 +165,10 @@ class WebTerminal(MainTerminal):
conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path,
thinking_mode=thinking_mode,
run_mode=self.run_mode
run_mode=self.run_mode,
metadata_overrides={
"permission_mode": self.get_permission_mode(),
},
)
# 重置相关状态
@ -320,6 +328,7 @@ class WebTerminal(MainTerminal):
"thinking_status": self.get_thinking_mode_status(),
"run_mode": self.run_mode,
"model_key": getattr(self, "model_key", None),
"permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted",
"has_images": getattr(self.context_manager, "has_images", False),
"has_videos": getattr(self.context_manager, "has_videos", False),
"context": {

View File

@ -16,6 +16,7 @@ from core.tool_config import TOOL_CATEGORIES
from config.model_profiles import get_registered_model_keys
ALLOWED_RUN_MODES = {"fast", "thinking", "deep"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
PERSONALIZATION_FILENAME = "personalization.json"
MAX_SHORT_FIELD_LENGTH = 20
@ -51,6 +52,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"enabled_skills": None,
"skills_catalog_snapshot": None,
"default_run_mode": None,
"default_permission_mode": "unrestricted",
"auto_generate_title": True,
"tool_intent_enabled": True,
"skill_hints_enabled": False, # Skill 提示系统开关(默认关闭)
@ -83,6 +85,7 @@ __all__ = [
"sanitize_personalization_payload",
"resolve_context_compression_settings",
"validate_context_compression_settings",
"ALLOWED_PERMISSION_MODES",
]
@ -224,6 +227,12 @@ def sanitize_personalization_payload(
else:
base["default_run_mode"] = _sanitize_run_mode(base.get("default_run_mode"))
permission_mode = data.get("default_permission_mode", base.get("default_permission_mode"))
if isinstance(permission_mode, str) and permission_mode in ALLOWED_PERMISSION_MODES:
base["default_permission_mode"] = permission_mode
elif base.get("default_permission_mode") not in ALLOWED_PERMISSION_MODES:
base["default_permission_mode"] = "unrestricted"
# 默认模型
chosen_model = data.get("default_model", base.get("default_model"))
if isinstance(chosen_model, str) and chosen_model in allowed_models:

View File

@ -0,0 +1,79 @@
from __future__ import annotations
import threading
import time
import uuid
from typing import Any, Dict, List, Optional
class ToolApprovalManager:
def __init__(self):
self._items: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
def create_request(
self,
*,
username: str,
conversation_id: Optional[str],
task_id: Optional[str],
tool_call_id: Optional[str],
tool_name: str,
arguments: Dict[str, Any],
preview: Dict[str, Any],
) -> Dict[str, Any]:
approval_id = f"approval_{uuid.uuid4().hex}"
item = {
"approval_id": approval_id,
"username": username,
"conversation_id": conversation_id,
"task_id": task_id,
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"arguments": arguments or {},
"preview": preview or {},
"status": "pending",
"created_at": time.time(),
"decided_at": None,
"decision": None,
}
with self._lock:
self._items[approval_id] = item
return dict(item)
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
item = self._items.get(approval_id)
return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
rows = []
for item in self._items.values():
if item.get("username") != username:
continue
if item.get("status") != "pending":
continue
if conversation_id and item.get("conversation_id") != conversation_id:
continue
rows.append(dict(item))
rows.sort(key=lambda x: x.get("created_at", 0.0))
return rows
def decide(self, approval_id: str, username: str, decision: str) -> Dict[str, Any]:
normalized = str(decision or "").strip().lower()
if normalized not in {"approved", "rejected"}:
raise ValueError("decision 仅支持 approved / rejected")
with self._lock:
item = self._items.get(approval_id)
if not item:
raise KeyError("审批请求不存在")
if item.get("username") != username:
raise PermissionError("无权限操作该审批请求")
if item.get("status") != "pending":
return dict(item)
item["status"] = normalized
item["decision"] = normalized
item["decided_at"] = time.time()
return dict(item)

View File

@ -0,0 +1,4 @@
## 权限模式约束(始终生效)
- 当前权限模式:{permission_mode_label}{permission_mode}
- 执行规则:{permission_rules}
- 注意:工具始终可见;若超出权限,调用会返回拒绝或等待批准结果。

View File

@ -33,6 +33,7 @@ from .context import with_terminal, get_gui_manager, get_upload_guard, build_upl
from .security import rate_limited, prune_socket_tokens
from .utils_common import debug_log
from .state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS
from .state import tool_approval_manager
from .extensions import socketio
from .monitor import get_cached_monitor_snapshot
from .files import sanitize_filename_preserve_unicode
@ -41,6 +42,8 @@ UPLOAD_FOLDER_NAME = "user_upload"
chat_bp = Blueprint('chat', __name__)
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"]
@chat_bp.route('/api/thinking-mode', methods=['POST'])
@api_login_required
@with_terminal
@ -584,6 +587,94 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str
"error": str(exc)
}), 400
@chat_bp.route('/api/permission-mode', methods=['GET'])
@api_login_required
@with_terminal
def get_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前权限模式。"""
current_conversation_id = getattr(terminal.context_manager, "current_conversation_id", None)
return jsonify({
"success": True,
"mode": terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else "unrestricted",
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": current_conversation_id,
})
@chat_bp.route('/api/permission-mode', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("permission_mode_switch", 30, 60, scope="user")
def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""更新当前对话权限模式。"""
data = request.get_json() or {}
target_mode = str(data.get("mode") or "").strip().lower()
if target_mode not in PERMISSION_MODE_OPTIONS:
return jsonify({
"success": False,
"error": "无效权限模式,仅支持 readonly / approval / unrestricted"
}), 400
try:
applied_mode = terminal.set_permission_mode(target_mode, persist=True)
except Exception as exc:
return jsonify({
"success": False,
"error": str(exc),
"message": "切换权限模式失败"
}), 500
session["permission_mode"] = applied_mode
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"mode": applied_mode,
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
})
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required
@with_terminal
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待审批工具列表。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = tool_approval_manager.list_pending(username=username, conversation_id=requested_conv_id)
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("tool_approval_decision", 60, 60, scope="user")
def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
"""提交工具审批决策。"""
data = request.get_json() or {}
decision = str(data.get("decision") or "").strip().lower()
try:
item = tool_approval_manager.decide(approval_id=approval_id, username=username, decision=decision)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": "审批请求不存在"}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})
@chat_bp.route('/api/terminals')
@api_login_required
@with_terminal

View File

@ -1102,6 +1102,14 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
if tool_loop_result.get("stopped"):
finalize_user_work_timer()
return
if tool_loop_result.get("approval_rejected"):
sender('task_stopped', {
'message': tool_loop_result.get("approval_message") or '操作被用户拒绝',
'reason': 'approval_rejected',
'conversation_id': conversation_id
})
finalize_user_work_timer()
return
if tool_loop_result.get("deep_compressed"):
deep_result = tool_loop_result.get("deep_result") or {}
guide_message = (deep_result.get("guide_message") or "").strip()

View File

@ -3,10 +3,12 @@ from __future__ import annotations
import asyncio
import json
import time
from typing import Optional
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
@ -18,6 +20,124 @@ from modules.personalization_manager import load_personalization_config, resolve
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")
preview["file_path"] = file_path
if not file_path:
preview["summary"] = "缺少 file_path"
return preview
try:
valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path))
if not valid or full_path is None:
preview["summary"] = err or "路径校验失败"
return preview
resolved_path = str(Path(full_path))
preview["resolved_path"] = resolved_path
if not full_path.exists() or not full_path.is_file():
preview["summary"] = "目标文件不存在,无法生成上下文预览"
return preview
content = full_path.read_text(encoding="utf-8", errors="ignore")
old_text = str(old_string or "")
new_text = str(new_string or "")
old_lines = old_text.splitlines()
new_lines = new_text.splitlines()
idx = content.find(old_text) if old_text else -1
if idx >= 0:
prefix = content[:idx]
start_line_no = prefix.count("\n") + 1
end_line_no = start_line_no + max(1, len(old_lines)) - 1
all_lines = content.splitlines()
before_start = max(1, start_line_no - 3)
before = all_lines[before_start - 1:start_line_no - 1]
after_end = min(len(all_lines), end_line_no + 3)
after = all_lines[end_line_no:after_end]
preview["edit_context"] = {
"before": _format_numbered_lines(before, before_start),
"old": _format_numbered_lines(old_lines or [""], start_line_no),
"new": _format_numbered_lines(new_lines or [""], start_line_no),
"after": _format_numbered_lines(after, end_line_no + 1),
"old_start_line": start_line_no,
"old_end_line": end_line_no,
}
preview["summary"] = f"编辑 {file_path}{start_line_no}-{end_line_no}"
else:
preview["edit_context"] = {
"before": [],
"old": _format_numbered_lines(old_lines or [""], 1),
"new": _format_numbered_lines(new_lines or [""], 1),
"after": [],
"old_start_line": None,
"old_end_line": None,
}
preview["summary"] = "未在文件中定位到 old_string显示原始替换内容"
except Exception as exc:
preview["summary"] = f"生成编辑预览失败: {exc}"
return preview
if function_name in {"run_command", "terminal_input"}:
preview["command"] = args.get("command")
preview["summary"] = f"执行命令: {args.get('command') or ''}"
return preview
if function_name in {"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[:4000]
preview["content_length"] = len(content)
preview["summary"] = f"write_file: {args.get('file_path') or ''} ({'append' if preview['append'] else 'overwrite'})"
return preview
return preview
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
started = time.time()
while True:
row = tool_approval_manager.get(approval_id)
if not row:
return {"decision": "rejected", "reason": "审批请求不存在"}
if row.get("username") != username:
return {"decision": "rejected", "reason": "审批请求用户不匹配"}
status = row.get("status")
if status in {"approved", "rejected"}:
return {"decision": status, "item": row}
if (time.time() - started) >= timeout_seconds:
return {"decision": "rejected", "reason": "审批超时"}
await asyncio.sleep(0.2)
async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None):
previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False)
web_terminal._tool_loop_active = True
@ -182,6 +302,115 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
debug_log(f"执行工具: {function_name} (ID: {tool_call_id})")
permission_eval = web_terminal.evaluate_tool_permission(function_name, arguments)
if not permission_eval.get("allowed", True):
denied_message = permission_eval.get("message") or "当前权限模式不允许执行该工具。"
denied_payload = {
"success": False,
"status": "denied",
"code": permission_eval.get("code") or "permission_denied",
"tool": function_name,
"mode": permission_eval.get("mode"),
"message": denied_message,
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': denied_payload,
'message': denied_message,
'conversation_id': conversation_id
})
denied_content = json.dumps(denied_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
denied_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": denied_content
})
continue
if permission_eval.get("requires_approval"):
approval_preview = _build_tool_approval_preview(web_terminal, function_name, arguments)
approval_item = tool_approval_manager.create_request(
username=username,
conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None),
tool_call_id=tool_call_id,
tool_name=function_name,
arguments=arguments,
preview=approval_preview,
)
sender('tool_approval_required', {
'approval': approval_item,
'conversation_id': conversation_id,
})
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'awaiting_approval',
'result': {
"success": False,
"status": "awaiting_approval",
"approval_id": approval_item.get("approval_id"),
"message": "等待用户审批"
},
'message': '等待用户审批',
'conversation_id': conversation_id
})
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"),
'decision': wait_result.get("decision"),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
reject_payload = {
"success": False,
"status": "rejected",
"code": "approval_rejected",
"tool": function_name,
"message": reject_message,
"approval_id": approval_item.get("approval_id"),
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': reject_payload,
'message': reject_message,
'conversation_id': conversation_id
})
reject_content = json.dumps(reject_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
reject_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": reject_content
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
# 发送工具开始事件
tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}"
monitor_snapshot = None

View File

@ -13,6 +13,7 @@ from modules.usage_tracker import UsageTracker
from modules.user_container_manager import UserContainerManager
from modules.user_manager import UserManager
from modules.api_user_manager import ApiUserManager
from modules.tool_approval_manager import ToolApprovalManager
# 全局实例
user_manager = UserManager()
@ -26,6 +27,7 @@ RECENT_UPLOAD_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60
stop_flags: Dict[str, Dict[str, Any]] = {}
active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling
tool_approval_manager = ToolApprovalManager()
# 监控/限流/用量
MONITOR_FILE_TOOLS = {'write_file', 'edit_file'}
@ -88,6 +90,7 @@ __all__ = [
"pending_socket_tokens",
"usage_trackers",
"active_login_nonces",
"tool_approval_manager",
"MONITOR_SNAPSHOT_CACHE",
"MONITOR_SNAPSHOT_CACHE_LIMIT",
"PROJECT_STORAGE_CACHE",

View File

@ -683,7 +683,13 @@ class ContextManager:
# 新增:对话持久化相关方法
# ===========================================
def start_new_conversation(self, project_path: str = None, thinking_mode: bool = False, run_mode: Optional[str] = None) -> str:
def start_new_conversation(
self,
project_path: str = None,
thinking_mode: bool = False,
run_mode: Optional[str] = None,
metadata_overrides: Optional[Dict[str, Any]] = None,
) -> str:
"""
开始新对话
@ -721,7 +727,8 @@ class ContextManager:
initial_messages=[],
model_key=getattr(self.main_terminal, "model_key", None),
has_images=False,
has_videos=False
has_videos=False,
metadata_overrides=metadata_overrides,
)
# 重置当前状态
@ -791,6 +798,7 @@ class ContextManager:
self.project_path = resolved_project_path
run_mode = metadata.get("run_mode")
permission_mode = metadata.get("permission_mode")
model_key = metadata.get("model_key")
self.has_images = metadata.get("has_images", False)
self.has_videos = metadata.get("has_videos", False)
@ -822,6 +830,11 @@ class ContextManager:
self.main_terminal.set_run_mode("fast")
except Exception:
pass
try:
fallback_mode = getattr(self.main_terminal, "default_permission_mode", "unrestricted")
self.main_terminal.set_permission_mode(permission_mode or fallback_mode, persist=False)
except Exception:
pass
print(f"📖 加载对话: {conversation_id} - {conversation_data.get('title', '未知标题')}")
print(f"📊 包含 {len(self.conversation_history)} 条消息")
@ -1153,7 +1166,10 @@ class ContextManager:
run_mode=run_mode,
initial_messages=[system_message],
model_key=model_key,
has_images=has_images
has_images=has_images,
metadata_overrides={
"permission_mode": metadata.get("permission_mode", "unrestricted"),
},
)
# 设置压缩后的对话标题
@ -1197,7 +1213,10 @@ class ContextManager:
run_mode=run_mode,
initial_messages=original_messages,
model_key=model_key,
has_images=has_images
has_images=has_images,
metadata_overrides={
"permission_mode": metadata.get("permission_mode", "unrestricted"),
},
)
token_stats = conversation_data.get("token_statistics")

View File

@ -374,6 +374,7 @@ class ConversationManager:
"thinking_mode": thinking_mode,
"run_mode": normalized_mode,
"model_key": model_key,
"permission_mode": "unrestricted",
"has_images": has_images,
"has_videos": has_videos,
# 首次对话尚未生成文件树快照,待首次用户消息时填充