refactor(runtime-mode): 运行期模式切换通知改为基线漂移检测机制

This commit is contained in:
JOJO 2026-08-01 10:02:24 +08:00
parent 2da6c87245
commit dc61bd92dc
8 changed files with 195 additions and 68 deletions

View File

@ -85,8 +85,9 @@ MAX_ACTIVE_USER_CONTAINERS=8
# === MCP 工具扩展(可选) ===================================================== # === MCP 工具扩展(可选) =====================================================
# 1=启用0=禁用 # 1=启用0=禁用
MCP_TOOLS_ENABLED=1 MCP_TOOLS_ENABLED=1
# MCP 服务配置文件路径(默认 data/mcp_servers.json # MCP 服务配置文件路径(默认 {DATA_DIR}/mcp_servers.json即 ~/.astrion/astrion/<mode>/data/ 下,不在源码树)
MCP_SERVERS_FILE=./data/mcp_servers.json # 下面仅为覆盖示例,按需启用:
# MCP_SERVERS_FILE=./data/mcp_servers.json
# MCP 协议版本(默认 2025-06-18 # MCP 协议版本(默认 2025-06-18
MCP_PROTOCOL_VERSION=2025-06-18 MCP_PROTOCOL_VERSION=2025-06-18
# MCP 默认超时(秒) # MCP 默认超时(秒)

View File

@ -323,13 +323,16 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
current = self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted" current = self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted"
if pending_permission != current: if pending_permission != current:
self.set_permission_mode(pending_permission, persist=False) self.set_permission_mode(pending_permission, persist=False)
label = { if hasattr(self, "build_runtime_mode_switch_notice"):
"readonly": "只读", notice_text = self.build_runtime_mode_switch_notice("permission_mode", pending_permission)
"approval": "审批", else:
"auto_approval": "自动审核", notice_text = f"权限模式被用户修改为 {pending_permission}"
"unrestricted": "无限制", notices.append({
}.get(pending_permission, pending_permission) "text": notice_text,
notices.append({"text": f"权限模式被用户修改为 {label}", "source": "权限变更"}) "source": "permission_change",
"kind": "permission_mode",
"mode": pending_permission,
})
updates["permission_mode"] = pending_permission updates["permission_mode"] = pending_permission
updates["pending_permission_mode"] = None updates["pending_permission_mode"] = None
self.pending_permission_mode = None self.pending_permission_mode = None
@ -339,12 +342,18 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
current_exec = self.get_execution_mode() current_exec = self.get_execution_mode()
if pending_execution != current_exec: if pending_execution != current_exec:
self.set_execution_mode(pending_execution) self.set_execution_mode(pending_execution)
label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(pending_execution, pending_execution) if hasattr(self, "build_runtime_mode_switch_notice"):
if hasattr(self, "_build_execution_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) notice_text = self._build_execution_mode_switch_notice(pending_execution)
else: else:
notice_text = f"执行环境被用户修改为 {label}" notice_text = f"执行环境被用户修改为 {pending_execution}"
notices.append({"text": notice_text, "source": "执行环境变更"}) notices.append({
"text": notice_text,
"source": "execution_change",
"kind": "execution_mode",
"mode": pending_execution,
})
updates["execution_mode"] = pending_execution updates["execution_mode"] = pending_execution
updates["pending_execution_mode"] = None updates["pending_execution_mode"] = None
self.pending_execution_mode = None self.pending_execution_mode = None
@ -354,8 +363,16 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
current_network = self.get_network_permission() current_network = self.get_network_permission()
if pending_network != current_network: if pending_network != current_network:
self.set_network_permission(pending_network) self.set_network_permission(pending_network)
label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(pending_network, pending_network) if hasattr(self, "build_runtime_mode_switch_notice"):
notices.append({"text": f"网络权限被用户修改为 {label}", "source": "网络权限变更"}) 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["network_permission"] = pending_network
updates["pending_network_permission"] = None updates["pending_network_permission"] = None
self.pending_network_permission = None self.pending_network_permission = None

View File

@ -110,6 +110,25 @@ class ModeMixin:
"direct": "完全访问权限", "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]: def _build_permission_mode_message(self) -> Optional[str]:
"""根据当前权限模式构建权限说明消息(从模板读取并替换占位符)""" """根据当前权限模式构建权限说明消息(从模板读取并替换占位符)"""
template = self.load_prompt("permission_mode") template = self.load_prompt("permission_mode")
@ -317,6 +336,87 @@ class ModeMixin:
"此前的命令如果是按 Linux 沙箱环境编写的,请按上述规则改写后重新执行。" "此前的命令如果是按 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]: def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:
return self._get_or_init_frozen_prompt(key, builder) return self._get_or_init_frozen_prompt(key, builder)

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from server.chat import chat_bp from server.chat import chat_bp
from server.chat.settings import _dispatch_runtime_mode_notice
import json, time import json, time
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"] PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
@ -194,8 +193,8 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
"message": "权限模式将在当前工具执行完成后生效", "message": "权限模式将在当前工具执行完成后生效",
}) })
# 空闲期间:直接生效 # 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发
previous_mode = terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None # (见 chat_flow_task_main 的 drift 注入点),此处不再 enqueue。
try: try:
applied_mode = terminal.set_permission_mode(target_mode) applied_mode = terminal.set_permission_mode(target_mode)
if hasattr(terminal, "pending_permission_mode"): if hasattr(terminal, "pending_permission_mode"):
@ -214,14 +213,6 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
}), 500 }), 500
session["permission_mode"] = applied_mode 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() status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}") socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
@ -297,8 +288,7 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern
"message": "执行环境将在当前工具执行完成后生效", "message": "执行环境将在当前工具执行完成后生效",
}) })
# 空闲期间:直接生效 # 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发。
previous_mode = terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None
try: try:
state = terminal.set_execution_mode(target_mode) state = terminal.set_execution_mode(target_mode)
if hasattr(terminal, "pending_execution_mode"): if hasattr(terminal, "pending_execution_mode"):
@ -312,10 +302,6 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": "更新执行环境失败"}), 500 return jsonify({"success": False, "error": str(exc), "message": "更新执行环境失败"}), 500
status = terminal.get_status() 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}") socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
@ -383,7 +369,7 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u
"message": "网络权限将在当前工具执行完成后生效", "message": "网络权限将在当前工具执行完成后生效",
}) })
previous_mode = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else None # 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发。
try: try:
applied = terminal.set_network_permission(target_mode) applied = terminal.set_network_permission(target_mode)
if hasattr(terminal, "pending_network_permission"): if hasattr(terminal, "pending_network_permission"):
@ -397,9 +383,6 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500 return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500
status = terminal.get_status() 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}") socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,

