From ec319e4e3ed4a4b6bde71a734cdc1c98fa8d9953 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 1 Jul 2026 16:25:48 -0500 Subject: [PATCH] fix(learning_graph): guard non-dict metadata so /journey can't crash parse_frontmatter's malformed-YAML fallback stores every value as a string, so a skill's `metadata` can be a str. `_category`/`_related` chained `.get("metadata", {}).get("hermes", {})` and blew up with `'str' object has no attribute 'get'`, taking down `build_learning_graph()` (and thus /journey and `hermes journey`) whenever any installed skill had bad frontmatter. Extract a `_hermes_meta()` helper that returns the nested dict only when it really is one. Fixes the whole class, not just the two call sites. --- agent/learning_graph.py | 12 ++++++++++-- tests/agent/test_learning_graph.py | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/agent/learning_graph.py b/agent/learning_graph.py index 6dc518b2a..b655e3e94 100644 --- a/agent/learning_graph.py +++ b/agent/learning_graph.py @@ -48,8 +48,16 @@ def _frontmatter(text: str) -> dict[str, Any]: return {} +def _hermes_meta(fm: dict[str, Any]) -> dict[str, Any]: + """``metadata.hermes`` as a dict, tolerant of the string-valued frontmatter + that ``parse_frontmatter``'s malformed-YAML fallback produces.""" + meta = fm.get("metadata") + hermes = meta.get("hermes") if isinstance(meta, dict) else None + return hermes if isinstance(hermes, dict) else {} + + def _related(fm: dict[str, Any]) -> list[str]: - raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills") + raw = fm.get("related_skills") or _hermes_meta(fm).get("related_skills") if isinstance(raw, list): return [str(r).strip() for r in raw if str(r).strip()] if isinstance(raw, str): @@ -58,7 +66,7 @@ def _related(fm: dict[str, Any]) -> list[str]: def _category(fm: dict[str, Any], skill_md: Path) -> str: - cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category") + cat = fm.get("category") or _hermes_meta(fm).get("category") if cat: return str(cat) # …/skills///SKILL.md diff --git a/tests/agent/test_learning_graph.py b/tests/agent/test_learning_graph.py index 7ba9780a1..7ff3d78cb 100644 --- a/tests/agent/test_learning_graph.py +++ b/tests/agent/test_learning_graph.py @@ -88,6 +88,30 @@ def test_memory_is_cards_split_on_separator(tmp_path): assert any(n["kind"] == "memory" for n in graph["nodes"]) +def test_malformed_frontmatter_metadata_does_not_crash(tmp_path): + """``parse_frontmatter``'s malformed-YAML fallback stores every value as a + string, so ``metadata`` can be a str. The graph must tolerate that instead + of crashing on chained ``.get()`` (the /journey base-CLI crash).""" + skill_dir = tmp_path / "skills" / "misc" / "bad-skill" + skill_dir.mkdir(parents=True) + # The unterminated quote makes yaml_load raise → fallback → metadata is a str. + skill_dir.joinpath("SKILL.md").write_text( + '---\nname: bad-skill\nmetadata: not-a-dict\ndescription: "oops\n---\n# Bad\n', + encoding="utf-8", + ) + + node = learning_graph.build_skill_nodes([("profile", tmp_path / "skills")])["bad-skill"] + + assert node.category == "misc" # directory fallback, not a crash + assert node.related == [] + + +def test_hermes_meta_tolerates_non_dict(): + assert learning_graph._hermes_meta({"metadata": "junk"}) == {} + assert learning_graph._hermes_meta({"metadata": {"hermes": "junk"}}) == {} + assert learning_graph._hermes_meta({"metadata": {"hermes": {"category": "x"}}}) == {"category": "x"} + + def test_full_payload_shape_and_edge_integrity(tmp_path): home = tmp_path / ".hermes" home.mkdir()