Skip to content

Commit a369df9

Browse files
author
codejunkie99
committed
refactor(.agent): host-agent review protocol + structural fixes
Written by Minimax-M2.7 in Claude Code Harness. Shifts the reasoning boundary: Python handles mechanical filing (cluster, extract, stage, heuristic prefilter, decay, archive). The host agent (Claude Code, Codex, Cursor, Windsurf) handles validation via CLI tools using the LLM it already has. The brain is no longer coupled to a specific provider SDK. Key modules - memory/auto_dream.py: staging-only dream cycle. No validation, no graduation, no git commit. Safe on Stop hooks or cron. - memory/validate.py: heuristic prefilter (length + exact duplicate against accepted lessons). Deterministic, no LLM. - memory/review_state.py: candidate lifecycle (staged/provisional/ accepted/rejected/superseded) + append-only decision log. Stamps evidence + lessons hash at decision time so re-stage only fires on real change, not churn. - memory/render_lessons.py: lessons.jsonl as source of truth; LESSONS.md rendered from it with sentinel-preserved user content. Auto-migrates legacy bullets on first run; dedupes by lesson id (handles provisional -> accepted transitions). - memory/cluster.py: proper single-linkage Jaccard clustering with bridge-merge; claim+conditions-stable pattern ids. - memory/promote.py: re-stage gate keyed on NEW evidence and specific-blocker presence, not reviewer identity or whole-file hashes. Decay-only evidence shrinkage doesn't churn. Full lifecycle respects all three subdirs (staged/rejected/graduated). - harness/context_budget.py: query-aware retrieval filtered to status=accepted only; loads AGENTS.md, DECISIONS.md, REVIEW_QUEUE.md per the stated read order. - harness/llm.py: used only by standalone conductor path; the memory layer does not import it. - harness/text.py: shared word_set + jaccard. - harness/hooks/_provenance.py: source metadata on every episodic entry (confidence, source{skill, run_id, commit_sha}, evidence_ids). - tools/{list_candidates,graduate,reject,reopen}.py: host-agent CLI. graduate.py requires --rationale and is atomic: lessons write first, candidate move last. --supersedes exempts the target lesson from the duplicate check. Workflow notes - Same pattern across cluster membership changes keeps the same id and lifecycle history. - Accepted lessons are terminal. Provisional acceptances re-enter review when new evidence arrives. - Rejected candidates re-enter review only when evidence grows OR the specific blocking lesson disappears. - Provisional supersessions do not strike the active lesson until the superseder is itself accepted. - Retrieval filters to accepted lessons only; empty filter returns empty rather than leaking raw markdown. Not tracked (stays per-user) - memory/personal/PREFERENCES.md - memory/working/* (WORKSPACE.md, REVIEW_QUEUE.md) - memory/episodic/AGENT_LEARNINGS.jsonl - memory/candidates/ (transient) - memory/semantic/lessons.jsonl (grows from graduations)
1 parent 6b8f457 commit a369df9

19 files changed

Lines changed: 1489 additions & 121 deletions

.agent/AGENTS.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,34 @@ same memory, skills, and protocols.
77
## Memory (read in this order)
88
- `memory/personal/PREFERENCES.md` — stable user conventions
99
- `memory/working/WORKSPACE.md` — current task state
10-
- `memory/semantic/LESSONS.md`distilled patterns, read before decisions
10+
- `memory/working/REVIEW_QUEUE.md`pending candidate lessons waiting for you
1111
- `memory/semantic/DECISIONS.md` — past architectural choices
12+
- `memory/semantic/LESSONS.md` — distilled patterns (rendered from `lessons.jsonl`)
1213
- `memory/episodic/AGENT_LEARNINGS.jsonl` — raw experience log (top-k by salience)
1314

