feat(permission): add auto-approval mode with approval agent, UI flow, and docs sync
Implemented a new permission mode between and , including backend policy, runtime behavior, frontend display, and documentation updates. Backend & policy changes: - Added to permission mode validation and persistence paths. - Updated tool permission evaluation: workspace-local direct pass, out-of-workspace requires approval. - Kept readonly-first flow and added auto-approval decision path when permission denial occurs. - Added approval reason/decider support in tool approval manager. - Improved rejection tool-content format to natural language: '工具调用被拒绝\n原因:...' - Fixed permission-mode sync edge cases on conversation create/load and restart-first-switch behavior. Approval agent subsystem: - Added and . - Added lightweight auto-approval config at (name/url/key/model/extra_params + timeouts). - Added optional transcript debug switch in code and transcript output under logs/approval_agent. - Aligned transcript saving toward cumulative messages format and captured reasoning/content/tool_calls/tool sequence. Frontend changes: - Added option to personalization and permission menus. - Reworked approval panel auto-review display into a dedicated block with simplified lines: start, command, final approve/reject + reason. - Added delayed sidebar auto-close (10s) after approval resolved. - Added stricter permission-switch verification and rollback on mismatch/error. Docs updated: - Synced AGENTS.md, README.md, and docs/host_sandbox_and_permission_model.md with new permission mode, approval-agent behavior, config, and current constraints (including docker caveat).
This commit is contained in:
parent
02d10ec7bb
commit
8d665ad6de
15
AGENTS.md
15
AGENTS.md
@ -161,7 +161,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
|
||||
为避免误解,当前系统有两套独立但叠加的控制:
|
||||
|
||||
1. **权限模式(Permission Mode)**:`readonly` / `approval` / `unrestricted`
|
||||
1. **权限模式(Permission Mode)**:`readonly` / `approval` / `auto_approval` / `unrestricted`
|
||||
2. **执行环境(Execution Mode)**:`sandbox` / `direct`(仅宿主机模式可切换)
|
||||
|
||||
### 10.1 权限模式
|
||||
@ -173,6 +173,10 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
- `run_command` 先走只读沙箱
|
||||
- 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批
|
||||
- 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果”
|
||||
- `auto_approval`
|
||||
- `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程
|
||||
- `run_command` 先走只读沙箱;触发权限拒绝后由后台审批智能体自动审核
|
||||
- 自动审核拒绝时,工具返回“被拒绝+理由”并继续主循环(不强制结束任务)
|
||||
- `unrestricted`
|
||||
- 不做权限模式拦截;是否沙箱由执行环境决定
|
||||
|
||||
@ -195,3 +199,12 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
|
||||
- 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等)
|
||||
- 文案应面向用户能力边界与操作建议,不描述内部判定算法
|
||||
|
||||
### 10.5 自动审批智能体配置与调试
|
||||
|
||||
- 自动审批配置文件:`config/auto_approval.json`
|
||||
- 建议字段:`name` / `url` / `key` / `model` / `extra_params`
|
||||
- 额外控制:`timeout_seconds` / `max_rounds` / `max_command_timeout`
|
||||
- 调试开关(代码变量):`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`
|
||||
- 开启后写入:`logs/approval_agent/`
|
||||
- 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式
|
||||
|
||||
@ -136,12 +136,18 @@ HOST_WORKSPACES_FILE=./config/host_workspaces.json
|
||||
- **权限模式(Permission Mode)**
|
||||
- `readonly`:`run_command` 在只读沙箱执行;写入会被系统拒绝
|
||||
- `approval`:`run_command` 先按只读沙箱执行;若遇权限拒绝,再触发前端审批;审批通过后仅该次命令以可写沙箱重试
|
||||
- `auto_approval`:工作区内 `write_file/edit_file` 直通;高风险操作由后台审批智能体自动审核。`run_command` 仍先只读执行,遇权限拒绝后再自动审批
|
||||
- `unrestricted`:不做权限模式拦截(仍受执行环境约束)
|
||||
- **路径授权(Path Authorization)**
|
||||
- 可配置两类路径:`可读可写` 与 `仅可读`
|
||||
- 语义:`可读集合 = 可读可写 + 仅可读`;`可写集合 = 可读可写`
|
||||
- 默认建议:仅工作区 + 临时目录可写,其余按需白名单
|
||||
|
||||
- **自动审批智能体(Auto Approval Agent)**
|
||||
- 配置文件:`config/auto_approval.json`
|
||||
- 核心字段:`name` / `url` / `key` / `model` / `extra_params`
|
||||
- 调试记录:可在 `modules/approval_agent.py` 打开 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`,输出到 `logs/approval_agent/`
|
||||
|
||||
`HOST_WORKSPACES_FILE` 对应 JSON 示例:
|
||||
|
||||
```json
|
||||
|
||||
14
config/auto_approval.json
Normal file
14
config/auto_approval.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "auto-approval-agent",
|
||||
"url": "https://api.deepseek.com",
|
||||
"key": "${API_KEY_DEEPSEEK}",
|
||||
"model": "deepseek-v4-flash",
|
||||
"extra_params": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
}
|
||||
},
|
||||
"timeout_seconds": 60,
|
||||
"max_rounds": 3,
|
||||
"max_command_timeout": 60
|
||||
}
|
||||
@ -93,12 +93,14 @@ class MainTerminalContextMixin:
|
||||
"unrestricted": "当前处于无限制模式,所有工具均可直接使用,无需额外批准。",
|
||||
"readonly": "当前处于只读模式,仅能执行不会修改工作区的读取类操作。",
|
||||
"approval": "当前处于批准模式,修改工作区的操作需要用户批准后方可执行。",
|
||||
"auto_approval": "当前处于自动审核模式,工作区内文件编辑可直接执行,高风险操作由后台审批智能体自动审核。",
|
||||
}
|
||||
|
||||
_PERMISSION_MODE_LABEL = {
|
||||
"unrestricted": "无限制",
|
||||
"readonly": "只读",
|
||||
"approval": "批准",
|
||||
"auto_approval": "自动审核",
|
||||
}
|
||||
|
||||
_EXECUTION_MODE_LABEL = {
|
||||
@ -135,6 +137,14 @@ class MainTerminalContextMixin:
|
||||
"- 需要用户批准:terminal_input、run_python、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n"
|
||||
"- 被拒绝或超时:本次操作不会执行写入"
|
||||
)
|
||||
elif mode == "auto_approval":
|
||||
detailed_rules = (
|
||||
"- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n"
|
||||
"- run_command:先在系统只读沙箱执行;仅当出现权限拒绝时才触发自动审批\n"
|
||||
"- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n"
|
||||
"- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n"
|
||||
"- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准"
|
||||
)
|
||||
else:
|
||||
detailed_rules = ""
|
||||
|
||||
|
||||
@ -341,6 +341,29 @@ class MainTerminalToolsExecutionMixin:
|
||||
return {"allowed": True, "mode": mode, "requires_approval": True}
|
||||
return {"allowed": True, "mode": mode}
|
||||
|
||||
if mode == "auto_approval":
|
||||
if tool_name in {"write_file", "edit_file"}:
|
||||
path = args.get("file_path") or args.get("path") or ""
|
||||
try:
|
||||
valid, _, full_path = self.file_manager._validate_path(str(path))
|
||||
if valid and full_path is not None:
|
||||
project_root = Path(self.context_manager.project_path).resolve()
|
||||
resolved = Path(full_path).resolve()
|
||||
if resolved == project_root or project_root in resolved.parents:
|
||||
return {"allowed": True, "mode": mode, "requires_approval": False}
|
||||
except Exception:
|
||||
pass
|
||||
return {"allowed": True, "mode": mode, "requires_approval": True}
|
||||
if tool_name == "run_command":
|
||||
return {"allowed": True, "mode": mode, "requires_approval": False}
|
||||
if tool_name == "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
|
||||
@ -1272,7 +1295,7 @@ class MainTerminalToolsExecutionMixin:
|
||||
write_granted_once = bool(arguments.get("_approval_write_granted", False))
|
||||
sandbox_write_access = not (
|
||||
permission_mode == "readonly"
|
||||
or (permission_mode == "approval" and not write_granted_once)
|
||||
or (permission_mode in {"approval", "auto_approval"} and not write_granted_once)
|
||||
)
|
||||
run_in_background = bool(arguments.get("run_in_background", False))
|
||||
timeout_value = arguments.get("timeout")
|
||||
|
||||
@ -84,7 +84,7 @@ from config.model_profiles import (
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
|
||||
PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
|
||||
|
||||
class MainTerminalToolsPolicyMixin:
|
||||
def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]:
|
||||
@ -209,7 +209,7 @@ class MainTerminalToolsPolicyMixin:
|
||||
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")
|
||||
raise ValueError("无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted")
|
||||
self.current_permission_mode = normalized
|
||||
if not persist:
|
||||
return normalized
|
||||
|
||||
@ -130,7 +130,13 @@ 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"
|
||||
preferred_permission_mode = None
|
||||
try:
|
||||
preferred_permission_mode = self.get_permission_mode()
|
||||
except Exception:
|
||||
preferred_permission_mode = None
|
||||
if not isinstance(preferred_permission_mode, str) or not preferred_permission_mode.strip():
|
||||
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())
|
||||
@ -210,6 +216,12 @@ class WebTerminal(MainTerminal):
|
||||
self.thinking_mode = mode
|
||||
self.api_client.thinking_mode = mode
|
||||
self.api_client.start_new_task()
|
||||
permission_mode = str(meta.get("permission_mode") or "").strip().lower()
|
||||
if permission_mode:
|
||||
try:
|
||||
self.set_permission_mode(permission_mode, persist=False)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# 重置相关状态
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
|
||||
- `readonly`
|
||||
- `approval`
|
||||
- `auto_approval`
|
||||
- `unrestricted`
|
||||
|
||||
作用:决定工具是否允许调用、是否先只读执行、是否需要用户审批。
|
||||
@ -89,7 +90,19 @@
|
||||
- 工具结果返回“最终执行结果”(即重试后结果),不会把中间权限拒绝作为最终结果返回给模型
|
||||
- 审批拒绝或超时:返回 rejected,不执行写入重试
|
||||
|
||||
## 5.3 unrestricted
|
||||
## 5.3 auto_approval
|
||||
|
||||
- `write_file` / `edit_file`:
|
||||
- 目标路径在当前工作区内:直接执行
|
||||
- 目标路径不在当前工作区内:进入审批流程
|
||||
- `run_command` 采用“两段式”:
|
||||
1) 先在只读沙箱执行
|
||||
2) 若出现权限拒绝,触发自动审批(审批智能体)
|
||||
3) 自动审批通过后,仅该次命令可写重试
|
||||
- 自动审批拒绝:返回“工具调用被拒绝 + 原因”,主智能体继续下一步(不强制中断整轮任务)
|
||||
- 用户可随时人工接管审批结果(同意/拒绝/切换无限制)
|
||||
|
||||
## 5.4 unrestricted
|
||||
|
||||
- 权限模式层不拦截工具
|
||||
- 是否沙箱执行取决于执行环境:
|
||||
@ -121,6 +134,7 @@
|
||||
- 支持权限模式联动(readonly/approval/unrestricted)
|
||||
- 在 host + sandbox 下可走只读沙箱或可写沙箱
|
||||
- 在 approval 模式可触发“权限拒绝 -> 审批 -> 单次可写重试”
|
||||
- 在 auto_approval 模式可触发“权限拒绝 -> 自动审批 -> 单次可写重试”
|
||||
|
||||
### 7.2 run_python
|
||||
|
||||
@ -140,6 +154,19 @@
|
||||
- `direct`:直接宿主机启动
|
||||
- Docker 模式维持容器执行
|
||||
|
||||
### 7.5 自动审批(approval agent)
|
||||
|
||||
- 自动审批由独立审批智能体执行(与主智能体分离)
|
||||
- 配置文件:`config/auto_approval.json`
|
||||
- `name` / `url` / `key` / `model` / `extra_params`
|
||||
- `timeout_seconds` / `max_rounds` / `max_command_timeout`
|
||||
- 审批智能体可调用能力:
|
||||
- `run_command`(用于只读核查)
|
||||
- 审批决策工具(approved / rejected + reason)
|
||||
- 调试开关(代码变量):
|
||||
- `modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`
|
||||
- 开启后记录到 `logs/approval_agent/`
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置与运行建议
|
||||
@ -167,6 +194,7 @@
|
||||
1. `run_command` 若严格收紧读路径,可能导致大量系统命令可用性下降
|
||||
2. 权限拒绝识别基于错误特征,需持续优化误判/漏判边界
|
||||
3. 不同 OS 的沙箱能力不完全对齐(Windows 侧为 WSL2 路径)
|
||||
4. Docker 模式下目前不存在与宿主机同等的 OS 只读沙箱机制;`sandbox_write_access=False` 不会提供同等级只读隔离(需后续补充容器侧只读策略)
|
||||
|
||||
---
|
||||
|
||||
@ -175,4 +203,3 @@
|
||||
- 面向用户的提示词仅描述“能力边界”和“可操作建议”
|
||||
- 不暴露内部实现细节(如内部判定逻辑、实现细节名称)
|
||||
- 审批流程文案要强调“单次授权、最小权限、可回退”
|
||||
|
||||
|
||||
323
modules/approval_agent.py
Normal file
323
modules/approval_agent.py
Normal file
@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "auto_approval.json"
|
||||
DEFAULT_MAX_ROUNDS = 3
|
||||
DEFAULT_TIMEOUT_SECONDS = 60
|
||||
DEFAULT_MAX_COMMAND_TIMEOUT = 20
|
||||
DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True
|
||||
DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "approval_agent"
|
||||
|
||||
|
||||
def _resolve_env_token(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if text.startswith("${") and text.endswith("}"):
|
||||
key = text[2:-1].strip()
|
||||
return os.environ.get(key, "")
|
||||
return text
|
||||
|
||||
|
||||
def load_approval_agent_config() -> Dict[str, Any]:
|
||||
base = {
|
||||
"name": "auto-approval-agent",
|
||||
"url": "",
|
||||
"key": "",
|
||||
"model": "deepseek-v4-flash",
|
||||
"extra_params": {},
|
||||
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
|
||||
"max_rounds": DEFAULT_MAX_ROUNDS,
|
||||
"max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT,
|
||||
}
|
||||
try:
|
||||
if not CONFIG_PATH.exists():
|
||||
return base
|
||||
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
base.update(raw)
|
||||
base["url"] = _resolve_env_token(base.get("url", ""))
|
||||
base["key"] = _resolve_env_token(base.get("key", ""))
|
||||
base["model"] = str(base.get("model") or "deepseek-v4-flash").strip()
|
||||
base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {}
|
||||
base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
base["max_rounds"] = max(1, int(base.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||
base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT))
|
||||
return base
|
||||
except Exception:
|
||||
return base
|
||||
|
||||
|
||||
class ApprovalAgent:
|
||||
def __init__(self, *, web_terminal: Any):
|
||||
self.web_terminal = web_terminal
|
||||
self.cfg = load_approval_agent_config()
|
||||
|
||||
@staticmethod
|
||||
def _system_prompt() -> str:
|
||||
return (
|
||||
"你是审批智能体,只负责风险与越权判断,不判断任务必要性。"
|
||||
"审批模式允许正常文件修改,因此“写入/删除”不能一概拒绝,应结合用户输入意图判断。"
|
||||
"若操作涉及凭据读取(如 .env、密钥文件、token)、越权访问(工作区外敏感目录)、"
|
||||
"危险破坏(如 rm -rf、权限变更、系统级配置修改、可疑网络外传),"
|
||||
"且用户输入并未明确要求这些行为,则应拒绝。"
|
||||
"需要特别注意:提权命令、删除系统文件、批量破坏性 git 操作、访问 SSH/GPG/AWS 凭据目录。"
|
||||
"你可最多使用少量只读检查(尽量在3轮内完成),然后必须调用 approve_decision 给出 approved/rejected + reason。"
|
||||
)
|
||||
|
||||
async def _run_readonly_command(self, command: str) -> Dict[str, Any]:
|
||||
work_path = Path(getattr(self.web_terminal.context_manager, "project_path", "."))
|
||||
timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)
|
||||
try:
|
||||
result = await self.web_terminal.terminal_ops.run_command(
|
||||
command=command,
|
||||
working_dir=str(work_path),
|
||||
timeout=timeout,
|
||||
sandbox_write_access=False,
|
||||
)
|
||||
return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
async def review(
|
||||
self,
|
||||
*,
|
||||
payload_text: str,
|
||||
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
debug_transcript: List[Dict[str, Any]] = []
|
||||
|
||||
def _trace(event: str, payload: Dict[str, Any]) -> None:
|
||||
if not DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT:
|
||||
return
|
||||
debug_transcript.append({
|
||||
"ts": int(time.time() * 1000),
|
||||
"event": event,
|
||||
"payload": payload,
|
||||
})
|
||||
|
||||
def _flush_trace(final_result: Dict[str, Any]) -> None:
|
||||
if not DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT:
|
||||
return
|
||||
try:
|
||||
DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"created_at": int(time.time() * 1000),
|
||||
"messages": messages,
|
||||
}
|
||||
file = DEBUG_TRANSCRIPT_DIR / f"approval_{int(time.time() * 1000)}.json"
|
||||
file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
url = str(self.cfg.get("url") or "").strip()
|
||||
key = str(self.cfg.get("key") or "").strip()
|
||||
if not url or not key:
|
||||
out = {"decision": "rejected", "reason": "审批智能体配置缺失", "source": "approval_agent"}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
endpoint = f"{url.rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||
max_rounds = int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)
|
||||
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
extra_params = dict(self.cfg.get("extra_params") or {})
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": (
|
||||
"在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、"
|
||||
"查看 git 状态与差异、确认路径是否存在、收集审批判断所需证据。"
|
||||
"禁止用于写入、删除、改权限或其他修改性操作。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、"
|
||||
"rg、git status、git diff、pwd、stat 等。"
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "approve_decision",
|
||||
"description": (
|
||||
"提交最终审批结果并结束本次审批流程。只能返回 approved 或 rejected,"
|
||||
"并提供简洁、可审计的理由。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"enum": ["approved", "rejected"],
|
||||
"description": "审批结论:approved 表示同意执行;rejected 表示拒绝执行。",
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"审批理由。应明确风险点或放行依据,例如是否涉及凭据访问、越权路径、"
|
||||
"破坏性命令、以及是否与用户输入意图一致。"
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["decision", "reason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": self._system_prompt()},
|
||||
{"role": "user", "content": payload_text},
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
rounds = 0
|
||||
while rounds < max_rounds + 1:
|
||||
rounds += 1
|
||||
if cancel_check:
|
||||
external = cancel_check()
|
||||
if external:
|
||||
return external
|
||||
if progress_cb:
|
||||
progress_cb({"stage": "model_call", "round": rounds, "message": f"审批轮次 {rounds}"})
|
||||
req = {
|
||||
"model": self.cfg.get("model"),
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.0,
|
||||
**extra_params,
|
||||
}
|
||||
_trace("request", {"round": rounds, "request": req})
|
||||
try:
|
||||
resp = await client.post(endpoint, headers=headers, json=req)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = exc.response.text
|
||||
except Exception:
|
||||
body = str(exc)
|
||||
_trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body})
|
||||
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params)
|
||||
if extra_params:
|
||||
retry_req = {
|
||||
"model": self.cfg.get("model"),
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.0,
|
||||
}
|
||||
_trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req})
|
||||
retry_resp = await client.post(endpoint, headers=headers, json=retry_req)
|
||||
try:
|
||||
retry_resp.raise_for_status()
|
||||
resp = retry_resp
|
||||
except httpx.HTTPStatusError:
|
||||
out = {
|
||||
"decision": "rejected",
|
||||
"reason": f"审批智能体请求失败({retry_resp.status_code})",
|
||||
"source": "approval_agent",
|
||||
}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
else:
|
||||
out = {
|
||||
"decision": "rejected",
|
||||
"reason": f"审批智能体请求失败({exc.response.status_code if exc.response else 'unknown'})",
|
||||
"source": "approval_agent",
|
||||
}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {}
|
||||
reasoning_content = (
|
||||
choice.get("reasoning_content")
|
||||
or choice.get("reasoning")
|
||||
or choice.get("thinking")
|
||||
or ""
|
||||
)
|
||||
_trace(
|
||||
"response",
|
||||
{
|
||||
"round": rounds,
|
||||
"message": choice,
|
||||
"reasoning_content": reasoning_content,
|
||||
},
|
||||
)
|
||||
tool_calls = choice.get("tool_calls") or []
|
||||
content = choice.get("content")
|
||||
if not tool_calls:
|
||||
if content or reasoning_content:
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": str(content or ""),
|
||||
"reasoning_content": str(reasoning_content or ""),
|
||||
}
|
||||
)
|
||||
if rounds > max_rounds:
|
||||
messages.append({"role": "user", "content": "请立刻根据当前已知信息决定:同意或拒绝,并给出理由。"})
|
||||
continue
|
||||
out = {"decision": "rejected", "reason": "审批智能体未产出可执行决策", "source": "approval_agent"}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
# 有 tool_calls 时追加一条完整 assistant 消息(与主智能体结构对齐)
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": str(content or ""),
|
||||
"reasoning_content": str(reasoning_content or ""),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
messages.append(assistant_message)
|
||||
for call in tool_calls:
|
||||
fn = (call.get("function") or {}).get("name")
|
||||
raw_args = (call.get("function") or {}).get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
|
||||
except Exception:
|
||||
args = {}
|
||||
if fn == "approve_decision":
|
||||
decision = str(args.get("decision") or "").strip().lower()
|
||||
reason = str(args.get("reason") or "").strip() or "无理由"
|
||||
if decision in {"approved", "rejected"}:
|
||||
out = {"decision": decision, "reason": reason, "source": "approval_agent"}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
out = {"decision": "rejected", "reason": "审批智能体返回非法决策", "source": "approval_agent"}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
if fn == "run_command":
|
||||
cmd = str(args.get("command") or "").strip()
|
||||
if progress_cb:
|
||||
progress_cb({"stage": "run_command", "round": rounds, "command": cmd})
|
||||
tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"}
|
||||
_trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result})
|
||||
tool_content = json.dumps(tool_result, ensure_ascii=False)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": tool_content,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + f".{int((time.time()%1)*1000000):06d}",
|
||||
"message_id": f"msg_{uuid.uuid4().hex}",
|
||||
"tool_call_id": call.get("id"),
|
||||
"name": "run_command",
|
||||
})
|
||||
out = {"decision": "rejected", "reason": "审批智能体超出最大轮数", "source": "approval_agent"}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
92
modules/auto_approval_service.py
Normal file
92
modules/auto_approval_service.py
Normal file
@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.approval_agent import ApprovalAgent
|
||||
from server.state import tool_approval_manager
|
||||
|
||||
|
||||
def build_auto_approval_payload(
|
||||
*,
|
||||
web_terminal: Any,
|
||||
recent_tool_actions: List[Dict[str, Any]],
|
||||
function_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
) -> str:
|
||||
history = list(getattr(getattr(web_terminal, "context_manager", None), "conversation_history", []) or [])
|
||||
user_input = ""
|
||||
for msg in reversed(history):
|
||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||
continue
|
||||
metadata = msg.get("metadata") or {}
|
||||
if bool(metadata.get("is_auto_generated")):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
user_input = content.strip()
|
||||
break
|
||||
|
||||
recent = recent_tool_actions[-3:] if isinstance(recent_tool_actions, list) else []
|
||||
lines = [
|
||||
"请按照流程审批:",
|
||||
f"用户输入:{user_input or '(空)'}",
|
||||
"AI运行的前三步操作:",
|
||||
]
|
||||
if recent:
|
||||
for idx, row in enumerate(recent, start=1):
|
||||
lines.append(f"{idx}. {row.get('tool_name')}: {json.dumps(row.get('arguments') or {}, ensure_ascii=False)}")
|
||||
else:
|
||||
lines.append("(无)")
|
||||
lines += [
|
||||
"请审核当前操作:",
|
||||
f"{function_name}: {json.dumps(arguments or {}, ensure_ascii=False)}",
|
||||
"仅判断危险性和越权风险,不判断任务必要性。",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def run_auto_approval(
|
||||
*,
|
||||
web_terminal: Any,
|
||||
username: str,
|
||||
approval_id: str,
|
||||
conversation_id: Optional[str],
|
||||
recent_tool_actions: List[Dict[str, Any]],
|
||||
function_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
sender,
|
||||
) -> Dict[str, Any]:
|
||||
def _progress(evt: Dict[str, Any]) -> None:
|
||||
sender("auto_approval_progress", {"approval_id": approval_id, "progress": evt, "conversation_id": conversation_id})
|
||||
|
||||
def _manual_takeover() -> Optional[Dict[str, Any]]:
|
||||
row = tool_approval_manager.get(approval_id)
|
||||
status = (row or {}).get("status")
|
||||
if status in {"approved", "rejected"}:
|
||||
return {"decision": status, "item": row, "source": "manual"}
|
||||
return None
|
||||
|
||||
_progress({"stage": "start", "message": "自动审批开始"})
|
||||
payload = build_auto_approval_payload(
|
||||
web_terminal=web_terminal,
|
||||
recent_tool_actions=recent_tool_actions,
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
agent = ApprovalAgent(web_terminal=web_terminal)
|
||||
result = await agent.review(payload_text=payload, progress_cb=_progress, cancel_check=_manual_takeover)
|
||||
if not _manual_takeover():
|
||||
decided = tool_approval_manager.decide(
|
||||
approval_id=approval_id,
|
||||
username=username,
|
||||
decision=str(result.get("decision") or "rejected"),
|
||||
reason=str(result.get("reason") or "").strip(),
|
||||
decider="approval_agent",
|
||||
)
|
||||
out = {"decision": decided.get("status"), "item": decided, "source": "approval_agent"}
|
||||
else:
|
||||
out = _manual_takeover() or {"decision": "rejected", "reason": "人工接管失败"}
|
||||
_progress({"stage": "done", "message": "自动审批结束", "decision": (out or {}).get("decision")})
|
||||
return out
|
||||
|
||||
@ -16,7 +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"}
|
||||
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
|
||||
ALLOWED_THEMES = {"classic", "light", "dark"}
|
||||
|
||||
PERSONALIZATION_FILENAME = "personalization.json"
|
||||
|
||||
@ -60,7 +60,15 @@ class ToolApprovalManager:
|
||||
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]:
|
||||
def decide(
|
||||
self,
|
||||
approval_id: str,
|
||||
username: str,
|
||||
decision: str,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
decider: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
normalized = str(decision or "").strip().lower()
|
||||
if normalized not in {"approved", "rejected"}:
|
||||
raise ValueError("decision 仅支持 approved / rejected")
|
||||
@ -75,5 +83,8 @@ class ToolApprovalManager:
|
||||
item["status"] = normalized
|
||||
item["decision"] = normalized
|
||||
item["decided_at"] = time.time()
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
item["reason"] = reason.strip()
|
||||
if isinstance(decider, str) and decider.strip():
|
||||
item["decider"] = decider.strip()
|
||||
return dict(item)
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ UPLOAD_FOLDER_NAME = "user_upload"
|
||||
|
||||
chat_bp = Blueprint('chat', __name__)
|
||||
|
||||
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"]
|
||||
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
|
||||
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
|
||||
|
||||
@chat_bp.route('/api/thinking-mode', methods=['POST'])
|
||||
@ -615,7 +615,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
|
||||
if target_mode not in PERMISSION_MODE_OPTIONS:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "无效权限模式,仅支持 readonly / approval / unrestricted"
|
||||
"error": "无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted"
|
||||
}), 400
|
||||
try:
|
||||
applied_mode = terminal.set_permission_mode(target_mode, persist=True)
|
||||
|
||||
@ -20,6 +20,7 @@ from utils.tool_result_formatter import (
|
||||
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
|
||||
|
||||
|
||||
@ -177,6 +178,11 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
|
||||
return any(marker in joined for marker in markers)
|
||||
|
||||
|
||||
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:
|
||||
@ -193,6 +199,7 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se
|
||||
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
|
||||
@ -205,6 +212,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
allowed_tool_names.add(name)
|
||||
except Exception as exc:
|
||||
debug_log(f"构建工具白名单失败(降级继续): {exc}")
|
||||
recent_tool_actions = list(getattr(web_terminal, "_recent_tool_actions", []) or [])
|
||||
|
||||
# 执行每个工具
|
||||
for tool_call in tool_calls:
|
||||
@ -409,6 +417,13 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
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):
|
||||
@ -470,19 +485,38 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
'message': '等待用户审批',
|
||||
'conversation_id': conversation_id
|
||||
})
|
||||
wait_result = await _wait_for_tool_approval(
|
||||
approval_id=approval_item.get("approval_id"),
|
||||
username=username,
|
||||
)
|
||||
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,
|
||||
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",
|
||||
@ -498,7 +532,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
'message': reject_message,
|
||||
'conversation_id': conversation_id
|
||||
})
|
||||
reject_content = json.dumps(reject_payload, ensure_ascii=False)
|
||||
reject_content = _format_rejected_tool_text(reason or reject_message)
|
||||
web_terminal.context_manager.add_conversation(
|
||||
"tool",
|
||||
reject_content,
|
||||
@ -511,13 +545,15 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
"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
|
||||
}
|
||||
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()}"
|
||||
@ -653,7 +689,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
# 批准模式下 run_command 采用“先只读执行,触发权限拒绝后再审批并单次可写重试”。
|
||||
if (
|
||||
function_name == "run_command"
|
||||
and permission_eval.get("mode") == "approval"
|
||||
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)
|
||||
):
|
||||
@ -683,19 +719,38 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
'message': '检测到写权限受限,等待用户审批',
|
||||
'conversation_id': conversation_id
|
||||
})
|
||||
wait_result = await _wait_for_tool_approval(
|
||||
approval_id=approval_item.get("approval_id"),
|
||||
username=username,
|
||||
)
|
||||
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,
|
||||
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",
|
||||
@ -711,7 +766,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
'message': reject_message,
|
||||
'conversation_id': conversation_id
|
||||
})
|
||||
reject_content = json.dumps(reject_payload, ensure_ascii=False)
|
||||
reject_content = _format_rejected_tool_text(reason or reject_message)
|
||||
web_terminal.context_manager.add_conversation(
|
||||
"tool",
|
||||
reject_content,
|
||||
@ -724,13 +779,15 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
|
||||
"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
|
||||
}
|
||||
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
|
||||
|
||||
@ -347,6 +347,8 @@
|
||||
:width="rightWidth"
|
||||
:approvals="pendingToolApprovals"
|
||||
:deciding-approval-ids="decidingApprovalIds"
|
||||
:auto-approval-feed-lines="autoApprovalFeedLines"
|
||||
:auto-approval-final-message="autoApprovalFinalMessage"
|
||||
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
|
||||
@approve="approveToolApproval"
|
||||
@reject="rejectToolApproval"
|
||||
@ -720,6 +722,8 @@
|
||||
:width="Math.min(rightWidth || 420, 460)"
|
||||
:approvals="pendingToolApprovals"
|
||||
:deciding-approval-ids="decidingApprovalIds"
|
||||
:auto-approval-feed-lines="autoApprovalFeedLines"
|
||||
:auto-approval-final-message="autoApprovalFinalMessage"
|
||||
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
|
||||
@approve="approveToolApproval"
|
||||
@reject="rejectToolApproval"
|
||||
|
||||
@ -515,6 +515,9 @@ export const taskPollingMethods = {
|
||||
case 'tool_approval_resolved':
|
||||
this.handleToolApprovalResolved(eventData, eventIdx);
|
||||
break;
|
||||
case 'auto_approval_progress':
|
||||
this.handleAutoApprovalProgress(eventData, eventIdx);
|
||||
break;
|
||||
|
||||
case 'append_payload':
|
||||
this.handleAppendPayload(eventData, eventIdx);
|
||||
@ -1171,6 +1174,13 @@ export const taskPollingMethods = {
|
||||
} else {
|
||||
this.pendingToolApprovals.push(approval);
|
||||
}
|
||||
if (this.approvalAutoCloseTimer) {
|
||||
clearTimeout(this.approvalAutoCloseTimer);
|
||||
this.approvalAutoCloseTimer = null;
|
||||
}
|
||||
if ((this.autoApprovalFeedLines || []).length === 0) {
|
||||
this.autoApprovalFinalMessage = '';
|
||||
}
|
||||
this.rightCollapsed = false;
|
||||
if (this.rightWidth < this.minPanelWidth) {
|
||||
this.rightWidth = this.minPanelWidth;
|
||||
@ -1189,10 +1199,39 @@ export const taskPollingMethods = {
|
||||
this.pendingToolApprovals = this.pendingToolApprovals.filter(
|
||||
(item: any) => item && item.approval_id !== approvalId
|
||||
);
|
||||
// 电脑端:审批完成后自动折叠面板
|
||||
if (!this.pendingToolApprovals.length && !this.isMobileViewport) {
|
||||
this.rightCollapsed = true;
|
||||
const decision = String(data?.decision || '').trim().toLowerCase();
|
||||
const reason = String(data?.reason || '').trim();
|
||||
if (decision === 'approved' || decision === 'rejected') {
|
||||
const decisionText = decision === 'approved' ? '批准通过' : '拒绝';
|
||||
this.autoApprovalFinalMessage = `${decisionText} 原因:${reason || '未提供'}`;
|
||||
}
|
||||
// 电脑端:审批完成后延迟折叠面板(给用户留出查看结果时间)
|
||||
if (!this.pendingToolApprovals.length && !this.isMobileViewport) {
|
||||
if (this.approvalAutoCloseTimer) {
|
||||
clearTimeout(this.approvalAutoCloseTimer);
|
||||
}
|
||||
this.approvalAutoCloseTimer = setTimeout(() => {
|
||||
this.rightCollapsed = true;
|
||||
}, 10000);
|
||||
}
|
||||
this.$forceUpdate();
|
||||
},
|
||||
|
||||
handleAutoApprovalProgress(data: any) {
|
||||
const progress = data?.progress;
|
||||
if (!progress || typeof progress !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(this.autoApprovalFeedLines)) {
|
||||
this.autoApprovalFeedLines = [];
|
||||
}
|
||||
if (progress.stage === 'start') {
|
||||
this.autoApprovalFeedLines = ['自动审批开始'];
|
||||
this.autoApprovalFinalMessage = '';
|
||||
} else if (progress.stage === 'run_command' && progress.command) {
|
||||
this.autoApprovalFeedLines.push(String(progress.command));
|
||||
}
|
||||
this.autoApprovalFeedLines = this.autoApprovalFeedLines.slice(-20);
|
||||
this.$forceUpdate();
|
||||
},
|
||||
|
||||
|
||||
@ -1141,10 +1141,11 @@ export const uiMethods = {
|
||||
const target = String(mode || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!target || target === this.currentPermissionMode) {
|
||||
if (!target) {
|
||||
this.closePermissionMenu();
|
||||
return;
|
||||
}
|
||||
const previousMode = this.currentPermissionMode;
|
||||
try {
|
||||
const response = await fetch('/api/permission-mode', {
|
||||
method: 'POST',
|
||||
@ -1157,7 +1158,23 @@ export const uiMethods = {
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.message || payload?.error || '切换权限失败');
|
||||
}
|
||||
this.currentPermissionMode = payload.mode || target;
|
||||
const reportedMode = typeof payload?.mode === 'string' ? payload.mode : '';
|
||||
if (!reportedMode) {
|
||||
throw new Error('切换权限失败:服务端未返回最新权限');
|
||||
}
|
||||
this.currentPermissionMode = reportedMode;
|
||||
// 二次确认,避免前端状态与后端实际权限漂移
|
||||
const verifyResponse = await fetch('/api/permission-mode');
|
||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
||||
if (!verifyResponse.ok || !verifyPayload?.success || typeof verifyPayload?.mode !== 'string') {
|
||||
throw new Error('切换权限后校验失败,请重试');
|
||||
}
|
||||
this.currentPermissionMode = verifyPayload.mode;
|
||||
if (verifyPayload.mode !== target) {
|
||||
throw new Error(
|
||||
`权限未生效(期望:${this.getPermissionModeLabel(target)},实际:${this.getPermissionModeLabel(verifyPayload.mode)})`
|
||||
);
|
||||
}
|
||||
this.uiPushToast({
|
||||
title: '权限已切换',
|
||||
message: `当前权限:${this.getPermissionModeLabel(this.currentPermissionMode)}`,
|
||||
@ -1165,6 +1182,7 @@ export const uiMethods = {
|
||||
duration: 1800
|
||||
});
|
||||
} catch (error) {
|
||||
this.currentPermissionMode = previousMode;
|
||||
const msg = error instanceof Error ? error.message : String(error || '切换权限失败');
|
||||
this.uiPushToast({
|
||||
title: '切换权限失败',
|
||||
|
||||
@ -143,6 +143,11 @@ export function dataState() {
|
||||
label: '批准',
|
||||
description: '对工作区文件进行修改的工具需用户批准后才会执行'
|
||||
},
|
||||
{
|
||||
value: 'auto_approval',
|
||||
label: '自动审核',
|
||||
description: '工作区内写入直通,高风险操作由后台审核智能体自动审批'
|
||||
},
|
||||
{
|
||||
value: 'unrestricted',
|
||||
label: '无限制',
|
||||
@ -163,6 +168,9 @@ export function dataState() {
|
||||
],
|
||||
pendingToolApprovals: [],
|
||||
decidingApprovalIds: [],
|
||||
autoApprovalFeedLines: [],
|
||||
autoApprovalFinalMessage: '',
|
||||
approvalAutoCloseTimer: null,
|
||||
imageEntries: [],
|
||||
imageLoading: false,
|
||||
videoEntries: [],
|
||||
|
||||
@ -341,7 +341,7 @@ const props = defineProps<{
|
||||
blockTokenPanel?: boolean;
|
||||
blockCompressConversation?: boolean;
|
||||
blockConversationReview?: boolean;
|
||||
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
|
||||
currentPermissionMode: 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
|
||||
permissionMenuOpen: boolean;
|
||||
permissionOptions: Array<{ value: string; label: string; description: string }>;
|
||||
executionModeEnabled?: boolean;
|
||||
|
||||
@ -75,7 +75,6 @@
|
||||
<div><strong>工具:</strong>{{ item.tool_name }}</div>
|
||||
<div v-if="item.preview?.summary"><strong>说明:</strong>{{ item.preview.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-actions">
|
||||
<button
|
||||
type="button"
|
||||
@ -104,6 +103,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="autoApprovalFeedLines.length || autoApprovalFinalMessage" class="auto-approval-block">
|
||||
<div class="auto-approval-block__title">自动审批记录</div>
|
||||
<pre class="auto-approval-block__content">{{ autoApprovalFeedLines.join('\n') }}</pre>
|
||||
<div v-if="autoApprovalFinalMessage" class="auto-approval-block__final">{{ autoApprovalFinalMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@ -118,6 +122,8 @@ const props = defineProps<{
|
||||
width: number;
|
||||
approvals: Array<any>;
|
||||
decidingApprovalIds?: string[];
|
||||
autoApprovalFeedLines?: string[];
|
||||
autoApprovalFinalMessage?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -203,3 +209,34 @@ const getEditNewLines = (item: any): string[] => {
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auto-approval-block {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color, #2a2f3a);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg, rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.auto-approval-block__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auto-approval-block__content {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.auto-approval-block__final {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1595,7 +1595,7 @@ const personalTabs = computed(() => {
|
||||
const activeTab = ref<PersonalTab>('preferences');
|
||||
|
||||
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
|
||||
type PermissionModeValue = 'readonly' | 'approval' | 'unrestricted';
|
||||
type PermissionModeValue = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
|
||||
type CompressionField =
|
||||
| 'shallow_compress_trigger_tokens'
|
||||
| 'shallow_compress_keep_recent_tools'
|
||||
@ -1623,6 +1623,7 @@ const permissionModeOptions: Array<{
|
||||
}> = [
|
||||
{ id: 'readonly', label: '只读', desc: '仅允许读取/检索类工具,修改操作将被拒绝。' },
|
||||
{ id: 'approval', label: '批准', desc: '对工作区文件进行修改的工具需人工批准后执行。' },
|
||||
{ id: 'auto_approval', label: '自动审核', desc: '工作区内写入直通;高风险操作由后台审核智能体自动审批。' },
|
||||
{ id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行。' }
|
||||
];
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { useModelStore } from './model';
|
||||
|
||||
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
|
||||
type RunMode = 'fast' | 'thinking' | 'deep';
|
||||
type PermissionMode = 'readonly' | 'approval' | 'unrestricted';
|
||||
type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
|
||||
|
||||
interface PersonalForm {
|
||||
enabled: boolean;
|
||||
@ -279,6 +279,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
default_permission_mode:
|
||||
data.default_permission_mode === 'readonly' ||
|
||||
data.default_permission_mode === 'approval' ||
|
||||
data.default_permission_mode === 'auto_approval' ||
|
||||
data.default_permission_mode === 'unrestricted'
|
||||
? data.default_permission_mode
|
||||
: 'unrestricted',
|
||||
@ -540,7 +541,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
this.clearFeedback();
|
||||
},
|
||||
setDefaultPermissionMode(mode: PermissionMode) {
|
||||
const allowed: PermissionMode[] = ['readonly', 'approval', 'unrestricted'];
|
||||
const allowed: PermissionMode[] = ['readonly', 'approval', 'auto_approval', 'unrestricted'];
|
||||
const target = allowed.includes(mode) ? mode : 'unrestricted';
|
||||
this.form = {
|
||||
...this.form,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user