fix: track applied mykeys snapshot per agent

This commit is contained in:
Liang Jiaqing committed 2026-10-16 09:30:02 +08:00
1 parent 24f7d6a0dc
commit a5979eac50
5 files changed
+275 -78

No files matched your search

+9 -5
View File
@@ -6,7 +6,7 @@ if sys.stderr is None: sys.stderr = open(os.devnull, "w")
elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace')
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from llmcore import reload_mykeys, ToolClient, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client
from llmcore import mykeys_store, ToolClient, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client
from agent_loop import agent_runner_loop
try:
from plugins.hooks import discover_and_load; discover_and_load()
@@ -64,8 +64,8 @@ class GenericAgent:
self.intervene = self.extrakeyinfo = None
def load_llm_sessions(self):
mykeys, changed = reload_mykeys()
if not changed and hasattr(self, 'llmclients'): 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 = []
@@ -73,7 +73,7 @@ class GenericAgent:
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): llm_sessions += [c]
elif c := resolve_client(k, cfg): llm_sessions += [c]
except: pass
for i, s in enumerate(llm_sessions):
if isinstance(s, dict) and 'mixin_cfg' in s:
@@ -83,11 +83,15 @@ class GenericAgent:
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
if not self.llmclients: return
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 oldhistory: self.llmclient.backend.history = oldhistory
self._mykeys_updated_at = updated_at
def next_llm(self, n=-1):
self.load_llm_sessions()
-34
View File
@@ -595,13 +595,6 @@ class AgentManager:
lines = [f" '{k}': {json.dumps(v, ensure_ascii=False)}," if isinstance(v, str) else f" '{k}': {v}," for k, v in d.items()]
return "{\n" + "\n".join(lines) + "\n}"
def _invalidate_mykey_cache(self) -> None:
self.ensure_ga_import_path()
sys.modules.pop("mykey", None)
with contextlib.suppress(Exception):
import llmcore
llmcore._mykey_mtime = None
def _profile_keys(self) -> List[str]:
self.ensure_ga_import_path()
from llmcore import reload_mykeys
@@ -679,35 +672,8 @@ class AgentManager:
def _save_mykey_text(self, text: str) -> list:
self._mykey_file().write_text(text, encoding="utf-8")
self._invalidate_mykey_cache()
self._reload_live_agents()
return self.list_model_profiles()
def _reload_live_agents(self) -> None:
"""mykey.py 改动后,强制所有活着的会话 agent 重建 LLM session,让新 key/模型
立即生效(无需重启)。重建保留对话 history(agentmain 内部用 oldhistory 接回)。
纯 bridge 侧实现,不改 agentmain:每次调 agent.load_llm_sessions() 前,把
llmcore 的全局 mtime 标志清空(与 _invalidate_mykey_cache 同一手法),使其内部
reload_mykeys() 报告 changed=True、从而真正重建——否则刷新模型列表等路径会先
消费掉变更标志,常驻 agent 的 load_llm_sessions 会因 changed=False 跳过重建。"""
self.ensure_ga_import_path()
try:
import llmcore
except Exception:
return
with self.lock:
agents = [s.agent for s in self.sessions.values() if getattr(s, "agent", None) is not None]
for agent in agents:
fn = getattr(agent, "load_llm_sessions", None)
if not callable(fn):
continue
try:
llmcore._mykey_mtime = None # 让本次 reload_mykeys() 视为“已变更”,触发真正重建
fn()
except Exception as e:
bridge_print(f"[bridge] reload live agent failed: {e}")
def add_model_profile(self, data: dict) -> dict:
cfg = self._build_cfg(data)
text = self._mykey_file().read_text(encoding="utf-8")
+203
View File
@@ -0,0 +1,203 @@
"""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)
+1 -6
View File
@@ -5620,22 +5620,17 @@ class GenericAgentTUI(App[None]):
self._refresh_all()
def _cmd_reload_keys(self, args, raw):
# Force rebuild of every session's llmclients from a fresh mykey.py.
# reload_mykeys() uses a module-level mtime cache, so the first agent
# to call it consumes the "changed" signal and subsequent agents see
# changed=False (and skip rebuild). Invalidate the cache before each
# agent so every session picks up the new config.
try:
import llmcore
except Exception as e:
self._system(f"❌ 无法 import llmcore: {e}"); return
llmcore.mykeys_store.reload(force=True)
n_ok = n_fail = 0
for sess in self.sessions.values():
agent = getattr(sess, "agent", None)
if agent is None:
continue
try:
llmcore._mykey_mtime = None
agent.load_llm_sessions()
n_ok += 1
except Exception:
+62 -33
View File
@@ -10,38 +10,67 @@ _RESP_CACHE_KEY = str(uuid.uuid4()); _RESP_CODEX_KEY = str(uuid.uuid4())
_ROOT = os.path.dirname(os.path.abspath(__file__))
if _ROOT not in sys.path: sys.path.append(_ROOT)
def _load_mykeys():
global _mykey_path
try:
class MyKeys:
def __init__(self):
self.path = self.mtime = self.updated_at = None
self.data = {}
self.lock = threading.Lock()
def _load(self):
sys.modules.pop('mykey', None)
import mykey; _mykey_path = mykey.__file__
return {k: v for k, v in vars(mykey).items() if not k.startswith('_')}
except ImportError as e:
if getattr(e, 'name', None) != 'mykey':
raise Exception(f'[ERROR] mykey.py found but failed to import: {e}') from e
except SyntaxError as e:
raise Exception(f'[ERROR] mykey.py has syntax error: {e}') from e
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
if not os.path.exists(p): raise Exception('[ERROR] mykey.py not found in sys.path and mykey.json not found. Run "python configure_mykey.py" or copy mykey_template.py to mykey.py and fill in your keys.')
with open(_mykey_path := p, encoding='utf-8') as f: mk = json.load(f)
if isinstance(mk, dict) and 'remote_url' in mk: return requests.get(mk['remote_url'], timeout=10).json()
return mk
spec = importlib.util.find_spec('mykey')
if spec is not None:
path = spec.origin
mt = os.stat(path).st_mtime_ns
module = importlib.util.module_from_spec(spec)
sys.modules['mykey'] = module
# Compile source directly: pyc timestamps only have second precision.
try:
exec(compile(spec.loader.get_source('mykey'), path, 'exec'), module.__dict__)
except BaseException:
sys.modules.pop('mykey', None)
raise
data = {k: v for k, v in vars(module).items() if not k.startswith('_')}
else:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
mt = os.stat(path).st_mtime_ns
with open(path, encoding='utf-8') as f: data = json.load(f)
if isinstance(data, dict) and 'remote_url' in data:
data = requests.get(data['remote_url'], timeout=10).json()
if not isinstance(data, dict): raise ValueError('mykey configuration must be a dict')
if os.stat(path).st_mtime_ns != mt: raise RuntimeError('mykey changed while loading')
return data, path, mt
def reload(self, force=False):
with self.lock:
try:
mt = os.stat(self.path).st_mtime_ns if self.path else None
if not force and self.updated_at is not None and mt == self.mtime:
return self.data, self.updated_at, False
data, path, mt = self._load()
self.data, self.path, self.mtime = data, path, mt
self.updated_at = max(time.monotonic_ns(), (self.updated_at or 0) + 1)
print(f'[Info] Load mykeys from {self.path}')
return self.data, self.updated_at, True
except Exception:
return self.data, self.updated_at, False
def snapshot(self):
data, updated_at, _ = self.reload()
return data, updated_at
mykeys_store = MyKeys()
_mykey_lock = threading.Lock()
_mykey_path = _mykey_mtime = None
def reload_mykeys():
global _mykey_mtime
try:
mt = os.stat(_mykey_path).st_mtime_ns if _mykey_path else -1
if mt == _mykey_mtime: return globals().get('mykeys', {}), False
with _mykey_lock: mk = _load_mykeys()
_mykey_mtime = os.stat(_mykey_path).st_mtime_ns
print(f'[Info] Load mykeys from {_mykey_path}')
globals().update(mykeys=mk)
return mk, True
except: return globals().get('mykeys', {}), False
data, _, changed = mykeys_store.reload()
return data, changed
def __getattr__(name): # once guard in PEP 562
def _load_mykeys():
with mykeys_store.lock:
return mykeys_store._load()[0]
def __getattr__(name):
if name == 'mykeys': return reload_mykeys()[0]
raise AttributeError(f"module 'llmcore' has no attribute {name}")
@@ -1223,16 +1252,16 @@ class NativeToolClient:
if resp and hasattr(resp, 'tool_calls') and resp.tool_calls: self._pending_tool_ids = [tc.id for tc in resp.tool_calls]
return resp
def resolve_session(cfg_name):
cfg = reload_mykeys()[0].get(cfg_name)
def resolve_session(cfg_name, cfg=None):
if cfg is None: cfg = reload_mykeys()[0].get(cfg_name)
if not cfg: raise ValueError(f"Config '{cfg_name}' not in mykey")
cfg['_mykey_name'] = cfg_name
cfg = {**cfg, '_mykey_name': cfg_name}
if 'native' in cfg_name: return (NativeClaudeSession if 'claude' in cfg_name else NativeOAISession)(cfg=cfg)
if 'claude' in cfg_name: return ClaudeSession(cfg=cfg)
return LLMSession(cfg=cfg) if 'oai' in cfg_name else None
def resolve_client(cfg_name):
s = resolve_session(cfg_name)
def resolve_client(cfg_name, cfg=None):
s = resolve_session(cfg_name, cfg)
return (NativeToolClient(s) if isinstance(s, (NativeClaudeSession, NativeOAISession)) else ToolClient(s)) if s else None
def fast_ask(prompt, cfg_name):