From 28869cae67cce490a6d4ddb8436e0e9ab2626115 Mon Sep 17 00:00:00 2001 From: codejunkie99 Date: Fri, 17 Apr 2026 02:32:37 +0530 Subject: [PATCH 01/10] refactor(.agent): host-agent review protocol + structural fixes Shifts the reasoning boundary: Python handles mechanical filing (cluster, extract, stage, prefilter, decay, archive). The host agent (Claude Code, Codex, Windsurf, ...) handles validation via CLI tools using the LLM it already has. The brain no longer needs its own ANTHROPIC_API_KEY. Bug fixes - promote.graduate_validated batch-unsoundness (fixed then the function was removed; replaced by tools/graduate.py which handles one candidate at a time against a fresh LESSONS.md read). - hooks/on_failure.py: rewrite-flag off-by-one (was firing on 4th failure; now 3rd, as FAILURE_THRESHOLD=3 implies). - hooks/post_execution.py + on_failure.py: ornamental `contradicts` field removed from episodic writes (never populated on raw events). Restructure - memory/auto_dream.py: staging-only. No validation, no graduation, no git commit. Safe to run on Stop hooks or cron. - memory/validate.py: gutted to heuristic prefilter (length + exact duplicate). No LLM calls. No more Anthropic SDK coupling. - memory/review_state.py (new): candidate lifecycle (staged / provisional / accepted / rejected / superseded) + append-only decision log. rejection_count + reopen history surface recurring churn instead of looking fresh. - memory/render_lessons.py (new): semantic/lessons.jsonl is the source of truth; LESSONS.md is rendered. User content above the `## Auto-promoted entries will be appended below` sentinel is preserved across renders. - memory/cluster.py (new): Jaccard content clustering + deterministic extractive pattern (canonical highest-salience episode as claim). Replaces action-prefix clustering. - harness/context_budget.py: query-aware retrieval (salience x relevance). Loads AGENTS.md, DECISIONS.md, REVIEW_QUEUE.md per the stated read order (previously silently skipped). - harness/llm.py (new): _call_model factored out of conductor. Used only by the standalone conductor path; memory/ no longer imports it. - harness/text.py (new): shared STOPWORDS, word_set, jaccard. - harness/hooks/_provenance.py (new): source metadata for episodic entries (confidence, source{skill, run_id, commit_sha}, evidence_ids). - tools/list_candidates.py, graduate.py, reject.py, reopen.py (new): host-agent CLI. Graduation requires --rationale so silent rubber-stamp is structurally impossible. Host-agent review protocol - auto_dream stages candidates + writes memory/working/REVIEW_QUEUE.md - Host agent checks the queue at session start - list_candidates.py to see pending (priority = cluster_size * canonical_salience * age_factor) - graduate.py --rationale "..." or reject.py --reason "..." - Review is batched so cross-candidate contradictions surface - Heuristic prefilter catches obvious junk; subjective judgment is the host agent's, not the brain's Addresses Codex review of the earlier draft - Batch-unsound graduate_validated: removed; per-call graduate.py reads fresh LESSONS.md each time. - Unattended reasoning + git commits on Stop hook: stripped to staging-only. - Provider coupling in validate.py: removed. - Fragile candidate state: durable lifecycle + decision log. - AGENTS.md / DECISIONS.md not loaded despite stated contract: now loaded. - Off-by-one rewrite trigger: fixed. - Anecdote-as-claim extraction: unchanged; mitigated by host-agent review. (Upgrading extraction is Phase 4 work.) Not pushed (stays per-user) - memory/personal/PREFERENCES.md - memory/working/WORKSPACE.md - memory/working/REVIEW_QUEUE.md (regenerated by auto_dream) - memory/episodic/AGENT_LEARNINGS.jsonl - memory/candidates/ - memory/semantic/lessons.jsonl (grows from graduations) --- .agent/AGENTS.md | 40 ++++- .agent/harness/conductor.py | 27 +--- .agent/harness/context_budget.py | 128 +++++++++++++--- .agent/harness/hooks/_provenance.py | 33 ++++ .agent/harness/hooks/on_failure.py | 11 +- .agent/harness/hooks/post_execution.py | 6 +- .agent/harness/llm.py | 38 +++++ .agent/harness/text.py | 30 ++++ .agent/memory/auto_dream.py | 91 ++++++++--- .agent/memory/cluster.py | 82 ++++++++++ .agent/memory/promote.py | 104 ++++++++----- .agent/memory/render_lessons.py | 123 +++++++++++++++ .agent/memory/review_state.py | 203 +++++++++++++++++++++++++ .agent/memory/validate.py | 79 ++++++++++ .agent/tools/graduate.py | 84 ++++++++++ .agent/tools/list_candidates.py | 62 ++++++++ .agent/tools/memory_reflect.py | 16 +- .agent/tools/reject.py | 38 +++++ .agent/tools/reopen.py | 34 +++++ 19 files changed, 1109 insertions(+), 120 deletions(-) create mode 100644 .agent/harness/hooks/_provenance.py create mode 100644 .agent/harness/llm.py create mode 100644 .agent/harness/text.py create mode 100644 .agent/memory/cluster.py create mode 100644 .agent/memory/render_lessons.py create mode 100644 .agent/memory/review_state.py create mode 100644 .agent/memory/validate.py create mode 100644 .agent/tools/graduate.py create mode 100644 .agent/tools/list_candidates.py create mode 100644 .agent/tools/reject.py create mode 100644 .agent/tools/reopen.py diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index 4191b53..c324b26 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -7,10 +7,34 @@ same memory, skills, and protocols. ## Memory (read in this order) - `memory/personal/PREFERENCES.md` — stable user conventions - `memory/working/WORKSPACE.md` — current task state -- `memory/semantic/LESSONS.md` — distilled patterns, read before decisions +- `memory/working/REVIEW_QUEUE.md` — pending candidate lessons waiting for you - `memory/semantic/DECISIONS.md` — past architectural choices +- `memory/semantic/LESSONS.md` — distilled patterns (rendered from `lessons.jsonl`) - `memory/episodic/AGENT_LEARNINGS.jsonl` — raw experience log (top-k by salience) +## Review Queue (host-agent responsibility) + +Candidate lessons are clustered + staged automatically by `memory/auto_dream.py`. +The host agent — you — does the actual review using the CLI tools below. + +Check `memory/working/REVIEW_QUEUE.md` at session start. If pending > 10 or +oldest staged > 7 days, review before substantive work. + +Workflow: +1. `python .agent/tools/list_candidates.py` — pending candidates, sorted by priority +2. For each: decide accept / reject / defer based on claim, evidence_ids, + cluster_size, and any contradictions with existing LESSONS.md +3. `python .agent/tools/graduate.py --rationale "..."` to accept +4. `python .agent/tools/reject.py --reason "..."` to reject +5. `python .agent/tools/reopen.py ` to requeue a previously-rejected item +6. Review in a **batch**, not one-by-one — cross-candidate contradictions + only surface when you see multiple at once. + +The heuristic prefilter in `memory/validate.py` has already dropped obvious +junk (too-short claims, exact duplicates). Everything staged needs real +judgment. Rationale is required for graduation — rubber-stamped promotions +are the exact failure mode this layer prevents. + ## Skills - `skills/_index.md` — read first for discovery - `skills/_manifest.jsonl` — machine-readable skill metadata @@ -24,8 +48,12 @@ same memory, skills, and protocols. ## Rules 1. Check memory before decisions you have been corrected on before. -2. Log every significant action to `memory/episodic/AGENT_LEARNINGS.jsonl`. -3. Update `memory/working/WORKSPACE.md` as you work; archive on completion. -4. Follow `protocols/permissions.md` strictly. Blocked means blocked. -5. When a self-rewrite hook fires, propose conservative edits only. -6. The harness is dumb on purpose. Reasoning lives in skills and memory. +2. If `REVIEW_QUEUE.md` shows backlog past threshold, handle it before the new task. +3. Log every significant action to `memory/episodic/AGENT_LEARNINGS.jsonl` + via `.agent/tools/memory_reflect.py`. +4. Update `memory/working/WORKSPACE.md` as you work; archive on completion. +5. Never hand-edit `memory/semantic/LESSONS.md` — it's rendered from + `lessons.jsonl`. Use `graduate.py` / `reject.py` instead. +6. Follow `protocols/permissions.md`. Blocked means blocked. +7. When a self-rewrite hook fires, propose conservative edits only. +8. The harness is dumb on purpose. Reasoning lives in skills + the host agent. diff --git a/.agent/harness/conductor.py b/.agent/harness/conductor.py index a19e90a..ad5a051 100644 --- a/.agent/harness/conductor.py +++ b/.agent/harness/conductor.py @@ -2,35 +2,12 @@ import os, sys from context_budget import build_context from hooks.post_execution import log_execution +from llm import call_model RESERVED = 40000 MAX_CTX = int(os.getenv("AGENT_MAX_CONTEXT", "128000")) -def _call_model(system, user): - provider = os.getenv("AGENT_PROVIDER", "anthropic").lower() - if provider == "anthropic": - from anthropic import Anthropic - c = Anthropic() - r = c.messages.create( - model=os.getenv("AGENT_MODEL", "claude-sonnet-4-5"), - max_tokens=4096, temperature=0.3, - system=system, - messages=[{"role": "user", "content": user}], - ) - return r.content[0].text - if provider == "openai": - from openai import OpenAI - c = OpenAI() - r = c.chat.completions.create( - model=os.getenv("AGENT_MODEL", "gpt-4o"), - messages=[{"role": "system", "content": system}, - {"role": "user", "content": user}], - ) - return r.choices[0].message.content - raise ValueError(f"unknown provider: {provider}") - - SYSTEM_PREAMBLE = ( "You are an agent with externalized memory, skills, and protocols.\n" "Your memory, skills, and constraints are in the context below.\n" @@ -43,7 +20,7 @@ def run(user_input: str) -> str: context, used = build_context(user_input, budget=MAX_CTX - RESERVED) system = SYSTEM_PREAMBLE + context try: - result = _call_model(system, user_input) + result = call_model(system, user_input) log_execution("conductor", user_input[:100], result[:500], True) return result except Exception as e: diff --git a/.agent/harness/context_budget.py b/.agent/harness/context_budget.py index 4068e43..67692c5 100644 --- a/.agent/harness/context_budget.py +++ b/.agent/harness/context_budget.py @@ -1,8 +1,19 @@ -"""Assemble context from memory + matched skills + protocols within a token budget.""" -import json, os +"""Assemble context from memory + matched skills + protocols within a token budget. + +Query-aware: episodes and lessons are scored against user_input so the agent +sees the memory that matters for *this* task, not just the most salient memory +in general. Always-on slots (PREFERENCES, WORKSPACE, permissions) are loaded +whole regardless of query — they're cheap and safety-critical. +""" +import json, os, sys from salience import salience_score +from text import word_set, jaccard ROOT = os.path.join(os.path.dirname(__file__), "..") +# skill_loader lives in tools/ — make it importable without requiring callers +# to configure PYTHONPATH themselves +sys.path.insert(0, os.path.join(ROOT, "tools")) +RELEVANCE_FLOOR = 0.3 # even zero-overlap episodes surface if very salient def _read(path, limit=None): @@ -13,11 +24,22 @@ def _read(path, limit=None): return content[:limit] if limit else content -def _tokens(text): +def _token_estimate(text): + """Rough chars-to-tokens estimate for budgeting.""" return len(text) // 4 -def _top_episodes(k=5): +def _relevance(entry_text, query_words): + """Fraction of query words that appear in entry. 1.0 when no query.""" + if not query_words: + return 1.0 + ew = word_set(entry_text) + if not ew: + return 0.0 + return len(query_words & ew) / len(query_words) + + +def _top_episodes(query, k=5): path = os.path.join(ROOT, "memory/episodic/AGENT_LEARNINGS.jsonl") if not os.path.exists(path): return "" @@ -30,7 +52,19 @@ def _top_episodes(k=5): entries.append(json.loads(line)) except json.JSONDecodeError: continue - entries.sort(key=salience_score, reverse=True) + + query_words = word_set(query) + + def _score(e): + text = " ".join([ + e.get("action", ""), + e.get("reflection", ""), + e.get("detail", ""), + ]) + rel = _relevance(text, query_words) + return salience_score(e) * (RELEVANCE_FLOOR + (1.0 - RELEVANCE_FLOOR) * rel) + + entries.sort(key=_score, reverse=True) top = entries[:k] return "\n".join( f"- [{e.get('timestamp','')[:10]}] {e.get('action','')}: " @@ -39,38 +73,88 @@ def _top_episodes(k=5): ) +def _lines_up_to_budget(lines, char_budget): + out, used = [], 0 + for line in lines: + block = f"- {line}\n" + if used + len(block) > char_budget: + break + out.append(block) + used += len(block) + return "".join(out) + + +def _top_lessons(query, lessons_md, char_budget=8000): + """Rank LESSONS.md bullets by query overlap; fall back to original order. + + Section headers (## ...) get dropped when ranking — the LLM only needs + the distilled claim lines. When the query has zero overlap with any + bullet, fall back to the original order so the agent still sees general + knowledge. + """ + lines = [] + for line in (lessons_md or "").splitlines(): + s = line.strip() + if s.startswith("- ") and len(s) > 2: + lines.append(s[2:].split("" + if status == "provisional": + return f"- [PROVISIONAL] {claim} " + return f"- {claim} " + + +def _build_auto_section(lessons): + superseded_by = {} + for L in lessons: + sup = L.get("supersedes") + if sup: + superseded_by[sup] = L.get("id") + + groups = defaultdict(list) + for L in lessons: + month = (L.get("accepted_at") or "")[:7] or "unknown" + groups[month].append(L) + + lines = [] + for month in sorted(groups.keys(), reverse=True): + lines.append(f"### {month}") + lines.append("") + for L in groups[month]: + lines.append(_bullet_for(L, superseded_by)) + lines.append("") + return "\n".join(lines).rstrip() + "\n" if lines else "" + + +def render_lessons(semantic_dir): + """Re-render LESSONS.md. Preserves hand-curated content above the sentinel.""" + lessons = load_lessons(semantic_dir) + auto_section = _build_auto_section(lessons) + + path = os.path.join(semantic_dir, LESSONS_MD) + + if os.path.exists(path): + existing = open(path).read() + if SENTINEL in existing: + prefix = existing.split(SENTINEL)[0].rstrip() + new = f"{prefix}\n\n{SENTINEL}\n\n{auto_section}" + else: + new = existing.rstrip() + f"\n\n{SENTINEL}\n\n{auto_section}" + else: + header = ( + "# Lessons\n\n" + "> _Auto-managed below. Hand-curated preamble + seed lessons " + "above the sentinel are preserved across renders._\n" + ) + new = f"{header}\n{SENTINEL}\n\n{auto_section}" + + os.makedirs(semantic_dir, exist_ok=True) + with open(path, "w") as f: + f.write(new) + return path + + +def render_lessons_as_text(semantic_dir): + return open(render_lessons(semantic_dir)).read() + + +if __name__ == "__main__": + import sys + sem = sys.argv[1] if len(sys.argv) > 1 else os.path.join( + os.path.dirname(os.path.abspath(__file__)), "semantic") + path = render_lessons(sem) + print(f"rendered: {path}") diff --git a/.agent/memory/review_state.py b/.agent/memory/review_state.py new file mode 100644 index 0000000..93c3ff6 --- /dev/null +++ b/.agent/memory/review_state.py @@ -0,0 +1,203 @@ +"""Candidate lifecycle + decision log. + +Each candidate JSON under memory/candidates/ carries: + status: staged | provisional | accepted | rejected | superseded + decisions: append-only list of {ts, action, reviewer, notes, **fields} + +Host-agent CLI tools (.agent/tools/graduate.py, reject.py, reopen.py) call +into this module to transition state. Rejection and re-stage preserve full +history so a candidate that keeps reappearing is visibly churning rather +than looking novel each time. +""" +import os, json, datetime + + +def _now(): + return datetime.datetime.now().isoformat() + + +def _touch(candidate, action, reviewer, notes="", **fields): + decisions = candidate.setdefault("decisions", []) + decisions.append({ + "ts": _now(), + "action": action, + "reviewer": reviewer, + "notes": notes, + **fields, + }) + + +def load_candidate(path): + with open(path) as f: + return json.load(f) + + +def save_candidate(candidate, path): + with open(path, "w") as f: + json.dump(candidate, f, indent=2) + + +def stage_candidate(candidate_path, reviewer="auto_dream"): + """Mark a freshly-written candidate as staged with an initial decision entry.""" + cand = load_candidate(candidate_path) + cand.setdefault("status", "staged") + _touch(cand, "staged", reviewer) + save_candidate(cand, candidate_path) + + +def mark_graduated(candidate_id, reviewer, rationale, candidates_dir, + provisional=False): + """Move a staged candidate to candidates/graduated/ with an accept decision. + + Returns the graduated candidate dict. Caller is responsible for writing + the structured lesson entry to semantic/lessons.jsonl and re-rendering + LESSONS.md — this function only handles the candidate side. + """ + src = os.path.join(candidates_dir, f"{candidate_id}.json") + if not os.path.exists(src): + raise FileNotFoundError(f"candidate not found: {candidate_id}") + cand = load_candidate(src) + cand["status"] = "provisional" if provisional else "accepted" + cand["accepted_at"] = _now() + cand["reviewer"] = reviewer + cand["rationale"] = rationale + _touch(cand, "graduated", reviewer, notes=rationale, + provisional=provisional) + + graduated_dir = os.path.join(candidates_dir, "graduated") + os.makedirs(graduated_dir, exist_ok=True) + dst = os.path.join(graduated_dir, f"{candidate_id}.json") + save_candidate(cand, dst) + os.remove(src) + return cand + + +def mark_rejected(candidate_id, reviewer, reason, candidates_dir): + """Move a staged candidate to candidates/rejected/ with a reject decision. + + rejection_count tracks how many times this id has been rejected — if it + keeps coming back, the reviewer sees churn instead of a 'fresh' item. + """ + src = os.path.join(candidates_dir, f"{candidate_id}.json") + if not os.path.exists(src): + raise FileNotFoundError(f"candidate not found: {candidate_id}") + cand = load_candidate(src) + cand["status"] = "rejected" + cand["rejection_count"] = cand.get("rejection_count", 0) + 1 + _touch(cand, "rejected", reviewer, notes=reason) + + rejected_dir = os.path.join(candidates_dir, "rejected") + os.makedirs(rejected_dir, exist_ok=True) + dst = os.path.join(rejected_dir, f"{candidate_id}.json") + save_candidate(cand, dst) + os.remove(src) + return cand + + +def mark_reopened(candidate_id, reviewer, candidates_dir): + """Move a rejected candidate back to the staged pool with history intact.""" + src = os.path.join(candidates_dir, "rejected", f"{candidate_id}.json") + if not os.path.exists(src): + raise FileNotFoundError(f"rejected candidate not found: {candidate_id}") + cand = load_candidate(src) + cand["status"] = "staged" + _touch(cand, "reopened", reviewer) + + dst = os.path.join(candidates_dir, f"{candidate_id}.json") + save_candidate(cand, dst) + os.remove(src) + return cand + + +def _age_factor(staged_at): + """1.0 at stage time, grows to 2.0 for candidates ~14 days old.""" + try: + staged = datetime.datetime.fromisoformat(staged_at) + except (ValueError, TypeError): + return 1.0 + age_days = (datetime.datetime.now() - staged).days + return 1.0 + min(1.0, age_days / 14.0) + + +def candidate_priority(candidate): + """priority = cluster_size * canonical_salience * age_factor. + + Reviewers attack high-priority items first. Older + more-recurrent + + higher-salience patterns deserve attention ahead of one-offs. + """ + return ( + max(1, candidate.get("cluster_size", 1)) * + max(0.1, candidate.get("canonical_salience", 0.1)) * + _age_factor(candidate.get("staged_at", "")) + ) + + +def list_candidates(candidates_dir, status="staged", sort_by="priority"): + """Return candidate dicts with the given status, sorted by the key.""" + if status == "staged": + search_dir = candidates_dir + else: + search_dir = os.path.join(candidates_dir, status) + if not os.path.isdir(search_dir): + return [] + + out = [] + for fname in os.listdir(search_dir): + if not fname.endswith(".json"): + continue + path = os.path.join(search_dir, fname) + if not os.path.isfile(path): + continue + try: + with open(path) as f: + out.append(json.load(f)) + except (OSError, json.JSONDecodeError): + continue + + if sort_by == "priority": + out.sort(key=candidate_priority, reverse=True) + elif sort_by == "age": + out.sort(key=lambda c: c.get("staged_at", "")) + return out + + +def write_review_queue_summary(candidates_dir, summary_path): + """Emit a compact REVIEW_QUEUE.md so the host agent sees the backlog. + + On-demand review without a surfacing mechanism grows silent backlog. + This file sits in memory/working/ and gets loaded by context_budget into + every host session — impossible to miss. + """ + pending = list_candidates(candidates_dir, status="staged") + os.makedirs(os.path.dirname(summary_path), exist_ok=True) + if not pending: + with open(summary_path, "w") as f: + f.write("# Review Queue\n\n_No pending candidates._\n") + return 0 + + staged_ats = [c.get("staged_at", "") for c in pending if c.get("staged_at")] + oldest = min(staged_ats) if staged_ats else "" + lines = ["# Review Queue", ""] + lines.append(f"**Pending:** {len(pending)}") + if oldest: + lines.append(f"**Oldest staged:** {oldest}") + lines.append("") + lines.append("Run `python .agent/tools/list_candidates.py` for detail, then:") + lines.append("- `python .agent/tools/graduate.py --rationale \"...\"` to accept") + lines.append("- `python .agent/tools/reject.py --reason \"...\"` to reject") + lines.append("- Review in a batch so cross-candidate contradictions are caught.") + lines.append("") + lines.append("## Priority order (top 10)") + lines.append("") + for cand in pending[:10]: + prio = candidate_priority(cand) + claim_preview = (cand.get("claim") or "")[:80] + lines.append( + f"- **{cand.get('id')}** (priority={prio:.2f}, " + f"size={cand.get('cluster_size', '?')}, " + f"rejections={cand.get('rejection_count', 0)}) " + f"— {claim_preview}" + ) + with open(summary_path, "w") as f: + f.write("\n".join(lines) + "\n") + return len(pending) diff --git a/.agent/memory/validate.py b/.agent/memory/validate.py new file mode 100644 index 0000000..c0d8f7e --- /dev/null +++ b/.agent/memory/validate.py @@ -0,0 +1,79 @@ +"""Heuristic pre-filter for candidate lessons. Deterministic, no LLM. + +The host agent (Claude Code, Codex, Windsurf) does actual reasoning via the +CLI tools in .agent/tools/ (graduate.py, reject.py). This module catches +obvious junk — too-short claims, exact duplicates — before the reviewer +sees the candidate at all. Anything subjective is the host's job. +""" +import re + +MIN_CLAIM_LEN = 20 +LENGTH_SATURATE = 100 +CLUSTER_SATURATE = 5 + + +def _normalize(text): + """Lowercase, strip punctuation, collapse whitespace. For exact-dup detection.""" + t = re.sub(r"[^\w\s]", " ", (text or "").lower()) + return re.sub(r"\s+", " ", t).strip() + + +def _extract_lesson_lines(lessons_md): + out = [] + for line in (lessons_md or "").splitlines(): + s = line.strip() + if s.startswith("- ") and len(s) > 2: + out.append(s[2:].split("`) + - `[PROVISIONAL]` prefix added by the renderer + - Strikethrough markers on superseded lessons (but the text is still + returned — a superseded lesson's claim can legitimately reappear) + """ out = [] for line in (lessons_md or "").splitlines(): s = line.strip() - if s.startswith("- ") and len(s) > 2: - out.append(s[2:].split("`) - - `[PROVISIONAL]` prefix added by the renderer - - Strikethrough markers on superseded lessons (but the text is still - returned — a superseded lesson's claim can legitimately reappear) + """Extract accepted lesson claims from rendered markdown. + + Only TERMINAL lessons count for duplicate detection: + - `[PROVISIONAL]` lessons are probationary; if a pattern recurs, the + host agent should see it for evidence accumulation or full + graduation. Blocking them here makes provisional an accidental + dead end. + - Strikethrough (`~~...~~`) lessons are superseded; a new claim that + happens to match a superseded one is a legitimate revival, not a + duplicate. + Stable candidate ids (derived from claim text in cluster.extract_pattern) + handle the "same claim under different slug" risk without needing this + function to include provisional/superseded lessons. """ out = [] for line in (lessons_md or "").splitlines(): @@ -34,9 +40,9 @@ def _extract_lesson_lines(lessons_md): continue text = s[2:].split("