15+
## Review Queue (host-agent responsibility)
16+
17+
Candidate lessons are clustered + staged automatically by `memory/auto_dream.py`.
18+
The host agent — you — does the actual review using the CLI tools below.
19+
20+
Check `memory/working/REVIEW_QUEUE.md` at session start. If pending > 10 or
21+
oldest staged > 7 days, review before substantive work.
22+
23+
Workflow:
24+
1. `python .agent/tools/list_candidates.py` — pending candidates, sorted by priority
25+
2. For each: decide accept / reject / defer based on claim, evidence_ids,
26+
cluster_size, and any contradictions with existing LESSONS.md
27+
3. `python .agent/tools/graduate.py <id> --rationale "..."` to accept
28+
4. `python .agent/tools/reject.py <id> --reason "..."` to reject
29+
5. `python .agent/tools/reopen.py <id>` to requeue a previously-rejected item
30+
6. Review in a **batch**, not one-by-one — cross-candidate contradictions
31+
only surface when you see multiple at once.
32+
33+
The heuristic prefilter in `memory/validate.py` has already dropped obvious
34+
junk (too-short claims, exact duplicates). Everything staged needs real
35+
judgment. Rationale is required for graduation — rubber-stamped promotions
36+
are the exact failure mode this layer prevents.
37+
1438
## Skills
1539
- `skills/_index.md` — read first for discovery
1640
- `skills/_manifest.jsonl` — machine-readable skill metadata
@@ -24,8 +48,12 @@ same memory, skills, and protocols.
2448

2549
## Rules
2650
1. Check memory before decisions you have been corrected on before.
27-
2. Log every significant action to `memory/episodic/AGENT_LEARNINGS.jsonl`.
28-
3. Update `memory/working/WORKSPACE.md` as you work; archive on completion.
29-
4. Follow `protocols/permissions.md` strictly. Blocked means blocked.
30-
5. When a self-rewrite hook fires, propose conservative edits only.
31-
6. The harness is dumb on purpose. Reasoning lives in skills and memory.
51+
2. If `REVIEW_QUEUE.md` shows backlog past threshold, handle it before the new task.
52+
3. Log every significant action to `memory/episodic/AGENT_LEARNINGS.jsonl`
53+
via `.agent/tools/memory_reflect.py`.
54+
4. Update `memory/working/WORKSPACE.md` as you work; archive on completion.
55+
5. Never hand-edit `memory/semantic/LESSONS.md` — it's rendered from
56+
`lessons.jsonl`. Use `graduate.py` / `reject.py` instead.
57+
6. Follow `protocols/permissions.md`. Blocked means blocked.
58+
7. When a self-rewrite hook fires, propose conservative edits only.
59+
8. The harness is dumb on purpose. Reasoning lives in skills + the host agent.

.agent/harness/conductor.py

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,12 @@
22
import os, sys
33
from context_budget import build_context
44
from hooks.post_execution import log_execution
5+
from llm import call_model
56

67
RESERVED = 40000
78
MAX_CTX = int(os.getenv("AGENT_MAX_CONTEXT", "128000"))
89

910