View File

@ -46,36 +46,6 @@ from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload" 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']) @chat_bp.route('/api/thinking-mode', methods=['POST'])
@api_login_required @api_login_required

View File

@ -122,6 +122,7 @@ from .chat_flow_task_support import (
process_background_command_updates, process_background_command_updates,
process_multi_agent_master_messages, process_multi_agent_master_messages,
inject_multi_agent_master_message, inject_multi_agent_master_message,
inject_runtime_user_message,
_auto_message_type_for_multi_agent_subtype, _auto_message_type_for_multi_agent_subtype,
) )
from .chat_flow_tool_loop import execute_tool_calls from .chat_flow_tool_loop import execute_tool_calls
@ -1516,6 +1517,43 @@ async def handle_task_with_sender(
) )
except Exception as exc: except Exception as exc:
debug_log(f"[TaskFlow] 发送 user_message 回显失败: {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( _prepare_hidden_versioning_baseline_for_first_input(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
@ -2409,7 +2447,9 @@ async def handle_task_with_sender(
# 结束,不会进入 execute_tool_calls → apply_pending_runtime_mode_changes。 # 结束,不会进入 execute_tool_calls → apply_pending_runtime_mode_changes。
# 若不在此清理pending 会残留到下一次工作首次工具调用时才被应用, # 若不在此清理pending 会残留到下一次工作首次工具调用时才被应用,
# 并把"变更通知"作为 user 消息注入到新一轮对话中bug # 并把"变更通知"作为 user 消息注入到新一轮对话中bug
# 正确行为task 结束时立即应用 pending等同空闲期切换但不生成通知。 # 正确行为task 结束时立即应用 pending等同空闲期切换此处不生成通知
# 由于通知基线runtime_mode_baseline未更新下一条真实 user 消息时会通过
# drift 检测一次性补发切换通知(见 user_message 回显后的注入点)。
try: try:
if hasattr(web_terminal, "apply_pending_runtime_mode_changes"): if hasattr(web_terminal, "apply_pending_runtime_mode_changes"):
residual_notices = web_terminal.apply_pending_runtime_mode_changes() or [] 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 []): for item in (raw_items or []):
if isinstance(item, dict): if isinstance(item, dict):
src = str(item.get("source") or "").strip().lower() src = str(item.get("source") or "").strip().lower()

View File

@ -24,6 +24,9 @@ _VALID_SOURCES = {
"permission", "permission",
"sandbox", "sandbox",
"skill", "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} return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}: if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False} 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 保持一致, # 也不暂停上一轮计时器。与 task_main 的 _user_message_ui_defaults 保持一致,
# 确保运行期直接渲染与刷新后从历史加载行为相同。 # 确保运行期直接渲染与刷新后从历史加载行为相同。

View File

@ -1333,9 +1333,13 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
if isinstance(notice, dict): if isinstance(notice, dict):
_text = str(notice.get("text") or "").strip() _text = str(notice.get("text") or "").strip()
_source = str(notice.get("source") or "notify").strip() or "notify" _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: else:
_text = str(notice or "").strip() _text = str(notice or "").strip()
_source = "notify" _source = "notify"
_kind = ""
_mode = ""
if not _text: if not _text:
continue continue
_inject_runtime_mode_notice( _inject_runtime_mode_notice(
@ -1346,6 +1350,12 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
sender=sender, sender=sender,
conversation_id=conversation_id, 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
# 运行期“引导对话”:需要等待同一轮全部工具执行结束后再注入, # 运行期“引导对话”:需要等待同一轮全部工具执行结束后再注入,
# 避免一轮内并行/多工具调用时过早插入。 # 避免一轮内并行/多工具调用时过早插入。