fix(aibox): sync compressor and snapshots on client swap via set_client (M1)

run_full_subagent replaced sub_chat.client by direct assignment after
construction, leaving ConversationCompressor (bound to the original
client in DeepSeekChat.__init__) and the api_key/name snapshots
pointing at the default model — compression requests silently kept
using the default endpoint/model while the dialogue itself used the
requested one.

DeepSeekChat gains set_client(): swaps self.client and refreshes
api_key, name, and compressor.llm_client in one place. run_full_subagent
now routes model overrides through it.

Tests: new TestM1SetClient (2 cases: full reference refresh on a real
DeepSeekChat instance, and run_full_subagent calling set_client with
the new client); full suite 295 passed.
This commit is contained in:
qing committed 2026-08-17 23:48:40 +08:00
1 parent 77262f8c3a
commit 5f3ca9a246
3 files changed
+59 -4

No files matched your search

+4 -3
View File
@@ -92,7 +92,7 @@ aibox/
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy
│ └── misc_tools.py # B 批:AskUserTool
├── tests/ # 单元测试套件(293 项全过)
├── tests/ # 单元测试套件(295 项全过)
│ ├── conftest.py # sys.path 配置 + websockets 桩
│ ├── test_basic_tools.py # A 批工具 43 项
│ ├── test_b_tools.py # B 批工具 58 项
@@ -885,12 +885,13 @@ elif command == "/mycommand":
## 📄 更新日志
### v3.4
- ✅ **多模型档案加固(H1/H2/H3/M2 四项修复)**:
- ✅ **多模型档案加固(H1/H2/H3/M1/M2 五项修复)**:
- **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效
- **H2(schema 校验前移)**:档案缺 `api_url`/`model` 或类型错误时,解析期即抛出指明档案名与缺失字段的 `ValueError`——不再把档案名当模型名发出、不再延迟到 `requests InvalidURL` 或 API 400
- **H3(--model 服务模式生效)**:`--model` 升级为进程级覆盖,WebSocket 服务模式(主对话/每个会话实例/定时任务实例)与 `--once` 均生效,此前服务模式静默忽略
- **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏)
- ✅ **测试**:293 项全过(H1 隔离 4 项 + H2 校验 6 项 + H3 传导 6 项 + M2 提示 5 项新增)
- **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型
- ✅ **测试**:295 项全过(H1 隔离 4 项 + H2 校验 6 项 + H3 传导 6 项 + M2 提示 5 项 + M1 替换 2 项新增)
### v3.3
- ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
+15 -1
View File
@@ -651,8 +651,10 @@ def run_full_subagent(role, task, config, logger=None, model=None,
)
# 覆盖模型(如果指定了不同模型)
# M1:经 set_client 统一替换——同步刷新 compressor.llm_client 与
# api_key/name 快照,避免压缩链仍用默认模型发请求
if model:
sub_chat.client = create_llm_client(config, api_key=None, logger=logger, model=model)
sub_chat.set_client(create_llm_client(config, api_key=None, logger=logger, model=model))
# 轮次限制:防止工具调用无限循环
# 注意:超限时不置 conversation_active=False——那会触发 think_and_respond
@@ -944,6 +946,18 @@ class DeepSeekChat:
self.tool_registry = ToolRegistry(self.logger)
self._register_tools(exit_callback, command_executor)
def set_client(self, client) -> None:
"""M1:统一替换 LLM 客户端(换模型/换档案),同步刷新派生引用。
直接赋值 self.client 会让 ConversationCompressor 等持有旧 client 的
组件继续用默认模型发请求(压缩链与实际对话模型不一致)。本方法
一并更新 compressor.llm_client 与 api_key/name 快照。
"""
self.client = client
self.api_key = client.api_key
self.name = client.name
self.compressor.llm_client = client
def cancel(self):
"""中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。"""
self.logger.info("收到中断信号,正在取消当前操作...")
+40
View File
@@ -439,6 +439,46 @@ class TestM2SubAgentModelHint:
assert "可用模型档案" not in tool.get_description()
# ==================== M1:set_client 统一替换 LLM 客户端 ====================
class TestM1SetClient:
"""M1:run_full_subagent 换模型后 compressor 仍持旧 client(压缩链用默认模型)。
验证 DeepSeekChat.set_client 统一替换:client/api_key/name/compressor.llm_client
四处同步刷新;run_full_subagent 经 set_client 而非直接赋值。
"""
def test_set_client_refreshes_all_references(self):
"""set_client → client/api_key/name/compressor.llm_client 同步为新 client"""
from test_once_mode import _make_ai, FakeLLMClient
ai = _make_ai()
assert ai.compressor.llm_client is ai.client # 前置:初始一致
new_client = FakeLLMClient()
new_client.api_key = "sk-new"
new_client.name = "NewVendor"
ai.set_client(new_client)
assert ai.client is new_client
assert ai.api_key == "sk-new"
assert ai.name == "NewVendor"
assert ai.compressor.llm_client is new_client
def test_run_full_subagent_uses_set_client(self):
"""run_full_subagent(model=...) → 经 set_client 替换(compressor 不再持旧 client)"""
from aibox import run_full_subagent
fake_sub_chat = mock.MagicMock()
fake_sub_chat.think_and_respond = mock.MagicMock(
return_value="任务完成报告")
fake_sub_chat.stats = {}
fake_sub_chat.messages = []
fake_new_client = mock.MagicMock()
with mock.patch("aibox.DeepSeekChat", return_value=fake_sub_chat), \
mock.patch("aibox.create_llm_client", return_value=fake_new_client), \
mock.patch("aibox.SubprocessCommandExecutor"):
run_full_subagent("测试角色", "测试任务", None,
model="kimi", logger=None)
fake_sub_chat.set_client.assert_called_once_with(fake_new_client)
# ==================== DeepSeekChat 档案级 reasoning 读取 ====================
class TestChatReasoningFlag: