第五轮审计(J1~J16)全量修复 + 子代理 max_turns 钳制: - 子代理 max_turns 钳制(0/None 回退默认 10,负数钳制 1)+ 14 项回归测试 - J1 任务系统投毒:add_memo 入库校验(时间/间隔/类型)、调度器预检与触发事务 防御、展示路径兜底、register 移入 try、add_user_task 补 repeat_end_time 透传 - J2/J13 前端 XSS:三页面 marked 输出经 DOMPurify 净化、entry1 系统消息/任务 分隔条改 textContent、CDN 脚本补 SRI - J3 FTS 删除换序(先 fts 后内容表,索引不再残留) - J5 原子写推广:新增 common/atomic.py(mkstemp+fsync+os.replace),config/prompt/ history 全部接入;损坏配置备份后回退 - J6 单例初始化加锁(PromptManager/ConfigManager 并发首例不双重加载) - J7 broadcast 并发发送 + wait_for 超时回收僵死连接 - J4/J8/J9~J12/J14~J16:验证异常脱敏、交互输入容错、MemoDatabase WAL+busy_timeout、 memo 批量删除、/new 竞态、skills 引号转义、voice_client 清理、死代码移除、 skill_manager 同名键/help 截断
179 lines
8.1 KiB
Python
179 lines
8.1 KiB
Python
"""run_full_subagent 最大轮次(max_turns)行为回归测试(2026-08-19)
|
||
|
||
覆盖 aibox.py run_full_subagent 的轮次限制链路(think_with_limit 补丁):
|
||
- 超限拦截:任务轮 LLM 调用被限制在 max_turns 次,超限递归返回哨兵串
|
||
- 哨兵串文本契约("已达到最大对话轮次,任务结束。")
|
||
- 收尾报告:超限/正常结束均由 original_think 绕过轮次限制生成,作为返回值
|
||
- 入参钳制:max_turns 为 0/None 回退默认 10,负数钳制下限 1
|
||
- 超限不置 conversation_active=False(收尾保留完整上下文,aibox.py 763-765 注释)
|
||
- 收尾失败兜底链:最后一条 assistant 消息 → "子代理未产生有效对话内容。"
|
||
- SubAgentTool.execute 的 max_turns 参数透传(默认 10)
|
||
"""
|
||
|
||
import sys
|
||
import threading
|
||
import types
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
|
||
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
|
||
if "websockets" not in sys.modules:
|
||
sys.modules["websockets"] = types.ModuleType("websockets")
|
||
|
||
from aibox import SubAgentTool, run_full_subagent # noqa: E402
|
||
|
||
# 与 aibox.py think_with_limit 超限返回值一致的哨兵串(文本契约)
|
||
SENTINEL = "已达到最大对话轮次,任务结束。"
|
||
WRAP_UP_MARKER = "【收尾】"
|
||
|
||
|
||
def _make_fake_chat(task_rounds=999, final_answer=None, wrap_report="收尾报告"):
|
||
"""模拟 DeepSeekChat:think_and_respond 的"原始实现"。
|
||
|
||
每次任务轮调用递归一次(模拟 aibox.py:1801 工具调用后递归继续,
|
||
经由补丁后的 think_and_respond 走轮次计数);累计 task_rounds 轮后
|
||
若给出 final_answer 则正常结束(模拟 AI 返回纯文本)。task_rounds
|
||
设为大数且不给 final_answer 时永不自行收敛——只有轮次限制能终止它。
|
||
检测到收尾 prompt(含【收尾】标记)时返回 wrap_report。
|
||
"""
|
||
fake = mock.MagicMock()
|
||
state = {"n": 0, "task_inputs": [], "recurse_returns": []}
|
||
|
||
def original_think(input_text, output_callback=None, is_continuation=False):
|
||
if WRAP_UP_MARKER in input_text:
|
||
return wrap_report
|
||
state["task_inputs"].append(input_text)
|
||
state["n"] += 1
|
||
if final_answer is not None and state["n"] >= task_rounds:
|
||
return final_answer
|
||
ret = fake.think_and_respond("请基于以上工具执行结果继续回答。",
|
||
output_callback, is_continuation=True)
|
||
state["recurse_returns"].append(ret)
|
||
return ret
|
||
|
||
fake.think_and_respond = original_think
|
||
fake.stats = {}
|
||
fake.messages = []
|
||
fake._cancel_event = threading.Event()
|
||
fake.conversation_active = True
|
||
fake._state = state
|
||
return fake
|
||
|
||
|
||
def _run(fake, **kwargs):
|
||
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
|
||
mock.patch("aibox.create_llm_client"), \
|
||
mock.patch("aibox.SubprocessCommandExecutor"):
|
||
return run_full_subagent("角色", "任务", None, **kwargs)
|
||
|
||
|
||
class TestMaxTurnsCutoff:
|
||
def test_task_turns_capped_at_max_turns(self):
|
||
"""永不收敛的任务:任务轮 LLM 调用恰为 max_turns 次,第 max_turns+1 次被拦截"""
|
||
fake = _make_fake_chat(task_rounds=999)
|
||
result = _run(fake, max_turns=3)
|
||
assert len(fake._state["task_inputs"]) == 3
|
||
assert result == "收尾报告"
|
||
|
||
def test_overrun_recursion_returns_sentinel(self):
|
||
"""超限那轮递归返回哨兵串(不调 LLM),并沿递归链上抛"""
|
||
fake = _make_fake_chat(task_rounds=999)
|
||
_run(fake, max_turns=3)
|
||
assert fake._state["recurse_returns"]
|
||
assert all(r == SENTINEL for r in fake._state["recurse_returns"])
|
||
|
||
def test_wrap_up_runs_after_overrun(self):
|
||
"""超限后收尾报告仍被请求(original_think 绕过轮次限制),且为最终返回值"""
|
||
fake = _make_fake_chat(task_rounds=999, wrap_report="最终报告")
|
||
result = _run(fake, max_turns=2)
|
||
assert result == "最终报告"
|
||
|
||
def test_max_turns_zero_clamped_to_default(self):
|
||
"""max_turns=0 → 回退默认 10(对齐 max_tokens 的 0/空回退惯例)"""
|
||
fake = _make_fake_chat(task_rounds=999)
|
||
_run(fake, max_turns=0)
|
||
assert len(fake._state["task_inputs"]) == 10
|
||
|
||
def test_negative_max_turns_clamped_to_one(self):
|
||
"""max_turns 为负 → 钳制下限 1(至少执行一个任务轮,不再首轮即拦截)"""
|
||
fake = _make_fake_chat(task_rounds=999)
|
||
_run(fake, max_turns=-3)
|
||
assert len(fake._state["task_inputs"]) == 1
|
||
|
||
def test_max_turns_none_falls_back_to_default(self):
|
||
"""max_turns=None → 默认 10"""
|
||
fake = _make_fake_chat(task_rounds=999)
|
||
_run(fake, max_turns=None)
|
||
assert len(fake._state["task_inputs"]) == 10
|
||
|
||
def test_overrun_does_not_deactivate_conversation(self):
|
||
"""超限不置 conversation_active=False(否则收尾前上下文被重置清空)"""
|
||
fake = _make_fake_chat(task_rounds=999)
|
||
_run(fake, max_turns=2)
|
||
assert fake.conversation_active is True
|
||
|
||
|
||
class TestNormalCompletion:
|
||
def test_result_is_wrap_up_report_not_final_answer(self):
|
||
"""正常收敛时返回值同样是收尾报告(最终回答不作为工具结果返回)"""
|
||
fake = _make_fake_chat(task_rounds=2, final_answer="最终答案")
|
||
result = _run(fake, max_turns=10)
|
||
assert len(fake._state["task_inputs"]) == 2
|
||
# 第一轮递归的返回值即第 2 轮的最终回答(无哨兵串、无超限拦截)
|
||
assert fake._state["recurse_returns"] == ["最终答案"]
|
||
assert result == "收尾报告"
|
||
|
||
def test_sentinel_never_appears_when_within_limit(self):
|
||
"""未达上限:任何递归都不返回哨兵串"""
|
||
fake = _make_fake_chat(task_rounds=3, final_answer="最终答案")
|
||
_run(fake, max_turns=10)
|
||
assert SENTINEL not in fake._state["recurse_returns"]
|
||
|
||
|
||
class TestWrapUpFallback:
|
||
def test_empty_wrap_up_falls_back_to_last_assistant_message(self):
|
||
"""收尾返回空 → 兜底取最后一条有内容的 assistant 消息"""
|
||
fake = _make_fake_chat(task_rounds=1, final_answer="x", wrap_report="")
|
||
fake.messages = [
|
||
{"role": "user", "content": "hi"},
|
||
{"role": "assistant", "content": "中间回答"},
|
||
{"role": "assistant", "content": ""},
|
||
{"role": "assistant", "content": "最终回答"},
|
||
]
|
||
assert _run(fake, max_turns=10) == "【子代理最终回答】\n最终回答"
|
||
|
||
def test_no_wrap_up_and_no_messages(self):
|
||
"""收尾为空且无任何 assistant 消息 → 固定兜底文案"""
|
||
fake = _make_fake_chat(task_rounds=1, final_answer="x", wrap_report="")
|
||
assert _run(fake, max_turns=10) == "子代理未产生有效对话内容。"
|
||
|
||
def test_wrap_up_exception_falls_back(self):
|
||
"""收尾调用抛异常 → 不中断,走 assistant 消息兜底"""
|
||
fake = _make_fake_chat(task_rounds=1, final_answer="x")
|
||
original = fake.think_and_respond
|
||
|
||
def boom_on_wrap_up(input_text, output_callback=None, is_continuation=False):
|
||
if WRAP_UP_MARKER in input_text:
|
||
raise RuntimeError("wrap-up boom")
|
||
return original(input_text, output_callback, is_continuation)
|
||
|
||
fake.think_and_respond = boom_on_wrap_up
|
||
fake.messages = [{"role": "assistant", "content": "最终回答"}]
|
||
assert _run(fake, max_turns=10) == "【子代理最终回答】\n最终回答"
|
||
|
||
|
||
class TestSubAgentToolMaxTurns:
|
||
def test_max_turns_passed_through(self):
|
||
"""SubAgentTool.execute 的 max_turns 透传给 run_full_subagent"""
|
||
tool = SubAgentTool(logger=None)
|
||
with mock.patch("aibox.run_full_subagent", return_value="ok") as m:
|
||
tool.execute(role="r", task="t", max_turns=4)
|
||
assert m.call_args.kwargs["max_turns"] == 4
|
||
|
||
def test_max_turns_default_is_10(self):
|
||
tool = SubAgentTool(logger=None)
|
||
with mock.patch("aibox.run_full_subagent", return_value="ok") as m:
|
||
tool.execute(role="r", task="t")
|
||
assert m.call_args.kwargs["max_turns"] == 10
|