From a34d92ba8be63ed31b5aa59be1418299eb3b847a Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Wed, 24 Jun 2026 21:25:41 +0800 Subject: [PATCH 1/4] feat(prompt): inject optional system info Co-authored-by: GPT-5 Codex --- config.toml.example | 40 +++ docs/configuration.md | 23 ++ docs/message-batching.md | 2 +- src/Undefined/ai/prompts/builder.py | 15 + src/Undefined/ai/prompts/system_info.py | 322 ++++++++++++++++++ src/Undefined/config/__init__.py | 2 + src/Undefined/config/config_class.py | 3 + src/Undefined/config/domain_parsers.py | 23 ++ src/Undefined/config/load_sections/domains.py | 3 + src/Undefined/config/models.py | 18 + tests/test_config_api.py | 28 ++ tests/test_prompt_builder_message_order.py | 103 ++++++ tests/test_prompt_system_info.py | 192 +++++++++++ 13 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 src/Undefined/ai/prompts/system_info.py create mode 100644 tests/test_prompt_system_info.py diff --git a/config.toml.example b/config.toml.example index 65fc169a..05d0ace9 100644 --- a/config.toml.example +++ b/config.toml.example @@ -959,6 +959,46 @@ prefetch_tools = ["get_current_time"] # zh: 隐藏已预取的工具声明。 # en: Hide prefetched tools from the model's tool list. prefetch_tools_hide = true + +# zh: Prompt 系统信息注入。总开关默认关闭;开启后各子项默认展示,可逐项关闭。 +# en: Prompt system information injection. The master switch is disabled by default; enabled sub-items are shown by default and can be disabled individually. +[prompt.system_info] +# zh: 是否把当前运行主机的系统信息注入提示词。默认关闭,避免升级后自动暴露本机信息给模型。 +# en: Inject current host system information into prompts. Disabled by default to avoid exposing host data automatically after upgrades. +enabled = false +# zh: 展示操作系统、版本、release 和架构。 +# en: Show operating system, version, release, and architecture. +show_os = true +# zh: 展示 Python 与 Undefined 版本。 +# en: Show Python and Undefined versions. +show_runtime = true +# zh: 展示主机名。 +# en: Show hostname. +show_host = true +# zh: 展示 CPU 型号与物理/逻辑核心数。 +# en: Show CPU model and physical/logical core counts. +show_cpu = true +# zh: 展示当前 CPU 总使用率。 +# en: Show current total CPU usage. +show_cpu_usage = true +# zh: 展示内存总量、已用量与占用率。 +# en: Show memory total, used amount, and usage percentage. +show_memory = true +# zh: 展示 Swap 总量、已用量与占用率。 +# en: Show swap total, used amount, and usage percentage. +show_swap = true +# zh: 展示可见磁盘分区、文件系统、容量与占用率。 +# en: Show visible disk partitions, filesystem, capacity, and usage percentage. +show_disks = true +# zh: 展示非回环网卡地址与网络收发累计。涉及 IP 信息,公网或共享部署可关闭。 +# en: Show non-loopback network addresses and cumulative network I/O. Disable this on public or shared deployments if IP exposure is undesirable. +show_network = true +# zh: 展示 Bot 进程 PID、启动时间、运行时长、RSS 与进程 CPU 占用。 +# en: Show bot process PID, start time, uptime, RSS, and process CPU usage. +show_process = true +# zh: 展示系统启动时间与系统运行时长。 +# en: Show system boot time and uptime. +show_uptime = true # zh: 搜索服务配置。 # en: Search service config. [search] diff --git a/docs/configuration.md b/docs/configuration.md index dd2a92fb..caf0c401 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -621,6 +621,29 @@ Prompt caching 补充: --- +### 4.11.1 `[prompt.system_info]` Prompt 系统信息注入 + +| 字段 | 默认值 | 说明 | +|---|---:|---| +| `enabled` | `false` | 总开关。开启后把当前运行主机的系统信息注入主模型 Prompt;默认关闭,避免升级后自动暴露本机信息给上游模型 | +| `show_os` | `true` | 展示操作系统、版本、release 与架构 | +| `show_runtime` | `true` | 展示 Python 与 Undefined 版本 | +| `show_host` | `true` | 展示主机名 | +| `show_cpu` | `true` | 展示 CPU 型号、物理核心数与逻辑核心数 | +| `show_cpu_usage` | `true` | 展示当前 CPU 总使用率 | +| `show_memory` | `true` | 展示内存总量、已用量与占用率 | +| `show_swap` | `true` | 展示 Swap 总量、已用量与占用率 | +| `show_disks` | `true` | 展示可见磁盘分区、文件系统、容量与占用率 | +| `show_network` | `true` | 展示非回环网卡地址与网络收发累计;涉及 IP 信息,公网或共享部署可关闭 | +| `show_process` | `true` | 展示 Bot 进程 PID、启动时间、运行时长、RSS 与进程 CPU 占用 | +| `show_uptime` | `true` | 展示系统启动时间与系统运行时长 | + +采集实现优先使用 `psutil` 与 Python 标准库 `platform/socket/os/time`,目标是 Windows、macOS、Linux 跨平台可用。单项采集失败时只省略该项,不会中断 Prompt 构建。该块属于动态上下文,会放在历史/记忆之后、`【当前时间】` 之前。 + +安全边界:不会注入 API Key、环境变量、命令行参数、完整配置文件内容、用户目录文件列表等敏感内容。主机名、网络地址、磁盘挂载点和 PID 属于运维信息,启用前请确认当前模型供应商和部署场景允许暴露这些信息。 + +--- + ### 4.12 `[search]` 搜索 | 字段 | 默认值 | 说明 | diff --git a/docs/message-batching.md b/docs/message-batching.md index e84c97e7..8cf69444 100644 --- a/docs/message-batching.md +++ b/docs/message-batching.md @@ -30,7 +30,7 @@ `res/prompts/undefined.xml`、`res/prompts/undefined_nagaagent.xml` 与 `res/IMPORTANT/each.md` 均按"当前输入批次"适配:有【连续消息说明】时整批当前 `` 都属于本轮输入;没有连续说明时,当前输入批次退化为最后一条消息。防幽灵任务规则仍然生效,但它只隔离当前输入批次之外的历史消息;「催促/在吗」不等于新任务,历史同类或语义等价操作不得自动重跑(与 each.md 硬性熔断一致)。 -Prompt 构建顺序按缓存命中友好设计:固定系统提示词、运行环境配置、Skills 元数据和强制规则尽量放在前面;会频繁变化的 memory / cognitive / end 摘要 / history / 当前时间 / 当前输入批次放在后面。`system_prompt_as_user=true` 时,系统块会合并进首条 user,但合并后的文本仍保留这个顺序,且当前输入批次仍在最后。 +Prompt 构建顺序按缓存命中友好设计:固定系统提示词、运行环境配置、Skills 元数据和强制规则尽量放在前面;会频繁变化的 memory / cognitive / end 摘要 / history / 可选系统信息 / 当前时间 / 当前输入批次放在后面。`system_prompt_as_user=true` 时,系统块会合并进首条 user,但合并后的文本仍保留这个顺序,且当前输入批次仍在最后。 `end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中有价值的新观察;这些观察不要求与 bot 相关,也不要求长期稳定,但只能来自当前输入批次。历史消息、认知记忆、侧写和最近消息参考只用于消歧,不能作为 observations 的新事实来源。后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 diff --git a/src/Undefined/ai/prompts/builder.py b/src/Undefined/ai/prompts/builder.py index d4c54dbc..84e483c7 100644 --- a/src/Undefined/ai/prompts/builder.py +++ b/src/Undefined/ai/prompts/builder.py @@ -30,6 +30,7 @@ build_model_config_info, select_system_prompt_path, ) +from Undefined.ai.prompts.system_info import build_prompt_system_info logger = logging.getLogger(__name__) @@ -520,6 +521,20 @@ async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: # 记忆/认知/历史等上下文统一排在主 system 之后、当前消息之前 messages.extend(deferred_messages) + if self._runtime_config_getter is not None: + try: + runtime_config = self._runtime_config_getter() + system_info_config = getattr(runtime_config, "prompt_system_info", None) + system_info = build_prompt_system_info(system_info_config) + if system_info: + messages.append({"role": "system", "content": system_info}) + logger.debug( + "[Prompt] 已注入当前系统信息,长度=%s", + len(system_info), + ) + except Exception as exc: + logger.debug("读取当前系统信息失败: %s", exc) + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") messages.append( { diff --git a/src/Undefined/ai/prompts/system_info.py b/src/Undefined/ai/prompts/system_info.py new file mode 100644 index 00000000..f9e2b0dc --- /dev/null +++ b/src/Undefined/ai/prompts/system_info.py @@ -0,0 +1,322 @@ +"""跨平台系统信息采集与 Prompt 格式化。""" + +from __future__ import annotations + +import ipaddress +import os +import platform +import socket +import time +from collections.abc import Iterable +from datetime import datetime +from typing import Any + +from Undefined import __version__ + +try: + import psutil +except Exception: # pragma: no cover - 仅在依赖缺失或平台导入失败时触发 + psutil = None + +_BYTES_PER_GIB = 1024**3 + + +def _safe_call(func: Any, *args: Any, **kwargs: Any) -> Any | None: + try: + return func(*args, **kwargs) + except Exception: + return None + + +def _format_bytes(value: float | int | None) -> str: + if value is None: + return "未知" + gib = float(value) / _BYTES_PER_GIB + if gib >= 1: + return f"{gib:.2f} GiB" + mib = float(value) / 1024**2 + return f"{mib:.1f} MiB" + + +def _format_percent(value: Any) -> str: + try: + return f"{float(value):.1f}%" + except (TypeError, ValueError): + return "未知" + + +def _format_duration(seconds: float | int | None) -> str: + if seconds is None: + return "未知" + total_seconds = max(0, int(seconds)) + days, remainder = divmod(total_seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + parts: list[str] = [] + if days: + parts.append(f"{days}天") + if hours: + parts.append(f"{hours}小时") + if minutes: + parts.append(f"{minutes}分钟") + if not parts: + parts.append(f"{seconds}秒") + return "".join(parts) + + +def _format_timestamp(timestamp: float | int | None) -> str: + if timestamp is None: + return "未知" + try: + return datetime.fromtimestamp(float(timestamp)).strftime("%Y-%m-%d %H:%M:%S") + except (OSError, OverflowError, ValueError): + return "未知" + + +def _read_cpu_model() -> str: + model = platform.processor().strip() + if model: + return model + if hasattr(platform, "uname"): + processor = str(platform.uname().processor or "").strip() + if processor: + return processor + return "未知" + + +def _is_loopback_or_empty_address(address: str) -> bool: + text = address.strip() + if not text: + return True + try: + parsed = ipaddress.ip_address(text.split("%", 1)[0]) + except ValueError: + return False + return parsed.is_loopback or parsed.is_unspecified + + +def _iter_network_addresses() -> Iterable[str]: + if psutil is None: + return [] + addrs = _safe_call(psutil.net_if_addrs) + if not isinstance(addrs, dict): + return [] + + lines: list[str] = [] + for name, items in sorted(addrs.items()): + address_parts: list[str] = [] + for item in items: + family = getattr(item, "family", None) + if family not in {socket.AF_INET, socket.AF_INET6}: + continue + address = str(getattr(item, "address", "") or "").strip() + if _is_loopback_or_empty_address(address): + continue + address_parts.append(address) + if address_parts: + lines.append(f"{name}: {', '.join(address_parts)}") + return lines + + +def _iter_disk_lines(max_items: int = 12) -> Iterable[str]: + if psutil is None: + return [] + partitions = _safe_call(psutil.disk_partitions, all=False) + if not isinstance(partitions, list): + return [] + + lines: list[str] = [] + seen_mounts: set[str] = set() + for part in partitions: + mountpoint = str(getattr(part, "mountpoint", "") or "").strip() + if not mountpoint or mountpoint in seen_mounts: + continue + seen_mounts.add(mountpoint) + usage = _safe_call(psutil.disk_usage, mountpoint) + if usage is None: + continue + filesystem = str(getattr(part, "fstype", "") or "未知") + total = _format_bytes(getattr(usage, "total", None)) + used = _format_bytes(getattr(usage, "used", None)) + percent = _format_percent(getattr(usage, "percent", None)) + lines.append(f"{mountpoint} ({filesystem}): {used}/{total}, {percent}") + if len(lines) >= max_items: + break + if len(seen_mounts) > max_items: + lines.append(f"... 其余 {len(seen_mounts) - max_items} 个分区已省略") + return lines + + +def _build_os_line() -> str: + platform_text = platform.platform(aliased=True, terse=False) + machine = platform.machine() or "未知" + return f"- OS: {platform_text}; 架构: {machine}" + + +def _build_runtime_line() -> str: + python_version = platform.python_version() + return f"- Runtime: Python {python_version}; Undefined {__version__}" + + +def _build_host_line() -> str: + hostname = platform.node() or socket.gethostname() or "未知" + return f"- Host: {hostname}" + + +def _build_cpu_line() -> str: + logical = os.cpu_count() + physical = None + if psutil is not None: + physical = _safe_call(psutil.cpu_count, logical=False) + logical_psutil = _safe_call(psutil.cpu_count, logical=True) + if isinstance(logical_psutil, int) and logical_psutil > 0: + logical = logical_psutil + model = _read_cpu_model() + core_text = f"逻辑核 {logical or '未知'}" + if isinstance(physical, int) and physical > 0: + core_text = f"物理核 {physical}, {core_text}" + return f"- CPU: {model}; {core_text}" + + +def _build_cpu_usage_line() -> str | None: + if psutil is None: + return None + usage = _safe_call(psutil.cpu_percent, interval=None) + if usage is None: + return None + return f"- CPU 使用率: {_format_percent(usage)}" + + +def _build_memory_line() -> str | None: + if psutil is None: + return None + mem = _safe_call(psutil.virtual_memory) + if mem is None: + return None + return ( + "- 内存: " + f"{_format_bytes(getattr(mem, 'used', None))}/" + f"{_format_bytes(getattr(mem, 'total', None))}, " + f"{_format_percent(getattr(mem, 'percent', None))}" + ) + + +def _build_swap_line() -> str | None: + if psutil is None: + return None + swap = _safe_call(psutil.swap_memory) + if swap is None: + return None + total = getattr(swap, "total", 0) or 0 + if int(total) <= 0: + return "- Swap: 未配置" + return ( + "- Swap: " + f"{_format_bytes(getattr(swap, 'used', None))}/" + f"{_format_bytes(total)}, " + f"{_format_percent(getattr(swap, 'percent', None))}" + ) + + +def _build_disks_line() -> str | None: + lines = list(_iter_disk_lines()) + if not lines: + return None + return "- 磁盘:\n - " + "\n - ".join(lines) + + +def _build_network_line() -> str | None: + address_lines = list(_iter_network_addresses()) + io_text = "" + if psutil is not None: + counters = _safe_call(psutil.net_io_counters) + if counters is not None: + sent = _format_bytes(getattr(counters, "bytes_sent", None)) + recv = _format_bytes(getattr(counters, "bytes_recv", None)) + io_text = f"收发累计: sent={sent}, recv={recv}" + if not address_lines and not io_text: + return None + parts: list[str] = [] + if io_text: + parts.append(io_text) + if address_lines: + parts.append("地址:\n - " + "\n - ".join(address_lines)) + return "- 网络: " + "\n ".join(parts) + + +def _build_process_line() -> str | None: + if psutil is None: + pid = os.getpid() + return f"- 进程: PID {pid}" + process = _safe_call(psutil.Process) + if process is None: + return None + pid = os.getpid() + create_time = _safe_call(process.create_time) + runtime = ( + time.time() - float(create_time) if isinstance(create_time, float) else None + ) + memory_info = _safe_call(process.memory_info) + rss = getattr(memory_info, "rss", None) if memory_info is not None else None + cpu_percent = _safe_call(process.cpu_percent, interval=None) + parts = [ + f"PID {pid}", + f"启动于 {_format_timestamp(create_time)}", + f"运行 {_format_duration(runtime)}", + ] + if rss is not None: + parts.append(f"RSS {_format_bytes(rss)}") + if cpu_percent is not None: + parts.append(f"CPU {_format_percent(cpu_percent)}") + return "- 进程: " + "; ".join(parts) + + +def _build_uptime_line() -> str | None: + if psutil is None: + return None + boot_time = _safe_call(psutil.boot_time) + if not isinstance(boot_time, (int, float)): + return None + uptime = time.time() - float(boot_time) + return f"- 系统启动: {_format_timestamp(boot_time)}; 已运行: {_format_duration(uptime)}" + + +def build_prompt_system_info(config: Any) -> str: + """按配置采集并格式化当前系统信息。""" + + if not bool(getattr(config, "enabled", False)): + return "" + + parts: list[str] = ["【当前系统信息】"] + + builders: list[tuple[str, Any]] = [ + ("show_os", _build_os_line), + ("show_runtime", _build_runtime_line), + ("show_host", _build_host_line), + ("show_cpu", _build_cpu_line), + ("show_cpu_usage", _build_cpu_usage_line), + ("show_memory", _build_memory_line), + ("show_swap", _build_swap_line), + ("show_disks", _build_disks_line), + ("show_network", _build_network_line), + ("show_process", _build_process_line), + ("show_uptime", _build_uptime_line), + ] + for flag_name, builder in builders: + if not bool(getattr(config, flag_name, True)): + continue + line = _safe_call(builder) + if isinstance(line, str) and line.strip(): + parts.append(line.strip()) + + if len(parts) == 1: + return "" + parts.append("") + parts.append( + "注意:以上是当前运行主机的系统信息,可能随时间变化;" + "回答系统状态、资源占用、运行环境相关问题时以此为准。" + ) + return "\n".join(parts) + + +__all__ = ["build_prompt_system_info"] diff --git a/src/Undefined/config/__init__.py b/src/Undefined/config/__init__.py index 4fe960fb..152ef846 100644 --- a/src/Undefined/config/__init__.py +++ b/src/Undefined/config/__init__.py @@ -14,6 +14,7 @@ MessageBatcherConfig, ModelPool, ModelPoolEntry, + PromptSystemInfoConfig, RenderCacheConfig, RerankModelConfig, SecurityModelConfig, @@ -36,6 +37,7 @@ "ModelPoolEntry", "MemeConfig", "MessageBatcherConfig", + "PromptSystemInfoConfig", "RenderCacheConfig", "get_config", "get_config_manager", diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index aa2e2d6a..f49ede09 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -21,6 +21,7 @@ MemeConfig, MessageBatcherConfig, NagaConfig, + PromptSystemInfoConfig, RenderCacheConfig, RerankModelConfig, SecurityModelConfig, @@ -199,6 +200,8 @@ class Config: memes: MemeConfig # 同 sender 短时多消息合并器 message_batcher: MessageBatcherConfig + # Prompt 系统信息注入 + prompt_system_info: PromptSystemInfoConfig # HTML 渲染结果缓存 render_cache: RenderCacheConfig # Naga 集成 diff --git a/src/Undefined/config/domain_parsers.py b/src/Undefined/config/domain_parsers.py index 9d8b35e5..1e392e5f 100644 --- a/src/Undefined/config/domain_parsers.py +++ b/src/Undefined/config/domain_parsers.py @@ -19,6 +19,7 @@ MemeConfig, MessageBatcherConfig, NagaConfig, + PromptSystemInfoConfig, RenderCacheConfig, ) @@ -232,6 +233,28 @@ def _parse_message_batcher_config(data: dict[str, Any]) -> MessageBatcherConfig: ) +def _parse_prompt_system_info_config(data: dict[str, Any]) -> PromptSystemInfoConfig: + prompt_raw = data.get("prompt", {}) + prompt_section = prompt_raw if isinstance(prompt_raw, dict) else {} + system_info_raw = prompt_section.get("system_info", {}) + section = system_info_raw if isinstance(system_info_raw, dict) else {} + + return PromptSystemInfoConfig( + enabled=_coerce_bool(section.get("enabled"), False), + show_os=_coerce_bool(section.get("show_os"), True), + show_runtime=_coerce_bool(section.get("show_runtime"), True), + show_host=_coerce_bool(section.get("show_host"), True), + show_cpu=_coerce_bool(section.get("show_cpu"), True), + show_cpu_usage=_coerce_bool(section.get("show_cpu_usage"), True), + show_memory=_coerce_bool(section.get("show_memory"), True), + show_swap=_coerce_bool(section.get("show_swap"), True), + show_disks=_coerce_bool(section.get("show_disks"), True), + show_network=_coerce_bool(section.get("show_network"), True), + show_process=_coerce_bool(section.get("show_process"), True), + show_uptime=_coerce_bool(section.get("show_uptime"), True), + ) + + def _parse_render_cache_config(data: dict[str, Any]) -> RenderCacheConfig: """解析 ``[render.cache]`` 段,落到 :class:`RenderCacheConfig`。 diff --git a/src/Undefined/config/load_sections/domains.py b/src/Undefined/config/load_sections/domains.py index ab4d8075..1ecde5ab 100644 --- a/src/Undefined/config/load_sections/domains.py +++ b/src/Undefined/config/load_sections/domains.py @@ -14,6 +14,7 @@ _parse_memes_config, _parse_message_batcher_config, _parse_naga_config, + _parse_prompt_system_info_config, _parse_render_cache_config, ) from ..model_parsers import ( @@ -36,6 +37,7 @@ def load_domains( cognitive = _parse_cognitive_config(data) memes = _parse_memes_config(data) message_batcher = _parse_message_batcher_config(data) + prompt_system_info = _parse_prompt_system_info_config(data) render_cache = _parse_render_cache_config(data) naga = _parse_naga_config(data) models_image_gen = _parse_image_gen_model_config(data) @@ -51,6 +53,7 @@ def load_domains( "cognitive": cognitive, "memes": memes, "message_batcher": message_batcher, + "prompt_system_info": prompt_system_info, "render_cache": render_cache, "naga": naga, "image_gen": image_gen, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index e97494a7..883b6730 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -397,6 +397,24 @@ class MessageBatcherConfig: allow_cancel_after_send: bool = False +@dataclass +class PromptSystemInfoConfig: + """Prompt 中的运行系统信息注入配置。""" + + enabled: bool = False + show_os: bool = True + show_runtime: bool = True + show_host: bool = True + show_cpu: bool = True + show_cpu_usage: bool = True + show_memory: bool = True + show_swap: bool = True + show_disks: bool = True + show_network: bool = True + show_process: bool = True + show_uptime: bool = True + + @dataclass class RenderCacheConfig: """HTML 渲染结果缓存配置。 diff --git a/tests/test_config_api.py b/tests/test_config_api.py index 2e97cda6..284976ce 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -209,3 +209,31 @@ def test_render_config_invalid_values_fallback_to_auto(tmp_path: Path) -> None: """, ) assert cfg.render_browser_max_concurrency == 0 + + +def test_prompt_system_info_defaults_to_disabled(tmp_path: Path) -> None: + cfg = _load_config(tmp_path / "config.toml", "") + + assert cfg.prompt_system_info.enabled is False + assert cfg.prompt_system_info.show_os is True + assert cfg.prompt_system_info.show_network is True + assert cfg.prompt_system_info.show_process is True + + +def test_prompt_system_info_custom_switches(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[prompt.system_info] +enabled = true +show_network = false +show_disks = false +show_process = false +""", + ) + + assert cfg.prompt_system_info.enabled is True + assert cfg.prompt_system_info.show_os is True + assert cfg.prompt_system_info.show_network is False + assert cfg.prompt_system_info.show_disks is False + assert cfg.prompt_system_info.show_process is False diff --git a/tests/test_prompt_builder_message_order.py b/tests/test_prompt_builder_message_order.py index 1886d063..a2550ead 100644 --- a/tests/test_prompt_builder_message_order.py +++ b/tests/test_prompt_builder_message_order.py @@ -133,6 +133,38 @@ def _make_builder_with_cognitive_service(cognitive_service: Any) -> PromptBuilde ) +def _make_builder_with_system_info() -> PromptBuilder: + runtime_config = SimpleNamespace( + keyword_reply_enabled=False, + repeat_enabled=False, + inverted_question_enabled=False, + knowledge_enabled=False, + grok_search_enabled=False, + chat_model=SimpleNamespace( + model_name="gpt-test", + pool=SimpleNamespace(enabled=False), + thinking_enabled=False, + reasoning_enabled=False, + ), + vision_model=None, + agent_model=None, + embedding_model=None, + security_model=None, + grok_model=None, + cognitive=SimpleNamespace(enabled=False, recent_end_summaries_inject_k=0), + memes=None, + prompt_system_info=SimpleNamespace(enabled=True), + ) + return PromptBuilder( + bot_qq=0, + memory_storage=None, + end_summary_storage=cast(Any, _FakeEndSummaryStorage()), + runtime_config_getter=lambda: runtime_config, + anthropic_skill_registry=cast(Any, None), + cognitive_service=cast(Any, None), + ) + + @pytest.mark.asyncio async def test_build_messages_places_each_rules_before_dynamic_context( monkeypatch: pytest.MonkeyPatch, @@ -202,6 +234,10 @@ async def _fake_recent_messages( assert positions["memory"] < positions["cognitive"] < positions["summary"] assert positions["summary"] < positions["history"] < positions["time"] assert positions["time"] < positions["current"] + assert all( + "【当前系统信息】" not in str(message.get("content", "")) + for message in messages + ) runtime_config_message = next( str(message.get("content", "")) @@ -332,6 +368,73 @@ async def _fake_recent_messages( assert "【当前输入批次】" in str(messages[-1].get("content", "")) +@pytest.mark.asyncio +async def test_build_messages_places_system_info_before_current_time( + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = _make_builder_with_system_info() + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "固定规则" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + monkeypatch.setattr( + "Undefined.ai.prompts.builder.build_prompt_system_info", + lambda _config: "【当前系统信息】\n- Host: bot-host", + ) + + async def _fake_recent_messages( + chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "chat_name": "研发群", + "timestamp": "2026-04-03 10:01:00", + "message": "上一条消息", + "attachments": [], + "role": "member", + "title": "", + } + ] + + messages = await builder.build_messages( + '\n看下系统状态\n', + get_recent_messages_callback=_fake_recent_messages, + extra_context={ + "group_id": 20001, + "sender_id": 10001, + "sender_name": "测试用户", + "group_name": "研发群", + "request_type": "group", + }, + ) + + labels = [ + "【历史消息存档】", + "【当前系统信息】", + "【当前时间】", + "【当前输入批次】", + ] + positions = [ + next( + idx + for idx, message in enumerate(messages) + if label in str(message.get("content", "")) + ) + for label in labels + ] + assert positions == sorted(positions) + + @pytest.mark.asyncio async def test_build_messages_keeps_current_input_batch_as_last_item( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_prompt_system_info.py b/tests/test_prompt_system_info.py new file mode 100644 index 00000000..7b0346a4 --- /dev/null +++ b/tests/test_prompt_system_info.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import socket +from types import SimpleNamespace +from typing import Any + +from Undefined.ai.prompts import system_info +from Undefined.ai.prompts.system_info import build_prompt_system_info + + +def _enabled_config(**overrides: bool) -> SimpleNamespace: + values: dict[str, bool] = { + "enabled": True, + "show_os": True, + "show_runtime": True, + "show_host": True, + "show_cpu": True, + "show_cpu_usage": True, + "show_memory": True, + "show_swap": True, + "show_disks": True, + "show_network": True, + "show_process": True, + "show_uptime": True, + } + values.update(overrides) + return SimpleNamespace(**values) + + +class _FakeProcess: + def create_time(self) -> float: + return 999_940.0 + + def memory_info(self) -> SimpleNamespace: + return SimpleNamespace(rss=256 * 1024 * 1024) + + def cpu_percent(self, interval: float | None = None) -> float: + _ = interval + return 1.5 + + +class _FakePsutil: + AF_INET = socket.AF_INET + AF_INET6 = socket.AF_INET6 + + def cpu_count(self, logical: bool = True) -> int: + return 16 if logical else 8 + + def cpu_percent(self, interval: float | None = None) -> float: + _ = interval + return 12.3 + + def virtual_memory(self) -> SimpleNamespace: + return SimpleNamespace( + total=32 * 1024**3, + used=8 * 1024**3, + percent=25.0, + ) + + def swap_memory(self) -> SimpleNamespace: + return SimpleNamespace( + total=4 * 1024**3, + used=1024**3, + percent=25.0, + ) + + def disk_partitions(self, all: bool = False) -> list[SimpleNamespace]: + _ = all + return [ + SimpleNamespace(mountpoint="/", fstype="ext4"), + SimpleNamespace(mountpoint="/data", fstype="xfs"), + ] + + def disk_usage(self, mountpoint: str) -> SimpleNamespace: + _ = mountpoint + return SimpleNamespace( + total=100 * 1024**3, + used=40 * 1024**3, + percent=40.0, + ) + + def net_if_addrs(self) -> dict[str, list[SimpleNamespace]]: + return { + "lo": [SimpleNamespace(family=socket.AF_INET, address="127.0.0.1")], + "eth0": [ + SimpleNamespace(family=socket.AF_INET, address="192.168.1.20"), + SimpleNamespace(family=socket.AF_INET6, address="fe80::1%eth0"), + ], + } + + def net_io_counters(self) -> SimpleNamespace: + return SimpleNamespace(bytes_sent=1024**3, bytes_recv=2 * 1024**3) + + def Process(self) -> _FakeProcess: + return _FakeProcess() + + def boot_time(self) -> float: + return 900_000.0 + + +def test_build_prompt_system_info_returns_empty_when_disabled() -> None: + assert build_prompt_system_info(SimpleNamespace(enabled=False)) == "" + assert build_prompt_system_info(None) == "" + + +def test_build_prompt_system_info_includes_enabled_sections( + monkeypatch: Any, +) -> None: + fake_psutil = _FakePsutil() + monkeypatch.setattr(system_info, "psutil", fake_psutil) + monkeypatch.setattr( + system_info, "_build_os_line", lambda: "- OS: Linux-6; 架构: x86_64" + ) + monkeypatch.setattr( + system_info, + "_build_runtime_line", + lambda: "- Runtime: Python 3.12.0; Undefined test", + ) + monkeypatch.setattr(system_info, "_build_host_line", lambda: "- Host: bot-host") + monkeypatch.setattr(system_info, "_read_cpu_model", lambda: "Test CPU") + + text = build_prompt_system_info(_enabled_config()) + + assert "【当前系统信息】" in text + assert "- OS: Linux-6; 架构: x86_64" in text + assert "- Runtime: Python 3.12.0; Undefined" in text + assert "- Host: bot-host" in text + assert "- CPU: Test CPU; 物理核 8, 逻辑核 16" in text + assert "- CPU 使用率: 12.3%" in text + assert "- 内存: 8.00 GiB/32.00 GiB, 25.0%" in text + assert "- Swap: 1.00 GiB/4.00 GiB, 25.0%" in text + assert "/ (ext4): 40.00 GiB/100.00 GiB, 40.0%" in text + assert "eth0: 192.168.1.20, fe80::1%eth0" in text + assert "127.0.0.1" not in text + assert "- 进程: PID" in text + assert "RSS 256.0 MiB" in text + assert "- 系统启动:" in text + + +def test_build_prompt_system_info_respects_section_switches( + monkeypatch: Any, +) -> None: + monkeypatch.setattr(system_info, "psutil", _FakePsutil()) + monkeypatch.setattr( + system_info, "_build_os_line", lambda: "- OS: Linux-6; 架构: x86_64" + ) + monkeypatch.setattr( + system_info, + "_build_runtime_line", + lambda: "- Runtime: Python 3.12.0; Undefined test", + ) + monkeypatch.setattr(system_info, "_build_host_line", lambda: "- Host: bot-host") + monkeypatch.setattr(system_info, "_read_cpu_model", lambda: "Test CPU") + + text = build_prompt_system_info( + _enabled_config(show_network=False, show_disks=False, show_process=False) + ) + + assert "【当前系统信息】" in text + assert "- 网络:" not in text + assert "- 磁盘:" not in text + assert "- 进程:" not in text + assert "- CPU:" in text + + +def test_build_prompt_system_info_skips_failed_sections(monkeypatch: Any) -> None: + class BrokenPsutil(_FakePsutil): + def virtual_memory(self) -> SimpleNamespace: + raise RuntimeError("boom") + + def disk_partitions(self, all: bool = False) -> list[SimpleNamespace]: + _ = all + raise RuntimeError("boom") + + monkeypatch.setattr(system_info, "psutil", BrokenPsutil()) + monkeypatch.setattr( + system_info, "_build_os_line", lambda: "- OS: Linux-6; 架构: x86_64" + ) + monkeypatch.setattr( + system_info, + "_build_runtime_line", + lambda: "- Runtime: Python 3.12.0; Undefined test", + ) + monkeypatch.setattr(system_info, "_build_host_line", lambda: "- Host: bot-host") + monkeypatch.setattr(system_info, "_read_cpu_model", lambda: "Test CPU") + + text = build_prompt_system_info(_enabled_config()) + + assert "【当前系统信息】" in text + assert "- 内存:" not in text + assert "- 磁盘:" not in text + assert "- CPU:" in text From 8f589a6420c9ce05e573cbfcf9151c240c83e141 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Wed, 24 Jun 2026 22:07:03 +0800 Subject: [PATCH 2/4] chore(version): bump version to 3.6.4 --- apps/undefined-chat/package-lock.json | 4 ++-- apps/undefined-chat/package.json | 2 +- apps/undefined-chat/src-tauri/Cargo.lock | 2 +- apps/undefined-chat/src-tauri/Cargo.toml | 2 +- apps/undefined-chat/src-tauri/tauri.conf.json | 2 +- apps/undefined-console/package-lock.json | 4 ++-- apps/undefined-console/package.json | 2 +- apps/undefined-console/src-tauri/Cargo.lock | 2 +- apps/undefined-console/src-tauri/Cargo.toml | 2 +- apps/undefined-console/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 2 +- src/Undefined/__init__.py | 2 +- uv.lock | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index 7a8047e4..ff155826 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.6.3", + "version": "3.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.6.3", + "version": "3.6.4", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json index ffe8de95..f0cd15f6 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,7 +1,7 @@ { "name": "undefined-chat", "private": true, - "version": "3.6.3", + "version": "3.6.4", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index 1b574853..d46717ae 100644 --- a/apps/undefined-chat/src-tauri/Cargo.lock +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "undefined_chat" -version = "3.6.3" +version = "3.6.4" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index dd288106..ec8bc4b6 100644 --- a/apps/undefined-chat/src-tauri/Cargo.toml +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_chat" -version = "3.6.3" +version = "3.6.4" description = "Undefined native chat client" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json index c4c04889..0b047d5c 100644 --- a/apps/undefined-chat/src-tauri/tauri.conf.json +++ b/apps/undefined-chat/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Chat", - "version": "3.6.3", + "version": "3.6.4", "identifier": "com.undefined.chat", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 95c14004..d57c9b11 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.6.3", + "version": "3.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.6.3", + "version": "3.6.4", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 660a2fa3..58bd205d 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.6.3", + "version": "3.6.4", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index 23fff594..dc5f47af 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.6.3" +version = "3.6.4" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index e373c291..0ef6cf22 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.6.3" +version = "3.6.4" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 546eabf7..d926f83f 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.6.3", + "version": "3.6.4", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/pyproject.toml b/pyproject.toml index 9ca5f7f1..6838cc50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.6.3" +version = "3.6.4" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 8a86b5da..fa77cca9 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__ = "3.6.3" +__version__ = "3.6.4" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/uv.lock b/uv.lock index 3c600da3..9e1ed878 100644 --- a/uv.lock +++ b/uv.lock @@ -4626,7 +4626,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.6.3" +version = "3.6.4" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From f2f29086d150e89d8f009c0aa14da7f0e74a0389 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Wed, 24 Jun 2026 22:10:08 +0800 Subject: [PATCH 3/4] docs(changelog): add 3.6.4 notes Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc3e165..483200dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v3.6.4 可选系统信息注入与版本同步 + +本版本新增默认关闭的系统信息 Prompt 注入能力,并完成 3.6.4 版本号同步。 + +- 新增 `[prompt.system_info]`:可按 OS、Runtime、主机名、CPU、内存、Swap、磁盘、网络、进程和 uptime 控制展示,采集失败时跳过单项,不影响 Prompt 构建。 +- 同步配置模板、配置文档、Prompt 顺序说明和回归测试。 + +--- + ## v3.6.3 合并转发快照、附件 UID 分析与 Release 下载说明 本版本聚焦合并转发在协议端不可二次读取时的可用性:收到消息时先把可访问的转发树按会话保存为本地快照,后续工具读取优先复用快照,避免内层转发过期或回源失败导致 AI 看不到内容。同时补齐文件分析 Agent 对内部附件 UID 的直接解析能力,并澄清 Release 产物的下载选择。 From ac5576aa7d4e56154c8933438a111f43333174c5 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Wed, 24 Jun 2026 22:48:45 +0800 Subject: [PATCH 4/4] fix(prompt): address system info review feedback Co-authored-by: GPT-5 Codex --- scripts/bump_version.py | 4 +-- src/Undefined/__init__.py | 2 +- src/Undefined/ai/prompts/builder.py | 14 +++++++-- src/Undefined/ai/prompts/system_info.py | 25 ++++++++++++---- tests/test_bump_version_script.py | 4 +-- tests/test_prompt_builder_message_order.py | 33 ++++++++++++++++++++++ tests/test_prompt_system_info.py | 25 ++++++++++++++++ 7 files changed, 93 insertions(+), 14 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index f4de994d..72ee5064 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -263,8 +263,8 @@ def bump_project_versions( ), ( root / "src" / "Undefined" / "__init__.py", - r'^__version__\s*=\s*"[^"]+"', - f'__version__ = "{version}"', + r'^(__version__(?:\s*:\s*str)?\s*=\s*)"[^"]+"', + rf'\g<1>"{version}"', ), ) for path, pattern, replacement in text_targets: diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index fa77cca9..d27df10d 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__ = "3.6.4" +__version__: str = "3.6.4" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/src/Undefined/ai/prompts/builder.py b/src/Undefined/ai/prompts/builder.py index 84e483c7..f2a065ce 100644 --- a/src/Undefined/ai/prompts/builder.py +++ b/src/Undefined/ai/prompts/builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import asyncio from collections import deque from datetime import datetime from typing import Any, Awaitable, Callable, Literal @@ -523,9 +524,9 @@ async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: if self._runtime_config_getter is not None: try: - runtime_config = self._runtime_config_getter() - system_info_config = getattr(runtime_config, "prompt_system_info", None) - system_info = build_prompt_system_info(system_info_config) + system_info = await asyncio.to_thread( + self._build_prompt_system_info_from_runtime_config + ) if system_info: messages.append({"role": "system", "content": system_info}) logger.debug( @@ -553,6 +554,13 @@ async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: ) return messages + def _build_prompt_system_info_from_runtime_config(self) -> str: + if self._runtime_config_getter is None: + return "" + runtime_config = self._runtime_config_getter() + system_info_config = getattr(runtime_config, "prompt_system_info", None) + return build_prompt_system_info(system_info_config) + def _resolve_chat_scope( self, extra_context: dict[str, Any] | None ) -> tuple[Literal["group", "private"], int] | None: diff --git a/src/Undefined/ai/prompts/system_info.py b/src/Undefined/ai/prompts/system_info.py index f9e2b0dc..bccf2fdc 100644 --- a/src/Undefined/ai/prompts/system_info.py +++ b/src/Undefined/ai/prompts/system_info.py @@ -19,6 +19,7 @@ psutil = None _BYTES_PER_GIB = 1024**3 +_CPU_PERCENT_PRIMED = False def _safe_call(func: Any, *args: Any, **kwargs: Any) -> Any | None: @@ -28,6 +29,17 @@ def _safe_call(func: Any, *args: Any, **kwargs: Any) -> Any | None: return None +def _prime_cpu_percent() -> None: + global _CPU_PERCENT_PRIMED + if _CPU_PERCENT_PRIMED or psutil is None: + return + _safe_call(psutil.cpu_percent, interval=None) + _CPU_PERCENT_PRIMED = True + + +_prime_cpu_percent() + + def _format_bytes(value: float | int | None) -> str: if value is None: return "未知" @@ -125,7 +137,7 @@ def _iter_disk_lines(max_items: int = 12) -> Iterable[str]: if not isinstance(partitions, list): return [] - lines: list[str] = [] + valid_lines: list[str] = [] seen_mounts: set[str] = set() for part in partitions: mountpoint = str(getattr(part, "mountpoint", "") or "").strip() @@ -139,11 +151,11 @@ def _iter_disk_lines(max_items: int = 12) -> Iterable[str]: total = _format_bytes(getattr(usage, "total", None)) used = _format_bytes(getattr(usage, "used", None)) percent = _format_percent(getattr(usage, "percent", None)) - lines.append(f"{mountpoint} ({filesystem}): {used}/{total}, {percent}") - if len(lines) >= max_items: - break - if len(seen_mounts) > max_items: - lines.append(f"... 其余 {len(seen_mounts) - max_items} 个分区已省略") + valid_lines.append(f"{mountpoint} ({filesystem}): {used}/{total}, {percent}") + lines = valid_lines[:max_items] + skipped_count = max(0, len(valid_lines) - max_items) + if skipped_count: + lines.append(f"... 其余 {skipped_count} 个分区已省略") return lines @@ -181,6 +193,7 @@ def _build_cpu_line() -> str: def _build_cpu_usage_line() -> str | None: if psutil is None: return None + _prime_cpu_percent() usage = _safe_call(psutil.cpu_percent, interval=None) if usage is None: return None diff --git a/tests/test_bump_version_script.py b/tests/test_bump_version_script.py index 1af96167..a13cf30f 100644 --- a/tests/test_bump_version_script.py +++ b/tests/test_bump_version_script.py @@ -33,7 +33,7 @@ def _write_bump_project(root: Path, *, version: str = "1.2.3") -> None: encoding="utf-8", ) (root / "src" / "Undefined" / "__init__.py").write_text( - f'__version__ = "{version}"\n', + f'__version__: str = "{version}"\n', encoding="utf-8", ) @@ -154,7 +154,7 @@ def test_bump_project_versions_updates_console_and_chat_manifests_and_locks( assert 'version = "2.0.0"' in (tmp_path / "pyproject.toml").read_text( encoding="utf-8" ) - assert '__version__ = "2.0.0"' in ( + assert '__version__: str = "2.0.0"' in ( tmp_path / "src" / "Undefined" / "__init__.py" ).read_text(encoding="utf-8") diff --git a/tests/test_prompt_builder_message_order.py b/tests/test_prompt_builder_message_order.py index a2550ead..a790a1eb 100644 --- a/tests/test_prompt_builder_message_order.py +++ b/tests/test_prompt_builder_message_order.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +import threading from types import SimpleNamespace from typing import Any, cast @@ -435,6 +436,38 @@ async def _fake_recent_messages( assert positions == sorted(positions) +@pytest.mark.asyncio +async def test_build_messages_collects_system_info_off_event_loop_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = _make_builder_with_system_info() + event_loop_thread_id = threading.get_ident() + observed_thread_ids: list[int] = [] + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "" + + def _fake_build_prompt_system_info(_config: Any) -> str: + observed_thread_ids.append(threading.get_ident()) + return "【当前系统信息】\n- Host: bot-host" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + monkeypatch.setattr( + "Undefined.ai.prompts.builder.build_prompt_system_info", + _fake_build_prompt_system_info, + ) + + messages = await builder.build_messages("看下系统状态") + + assert observed_thread_ids + assert all(thread_id != event_loop_thread_id for thread_id in observed_thread_ids) + assert any("【当前系统信息】" in str(item.get("content", "")) for item in messages) + + @pytest.mark.asyncio async def test_build_messages_keeps_current_input_batch_as_last_item( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_prompt_system_info.py b/tests/test_prompt_system_info.py index 7b0346a4..06c70336 100644 --- a/tests/test_prompt_system_info.py +++ b/tests/test_prompt_system_info.py @@ -43,11 +43,15 @@ class _FakePsutil: AF_INET = socket.AF_INET AF_INET6 = socket.AF_INET6 + def __init__(self) -> None: + self.cpu_percent_calls = 0 + def cpu_count(self, logical: bool = True) -> int: return 16 if logical else 8 def cpu_percent(self, interval: float | None = None) -> float: _ = interval + self.cpu_percent_calls += 1 return 12.3 def virtual_memory(self) -> SimpleNamespace: @@ -108,6 +112,7 @@ def test_build_prompt_system_info_includes_enabled_sections( ) -> None: fake_psutil = _FakePsutil() monkeypatch.setattr(system_info, "psutil", fake_psutil) + monkeypatch.setattr(system_info, "_CPU_PERCENT_PRIMED", False) monkeypatch.setattr( system_info, "_build_os_line", lambda: "- OS: Linux-6; 架构: x86_64" ) @@ -135,6 +140,26 @@ def test_build_prompt_system_info_includes_enabled_sections( assert "- 进程: PID" in text assert "RSS 256.0 MiB" in text assert "- 系统启动:" in text + assert fake_psutil.cpu_percent_calls == 2 + + +def test_build_prompt_system_info_truncates_disks_using_valid_partition_count( + monkeypatch: Any, +) -> None: + class ManyDisksPsutil(_FakePsutil): + def disk_partitions(self, all: bool = False) -> list[SimpleNamespace]: + _ = all + return [ + SimpleNamespace(mountpoint=f"/mnt/disk-{index}", fstype="ext4") + for index in range(14) + ] + + monkeypatch.setattr(system_info, "psutil", ManyDisksPsutil()) + + lines = list(system_info._iter_disk_lines(max_items=12)) + + assert len(lines) == 13 + assert lines[-1] == "... 其余 2 个分区已省略" def test_build_prompt_system_info_respects_section_switches(