diff --git a/agent/curator.py b/agent/curator.py index f9c10d05656..d0147d4c4fb 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -899,9 +899,12 @@ def _build_rename_summary( • flaky-thing — pruned (stale) • old-utility → spreadsheet-ops full report: hermes curator status + keep an umbrella stable: hermes curator pin document-tools Cap is 10 entries so a 50-skill consolidation doesn't blow up - agent.log; the full list is always in REPORT.md. + agent.log; the full list is always in REPORT.md. The pin hint only + appears when at least one consolidation produced an umbrella worth + pinning (pruned-only runs skip it). """ after_by_name = {r.get("name"): r for r in after_report if isinstance(r, dict)} after_names = set(after_by_name.keys()) @@ -950,6 +953,17 @@ def _build_rename_summary( if total > SHOW: lines.append(f" … and {total - SHOW} more") lines.append("full report: hermes curator status") + # Pin hint — only surface it when there's actually a destination skill + # worth pinning. The umbrella skills that absorbed content are the natural + # candidates: pinning one tells future curator runs to leave it alone. + # Pruned-only runs don't get this hint (nothing surviving to pin). + if consolidated: + umbrellas = sorted({e.get("into") for e in consolidated if e.get("into")}) + if umbrellas: + example = umbrellas[0] + lines.append( + f"keep an umbrella stable: hermes curator pin {example}" + ) return "\n".join(lines) diff --git a/tests/agent/test_curator_classification.py b/tests/agent/test_curator_classification.py index 29187c5a641..804e5a65ecc 100644 --- a/tests/agent/test_curator_classification.py +++ b/tests/agent/test_curator_classification.py @@ -1020,3 +1020,106 @@ def test_rename_summary_mixed_consolidation_and_pruning(curator_env): assert merge_idx < drop_idx, "consolidated should render before pruned" assert "merge-me → umbrella" in lines[merge_idx] assert "drop-me — pruned (stale)" in lines[drop_idx] + + +# --------------------------------------------------------------------------- +# Pin hint — surfaces `hermes curator pin ` in the rename block so +# users learn the command exists at the moment they care (a consolidation +# just landed against their library). The hint is gated on having at least +# one umbrella destination — pruned-only runs skip it. +# --------------------------------------------------------------------------- + + +def test_rename_summary_pin_hint_appears_when_consolidation_produced_umbrella(curator_env): + """When at least one skill was absorbed into an umbrella, hint at pinning it.""" + result = curator_env._build_rename_summary( + before_names={"pdf-extraction", "docx-extraction", "document-tools"}, + after_report=[{"name": "document-tools", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "pdf-extraction", + "absorbed_into": "document-tools", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "docx-extraction", + "absorbed_into": "document-tools", + }), + }, + ], + model_final="", + ) + assert "hermes curator pin document-tools" in result + assert "keep an umbrella stable" in result + + +def test_rename_summary_pin_hint_skipped_for_pruned_only_runs(curator_env): + """Pruned-only runs have nothing surviving to pin — hint should not appear.""" + result = curator_env._build_rename_summary( + before_names={"old-flaky-thing", "another-stale", "keeper"}, + after_report=[{"name": "keeper", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "old-flaky-thing", + "absorbed_into": "", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "another-stale", + "absorbed_into": "", + }), + }, + ], + model_final="", + ) + # Block still renders (skills were archived) but no pin hint. + assert "archived 2 skill(s):" in result + assert "hermes curator pin" not in result + assert "keep an umbrella stable" not in result + + +def test_rename_summary_pin_hint_picks_one_umbrella_when_multiple_absorbed(curator_env): + """Multiple umbrellas → hint shows one example (alphabetically first), not a list.""" + result = curator_env._build_rename_summary( + before_names={"a-skill", "b-skill", "umbrella-zeta", "umbrella-alpha"}, + after_report=[ + {"name": "umbrella-zeta", "state": "active"}, + {"name": "umbrella-alpha", "state": "active"}, + ], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "a-skill", + "absorbed_into": "umbrella-zeta", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "b-skill", + "absorbed_into": "umbrella-alpha", + }), + }, + ], + model_final="", + ) + # Sorted picks alphabetically first. + assert "hermes curator pin umbrella-alpha" in result + # Exactly one hint line, not one per umbrella. + pin_lines = [ln for ln in result.splitlines() if "hermes curator pin" in ln] + assert len(pin_lines) == 1