Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions install.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,16 +457,52 @@ def _config_get(profile, key):


_LIST_ITEM_RX = re.compile(r"^\s*-\s*(.*)$")
_MAP_PAIR_RX = re.compile(r"^\s*([^:\s][^:]*):(?:\s+(.*))?$")


def _block_mappings(lines):
"""A YAML block list of flat mappings (`- provider: p` with ` model: m` under it), as Hermes prints
fallback_providers, as a list of {key: text}; None when the lines are anything else."""
items = []
for line in lines:
item = _LIST_ITEM_RX.match(line)
pair = _MAP_PAIR_RX.match(item.group(1) if item else line)
if not pair or not (item or items):
return None
if item:
items.append({})
items[-1][pair.group(1).strip()] = (pair.group(2) or "").strip()
return items or None


def _canon(value):
"""A config value in one comparable form: a JSON list of mappings re-dumped with sorted keys and every scalar
as its text, so `[{"provider":"p","model":"m"}]` from a settings.conf matches what _cfg reads back; anything
else stripped as it is."""
text = (value or "").strip()
try:
data = json.loads(text)
except ValueError:
return text
if not (isinstance(data, list) and data and all(isinstance(x, dict) for x in data)):
return text
as_text = lambda v: v if isinstance(v, str) else json.dumps(v)
return json.dumps([{str(k): as_text(v) for k, v in m.items()} for m in data], sort_keys=True,
separators=(",", ":"))


def _cfg(profile, key):
"""The value `hermes config get` prints for a dotted key, as one line: a scalar as it is, a list as
'[a,b]' ('[]' when empty), None when the key is not set. Hermes does the reading, the way it does the writing."""
'[a,b]' ('[]' when empty), a list of mappings as JSON (see _canon), None when the key is not set. Hermes does
the reading, the way it does the writing."""
rc, out = _cfg_raw_many(profile, [key])[key]
out = (out or "").strip()
if rc != 0 or not out:
return None
lines = [l for l in out.splitlines() if l.strip()]
mappings = _block_mappings(lines) if _LIST_ITEM_RX.match(lines[0]) else None
if mappings is not None:
return _canon(json.dumps(mappings))
if all(_LIST_ITEM_RX.match(l) for l in lines):
return "[%s]" % ",".join(_LIST_ITEM_RX.match(l).group(1).strip() for l in lines)
return out if len(lines) > 1 else lines[0].strip()
Expand Down Expand Up @@ -840,7 +876,7 @@ def settings_problems(profile, role, tpl):
entries = [(k, role if k == "crew.role" else v) for k, v in _conf_entries(conf)]
have = _cfg_many(profile, [k for k, _v in entries])
return ["%s = %s, want %s" % (key, (have[key] or "unset").strip(), want)
for key, want in entries if (have[key] or "").strip() != want] + _override_lines(conf)[1]
for key, want in entries if _canon(have[key]) != _canon(want)] + _override_lines(conf)[1]


def consent_hint(name):
Expand Down Expand Up @@ -1432,7 +1468,7 @@ def _apply_settings(profile, conf_path):
have = _cfg_many(profile, [k for k, _v in entries])
changed = []
for key, val in entries:
if have[key] == val:
if have[key] is not None and _canon(have[key]) == _canon(val):
continue
r = h(profile, "config", "set", key, val)
if r.returncode == 0:
Expand Down
26 changes: 23 additions & 3 deletions tests/hermes_fake.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""A stand-in for `hermes -p <profile> config get/set`, shared by the installer tests (a test never starts the real launcher).

It keeps one dict per profile and prints what Hermes prints: a scalar as text, a list as ` - item` lines, `[]` for an
empty list, exit 1 for a key that is not set. `set` coerces a `[a,b]` literal to a list the way `hermes config set` does.
It keeps one dict per profile and prints what Hermes prints: a scalar as text, a list as ` - item` lines (a mapping
item as ` - k: v` with its other keys indented under it), `[]` for an empty list, exit 1 for a key that is not set. `set` coerces a `[a,b]` literal to a list the way `hermes config set` does.
"""
import json

Expand All @@ -23,6 +23,21 @@ def _items(text):
return [x.strip() for x in text[1:-1].split(",") if x.strip()]


def _mappings(text):
"""A JSON list of mappings (`[{"provider":"p","model":"m"}]`) as that list, else None."""
try:
data = json.loads(text.strip())
except ValueError:
return None
return data if isinstance(data, list) and data and all(isinstance(x, dict) for x in data) else None


def _block_item(mapping):
"""One list item the way Hermes prints a mapping: ` - k: v` and the other keys indented under it."""
lines = ["%s: %s" % (k, v) for k, v in mapping.items()]
return " - %s\n" % lines[0] + "".join(" %s\n" % l for l in lines[1:])


class FakeConfig:
def __init__(self):
self.store = {} # profile -> {dotted key: text as stored ('[a,b]' for a list)}
Expand All @@ -38,13 +53,18 @@ def __call__(self, profile, *args):
val = conf.get(args[2])
if val is None:
r.returncode = 1
elif _mappings(val) is not None:
r.stdout = "".join(_block_item(m) for m in _mappings(val))
elif val.startswith("[") and val != "[]":
r.stdout = "\n".join(" - %s" % i for i in _items(val)) + "\n"
else:
r.stdout = val + "\n"
elif args[:2] == ("config", "set"):
val = args[3]
conf[args[2]] = "[%s]" % ",".join(_items(val)) if val.startswith("[") else val
if _mappings(val) is not None:
conf[args[2]] = json.dumps(_mappings(val))
else:
conf[args[2]] = "[%s]" % ",".join(_items(val)) if val.startswith("[") else val
else:
r.returncode = 1
return r
18 changes: 18 additions & 0 deletions tests/test_install_template_overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ def test_fallback_providers_is_an_owner_key(self):
self.assertIn(("fallback_providers", "[{provider: p, model: m}]"), CI._conf_entries(self.conf))
self.assertEqual([], CI._override_lines(self.conf)[1])

def test_a_fallback_list_of_mappings_converges(self):
# Hermes prints a list of mappings as a YAML block; the override writes it as JSON. Same value: once applied,
# a second install changes nothing and --check is clean.
self.override("settings.conf", 'fallback_providers = [{"provider":"litellm","model":"reason"}]\n')
self.check_with(crew__role="worker", model__default="shipped-model", agent__max_turns="150",
fallback_providers="[]")
self.assertIn("fallback_providers", CI._apply_settings("p", self.conf))
self.assertEqual([], CI._apply_settings("p", self.conf), "applying twice must change nothing")
self.assertEqual([], CI.settings_problems("p", "worker", self.tpl))

def test_a_different_fallback_mapping_is_still_reported(self):
self.override("settings.conf", 'fallback_providers = [{"provider":"litellm","model":"reason"}]\n')
self.check_with(crew__role="worker", model__default="shipped-model", agent__max_turns="150",
fallback_providers='[{"provider":"litellm","model":"other"}]')
problems = CI.settings_problems("p", "worker", self.tpl)
self.assertEqual(1, len(problems), problems)
self.assertIn("fallback_providers", problems[0])

def test_a_soul_override_replaces_the_shipped_persona(self):
self.override("SOUL.md", "my persona\n")
self.assertEqual(os.path.join(self.over, "worker", "SOUL.md"), CI._soul_source(self.tpl))
Expand Down
Loading