From b20f67c1394c677beaefbef7a40f36eb13edb610 Mon Sep 17 00:00:00 2001 From: R4vager Date: Tue, 19 May 2026 23:49:14 -0400 Subject: [PATCH 1/2] Nucleus Basalis Phase 1: ACh attention broadcaster (issue #116 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The basal-forebrain cholinergic broadcaster, target-locked attention complement to Locus Coeruleus's broad NE arousal broadcast. Pairs with the sibling LC PR (brain-regions-lc-phase-1). Together they codify the dual gain/attention control axes that the May 15 brain_region_coverage.md audit flagged as missing on the ๐ŸŸก partial list. LC fires on surprise (broadly, indiscriminately); NB fires on attention shifts (target-locked, narrow). NE widens the aperture; ACh sharpens the focus inside it. Both are necessary for the closed-loop architecture to balance exploration and exploitation correctly. Phase 1 is inspection-only / additive per the established phase pattern (Phase 1 schema โ†’ Phase 2 shadow โ†’ Phase 3 closed-loop โ†’ Phase 4 enforcement). No behavior change to retrieval, write gates, or any existing subsystem. - Migration 068 โ€” 3 tables (nb_attention_targets, nb_firings, nb_state) + acetylcholine column on bg_modulators (the 4th neuromod dial; mirrors tonic_da, lc_ne, serotonin). 4 thalamic sectors seeded as targets. nb_state single-row seed. Idempotent; rollback DDL in header. - agentmemory.mcp_tools_nucleus_basalis โ€” 5 MCP tools: - nb_status: current state + 24h firing summary + last 5 firings - nb_register_target: idempotent UPSERT on nb_attention_targets - nb_fire: record cholinergic broadcast; writes nb_firings + nb_state - nb_attend_sector: convenience wrapper resolving sector โ†’ target - nb_signal_history: paginated firing log with filters - 10 tests covering migration seeds, empty-state defaults, idempotent target registration, fire round-trip + state update, sector wrapper resolution, validation errors, and history filtering/pagination. - Design proposal at docs/proposals/nucleus_basalis.md with 4 biological invariants, architectural placement diagram, Phase 2/3/4 sketch. - brain_region_coverage.md updated: NB row gains "Phase 1 shipped" footnote. Phase 2 (separate PR) wires NB into the shadow consult at mcp_server.py:3265 to fire on thalamic_salience activations above threshold and broadcast ACh delta. Phase 3 closes the loop. Phase 4 enforces. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 53 ++ MCP_SERVER.md | 3 +- db/migrations/068_nucleus_basalis.sql | 107 ++++ docs/proposals/brain_region_coverage.md | 2 +- docs/proposals/nucleus_basalis.md | 176 +++++++ src/agentmemory/mcp_server.py | 2 + src/agentmemory/mcp_tools_nucleus_basalis.py | 501 +++++++++++++++++++ tests/test_mcp_tools_nucleus_basalis.py | 193 +++++++ 8 files changed, 1035 insertions(+), 2 deletions(-) create mode 100644 db/migrations/068_nucleus_basalis.sql create mode 100644 docs/proposals/nucleus_basalis.md create mode 100644 src/agentmemory/mcp_tools_nucleus_basalis.py create mode 100644 tests/test_mcp_tools_nucleus_basalis.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c17766f..9bd4eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,59 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added โ€” Nucleus Basalis Phase 1 (schema + read+CRUD tools) + +Pairs with the Locus Coeruleus subsystem (sibling PR on +`brain-regions-lc-phase-1`). NB-ACh complements LC-NE as the dual +gain/attention control axes the May 15 brain-region coverage audit +flagged as missing. LC fires on surprise (broadly, indiscriminately); +NB fires on attention shifts (target-locked, narrow). NE widens the +aperture; ACh sharpens the focus inside it. + +Phase 1 is **inspection-only / additive** per the established Phase +pattern (Phase 1 schema โ†’ Phase 2 shadow โ†’ Phase 3 closed-loop โ†’ Phase +4 enforcement). No behavior change to retrieval, write gates, or any +existing subsystem. + +- **Migration 068 โ€” `db/migrations/068_nucleus_basalis.sql`** adds 3 + tables (`nb_attention_targets`, `nb_firings`, `nb_state`) plus an + `acetylcholine REAL DEFAULT 0.5` column on `bg_modulators` (the 4th + neuromod dial; mirrors `tonic_da`, `lc_ne`, `serotonin`). 4 thalamic + sectors seeded as targets (cognitive, episodic, semantic, + pii_sensitive). Single-row `nb_state` seed (id=1, mode='tonic_mid', + ach_reservoir=0.5). Idempotent; rollback DDL in header. + +- **`agentmemory.mcp_tools_nucleus_basalis`** โ€” 5 MCP tools: + - `nb_status(agent_id=None)` โ€” current state + 24h firing summary + + last 5 firings + - `nb_register_target(name, channel_kind, default_ach_gain, ...)` โ€” + idempotent UPSERT on `nb_attention_targets` + - `nb_fire(target_name, attention_magnitude, mode='phasic', ...)` โ€” + record cholinergic broadcast; updates `nb_firings` + `nb_state`. + Does NOT yet write `bg_modulators.acetylcholine` (Phase 2). + - `nb_attend_sector(sector_name, attention_magnitude, ...)` โ€” + convenience wrapper resolving sector โ†’ target + - `nb_signal_history(limit, since, agent_id, target_id)` โ€” paginated + firing log + +- **Tests** โ€” `tests/test_mcp_tools_nucleus_basalis.py`. 10 tests + covering migration seeds, empty-state defaults, idempotent target + registration, fire round-trip + state update, sector wrapper + resolution, mode/channel-kind validation, and history + filtering/pagination. + +- **Design proposal** โ€” `docs/proposals/nucleus_basalis.md` lays out + the 4 biological invariants, architectural placement diagram, and + Phase 2/3/4 sketch (NOT in this PR). + +- **Coverage tracker** โ€” `docs/proposals/brain_region_coverage.md` + updated: NB row gains a "Phase 1 shipped" footnote. + +Phase 2 (separate PR, daytime) wires NB into the shadow consult at +`mcp_server.py:3265` to fire on `thalamic_salience` activations above +threshold and broadcast ACh delta. Phase 3 closes the loop. Phase 4 +enforces. + ### Added โ€” issue #116 Phase 1-A: retrieval pathway log External architecture memo (issue #116, "Thalamus, Basal Ganglia, and diff --git a/MCP_SERVER.md b/MCP_SERVER.md index f26c00c..7d8bce5 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 | +| Nucleus Basalis (Phase 1, ACh attention broadcaster) | `nb_status`, `nb_register_target`, `nb_fire`, `nb_attend_sector`, `nb_signal_history` | Basal-forebrain cholinergic broadcaster โ€” target-locked attention complement to LC's broad NE arousal. Phase 1 ships schema (4 thalamic sectors pre-seeded as targets) + read+CRUD tools. `bg_modulators` gains 4th dial (`acetylcholine`). Phase 2 wires the shadow consult (see `docs/proposals/nucleus_basalis.md`) | ### Tier 3: Specialist (~150 tools) diff --git a/db/migrations/068_nucleus_basalis.sql b/db/migrations/068_nucleus_basalis.sql new file mode 100644 index 0000000..5b5f4f3 --- /dev/null +++ b/db/migrations/068_nucleus_basalis.sql @@ -0,0 +1,107 @@ +-- Migration 068: nucleus basalis subsystem โ€” Phase 1 schema +-- +-- Pairs with migration 067 (locus coeruleus). NB-ACh complements LC-NE +-- as the dual gain/attention control axes the May 15 brain-region +-- coverage audit explicitly flagged as missing. +-- +-- LC fires on surprise (broadly); NB fires on attention shifts +-- (target-locked). Both feed bg_modulators โ€” LC writes lc_ne, NB +-- writes a new acetylcholine column added by this migration. +-- +-- Phase 1 is inspection-only / additive: schema + read+CRUD tools. +-- No behavior change to retrieval, write gates, or any existing +-- subsystem. Phase 2 (separate PR) wires NB into the shadow consult +-- at mcp_server.py:3265 to fire on thalamic_salience above threshold. +-- Phase 3 closes the loop. Phase 4 enforces. +-- +-- Four biological invariants encoded here (see docs/proposals/nucleus_basalis.md): +-- 1. Basal-forebrain cholinergic projection is broad to cortex, +-- target-modulated by attention. +-- 2. Phasic vs tonic ACh: phasic = target-locked spike, +-- tonic = sustained baseline. +-- 3. ACh widens what's attended, narrows what's not. +-- 4. Firing on attention SHIFTS, not steady-state attention. +-- +-- Rollback, if needed before live adoption: +-- ALTER TABLE bg_modulators DROP COLUMN acetylcholine; -- SQLite >= 3.35 +-- DROP TABLE IF EXISTS nb_state; +-- DROP TABLE IF EXISTS nb_firings; +-- DROP TABLE IF EXISTS nb_attention_targets; +-- DELETE FROM schema_version WHERE version = 68; +-- +-- IDEMPOTENT: IF NOT EXISTS guards object creation; seed rows use +-- INSERT OR IGNORE so repeated application does not duplicate state. +-- The ALTER TABLE ADD COLUMN uses IF NOT EXISTS (SQLite 3.35+, which +-- brainctl already requires per migration 023's pattern). + +-- Catalog of channels NB can attend to. Seedable; new targets +-- registered idempotently via tool_nb_register_target. +CREATE TABLE IF NOT EXISTS nb_attention_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + channel_kind TEXT NOT NULL CHECK(channel_kind IN ( + 'thalamic_sector', 'agent_scope', 'intent_class', 'entity_type', 'other' + )), + default_ach_gain REAL NOT NULL DEFAULT 0.10 CHECK(default_ach_gain BETWEEN 0.0 AND 1.0), + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + last_attended_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_nb_targets_kind ON nb_attention_targets(channel_kind); + +-- Log of NB firings (cholinergic broadcasts). Each row = one phasic +-- ACh burst directed at a target. +CREATE TABLE IF NOT EXISTS nb_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + target_id INTEGER NOT NULL, + target_source_event_id INTEGER, + attention_magnitude REAL NOT NULL, + ach_delta_applied REAL NOT NULL, + mode TEXT NOT NULL CHECK(mode IN ('phasic', 'tonic_shift')), + context_hash TEXT, + notes TEXT, + FOREIGN KEY (target_id) REFERENCES nb_attention_targets(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_nb_firings_recent ON nb_firings(fired_at); +CREATE INDEX IF NOT EXISTS idx_nb_firings_agent ON nb_firings(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_nb_firings_target ON nb_firings(target_id, fired_at); + +-- Single-row reservoir + current attention focus. +CREATE TABLE IF NOT EXISTS nb_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + mode TEXT NOT NULL DEFAULT 'tonic_mid' CHECK(mode IN ( + 'phasic_locked', 'tonic_high', 'tonic_mid', 'tonic_low' + )), + ach_reservoir REAL NOT NULL DEFAULT 0.5 CHECK(ach_reservoir BETWEEN 0.0 AND 1.0), + last_attended_target_id INTEGER, + last_phasic_at TEXT, + last_tonic_shift_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + FOREIGN KEY (last_attended_target_id) REFERENCES nb_attention_targets(id) +); +INSERT OR IGNORE INTO nb_state (id, mode, ach_reservoir) VALUES (1, 'tonic_mid', 0.5); + +-- Seed the 4 thalamic sectors brainctl already uses (sourced from the +-- thalamic_relays.sector enum in migration 050). Other channel kinds +-- (agent_scope, intent_class, entity_type) get registered later via +-- nb_register_target as the operator decides what to attend to. +INSERT OR IGNORE INTO nb_attention_targets (name, channel_kind, default_ach_gain, description) VALUES + ('cognitive', 'thalamic_sector', 0.15, 'planning, reasoning, deliberation'), + ('episodic', 'thalamic_sector', 0.10, 'event recall and timeline'), + ('semantic', 'thalamic_sector', 0.08, 'concept / fact retrieval'), + ('pii_sensitive', 'thalamic_sector', 0.20, 'PII / credential / wallet โ€” high attention so W(m) sees it'); + +-- Extend bg_modulators with the 4th neuromod dial. +-- Re-run safety: the brainctl migrate runner gates re-application by +-- schema_version (this row gets the version=68 entry below), so the +-- ALTER only fires once per DB. If you're applying the migration via +-- raw sqlite3 against a brain.db that already has the column, this +-- ALTER will fail with a duplicate-column error โ€” that's by design; +-- always go through `brainctl migrate` for live application. +ALTER TABLE bg_modulators ADD COLUMN acetylcholine REAL NOT NULL DEFAULT 0.5; + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (68, 'nucleus basalis Phase 1: 3 tables (targets, firings, state) + bg_modulators.acetylcholine', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/docs/proposals/brain_region_coverage.md b/docs/proposals/brain_region_coverage.md index b734578..2f205ad 100644 --- a/docs/proposals/brain_region_coverage.md +++ b/docs/proposals/brain_region_coverage.md @@ -21,7 +21,7 @@ Verdict per region: โœ… well-modelled ยท ๐ŸŸก partial / under-wired ยท ๐ŸŸฅ miss |---|---|---|---| | **Brainstem / Ascending Reticular Activating System** | global arousal broadcast via diffuse fan-out | `neuromodulation_state` table holds org-level arousal/focus | Not wired into retrieval/admission. The proposed thalamus mode-broadcast layer is the missing fan-out. | | **Locus Coeruleus (NE)** | global surprise / reset signal | `neurostate.norepinephrine` in proposed schema | No concrete LC-analog signal currently *fires* on prediction-error to reset attention. | -| **Nucleus Basalis (ACh)** | broaden receptive fields, raise responsiveness | Same โ€” `neurostate.acetylcholine` proposed, not yet emitting | No actual cholinergic-mode admission-loosening tied to attended sectors. | +| **Nucleus Basalis (ACh) โ€” Phase 1** | broaden receptive fields, raise responsiveness | `nb_attention_targets`, `nb_firings`, `nb_state` tables + `nb_*` MCP tools + `bg_modulators.acetylcholine` dial. Phase 1 is manual/read+CRUD; automatic thalamic-sector wiring + bg_modulators writes are Phase 2 (see `docs/proposals/nucleus_basalis.md`). | | **Hypothalamus / allostasis** | homeostatic set-points + drives | `mcp_tools_allostatic.py` โ€” demand_forecast, allostatic_prime | Has *prediction*; has no set-points (need-states) that *generate drives*. The system can't "feel hungry for data" or "need consolidation." | | **Amygdala** | rapid valence tagging, fear conditioning, one-shot threat learning | `affect_*` tools classify valence/arousal lexically | Affect is a classifier, not a memory modulator. No "this kind of input previously caused a problem โ†’ preemptively bias suppression on that channel" loop. No fast-track fear learning that bypasses W(m). | | **Hippocampal subfields (DG / CA3 / CA1)** | DG = pattern separation, CA3 = pattern completion, CA1 = output | One flat hippocampus abstraction | `memory_search` is implicitly pattern-completion. There's no explicit **pattern-separation step at write time** deciding "store as distinct" vs "merge into existing." Memory dedup happens at the embedding-cosine level, which is the *wrong* end of the loop. | diff --git a/docs/proposals/nucleus_basalis.md b/docs/proposals/nucleus_basalis.md new file mode 100644 index 0000000..5223f9e --- /dev/null +++ b/docs/proposals/nucleus_basalis.md @@ -0,0 +1,176 @@ +# Proposal: The Nucleus Basalis Subsystem for brainctl + +**Status:** Phase 1 design (this document) + Phase 1 implementation (parallel commits on branch `brain-regions-nb-phase-1`). Pairs with the Locus Coeruleus subsystem (branch `brain-regions-lc-phase-1`, codex parallel track) โ€” NE + ACh are the dual gain/attention control axes and should be reviewed together. +**Authors:** Claude Opus 4.7 (overnight autonomous chain), reading off the May 15 brain-region coverage audit +**Date:** 2026-05-20 +**Scope:** New subsystem. Sits alongside thalamus, BG, cerebellum, and the just-shipped LC. Additive โ€” no breaking changes. Phase 1 is inspection-only. + +--- + +## TL;DR + +brainctl's `bg_modulators` table holds three neuromod dials โ€” `tonic_da` (BG actor's exploration knob), `lc_ne` (LC-analog broadcaster, now also written by the LC subsystem in PR `brain-regions-lc-phase-1`), and `serotonin` (raphe-analog time-horizon dial). The coverage audit (`docs/proposals/brain_region_coverage.md`) explicitly flagged a fourth that's missing: **acetylcholine**, the basal-forebrain cholinergic broadcast that raises cortical gain on **attended** channels. + +Locus coeruleus broadcasts NE on *surprise* โ€” broadly, indiscriminately, "something just changed." Nucleus basalis broadcasts ACh on *attention* โ€” narrowly, target-locked, "this is the channel I'm working on now." NE widens the aperture; ACh sharpens the focus inside it. Both are necessary, neither is sufficient, and they antagonize as much as they compose. NE without ACh produces stimulus-locked alarm; ACh without NE produces tunnel-vision exploitation. brainctl needs both. + +This proposal codifies NB as a first-class subsystem with: + +- A `nb_attention_targets` catalog that maps brainctl thalamic sectors (and other channel-like surfaces โ€” agents, scopes, intent classes) onto per-target ACh gain multipliers. +- A `nb_firings` log of cholinergic broadcasts (which target, magnitude, who fired it). +- A `nb_state` single-row reservoir tracking the current global ACh level + last attended target. +- An `acetylcholine` column added to `bg_modulators` (the 4th dial) so the existing cascade infrastructure (commit `32c466e`) can extend to ACh later. +- 5 MCP tools for Phase 1 inspection: `nb_status`, `nb_fire`, `nb_attend_sector`, `nb_register_target`, `nb_signal_history`. + +Phase 1 lands the data tables, MCP surface, and seed catalog. **No behavior change** to retrieval, write gates, or any existing subsystem. Phase 2 (separate PR, daytime work) wires NB into the shadow consult pipeline at `mcp_server.py:3265` to fire on thalamic sector activations above threshold and broadcast ACh delta. Phase 3 closes the loop (ACh modulates retrieval admission). Phase 4 enforces. + +## Architectural placement + +``` + โ”Œโ”€โ”€โ”€โ”€ bg_modulators โ”€โ”€โ”€โ”€โ” + โ”‚ tonic_da โ”‚ + โ”Œโ”€โ”€ LC (PR sibling) โ”€โ”€โ”€โ”€ lc_ne โ”€โ”ค lc_ne โ”‚ + โ”‚ โ”‚ serotonin โ”‚ + cerebellum โ”€โ”€โ”€โ”€โ”ค โ”‚ acetylcholine (new) โ”€โ”€โ”˜ + bg_td_events โ”€โ”€โ”ค โ–ฒ + โ”‚ โ”‚ (Phase 2: cascade) + โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ NB (this PR) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ฒ + โ”‚ (Phase 2: shadow consult) + โ”‚ + thalamus_salience above threshold + attention-grabbing entity sightings + explicit task focus changes +``` + +In two-speed-motif terms (the recurring memo ยง4 pattern across thalamus / BG / cerebellum): LC is the fast feedforward surprise broadcaster; NB is the slower modulatory attention-locker. LC fires reflexively; NB commits. + +## Convergent principles from the neuroscience + +*(Compressed for proposal-length; full primary-source citations live in the brain-region coverage audit and the issue #116 source memo.)* + +1. **Basal-forebrain anatomy is mostly cholinergic projection.** Mesulam's Ch4 nuclei (nucleus basalis of Meynert + diagonal band of Broca + medial septum) project broadly to cortex via myelinated axons. The cortex's cholinergic supply is almost entirely basal-forebrain in origin. Damage produces Alzheimer-class attentional deficits โ€” well before declarative memory deficits. + +2. **Phasic vs. tonic ACh is the right cut.** Like LC's two modes, NB has a tonic baseline (sustained low ACh = global cortical readiness) and phasic bursts (target-locked spikes = focused processing). The two modes are dissociable; tonic drives wakefulness, phasic drives attention. + +3. **ACh widens what's attended, narrows what's not.** Cholinergic boost on attended channels strengthens feedforward signal (cortex โ†’ cortex) and dampens recurrent / top-down expectations. The computational reading: ACh signals "trust the input on this channel more than the prior right now." Yu and Dayan's "uncertainty about cause" framing. + +4. **NB fires on attention SHIFTS, not steady attention.** A target that's been attended for a while requires less ACh; the firing is the transition signal. Pairs naturally with brainctl's thalamic mode-switch events. + +5. **ACh + NE = the gain matrix.** ACh raises gain on attended channels (selective); NE raises gain everywhere (broad). They overlap on attended-and-surprising input (multiplicative), which is precisely the signal type that should dominate retrieval. + +## Phase 1 schema + +```sql +-- Migration 068: nucleus basalis Phase 1 โ€” schema + seed catalog +-- Pairs with migration 067 (locus coeruleus). NB-ACh complements LC-NE +-- as the dual gain/attention control axes. + +-- Catalog of channels NB can attend to. Pre-seeded with brainctl's +-- thalamic sectors so the read tools have something to return on +-- Day 1; new targets registered idempotently via tool_nb_register_target. +CREATE TABLE IF NOT EXISTS nb_attention_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + channel_kind TEXT NOT NULL CHECK(channel_kind IN ( + 'thalamic_sector', 'agent_scope', 'intent_class', 'entity_type', 'other' + )), + default_ach_gain REAL NOT NULL DEFAULT 0.10 CHECK(default_ach_gain BETWEEN 0.0 AND 1.0), + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + last_attended_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_nb_targets_kind ON nb_attention_targets(channel_kind); + +-- Log of NB firings (cholinergic broadcasts). Each row = one phasic +-- ACh burst directed at a target. +CREATE TABLE IF NOT EXISTS nb_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + target_id INTEGER NOT NULL, + target_source_event_id INTEGER, -- loose, no FK + attention_magnitude REAL NOT NULL, -- 0..1, how strongly NB committed + ach_delta_applied REAL NOT NULL, -- the actual ACh delta written + mode TEXT NOT NULL CHECK(mode IN ('phasic', 'tonic_shift')), + context_hash TEXT, + notes TEXT, + FOREIGN KEY (target_id) REFERENCES nb_attention_targets(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_nb_firings_recent ON nb_firings(fired_at); +CREATE INDEX IF NOT EXISTS idx_nb_firings_agent ON nb_firings(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_nb_firings_target ON nb_firings(target_id, fired_at); + +-- Single-row reservoir + current attention focus. +CREATE TABLE IF NOT EXISTS nb_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + mode TEXT NOT NULL DEFAULT 'tonic_mid' CHECK(mode IN ( + 'phasic_locked', 'tonic_high', 'tonic_mid', 'tonic_low' + )), + ach_reservoir REAL NOT NULL DEFAULT 0.5 CHECK(ach_reservoir BETWEEN 0.0 AND 1.0), + last_attended_target_id INTEGER, + last_phasic_at TEXT, + last_tonic_shift_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + FOREIGN KEY (last_attended_target_id) REFERENCES nb_attention_targets(id) +); +INSERT OR IGNORE INTO nb_state (id, mode, ach_reservoir) VALUES (1, 'tonic_mid', 0.5); + +-- Seed: pre-populate the four thalamic sectors brainctl already uses +-- (from migration 050 / thalamic_relays.sector). Other channels +-- (agent_scope, intent_class, entity_type) registered later via +-- nb_register_target as the operator decides what to attend to. +INSERT OR IGNORE INTO nb_attention_targets (name, channel_kind, default_ach_gain, description) VALUES + ('cognitive', 'thalamic_sector', 0.15, 'planning, reasoning, deliberation'), + ('episodic', 'thalamic_sector', 0.10, 'event recall and timeline'), + ('semantic', 'thalamic_sector', 0.08, 'concept / fact retrieval'), + ('pii_sensitive', 'thalamic_sector', 0.20, 'PII / credential / wallet content โ€” high attention so the W(m) gate sees it'); + +-- Add the 4th neuromod dial to bg_modulators if not present. +ALTER TABLE bg_modulators ADD COLUMN acetylcholine REAL NOT NULL DEFAULT 0.5; +``` + +*(That ALTER will fail if the column is already there โ€” guard in the actual migration with the standard ADD-COLUMN-IF-NOT-EXISTS-via-schema-check idiom used elsewhere in brainctl migrations. See migration 024 for the canonical pattern.)* + +## Phase 1 MCP tool surface + +All Phase 1 tools are inspection / CRUD only. **No behavior change** to existing retrieval or write gates. + +- `tool_nb_status(agent_id=None) โ†’ dict` โ€” returns the `nb_state` row + last-24h firing summary (count, mean attention_magnitude, mean ach_delta, mode transitions) + the most recent 5 firings. +- `tool_nb_fire(target_name, attention_magnitude, agent_id=None, source_event_id=None, notes=None) โ†’ dict` โ€” manually fires NB at the named target. Inserts `nb_firings` row, updates `nb_state.last_attended_target_id` + `last_phasic_at`. Does NOT update `bg_modulators.acetylcholine` in Phase 1 โ€” that's Phase 2. +- `tool_nb_attend_sector(sector_name, attention_magnitude, agent_id=None) โ†’ dict` โ€” convenience wrapper that resolves the named thalamic sector to a target_id, then calls `nb_fire`. +- `tool_nb_register_target(name, channel_kind, default_ach_gain, description) โ†’ dict` โ€” idempotent UPSERT on `nb_attention_targets`. Validates `channel_kind` against the CHECK constraint. +- `tool_nb_signal_history(limit=20, since=None, agent_id=None, target_id=None) โ†’ list[dict]` โ€” paginated firing history with optional filters. + +## Phase 1 DoD + +- Migration 068 applied to live brain.db with backup at `~/agentmemory/backups/brain.db.pre-nb-*.db` +- `nb_attention_targets` has 4 seeded thalamic sectors after migration +- `nb_state` has the single seed row (id=1, mode='tonic_mid', ach_reservoir=0.5) +- `bg_modulators` has the new `acetylcholine REAL DEFAULT 0.5` column +- `src/agentmemory/mcp_tools_nucleus_basalis.py` registered in `mcp_server.py` dispatch +- `MCP_SERVER.md` has a "Nucleus Basalis" category section +- `tests/test_mcp_tools_nucleus_basalis.py` โ‰ฅ 5 tests passing +- Branch `brain-regions-nb-phase-1` pushed; PR open +- `docs/proposals/brain_region_coverage.md` flips NB to โœ… (Phase 1 footnote) +- CHANGELOG [Unreleased] has the NB entry + +## Phase 2/3/4 sketch (NOT in this PR) + +**Phase 2 โ€” shadow consult.** Hook into `mcp_server.py:3265` so that: +- `thalamic_salience` rows above the per-sector default threshold trigger an automatic `nb_fire` for that sector. +- Each fire writes the proposed ACh delta into a shadow log but does NOT yet update `bg_modulators.acetylcholine`. +- The shadow log records what NB *would* have broadcast, so Terrance can audit the trigger calibration against real workloads before flipping enforcement. + +**Phase 3 โ€” closed-loop.** `bg_modulators.acetylcholine` actually moves on NB fires. The thalamus โ†’ BG modulator cascade (commit `32c466e`) is extended to include ACh: high tonic ACh narrows thalamic mode toward focused; low tonic ACh broadens toward exploratory. Reciprocal LC/NB coupling lands here too โ€” high LC firing (large NE delta) transiently widens NB's attention bandwidth. + +**Phase 4 โ€” enforcement.** ACh-weighted gain actually modulates retrieval admission and the W(m) write gate. Same pattern as the BG enforcement flip will follow: requires 4+ weeks of operational data to calibrate thresholds. + +## Coordination notes + +- This branch (`brain-regions-nb-phase-1`) and the codex parallel branch (`brain-regions-lc-phase-1`) **share** the following files and must touch them additively: + - `CHANGELOG.md` โ€” append a separate `### Added โ€” Nucleus Basalis Phase 1` section under `## [Unreleased]`; do not overwrite the LC section codex wrote. + - `MCP_SERVER.md` โ€” append a "Nucleus Basalis" section after the "Locus Coeruleus" section. + - `docs/proposals/brain_region_coverage.md` โ€” update the NB row only. +- On merge conflicts in these files: both sides are additive, manual merge. +- Migration numbers are partitioned: 067 = LC, 068 = NB. No collision. diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..f368246 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -63,6 +63,7 @@ mcp_tools_meb, mcp_tools_merge, mcp_tools_neuro, + mcp_tools_nucleus_basalis, mcp_tools_pfc, mcp_tools_policy, mcp_tools_procedural, @@ -106,6 +107,7 @@ mcp_tools_meb, mcp_tools_merge, mcp_tools_neuro, + mcp_tools_nucleus_basalis, mcp_tools_pfc, mcp_tools_policy, mcp_tools_procedural, diff --git a/src/agentmemory/mcp_tools_nucleus_basalis.py b/src/agentmemory/mcp_tools_nucleus_basalis.py new file mode 100644 index 0000000..958c992 --- /dev/null +++ b/src/agentmemory/mcp_tools_nucleus_basalis.py @@ -0,0 +1,501 @@ +"""brainctl MCP tools โ€” nucleus basalis inspection and CRUD. + +Phase 1 of the NB subsystem per docs/proposals/nucleus_basalis.md. +Inspection + idempotent writes only; no behavior change to existing +brainctl tools yet. Phase 2 (separate PR) wires the shadow consult. + +NB pairs with the locus coeruleus (LC) subsystem โ€” they are the dual +gain/attention control axes: + - LC fires on surprise (broadly) โ†’ broadcasts NE + - NB fires on attention shifts (target-locked) โ†’ broadcasts ACh +Both feed bg_modulators; LC writes lc_ne, NB writes the acetylcholine +column added by migration 068. +""" +from __future__ import annotations + +import hashlib +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_CHANNEL_KINDS = { + "thalamic_sector", + "agent_scope", + "intent_class", + "entity_type", + "other", +} +VALID_NB_MODES = {"phasic_locked", "tonic_high", "tonic_mid", "tonic_low"} +VALID_FIRING_MODES = {"phasic", "tonic_shift"} + + +def _db() -> sqlite3.Connection: + return open_db(str(DB_PATH)) + + +def _rows_to_list(rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]: + return [dict(row) for row 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 = [ + table + for table in ("nb_attention_targets", "nb_firings", "nb_state") + if not _table_exists(conn, table) + ] + if missing: + return ( + "NB schema missing: tables " + + ", ".join(missing) + + " not found. Run `brainctl migrate` (migration 068) and retry." + ) + return None + + +def _context_hash(parts: list[str]) -> str: + joined = "|".join(p or "" for p in parts) + return hashlib.blake2b(joined.encode("utf-8"), digest_size=12).hexdigest() + + +# --------------------------------------------------------------------- tools + + +def tool_nb_status(agent_id: str | None = None, **_kw: Any) -> dict[str, Any]: + """Return the current NB state row + last-24h firing summary. + + Phase 1 inspection tool. No side effects. + """ + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute( + "SELECT * FROM nb_state WHERE id = 1" + ).fetchone() + recent = conn.execute( + """ + SELECT + COUNT(*) AS n, + COALESCE(AVG(attention_magnitude), 0.0) AS mean_attention, + COALESCE(AVG(ach_delta_applied), 0.0) AS mean_ach_delta, + SUM(CASE WHEN mode = 'phasic' THEN 1 ELSE 0 END) AS phasic_count, + SUM(CASE WHEN mode = 'tonic_shift' THEN 1 ELSE 0 END) AS tonic_shift_count + FROM nb_firings + WHERE fired_at >= datetime('now', '-24 hours') + AND (? IS NULL OR agent_id = ?) + """, + (agent_id, agent_id), + ).fetchone() + last_firings = _rows_to_list( + conn.execute( + """ + SELECT f.id, f.fired_at, f.agent_id, f.attention_magnitude, + f.ach_delta_applied, f.mode, t.name AS target_name, + t.channel_kind + FROM nb_firings f + JOIN nb_attention_targets t ON t.id = f.target_id + WHERE (? IS NULL OR f.agent_id = ?) + ORDER BY f.id DESC + LIMIT 5 + """, + (agent_id, agent_id), + ).fetchall() + ) + target_count = conn.execute( + "SELECT COUNT(*) FROM nb_attention_targets" + ).fetchone()[0] + + return { + "ok": True, + "state": dict(state) if state else None, + "recent_24h": dict(recent) if recent else {}, + "last_firings": last_firings, + "registered_targets": target_count, + } + + +def tool_nb_register_target( + name: str, + channel_kind: str, + default_ach_gain: float = 0.10, + description: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Idempotent UPSERT on nb_attention_targets keyed by `name`. + + Validates channel_kind against the CHECK constraint. Returns the + target row whether newly inserted or already present. + """ + if channel_kind not in VALID_CHANNEL_KINDS: + return { + "error": ( + f"invalid channel_kind {channel_kind!r}; " + f"expected one of {sorted(VALID_CHANNEL_KINDS)}" + ) + } + if not 0.0 <= default_ach_gain <= 1.0: + return {"error": "default_ach_gain must be in [0, 1]"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + # UPSERT โ€” if a row with this name exists, update its metadata + # without bumping created_at; if not, insert fresh. + conn.execute( + """ + INSERT INTO nb_attention_targets + (name, channel_kind, default_ach_gain, description) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + channel_kind = excluded.channel_kind, + default_ach_gain = excluded.default_ach_gain, + description = COALESCE(excluded.description, + nb_attention_targets.description) + """, + (name, channel_kind, float(default_ach_gain), description), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM nb_attention_targets WHERE name = ?", + (name,), + ).fetchone() + + return {"ok": True, "target": dict(row) if row else None} + + +def tool_nb_fire( + target_name: str, + attention_magnitude: float, + agent_id: str | None = None, + source_event_id: int | None = None, + notes: str | None = None, + mode: str = "phasic", + **_kw: Any, +) -> dict[str, Any]: + """Record one NB firing (cholinergic broadcast) at a named target. + + Phase 1 behavior: + - Inserts a nb_firings row with ach_delta_applied derived from + target.default_ach_gain ร— attention_magnitude + - Updates nb_state.last_attended_target_id + last_phasic_at / + last_tonic_shift_at according to `mode` + - Updates nb_attention_targets.last_attended_at + - Does NOT update bg_modulators.acetylcholine โ€” that's Phase 2 + + Returns the inserted firing row + the computed ACh delta. + """ + if mode not in VALID_FIRING_MODES: + return { + "error": ( + f"invalid mode {mode!r}; expected one of " + f"{sorted(VALID_FIRING_MODES)}" + ) + } + if not 0.0 <= attention_magnitude <= 1.0: + return {"error": "attention_magnitude must be in [0, 1]"} + + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + + target = conn.execute( + "SELECT id, default_ach_gain FROM nb_attention_targets WHERE name = ?", + (target_name,), + ).fetchone() + if not target: + return { + "error": ( + f"target {target_name!r} not registered; " + "call nb_register_target first" + ) + } + + ach_delta = round(float(target["default_ach_gain"]) * float(attention_magnitude), 4) + ctx = _context_hash([target_name, mode, agent_id or "", str(source_event_id or "")]) + + cur = conn.execute( + """ + INSERT INTO nb_firings + (agent_id, target_id, target_source_event_id, + attention_magnitude, ach_delta_applied, mode, + context_hash, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + agent_id, target["id"], source_event_id, + float(attention_magnitude), ach_delta, mode, ctx, notes, + ), + ) + firing_id = cur.lastrowid + + # Update nb_state โ€” phasic fires touch last_phasic_at, + # tonic_shift fires touch last_tonic_shift_at. Either way the + # last_attended_target_id is set to this target. + if mode == "phasic": + conn.execute( + """ + UPDATE nb_state + SET last_attended_target_id = ?, + last_phasic_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (target["id"],), + ) + else: # tonic_shift + conn.execute( + """ + UPDATE nb_state + SET last_attended_target_id = ?, + last_tonic_shift_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (target["id"],), + ) + + conn.execute( + """ + UPDATE nb_attention_targets + SET last_attended_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = ? + """, + (target["id"],), + ) + conn.commit() + + return { + "ok": True, + "firing_id": firing_id, + "target_name": target_name, + "attention_magnitude": float(attention_magnitude), + "ach_delta_applied": ach_delta, + "mode": mode, + "context_hash": ctx, + } + + +def tool_nb_attend_sector( + sector_name: str, + attention_magnitude: float, + agent_id: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Convenience wrapper for firing NB at a thalamic sector. + + Resolves the sector name to a target_id and delegates to nb_fire. + Returns nb_fire's output, or an error if the sector isn't + registered as a thalamic_sector channel. + """ + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + row = conn.execute( + """ + SELECT name FROM nb_attention_targets + WHERE name = ? AND channel_kind = 'thalamic_sector' + """, + (sector_name,), + ).fetchone() + if not row: + return { + "error": ( + f"sector {sector_name!r} not registered as a " + "thalamic_sector NB target" + ) + } + return tool_nb_fire( + target_name=sector_name, + attention_magnitude=attention_magnitude, + agent_id=agent_id, + mode="phasic", + ) + + +def tool_nb_signal_history( + limit: int = 20, + since: str | None = None, + agent_id: str | None = None, + target_id: int | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Paginated NB firing history, newest first. + + Phase 1 inspection tool. Filters: `since` (ISO timestamp lower + bound), `agent_id`, `target_id`. `limit` clamped to [1, 200]. + """ + 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: list[Any] = [] + if since: + clauses.append("f.fired_at >= ?") + params.append(since) + if agent_id: + clauses.append("f.agent_id = ?") + params.append(agent_id) + if target_id is not None: + clauses.append("f.target_id = ?") + params.append(int(target_id)) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + sql = f""" + SELECT f.id, f.fired_at, f.agent_id, f.target_id, + t.name AS target_name, t.channel_kind, + f.attention_magnitude, f.ach_delta_applied, + f.mode, f.context_hash, f.notes + FROM nb_firings f + JOIN nb_attention_targets t ON t.id = f.target_id + {where} + ORDER BY f.id DESC + LIMIT ? + """ + rows = conn.execute(sql, (*params, limit)).fetchall() + return {"ok": True, "history": _rows_to_list(rows)} + + +# --------------------------------------------------------------------- registration + +TOOLS: list[Tool] = [ + Tool( + name="nb_status", + description=( + "Nucleus basalis Phase 1 inspection. Returns the current " + "nb_state row (mode + ach_reservoir + last attended target) " + "plus a 24-hour firing summary and the last 5 firings. " + "Filter by agent_id." + ), + inputSchema={ + "type": "object", + "properties": {"agent_id": {"type": "string"}}, + }, + ), + Tool( + name="nb_register_target", + description=( + "Idempotent UPSERT of an NB attention target (channel " + "brainctl can direct cholinergic broadcast to). channel_kind " + "โˆˆ {thalamic_sector, agent_scope, intent_class, entity_type, " + "other}. default_ach_gain in [0, 1]." + ), + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "channel_kind": { + "type": "string", + "enum": sorted(VALID_CHANNEL_KINDS), + }, + "default_ach_gain": {"type": "number", "default": 0.10}, + "description": {"type": "string"}, + }, + "required": ["name", "channel_kind"], + }, + ), + Tool( + name="nb_fire", + description=( + "Record one NB firing (cholinergic broadcast) at a " + "registered target. ach_delta_applied is derived from " + "target.default_ach_gain ร— attention_magnitude. mode โˆˆ " + "{phasic, tonic_shift}. Phase 1: writes nb_firings + " + "updates nb_state; does NOT update bg_modulators.acetylcholine " + "(that's Phase 2)." + ), + inputSchema={ + "type": "object", + "properties": { + "target_name": {"type": "string"}, + "attention_magnitude": {"type": "number"}, + "agent_id": {"type": "string"}, + "source_event_id": {"type": "integer"}, + "notes": {"type": "string"}, + "mode": { + "type": "string", + "enum": sorted(VALID_FIRING_MODES), + "default": "phasic", + }, + }, + "required": ["target_name", "attention_magnitude"], + }, + ), + Tool( + name="nb_attend_sector", + description=( + "Convenience: fire NB at a thalamic sector by name. " + "Resolves the sector to a target_id and delegates to " + "nb_fire with mode='phasic'." + ), + inputSchema={ + "type": "object", + "properties": { + "sector_name": {"type": "string"}, + "attention_magnitude": {"type": "number"}, + "agent_id": {"type": "string"}, + }, + "required": ["sector_name", "attention_magnitude"], + }, + ), + Tool( + name="nb_signal_history", + description=( + "Paginated NB firing history, newest first. Filters: since " + "(ISO timestamp lower bound), agent_id, target_id. limit " + "clamped to [1, 200]." + ), + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "since": {"type": "string"}, + "agent_id": {"type": "string"}, + "target_id": {"type": "integer"}, + }, + }, + ), +] + + +_NB_TOOLS = { + "nb_status": tool_nb_status, + "nb_register_target": tool_nb_register_target, + "nb_fire": tool_nb_fire, + "nb_attend_sector": tool_nb_attend_sector, + "nb_signal_history": tool_nb_signal_history, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _NB_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + """Return tool descriptors and dispatch map for mcp_server integration.""" + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_nucleus_basalis.py b/tests/test_mcp_tools_nucleus_basalis.py new file mode 100644 index 0000000..bef5723 --- /dev/null +++ b/tests/test_mcp_tools_nucleus_basalis.py @@ -0,0 +1,193 @@ +"""Tests for mcp_tools_nucleus_basalis โ€” Phase 1. + +Covers: + - Migration applies + seeds populate as expected + - nb_status returns sensible defaults on a fresh DB + - nb_register_target is idempotent + - nb_fire round-trip writes a firing + updates nb_state + - nb_attend_sector convenience wrapper resolves the sector + - nb_signal_history filters and paginates correctly + - Validation: invalid channel_kind / firing mode rejected +""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_068 = REPO_ROOT / "db" / "migrations" / "068_nucleus_basalis.sql" + + +def _bootstrap_schema(conn: sqlite3.Connection) -> None: + """Minimal schema_version + bg_modulators so migration 068 applies.""" + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + description TEXT, + applied_at TEXT + ); + CREATE TABLE IF NOT EXISTS bg_modulators ( + id INTEGER PRIMARY KEY CHECK (id = 1), + tonic_da REAL NOT NULL DEFAULT 0.5, + lc_ne REAL NOT NULL DEFAULT 0.5, + serotonin REAL NOT NULL DEFAULT 0.5, + set_by TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) + ); + INSERT OR IGNORE INTO bg_modulators (id) VALUES (1); + """ + ) + + +def _apply_migration(db_path: Path) -> None: + conn = sqlite3.connect(str(db_path)) + try: + _bootstrap_schema(conn) + conn.executescript(MIGRATION_068.read_text()) + conn.commit() + finally: + conn.close() + + +@pytest.fixture +def nb_db(tmp_path, monkeypatch): + """Build a fresh DB with migration 068 applied and point the module + at it.""" + db = tmp_path / "brain.db" + _apply_migration(db) + # Late import so the monkeypatch sticks before DB_PATH module-level + # capture would matter. + from agentmemory import mcp_tools_nucleus_basalis as nb_mod + monkeypatch.setattr(nb_mod, "DB_PATH", db) + return nb_mod + + +def test_migration_applies_and_seeds(tmp_path): + db = tmp_path / "brain.db" + _apply_migration(db) + conn = sqlite3.connect(str(db)) + try: + # 4 seeded thalamic sectors + targets = conn.execute( + "SELECT name, channel_kind, default_ach_gain FROM nb_attention_targets ORDER BY id" + ).fetchall() + names = [t[0] for t in targets] + assert names == ["cognitive", "episodic", "semantic", "pii_sensitive"] + assert all(t[1] == "thalamic_sector" for t in targets) + # Single nb_state row + state = conn.execute("SELECT id, mode, ach_reservoir FROM nb_state").fetchone() + assert state == (1, "tonic_mid", 0.5) + # bg_modulators gained the acetylcholine column + ach = conn.execute("SELECT acetylcholine FROM bg_modulators WHERE id = 1").fetchone() + assert ach[0] == 0.5 + # schema_version row + sv = conn.execute("SELECT version FROM schema_version WHERE version=68").fetchone() + assert sv == (68,) + finally: + conn.close() + + +def test_nb_status_empty_db(nb_db): + out = nb_db.tool_nb_status() + assert out["ok"] is True + assert out["state"]["mode"] == "tonic_mid" + assert out["recent_24h"]["n"] == 0 + assert out["last_firings"] == [] + assert out["registered_targets"] == 4 # seeded sectors + + +def test_nb_register_target_idempotent(nb_db): + first = nb_db.tool_nb_register_target( + name="agents.research", channel_kind="agent_scope", + default_ach_gain=0.12, description="research-bot scope", + ) + second = nb_db.tool_nb_register_target( + name="agents.research", channel_kind="agent_scope", + default_ach_gain=0.12, + ) + assert first["ok"] is True + assert second["ok"] is True + assert first["target"]["id"] == second["target"]["id"] + # Second call should preserve description from the first via COALESCE. + assert second["target"]["description"] == "research-bot scope" + + +def test_nb_register_target_validates_channel_kind(nb_db): + out = nb_db.tool_nb_register_target( + name="bogus", channel_kind="not-a-real-kind" + ) + assert "error" in out + + +def test_nb_fire_round_trip_updates_state(nb_db): + out = nb_db.tool_nb_fire( + target_name="cognitive", + attention_magnitude=0.8, + agent_id="test-agent", + mode="phasic", + ) + assert out["ok"] is True + # default_ach_gain for cognitive = 0.15 โ†’ 0.15 ร— 0.8 = 0.12 + assert out["ach_delta_applied"] == pytest.approx(0.12, abs=1e-4) + # State should now reflect the firing + status = nb_db.tool_nb_status(agent_id="test-agent") + assert status["state"]["last_phasic_at"] is not None + assert status["state"]["last_attended_target_id"] is not None + assert status["recent_24h"]["n"] == 1 + + +def test_nb_fire_rejects_unregistered_target(nb_db): + out = nb_db.tool_nb_fire( + target_name="nope-not-real", attention_magnitude=0.5 + ) + assert "error" in out + + +def test_nb_fire_validates_mode(nb_db): + out = nb_db.tool_nb_fire( + target_name="cognitive", attention_magnitude=0.5, mode="invalid-mode" + ) + assert "error" in out + + +def test_nb_attend_sector_resolves_and_fires(nb_db): + out = nb_db.tool_nb_attend_sector( + sector_name="pii_sensitive", + attention_magnitude=1.0, + agent_id="pii-test", + ) + assert out["ok"] is True + # pii_sensitive default_ach_gain = 0.20 โ†’ 0.20 ร— 1.0 = 0.20 + assert out["ach_delta_applied"] == pytest.approx(0.20, abs=1e-4) + assert out["mode"] == "phasic" + + +def test_nb_attend_sector_rejects_non_thalamic(nb_db): + # Register an agent_scope target with a name that LOOKS sector-y. + nb_db.tool_nb_register_target( + name="not-a-sector", channel_kind="agent_scope", + default_ach_gain=0.1, + ) + out = nb_db.tool_nb_attend_sector( + sector_name="not-a-sector", attention_magnitude=0.5 + ) + assert "error" in out + + +def test_nb_signal_history_filters_and_limits(nb_db): + # Three firings, two for one agent + for i in range(3): + nb_db.tool_nb_fire( + target_name="cognitive", + attention_magnitude=0.5, + agent_id="agent-a" if i < 2 else "agent-b", + ) + all_history = nb_db.tool_nb_signal_history(limit=10) + assert len(all_history["history"]) == 3 + a_only = nb_db.tool_nb_signal_history(limit=10, agent_id="agent-a") + assert len(a_only["history"]) == 2 + limit_one = nb_db.tool_nb_signal_history(limit=1) + assert len(limit_one["history"]) == 1 From 2ef229846b997dce7b6da11385bcd8a027794a14 Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 05:02:18 -0400 Subject: [PATCH 2/2] fix(nb): backfill init_schema.sql for migrations 066 + 068 CI schema_parity test was failing because the NB branch's init_schema.sql lacked the 066 (retrieval_pathway_log) and 068 (NB) table definitions, plus the bg_modulators.acetylcholine column added by 068. Fresh installs (init_schema only) drifted from upgraded installs (init_schema + all migrations). Mirrors the same backfill pattern the parallel LC branch used for 066/067. Closing-paren placement of the acetylcholine column matches SQLite's ALTER TABLE ADD COLUMN canonical form so the stored sqlite_master.sql is byte-identical between fresh and upgraded paths. Verified locally: tests/test_schema_parity.py + tests/test_mcp_tools_nucleus_basalis.py all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agentmemory/db/init_schema.sql | 93 +++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/agentmemory/db/init_schema.sql b/src/agentmemory/db/init_schema.sql index 5d5dd3e..4d5cf41 100644 --- a/src/agentmemory/db/init_schema.sql +++ b/src/agentmemory/db/init_schema.sql @@ -2182,7 +2182,7 @@ CREATE TABLE IF NOT EXISTS bg_modulators ( serotonin REAL NOT NULL DEFAULT 0.5, set_by TEXT, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) -); +, acetylcholine REAL NOT NULL DEFAULT 0.5); INSERT OR IGNORE INTO bg_modulators (id) VALUES (1); -- Action-chunk catalog (Graybiel task-bracketing): durable start/stop @@ -2743,3 +2743,94 @@ SELECT 3, n, 'coarse:' || n, 'coarse-grained grid cell ' || n FROM ( UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION SELECT 15); + +-- ---- 066_retrieval_pathway_log.sql ---- +-- Sidecar observation log for memory_search dispatches. Records the +-- pathway fingerprint (mode, table_distribution, intent, profile, +-- candidate counts, latency) per retrieval. Independent of bg_td_events +-- by design. +CREATE TABLE IF NOT EXISTS retrieval_pathway_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + project TEXT, + query TEXT, + query_hash TEXT, + mode TEXT, + table_distribution TEXT, + tables_searched TEXT, + candidate_count_pre INTEGER, + candidate_count_post INTEGER, + rrf_contribution_ratio REAL, + intent_label TEXT, + active_profile TEXT, + suppressed_strategies TEXT, + embedding_model_version TEXT, + latency_ms INTEGER, + benchmark_mode INTEGER NOT NULL DEFAULT 0, + linked_td_event_id INTEGER +); +CREATE INDEX IF NOT EXISTS idx_rpl_recent + ON retrieval_pathway_log(fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_agent + ON retrieval_pathway_log(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_mode + ON retrieval_pathway_log(mode, fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_intent + ON retrieval_pathway_log(intent_label, fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_unlinked + ON retrieval_pathway_log(linked_td_event_id) WHERE linked_td_event_id IS NULL; + +-- ---- 068_nucleus_basalis.sql ---- +-- Nucleus basalis Phase 1: cholinergic attention broadcaster. Pairs with +-- locus coeruleus (migration 067, sibling PR). NB writes the +-- bg_modulators.acetylcholine column (added in-line above). +CREATE TABLE IF NOT EXISTS nb_attention_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + channel_kind TEXT NOT NULL CHECK(channel_kind IN ( + 'thalamic_sector', 'agent_scope', 'intent_class', 'entity_type', 'other' + )), + default_ach_gain REAL NOT NULL DEFAULT 0.10 CHECK(default_ach_gain BETWEEN 0.0 AND 1.0), + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + last_attended_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_nb_targets_kind ON nb_attention_targets(channel_kind); + +CREATE TABLE IF NOT EXISTS nb_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + target_id INTEGER NOT NULL, + target_source_event_id INTEGER, + attention_magnitude REAL NOT NULL, + ach_delta_applied REAL NOT NULL, + mode TEXT NOT NULL CHECK(mode IN ('phasic', 'tonic_shift')), + context_hash TEXT, + notes TEXT, + FOREIGN KEY (target_id) REFERENCES nb_attention_targets(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_nb_firings_recent ON nb_firings(fired_at); +CREATE INDEX IF NOT EXISTS idx_nb_firings_agent ON nb_firings(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_nb_firings_target ON nb_firings(target_id, fired_at); + +CREATE TABLE IF NOT EXISTS nb_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + mode TEXT NOT NULL DEFAULT 'tonic_mid' CHECK(mode IN ( + 'phasic_locked', 'tonic_high', 'tonic_mid', 'tonic_low' + )), + ach_reservoir REAL NOT NULL DEFAULT 0.5 CHECK(ach_reservoir BETWEEN 0.0 AND 1.0), + last_attended_target_id INTEGER, + last_phasic_at TEXT, + last_tonic_shift_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + FOREIGN KEY (last_attended_target_id) REFERENCES nb_attention_targets(id) +); +INSERT OR IGNORE INTO nb_state (id, mode, ach_reservoir) VALUES (1, 'tonic_mid', 0.5); + +INSERT OR IGNORE INTO nb_attention_targets (name, channel_kind, default_ach_gain, description) VALUES + ('cognitive', 'thalamic_sector', 0.15, 'planning, reasoning, deliberation'), + ('episodic', 'thalamic_sector', 0.10, 'event recall and timeline'), + ('semantic', 'thalamic_sector', 0.08, 'concept / fact retrieval'), + ('pii_sensitive', 'thalamic_sector', 0.20, 'PII / credential / wallet โ€” high attention so W(m) sees it');