From 53d2e82c06ae14023752487d98cf8d3eb1f2490f Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 01:18:37 -0400 Subject: [PATCH] VTA/SNc Phase 1: dopamine source as first-class structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avenue 7 from PR #125 research memo. Codifies dopamine source as a nucleus with its own firing log + state, rather than a derived quantity in bg_td_events + bg_modulators. - Migration 075: vta_state (single row, tonic_da + burst_budget + pathology_flag), vta_firings (timestamped burst log), vta_pathway_links (6 seeded: mesolimbic→NAc/amygdala, mesocortical→PFC/ACC, nigrostriatal→BG, broadcast→bg_modulators). - 5 MCP tools: status, fire, set_tonic, pathways, history. - 8 tests. - Pairs with Habenula PR #124 — Phase 3 wires habenula.suggested_da_damp into vta_state.tonic_da. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/migrations/075_vta_snc.sql | 80 +++++++ src/agentmemory/mcp_server.py | 2 + src/agentmemory/mcp_tools_vta_snc.py | 309 +++++++++++++++++++++++++++ tests/test_mcp_tools_vta_snc.py | 111 ++++++++++ 4 files changed, 502 insertions(+) create mode 100644 db/migrations/075_vta_snc.sql create mode 100644 src/agentmemory/mcp_tools_vta_snc.py create mode 100644 tests/test_mcp_tools_vta_snc.py diff --git a/db/migrations/075_vta_snc.sql b/db/migrations/075_vta_snc.sql new file mode 100644 index 0000000..863f182 --- /dev/null +++ b/db/migrations/075_vta_snc.sql @@ -0,0 +1,80 @@ +-- Migration 075: VTA/SNc dopamine source — Phase 1 schema +-- +-- Avenue 7 from research/autonomous-research-avenues-2026-05-20.md. +-- Currently dopamine in brainctl exists as a *dial* +-- (bg_modulators.tonic_da) and a *broadcast* (bg_td_events.delta). +-- What's missing is the **nucleus** that sources the signal with its +-- own state and firing log. +-- +-- This migration adds: +-- vta_firings — log of phasic dopamine events (the nucleus's +-- actual firing) with magnitude + source +-- vta_state — single row tracking tonic baseline, phasic count, +-- authentication-style "burst budget" (depletes per +-- firing, refills with time) +-- vta_pathway_links — VTA projects to many targets; this catalogs +-- which downstream subsystems receive DA from VTA +-- vs SNc (Mesolimbic, Mesocortical, Nigrostriatal) +-- +-- Pairs with Habenula (PR #124, migration 070): Habenula's +-- suggested_da_damp is the input habenula side; VTA tracks the output +-- side. Phase 3 connects them — Habenula damping reduces VTA tonic. +-- +-- Rollback: +-- DROP TABLE IF EXISTS vta_pathway_links; +-- DROP TABLE IF EXISTS vta_firings; +-- DROP TABLE IF EXISTS vta_state; +-- DELETE FROM schema_version WHERE version = 75; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS vta_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + tonic_da REAL NOT NULL DEFAULT 0.5 CHECK(tonic_da BETWEEN 0.0 AND 1.0), + phasic_burst REAL NOT NULL DEFAULT 0.0 CHECK(phasic_burst BETWEEN 0.0 AND 1.0), + burst_budget REAL NOT NULL DEFAULT 1.0 CHECK(burst_budget BETWEEN 0.0 AND 1.0), + pathology_flag TEXT CHECK(pathology_flag IN ('none', 'low_da', 'high_da') OR pathology_flag IS NULL), + last_phasic_at TEXT, + last_tonic_update_at TEXT, + total_firings INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO vta_state (id, pathology_flag) VALUES (1, 'none'); + +CREATE TABLE IF NOT EXISTS vta_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + burst_magnitude REAL NOT NULL CHECK(burst_magnitude BETWEEN 0.0 AND 1.0), + source_kind TEXT NOT NULL CHECK(source_kind IN ( + 'bg_td_positive', 'novelty', 'reward_received', 'explicit_motivation', 'other' + )), + source_event_id INTEGER, + target_pathway TEXT CHECK(target_pathway IN ( + 'mesolimbic', 'mesocortical', 'nigrostriatal', 'broadcast', 'other' + )), + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_vta_recent ON vta_firings(fired_at); +CREATE INDEX IF NOT EXISTS idx_vta_pathway ON vta_firings(target_pathway, fired_at); +CREATE INDEX IF NOT EXISTS idx_vta_source ON vta_firings(source_kind, fired_at); + +CREATE TABLE IF NOT EXISTS vta_pathway_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pathway TEXT NOT NULL CHECK(pathway IN ('mesolimbic', 'mesocortical', 'nigrostriatal', 'broadcast')), + target_subsystem TEXT NOT NULL, + description TEXT, + UNIQUE (pathway, target_subsystem) +); + +INSERT OR IGNORE INTO vta_pathway_links (pathway, target_subsystem, description) VALUES + ('mesolimbic', 'nucleus_accumbens', 'reward-seeking / motivational salience (NAc-analog in BG)'), + ('mesolimbic', 'amygdala', 'salience tagging — DA boosts amygdala valence updates'), + ('mesocortical', 'pfc', 'PFC working memory + executive — DA gates PBWM updates'), + ('mesocortical', 'acc', 'effort / cost-of-control modulation'), + ('nigrostriatal', 'basal_ganglia', 'striatal Go/NoGo learning — primary RL training signal'), + ('broadcast', 'bg_modulators', 'global tonic_da dial — every reader sees the modulation'); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (75, 'VTA/SNc Phase 1: dopamine source structure (3 tables, 6 pathway-link seeds)', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..2256085 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -77,6 +77,7 @@ mcp_tools_tom, mcp_tools_trust, mcp_tools_usage, + mcp_tools_vta_snc, mcp_tools_workspace, mcp_tools_world, ) @@ -120,6 +121,7 @@ mcp_tools_tom, mcp_tools_trust, mcp_tools_usage, + mcp_tools_vta_snc, mcp_tools_workspace, mcp_tools_world, ] diff --git a/src/agentmemory/mcp_tools_vta_snc.py b/src/agentmemory/mcp_tools_vta_snc.py new file mode 100644 index 0000000..2039b6d --- /dev/null +++ b/src/agentmemory/mcp_tools_vta_snc.py @@ -0,0 +1,309 @@ +"""brainctl MCP tools — VTA/SNc dopamine source. + +Phase 1 per research-avenues memo Avenue 7. Codifies the dopamine +source as a first-class structure with its own firing log and state, +rather than just a derived quantity in bg_td_events + bg_modulators. + +Pairs with Habenula (PR #124): habenula's suggested_da_damp feeds VTA +tonic in Phase 3. +""" +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_SOURCE_KINDS = {"bg_td_positive", "novelty", "reward_received", "explicit_motivation", "other"} +VALID_PATHWAYS = {"mesolimbic", "mesocortical", "nigrostriatal", "broadcast", "other"} +VALID_PATHOLOGY = {"none", "low_da", "high_da"} + + +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 _require_schema(conn: sqlite3.Connection) -> str | None: + for t in ("vta_state", "vta_firings", "vta_pathway_links"): + if not conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (t,) + ).fetchone(): + return f"VTA/SNc schema missing: {t}. Run `brainctl migrate` (075)." + return None + + +def tool_vta_status(**_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 vta_state WHERE id = 1").fetchone() + last_5 = _rows(conn.execute( + "SELECT * FROM vta_firings ORDER BY id DESC LIMIT 5" + ).fetchall()) + agg_24h = conn.execute( + """ + SELECT COUNT(*) AS n, + COALESCE(AVG(burst_magnitude), 0.0) AS mean_mag, + COALESCE(MAX(burst_magnitude), 0.0) AS peak_mag, + SUM(CASE WHEN source_kind='bg_td_positive' THEN 1 ELSE 0 END) AS n_td, + SUM(CASE WHEN source_kind='novelty' THEN 1 ELSE 0 END) AS n_novelty, + SUM(CASE WHEN source_kind='reward_received' THEN 1 ELSE 0 END) AS n_reward + FROM vta_firings + WHERE fired_at >= datetime('now', '-24 hours') + """ + ).fetchone() + pathway_dist = _rows(conn.execute( + """ + SELECT target_pathway, COUNT(*) AS n + FROM vta_firings + WHERE fired_at >= datetime('now', '-24 hours') + GROUP BY target_pathway + """ + ).fetchall()) + return { + "ok": True, + "state": dict(state) if state else None, + "last_5_firings": last_5, + "aggregate_24h": dict(agg_24h) if agg_24h else {}, + "pathway_distribution_24h": pathway_dist, + } + + +def tool_vta_fire( + burst_magnitude: float, source_kind: str, + target_pathway: str | None = None, agent_id: str | None = None, + source_event_id: int | None = None, notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Record one phasic dopamine burst from VTA/SNc. + + Updates `vta_state.burst_budget` (depletes by 0.1 × magnitude; + clamped at 0) and `phasic_burst` (set to current magnitude). + Does NOT update `bg_modulators.tonic_da` in Phase 1; Phase 3 will. + """ + if not 0.0 <= burst_magnitude <= 1.0: + return {"error": "burst_magnitude must be in [0, 1]"} + if source_kind not in VALID_SOURCE_KINDS: + return {"error": f"invalid source_kind {source_kind!r}; expected {sorted(VALID_SOURCE_KINDS)}"} + if target_pathway is not None and target_pathway not in VALID_PATHWAYS: + return {"error": f"invalid target_pathway {target_pathway!r}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + cur = conn.execute( + """ + INSERT INTO vta_firings + (agent_id, burst_magnitude, source_kind, source_event_id, target_pathway, notes) + VALUES (?, ?, ?, ?, ?, ?) + """, + (agent_id, float(burst_magnitude), source_kind, source_event_id, target_pathway, notes), + ) + firing_id = cur.lastrowid + state = conn.execute("SELECT * FROM vta_state WHERE id = 1").fetchone() + new_budget = max(0.0, float(state["burst_budget"]) - 0.1 * float(burst_magnitude)) + conn.execute( + """ + UPDATE vta_state SET + phasic_burst = ?, + burst_budget = ?, + last_phasic_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + total_firings = total_firings + 1, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (float(burst_magnitude), new_budget), + ) + conn.commit() + return { + "ok": True, "firing_id": firing_id, + "burst_magnitude": float(burst_magnitude), + "new_burst_budget": new_budget, + } + + +def tool_vta_set_tonic( + tonic_da: float | None = None, + pathology_flag: str | None = None, + reason: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Update VTA tonic state. Phase 1: manual adjustment for + diagnostics + testing. Phase 3 lets Habenula's suggested_da_damp + + ARAS arousal modulate it automatically.""" + if tonic_da is not None and not 0.0 <= tonic_da <= 1.0: + return {"error": "tonic_da must be in [0, 1]"} + if pathology_flag is not None and pathology_flag not in VALID_PATHOLOGY: + return {"error": f"invalid pathology_flag; expected {sorted(VALID_PATHOLOGY)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + updates, params = [], [] + if tonic_da is not None: + updates.append("tonic_da = ?"); params.append(float(tonic_da)) + if pathology_flag is not None: + updates.append("pathology_flag = ?"); params.append(pathology_flag) + if not updates: + return {"error": "no fields to update"} + updates.append("last_tonic_update_at = strftime('%Y-%m-%dT%H:%M:%S', 'now')") + updates.append("updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now')") + conn.execute( + f"UPDATE vta_state SET {', '.join(updates)} WHERE id = 1", + tuple(params), + ) + conn.commit() + state = conn.execute("SELECT * FROM vta_state WHERE id = 1").fetchone() + return {"ok": True, "state": dict(state) if state else None, "reason": reason} + + +def tool_vta_pathways(pathway: str | None = None, **_kw: Any) -> dict[str, Any]: + """Catalog of VTA → target-subsystem links by pathway.""" + if pathway is not None and pathway not in VALID_PATHWAYS: + return {"error": f"invalid pathway"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + if pathway: + rows = conn.execute( + "SELECT * FROM vta_pathway_links WHERE pathway = ?", (pathway,) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM vta_pathway_links ORDER BY pathway, target_subsystem" + ).fetchall() + return {"ok": True, "pathway_links": _rows(rows)} + + +def tool_vta_history( + limit: int = 20, since: str | None = None, + source_kind: str | None = None, target_pathway: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + limit = max(1, min(int(limit), 200)) + if source_kind is not None and source_kind not in VALID_SOURCE_KINDS: + return {"error": "invalid source_kind"} + if target_pathway is not None and target_pathway not in VALID_PATHWAYS: + return {"error": "invalid target_pathway"} + 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 source_kind: + clauses.append("source_kind = ?"); params.append(source_kind) + if target_pathway: + clauses.append("target_pathway = ?"); params.append(target_pathway) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + rows = conn.execute( + f"SELECT * FROM vta_firings {where} ORDER BY id DESC LIMIT ?", + (*params, limit), + ).fetchall() + return {"ok": True, "history": _rows(rows)} + + +TOOLS: list[Tool] = [ + Tool( + name="vta_status", + description="VTA/SNc state + last 5 firings + 24h aggregate + pathway distribution.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="vta_fire", + description=( + "Record one phasic dopamine burst. burst_magnitude in [0,1]. source_kind ∈ " + "{bg_td_positive, novelty, reward_received, explicit_motivation, other}. " + "Optional target_pathway ∈ {mesolimbic, mesocortical, nigrostriatal, broadcast}. " + "Depletes burst_budget by 0.1×magnitude." + ), + inputSchema={ + "type": "object", + "properties": { + "burst_magnitude": {"type": "number"}, + "source_kind": {"type": "string", "enum": sorted(VALID_SOURCE_KINDS)}, + "target_pathway": {"type": "string", "enum": sorted(VALID_PATHWAYS)}, + "agent_id": {"type": "string"}, + "source_event_id": {"type": "integer"}, + "notes": {"type": "string"}, + }, + "required": ["burst_magnitude", "source_kind"], + }, + ), + Tool( + name="vta_set_tonic", + description="Manually set tonic_da and/or pathology_flag. Phase 3 will automate from Habenula + ARAS.", + inputSchema={ + "type": "object", + "properties": { + "tonic_da": {"type": "number"}, + "pathology_flag": {"type": "string", "enum": sorted(VALID_PATHOLOGY)}, + "reason": {"type": "string"}, + }, + }, + ), + Tool( + name="vta_pathways", + description=( + "Catalog of VTA → target-subsystem links. Without filter: all 4 pathways " + "(mesolimbic, mesocortical, nigrostriatal, broadcast). With pathway filter: " + "just that pathway's links." + ), + inputSchema={ + "type": "object", + "properties": { + "pathway": {"type": "string", "enum": sorted(VALID_PATHWAYS)}, + }, + }, + ), + Tool( + name="vta_history", + description="Paginated firing history. Filters: since, source_kind, target_pathway. limit clamped to [1, 200].", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "since": {"type": "string"}, + "source_kind": {"type": "string", "enum": sorted(VALID_SOURCE_KINDS)}, + "target_pathway": {"type": "string", "enum": sorted(VALID_PATHWAYS)}, + }, + }, + ), +] + + +_VTA_TOOLS = { + "vta_status": tool_vta_status, + "vta_fire": tool_vta_fire, + "vta_set_tonic": tool_vta_set_tonic, + "vta_pathways": tool_vta_pathways, + "vta_history": tool_vta_history, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _VTA_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_vta_snc.py b/tests/test_mcp_tools_vta_snc.py new file mode 100644 index 0000000..9b1c309 --- /dev/null +++ b/tests/test_mcp_tools_vta_snc.py @@ -0,0 +1,111 @@ +"""Tests for mcp_tools_vta_snc — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_075 = REPO_ROOT / "db" / "migrations" / "075_vta_snc.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_075.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_vta_snc as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_seeds_pathways(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + n = conn.execute("SELECT COUNT(*) FROM vta_pathway_links").fetchone()[0] + assert n == 6 + state = conn.execute( + "SELECT tonic_da, burst_budget, total_firings, pathology_flag FROM vta_state" + ).fetchone() + assert state == (0.5, 1.0, 0, "none") + finally: + conn.close() + + +def test_status_returns_state_and_aggregates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_vta_status() + assert out["ok"] is True + assert out["state"]["tonic_da"] == 0.5 + assert out["aggregate_24h"]["n"] == 0 + + +def test_fire_updates_state_and_depletes_budget(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_vta_fire(burst_magnitude=0.5, source_kind="bg_td_positive", + target_pathway="nigrostriatal") + assert out["ok"] is True + # 0.5 × 0.1 = 0.05 depletion → 1.0 → 0.95 + assert abs(out["new_burst_budget"] - 0.95) < 1e-9 + status = mod.tool_vta_status() + assert status["state"]["total_firings"] == 1 + + +def test_fire_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_vta_fire(burst_magnitude=1.5, source_kind="novelty") + assert "error" in mod.tool_vta_fire(burst_magnitude=0.5, source_kind="bogus") + + +def test_set_tonic_updates_state(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_vta_set_tonic(tonic_da=0.25, pathology_flag="low_da", reason="test") + assert out["ok"] is True + assert out["state"]["tonic_da"] == 0.25 + assert out["state"]["pathology_flag"] == "low_da" + + +def test_set_tonic_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_vta_set_tonic(tonic_da=1.5) + assert "error" in mod.tool_vta_set_tonic(pathology_flag="bogus") + assert "error" in mod.tool_vta_set_tonic() + + +def test_pathways_filter(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + all_p = mod.tool_vta_pathways() + assert len(all_p["pathway_links"]) == 6 + meso = mod.tool_vta_pathways(pathway="mesolimbic") + assert len(meso["pathway_links"]) == 2 + # All mesolimbic links target nucleus_accumbens or amygdala + targets = {p["target_subsystem"] for p in meso["pathway_links"]} + assert targets == {"nucleus_accumbens", "amygdala"} + + +def test_history_filters(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_vta_fire(burst_magnitude=0.3, source_kind="bg_td_positive", target_pathway="nigrostriatal") + mod.tool_vta_fire(burst_magnitude=0.5, source_kind="novelty", target_pathway="mesolimbic") + mod.tool_vta_fire(burst_magnitude=0.2, source_kind="bg_td_positive", target_pathway="mesocortical") + all_h = mod.tool_vta_history(limit=10) + assert len(all_h["history"]) == 3 + td = mod.tool_vta_history(limit=10, source_kind="bg_td_positive") + assert len(td["history"]) == 2 + meso = mod.tool_vta_history(limit=10, target_pathway="mesolimbic") + assert len(meso["history"]) == 1