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

1"""Launcher protocol and the orchestrator that runs any launcher.""" 

2 

3from __future__ import annotations 

4 

5import subprocess 

6from typing import Protocol 

7 

8import typer 

9 

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 

19 

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) 

23 

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" 

27 

28 

29class Launcher(Protocol): 

30 """A third-party AI client that lilbee knows how to launch.""" 

31 

32 name: str 

33 """CLI subcommand name; ``lilbee launch <name>`` runs this launcher.""" 

34 

35 install_hint: str 

36 """User-facing message shown when ``find_binary`` returns None.""" 

37 

38 def find_binary(self) -> str | None: 

39 """Return the absolute path to the client binary, or None if not installed.""" 

40 ... 

41 

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. 

46 

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 ... 

52 

53 

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 ) 

77 

78 

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. 

81 

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 ) 

98 

99 

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)