fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes
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)
This commit is contained in:
1 parent
9fee9a1478
commit
db54921066
6 files changed
+379
-23
No files matched your search
+75
-7
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-7
@@ -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,))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user