Skip to content

Commit b93a069

Browse files
utk7arshcodejunkie99
authored andcommitted
feat(memory): add semantic lesson retraction workflow
Introduce to selectively unlearn accepted semantic lessons with required rationale while preserving append-only audit history in . Update lesson rendering and recall loading to honor latest lesson state (including retracted entries), and add tests/docs covering retraction behavior end-to-end.
1 parent 8825397 commit b93a069

6 files changed

Lines changed: 367 additions & 21 deletions

File tree

.agent/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Daily driver, highest-leverage first:
7676
flywheel metrics. It does not train models or call APIs.
7777
- `list_candidates.py` / `graduate.py` / `reject.py` / `reopen.py` — review
7878
protocol for patterns the dream cycle has staged.
79+
- `retract_lesson.py <lesson_id> --rationale "..."` — stop an accepted lesson
80+
from being injected into future recall/context while preserving audit history.
7981
- `memory_reflect.py <skill> <action> <outcome>` — log a significant event.
8082

8183
## Rules
@@ -85,7 +87,7 @@ Daily driver, highest-leverage first:
8587
via `.agent/tools/memory_reflect.py`.
8688
4. Update `memory/working/WORKSPACE.md` as you work; archive on completion.
8789
5. Never hand-edit `memory/semantic/LESSONS.md` — it's rendered from
88-
`lessons.jsonl`. Use `graduate.py` / `reject.py` instead.
90+
`lessons.jsonl`. Use `graduate.py` / `reject.py` / `retract_lesson.py`.
8991
6. Follow `protocols/permissions.md`. Blocked means blocked.
9092
7. When a self-rewrite hook fires, propose conservative edits only.
9193
8. The harness is dumb on purpose. Reasoning lives in skills + the host agent.

.agent/memory/render_lessons.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,15 @@ def load_lessons(semantic_dir):
103103
if not os.path.exists(path):
104104
return []
105105
out = []
106-
for line in open(path):
107-
line = line.strip()
108-
if not line:
109-
continue
110-
try:
111-
out.append(json.loads(line))
112-
except json.JSONDecodeError:
113-
continue
106+
with open(path, encoding="utf-8") as f:
107+
for line in f:
108+
line = line.strip()
109+
if not line:
110+
continue
111+
try:
112+
out.append(json.loads(line))
113+
except json.JSONDecodeError:
114+
continue
114115
return out
115116

116117

@@ -124,6 +125,8 @@ def _bullet_for(lesson, superseded_by):
124125
sup_by = superseded_by.get(lid)
125126
if sup_by:
126127
return f"- ~~{claim}~~ <!-- {ann} superseded_by={sup_by} -->"
128+
if status == "retracted":
129+
return f"- ~~[RETRACTED] {claim}~~ <!-- {ann} -->"
127130
if status == "provisional":
128131
return f"- [PROVISIONAL] {claim} <!-- {ann} -->"
129132
return f"- {claim} <!-- {ann} -->"

.agent/tools/recall.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,45 @@ def _load_structured():
4242
"""
4343
if not os.path.exists(LESSONS_JSONL):
4444
return []
45-
out = []
46-
for line in open(LESSONS_JSONL):
47-
line = line.strip()
48-
if not line:
45+
rows = []
46+
with open(LESSONS_JSONL, encoding="utf-8") as f:
47+
for line in f:
48+
line = line.strip()
49+
if not line:
50+
continue
51+
try:
52+
rows.append(json.loads(line))
53+
except json.JSONDecodeError:
54+
continue
55+
56+
# lessons.jsonl is append-only, so only the latest row per lesson id is
57+
# considered active. This keeps retracted/provisional transitions from
58+
# leaking stale accepted rows into recall.
59+
latest = {}
60+
order = []
61+
no_id_rows = []
62+
for row in rows:
63+
lid = row.get("id")
64+
if not lid:
65+
no_id_rows.append(row)
4966
continue
50-
try:
51-
l = json.loads(line)
52-
except json.JSONDecodeError:
67+
if lid not in latest:
68+
order.append(lid)
69+
latest[lid] = row
70+
71+
out = []
72+
for lid in order:
73+
lesson = latest[lid]
74+
if lesson.get("status") != "accepted":
5375
continue
54-
if l.get("status") != "accepted":
76+
lesson.setdefault("_source", "lessons.jsonl")
77+
out.append(lesson)
78+
79+
for lesson in no_id_rows:
80+
if lesson.get("status") != "accepted":
5581
continue
56-
l.setdefault("_source", "lessons.jsonl")
57-
out.append(l)
82+
lesson.setdefault("_source", "lessons.jsonl")
83+
out.append(lesson)
5884
return out
5985

6086

@@ -68,7 +94,8 @@ def _load_markdown_fallback():
6894
"""
6995
if not os.path.exists(LESSONS_MD):
7096
return []
71-
text = open(LESSONS_MD).read()
97+
with open(LESSONS_MD, encoding="utf-8") as f:
98+
text = f.read()
7299
out = []
73100
for line in text.splitlines():
74101
s = line.strip()

