fix: synchronize live model reload and preserve config selection

This commit is contained in:
Liang Jiaqing committed 2026-10-16 19:46:04 +08:00
1 parent 7fb22a2f85
commit 2ba5529060
4 files changed
+52 -232

No files matched your search

+3
View File
@@ -156,3 +156,6 @@ frontends/conductor_im_plugins/*
# Beta 渠道:订阅门户插件(仅 beta 分发携带,不入库)
plugins/subscription_portal.py
# Local-only model reload regression tests
/frontends/tests/test_mykeys_reload.py
+29 -15
View File
@@ -46,6 +46,7 @@ class GenericAgent:
def __init__(self):
os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
self.lock = threading.Lock()
self._model_lock = threading.RLock()
self.task_dir = None
self.history = []; self.handler = None; self.all_outputs = []
self.task_queue = queue.Queue()
@@ -64,16 +65,22 @@ class GenericAgent:
self.intervene = self.extrakeyinfo = None
def load_llm_sessions(self):
with self._model_lock:
if self.is_running: return
mykeys, updated_at = mykeys_store.snapshot()
if hasattr(self, 'llmclients') and getattr(self, '_mykeys_updated_at', None) == updated_at: return
try: oldhistory, oldname = self.llmclient.backend.history, self.llmclient.backend.name
except: oldhistory = oldname = None
llm_sessions = []
try: oldhistory = self.llmclient.backend.history
except: oldhistory = None
try: oldkey = self._llm_config_keys[self.llm_no]
except (AttributeError, IndexError): oldkey = None
llm_sessions, config_keys = [], []
for k, cfg in mykeys.items():
if not any(x in k for x in ['api', 'config', 'cookie']): continue
try:
if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]
elif c := resolve_client(k, cfg): llm_sessions += [c]
else: continue
config_keys.append(k)
except: pass
for i, s in enumerate(llm_sessions):
if isinstance(s, dict) and 'mixin_cfg' in s:
@@ -82,18 +89,20 @@ class GenericAgent:
if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)
else: llm_sessions[i] = ToolClient(mixin)
except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
self.llmclients = llm_sessions
self.llmclients, self._llm_config_keys = llm_sessions, config_keys
if not self.llmclients:
self.llmclient = None
self._mykeys_updated_at = updated_at
return
names = [c.backend.name if not isinstance(c, dict) else f'BADMIXIN_{i}' for i, c in enumerate(self.llmclients)]
if oldname in names: self.llm_no = names.index(oldname)
self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]
if oldkey in config_keys: self.llm_no = config_keys.index(oldkey)
self.llm_no %= len(self.llmclients)
self.llmclient = self.llmclients[self.llm_no]
if oldhistory: self.llmclient.backend.history = oldhistory
self._mykeys_updated_at = updated_at
def next_llm(self, n=-1):
with self._model_lock:
if self.is_running: return
self.load_llm_sessions()
if not self.llmclients: return
self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients)
@@ -104,6 +113,7 @@ class GenericAgent:
self.llmclient.last_tools = ''
load_tool_schema()
def list_llms(self):
with self._model_lock:
self.load_llm_sessions()
return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)]
def get_llm_name(self, b=None, model=False):
@@ -158,15 +168,18 @@ class GenericAgent:
task = self.task_queue.get()
if isinstance(task, str): break
raw_query, source, display_queue = task["query"], task["source"], task["output"]
raw_query = self._handle_slash_cmd(raw_query, display_queue)
if raw_query is None:
self.task_queue.task_done(); continue
full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = []
try:
with self._model_lock:
self.load_llm_sessions()
self.is_running = True; self._current_queue = display_queue
raw_query = self._handle_slash_cmd(raw_query, display_queue)
if raw_query is None: continue
if len(raw_query) > 2000:
task_file = os.path.join(script_dir, 'temp', f'user_prompt_{os.getpid()}_{time.time_ns()}.md')
with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query)
raw_query = f'Long user prompt saved to {task_file}. Read and execute.'
self.all_outputs.append({"input": raw_query, "outputs": []})
self.all_outputs.append({"input": raw_query, "outputs": turn_resps})
if len(self.all_outputs) > 10000: self.all_outputs = self.all_outputs[-5000:]
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
self.history.append(f"[USER]: {rquery}")
@@ -186,8 +199,6 @@ class GenericAgent:
self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200)
gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA,
max_turns=180, verbose=self.verbose, yield_info=True)
try:
full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = self.all_outputs[-1]["outputs"]
for chunk in gen:
if consume_file(self.task_dir, '_stop'): self.abort()
if self.stop_sig: break
@@ -208,9 +219,12 @@ class GenericAgent:
display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()})
finally:
if self.stop_sig: print('User aborted the task.')
self.is_running = self.stop_sig = False # keep _current_queue: its final 'done' may still be unclaimed (refreshed UI salvages it); next task overwrites it
self.task_queue.task_done()
if self.handler is not None: self.handler.code_stop_signal.append(1)
with self._model_lock:
self.is_running = self.stop_sig = False # keep _current_queue for final output retrieval
try: self.load_llm_sessions()
except Exception as e: print(f'[ERROR] Failed to reload models: {e}')
self.task_queue.task_done()
GeneraticAgent = GenericAgent
+20 -14
View File
@@ -672,6 +672,14 @@ class AgentManager:
def _save_mykey_text(self, text: str) -> list:
self._mykey_file().write_text(text, encoding="utf-8")
with self.lock:
for sess in self.sessions.values():
if sess.agent is not None:
try:
sess.agent.list_llms()
emit_session_state(sess, sess.status)
except Exception as e:
bridge_print(f"[model reload] {sess.id}: {e}")
return self.list_model_profiles()
def add_model_profile(self, data: dict) -> dict:
@@ -741,6 +749,9 @@ class AgentManager:
agentmain = importlib.import_module("agentmain")
GA = getattr(agentmain, "GenericAgent")
agent = GA()
no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no()
if no is not None:
agent.next_llm(int(no))
agent.inc_out = True
agent.verbose = True
threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start()
@@ -796,7 +807,7 @@ class AgentManager:
cfg = mk.get(k) if isinstance(mk.get(k), dict) else {}
if "mixin" in k:
members = [str(m) for m in (cfg.get("llm_nos") or [])]
out.append({"id": i, "varName": k, "kind": "mixin", "name": "",
out.append({"id": i, "varName": k, "kind": "mixin", "name": str(cfg.get("name") or k),
"members": members, "active": i == active})
else:
name = self._base_display_name(k, cfg)
@@ -871,14 +882,14 @@ class AgentManager:
def _live_model(sess: Session) -> Optional[dict]:
"""该会话 agent 当前真正在用的模型(渠道组会随故障转移变化)。
agent 还没建(没跑过 turn)时返回静态绑定信息,前端据 llmNo 回显选择器。
llmNo: 始终以 sess.llm_no 为权威(用户选择),agent.llm_no 在初始化窗口可能滞后。"""
已创建的 agent 是模型选择的唯一来源。"""
ag = getattr(sess, "agent", None)
if ag is None:
return {"current": None, "isMixin": False, "llmNo": sess.llm_no,
"runningLlmNo": sess.running_llm_no, "runningModel": sess.running_model}
try:
back = ag.llmclient.backend
live_no = sess.llm_no if sess.llm_no is not None else getattr(ag, "llm_no", 0)
live_no = sess.llm_no = ag.llm_no
if "Mixin" in type(back).__name__:
return {"current": back.current_name, "isMixin": True, "llmNo": live_no,
"runningLlmNo": sess.running_llm_no, "runningModel": sess.running_model}
@@ -1080,13 +1091,8 @@ class AgentManager:
except Exception:
pass
agent = sess.agent
# 模型取会话绑定 sess.llm_no,未绑定回退全局默认。切换走 set_session_model。
no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no()
if no is not None and hasattr(agent, "next_llm"):
with contextlib.suppress(Exception):
agent.next_llm(int(no))
with self.lock:
sess.running_llm_no = getattr(agent, "llm_no", no)
sess.running_llm_no = agent.llm_no
try:
running_backend = agent.llmclient.backend
sess.running_model = str(
@@ -1311,18 +1317,18 @@ class AgentManager:
def set_session_model(self, sid: str, llm_no: int) -> dict:
"""Bind a model to a session.
An idle agent switches immediately. A running turn keeps its captured client;
the new binding is applied at the start of the next turn.
Existing agents own selection; running turns ignore switch requests.
"""
with self.mutation():
sess = self.sessions.get(sid)
if not sess:
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
if sess.status != "running":
if sess.agent is None:
sess.llm_no = int(llm_no)
if (sess.status != "running" and sess.agent is not None
and hasattr(sess.agent, "next_llm")):
with contextlib.suppress(Exception):
else:
sess.agent.next_llm(int(llm_no))
sess.llm_no = sess.agent.llm_no
sess.updated_at = time.time()
self._persist_session(sess)
return {"ok": True, "sessionId": sid, "llmNo": sess.llm_no, "model": self._live_model(sess)}
-203
View File
@@ -1,203 +0,0 @@
"""Isolate configuration loading from real keys, network and agent startup."""
import ast
import importlib.util
import json
import os
import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from concurrent.futures import ThreadPoolExecutor
import pytest
ROOT = Path(__file__).resolve().parents[2]
def extract(path, names, namespace):
tree = ast.parse(path.read_text(encoding="utf-8"))
nodes = [n for n in tree.body if getattr(n, "name", None) in names]
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), namespace)
@pytest.fixture
def core(tmp_path, monkeypatch):
# Never resolve or import the user's actual mykey module.
source = tmp_path / "mykey.py"
def find_spec(name):
assert name == "mykey"
return importlib.util.spec_from_file_location(name, source) if source.exists() else None
monkeypatch.setattr(importlib.util, "find_spec", find_spec)
monkeypatch.delitem(sys.modules, "mykey", raising=False)
ns = dict(os=os, sys=sys, json=json, time=time, threading=threading,
importlib=importlib, __file__=str(tmp_path / "llmcore.py"))
extract(ROOT / "llmcore.py", {"MyKeys", "reload_mykeys", "_load_mykeys", "__getattr__", "resolve_session", "resolve_client"}, ns)
ns["mykeys_store"] = ns["MyKeys"]()
yield ns, source
sys.modules.pop("mykey", None)
def write(source, value, stamp=1700000000000000100):
source.write_text("oai_config = " + repr({"name": "test", "model": value}) + "\n", encoding="utf-8")
os.utime(source, ns=(stamp, stamp))
def agent_type(ns):
tree = ast.parse((ROOT / "agentmain.py").read_text(encoding="utf-8"))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "GenericAgent")
cls.body = [n for n in cls.body if getattr(n, "name", None) == "load_llm_sessions"]
exec(compile(ast.Module(body=[cls], type_ignores=[]), "agentmain.py", "exec"), ns)
return ns["GenericAgent"]
def test_two_agents_update_even_if_bridge_read_first(core):
ns, source = core
write(source, "A")
built = []
def resolve(name, cfg):
built.append(cfg["model"])
return SimpleNamespace(backend=SimpleNamespace(name=cfg["name"], model=cfg["model"], history=[]))
ns["resolve_client"] = resolve
Agent = agent_type(ns)
a, b = Agent(), Agent()
for ga in (a, b):
ga.llm_no = 0
ga.load_llm_sessions()
history = [{"role": "user", "content": "preserve"}]
a.llmclient.backend.history = history
old = a.llmclient
a.load_llm_sessions()
assert a.llmclient is old
write(source, "B", 1700000000000000200)
assert ns["reload_mykeys"]()[1] is True # bridge consumes old changed signal
assert ns["reload_mykeys"]()[1] is False
for ga in (a, b):
ga.load_llm_sessions()
assert ga.llmclient.backend.model == "B"
assert a.llmclient.backend.history is history
assert built == ["A", "A", "B", "B"]
def test_same_second_same_size_and_backwards_mtime(core):
ns, source = core
write(source, "A")
store = ns["mykeys_store"]
first = store.snapshot()[1]
write(source, "B", 1700000000000000200)
data, second = store.snapshot()
assert data["oai_config"]["model"] == "B" and second > first
write(source, "C", 1600000000000000000)
data, third = store.snapshot()
assert data["oai_config"]["model"] == "C" and third > second
def test_json_fallback_and_legacy_interfaces(core):
ns, source = core
source.with_suffix(".json").write_text('{"oai_config": {"model": "A"}}', encoding="utf-8")
data, changed = ns["reload_mykeys"]()
assert changed is True
assert ns["__getattr__"]("mykeys") is data
assert ns["reload_mykeys"]() == (data, False)
assert ns["_load_mykeys"]() == data
with pytest.raises(AttributeError): ns["__getattr__"]("unknown")
def test_failure_keeps_last_good_snapshot_and_recovers(core):
ns, source = core
write(source, "A")
store = ns["mykeys_store"]
before = store.snapshot()
source.write_text("oai_config = {", encoding="utf-8")
assert store.snapshot() == before
source.unlink()
assert store.snapshot() == before
write(source, "B", 1700000000000000300)
assert store.snapshot()[0]["oai_config"]["model"] == "B"
def test_force_refresh_publishes_once_for_all_readers(core):
ns, source = core
write(source, "A")
store = ns["mykeys_store"]
before = store.snapshot()[1]
store.reload(force=True)
after = store.snapshot()[1]
assert after > before
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: store.snapshot()[1], range(32)))
assert results == [after] * 32
def test_resolvers_accept_snapshot_without_reading_global_or_mutating_it(core):
ns, source = core
class Session:
def __init__(self, cfg): self.cfg = cfg
ns.update(LLMSession=Session, ClaudeSession=Session, NativeClaudeSession=type("NC", (), {}),
NativeOAISession=type("NO", (), {}), ToolClient=lambda s: s)
cfg = {"model": "A"}
ns["reload_mykeys"] = lambda: pytest.fail("unexpected global config read")
session = ns["resolve_client"]("oai_config", cfg)
assert session.cfg == {"model": "A", "_mykey_name": "oai_config"}
assert cfg == {"model": "A"}
ns["reload_mykeys"] = lambda: ({"oai_config": cfg}, False)
assert ns["resolve_session"]("oai_config").cfg == session.cfg
def test_bridge_save_only_writes_config_and_reads_profiles(tmp_path):
tree = ast.parse((ROOT / "frontends/desktop_bridge.py").read_text(encoding="utf-8"))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "AgentManager")
method = next(n for n in cls.body if getattr(n, "name", None) == "_save_mykey_text")
ns = {}
exec(compile(ast.Module(body=[method], type_ignores=[]), "desktop_bridge.py", "exec"), ns)
file = tmp_path / "synthetic_config.py"
manager = SimpleNamespace(_mykey_file=lambda: file, list_model_profiles=lambda: ["profile"])
assert ns["_save_mykey_text"](manager, "synthetic = {}") == ["profile"]
assert file.read_text(encoding="utf-8") == "synthetic = {}"
def test_ga_preserves_selected_model_when_config_order_changes(core):
ns, source = core
ns["resolve_client"] = lambda name, cfg: SimpleNamespace(backend=SimpleNamespace(name=cfg["name"], history=[]))
Agent = agent_type(ns)
ga = Agent()
ga.llm_no = 1
source.write_text("oai_config_a = {'name': 'A'}\noai_config_b = {'name': 'B'}\n", encoding="utf-8")
ga.load_llm_sessions()
assert ga.llmclient.backend.name == "B"
source.write_text("oai_config_b = {'name': 'B'}\noai_config_a = {'name': 'A'}\n", encoding="utf-8")
os.utime(source, ns=(1700000000000000000, 1700000000000000000))
ga.load_llm_sessions()
assert ga.llmclient.backend.name == "B" and ga.llm_no == 0
def test_ga_drops_stale_client_when_models_removed(core):
ns, source = core
ns["resolve_client"] = lambda name, cfg: SimpleNamespace(backend=SimpleNamespace(name=cfg["name"], history=[]))
Agent = agent_type(ns)
ga = Agent()
ga.llm_no = 0
write(source, "A")
ga.load_llm_sessions()
source.write_text("", encoding="utf-8")
os.utime(source, ns=(1700000000000000500, 1700000000000000500))
ga.load_llm_sessions()
assert ga.llmclients == [] and ga.llmclient is None
def test_loader_does_not_reuse_same_second_bytecode(core):
import py_compile
ns, source = core
write(source, "A")
py_compile.compile(str(source), doraise=True)
write(source, "B", 1700000000000000200)
assert ns["mykeys_store"].snapshot()[0]["oai_config"]["model"] == "B"
def test_legacy_direct_loader_raises_and_cleans_failed_module(core):
ns, source = core
source.write_text("partial = 1\nraise RuntimeError('synthetic failure')\n", encoding="utf-8")
with pytest.raises(RuntimeError, match="synthetic failure"):
ns["_load_mykeys"]()
assert "mykey" not in sys.modules
assert ns["reload_mykeys"]() == ({}, False)