refactor(conductor): manage model selection in agent memory only
This commit is contained in:
1 parent
e4e610d6a1
commit
24f7d6a0dc
2 files changed
+167
-265
No files matched your search
+43
-152
@@ -1,6 +1,5 @@
|
||||
import os, sys, re, time, json, uuid, queue, asyncio, threading, argparse, base64, secrets
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
@@ -11,7 +10,6 @@ from pydantic import BaseModel
|
||||
|
||||
FRONTENDS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if FRONTENDS_DIR not in sys.path: sys.path.insert(0, FRONTENDS_DIR)
|
||||
from desktop_settings import DesktopSettingsError, update_settings
|
||||
|
||||
def _resolve_ga_root() -> str:
|
||||
"""Use the external core selected by the package-owned desktop bridge when valid."""
|
||||
@@ -72,41 +70,6 @@ HOST = args.host
|
||||
PORT = args.port
|
||||
|
||||
|
||||
SETTINGS_PATH = Path.home() / ".ga_desktop_settings.json"
|
||||
|
||||
|
||||
def _settings_doc() -> dict:
|
||||
try:
|
||||
doc = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||
return doc if isinstance(doc, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _persist_conductor_llm_no(llm_no: int) -> None:
|
||||
def mutate(document: dict) -> None:
|
||||
section = document.get("conductor")
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
document["conductor"] = section
|
||||
section["llmNo"] = llm_no
|
||||
|
||||
update_settings(SETTINGS_PATH, mutate)
|
||||
|
||||
|
||||
def _conductor_llm_no() -> Optional[int]:
|
||||
"""Read the model index bound to the conductor session.
|
||||
Falls back to the legacy desktop default ui.llmNo for existing installs."""
|
||||
doc = _settings_doc()
|
||||
for section in (doc.get("conductor"), doc.get("ui")):
|
||||
if isinstance(section, dict) and section.get("llmNo") is not None:
|
||||
try:
|
||||
return int(section.get("llmNo"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _client_usable(agent: "GenericAgent") -> bool:
|
||||
return hasattr(getattr(agent, "llmclient", None), "backend")
|
||||
|
||||
@@ -131,89 +94,12 @@ def _activate_model(agent: "GenericAgent", no: int) -> None:
|
||||
agent.next_llm(no)
|
||||
|
||||
|
||||
def _runtime_model_state(agent: "GenericAgent", configured: Optional[int], reason: Optional[str]) -> dict:
|
||||
effective = getattr(agent, "llm_no", None) if _client_usable(agent) else None
|
||||
current = None
|
||||
if _client_usable(agent):
|
||||
with suppress(Exception):
|
||||
current = str(agent.llmclient.backend.name)
|
||||
return {"configured": configured, "effective": effective,
|
||||
"fallbackReason": reason, "current": current}
|
||||
|
||||
|
||||
def _apply_desktop_model(agent: "GenericAgent") -> dict:
|
||||
"""Make the conductor's session reflect the current desktop config before a task:
|
||||
switch to its bound model if one is set, otherwise still refresh sessions from
|
||||
mykey so live key/model edits (e.g. importing keys) take effect without a restart.
|
||||
next_llm() already reloads internally; the no-bound-model branch must reload too,
|
||||
or a conductor started on an empty/stale mykey would never pick up imported keys."""
|
||||
doc = _settings_doc()
|
||||
conductor_cfg = doc.get("conductor") if isinstance(doc.get("conductor"), dict) else {}
|
||||
ui_cfg = doc.get("ui") if isinstance(doc.get("ui"), dict) else {}
|
||||
raw_configured = conductor_cfg.get("llmNo")
|
||||
configured = _parse_model_no(raw_configured)
|
||||
ui_default = _parse_model_no(ui_cfg.get("llmNo"))
|
||||
|
||||
try:
|
||||
agent.load_llm_sessions() # mtime-guarded; rebuilds only when mykey changed
|
||||
except Exception as e:
|
||||
print(f"[conductor] failed to refresh model sessions: {e}", file=sys.stderr)
|
||||
|
||||
clients = getattr(agent, "llmclients", []) or []
|
||||
|
||||
configured_failed = False
|
||||
if _usable_model(agent, configured):
|
||||
try:
|
||||
_activate_model(agent, configured)
|
||||
return _runtime_model_state(agent, configured, None)
|
||||
except Exception as e:
|
||||
configured_failed = True
|
||||
print(f"[conductor] configured model #{configured} is unavailable: {e}", file=sys.stderr)
|
||||
|
||||
if _usable_model(agent, ui_default):
|
||||
if raw_configured is None:
|
||||
reason = "ui_default"
|
||||
elif configured_failed:
|
||||
reason = "configured_unavailable"
|
||||
elif configured is not None:
|
||||
reason = "configured_unavailable" if 0 <= configured < len(clients) else "invalid_configured"
|
||||
else:
|
||||
reason = "invalid_configured"
|
||||
try:
|
||||
_activate_model(agent, ui_default)
|
||||
return _runtime_model_state(agent, configured, reason)
|
||||
except Exception as e:
|
||||
print(f"[conductor] UI default model #{ui_default} is unavailable: {e}", file=sys.stderr)
|
||||
|
||||
for i, client in enumerate(clients):
|
||||
if hasattr(client, "backend"):
|
||||
try:
|
||||
_activate_model(agent, i)
|
||||
return _runtime_model_state(agent, configured, "first_available")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
print("[conductor] no usable model is available", file=sys.stderr)
|
||||
return {"configured": configured, "effective": None,
|
||||
"fallbackReason": "no_models", "current": None}
|
||||
|
||||
|
||||
def _selected_conductor_llm_no(agent: "GenericAgent") -> int:
|
||||
configured = _conductor_llm_no()
|
||||
return configured if _usable_model(agent, configured) else agent.llm_no
|
||||
|
||||
|
||||
def _set_conductor_llm_no(agent: "GenericAgent", value: Any) -> int:
|
||||
"""Persist the standalone UI binding without mutating an in-flight client.
|
||||
|
||||
The Conductor loop applies this binding at its existing idle task boundary, so
|
||||
a selection made immediately before sending a message controls that next turn.
|
||||
"""
|
||||
no = _parse_model_no(value)
|
||||
if no is None or not _usable_model(agent, no):
|
||||
raise ValueError(f"llm index out of range or unavailable: {value}")
|
||||
_persist_conductor_llm_no(no)
|
||||
return no
|
||||
def _runtime_model_state(agent: "GenericAgent") -> dict:
|
||||
usable = _client_usable(agent)
|
||||
return {"configured": None,
|
||||
"effective": agent.llm_no if usable else None,
|
||||
"fallbackReason": None if usable else "no_models",
|
||||
"current": str(agent.llmclient.backend.name) if usable else None}
|
||||
|
||||
|
||||
def _select_llm(agent: "GenericAgent", llm: Any) -> bool:
|
||||
@@ -447,7 +333,7 @@ class SubagentPool:
|
||||
agent.inc_out = True
|
||||
agent.verbose = False
|
||||
agent.no_print = True
|
||||
if not _select_llm(agent, llm): _apply_desktop_model(agent)
|
||||
_select_llm(agent, llm)
|
||||
th = start_agent_runner(agent, f"subagent-{sid}")
|
||||
state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running", thread=th)
|
||||
with self.lock: self.subagents[sid] = state
|
||||
@@ -532,24 +418,30 @@ class Conductor:
|
||||
self._runner_thread: Optional[threading.Thread] = None
|
||||
self.log: list = []
|
||||
self._model_lock = threading.RLock()
|
||||
self._model_state = {
|
||||
"configured": None,
|
||||
"effective": None,
|
||||
"fallbackReason": "no_models",
|
||||
"current": None,
|
||||
"running": False,
|
||||
}
|
||||
self._model_running = False
|
||||
|
||||
def model_snapshot(self) -> dict:
|
||||
with self._model_lock:
|
||||
return dict(self._model_state)
|
||||
return {**_runtime_model_state(self.agent), "running": self._model_running}
|
||||
|
||||
def _publish_model_state(self, state: dict, running: bool) -> None:
|
||||
snapshot = {**state, "running": bool(running)}
|
||||
with self._model_lock:
|
||||
self._model_state = snapshot
|
||||
self._model_running = bool(running)
|
||||
schedule_broadcast({"type": "model", "model": snapshot})
|
||||
|
||||
def select_model(self, value: Any) -> int:
|
||||
with self._model_lock:
|
||||
if self._model_running or self.agent.task_queue.unfinished_tasks:
|
||||
raise ValueError("conductor is busy; switch models after the task finishes")
|
||||
no = _parse_model_no(value)
|
||||
if no is None:
|
||||
raise ValueError(f"invalid model index: {value}")
|
||||
self.agent.load_llm_sessions()
|
||||
_activate_model(self.agent, no)
|
||||
self._publish_model_state(_runtime_model_state(self.agent), running=False)
|
||||
return self.agent.llm_no
|
||||
|
||||
def notify(self, event: dict): self.inbox.put(event)
|
||||
|
||||
def _wait_for_agent_idle(self, timeout: float = 10.0) -> None:
|
||||
@@ -657,24 +549,23 @@ API: {base};requests,GET /readme查用法,GET /chat读未读对话,GET /
|
||||
# mutate the live client until that previous task has reached
|
||||
# ``task_done``; the queue join is the authoritative idle barrier.
|
||||
self._wait_for_agent_idle()
|
||||
# Settings can change while this long-lived process remains healthy.
|
||||
# Refresh only at the task boundary so an in-flight task keeps its
|
||||
# client, while the next task observes the newly persisted binding.
|
||||
model_state = _apply_desktop_model(self.agent)
|
||||
if model_state.get("effective") is None:
|
||||
self._publish_model_state(model_state, running=False)
|
||||
self._record_unavailable_model(events)
|
||||
deferred_events = events
|
||||
continue
|
||||
kind = events[0].get("type")
|
||||
other_events = [event for event in events if event.get("type") != kind]
|
||||
events = [event for event in events if event.get("type") == kind]
|
||||
for event in other_events:
|
||||
self.inbox.put(event)
|
||||
prompt = self._build_prompt(events)
|
||||
self._publish_model_state(model_state, running=True)
|
||||
try:
|
||||
with self._model_lock:
|
||||
self.agent.load_llm_sessions()
|
||||
model_state = _runtime_model_state(self.agent)
|
||||
if model_state.get("effective") is None:
|
||||
self._publish_model_state(model_state, running=False)
|
||||
self._record_unavailable_model(events)
|
||||
deferred_events = events
|
||||
continue
|
||||
kind = events[0].get("type")
|
||||
other_events = [event for event in events if event.get("type") != kind]
|
||||
events = [event for event in events if event.get("type") == kind]
|
||||
for event in other_events:
|
||||
self.inbox.put(event)
|
||||
prompt = self._build_prompt(events)
|
||||
dq = self.agent.put_task(prompt, source="conductor")
|
||||
self._publish_model_state(model_state, running=True)
|
||||
try:
|
||||
self._drain(dq, events)
|
||||
finally:
|
||||
self._publish_model_state(model_state, running=False)
|
||||
@@ -682,7 +573,7 @@ API: {base};requests,GET /readme查用法,GET /chat读未读对话,GET /
|
||||
|
||||
def start(self):
|
||||
self.agent = GenericAgent()
|
||||
self._publish_model_state(_apply_desktop_model(self.agent), running=False)
|
||||
self._publish_model_state(_runtime_model_state(self.agent), running=False)
|
||||
threading.Thread(target=self._run, name="conductor-loop", daemon=True).start()
|
||||
|
||||
|
||||
@@ -838,17 +729,17 @@ async def websocket(ws: WebSocket):
|
||||
"log": conductor.log, "running": running,
|
||||
"model": conductor.model_snapshot(),
|
||||
"llms": conductor.agent.list_llms(),
|
||||
"llm": _selected_conductor_llm_no(conductor.agent)})
|
||||
"llm": conductor.agent.llm_no})
|
||||
while True:
|
||||
data = await ws.receive_json()
|
||||
if "llm" in data:
|
||||
try:
|
||||
llm_no = _set_conductor_llm_no(conductor.agent, data["llm"])
|
||||
except (TypeError, ValueError, OSError, DesktopSettingsError) as error:
|
||||
llm_no = conductor.select_model(data["llm"])
|
||||
except (TypeError, ValueError, RuntimeError) as error:
|
||||
await ws.send_json({
|
||||
"type": "model_error",
|
||||
"error": str(error),
|
||||
"llm": _selected_conductor_llm_no(conductor.agent),
|
||||
"llm": conductor.agent.llm_no,
|
||||
})
|
||||
else:
|
||||
await broadcast({"type": "model_selected", "llm": llm_no})
|
||||
|
||||
@@ -61,6 +61,7 @@ class FakeAgent:
|
||||
self.next_llm_calls: list[int] = []
|
||||
self.reloads = 0
|
||||
self.failing = failing or set()
|
||||
self.task_queue = queue.Queue()
|
||||
|
||||
def load_llm_sessions(self):
|
||||
self.reloads += 1
|
||||
@@ -79,20 +80,6 @@ class FakeAgent:
|
||||
return client.backend.model if model else client.backend.name
|
||||
|
||||
|
||||
def test_out_of_range_config_falls_back_without_modulo(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one", "two", "three"])
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": 99}, "ui": {"llmNo": 1}},
|
||||
)
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [1]
|
||||
assert state["effective"] == 1
|
||||
assert state["fallbackReason"] == "invalid_configured"
|
||||
|
||||
|
||||
def test_conductor_default_port_override_is_e2e_scoped(monkeypatch):
|
||||
monkeypatch.delenv(conductor.E2E_REPORT_DIR_ENV, raising=False)
|
||||
@@ -114,45 +101,7 @@ def test_conductor_port_parser_rejects_invalid_values(value):
|
||||
conductor._parse_conductor_port(value)
|
||||
|
||||
|
||||
def test_unusable_configured_client_falls_back_to_ui_default(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one", None, "three"])
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}},
|
||||
)
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [1]
|
||||
assert state["effective"] == 1
|
||||
assert state["fallbackReason"] == "configured_unavailable"
|
||||
|
||||
|
||||
def test_configured_activation_failure_falls_back_to_ui_default(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one", "two"], failing={2})
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}},
|
||||
)
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [2, 1]
|
||||
assert state["effective"] == 1
|
||||
assert state["fallbackReason"] == "configured_unavailable"
|
||||
|
||||
|
||||
def test_missing_config_uses_first_usable_when_ui_is_invalid(monkeypatch):
|
||||
agent = FakeAgent([None, None, "two", "three"])
|
||||
monkeypatch.setattr(conductor, "_settings_doc", lambda: {"ui": {"llmNo": 99}})
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [2]
|
||||
assert state["effective"] == 2
|
||||
assert state["fallbackReason"] == "first_available"
|
||||
|
||||
|
||||
def test_explicit_numeric_worker_model_rejects_out_of_range():
|
||||
@@ -164,52 +113,12 @@ def test_explicit_numeric_worker_model_rejects_out_of_range():
|
||||
assert agent.next_llm_calls == []
|
||||
|
||||
|
||||
def test_standalone_model_selection_persists_without_mutating_live_client(tmp_path, monkeypatch):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
settings_path.write_text('{"theme":"dark","ui":{"llmNo":0}}', encoding="utf-8")
|
||||
monkeypatch.setattr(conductor, "SETTINGS_PATH", settings_path)
|
||||
agent = FakeAgent(["zero", "one", "two"])
|
||||
|
||||
selected = conductor._set_conductor_llm_no(agent, 2)
|
||||
|
||||
assert selected == 2
|
||||
assert agent.llm_no == 0
|
||||
assert agent.next_llm_calls == []
|
||||
assert conductor._settings_doc() == {
|
||||
"theme": "dark",
|
||||
"ui": {"llmNo": 0},
|
||||
"conductor": {"llmNo": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_standalone_model_selection_rejects_unavailable_model(tmp_path, monkeypatch):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
settings_path.write_text('{"theme":"dark"}', encoding="utf-8")
|
||||
monkeypatch.setattr(conductor, "SETTINGS_PATH", settings_path)
|
||||
agent = FakeAgent(["zero", None])
|
||||
|
||||
with pytest.raises(ValueError, match="unavailable"):
|
||||
conductor._set_conductor_llm_no(agent, 1)
|
||||
|
||||
assert conductor._settings_doc() == {"theme": "dark"}
|
||||
|
||||
|
||||
def test_standalone_model_selection_applies_at_next_task_boundary(tmp_path, monkeypatch):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(conductor, "SETTINGS_PATH", settings_path)
|
||||
agent = FakeAgent(["zero", "one", "two"])
|
||||
|
||||
conductor._set_conductor_llm_no(agent, 2)
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [2]
|
||||
assert state["configured"] == 2
|
||||
assert state["effective"] == 2
|
||||
assert state["fallbackReason"] is None
|
||||
|
||||
|
||||
def test_runtime_model_snapshot_is_broadcast_with_running_state(monkeypatch):
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = FakeAgent(["model-one"])
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append)
|
||||
state = {
|
||||
@@ -221,12 +130,12 @@ def test_runtime_model_snapshot_is_broadcast_with_running_state(monkeypatch):
|
||||
|
||||
instance._publish_model_state(state, running=True)
|
||||
|
||||
assert instance.model_snapshot() == {**state, "running": True}
|
||||
assert instance.model_snapshot() == {**conductor._runtime_model_state(instance.agent), "running": True}
|
||||
assert payloads == [{"type": "model", "model": {**state, "running": True}}]
|
||||
|
||||
|
||||
def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
"""A saved binding waits for task cleanup, then affects the next task."""
|
||||
"""Display completion does not permit switching before task cleanup."""
|
||||
|
||||
class EndOfInbox(Exception):
|
||||
pass
|
||||
@@ -254,7 +163,6 @@ def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
# Keep each event as a separate task boundary for this regression.
|
||||
return True
|
||||
|
||||
selected = {"llmNo": 1}
|
||||
timeline: list[tuple[str, int]] = []
|
||||
allow_first_cleanup = threading.Event()
|
||||
first_cleanup_done = threading.Event()
|
||||
@@ -276,10 +184,7 @@ def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
self.task_models.append(self.llm_no)
|
||||
timeline.append(("submit", self.llm_no))
|
||||
if len(self.task_models) == 1:
|
||||
# Simulate save_config while task one is already submitted. The
|
||||
# display queue reports done before the agent runner persists its
|
||||
# final state and calls task_done().
|
||||
selected["llmNo"] = 2
|
||||
# Display completion precedes task cleanup.
|
||||
assert self.llm_no == 1
|
||||
self.task_queue.put("first")
|
||||
|
||||
@@ -302,13 +207,9 @@ def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = TaskAgent()
|
||||
instance.agent.next_llm(1)
|
||||
instance.inbox = BoundaryInbox()
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": selected["llmNo"]}},
|
||||
)
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append)
|
||||
monkeypatch.setattr(conductor, "start_agent_runner", lambda *_args: None)
|
||||
monkeypatch.setattr(conductor.time, "sleep", lambda _seconds: None)
|
||||
@@ -333,6 +234,8 @@ def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
assert instance.agent.task_models == [1]
|
||||
assert timeline == [("select", 1), ("submit", 1)]
|
||||
|
||||
with pytest.raises(ValueError, match="busy"):
|
||||
instance.select_model(2)
|
||||
allow_first_cleanup.set()
|
||||
assert first_cleanup_done.wait(timeout=2)
|
||||
runner.join(timeout=2)
|
||||
@@ -342,21 +245,20 @@ def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
assert not runner.is_alive()
|
||||
assert run_errors == []
|
||||
|
||||
assert instance.agent.task_models == [1, 2]
|
||||
assert instance.agent.task_models == [1, 1]
|
||||
assert timeline == [
|
||||
("select", 1),
|
||||
("submit", 1),
|
||||
("select", 2),
|
||||
("submit", 2),
|
||||
("submit", 1),
|
||||
]
|
||||
assert instance.model_snapshot() == {
|
||||
"configured": 2,
|
||||
"effective": 2,
|
||||
"configured": None,
|
||||
"effective": 1,
|
||||
"fallbackReason": None,
|
||||
"current": "two",
|
||||
"current": "one",
|
||||
"running": False,
|
||||
}
|
||||
assert [item["model"]["effective"] for item in payloads] == [1, 1, 2, 2]
|
||||
assert [item["model"]["effective"] for item in payloads] == [1, 1, 1, 1]
|
||||
assert [item["model"]["running"] for item in payloads] == [True, False, True, False]
|
||||
|
||||
|
||||
@@ -397,6 +299,7 @@ def test_conductor_defers_events_without_models_and_recovers(monkeypatch):
|
||||
def load_llm_sessions(self):
|
||||
if ready["value"] and not self.llmclients:
|
||||
self.llmclients = [Client("recovered")]
|
||||
self.llmclient = self.llmclients[0]
|
||||
|
||||
def next_llm(self, no: int):
|
||||
self.llm_no = no
|
||||
@@ -417,7 +320,6 @@ def test_conductor_defers_events_without_models_and_recovers(monkeypatch):
|
||||
instance.agent = RecoveringAgent()
|
||||
instance.inbox = BoundaryInbox()
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(conductor, "_settings_doc", lambda: {})
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append)
|
||||
monkeypatch.setattr(conductor, "start_agent_runner", lambda *_args: None)
|
||||
monkeypatch.setattr(conductor.time, "sleep", lambda _seconds: None)
|
||||
@@ -453,3 +355,112 @@ def test_parallel_worker_model_selection_keeps_agents_isolated():
|
||||
assert selected == [True] * 10
|
||||
assert [agent.llm_no for agent in agents] == requested
|
||||
assert [agent.next_llm_calls for agent in agents] == [[no] for no in requested]
|
||||
|
||||
|
||||
def test_model_selection_is_memory_only_and_survives_refresh(monkeypatch):
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = FakeAgent(["zero", "one", "two"])
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", lambda _: None)
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("model selection must not access settings files")
|
||||
monkeypatch.setattr("builtins.open", forbidden)
|
||||
monkeypatch.setattr(Path, "open", forbidden)
|
||||
assert instance.select_model(2) == 2
|
||||
instance.agent.load_llm_sessions()
|
||||
assert instance.model_snapshot()["effective"] == 2
|
||||
assert instance.agent.next_llm_calls == [2]
|
||||
# A fresh agent uses its own default, not a previously saved selection.
|
||||
other = conductor.Conductor()
|
||||
other.agent = FakeAgent(["zero", "one", "two"])
|
||||
assert other.model_snapshot()["effective"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [99, -1, True, None, "invalid", 1])
|
||||
def test_invalid_model_selection_keeps_current_agent(value, monkeypatch):
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = FakeAgent(["zero", None])
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", lambda _: None)
|
||||
with pytest.raises(ValueError):
|
||||
instance.select_model(value)
|
||||
assert instance.agent.llm_no == 0
|
||||
assert instance.agent.next_llm_calls == []
|
||||
|
||||
|
||||
def test_running_model_selection_is_rejected(monkeypatch):
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = FakeAgent(["zero", "one"])
|
||||
instance._model_running = True
|
||||
with pytest.raises(ValueError, match="busy"):
|
||||
instance.select_model(1)
|
||||
assert instance.agent.next_llm_calls == []
|
||||
|
||||
|
||||
def test_actual_generic_agent_next_llm_preserves_history(monkeypatch):
|
||||
# Execute the real method with inert clients; no credentials or LLM calls.
|
||||
import ast
|
||||
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")
|
||||
method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "next_llm")
|
||||
namespace = {"load_tool_schema": lambda: None}
|
||||
exec(compile(ast.Module(body=[method], type_ignores=[]), "agentmain.py", "exec"), namespace)
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = FakeAgent(["zero", "one"])
|
||||
instance.agent.llmclient.backend.history = [{"role": "user", "content": "history"}]
|
||||
history = instance.agent.llmclient.backend.history
|
||||
instance.agent.next_llm = types.MethodType(namespace["next_llm"], instance.agent)
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", lambda _: None)
|
||||
assert instance.select_model(1) == 1
|
||||
assert instance.agent.llmclient is instance.agent.llmclients[1]
|
||||
assert instance.agent.llmclient.backend.history is history
|
||||
assert instance.agent.llmclient.last_tools == ""
|
||||
|
||||
|
||||
def test_websocket_switch_reconnect_and_busy_error(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = FakeAgent(["zero", "one"])
|
||||
instance.agent.list_llms = lambda: [(i, c.backend.name, i == instance.agent.llm_no)
|
||||
for i, c in enumerate(instance.agent.llmclients)]
|
||||
monkeypatch.setattr(conductor, "conductor", instance)
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", lambda _: None)
|
||||
# Do not enter TestClient lifespan: no real runner or IM poller is started.
|
||||
client = TestClient(conductor.app)
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
assert ws.receive_json()["llm"] == 0
|
||||
ws.send_json({"llm": 1})
|
||||
assert ws.receive_json() == {"type": "model_selected", "llm": 1}
|
||||
instance.agent.task_queue.put("running")
|
||||
ws.send_json({"llm": 0})
|
||||
error = ws.receive_json()
|
||||
assert error["type"] == "model_error"
|
||||
assert error["llm"] == 1
|
||||
instance.agent.task_queue.get_nowait()
|
||||
instance.agent.task_queue.task_done()
|
||||
ws.send_json({"llm": 99})
|
||||
assert ws.receive_json()["type"] == "model_error"
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
hello = ws.receive_json()
|
||||
assert hello["llm"] == hello["model"]["effective"] == 1
|
||||
|
||||
|
||||
def test_conductor_has_no_desktop_settings_dependency():
|
||||
source = CONDUCTOR_PATH.read_text(encoding="utf-8")
|
||||
for removed in ["desktop_settings", "SETTINGS_PATH", "_apply_desktop_model", "ui_default"]:
|
||||
assert removed not in source
|
||||
|
||||
|
||||
def test_start_uses_generic_agent_default_without_files(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one"])
|
||||
agent.next_llm(1)
|
||||
monkeypatch.setattr(conductor, "GenericAgent", lambda: agent)
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", lambda _: None)
|
||||
monkeypatch.setattr(conductor.threading.Thread, "start", lambda _: None)
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("startup must not access desktop settings")
|
||||
monkeypatch.setattr("builtins.open", forbidden)
|
||||
monkeypatch.setattr(Path, "open", forbidden)
|
||||
instance = conductor.Conductor()
|
||||
instance.start()
|
||||
assert instance.agent is agent
|
||||
assert instance.model_snapshot()["effective"] == 1
|
||||
assert agent.next_llm_calls == [1]
|
||||
Reference in new issue
Block a user