.agent/tools/retract_lesson.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Retract an accepted lesson from semantic memory.
2+
3+
This is an append-only transition: a new row with the same lesson id is
4+
written to lessons.jsonl with status='retracted'. LESSONS.md is then
5+
re-rendered from the structured source of truth.
6+
"""
7+
import argparse
8+
import datetime
9+
import os
10+
import sys
11+
12+
BASE = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
13+
sys.path.insert(0, os.path.join(BASE, "memory"))
14+
15+
from render_lessons import append_lesson, load_lessons, render_lessons
16+
17+
SEMANTIC = os.path.join(BASE, "memory/semantic")
18+
19+
20+
def _latest_by_id(lesson_id, lessons):
21+
latest = None
22+
for lesson in lessons:
23+
if lesson.get("id") == lesson_id:
24+
latest = lesson
25+
return latest
26+
27+
28+
def retract_lesson(lesson_id, rationale, reviewer="host-agent", semantic_dir=SEMANTIC):
29+
if not str(rationale or "").strip():
30+
raise ValueError("retraction rationale is required")
31+
32+
lessons = load_lessons(semantic_dir)
33+
latest = _latest_by_id(lesson_id, lessons)
34+
if latest is None:
35+
raise ValueError(f"lesson not found: {lesson_id}")
36+
37+
status = latest.get("status")
38+
if status != "accepted":
39+
raise ValueError(
40+
f"lesson {lesson_id} is not retractable (current status: {status})"
41+
)
42+
43+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
44+
updated = {
45+
**latest,
46+
"status": "retracted",
47+
"retracted_at": now,
48+
"retracted_by": reviewer,
49+
"retraction_rationale": rationale,
50+
}
51+
52+
append_lesson(updated, semantic_dir)
53+
md_path = render_lessons(semantic_dir)
54+
return updated, md_path
55+
56+
57+
def main():
58+
parser = argparse.ArgumentParser(
59+
description="Retract an accepted lesson by lesson id."
60+
)
61+
parser.add_argument("lesson_id")
62+
parser.add_argument(
63+
"--rationale",
64+
required=True,
65+
help="Why this lesson should stop guiding future decisions.",
66+
)
67+
parser.add_argument("--reviewer", default="host-agent")
68+
args = parser.parse_args()
69+
70+
try:
71+
lesson, md_path = retract_lesson(
72+
lesson_id=args.lesson_id,
73+
rationale=args.rationale,
74+
reviewer=args.reviewer,
75+
)
76+
except ValueError as exc:
77+
print(f"ERROR: {exc}", file=sys.stderr)
78+
sys.exit(1)
79+
80+
print(f"retracted {args.lesson_id}")
81+
print(f"status: {lesson.get('status')}")
82+
print(f"re-rendered: {md_path}")
83+
84+
85+
if __name__ == "__main__":
86+
main()

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,15 @@ python3 .agent/tools/reject.py <id> --reason "too specific to generalize"
287287

288288
# requeue a previously-rejected candidate
289289
python3 .agent/tools/reopen.py <id>
290+
291+
# retract an accepted lesson from future recall/context (append-only audit)
292+
python3 .agent/tools/retract_lesson.py <lesson_id> --rationale "obsolete after migration"
290293
```
291294

292295
Graduated lessons land in `semantic/lessons.jsonl` (source of truth) and
293296
are rendered to `semantic/LESSONS.md`. Rejected candidates retain full
294-
decision history so recurring churn is visible, not fresh.
297+
decision history so recurring churn is visible, not fresh. Retracted lessons
298+
stay in history with `status=retracted` but are excluded from proactive recall.
295299

296300
See [`docs/architecture.md`](docs/architecture.md) for the full lifecycle.
297301

0 commit comments

Comments
 (0)