diff --git a/agent/learning_graph.py b/agent/learning_graph.py index c653107301..29311cd3a7 100644 --- a/agent/learning_graph.py +++ b/agent/learning_graph.py @@ -166,13 +166,22 @@ def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNo return edges +def _has_learning_signal(node: SkillNode) -> bool: + """Graph-worthy: agent-created, user-taught (/learn), or actually used. + + ``created_by="learn"`` is a learning-signal marker only — curator management stays keyed + strictly on ``"agent"`` (see ``tools.skill_usage._is_curator_managed_record``). + """ + return node.created_by in {"agent", "learn"} or node.use_count > 0 + + def build_learning_graph() -> dict[str, Any]: """Full payload for the desktop learning panel: non-base skills with real learning signal (agent-created or used) plus memory chunks as graph nodes.""" roots = [("base", Path(__file__).resolve().parent.parent / "skills"), ("profile", get_hermes_home() / "skills")] learned_skills = { name: node for name, node in build_skill_nodes(roots).items() - if node.source != "base" and (node.created_by == "agent" or node.use_count > 0) + if node.source != "base" and _has_learning_signal(node) } skill_edges, memory_cards = build_edges(learned_skills), _memory_cards() memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values())) diff --git a/tests/agent/test_learning_graph.py b/tests/agent/test_learning_graph.py index 3049273735..8b68fc6f11 100644 --- a/tests/agent/test_learning_graph.py +++ b/tests/agent/test_learning_graph.py @@ -79,3 +79,15 @@ def test_full_payload_shape_and_edge_integrity(tmp_path): assert graph["stats"]["nodes"] == len(skill_nodes) assert graph["stats"]["memory_nodes"] == len(graph["memory"]) assert all("timestamp" in n for n in graph["nodes"]) + + +def test_learning_signal_predicate_includes_user_taught_skills(): + """created_by='learn' (foreground /learn, zero uses) is graph-worthy; an unmarked + unused skill is not; 'installed' alone is not a learning signal.""" + from agent.learning_graph import SkillNode, _has_learning_signal + + assert _has_learning_signal(SkillNode(name="a", category="x", created_by="learn")) + assert _has_learning_signal(SkillNode(name="b", category="x", created_by="agent")) + assert _has_learning_signal(SkillNode(name="c", category="x", use_count=3)) + assert not _has_learning_signal(SkillNode(name="d", category="x")) + assert not _has_learning_signal(SkillNode(name="e", category="x", created_by="installed")) diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index bf4f4a03b8..87caa16e8c 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -432,10 +432,11 @@ class TestSkillManageDispatcher: usage = load_usage() result = json.loads(raw) assert result["success"] is True - # No provenance marker on a foreground create — record either missing - # entirely (telemetry best-effort) or present with created_by unset. + # Foreground create carries the "learn" learning-signal marker — never the + # curator-management opt-in ("agent"), and the record may be missing + # entirely (telemetry best-effort). rec = usage.get("test-skill") or {} - assert rec.get("created_by") in {None, "", False} + assert rec.get("created_by") in {"learn", None, "", False} def test_successful_mutations_emit_lifecycle_with_correlation(self, tmp_path): with ( diff --git a/tests/tools/test_skill_usage.py b/tests/tools/test_skill_usage.py index 87f22703de..d7267fa7f8 100644 --- a/tests/tools/test_skill_usage.py +++ b/tests/tools/test_skill_usage.py @@ -247,7 +247,7 @@ def test_created_skill_does_not_inherit_stale_identity_or_continuity( skill_usage.bump_use("recreated") record = skill_usage.get_record("recreated") - assert record["created_by"] is None + assert record["created_by"] == "learn" # foreground re-create: learning-signal marker, not curator opt-in assert record["use_count"] == 1 assert record["patch_count"] == 0 assert record["patch_generation"] == 0 @@ -556,3 +556,18 @@ def test_adopt_rejects_empty_name(skills_home): from tools.skill_usage import adopt_skill assert adopt_skill("")[0] is False + + +def test_foreground_create_stamps_learn_provenance_not_agent(skills_home): + """A foreground create (e.g. /learn) gets a learning-signal marker ("learn"), never the + curator-management opt-in ("agent") — /journey shows it, autonomous curation stays off.""" + from tools import skill_usage + + skill_usage.record_created("taught-skill", agent_created=False) + rec = skill_usage.get_record("taught-skill") + assert rec["created_by"] == "learn" + assert not skill_usage.is_curator_managed("taught-skill") + # And the background path is unchanged. + skill_usage.record_created("agent-skill", agent_created=True) + assert skill_usage.get_record("agent-skill")["created_by"] == "agent" + assert skill_usage.is_curator_managed("agent-skill") diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 48d7edc9d3..3066d2ac87 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -488,10 +488,15 @@ def bump_patch(skill_name: str, *, action: str = "patch", task_id: Optional[str] def record_created(skill_name: str, *, agent_created: bool, task_id: Optional[str] = None, session_id: Optional[str] = None) -> None: - """Persist creation provenance and emit a create fact; the record is reset (a create is a new logical skill).""" + """Persist creation provenance and emit a create fact; the record is reset (a create is a new logical skill). + + Foreground creates (``agent_created=False`` — e.g. ``/learn`` at the user's request) are stamped + ``created_by="learn"``: a learning-signal marker, NOT the curator-management opt-in (``"agent"``), + so /journey can show user-taught skills without handing them to autonomous curation. + """ def _apply(rec: Dict[str, Any]) -> Dict[str, Any]: rec.clear() - rec.update(_empty_record(), created_by="agent" if agent_created else None) + rec.update(_empty_record(), created_by="agent" if agent_created else "learn") return {"created_by": rec["created_by"]} _mutate_and_emit(skill_name, "created", _apply, task_id=task_id, session_id=session_id)