fix: track applied mykeys snapshot per agent
This commit is contained in:
1 parent
24f7d6a0dc
commit
a5979eac50
5 files changed
+275
-78
No files matched your search
+9
-5
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user