Coverage for src/lilbee/app/agent_configs/hermes.py: 100%
23 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""hermes config.yaml fragment builder: lilbee as a provider plus MCP search tool."""
3from __future__ import annotations
5from typing import Any
7from lilbee.app.agent_configs.merge import LILBEE_PROVIDER_KEY
8from lilbee.app.endpoints import MCP_PATH, OPENAI_PATH
9from lilbee.catalog import agent_model_id
11# hermes's custom provider defaults max_tokens to the full window, leaving ~no
12# room for input. Pin a sane output cap so the system prompt + history fit.
13_MAX_OUTPUT_TOKENS = 8192
14# hermes truncates a project context file (AGENTS.md and the like) to this many
15# chars by default, printing a visible TRUNCATED warning. Raised to the served
16# window below so large context files load intact.
17_HERMES_DEFAULT_CONTEXT_FILE_MAX_CHARS = 20000
20def hermes_config(
21 *,
22 base_url: str,
23 api_key: str,
24 model_refs: list[str],
25 default_ref: str | None = None,
26 chat_ctx: int | None = None,
27 include_mcp: bool = True,
28) -> dict[str, Any]:
29 """Return the hermes config fragment registering lilbee as a provider (and MCP).
31 ``api_key`` is embedded verbatim: a literal token for the paste path, or the
32 ``${LILBEE_TOKEN}`` reference for the launcher (hermes expands it from the env
33 at load, so the on-disk file never holds the literal token)."""
34 pin = default_ref or (model_refs[0] if model_refs else None)
35 # hermes shows the model id it is pinned to and has no separate display field,
36 # so pin the clean agent id (e.g. "Qwen3-235B-A22B") instead of the full GGUF
37 # path. lilbee's /v1 resolves it back to the ref, so routing is unchanged.
38 pin_id = agent_model_id(pin) if pin is not None else None
39 provider: dict[str, Any] = {
40 "api": f"{base_url}{OPENAI_PATH}",
41 "api_key": api_key,
42 "max_tokens": _MAX_OUTPUT_TOKENS,
43 }
44 if pin_id is not None:
45 provider["default_model"] = pin_id
46 if chat_ctx is not None:
47 provider["context_length"] = chat_ctx
48 config: dict[str, Any] = {"providers": {LILBEE_PROVIDER_KEY: provider}}
49 if chat_ctx is not None:
50 # Size hermes's context-file budget to the served window so a large project
51 # context file loads instead of being cut to the 20k default. chat_ctx is
52 # tokens; used as a char budget it stays within the model window.
53 config["context_file_max_chars"] = max(_HERMES_DEFAULT_CONTEXT_FILE_MAX_CHARS, chat_ctx)
54 if include_mcp:
55 # An `url` (no `transport` key) is hermes's HTTP MCP shape; `headers`
56 # carries the bearer with ${VAR} env resolution. A `transport` string
57 # makes hermes reject the entry (it must be a mapping) -> 0 connected.
58 config["mcp_servers"] = {
59 LILBEE_PROVIDER_KEY: {
60 "url": f"{base_url}{MCP_PATH}",
61 "headers": {"Authorization": f"Bearer {api_key}"},
62 }
63 }
64 if pin_id is not None:
65 # Dict form binds the active model to the lilbee provider explicitly, so the
66 # pin is unambiguous even when another provider could serve the same ref.
67 # `model.max_tokens` is hermes's documented winning output cap: without it
68 # hermes requests the full window as output, so input+output overflows and
69 # it misreads the error as "prompt too long" and compresses to nothing.
70 config["model"] = {
71 "default": pin_id,
72 "provider": LILBEE_PROVIDER_KEY,
73 "max_tokens": _MAX_OUTPUT_TOKENS,
74 }
75 return config