From 7e73ca48c005dcb8424df9068923f51195870f7a Mon Sep 17 00:00:00 2001 From: cyre Date: Fri, 17 Jul 2026 02:05:41 +0000 Subject: [PATCH 1/2] fix(memory): stage() mirrors manual lessons into AGENT_LEARNINGS.jsonl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual-stage path in .agent/tools/learn.py writes a candidate with evidence_ids: [now], promising an episodic record exists at that timestamp — but never writes one. The auto-derived candidate path mirrors to AGENT_LEARNINGS.jsonl correctly; the manual path does not. The result is a referential-integrity gap: a graduated lesson's evidence_ids points at a timestamp with no matching episodic record. Fix: add _append_episodic_mirror(), called right after the candidate file is written, using the same 'now' timestamp so evidence_ids[0] always resolves. Fail-open on OSError so a mirror-write failure never blocks staging. Minimal and additive — stdlib only, no new dependencies, no change to the auto-derived path. Found and fixed downstream in Perpetua-Tools (which vendors this project); contributing back per that project's dogfooding practice. --- .agent/tools/learn.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.agent/tools/learn.py b/.agent/tools/learn.py index 0a63b07..3154b0e 100644 --- a/.agent/tools/learn.py +++ b/.agent/tools/learn.py @@ -55,6 +55,34 @@ def _lesson_already_appended(cid): return False +def _append_episodic_mirror(cid, claim, ts, source="learn"): + """Mirror a manual stage into AGENT_LEARNINGS.jsonl so evidence_ids + referencing `ts` resolve to a real episodic record — matching the + auto-derived candidate path's existing behavior. Never raises; a + failure here must not block staging (same fail-open posture as + _lesson_already_appended's read-only probe). + """ + episodic_path = os.path.join(BASE, "memory/episodic/AGENT_LEARNINGS.jsonl") + entry = { + "timestamp": ts, + "skill": "learn", + "action": f"manual-stage:{cid}", + "result": "success", + "detail": f"Manually staged lesson {cid} via .agent/tools/learn.py: {claim!r}", + "pain_score": 1, + "importance": 6, + "reflection": "", + "confidence": 0.9, + "source": {"skill": "learn", "profile": "manual", "run_id": f"manual_{cid[:6]}"}, + "evidence_ids": [ts], + } + try: + with open(episodic_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except OSError: + pass # fail-open: staging must succeed even if the mirror write fails + + def stage(claim, conditions, source="learn", importance=7): os.makedirs(CANDIDATES, exist_ok=True) cid = pattern_id(claim, conditions) @@ -79,6 +107,7 @@ def stage(claim, conditions, source="learn", importance=7): path = os.path.join(CANDIDATES, f"{cid}.json") with open(path, "w") as f: json.dump(candidate, f, indent=2) + _append_episodic_mirror(cid, claim, now, source) return cid, path From a3d5139219ec83494b7efc5adbc16fc395eca273 Mon Sep 17 00:00:00 2001 From: cyre Date: Fri, 17 Jul 2026 02:06:10 +0000 Subject: [PATCH 2/2] test(memory): cover the manual-stage episodic mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone stdlib unittest (no third-party test dependency) since this project currently ships no test harness — intended as the first test file. Kept as a SEPARATE commit from the fix so it can be cherry-picked out if the maintainer prefers to merge the fix alone. Adapted from the downstream Perpetua-Tools suite that proved this fix. Covers: stage() writes exactly one matching episodic mirror; the candidate's evidence_ids[0] resolves to exactly one episodic record (the regression this targets); and _append_episodic_mirror fails open on write error. Run: python3 -m unittest .agent/tools/test_learn_episodic_mirror.py --- .agent/tools/test_learn_episodic_mirror.py | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .agent/tools/test_learn_episodic_mirror.py diff --git a/.agent/tools/test_learn_episodic_mirror.py b/.agent/tools/test_learn_episodic_mirror.py new file mode 100644 index 0000000..808e3cc --- /dev/null +++ b/.agent/tools/test_learn_episodic_mirror.py @@ -0,0 +1,85 @@ +"""Regression test for learn.py's manual-stage episodic mirror. + +Standalone (stdlib `unittest`, no third-party test dependency) because this +project currently ships no test harness — this is intended as the first test +file, easy to run with `python3 -m unittest` from the repo root or to remove +if the maintainer prefers to merge the fix without it. + +Adapted from the downstream Perpetua-Tools suite that proved this fix +(3 passing tests + 6 real-world learn.py invocations, all evidence_ids +verified to resolve). +""" + +import importlib.util +import json +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path + + +def _load_learn(base_dir): + """Load .agent/tools/learn.py with BASE/CANDIDATES pointed at base_dir. + + Sibling modules (text.word_set, cluster.pattern_id) are stubbed so the + test needs no part of the harness beyond learn.py itself. + """ + for name, attrs in [ + ("text", {"word_set": lambda *a, **k: set()}), + ("cluster", {"pattern_id": lambda claim, cond: "testcid" + str(abs(hash((claim, tuple(cond)))))[:6]}), + ]: + m = types.ModuleType(name) + for k, v in attrs.items(): + setattr(m, k, v) + sys.modules[name] = m + + module_path = Path(__file__).with_name("learn.py") + spec = importlib.util.spec_from_file_location("learn_under_test", module_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.BASE = base_dir + mod.CANDIDATES = os.path.join(base_dir, "memory", "candidates") + os.makedirs(mod.CANDIDATES, exist_ok=True) + os.makedirs(os.path.join(base_dir, "memory", "episodic"), exist_ok=True) + return mod + + +def _episodic(base_dir): + path = os.path.join(base_dir, "memory", "episodic", "AGENT_LEARNINGS.jsonl") + if not os.path.exists(path): + return [] + with open(path, encoding="utf-8") as f: + return [json.loads(line) for line in f if line.strip()] + + +class EpisodicMirrorTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_stage_writes_one_episodic_mirror(self): + mod = _load_learn(self.tmp) + cid, _ = mod.stage("Serialize timestamps in UTC", ["timestamps", "utc"]) + entries = _episodic(self.tmp) + mirrors = [e for e in entries if e.get("action") == f"manual-stage:{cid}"] + self.assertEqual(len(mirrors), 1) + + def test_evidence_id_resolves_to_the_mirror(self): + mod = _load_learn(self.tmp) + cid, path = mod.stage("Serialize timestamps in UTC", ["timestamps", "utc"]) + candidate = json.loads(Path(path).read_text()) + evidence_ts = candidate["evidence_ids"][0] + matching = [e for e in _episodic(self.tmp) if e["timestamp"] == evidence_ts] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0]["evidence_ids"], [evidence_ts]) + + def test_append_mirror_fails_open_on_write_error(self): + mod = _load_learn(self.tmp) + mod.BASE = os.path.join(self.tmp, "does-not-exist") + # Must not raise even though the target directory is missing. + mod._append_episodic_mirror("deadbeef", "claim", "2026-01-01T00:00:00+00:00") + + +if __name__ == "__main__": + unittest.main()