From ffe97347df76551c381597ad884a68cd3b432cae Mon Sep 17 00:00:00 2001 From: Liang Jiaqing Date: Mon, 24 Aug 2026 10:09:15 +0800 Subject: [PATCH] fix(conductor): restore standalone model selection --- frontends/conductor.html | 7 ++- frontends/conductor.py | 55 ++++++++++++++++++- .../tests/test_conductor_model_routing.py | 44 +++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/frontends/conductor.html b/frontends/conductor.html index 9959b75a..8903195f 100644 --- a/frontends/conductor.html +++ b/frontends/conductor.html @@ -540,7 +540,12 @@ messagesEl.innerHTML=''; (data.chat||[]).forEach(addMsg); renderLog(data.log||[]); llmEl.innerHTML=(data.llms||[]).map(x=>``).join(''); llmEl.value=data.llm; llmEl.disabled=!llmEl.options.length; - } else if(data.type==='subagents'){ renderCards(data.items||[]); } + } else if(data.type==='model_selected'){ llmEl.value=String(data.llm); } + else if(data.type==='model_error'){ + if(data.llm!==undefined) llmEl.value=String(data.llm); + showLimitToast(data.error||'Failed to select model'); + } + else if(data.type==='subagents'){ renderCards(data.items||[]); } else if(data.type==='chat'){ addMsg(data.item); } else if(data.type==='chat_read'){ markAllRead(); } else if(data.type==='log'){ appendLog(data.item); } diff --git a/frontends/conductor.py b/frontends/conductor.py index cbd205ac..ad7817d4 100644 --- a/frontends/conductor.py +++ b/frontends/conductor.py @@ -1,5 +1,6 @@ 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 @@ -8,6 +9,10 @@ from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware 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.""" value = (os.environ.get("GA_ROOT", "") or "").strip() @@ -67,15 +72,28 @@ HOST = args.host PORT = args.port +SETTINGS_PATH = Path.home() / ".ga_desktop_settings.json" + + def _settings_doc() -> dict: try: - from pathlib import Path - doc = json.loads((Path.home() / ".ga_desktop_settings.json").read_text(encoding="utf-8")) + 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.""" @@ -180,6 +198,24 @@ def _apply_desktop_model(agent: "GenericAgent") -> dict: "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 _select_llm(agent: "GenericAgent", llm: Any) -> bool: if llm is None or str(llm).strip() == "": return False q = str(llm).strip() @@ -800,9 +836,22 @@ async def websocket(ws: WebSocket): await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages, "log": conductor.log, "running": running, "model": conductor.model_snapshot(), - "llms": conductor.agent.list_llms(), "llm": conductor.agent.llm_no}) + "llms": conductor.agent.list_llms(), + "llm": _selected_conductor_llm_no(conductor.agent)}) 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: + await ws.send_json({ + "type": "model_error", + "error": str(error), + "llm": _selected_conductor_llm_no(conductor.agent), + }) + else: + await broadcast({"type": "model_selected", "llm": llm_no}) + continue msg = (data.get("msg") or "").strip() if not msg: continue add_chat(msg, role="user", files=data.get("files") or [], images=data.get("images") or []) diff --git a/frontends/tests/test_conductor_model_routing.py b/frontends/tests/test_conductor_model_routing.py index e0fa335b..3daf9e34 100644 --- a/frontends/tests/test_conductor_model_routing.py +++ b/frontends/tests/test_conductor_model_routing.py @@ -164,6 +164,50 @@ 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() payloads: list[dict] = []