10-
def _call_model(system, user):
11-
provider = os.getenv("AGENT_PROVIDER", "anthropic").lower()
12-
if provider == "anthropic":
13-
from anthropic import Anthropic
14-
c = Anthropic()
15-
r = c.messages.create(
16-
model=os.getenv("AGENT_MODEL", "claude-sonnet-4-5"),
17-
max_tokens=4096, temperature=0.3,
18-
system=system,
19-
messages=[{"role": "user", "content": user}],
20-
)
21-
return r.content[0].text
22-
if provider == "openai":
23-
from openai import OpenAI
24-
c = OpenAI()
25-
r = c.chat.completions.create(
26-
model=os.getenv("AGENT_MODEL", "gpt-4o"),
27-
messages=[{"role": "system", "content": system},
28-
{"role": "user", "content": user}],
29-
)
30-
return r.choices[0].message.content
31-
raise ValueError(f"unknown provider: {provider}")
32-
33-
3411
SYSTEM_PREAMBLE = (
3512
"You are an agent with externalized memory, skills, and protocols.\n"
3613
"Your memory, skills, and constraints are in the context below.\n"
@@ -43,7 +20,7 @@ def run(user_input: str) -> str:
4320
context, used = build_context(user_input, budget=MAX_CTX - RESERVED)
4421
system = SYSTEM_PREAMBLE + context
4522
try:
46-
result = _call_model(system, user_input)
23+
result = call_model(system, user_input)
4724
log_execution("conductor", user_input[:100], result[:500], True)
4825
return result
4926
except Exception as e:

.agent/harness/context_budget.py

Lines changed: 126 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
"""Assemble context from memory + matched skills + protocols within a token budget."""
2-
import json, os
1+
"""Assemble context from memory + matched skills + protocols within a token budget.
2+
3+
Query-aware: episodes and lessons are scored against user_input so the agent
4+
sees the memory that matters for *this* task, not just the most salient memory
5+
in general. Always-on slots (PREFERENCES, WORKSPACE, permissions) are loaded
6+
whole regardless of query — they're cheap and safety-critical.
7+
"""
8+
import json, os, re, sys
39
from salience import salience_score
10+
from text import word_set, jaccard
411

512
ROOT = os.path.join(os.path.dirname(__file__), "..")
13+
# skill_loader lives in tools/ — make it importable without requiring callers
14+
# to configure PYTHONPATH themselves
15+
sys.path.insert(0, os.path.join(ROOT, "tools"))
16+
RELEVANCE_FLOOR = 0.3 # even zero-overlap episodes surface if very salient
17+
18+
# Keep in sync with memory/validate._extract_lesson_lines — both filters
19+
# want TERMINAL-only lesson content.
20+
_STATUS_RE = re.compile(r"status=(\w+)")
621

722

823
def _read(path, limit=None):
@@ -13,11 +28,22 @@ def _read(path, limit=None):
1328
return content[:limit] if limit else content
1429

1530

16-
def _tokens(text):
31+
def _token_estimate(text):
32+
"""Rough chars-to-tokens estimate for budgeting."""
1733
return len(text) // 4
1834

1935

20-
def _top_episodes(k=5):
36+
def _relevance(entry_text, query_words):
37+
"""Fraction of query words that appear in entry. 1.0 when no query."""
38+
if not query_words:
39+
return 1.0
40+
ew = word_set(entry_text)
41+
if not ew:
42+
return 0.0
43+
return len(query_words & ew) / len(query_words)
44+
45+
46+
def _top_episodes(query, k=5):
2147
path = os.path.join(ROOT, "memory/episodic/AGENT_LEARNINGS.jsonl")
2248
if not os.path.exists(path):
2349
return ""
@@ -30,7 +56,19 @@ def _top_episodes(k=5):
3056
entries.append(json.loads(line))
3157
except json.JSONDecodeError:
3258
continue
33-
entries.sort(key=salience_score, reverse=True)
59+
60+
query_words = word_set(query)
61+
62+
def _score(e):
63+
text = " ".join([
64+
e.get("action", ""),
65+
e.get("reflection", ""),
66+
e.get("detail", ""),
67+
])
68+
rel = _relevance(text, query_words)
69+
return salience_score(e) * (RELEVANCE_FLOOR + (1.0 - RELEVANCE_FLOOR) * rel)
70+
71+
entries.sort(key=_score, reverse=True)
3472
top = entries[:k]
3573
return "\n".join(
3674
f"- [{e.get('timestamp','')[:10]}] {e.get('action','')}: "
@@ -39,38 +77,104 @@ def _top_episodes(k=5):
3977
)
4078

4179

80+
def _lines_up_to_budget(lines, char_budget):
81+
out, used = [], 0
82+
for line in lines:
83+
block = f"- {line}\n"
84+
if used + len(block) > char_budget:
85+
break
86+
out.append(block)
87+
used += len(block)
88+
return "".join(out)
89+
90+
91+
def _top_lessons(query, lessons_md, char_budget=8000):
92+
"""Rank accepted lesson bullets by query overlap; fall back to original order.
93+
94+
Only terminal (status=accepted) lessons reach the host agent as retrievable
95+
guidance. Provisional, legacy, and superseded bullets exist in LESSONS.md
96+
for audit but must not be injected into the system prompt — they'd let the
97+
agent act on probationary or stale memory.
98+
"""
99+
lines = []
100+
for line in (lessons_md or "").splitlines():
101+
s = line.strip()
102+
if not s.startswith("- ") or len(s) <= 2:
103+
continue
104+
# Primary status filter: HTML annotation
105+
if "<!--" in s:
106+
ann = s.split("<!--", 1)[1]
107+
m = _STATUS_RE.search(ann)
108+
if m and m.group(1) != "accepted":
109+
continue
110+
text = s[2:].split("<!--")[0].strip()
111+
# Fallback: visual markers
112+
if text.startswith("[PROVISIONAL]"):
113+
continue
114+
if text.startswith("~~") and text.endswith("~~"):
115+
continue
116+
if text:
117+
lines.append(text)
118+
if not lines:
119+
# No accepted lessons → return empty. Returning raw markdown would
120+
# leak the non-terminal content the filter is designed to block.
121+
return ""
122+
123+
query_words = word_set(query)
124+
if not query_words:
125+
return _lines_up_to_budget(lines, char_budget)
126+
127+
scored = [(len(query_words & word_set(l)), i, l) for i, l in enumerate(lines)]
128+
relevant = sorted([s for s in scored if s[0] > 0], key=lambda s: (-s[0], s[1]))
129+
130+
if not relevant:
131+
return _lines_up_to_budget(lines, char_budget)
132+
return _lines_up_to_budget([l for _, _, l in relevant], char_budget)
133+
134+
42135
def build_context(user_input: str, budget: int = 88000):
43-
"""Returns (context_string, tokens_used). Lean by design."""
44-
from skill_loader import progressive_load # lazy to avoid cycles
136+
"""Returns (context_string, tokens_used). Lean and query-aware."""
45137
parts, used = [], 0
46138

47-
# always load: personal preferences + live workspace
48-
for rel in ("memory/personal/PREFERENCES.md", "memory/working/WORKSPACE.md"):
139+
# always load: personal preferences + live workspace + AGENTS map + DECISIONS
140+
# AGENTS.md and DECISIONS.md were missing despite AGENTS.md specifying the
141+
# read order — the standalone path was not faithful to its own contract.
142+
for rel in (
143+
"AGENTS.md",
144+
"memory/personal/PREFERENCES.md",
145+
"memory/working/WORKSPACE.md",
146+
"memory/working/REVIEW_QUEUE.md",
147+
"memory/semantic/DECISIONS.md",
148+
):
49149
text = _read(rel)
50150
if text:
51151
parts.append(f"# {rel}\n{text}")
52-
used += _tokens(text)
152+
used += _token_estimate(text)
53153

54-
# semantic lessons, truncated
55-
lessons = _read("memory/semantic/LESSONS.md", limit=8000)
56-
if lessons:
57-
parts.append(f"# LESSONS\n{lessons}")
58-
used += _tokens(lessons)
154+
# query-aware lessons
155+
lessons_raw = _read("memory/semantic/LESSONS.md")
156+
if lessons_raw:
157+
lessons = _top_lessons(user_input, lessons_raw, char_budget=8000)
158+
if lessons:
159+
parts.append(f"# LESSONS (query-relevant)\n{lessons}")
160+
used += _token_estimate(lessons)
59161

60-
# top episodic by salience
61-
episodes = _top_episodes(k=5)
162+
# query-aware top episodes
163+
episodes = _top_episodes(user_input, k=5)
62164
if episodes:
63-
parts.append(f"# RECENT EPISODES (top by salience)\n{episodes}")
64-
used += _tokens(episodes)
165+
parts.append(f"# RECENT EPISODES (salience x relevance)\n{episodes}")
166+
used += _token_estimate(episodes)
65167

66-
# matched skills only
168+
# matched skills only (progressive_load is already input-matched).
169+
# Lazy import so a missing skill_loader doesn't kill context assembly.
67170
try:
171+
from skill_loader import progressive_load
68172
skills = progressive_load(user_input)
69173
except Exception:
70174
skills = []
71175
for s in skills:
72176
block = f"## Skill: {s['name']}\n{s['content']}"
73-
t = _tokens(block)
177+
t = _token_estimate(block)
74178
if used + t < budget:
75179
parts.append(block)
76180
used += t
@@ -79,6 +183,6 @@ def build_context(user_input: str, budget: int = 88000):
79183
perms = _read("protocols/permissions.md")
80184
if perms:
81185
parts.append(f"# PERMISSIONS\n{perms}")
82-
used += _tokens(perms)
186+
used += _token_estimate(perms)
83187

84188
return "\n\n---\n\n".join(parts), used
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Shared provenance helpers for episodic entries. Cached per-process."""
2+
import os, subprocess
3+
4+
AGENT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
5+
6+
_CACHED_COMMIT = None
7+
_CACHED_RUN_ID = None
8+
9+
10+
def run_id():
11+
global _CACHED_RUN_ID
12+
if _CACHED_RUN_ID is None:
13+
_CACHED_RUN_ID = os.environ.get("AGENT_RUN_ID", f"pid-{os.getpid()}")
14+
return _CACHED_RUN_ID
15+
16+
17+
def commit_sha():
18+
global _CACHED_COMMIT
19+
if _CACHED_COMMIT is None:
20+
try:
21+
out = subprocess.run(
22+
["git", "rev-parse", "HEAD"],
23+
capture_output=True, text=True, timeout=2,
24+
cwd=AGENT_ROOT,
25+
)
26+
_CACHED_COMMIT = out.stdout.strip() if out.returncode == 0 else ""
27+
except Exception:
28+
_CACHED_COMMIT = ""
29+
return _CACHED_COMMIT
30+
31+
32+
def build_source(skill):
33+
return {"skill": skill, "run_id": run_id(), "commit_sha": commit_sha()}

.agent/harness/hooks/on_failure.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Failures are learning. High pain score + rewrite flag after repeat offenses."""
22
import json, datetime, os
3+
from ._provenance import build_source
34

45
ROOT = os.path.join(os.path.dirname(__file__), "..", "..")
56
EPISODIC = os.path.join(ROOT, "memory/episodic/AGENT_LEARNINGS.jsonl")
@@ -30,7 +31,8 @@ def _count_recent_failures(skill_name):
3031
return count
3132

3233

33-
def on_failure(skill_name, action, error, context=""):
34+
def on_failure(skill_name, action, error, context="", confidence=0.9,
35+
evidence_ids=None):
3436
entry = {
3537
"timestamp": datetime.datetime.now().isoformat(),
3638
"skill": skill_name,
@@ -42,8 +44,13 @@ def on_failure(skill_name, action, error, context=""):
4244
"reflection": f"FAILURE in {skill_name}: {type(error).__name__}: "
4345
f"{str(error)[:200]}",
4446
"context": context[:300],
47+
"confidence": confidence,
48+
"source": build_source(skill_name),
49+
"evidence_ids": list(evidence_ids) if evidence_ids else [],
4550
}
46-
recent = _count_recent_failures(skill_name)
51+
# _count_recent_failures returns PRIOR failures only; add 1 for this one
52+
# so the rewrite flag fires on the Nth failure, not the (N+1)th.
53+
recent = _count_recent_failures(skill_name) + 1
4754
if recent >= FAILURE_THRESHOLD:
4855
entry["reflection"] += (
4956
f" | THIS SKILL HAS FAILED {recent} TIMES IN {WINDOW_DAYS}d. "

.agent/harness/hooks/post_execution.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Runs after every action. Appends a structured entry to episodic memory."""
22
import json, datetime, os
3+
from ._provenance import build_source
34

45
ROOT = os.path.join(os.path.dirname(__file__), "..", "..")
56
EPISODIC = os.path.join(ROOT, "memory/episodic/AGENT_LEARNINGS.jsonl")
67

78

89
def log_execution(skill_name, action, result, success, reflection="",
9-
importance=5):
10+
importance=5, confidence=0.5, evidence_ids=None):
1011
os.makedirs(os.path.dirname(EPISODIC), exist_ok=True)
1112
entry = {
1213
"timestamp": datetime.datetime.now().isoformat(),
@@ -17,6 +18,9 @@ def log_execution(skill_name, action, result, success, reflection="",
1718
"pain_score": 2 if success else 7,
1819
"importance": importance,
1920
"reflection": reflection,
21+
"confidence": confidence,
22+
"source": build_source(skill_name),
23+
"evidence_ids": list(evidence_ids) if evidence_ids else [],
2024
}
2125
with open(EPISODIC, "a") as f:
2226
f.write(json.dumps(entry) + "\n")

0 commit comments

Comments
 (0)