From db54921066f932acf90ed89bbedfa8afd13ba19c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 18 Aug 2026 03:09:05 +0000 Subject: [PATCH] fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining-item sweep (audit report §12): - N9: cap prompt file writes (identity/soul/status, 10k chars) to bound persistent-injection surface - F5: run_light_subagent switches to stream_chat + parent cancel_event (mid-stream interrupt); ExpressEmotionTool injects parent event - Q4: busy_timeout=10s on mark_as_triggered (multi-process DB contention) - Q5: malformed reminder_time tasks returned as due (were silently dropped by SQL-level string filter, which is now Python-side authoritative check) - Q7b: load_conversation realpath prefix check (../ traversal defense) - Q8: atomic JSON writes (tmp+os.replace) for index/current/archive - F6: dedicated ws command executor for /new, /reload_prompts - F9: --once hard-exit backstop after soft timeout - PromptManager singleton lock; limit=0 semantics (None = no LIMIT) --- aibox/aibox.py | 82 +++++++- aibox/subsystems/conversation_history.py | 28 ++- aibox/subsystems/db.py | 29 ++- aibox/subsystems/prompt_manager.py | 21 ++- aibox/tests/test_i_fixes.py | 227 +++++++++++++++++++++++ aibox/tools/prompt_tools.py | 15 ++ 6 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 aibox/tests/test_i_fixes.py diff --git a/aibox/aibox.py b/aibox/aibox.py index 07ce313..98be13d 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -26,6 +26,7 @@ import locale import asyncio import websockets from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set import argparse @@ -511,6 +512,12 @@ class TaskScheduler: class ExpressEmotionTool(Tool): + def __init__(self, logger=None, cancel_event: threading.Event = None): + super().__init__(logger) + # I 批(审计 2026-08-18):父代理取消事件注入——情绪子代理此前 + # 用独立 client 且不可中断,父级 cancel 后仍阻塞至请求自然结束 + self.cancel_event = cancel_event + def get_name(self) -> str: return "express_emotion" @@ -594,7 +601,8 @@ class ExpressEmotionTool(Tool): task = "\n".join(task_lines) # 情绪子代理:独立上下文/人格,生成情绪化回应 - return run_light_subagent(role, task, self.config, logger=self.logger, max_tokens=2048) + return run_light_subagent(role, task, self.config, logger=self.logger, + max_tokens=2048, cancel_event=self.cancel_event) except Exception as e: self.logger.error(f"情绪子代理执行异常: {e}") return f"❌ 情绪子代理执行失败: {str(e)}" @@ -606,12 +614,20 @@ class ExpressEmotionTool(Tool): def run_light_subagent(role, task, config, logger=None, model=None, - temperature=None, max_tokens=2048) -> str: + temperature=None, max_tokens=2048, + cancel_event: threading.Event = None) -> str: """轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。 供 express_emotion 情绪子代理使用。 返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。 + + cancel_event(I 批,审计 2026-08-18):父代理取消事件。此前本函数用 + 非流式 chat_completion,父级 cancel 只能等请求自然结束(最长 timeout, + 期间持锁阻塞);改走 stream_chat 后由观察者线程转发 sub_client.cancel() + 强关在途 HTTP 流,用户中断可即时终止。 """ + if cancel_event is not None and cancel_event.is_set(): + return "❌ 子代理已因父级取消而终止" try: # 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗 max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS)) @@ -627,8 +643,33 @@ def run_light_subagent(role, task, config, logger=None, model=None, ] if logger is not None: logger.info(f"子代理执行: model={model or sub_client.model}, role={str(role)[:40]}...") - result = sub_client.chat_completion(messages, temperature=temp, max_tokens=max_tokens) - if result is None or not str(result).strip(): + + _watch_done = threading.Event() + if cancel_event is not None: + def _watch_parent_cancel(): + while not _watch_done.wait(timeout=0.2): + if cancel_event.is_set(): + try: + sub_client.cancel() + except Exception: + pass + return + + threading.Thread(target=_watch_parent_cancel, daemon=True, + name="light-subagent-cancel-watch").start() + + # I 批:改走流式(可被 cancel 强关在途 HTTP),拼接 content 事件 + parts = [] + try: + for event in sub_client.stream_chat(messages, temperature=temp, + max_tokens=max_tokens): + if event.get("type") == "content": + parts.append(event.get("text") or "") + finally: + _watch_done.set() + + result = "".join(parts) + if not str(result).strip(): return "❌ 子代理执行失败:未获得有效响应" return str(result).strip() except Exception as e: @@ -1161,8 +1202,9 @@ class DeepSeekChat: builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger)) # 添加询问用户的工具 + # I 批:ExpressEmotionTool 注入父取消事件(情绪子代理可被中断) builtin_tools.append(AskUserTool(self.logger)) - builtin_tools.append(ExpressEmotionTool(self.logger)) + builtin_tools.append(ExpressEmotionTool(self.logger, cancel_event=self._cancel_event)) # 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具 builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger)) @@ -2052,6 +2094,17 @@ class WebSocketHandler: self.client_sessions = {} self._processing_tasks = {} # websocket -> {asyncio.Task}(N14:集合登记,支持并发多任务) self._shared_skill_manager = None # 共享技能管理器(懒加载,供所有连接复用) + # I 批(审计 2026-08-18):/new、/reload_prompts 等命令专用线程池—— + # 此前 run_in_executor(None) 与 AI think 共用默认池,繁忙时 reset 任务 + # 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理) + self._cmd_executor = ThreadPoolExecutor(max_workers=2, + thread_name_prefix="ws-cmd") + + def shutdown(self): + """I 批:释放命令专用线程池(服务退出时调用)""" + if self._cmd_executor is not None: + self._cmd_executor.shutdown(wait=False) + self._cmd_executor = None def _get_shared_skill_manager(self): """获取共享技能管理器(懒加载一次,供所有连接实例复用,避免重复扫描技能)""" @@ -2425,7 +2478,7 @@ class WebSocketHandler: # 重新初始化提示词管理器(确保最新) self.ai_manager.prompt_manager.reload() - await loop.run_in_executor(None, _do_reset) + await loop.run_in_executor(self._cmd_executor, _do_reset) # 重置该连接对话进行中标记 if session: session["dialogue_in_progress"] = False @@ -2533,7 +2586,7 @@ class WebSocketHandler: return ai.reload_prompts() return self.ai_manager.prompt_manager.reload() - new_count = await loop.run_in_executor(None, _do_reload_prompts) + new_count = await loop.run_in_executor(self._cmd_executor, _do_reload_prompts) await self.send_message(websocket, { "type": "command_result", "content": i18n.get('prompts_reloaded', new_count) @@ -2957,6 +3010,8 @@ class EnhancedAIManager: finally: if self.scheduler: self.scheduler.stop() + # I 批:释放命令专用线程池 + self.ws_handler.shutdown() def run(self): """运行WebSocket服务器""" @@ -3229,6 +3284,19 @@ def handle_once_mode(manager, args) -> bool: def _on_timeout(): state["timed_out"] = True manager.cancel() + # I 批(F9,审计 2026-08-18):软超时兜底——cancel 后若 AI 卡死在 + # 不可中断路径(HTTP 请求空窗、忽略信号的子进程),think 可能永不 + # 返回、进程挂起(退出码 4 永远到不了)。宽限一个超时周期后强杀进程, + # 保证 --once 有硬性结束语义 + def _hard_exit(): + print(f"\n⏰ 一次性对话强制超时({args.once_timeout * 2} 秒),已终止", + file=sys.stderr) + sys.stderr.flush() + os._exit(4) + + hard_timer = threading.Timer(max(1, args.once_timeout), _hard_exit) + hard_timer.daemon = True + hard_timer.start() timer = threading.Timer(max(1, args.once_timeout), _on_timeout) timer.daemon = True timer.start() diff --git a/aibox/subsystems/conversation_history.py b/aibox/subsystems/conversation_history.py index 04fd8cc..9e0d2b4 100644 --- a/aibox/subsystems/conversation_history.py +++ b/aibox/subsystems/conversation_history.py @@ -15,6 +15,7 @@ subsystems.conversation_history - 对话历史管理器 """ import json +import os import time from datetime import datetime from pathlib import Path @@ -68,10 +69,19 @@ class ConversationHistory: return {"conversations": [], "last_id": 0} return {"conversations": [], "last_id": 0} + @staticmethod + def _atomic_write_json(path: Path, data) -> None: + """原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再 + os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏 + 后仅静默置 None,历史内容丢失)""" + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) + def _save_history_index(self): """保存历史索引""" - with open(self.history_file, 'w', encoding='utf-8') as f: - json.dump(self.history_index, f, ensure_ascii=False, indent=2) + self._atomic_write_json(self.history_file, self.history_index) def _load_current_conversation(self): """加载当前的对话(如果有)""" @@ -86,8 +96,7 @@ class ConversationHistory: def save_current_conversation(self): """保存当前对话""" if self.current_conversation: - with open(self.current_file, 'w', encoding='utf-8') as f: - json.dump(self.current_conversation, f, ensure_ascii=False, indent=2) + self._atomic_write_json(self.current_file, self.current_conversation) def start_new_conversation(self, topic: str = None) -> Dict: """开始新对话""" @@ -145,8 +154,7 @@ class ConversationHistory: counter += 1 archive_path = self.archive_dir / archive_filename - with open(archive_path, 'w', encoding='utf-8') as f: - json.dump(self.current_conversation, f, ensure_ascii=False, indent=2) + self._atomic_write_json(archive_path, self.current_conversation) self.history_index["conversations"].append({ "id": self.current_conversation["id"], @@ -350,7 +358,13 @@ class ConversationHistory: if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation: return self.current_conversation - file_path = self.archive_dir / file_name + # I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦 + # 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验 + archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir))) + target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name))) + if not target.startswith(archive_root + os.sep): + return None + file_path = Path(target) if file_path.exists(): with open(file_path, 'r', encoding='utf-8') as f: return json.load(f) diff --git a/aibox/subsystems/db.py b/aibox/subsystems/db.py index f50366f..7b8bbfc 100644 --- a/aibox/subsystems/db.py +++ b/aibox/subsystems/db.py @@ -402,11 +402,14 @@ class MemoDatabase: # 如果传入了 before_time,使用它,否则使用当前时间 compare_time = before_time if before_time else now + # I 批(审计 2026-08-18):去掉 SQL 层 reminder_time <= ? 预过滤—— + # 字符串比较会把格式异常的时间(如 "不是时间")静默排除在 SQL 层, + # Python 二次验证永远看不到它们(Q5:格式异常任务按到期返回)。 + # 时间过滤改由下方 Python 解析做权威判断(本地小库全扫描可忽略)。 query = ''' SELECT * FROM memos WHERE is_completed = 0 AND reminder_time IS NOT NULL - AND reminder_time <= ? -- 严格小于等于比较时间 AND (repeat_end_time IS NULL OR repeat_end_time >= ?) ''' @@ -415,11 +418,13 @@ class MemoDatabase: query += ' ORDER BY reminder_time ASC, priority DESC' - params = [compare_time, compare_time] + params = [compare_time] - if limit: + # I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限", + # 与 None=不限的惯例冲突;改为 None 才不加 LIMIT + if limit is not None: query += ' LIMIT ?' - params.append(int(limit)) + params.append(max(0, int(limit))) cursor.execute(query, params) @@ -440,7 +445,11 @@ class MemoDatabase: # 调试日志 self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}") except (ValueError, TypeError) as e: - self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}") + # I 批(审计 2026-08-18):格式异常此前被静默丢弃—— + # 任务永不执行且用户无感知。改为按"到期"返回并告警, + # 至少保证任务被处理而非永久丢失 + self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败,按到期处理: {e}") + results.append(memo) else: # 没有提醒时间的任务不应该在待执行列表中 self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行") @@ -461,9 +470,11 @@ class MemoDatabase: ''' params = [] - if limit: + # I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限", + # 与 None=不限的惯例冲突;改为 None 才不加 LIMIT + if limit is not None: query += ' LIMIT ?' - params.append(int(limit)) + params.append(max(0, int(limit))) cursor.execute(query, params) @@ -525,6 +536,10 @@ class MemoDatabase: with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row cursor = conn.cursor() + # I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度) + # 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让 + # SQLite 等待锁释放(最长 10s)而不是立即失败 + cursor.execute("PRAGMA busy_timeout = 10000") cursor.execute("BEGIN IMMEDIATE") try: cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,)) diff --git a/aibox/subsystems/prompt_manager.py b/aibox/subsystems/prompt_manager.py index 3de4b0c..21a58cb 100644 --- a/aibox/subsystems/prompt_manager.py +++ b/aibox/subsystems/prompt_manager.py @@ -18,6 +18,7 @@ subsystems.prompt_manager - 提示词管理器 import os import re import shutil +import threading from datetime import datetime from typing import Dict, List, Optional @@ -35,6 +36,7 @@ class PromptManager: """ _instance = None + _instance_lock = threading.Lock() # I 批:并发首例化竞态防护 _prompts = [] _prompts_dir = None @@ -43,12 +45,18 @@ class PromptManager: STATUS_FILE = "100_STATUS.md" SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容 + # N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续 + # 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模, + # 超长内容拒绝写入(工具层有更友好的提示)。 + MAX_CONTENT_LENGTH = 10000 + # 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md) _SKILL_FILE_RE = re.compile(r'^(\d+)_') def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) + with cls._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) return cls._instance def __init__(self): @@ -151,6 +159,9 @@ class PromptManager: def update_identity(self, content: str) -> bool: """更新用户身份信息""" + if len(content or "") > self.MAX_CONTENT_LENGTH: + print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入") + return False identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE) try: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -171,6 +182,9 @@ class PromptManager: def update_status(self, content: str) -> bool: """更新智能体状态""" + if len(content or "") > self.MAX_CONTENT_LENGTH: + print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入") + return False status_path = os.path.join(self._prompts_dir, self.STATUS_FILE) try: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -191,6 +205,9 @@ class PromptManager: def update_soul(self, content: str) -> bool: """更新智能体灵魂定义 - 所有角色定位内容""" + if len(content or "") > self.MAX_CONTENT_LENGTH: + print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入") + return False soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE) try: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") diff --git a/aibox/tests/test_i_fixes.py b/aibox/tests/test_i_fixes.py new file mode 100644 index 0000000..3d14cf5 --- /dev/null +++ b/aibox/tests/test_i_fixes.py @@ -0,0 +1,227 @@ +"""aibox 第四轮审计 Batch I 修复回归测试(2026-08-18,审计报告 §十二) + +覆盖: + - N9:prompt 写入内容长度上限(持久化注入面约束) + - Q7b:load_conversation 越界路径拒绝(realpath 前缀校验) + - F5:run_light_subagent 取消传播(流式可中断 + 父已取消早退) + - Q5:提醒时间格式异常的任务按到期返回(不再静默丢弃) + - Q8:对话文件原子写(无 .tmp 残留、JSON 完整) + - F6:WebSocketHandler 命令专用线程池存在且可释放 + - limit=0 语义修正(None 才不加 LIMIT) + +Q4(busy_timeout 10s)为时序性行为(默认 5s 等待已能通过短竞争), +不做慢速单测;F9(--once 硬超时)为进程级行为,由 CLI 集成面覆盖。 +""" + +import os +import queue +import sqlite3 +import sys +import threading +import time +import types +from pathlib import Path +from unittest import mock + +import pytest + +# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ---- +if "websockets" not in sys.modules: + sys.modules["websockets"] = types.ModuleType("websockets") + +import aibox # noqa: E402 +from aibox import run_light_subagent # noqa: E402 + +from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402 +from subsystems.prompt_manager import PromptManager # noqa: E402 +from subsystems.conversation_history import ConversationHistory # noqa: E402 +from tools.prompt_tools import ( # noqa: E402 + UpdateIdentityTool, + UpdateSoulTool, + SaveMemoryAndEndConversationTool, +) + + +# ==================== N9:prompt 写入长度上限 ==================== + +class TestN9PromptContentCap: + def test_update_identity_oversized_rejected(self, tmp_path, monkeypatch): + """manager 层兜底:超长身份内容拒绝写入""" + pm = PromptManager() + with mock.patch.object(pm, "_prompts_dir", str(tmp_path)): + assert pm.update_identity("x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) is False + assert not (tmp_path / pm.IDENTITY_FILE).exists() + + def test_update_identity_tool_friendly_error(self, tmp_path): + """工具层:超长内容返回明确提示而非写入""" + pm = PromptManager() + with mock.patch.object(pm, "_prompts_dir", str(tmp_path)): + tool = UpdateIdentityTool(pm, None) + r = tool.execute(content="x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) + assert "内容过长" in r + + def test_update_soul_tool_friendly_error(self, tmp_path): + pm = PromptManager() + with mock.patch.object(pm, "_prompts_dir", str(tmp_path)): + tool = UpdateSoulTool(pm, None) + r = tool.execute(content="y" * (PromptManager.MAX_CONTENT_LENGTH + 1)) + assert "内容过长" in r + + def test_save_memory_status_oversized_rejected(self, tmp_path): + """save_memory_and_end_conversation:状态内容超长拒绝""" + pm = PromptManager() + with mock.patch.object(pm, "_prompts_dir", str(tmp_path)): + tool = SaveMemoryAndEndConversationTool( + exit_callback=lambda *a: None, + memory_db=None, prompt_manager=pm, logger=None) + r = tool.execute(summary="这是一段足够长的对话总结" * 20, + current_status="x" * PromptManager.MAX_CONTENT_LENGTH) + assert "状态内容过长" in r + + def test_normal_content_still_written(self, tmp_path): + pm = PromptManager() + with mock.patch.object(pm, "_prompts_dir", str(tmp_path)): + assert pm.update_identity("正常身份内容") is True + assert "正常身份内容" in (tmp_path / pm.IDENTITY_FILE).read_text(encoding="utf-8") + + +# ==================== Q7b:load_conversation 路径校验 ==================== + +class TestQ7bLoadConversationBoundary: + def test_traversal_rejected(self, tmp_path): + h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False) + secret = tmp_path / "secret.json" + secret.write_text('{"leak": true}', encoding="utf-8") + assert h.load_conversation("../secret.json") is None + + def test_normal_file_still_loads(self, tmp_path): + h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False) + h.current_conversation = { + "id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None, + "topic": "t", "messages": [{"role": "user", "content": "hi"}], + "tool_calls": [], "stats": {}, + } + h.save_current_conversation() + h.archive_current_conversation() + archives = list(h.archive_dir.glob("conversation_*.json")) + assert archives + loaded = h.load_conversation(archives[0].name) + assert loaded is not None and loaded["id"] == "1" + + +# ==================== F5:run_light_subagent 取消传播 ==================== + +class _BlockingLightClient: + """流式产出首段后卡住,直到 cancel() 才中断的伪 LLM 客户端""" + + model = "fake-model" + default_temperature = None + + def __init__(self): + self.cancelled = threading.Event() + + def cancel(self): + self.cancelled.set() + + def stream_chat(self, messages, **kwargs): + yield {"type": "content", "text": "开头"} + while not self.cancelled.wait(timeout=0.1): + pass + raise RuntimeError("连接已中断(cancel)") + + +class TestF5LightSubagentCancel: + def test_cancelled_before_start_returns_early(self): + """父事件已置位 → 直接返回取消提示,不创建客户端""" + parent = threading.Event() + parent.set() + with mock.patch("aibox.create_llm_client") as mk: + r = run_light_subagent("r", "t", None, cancel_event=parent) + assert "取消" in r + mk.assert_not_called() + + def test_cancel_during_stream_aborts(self): + """流式中父级取消 → 观察者线程 cancel 客户端 → 立即中断返回""" + parent = threading.Event() + fake = _BlockingLightClient() + with mock.patch("aibox.create_llm_client", return_value=fake): + result_holder = {} + + def _run(): + result_holder["r"] = run_light_subagent( + "r", "t", None, cancel_event=parent) + + th = threading.Thread(target=_run, daemon=True) + th.start() + time.sleep(0.5) # 等待首段产出、观察者就绪 + parent.set() + th.join(timeout=5) + assert not th.is_alive() # 未挂死 + assert "失败" in result_holder.get("r", "") + + def test_express_emotion_registered_with_parent_event(self): + """DeepSeekChat 注册 ExpressEmotionTool 时注入自身取消事件""" + from test_once_mode import _make_ai + ai = _make_ai() + tool = next((t for t in ai.tool_registry.get_all_tools() + if t.get_name() == "express_emotion"), None) + assert tool is not None + assert tool.cancel_event is ai._cancel_event + + +# ==================== Q5:格式异常任务不丢弃 ==================== + +class TestQ5MalformedReminderTime: + def test_malformed_time_task_returned_as_due(self, tmp_path): + db = MemoDatabase(db_path=str(tmp_path / "m.db")) + mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间") + pending = db.get_pending_memos(before_time="2026-08-18 00:00:00") + assert any(m["id"] == mid for m in pending) + + def test_normal_pending_still_filtered(self, tmp_path): + db = MemoDatabase(db_path=str(tmp_path / "m.db")) + db.add_memo(title="未来任务", content="c", reminder_time="2099-01-01 00:00:00") + pending = db.get_pending_memos(before_time="2026-08-18 00:00:00") + assert pending == [] + + +# ==================== Q8:对话文件原子写 ==================== + +class TestQ8AtomicWrites: + def test_no_tmp_leftover_and_valid_json(self, tmp_path): + h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False) + h.current_conversation = { + "id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None, + "topic": "t", "messages": [], "tool_calls": [], "stats": {}, + } + h.save_current_conversation() + assert h.current_file.exists() # 文件名可能被用户配置覆盖,用实例属性 + import json + json.loads(h.current_file.read_text(encoding="utf-8")) # 不抛即合法 JSON + h.archive_current_conversation() # 归档后 current 文件按设计移除 + assert list(Path(tmp_path).rglob("*.tmp")) == [] + + +# ==================== F6:WS 命令专用线程池 ==================== + +class TestF6CmdExecutor: + def test_handler_has_dedicated_executor(self): + from aibox import WebSocketHandler + h = WebSocketHandler(ai_manager=None) + assert h._cmd_executor is not None + h.shutdown() + assert h._cmd_executor is None + + +# ==================== limit=0 语义 ==================== + +class TestLimitZeroSemantics: + def test_pending_memos_limit_zero_returns_none(self, tmp_path): + db = MemoDatabase(db_path=str(tmp_path / "m.db")) + db.add_memo(title="t", content="c", reminder_time="2026-01-01 00:00:00") + assert db.get_pending_memos(limit=0, before_time="2026-08-18 00:00:00") == [] + + def test_immediate_memos_limit_zero_returns_none(self, tmp_path): + db = MemoDatabase(db_path=str(tmp_path / "m.db")) + db.add_memo(title="t", content="c", is_immediate=True) + assert db.get_immediate_memos(limit=0) == [] diff --git a/aibox/tools/prompt_tools.py b/aibox/tools/prompt_tools.py index 7527f1a..48594c9 100644 --- a/aibox/tools/prompt_tools.py +++ b/aibox/tools/prompt_tools.py @@ -56,6 +56,11 @@ class UpdateIdentityTool(Tool): if not content: return "❌ 需要提供身份信息内容" + # N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底) + if len(content) > PromptManager.MAX_CONTENT_LENGTH: + return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符)," + f"身份文件作为系统提示词加载,请精简后重试") + if not confirm: return "❌ 身份更新已取消,如需更新请设置 confirm=true" @@ -119,6 +124,11 @@ class UpdateSoulTool(Tool): if not content: return "❌ 需要提供智能体自我认识定义内容" + # N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底) + if len(content) > PromptManager.MAX_CONTENT_LENGTH: + return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符)," + f"灵魂文件作为系统提示词加载,请精简后重试") + if not confirm: return "❌ 自我认识更新已取消,如需更新请设置 confirm=true" @@ -223,6 +233,11 @@ class SaveMemoryAndEndConversationTool(Tool): status_content += f"【下一步计划】\n{next_goals}\n\n" status_content += f"【对话总结】\n{summary}" + # N9:状态文件作为系统提示词加载,超长拒绝写入(约束注入规模) + if len(status_content) > PromptManager.MAX_CONTENT_LENGTH: + return (f"❌ 状态内容过长({len(status_content)} > " + f"{PromptManager.MAX_CONTENT_LENGTH} 字符),请精简 current_status/summary 后重试") + # 更新状态文件 status_updated = self.prompt_manager.update_status(status_content)