Coverage for src/lilbee/cli/launchers/launcher.py: 100%
43 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Launcher protocol and the orchestrator that runs any launcher."""
3from __future__ import annotations
5import subprocess
6from typing import Protocol
8import typer
10from lilbee.app.agent_configs.window import AGENT_CHAT_CTX_FLOOR, agent_chat_ctx_target
11from lilbee.app.models import installed_chat_model_refs, with_configured_chat
12from lilbee.cli.launchers.server import (
13 ensure_server_running,
14 served_chat_ctx,
15 stop_spawned_server,
16 wait_for_chat_warm,
17)
18from lilbee.core.config import cfg
20# The env var each launcher sets to the live session token; the written config
21# references it (opencode `{env:...}`, hermes `${...}`) so no literal lands on disk.
22LILBEE_TOKEN_ENV_VAR = "LILBEE_TOKEN" # noqa: S105 (env var name, not a secret)
24# Config key the spawned `lilbee serve` reads for its working chat window; a
25# LILBEE_ env var overrides config.toml, so the launcher raises it per launch.
26_CHAT_CTX_TARGET_ENV_VAR = "LILBEE_CHAT_N_CTX_TARGET"
29class Launcher(Protocol):
30 """A third-party AI client that lilbee knows how to launch."""
32 name: str
33 """CLI subcommand name; ``lilbee launch <name>`` runs this launcher."""
35 install_hint: str
36 """User-facing message shown when ``find_binary`` returns None."""
38 def find_binary(self) -> str | None:
39 """Return the absolute path to the client binary, or None if not installed."""
40 ...
42 def prepare(
43 self, *, token: str, port: int, model_refs: list[str]
44 ) -> tuple[list[str], dict[str, str]]:
45 """Return ``(extra_args, env)`` for the client invocation.
47 Side effects (skill installs, picker-state writes, config-file
48 materialization) happen here. The orchestrator does not introspect
49 them; whatever the launcher decides is the launcher's business.
50 """
51 ...
54def _warn_on_model_pin_gaps(model_refs: list[str]) -> None:
55 """Warn when the launched client cannot open on a lilbee-served chat model."""
56 if not model_refs:
57 # The client provider is written with no models, so it cannot use lilbee.
58 # Some clients (e.g. opencode) then silently fall back to their own default
59 # provider, so make the cause loud instead of leaving an empty picker.
60 typer.secho(
61 "Warning: no chat models are installed, so the launched client will have "
62 "no lilbee models to select. Pull one first, e.g. "
63 "'lilbee model pull Qwen/Qwen3-8B-GGUF'.",
64 err=True,
65 fg=typer.colors.YELLOW,
66 )
67 elif cfg.chat_model and str(cfg.chat_model) not in model_refs:
68 # The startup pin would point at a model the provider does not serve,
69 # so the client opens on its own default provider instead of lilbee.
70 typer.secho(
71 f"Warning: configured chat model '{cfg.chat_model}' is not installed; "
72 "the launched client will not open on a lilbee model. Pull it first "
73 "or set chat_model to an installed ref.",
74 err=True,
75 fg=typer.colors.YELLOW,
76 )
79def _warn_on_reused_small_window(port: int) -> None:
80 """Say when a reused server's window is below the agent floor, with the remedy.
82 The env override only reaches a freshly spawned ``lilbee serve``; a server
83 that was already running keeps the window it booted with, and this launch
84 cannot resize it. A cold chat engine reports no window yet, so nothing can
85 be checked then and the probe stays silent.
86 """
87 ctx = served_chat_ctx(port)
88 if ctx is None or ctx >= AGENT_CHAT_CTX_FLOOR:
89 return
90 typer.secho(
91 f"Warning: reusing the running lilbee server, which serves a {ctx:,}-token "
92 f"context window; an agent's first turn needs about {AGENT_CHAT_CTX_FLOOR:,}. "
93 "The window is fixed when the server starts. Stop that server, run "
94 "'lilbee engine stop', then re-run this command to start one sized for agents.",
95 err=True,
96 fg=typer.colors.YELLOW,
97 )
100def run_launcher(launcher: Launcher) -> None:
101 """Find the client, ensure a lilbee server is up, prepare, exec, clean up."""
102 binary = launcher.find_binary()
103 if binary is None:
104 typer.secho(launcher.install_hint, err=True, fg=typer.colors.RED)
105 raise typer.Exit(1)
106 # The launcher only reads the registry and talks to the spawned `lilbee serve`
107 # over HTTP; it runs no inference itself. Skip the eager warm so get_services()
108 # here doesn't start a second llama-swap that races the server's for the model
109 # port (the loser gets connection-refused). The spawned serve warms its own.
110 cfg.worker_pool_eager_start = False
111 # Size the served window for the agent before the server spawns: in-process so
112 # the warm wait and window warning agree, and via the child's env (LILBEE_
113 # overrides config.toml) so the spawned serve actually grows it.
114 agent_target = agent_chat_ctx_target(cfg.chat_n_ctx_target)
115 cfg.chat_n_ctx_target = agent_target
116 (token, port), spawned = ensure_server_running(
117 env_overrides={_CHAT_CTX_TARGET_ENV_VAR: str(agent_target)}
118 )
119 if spawned is None:
120 _warn_on_reused_small_window(port)
121 # Everything after the spawn runs under the finally so a raise from prepare()
122 # (e.g. the user declining opencode setup) or the warm wait can't leak the
123 # spawned `lilbee serve` process.
124 try:
125 native_refs = installed_chat_model_refs()
126 model_refs = with_configured_chat(native_refs, cfg.chat_model)
127 _warn_on_model_pin_gaps(model_refs)
128 # Wait out the cold model load before handing off, so the client opens onto a
129 # warm engine instead of an apparently-dead stream. Only meaningful when a
130 # native chat model is installed to warm; a remote-configured model has no
131 # local load to wait for.
132 if native_refs:
133 wait_for_chat_warm(port)
134 extra_args, env = launcher.prepare(token=token, port=port, model_refs=model_refs)
135 # The client paints its own UI only after its runtime boots, a few silent
136 # seconds; announce the handoff so the warm bar isn't followed by a dead
137 # screen with no explanation.
138 typer.secho(f"Launching {launcher.name}...", fg=typer.colors.GREEN)
139 # binary resolved via the launcher's find_binary on PATH; no shell.
140 result = subprocess.run([binary, *extra_args], env=env, check=False) # noqa: S603
141 finally:
142 if spawned is not None:
143 stop_spawned_server(spawned)
144 raise typer.Exit(result.returncode)