fix(cli): give a profile created without a clone a usable model block

Creating a bot from the desktop dialog builds the profile tree but no
config.yaml, so the profile resolves no provider and its first turn dies
with "No LLM provider configured" — created, but unable to run. Every bot
made that way was dead on arrival.

Seed the active profile's model block at creation. It is a copy, not a
link: profiles stay independent islands and editing either afterwards never
touches the other. "Fresh" means fresh skills and SOUL, not unreachable.
This commit is contained in:
Brooklyn Nicholson
2026-08-27 18:41:01 -05:00
parent 67854b50b0
commit 01a3e9a44c
2 changed files with 78 additions and 0 deletions

View File

@@ -758,6 +758,41 @@ def _read_config_model(profile_dir: Path) -> tuple:
return None, None
def _seed_model_config(profile_dir: Path) -> None:
"""Give a profile created without a clone source a usable model block.
Such a profile gets its directory tree but no ``config.yaml`` at all, so it
resolves no provider and its first turn dies with "No LLM provider
configured" — created, but unable to run. Copy the active profile's
``model`` block over at creation time.
This is a copy, not a link: profiles remain independent islands, and
editing either one afterwards never touches the other. "Fresh" means fresh
skills and SOUL, not unreachable.
"""
config_path = profile_dir / "config.yaml"
if config_path.exists():
return
try:
import yaml
from hermes_constants import get_hermes_home
from hermes_cli.config import read_user_config_raw
source = get_hermes_home() / "config.yaml"
if not source.is_file():
return
model_cfg = read_user_config_raw(source).get("model")
if not model_cfg:
return
config_path.write_text(
yaml.safe_dump({"model": model_cfg}, sort_keys=False),
encoding="utf-8",
)
except Exception:
# Creation must not fail over this; `hermes model` still sets it later.
pass
def _check_gateway_running(profile_dir: Path) -> bool:
"""Check if a gateway is running for a given profile directory.
@@ -1209,6 +1244,9 @@ def create_profile(
for subdir in _PROFILE_DIRS:
(profile_dir / subdir).mkdir(parents=True, exist_ok=True)
if source_dir is None:
_seed_model_config(profile_dir)
# Clone config files from source
if source_dir is not None:
for filename in _CLONE_CONFIG_FILES:

View File

@@ -133,6 +133,46 @@ class TestCreateProfile:
assert mode == 0o600
def test_fresh_profile_inherits_a_usable_model(self, profile_env):
"""A profile created without a clone source still resolves a provider.
Without this it gets no config.yaml at all, so its very first turn dies
with "No LLM provider configured" — created, but unable to run. Fresh
means fresh skills and SOUL, not unreachable.
"""
default_home = profile_env / ".hermes"
(default_home / "config.yaml").write_text(
"model:\n provider: nous\n default: some/model\n"
)
profile_dir = create_profile("coder", no_alias=True)
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text())
assert cfg["model"]["provider"] == "nous"
assert cfg["model"]["default"] == "some/model"
def test_fresh_profile_model_is_copied_not_linked(self, profile_env):
"""Profiles stay independent islands.
The model block is copied at creation, so later edits to the source
profile never reach one already created from it.
"""
default_home = profile_env / ".hermes"
(default_home / "config.yaml").write_text(
"model:\n provider: nous\n default: some/model\n"
)
profile_dir = create_profile("coder", no_alias=True)
(default_home / "config.yaml").write_text(
"model:\n provider: other\n default: changed/model\n"
)
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text())
assert cfg["model"]["provider"] == "nous"
assert cfg["model"]["default"] == "some/model"
def test_clone_config_copies_files(self, profile_env):