From dc61bd92dcf9d302293586ad49382606a03304ed Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 1 Aug 2026 10:02:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(runtime-mode):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=9C=9F=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=9F=BA=E7=BA=BF=E6=BC=82=E7=A7=BB=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- core/main_terminal.py | 43 +++++++--- core/main_terminal_parts/context/mode.py | 100 +++++++++++++++++++++++ server/chat/permission.py | 25 +----- server/chat/settings.py | 30 ------- server/chat_flow_task_main.py | 44 +++++++++- server/chat_flow_task_support.py | 6 ++ server/chat_flow_tool_loop.py | 10 +++ 8 files changed, 195 insertions(+), 68 deletions(-) diff --git a/.env.example b/.env.example index 6df88fde..cb00a61f 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,9 @@ MAX_ACTIVE_USER_CONTAINERS=8 # === MCP 工具扩展(可选) ===================================================== # 1=启用,0=禁用 MCP_TOOLS_ENABLED=1 -# MCP 服务配置文件路径(默认 data/mcp_servers.json) -MCP_SERVERS_FILE=./data/mcp_servers.json +# MCP 服务配置文件路径(默认 {DATA_DIR}/mcp_servers.json,即 ~/.astrion/astrion//data/ 下,不在源码树) +# 下面仅为覆盖示例,按需启用: +# MCP_SERVERS_FILE=./data/mcp_servers.json # MCP 协议版本(默认 2025-06-18) MCP_PROTOCOL_VERSION=2025-06-18 # MCP 默认超时(秒) diff --git a/core/main_terminal.py b/core/main_terminal.py index 2b28cefd..361526e2 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -323,13 +323,16 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi current = self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted" if pending_permission != current: self.set_permission_mode(pending_permission, persist=False) - label = { - "readonly": "只读", - "approval": "审批", - "auto_approval": "自动审核", - "unrestricted": "无限制", - }.get(pending_permission, pending_permission) - notices.append({"text": f"权限模式被用户修改为 {label}", "source": "权限变更"}) + if hasattr(self, "build_runtime_mode_switch_notice"): + notice_text = self.build_runtime_mode_switch_notice("permission_mode", pending_permission) + else: + notice_text = f"权限模式被用户修改为 {pending_permission}" + notices.append({ + "text": notice_text, + "source": "permission_change", + "kind": "permission_mode", + "mode": pending_permission, + }) updates["permission_mode"] = pending_permission updates["pending_permission_mode"] = None self.pending_permission_mode = None @@ -339,12 +342,18 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi current_exec = self.get_execution_mode() if pending_execution != current_exec: self.set_execution_mode(pending_execution) - label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(pending_execution, pending_execution) - if hasattr(self, "_build_execution_mode_switch_notice"): + if hasattr(self, "build_runtime_mode_switch_notice"): + notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution) + elif hasattr(self, "_build_execution_mode_switch_notice"): notice_text = self._build_execution_mode_switch_notice(pending_execution) else: - notice_text = f"执行环境被用户修改为 {label}" - notices.append({"text": notice_text, "source": "执行环境变更"}) + notice_text = f"执行环境被用户修改为 {pending_execution}" + notices.append({ + "text": notice_text, + "source": "execution_change", + "kind": "execution_mode", + "mode": pending_execution, + }) updates["execution_mode"] = pending_execution updates["pending_execution_mode"] = None self.pending_execution_mode = None @@ -354,8 +363,16 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi current_network = self.get_network_permission() if pending_network != current_network: self.set_network_permission(pending_network) - label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(pending_network, pending_network) - notices.append({"text": f"网络权限被用户修改为 {label}", "source": "网络权限变更"}) + if hasattr(self, "build_runtime_mode_switch_notice"): + notice_text = self.build_runtime_mode_switch_notice("network_permission", pending_network) + else: + notice_text = f"网络权限被用户修改为 {pending_network}" + notices.append({ + "text": notice_text, + "source": "network_change", + "kind": "network_permission", + "mode": pending_network, + }) updates["network_permission"] = pending_network updates["pending_network_permission"] = None self.pending_network_permission = None diff --git a/core/main_terminal_parts/context/mode.py b/core/main_terminal_parts/context/mode.py index e23081a0..7791c1d9 100644 --- a/core/main_terminal_parts/context/mode.py +++ b/core/main_terminal_parts/context/mode.py @@ -110,6 +110,25 @@ class ModeMixin: "direct": "完全访问权限", } + _NETWORK_PERMISSION_LABEL = { + "restricted": "受限", + "full": "完全开放", + "none": "完全禁止", + } + + # 运行期模式(权限/执行环境/网络权限)切换通知的 kind 与 source 映射。 + # source 需与 server/chat_flow_task_support.py 的 _VALID_SOURCES 保持一致。 + _RUNTIME_MODE_KINDS = ("permission_mode", "execution_mode", "network_permission") + _RUNTIME_MODE_SOURCE = { + "permission_mode": "permission_change", + "execution_mode": "execution_change", + "network_permission": "network_change", + } + # conversation metadata key:本对话「智能体已知晓」的运行期模式基线。 + # 只在通知真正注入对话后更新;空闲期切换不改它,从而在下一条 user 消息时 + # 通过 drift 检测一次性补发通知(天然去重:怎么切都只比较最终值)。 + _RUNTIME_MODE_BASELINE_META_KEY = "runtime_mode_baseline" + def _build_permission_mode_message(self) -> Optional[str]: """根据当前权限模式构建权限说明消息(从模板读取并替换占位符)""" template = self.load_prompt("permission_mode") @@ -317,6 +336,87 @@ class ModeMixin: "此前的命令如果是按 Linux 沙箱环境编写的,请按上述规则改写后重新执行。" ) + # ---- 运行期模式基线(权限/执行环境/网络权限的「智能体已知晓」状态) ---- + + def _current_runtime_modes(self) -> Dict[str, str]: + """当前实际生效的三种运行期模式。""" + modes: Dict[str, str] = {} + try: + if hasattr(self, "get_permission_mode"): + modes["permission_mode"] = str(self.get_permission_mode() or "") + except Exception: + pass + try: + if hasattr(self, "get_execution_mode"): + modes["execution_mode"] = str(self.get_execution_mode() or "") + except Exception: + pass + try: + if hasattr(self, "get_network_permission"): + modes["network_permission"] = str(self.get_network_permission() or "") + except Exception: + pass + return modes + + def get_runtime_mode_baseline(self) -> Dict[str, str]: + """读取本对话的运行期模式基线(conversation metadata)。""" + cm = getattr(self, "context_manager", None) + meta = getattr(cm, "conversation_metadata", None) if cm else None + raw = meta.get(self._RUNTIME_MODE_BASELINE_META_KEY) if isinstance(meta, dict) else None + return dict(raw) if isinstance(raw, dict) else {} + + def update_runtime_mode_baseline(self, updates: Dict[str, str]) -> None: + """通知真正注入对话后更新基线。只允许在通知注入点调用。""" + if not isinstance(updates, dict) or not updates: + return + baseline = self.get_runtime_mode_baseline() + baseline.update({k: str(v) for k, v in updates.items() if v}) + if hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({self._RUNTIME_MODE_BASELINE_META_KEY: baseline}) + except Exception: + pass + + def collect_runtime_mode_drift(self) -> List[Dict[str, str]]: + """比较当前实际模式与本对话基线,返回需要补发通知的差异列表。 + + - 基线缺失(新对话/历史对话):静默初始化为当前模式,返回空列表(不通知)。 + 新对话首轮消息的冻结系统提示词本身就是当前模式,无需额外通知。 + - 返回项结构:{"kind": ..., "mode": ..., "source": ...} + """ + current = self._current_runtime_modes() + baseline = self.get_runtime_mode_baseline() + if not baseline: + self.update_runtime_mode_baseline(current) + return [] + drift: List[Dict[str, str]] = [] + for kind in self._RUNTIME_MODE_KINDS: + cur = current.get(kind) + if not cur: + continue + if baseline.get(kind) != cur: + drift.append({ + "kind": kind, + "mode": cur, + "source": self._RUNTIME_MODE_SOURCE.get(kind, "notify"), + }) + return drift + + def build_runtime_mode_switch_notice(self, kind: str, mode: str) -> str: + """构建运行期模式切换通知文本(运行期注入与空闲期补注共用,保证两处一致)。""" + if kind == "permission_mode": + label = self._PERMISSION_MODE_LABEL.get(mode, mode) + return f"权限模式被用户修改为 {label}" + if kind == "network_permission": + label = self._NETWORK_PERMISSION_LABEL.get(mode, mode) + return f"网络权限被用户修改为 {label}" + if kind == "execution_mode": + if hasattr(self, "_build_execution_mode_switch_notice"): + return self._build_execution_mode_switch_notice(mode) + label = self._EXECUTION_MODE_LABEL.get(mode, mode) + return f"执行环境被用户修改为 {label}" + return f"运行模式被用户修改为 {mode}" + def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]: return self._get_or_init_frozen_prompt(key, builder) diff --git a/server/chat/permission.py b/server/chat/permission.py index 19ad776c..e1fc2504 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -1,6 +1,5 @@ from __future__ import annotations from server.chat import chat_bp -from server.chat.settings import _dispatch_runtime_mode_notice import json, time PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"] @@ -194,8 +193,8 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user "message": "权限模式将在当前工具执行完成后生效", }) - # 空闲期间:直接生效 - previous_mode = terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None + # 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发 + # (见 chat_flow_task_main 的 drift 注入点),此处不再 enqueue。 try: applied_mode = terminal.set_permission_mode(target_mode) if hasattr(terminal, "pending_permission_mode"): @@ -214,14 +213,6 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user }), 500 session["permission_mode"] = applied_mode - if applied_mode != previous_mode: - permission_label = { - "readonly": "只读", - "approval": "批准", - "auto_approval": "自动审核", - "unrestricted": "无限制", - }.get(applied_mode, applied_mode) - _dispatch_runtime_mode_notice(terminal, username, f"权限模式被用户修改为 {permission_label}", source="权限变更") status = terminal.get_status() socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ @@ -297,8 +288,7 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern "message": "执行环境将在当前工具执行完成后生效", }) - # 空闲期间:直接生效 - previous_mode = terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None + # 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发。 try: state = terminal.set_execution_mode(target_mode) if hasattr(terminal, "pending_execution_mode"): @@ -312,10 +302,6 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern except Exception as exc: return jsonify({"success": False, "error": str(exc), "message": "更新执行环境失败"}), 500 status = terminal.get_status() - current_mode = state.get("mode", target_mode) - if current_mode != previous_mode: - execution_label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(current_mode, current_mode) - _dispatch_runtime_mode_notice(terminal, username, f"执行环境被用户修改为 {execution_label}", source="执行环境变更") socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, @@ -383,7 +369,7 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u "message": "网络权限将在当前工具执行完成后生效", }) - previous_mode = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else None + # 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发。 try: applied = terminal.set_network_permission(target_mode) if hasattr(terminal, "pending_network_permission"): @@ -397,9 +383,6 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u except Exception as exc: return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500 status = terminal.get_status() - if applied != previous_mode: - label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(applied, applied) - _dispatch_runtime_mode_notice(terminal, username, f"网络权限被用户修改为 {label}", source="网络权限变更") socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, diff --git a/server/chat/settings.py b/server/chat/settings.py index 99ef9d5c..48630b35 100644 --- a/server/chat/settings.py +++ b/server/chat/settings.py @@ -46,36 +46,6 @@ from server.monitor import get_cached_monitor_snapshot from server.files import sanitize_filename_preserve_unicode UPLOAD_FOLDER_NAME = ".astrion/user_upload" -def _dispatch_runtime_mode_notice(terminal: WebTerminal, username: str, text: str, source: str = "notify") -> None: - notice = str(text or "").strip() - if not notice: - return - - # 仅在“有运行中的任务”时注入 [系统通知|notify],空闲期切换不写入 user 消息。 - try: - from server.tasks import task_manager - - current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None) - running_tasks = [ - r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"} - ] - if current_conv: - running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1) - if not running_tasks: - debug_log(f"[RuntimeMode] idle switch ignored notify injection: {notice}") - return - - target = running_tasks[0] - result = task_manager.enqueue_runtime_guidance( - username=username, - task_id=target.task_id, - message=notice, - source=source, - ) - if not result.get("success"): - debug_log(f"[RuntimeMode] enqueue runtime guidance failed: {result}") - except Exception as exc: - debug_log(f"[RuntimeMode] dispatch notice failed: {exc}") @chat_bp.route('/api/thinking-mode', methods=['POST']) @api_login_required diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 80bd2923..42bfcde3 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -122,6 +122,7 @@ from .chat_flow_task_support import ( process_background_command_updates, process_multi_agent_master_messages, inject_multi_agent_master_message, + inject_runtime_user_message, _auto_message_type_for_multi_agent_subtype, ) from .chat_flow_tool_loop import execute_tool_calls @@ -1516,6 +1517,43 @@ async def handle_task_with_sender( ) except Exception as exc: debug_log(f"[TaskFlow] 发送 user_message 回显失败: {exc}") + # 空闲期运行期模式(权限/执行环境/网络权限)切换的补发通知。 + # 仅真实用户消息(直接发送 / 提前输入队列 / 引导消息转换)触发, + # 自动消息(子智能体/后台命令完成等触发的续答)不触发。 + # drift 检测比较「当前实际模式 vs 本对话已通知基线」,天然去重: + # 空闲期反复切换只在最终值与基线不同时补发一遍; + # 基线缺失(新对话/历史对话)时静默初始化,不补发。 + if user_message_source in {"user", "presend", "guidance"}: + try: + drift = ( + web_terminal.collect_runtime_mode_drift() + if hasattr(web_terminal, "collect_runtime_mode_drift") + else [] + ) + except Exception as exc: + drift = [] + debug_log(f"[RuntimeMode] 空闲期模式漂移检测失败: {exc}") + for drift_item in drift: + try: + notice_text = web_terminal.build_runtime_mode_switch_notice( + drift_item.get("kind"), drift_item.get("mode") + ) + if not str(notice_text or "").strip(): + continue + inject_runtime_user_message( + web_terminal=web_terminal, + messages=None, + text=notice_text, + source=str(drift_item.get("source") or "notify"), + sender=sender, + conversation_id=conversation_id, + inline=False, + ) + web_terminal.update_runtime_mode_baseline( + {drift_item.get("kind"): drift_item.get("mode")} + ) + except Exception as exc: + debug_log(f"[RuntimeMode] 空闲期切换通知注入失败: {exc}") _prepare_hidden_versioning_baseline_for_first_input( web_terminal=web_terminal, workspace=workspace, @@ -2409,7 +2447,9 @@ async def handle_task_with_sender( # 结束,不会进入 execute_tool_calls → apply_pending_runtime_mode_changes。 # 若不在此清理,pending 会残留到下一次工作首次工具调用时才被应用, # 并把"变更通知"作为 user 消息注入到新一轮对话中(bug)。 - # 正确行为:task 结束时立即应用 pending(等同空闲期切换),但不生成通知。 + # 正确行为:task 结束时立即应用 pending(等同空闲期切换),此处不生成通知; + # 由于通知基线(runtime_mode_baseline)未更新,下一条真实 user 消息时会通过 + # drift 检测一次性补发切换通知(见 user_message 回显后的注入点)。 try: if hasattr(web_terminal, "apply_pending_runtime_mode_changes"): residual_notices = web_terminal.apply_pending_runtime_mode_changes() or [] @@ -2431,7 +2471,7 @@ async def handle_task_with_sender( ) # 权限/执行环境/网络权限变更通知在任务完成时不应该触发新一轮工作, # 只保留真正的引导消息(压缩续接等),丢弃变更通知。 - runtime_skip_sources = {"权限变更", "执行环境变更", "网络权限变更", "notify"} + runtime_skip_sources = {"权限变更", "执行环境变更", "网络权限变更", "notify", "permission_change", "execution_change", "network_change"} for item in (raw_items or []): if isinstance(item, dict): src = str(item.get("source") or "").strip().lower() diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 698617aa..4c46d01c 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -24,6 +24,9 @@ _VALID_SOURCES = { "permission", "sandbox", "skill", + "permission_change", + "execution_change", + "network_change", } @@ -34,6 +37,9 @@ def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str, return {"visibility": "hidden", "starts_work": False} if normalized in {"guidance", "notify"}: return {"visibility": "compact", "starts_work": False} + # 运行期模式(权限/执行环境/网络权限)切换通知:仅通知,不开启新一轮工作。 + if normalized in {"permission_change", "execution_change", "network_change"}: + return {"visibility": "compact", "starts_work": False} # 后台完成通知 / 运行期压缩续接:属于当前这轮工作的延续,不开启新的工作头与计时器, # 也不暂停上一轮计时器。与 task_main 的 _user_message_ui_defaults 保持一致, # 确保运行期直接渲染与刷新后从历史加载行为相同。 diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 5a644d8d..0f30f26e 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -1333,9 +1333,13 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie if isinstance(notice, dict): _text = str(notice.get("text") or "").strip() _source = str(notice.get("source") or "notify").strip() or "notify" + _kind = str(notice.get("kind") or "").strip() + _mode = str(notice.get("mode") or "").strip() else: _text = str(notice or "").strip() _source = "notify" + _kind = "" + _mode = "" if not _text: continue _inject_runtime_mode_notice( @@ -1346,6 +1350,12 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie sender=sender, conversation_id=conversation_id, ) + # 通知真正注入对话后同步更新基线,避免下一条 user 消息重复补发。 + if _kind and _mode and hasattr(web_terminal, "update_runtime_mode_baseline"): + try: + web_terminal.update_runtime_mode_baseline({_kind: _mode}) + except Exception: + pass # 运行期“引导对话”:需要等待同一轮全部工具执行结束后再注入, # 避免一轮内并行/多工具调用时过早插入。