From 38be47bd68ec28c7dcdbfa5813d087e61c8cda62 Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 00:52:25 -0400 Subject: [PATCH 1/2] Habenula Phase 1: lateral habenula / anti-reward channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Negative-RPE source. Pairs antisymmetrically with LC: LC fires on +surprise / novelty; habenula fires on −surprise / omission / aversion. Records reward-omission, aversive valences, repeated failures as a dedicated channel separate from bg_td_events. Phase 1 is inspection-only. Computes suggested_da_damp for Phase 3 readers but does NOT yet damp bg_modulators.tonic_da. - Migration 070: 3 tables (triggers, firings, state) + 5 seed triggers (reward_omission, retrieval_failure, repeated_low_utility, aversive_valence, task_abandoned). - mcp_tools_habenula: 5 tools (status, fire, register_trigger, history, reset). - 10 tests; all green. - Design proposal at docs/proposals/habenula.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- MCP_SERVER.md | 3 +- db/migrations/070_habenula.sql | 71 ++++++ docs/proposals/habenula.md | 108 ++++++++ src/agentmemory/mcp_tools_habenula.py | 339 ++++++++++++++++++++++++++ tests/test_mcp_tools_habenula.py | 147 +++++++++++ 5 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 db/migrations/070_habenula.sql create mode 100644 docs/proposals/habenula.md create mode 100644 src/agentmemory/mcp_tools_habenula.py create mode 100644 tests/test_mcp_tools_habenula.py diff --git a/MCP_SERVER.md b/MCP_SERVER.md index f26c00c..0600a4c 100644 --- a/MCP_SERVER.md +++ b/MCP_SERVER.md @@ -50,7 +50,7 @@ docker run -v ~/.agentmemory:/data -e BRAIN_DB=/data/brain.db brainctl The `CMD` defaults to `brainctl-mcp`, so the container runs the MCP server over stdio. -## Available Tools (260) +## Available Tools (265) | Tool | Description | |------|-------------| @@ -164,6 +164,7 @@ server over stdio. | Insula (Phase 1, interoception) | `insula_sample`, `insula_state`, `insula_subscribe`, `insula_check_triggers` | Self-state vector (write_pressure, retrieval_strain, consolidation_debt, embedding_health, attention_load, certainty) with EMA baseline + deviation. Subscriber registry routes signals to subsystems | | PFC sub-regions (Phase 1, named slots) | `pfc_slot_set`, `pfc_slot_get`, `pfc_status` | 4 named slots per agent: dlPFC (active task), vmPFC (outcome-utility), OFC (realized-outcome log), frontopolar (meta-monitor). Mostly aggregation | | Entorhinal grid (Phase 1, conceptual indexing) | `entorhinal_activate`, `entorhinal_lookup`, `entorhinal_status` | 48 grid cells across 3 scales (fine/medium/coarse). Deterministic hash maps content → cell activations; sub-linear pattern lookup | +| Habenula (Phase 1, anti-reward) | `habenula_status`, `habenula_fire`, `habenula_register_trigger`, `habenula_history`, `habenula_reset` | Lateral habenula — negative-RPE source. Pairs antisymmetrically with LC. Records reward-omission / aversive / repeated-failure events; computes `suggested_da_damp` for Phase 3 DA suppression (see `docs/proposals/habenula.md`) | ### Tier 3: Specialist (~150 tools) diff --git a/db/migrations/070_habenula.sql b/db/migrations/070_habenula.sql new file mode 100644 index 0000000..1a51646 --- /dev/null +++ b/db/migrations/070_habenula.sql @@ -0,0 +1,71 @@ +-- Migration 070: lateral habenula subsystem — Phase 1 schema +-- +-- The "anti-reward" / negative-RPE source. Pairs antisymmetrically +-- with LC (LC = positive surprise → NE; Hb = negative surprise / +-- reward omission / aversion → DA suppression in Phase 3). +-- +-- Phase 1 is inspection-only / additive: schema + read+CRUD tools. +-- Does NOT yet damp bg_modulators.tonic_da. That's Phase 3. +-- +-- Four invariants encoded: +-- 1. Negative-RPE coding: signed_pe always <= 0. +-- 2. Reward omission distinct from punishment (event_kind). +-- 3. Tonic vs phasic separation. +-- 4. DA-suppression effect proportional to integrated activity +-- (Phase 3 will use EWMA; Phase 1 just records events). +-- +-- Rollback: +-- DROP TABLE IF EXISTS habenula_state; +-- DROP TABLE IF EXISTS habenula_firings; +-- DROP TABLE IF EXISTS habenula_triggers; +-- DELETE FROM schema_version WHERE version = 70; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS habenula_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + event_kind TEXT NOT NULL CHECK(event_kind IN ('omission', 'aversive', 'repeated_failure', 'other')), + default_pe REAL NOT NULL DEFAULT -0.1 CHECK(default_pe <= 0.0), + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_hb_triggers_kind ON habenula_triggers(event_kind); + +CREATE TABLE IF NOT EXISTS habenula_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + trigger_id INTEGER, + event_kind TEXT NOT NULL CHECK(event_kind IN ('omission', 'aversive', 'repeated_failure', 'other')), + signed_pe REAL NOT NULL CHECK(signed_pe <= 0.0), + context_hash TEXT, + source_event_id INTEGER, + notes TEXT, + FOREIGN KEY (trigger_id) REFERENCES habenula_triggers(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_hb_firings_recent ON habenula_firings(fired_at); +CREATE INDEX IF NOT EXISTS idx_hb_firings_agent ON habenula_firings(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_hb_firings_kind ON habenula_firings(event_kind, fired_at); + +CREATE TABLE IF NOT EXISTS habenula_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + tonic_activity REAL NOT NULL DEFAULT 0.0, + phasic_burst REAL NOT NULL DEFAULT 0.0, + rolling_disappointment_24h INTEGER NOT NULL DEFAULT 0, + last_firing_at TEXT, + suggested_da_damp REAL NOT NULL DEFAULT 0.0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO habenula_state (id) VALUES (1); + +INSERT OR IGNORE INTO habenula_triggers (name, event_kind, default_pe, description) VALUES + ('reward_omission', 'omission', -0.15, 'expected positive outcome did not arrive'), + ('retrieval_failure', 'omission', -0.10, 'memory_search returned no useful candidates'), + ('repeated_low_utility', 'repeated_failure', -0.20, 'same query pattern failed 3+ times in 24h'), + ('aversive_valence', 'aversive', -0.30, 'amygdala flagged content with strong negative valence'), + ('task_abandoned', 'repeated_failure', -0.25, 'agent abandoned a task after failure cascade'); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (70, 'habenula Phase 1: 3 tables (triggers, firings, state) + 5 seed trigger classes', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/docs/proposals/habenula.md b/docs/proposals/habenula.md new file mode 100644 index 0000000..a17b9f8 --- /dev/null +++ b/docs/proposals/habenula.md @@ -0,0 +1,108 @@ +# Proposal: The Habenula Subsystem for brainctl + +**Status:** Phase 1 design + implementation, branch `brain-regions-habenula-phase-1`. +**Author:** Claude Opus 4.7 (overnight chain continuation) +**Date:** 2026-05-20 +**Scope:** New subsystem. Pairs antisymmetrically with LC + the BG's TD-error bus. + +--- + +## TL;DR + +Lateral habenula (LHb) is the brain's **negative-reward-prediction-error** source. It fires when an expected positive outcome **fails to materialize** (omission) or when an aversive outcome arrives. Its primary projection target is the rostromedial tegmental nucleus (RMTg), which then **suppresses** dopaminergic VTA/SNc neurons. The functional consequence: habenula activity damps dopamine, disengages exploration, and drives task switching. + +In brainctl, the BG's `bg_td_events` already broadcasts a TD-error signal (`δ = utility + γ·V(s') − V(s)`) that can be either sign. But there's no first-class structure for tracking **systematic negative outcomes** — repeated retrieval failures, repeated aversive valences, expected-good-results that didn't pan out. Issue #116's audit memo noted that brainctl learns from positive outcomes through bg_striatal_weights but doesn't have a dedicated "anti-reward" channel that drives disengagement, task abandonment, or exploration cessation. + +This proposal codifies habenula as a thin first-class subsystem that: + +- Logs negative outcome events specifically (separately from the general `bg_td_events` stream) +- Tracks running disappointment / aversive-event counters per (agent, context) +- Provides a `tonic_da` damping signal that the existing BG-thalamus modulator cascade can read in Phase 2 +- Pairs antisymmetrically with LC: LC fires on positive surprise (high |+δ|), habenula fires on negative surprise (high |−δ|) or expected-positive omission + +Phase 1 ships schema + 5 MCP tools + tests. **No behavior change** — does not yet damp `bg_modulators.tonic_da`. That's Phase 3. + +## Architectural placement + +``` + ┌────── LC (PR #121) ─────────┐ ┌────── Habenula (this PR) ──────┐ + │ fires on +surprise / NE │ │ fires on −surprise / aversion │ + │ → bg_modulators.lc_ne │ │ → Phase 3 damps tonic_da │ + └────────────────────────────┘ └────────────────────────────────┘ + │ │ + │ │ + └────────────┬─────────────────────┘ + ▼ + ┌────────────────────┐ + │ bg_td_events bus │ + │ (sign-agnostic δ) │ + └────────────────────┘ +``` + +Habenula is NOT the same as a negative δ in bg_td_events. The TD signal already supports negative δ. What habenula adds is: + +1. **Expected-positive omission detection** — δ ≈ 0 isn't enough; we need "the prediction said *positive*, the observation gave *neutral or worse*" +2. **Aggregation across events** — sustained disappointment looks different from one bad TD +3. **A dedicated channel for disengagement triggers** — agents/contexts where the agent should *stop trying that retrieval pattern* +4. **Asymmetric coupling to LC** — habenula and LC together cover the full ±PE space; together they're the candidate signal for the Phase 4 enforcement flip + +## Biological invariants encoded + +1. **Negative-RPE coding.** LHb neurons phasically activate on negative-RPE events (Matsumoto & Hikosaka 2007). brainctl schema: `habenula_firings.signed_pe` is the source of truth, always ≤ 0. +2. **Reward omission ≠ punishment.** Both fire habenula but with different downstream consequences. `habenula_firings.event_kind` distinguishes `omission` from `aversive`. +3. **Tonic vs phasic.** Like LC and ARAS, habenula has tonic baseline and phasic bursts. Tracked in `habenula_state`. +4. **DA-suppression effect proportional to integrated activity.** A single bad event doesn't kill the whole reward circuit — sustained or extreme activity does. Phase 3 implementation will use an exponentially-decayed running average; Phase 1 just records the events. + +## Phase 1 schema (migration 070) + +```sql +CREATE TABLE habenula_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + event_kind TEXT NOT NULL CHECK(event_kind IN ('omission', 'aversive', 'repeated_failure', 'other')), + default_pe REAL NOT NULL DEFAULT -0.1 CHECK(default_pe <= 0.0), + description TEXT, + created_at TEXT NOT NULL DEFAULT (...) +); + +CREATE TABLE habenula_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (...), + agent_id TEXT, + trigger_id INTEGER REFERENCES habenula_triggers(id), + event_kind TEXT NOT NULL CHECK(event_kind IN ('omission', 'aversive', 'repeated_failure', 'other')), + signed_pe REAL NOT NULL CHECK(signed_pe <= 0.0), + context_hash TEXT, + source_event_id INTEGER, + notes TEXT +); + +CREATE TABLE habenula_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + tonic_activity REAL NOT NULL DEFAULT 0.0, + phasic_burst REAL NOT NULL DEFAULT 0.0, + rolling_disappointment_24h INTEGER NOT NULL DEFAULT 0, + last_firing_at TEXT, + suggested_da_damp REAL NOT NULL DEFAULT 0.0, + updated_at TEXT NOT NULL DEFAULT (...) +); +``` + +Seeded triggers: `reward_omission`, `retrieval_failure`, `repeated_low_utility`, `aversive_valence`, `task_abandoned`. + +## Phase 1 MCP tools + +- `habenula_status` — current state + last 5 firings + 24h aggregate +- `habenula_fire(trigger_name, signed_pe, agent_id, context_hash, ...)` — record a negative event +- `habenula_register_trigger` — idempotent UPSERT +- `habenula_history(limit, since, agent_id, event_kind)` — paginated firings +- `habenula_reset(agent_id)` — manually zero out tonic/phasic for an agent (admin-mode disengagement-cooldown) + +## DoD + +- Migration 070 applies cleanly to /tmp + live (with backup) +- Schema-version 70 row present +- 5 seed triggers + single state row +- 5 MCP tools registered + discoverable +- ≥7 tests passing +- Branch pushed, PR open, NOT merged to main diff --git a/src/agentmemory/mcp_tools_habenula.py b/src/agentmemory/mcp_tools_habenula.py new file mode 100644 index 0000000..4c4d9d9 --- /dev/null +++ b/src/agentmemory/mcp_tools_habenula.py @@ -0,0 +1,339 @@ +"""brainctl MCP tools — habenula (lateral habenula, anti-reward). + +Phase 1 per docs/proposals/habenula.md. Records negative-RPE / +omission / aversive events as a dedicated channel separate from +bg_td_events. Phase 1 is inspection + writes only; Phase 3 will damp +bg_modulators.tonic_da from accumulated habenula activity. +""" +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from mcp.types import Tool + +from agentmemory.lib.mcp_helpers import open_db +from agentmemory.paths import get_db_path + +DB_PATH: Path = get_db_path() + +VALID_EVENT_KINDS = {"omission", "aversive", "repeated_failure", "other"} + + +def _db() -> sqlite3.Connection: + return open_db(str(DB_PATH)) + + +def _rows(rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]: + return [dict(r) for r in rows] + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + ) + + +def _require_schema(conn: sqlite3.Connection) -> str | None: + missing = [ + t for t in ("habenula_triggers", "habenula_firings", "habenula_state") + if not _table_exists(conn, t) + ] + if missing: + return ("habenula schema missing: " + ", ".join(missing) + + ". Run `brainctl migrate` (migration 070).") + return None + + +def tool_habenula_status(agent_id: str | None = None, **_kw: Any) -> dict[str, Any]: + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM habenula_state WHERE id = 1").fetchone() + last_firings = _rows(conn.execute( + """ + SELECT f.id, f.fired_at, f.agent_id, f.event_kind, f.signed_pe, + f.notes, t.name AS trigger_name + FROM habenula_firings f + LEFT JOIN habenula_triggers t ON t.id = f.trigger_id + WHERE (? IS NULL OR f.agent_id = ?) + ORDER BY f.id DESC LIMIT 5 + """, (agent_id, agent_id), + ).fetchall()) + agg = conn.execute( + """ + SELECT COUNT(*) AS n, + COALESCE(AVG(signed_pe), 0.0) AS mean_pe, + COALESCE(MIN(signed_pe), 0.0) AS worst_pe, + SUM(CASE WHEN event_kind='omission' THEN 1 ELSE 0 END) AS n_omission, + SUM(CASE WHEN event_kind='aversive' THEN 1 ELSE 0 END) AS n_aversive, + SUM(CASE WHEN event_kind='repeated_failure' THEN 1 ELSE 0 END) AS n_repeated + FROM habenula_firings + WHERE fired_at >= datetime('now', '-24 hours') + AND (? IS NULL OR agent_id = ?) + """, (agent_id, agent_id), + ).fetchone() + trigger_count = conn.execute( + "SELECT COUNT(*) FROM habenula_triggers" + ).fetchone()[0] + return { + "ok": True, + "state": dict(state) if state else None, + "last_5_firings": last_firings, + "aggregate_24h": dict(agg) if agg else {}, + "registered_triggers": trigger_count, + } + + +def tool_habenula_fire( + trigger_name: str | None = None, + signed_pe: float | None = None, + event_kind: str | None = None, + agent_id: str | None = None, + context_hash: str | None = None, + source_event_id: int | None = None, + notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Record one habenula firing (negative event). + + Either pass `trigger_name` (uses its default_pe and event_kind) + OR pass both `signed_pe` and `event_kind` explicitly. + """ + if trigger_name is None and (signed_pe is None or event_kind is None): + return {"error": "must pass trigger_name OR (signed_pe + event_kind)"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + trigger_id: int | None = None + if trigger_name is not None: + trig = conn.execute( + "SELECT id, default_pe, event_kind FROM habenula_triggers WHERE name = ?", + (trigger_name,), + ).fetchone() + if not trig: + return {"error": f"trigger {trigger_name!r} not registered"} + trigger_id = int(trig["id"]) + if signed_pe is None: + signed_pe = float(trig["default_pe"]) + if event_kind is None: + event_kind = trig["event_kind"] + if event_kind not in VALID_EVENT_KINDS: + return {"error": f"invalid event_kind {event_kind!r}"} + if signed_pe > 0.0: + return {"error": "signed_pe must be <= 0 (habenula codes negative PE)"} + cur = conn.execute( + """ + INSERT INTO habenula_firings + (agent_id, trigger_id, event_kind, signed_pe, context_hash, source_event_id, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (agent_id, trigger_id, event_kind, float(signed_pe), context_hash, source_event_id, notes), + ) + firing_id = cur.lastrowid + # Update state: EWMA on tonic_activity, instantaneous phasic burst, + # increment 24h counter (rough — Phase 3 will use a proper sliding window). + state = conn.execute("SELECT * FROM habenula_state WHERE id = 1").fetchone() + old_tonic = float(state["tonic_activity"]) if state else 0.0 + magnitude = abs(float(signed_pe)) + new_tonic = max(0.0, min(1.0, 0.9 * old_tonic + 0.1 * magnitude)) + new_phasic = max(0.0, min(1.0, magnitude)) + # Phase 1 suggested damp is purely informational; Phase 3 will read it. + new_damp = max(0.0, min(0.5, 0.5 * new_tonic + 0.3 * new_phasic)) + conn.execute( + """ + UPDATE habenula_state SET + tonic_activity = ?, + phasic_burst = ?, + rolling_disappointment_24h = rolling_disappointment_24h + 1, + last_firing_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + suggested_da_damp = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (new_tonic, new_phasic, new_damp), + ) + conn.commit() + return { + "ok": True, "firing_id": firing_id, "trigger_name": trigger_name, + "event_kind": event_kind, "signed_pe": float(signed_pe), + "new_tonic_activity": new_tonic, + "new_phasic_burst": new_phasic, + "suggested_da_damp": new_damp, + } + + +def tool_habenula_register_trigger( + name: str, event_kind: str, + default_pe: float = -0.1, description: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + if event_kind not in VALID_EVENT_KINDS: + return {"error": f"invalid event_kind {event_kind!r}"} + if default_pe > 0.0: + return {"error": "default_pe must be <= 0"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + conn.execute( + """ + INSERT INTO habenula_triggers (name, event_kind, default_pe, description) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + event_kind = excluded.event_kind, + default_pe = excluded.default_pe, + description = COALESCE(excluded.description, habenula_triggers.description) + """, + (name, event_kind, float(default_pe), description), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM habenula_triggers WHERE name = ?", (name,) + ).fetchone() + return {"ok": True, "trigger": dict(row) if row else None} + + +def tool_habenula_history( + limit: int = 20, since: str | None = None, + agent_id: str | None = None, event_kind: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + limit = max(1, min(int(limit), 200)) + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + clauses, params = [], [] + if since: + clauses.append("fired_at >= ?"); params.append(since) + if agent_id: + clauses.append("agent_id = ?"); params.append(agent_id) + if event_kind: + clauses.append("event_kind = ?"); params.append(event_kind) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + rows = conn.execute( + f""" + SELECT id, fired_at, agent_id, trigger_id, event_kind, signed_pe, + context_hash, source_event_id, notes + FROM habenula_firings + {where} + ORDER BY id DESC LIMIT ? + """, + (*params, limit), + ).fetchall() + return {"ok": True, "history": _rows(rows)} + + +def tool_habenula_reset(agent_id: str | None = None, **_kw: Any) -> dict[str, Any]: + """Admin-mode reset of habenula state. Use sparingly — clears the + accumulated disengagement signal. Returns the prior state for audit. + """ + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + prior = conn.execute("SELECT * FROM habenula_state WHERE id = 1").fetchone() + conn.execute( + """ + UPDATE habenula_state SET + tonic_activity = 0.0, + phasic_burst = 0.0, + rolling_disappointment_24h = 0, + suggested_da_damp = 0.0, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """ + ) + conn.commit() + return {"ok": True, "reset_for_agent": agent_id, "prior_state": dict(prior) if prior else None} + + +TOOLS: list[Tool] = [ + Tool( + name="habenula_status", + description="Habenula Phase 1 inspection. Current state + last 5 firings + 24h aggregate.", + inputSchema={"type": "object", "properties": {"agent_id": {"type": "string"}}}, + ), + Tool( + name="habenula_register_trigger", + description="Idempotent UPSERT on habenula_triggers. event_kind ∈ {omission, aversive, repeated_failure, other}. default_pe ≤ 0.", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "event_kind": {"type": "string", "enum": sorted(VALID_EVENT_KINDS)}, + "default_pe": {"type": "number", "default": -0.1}, + "description": {"type": "string"}, + }, + "required": ["name", "event_kind"], + }, + ), + Tool( + name="habenula_fire", + description=( + "Record a negative-PE event. Pass trigger_name (uses default_pe + event_kind) " + "OR pass signed_pe + event_kind explicitly. signed_pe must be ≤ 0. Updates " + "habenula_state tonic/phasic + 24h counter + suggested_da_damp." + ), + inputSchema={ + "type": "object", + "properties": { + "trigger_name": {"type": "string"}, + "signed_pe": {"type": "number"}, + "event_kind": {"type": "string", "enum": sorted(VALID_EVENT_KINDS)}, + "agent_id": {"type": "string"}, + "context_hash": {"type": "string"}, + "source_event_id": {"type": "integer"}, + "notes": {"type": "string"}, + }, + }, + ), + Tool( + name="habenula_history", + description="Paginated firings. Filters: since, agent_id, event_kind. limit clamped to [1, 200].", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "since": {"type": "string"}, + "agent_id": {"type": "string"}, + "event_kind": {"type": "string", "enum": sorted(VALID_EVENT_KINDS)}, + }, + }, + ), + Tool( + name="habenula_reset", + description="Admin reset of habenula_state (clears tonic/phasic/counter/damp). Returns prior state for audit.", + inputSchema={"type": "object", "properties": {"agent_id": {"type": "string"}}}, + ), +] + + +_HABENULA_TOOLS = { + "habenula_status": tool_habenula_status, + "habenula_register_trigger": tool_habenula_register_trigger, + "habenula_fire": tool_habenula_fire, + "habenula_history": tool_habenula_history, + "habenula_reset": tool_habenula_reset, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _HABENULA_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_habenula.py b/tests/test_mcp_tools_habenula.py new file mode 100644 index 0000000..dbface7 --- /dev/null +++ b/tests/test_mcp_tools_habenula.py @@ -0,0 +1,147 @@ +"""Tests for mcp_tools_habenula — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_070 = REPO_ROOT / "db" / "migrations" / "070_habenula.sql" + + +def _bootstrap(conn): + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + description TEXT, + applied_at TEXT + ); + """ + ) + + +def _apply(db_path): + conn = sqlite3.connect(str(db_path)) + try: + _bootstrap(conn) + conn.executescript(MIGRATION_070.read_text()) + conn.commit() + finally: + conn.close() + + +def _make_db(tmp_path, monkeypatch): + db = tmp_path / "brain.db" + _apply(db) + from agentmemory import mcp_tools_habenula as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_applies_with_seeds(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + names = [r[0] for r in conn.execute("SELECT name FROM habenula_triggers ORDER BY id").fetchall()] + assert names == [ + "reward_omission", "retrieval_failure", "repeated_low_utility", + "aversive_valence", "task_abandoned", + ] + state = conn.execute("SELECT tonic_activity, suggested_da_damp FROM habenula_state").fetchone() + assert state == (0.0, 0.0) + sv = conn.execute("SELECT version FROM schema_version WHERE version=70").fetchone() + assert sv == (70,) + finally: + conn.close() + + +def test_status_empty(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_habenula_status() + assert out["ok"] is True + assert out["aggregate_24h"]["n"] == 0 + assert out["registered_triggers"] == 5 + + +def test_fire_via_trigger_uses_defaults(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_habenula_fire(trigger_name="reward_omission", agent_id="a1") + assert out["ok"] is True + assert out["signed_pe"] == -0.15 + assert out["event_kind"] == "omission" + # state advanced + status = mod.tool_habenula_status() + assert status["state"]["rolling_disappointment_24h"] == 1 + assert status["state"]["last_firing_at"] is not None + + +def test_fire_explicit_pe(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_habenula_fire( + signed_pe=-0.5, event_kind="aversive", agent_id="a1", notes="explicit" + ) + assert out["ok"] is True + assert out["signed_pe"] == -0.5 + + +def test_fire_rejects_positive_pe(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_habenula_fire(signed_pe=0.5, event_kind="aversive") + assert "error" in out + + +def test_fire_rejects_missing_args(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_habenula_fire() + assert "error" in out + + +def test_fire_unknown_trigger(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_habenula_fire(trigger_name="nope") + assert "error" in out + + +def test_register_trigger_idempotent_and_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + good = mod.tool_habenula_register_trigger( + name="custom_aversive", event_kind="aversive", default_pe=-0.5, + ) + again = mod.tool_habenula_register_trigger( + name="custom_aversive", event_kind="aversive", default_pe=-0.4, + ) + assert good["ok"] and again["ok"] + assert good["trigger"]["id"] == again["trigger"]["id"] + assert again["trigger"]["default_pe"] == -0.4 + # Validation + bad = mod.tool_habenula_register_trigger( + name="bad", event_kind="not-a-kind", + ) + assert "error" in bad + + +def test_history_filters(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_habenula_fire(trigger_name="reward_omission", agent_id="a1") + mod.tool_habenula_fire(trigger_name="aversive_valence", agent_id="a2") + mod.tool_habenula_fire(trigger_name="reward_omission", agent_id="a1") + all_h = mod.tool_habenula_history(limit=10) + assert len(all_h["history"]) == 3 + a1 = mod.tool_habenula_history(limit=10, agent_id="a1") + assert len(a1["history"]) == 2 + omissions = mod.tool_habenula_history(limit=10, event_kind="omission") + assert len(omissions["history"]) == 2 + + +def test_reset_clears_state(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_habenula_fire(trigger_name="aversive_valence", agent_id="a1") + pre = mod.tool_habenula_status() + assert pre["state"]["rolling_disappointment_24h"] == 1 + out = mod.tool_habenula_reset(agent_id="a1") + assert out["ok"] is True + assert out["prior_state"]["rolling_disappointment_24h"] == 1 + post = mod.tool_habenula_status() + assert post["state"]["rolling_disappointment_24h"] == 0 + assert post["state"]["tonic_activity"] == 0.0 From b72cf00acf0b2447c594d402b324a37ce03400f8 Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 00:53:14 -0400 Subject: [PATCH 2/2] habenula: register mcp_server import + CHANGELOG entry (fixup for 38be47b) --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ src/agentmemory/mcp_server.py | 2 ++ 2 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c17766f..9e00993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added — Habenula Phase 1 (anti-reward / negative-RPE) + +Lateral habenula subsystem. Records negative-reward-prediction-error +events as a dedicated channel separate from `bg_td_events`. Fires on +reward omission, aversive valences, repeated failures. Pairs +antisymmetrically with LC (PR #121): LC fires on +surprise, Hb fires +on −surprise / omission. + +Phase 1 is inspection-only / additive. Does NOT yet damp +`bg_modulators.tonic_da`. That's Phase 3. + +- **Migration 070** — 3 tables (`habenula_triggers`, `habenula_firings`, + `habenula_state`). 5 seed triggers (reward_omission, retrieval_failure, + repeated_low_utility, aversive_valence, task_abandoned). Single-row + state seed. +- **`agentmemory.mcp_tools_habenula`** — 5 MCP tools (`habenula_status`, + `habenula_fire`, `habenula_register_trigger`, `habenula_history`, + `habenula_reset`). +- **10 tests** covering migration, empty state, fire-via-trigger, + explicit-pe fire, positive-PE rejection, missing-args rejection, + unknown-trigger rejection, idempotent registration, history filters, + reset semantics. +- **Design proposal** at `docs/proposals/habenula.md`. + +Phase 2 wires `habenula_fire` into outcome_annotate's negative path. +Phase 3 lets `suggested_da_damp` actually subtract from +`bg_modulators.tonic_da`. Phase 4 enforces. + ### Added — issue #116 Phase 1-A: retrieval pathway log External architecture memo (issue #116, "Thalamus, Basal Ganglia, and diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..49a646a 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -54,6 +54,7 @@ mcp_tools_entorhinal_grid, mcp_tools_expertise, mcp_tools_federation, + mcp_tools_habenula, mcp_tools_health, mcp_tools_hippocampal_subfields, mcp_tools_immunity, @@ -97,6 +98,7 @@ mcp_tools_entorhinal_grid, mcp_tools_expertise, mcp_tools_federation, + mcp_tools_habenula, mcp_tools_health, mcp_tools_hippocampal_subfields, mcp_tools_immunity,