fix(tui): propagate the live agent's provider into the slash worker (#57283)
A Desktop/TUI MoA session pins the live agent to the virtual moa provider, but the persistent slash-worker subprocess was spawned with only --model. HermesCLI re-resolves provider from config.yaml, so /moa dispatched the MoA preset NAME (model='default') to the configured real provider — openrouter 402 max_tokens errors in the original report. Forward the parent agent's resolved provider on the worker argv (--provider) and pass it through to HermesCLI at both spawn sites (first-use in slash.exec and the post-switch _restart_slash_worker). The issue's first layer (active-profile.json persistence) was already fixed on main by d27180ba7f; this is the remaining provider-propagation layer.
This commit is contained in:
112
tests/tui_gateway/test_slash_worker_provider.py
Normal file
112
tests/tui_gateway/test_slash_worker_provider.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""The slash worker inherits the parent agent's provider, not the config default (#57283).
|
||||||
|
|
||||||
|
A Desktop/TUI MoA session pins the live agent to the virtual ``moa`` provider, but the
|
||||||
|
persistent slash-worker subprocess was spawned with only ``--model``. HermesCLI re-resolves
|
||||||
|
the provider from config.yaml, so a ``/moa`` one-shot dispatched the MoA PRESET NAME to the
|
||||||
|
configured real provider (openrouter 402 / "No available channel for model default"). The
|
||||||
|
worker argv must carry ``--provider`` and the child must hand it to ``HermesCLI``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import tui_gateway.server as server
|
||||||
|
|
||||||
|
|
||||||
|
class _Popen:
|
||||||
|
"""Capture the spawn argv without a real child."""
|
||||||
|
|
||||||
|
def __init__(self, argv, **kw):
|
||||||
|
self.argv = list(argv)
|
||||||
|
self.stdout = self.stderr = iter(())
|
||||||
|
self.stdin = None
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(monkeypatch, **worker_kwargs) -> list[str]:
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _popen(argv, **kw):
|
||||||
|
captured["argv"] = list(argv)
|
||||||
|
return _Popen(argv, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server.subprocess, "Popen", _popen)
|
||||||
|
server._SlashWorker("sess", "default", **worker_kwargs)
|
||||||
|
return captured["argv"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_worker_argv_carries_provider(monkeypatch):
|
||||||
|
argv = _spawn(monkeypatch, provider="moa")
|
||||||
|
|
||||||
|
assert "--provider" in argv
|
||||||
|
assert argv[argv.index("--provider") + 1] == "moa"
|
||||||
|
# model still forwarded
|
||||||
|
assert argv[argv.index("--model") + 1] == "default"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_worker_argv_omits_empty_provider(monkeypatch):
|
||||||
|
argv = _spawn(monkeypatch, provider=None)
|
||||||
|
|
||||||
|
assert "--provider" not in argv
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_exec_spawns_with_the_live_agent_provider(monkeypatch):
|
||||||
|
"""First-use spawn in ``slash.exec`` passes the session agent's resolved provider — the MoA
|
||||||
|
one-shot builds ``agent.provider == "moa"`` via ``_apply_model_switch`` before the worker runs."""
|
||||||
|
worker_args = None
|
||||||
|
|
||||||
|
class _RecordingWorker:
|
||||||
|
def __init__(self, session_key, model, profile_home=None, provider=None):
|
||||||
|
nonlocal worker_args
|
||||||
|
worker_args = (session_key, model, profile_home, provider)
|
||||||
|
self.stderr_tail = []
|
||||||
|
|
||||||
|
def run(self, command):
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
agent = SimpleNamespace(model="default", provider="moa")
|
||||||
|
session = {"session_key": "s-key", "agent": agent, "profile_home": None, "running": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_sessions", {"sid": session})
|
||||||
|
monkeypatch.setattr(server, "_SlashWorker", _RecordingWorker)
|
||||||
|
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None))
|
||||||
|
monkeypatch.setattr(server, "_live_slash_command_output", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(server, "_is_snapshot_restore", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(server, "_mirror_slash_side_effects", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(server, "_resolve_model", lambda: "fallback-model")
|
||||||
|
|
||||||
|
resp = server._methods["slash.exec"](1, {"session_id": "sid", "command": "/tools"})
|
||||||
|
|
||||||
|
assert "error" not in resp, resp
|
||||||
|
assert worker_args is not None
|
||||||
|
assert worker_args[3] == "moa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_worker_child_forwards_provider_to_hermes_cli(monkeypatch):
|
||||||
|
"""The child's argparse accepts --provider and hands it to HermesCLI (mocked): the MoA
|
||||||
|
virtual provider must reach ``HermesCLI(provider=...)`` instead of config re-resolution."""
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
import tui_gateway.slash_worker as sw
|
||||||
|
|
||||||
|
built = {}
|
||||||
|
|
||||||
|
class _FakeCLI:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
built.update(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sw, "HermesCLI", _FakeCLI)
|
||||||
|
monkeypatch.setattr(sw, "_start_parent_death_watchdog", lambda *a: None)
|
||||||
|
monkeypatch.setattr(sw, "_prepare_slash_worker_runtime", lambda: None)
|
||||||
|
monkeypatch.setattr(sw.sys, "stdin", type("S", (), {"readline": lambda self: ""})())
|
||||||
|
monkeypatch.setattr(_sys, "argv", ["slash_worker", "--session-key", "s", "--model", "m",
|
||||||
|
"--provider", "moa"])
|
||||||
|
|
||||||
|
sw.main()
|
||||||
|
|
||||||
|
assert built.get("provider") == "moa"
|
||||||
|
assert built.get("model") == "m"
|
||||||
@@ -981,7 +981,8 @@ def _(rid, params: dict) -> dict:
|
|||||||
try:
|
try:
|
||||||
worker = _SlashWorker(
|
worker = _SlashWorker(
|
||||||
session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
|
session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
|
||||||
profile_home=session.get("profile_home"))
|
profile_home=session.get("profile_home"),
|
||||||
|
provider=getattr(session.get("agent"), "provider", None) or None)
|
||||||
_attach_worker(sid, session, worker)
|
_attach_worker(sid, session, worker)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(rid, 5030, f"slash worker start failed: {e}")
|
return _err(rid, 5030, f"slash worker start failed: {e}")
|
||||||
|
|||||||
@@ -232,12 +232,18 @@ def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]:
|
|||||||
class _SlashWorker:
|
class _SlashWorker:
|
||||||
"""Persistent HermesCLI subprocess for slash commands."""
|
"""Persistent HermesCLI subprocess for slash commands."""
|
||||||
|
|
||||||
def __init__(self, session_key: str, model: str, profile_home: str | None = None):
|
def __init__(self, session_key: str, model: str, profile_home: str | None = None,
|
||||||
|
provider: str | None = None):
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._seq = 0
|
self._seq = 0
|
||||||
self.stderr_tail: list[str] = []
|
self.stderr_tail: list[str] = []
|
||||||
self.stdout_queue: queue.Queue[dict | None] = queue.Queue()
|
self.stdout_queue: queue.Queue[dict | None] = queue.Queue()
|
||||||
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] + (["--model", model] if model else [])
|
# ``--provider`` pins the child to the parent agent's virtual provider: without it the
|
||||||
|
# worker re-resolves provider from config, so a MoA session (provider=moa, model=<preset>)
|
||||||
|
# dispatched its preset NAME to the configured real provider and 402/503'd (#57283).
|
||||||
|
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] \
|
||||||
|
+ (["--model", model] if model else []) \
|
||||||
|
+ (["--provider", provider] if provider else [])
|
||||||
self._closed = False
|
self._closed = False
|
||||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||||
# slash_worker runs the Hermes agent → needs provider credentials. Tier-1 secrets
|
# slash_worker runs the Hermes agent → needs provider credentials. Tier-1 secrets
|
||||||
@@ -2013,7 +2019,8 @@ def _restart_slash_worker(sid: str, session: dict):
|
|||||||
worker.close()
|
worker.close()
|
||||||
try:
|
try:
|
||||||
new_worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
|
new_worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
|
||||||
profile_home=session.get("profile_home"))
|
profile_home=session.get("profile_home"),
|
||||||
|
provider=getattr(session.get("agent"), "provider", None) or None)
|
||||||
except Exception:
|
except Exception:
|
||||||
session["slash_worker"] = None
|
session["slash_worker"] = None
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ def main():
|
|||||||
p = argparse.ArgumentParser(add_help=False)
|
p = argparse.ArgumentParser(add_help=False)
|
||||||
p.add_argument("--session-key", required=True)
|
p.add_argument("--session-key", required=True)
|
||||||
p.add_argument("--model", default="")
|
p.add_argument("--model", default="")
|
||||||
|
p.add_argument("--provider", default="")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
os.environ["HERMES_SESSION_KEY"] = args.session_key
|
os.environ["HERMES_SESSION_KEY"] = args.session_key
|
||||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||||
@@ -108,7 +109,11 @@ def main():
|
|||||||
_start_parent_death_watchdog(os.getppid())
|
_start_parent_death_watchdog(os.getppid())
|
||||||
_prepare_slash_worker_runtime()
|
_prepare_slash_worker_runtime()
|
||||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||||
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
|
# --provider pins the CLI to the parent agent's resolved provider (a MoA session's virtual
|
||||||
|
# "moa" provider included). Without it HermesCLI re-resolves from config and dispatches the
|
||||||
|
# MoA preset NAME to the configured real provider (#57283).
|
||||||
|
cli = HermesCLI(model=args.model or None, provider=args.provider or None,
|
||||||
|
compact=True, resume=args.session_key, verbose=False)
|
||||||
# Spurious stdin-EOF recovery (same shared-file-description O_NONBLOCK issue as the gateway entry
|
# Spurious stdin-EOF recovery (same shared-file-description O_NONBLOCK issue as the gateway entry
|
||||||
# point — any child inheriting fd 0 can flip the flag).
|
# point — any child inheriting fd 0 can flip the flag).
|
||||||
_sw_recovery_times: list[float] = []
|
_sw_recovery_times: list[float] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user