From 5f3ca9a24631ad45b9502d0a1a32794d80690694 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 17 Aug 2026 23:48:40 +0800 Subject: [PATCH] fix(aibox): sync compressor and snapshots on client swap via set_client (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- aibox/README.md | 7 +++--- aibox/aibox.py | 20 ++++++++++++--- aibox/tests/test_models_profile.py | 40 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/aibox/README.md b/aibox/README.md index b3bea3d..bb33f6a 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -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)): diff --git a/aibox/aibox.py b/aibox/aibox.py index fca022c..389c145 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -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 @@ -938,11 +940,23 @@ class DeepSeekChat: self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程 # 消息锁:保护 self.messages 免受 WebSocket 线程与调度器线程并发修改导致对话历史污染 self._msg_lock = threading.RLock() - + self._init_system_prompts(system_prompt) - + 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思考/工具执行。线程安全,可从任意线程调用。""" diff --git a/aibox/tests/test_models_profile.py b/aibox/tests/test_models_profile.py index 9b499e8..9962dea 100644 --- a/aibox/tests/test_models_profile.py +++ b/aibox/tests/test_models_profile.py @@ -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: