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..1dc7045 100644 --- a/.agent/harness/context_budget.py +++ b/.agent/harness/context_budget.py @@ -1,8 +1,23 @@ -"""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, re, 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 + +# Keep in sync with memory/validate._extract_lesson_lines — both filters +# want TERMINAL-only lesson content. +_STATUS_RE = re.compile(r"status=(\w+)") def _read(path, limit=None): @@ -13,11 +28,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 +56,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 +77,104 @@ 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 accepted lesson bullets by query overlap; fall back to original order. + + Only terminal (status=accepted) lessons reach the host agent as retrievable + guidance. Provisional, legacy, and superseded bullets exist in LESSONS.md + for audit but must not be injected into the system prompt — they'd let the + agent act on probationary or stale memory. + """ + lines = [] + for line in (lessons_md or "").splitlines(): + s = line.strip() + if not s.startswith("- ") or len(s) <= 2: + continue + # Primary status filter: HTML annotation + if "" + if status == "provisional": + return f"- [PROVISIONAL] {claim} " + return f"- {claim} " + + +def _build_auto_section(lessons): + # Only accepted supersessions flip the old lesson to strikethrough. + # A provisional --supersedes would otherwise blank the active lesson + # before its replacement has been accepted, leaving no active guidance + # on that topic at all (retrieval skips both provisional and + # strikethrough). + superseded_by = {} + for L in lessons: + if L.get("status") != "accepted": + continue + 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 migrate_legacy_bullets(semantic_dir): + """Import any bullets below the sentinel not yet in lessons.jsonl. + + Upgrade safety: installations that ran the old markdown-only promotion + have auto-promoted bullets below the sentinel. Without this pass, the + first call to render_lessons with an empty lessons.jsonl would rewrite + LESSONS.md with an empty auto-section and lose all of them silently. + Migrated entries land with status='legacy' so they're visually distinct + and can be reviewed + superseded by the host agent later. + """ + md_path = os.path.join(semantic_dir, LESSONS_MD) + if not os.path.exists(md_path): + return 0 + content = open(md_path).read() + if SENTINEL not in content: + return 0 + + below = content.split(SENTINEL, 1)[1] + bullets = [] + for line in below.splitlines(): + s = line.strip() + if not s.startswith("- ") or len(s) <= 2: + continue + text = s[2:].split("