From 078a0fca1e1f3f273f848ce44ee6485d353814c8 Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 00:49:04 -0400 Subject: [PATCH] ARAS Phase 1: ascending reticular activating system (overnight continuation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstem-level global arousal broadcast. Sits ABOVE LC + NB โ€” ARAS gates whether the rest of the neuromod surface is responsive at all. Anesthesia = ARAS shutdown; waking = ARAS ramp. The May 15 brain_region_coverage.md audit flagged it on the ๐ŸŸก partial list: "neuromodulation_state table holds org-level arousal/focus. Not wired into retrieval/admission." Phase 1 is inspection-only / additive. No behavior change to LC, NB, retrieval, or any existing subsystem. - Migration 069 โ€” 3 tables (aras_triggers, aras_state with sleep_wake_mode + arousal_level + tonic_drive + phasic_alertness, aras_transitions). Single-row aras_state seed. 5 seed trigger classes (novel_query, high_pe_event, consolidation_complete, idle_30min, explicit_user_alert). - agentmemory.mcp_tools_aras โ€” 5 MCP tools: - aras_status: current state + last 5 transitions + 24h summary - aras_transition: explicit mode change with soft-pull arousal update - aras_drive: phasic arousal pulse via registered trigger; auto-fires aras_transition on |delta|>=0.2 or phasic>=0.7 - aras_register_trigger: idempotent UPSERT on aras_triggers - aras_history: paginated transitions with mode + agent filters - 9 tests covering migration seeds, empty state, transitions, invalid mode rejection, drive deltas, auto-transition logic, unknown-trigger rejection, idempotent registration, history filters. - Design proposal at docs/proposals/aras.md. Phase 2 (separate PR) wires ARAS into the dispatch shadow consult to log would-be mode transitions from event patterns. Phase 3 lets ARAS modulate the downstream LC/NB response (low arousal damps LC phasic firings; high arousal amplifies NB attention bursts). Phase 4 enforces. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 26 ++ MCP_SERVER.md | 3 +- db/migrations/069_aras.sql | 78 +++++ docs/proposals/aras.md | 121 +++++++ docs/proposals/brain_region_coverage.md | 2 +- src/agentmemory/mcp_server.py | 2 + src/agentmemory/mcp_tools_aras.py | 411 ++++++++++++++++++++++++ tests/test_mcp_tools_aras.py | 134 ++++++++ 8 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 db/migrations/069_aras.sql create mode 100644 docs/proposals/aras.md create mode 100644 src/agentmemory/mcp_tools_aras.py create mode 100644 tests/test_mcp_tools_aras.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c17766f..1e95cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added โ€” ARAS (Ascending Reticular Activating System) Phase 1 + +Brainstem-level global arousal broadcast. Sits **above** LC + NB โ€” +ARAS gates whether the rest of the neuromod surface is responsive +at all. The May 15 brain_region_coverage.md audit flagged it on the +๐ŸŸก partial list: "`neuromodulation_state` table holds org-level +arousal/focus. Not wired into retrieval/admission." + +Phase 1 is inspection-only / additive. No behavior change to LC, NB, +retrieval, or any existing subsystem. + +- **Migration 069** โ€” 3 tables (`aras_triggers`, `aras_state`, + `aras_transitions`). Single-row `aras_state` seeded with + `sleep_wake_mode='awake_relaxed'`, `arousal_level=0.5`. 5 trigger + classes seeded. +- **`agentmemory.mcp_tools_aras`** โ€” 5 MCP tools (`aras_status`, + `aras_transition`, `aras_drive`, `aras_register_trigger`, + `aras_history`). +- **9 tests** covering migration seeds, empty state, transitions, + drive deltas, auto-transition on large delta, idempotent + registration, history filters. + +Phase 2 (separate PR) wires ARAS into the dispatch shadow consult to +log would-be mode transitions from event patterns. Phase 3 lets ARAS +modulate the downstream LC/NB response. 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..940f34a 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 | +| ARAS (Phase 1, global arousal) | `aras_status`, `aras_transition`, `aras_drive`, `aras_register_trigger`, `aras_history` | Brainstem-level sleep/wake gate sitting above LC + NB. `aras_state` tracks sleep_wake_mode + arousal_level + tonic/phasic; `aras_drive` fires phasic pulses with auto-transition on large delta. Phase 1 is inspection-only; Phase 3 modulates LC/NB response (see `docs/proposals/aras.md`) | ### Tier 3: Specialist (~150 tools) diff --git a/db/migrations/069_aras.sql b/db/migrations/069_aras.sql new file mode 100644 index 0000000..9778498 --- /dev/null +++ b/db/migrations/069_aras.sql @@ -0,0 +1,78 @@ +-- Migration 069: ascending reticular activating system โ€” Phase 1 schema +-- +-- The brainstem-level global arousal broadcast. Sits ABOVE LC + NB โ€” +-- ARAS gates whether the rest of the neuromod surface is responsive +-- at all (anesthesia is functionally an ARAS shutdown; waking is +-- ARAS ramping up). +-- +-- Phase 1 is inspection-only / additive: schema + read+CRUD tools. +-- Does not yet modulate LC/NB/retrieval. That's Phase 3. +-- +-- Four biological invariants encoded: +-- 1. Tonic vs phasic separation (sustained drive + brief pulses). +-- 2. Discrete sleep/wake regimes (not just a scalar). +-- 3. Recovery from suppression takes time (last_transition_at). +-- 4. Event classes drive specific arousal deltas (seed catalog). +-- +-- Rollback: +-- DROP TABLE IF EXISTS aras_transitions; +-- DROP TABLE IF EXISTS aras_state; +-- DROP TABLE IF EXISTS aras_triggers; +-- DELETE FROM schema_version WHERE version = 69; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS aras_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + trigger_kind TEXT NOT NULL CHECK(trigger_kind IN ( + 'novelty', 'threat', 'explicit_alert', 'consolidation_signal', 'idle_decay', 'other' + )), + default_arousal_delta REAL NOT NULL DEFAULT 0.05, + default_target_mode TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_aras_triggers_kind ON aras_triggers(trigger_kind); + +CREATE TABLE IF NOT EXISTS aras_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + sleep_wake_mode TEXT NOT NULL DEFAULT 'awake_relaxed' CHECK(sleep_wake_mode IN ( + 'nrem_sleep', 'rem_sleep', 'drowsy', 'awake_relaxed', 'awake_focused', 'hyperalert' + )), + arousal_level REAL NOT NULL DEFAULT 0.5 CHECK(arousal_level BETWEEN 0.0 AND 1.0), + tonic_drive REAL NOT NULL DEFAULT 0.5 CHECK(tonic_drive BETWEEN 0.0 AND 1.0), + phasic_alertness REAL NOT NULL DEFAULT 0.0 CHECK(phasic_alertness BETWEEN 0.0 AND 1.0), + last_transition_at TEXT, + last_drive_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO aras_state (id) VALUES (1); + +CREATE TABLE IF NOT EXISTS aras_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transitioned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + from_mode TEXT NOT NULL, + to_mode TEXT NOT NULL, + reason TEXT, + trigger_id INTEGER, + arousal_before REAL, + arousal_after REAL, + notes TEXT, + FOREIGN KEY (trigger_id) REFERENCES aras_triggers(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_aras_transitions_recent ON aras_transitions(transitioned_at); +CREATE INDEX IF NOT EXISTS idx_aras_transitions_agent ON aras_transitions(agent_id, transitioned_at); +CREATE INDEX IF NOT EXISTS idx_aras_transitions_to_mode ON aras_transitions(to_mode, transitioned_at); + +INSERT OR IGNORE INTO aras_triggers (name, trigger_kind, default_arousal_delta, default_target_mode, description) VALUES + ('novel_query', 'novelty', 0.05, 'awake_focused', 'previously-unseen query pattern โ€” gentle arousal nudge'), + ('high_pe_event', 'novelty', 0.10, 'awake_focused', 'cerebellum_predictions delta_forward above threshold'), + ('consolidation_complete', 'consolidation_signal', -0.10, 'drowsy', 'dream cycle finished โ€” permits arousal taper'), + ('idle_30min', 'idle_decay', -0.05, 'drowsy', 'no agent activity for 30 min'), + ('explicit_user_alert', 'explicit_alert', 0.30, 'hyperalert', 'user-flagged urgent input'); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (69, 'ARAS Phase 1: 3 tables (triggers, state, transitions) + 5 seed trigger classes', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/docs/proposals/aras.md b/docs/proposals/aras.md new file mode 100644 index 0000000..aeedc15 --- /dev/null +++ b/docs/proposals/aras.md @@ -0,0 +1,121 @@ +# Proposal: The Ascending Reticular Activating System (ARAS) for brainctl + +**Status:** Phase 1 design + implementation, branch `brain-regions-aras-phase-1`. +**Author:** Claude Opus 4.7 (overnight continuation after LC+NB Phase 1 ship) +**Date:** 2026-05-20 +**Scope:** New subsystem. Sits **above** LC + NB โ€” the brainstem-level global arousal broadcast that gates whether the rest of the neuromod surface is even responsive. Additive โ€” no breaking changes. Phase 1 is inspection-only. + +--- + +## TL;DR + +ARAS is the reticular formation broadcaster that decides whether the brain is *available for processing at all*. It precedes (and modulates the responsiveness of) LC, NB, and every other cortical / subcortical structure. Anesthesia is functionally an ARAS shutdown. Waking, drowsiness, hyperarousal, sleep โ€” all are ARAS states. + +brainctl's `neuromodulation_state` and `bg_modulators` carry per-dial knobs (`tonic_da`, `lc_ne`, `serotonin`, now `acetylcholine`) but have **no global arousal axis** that gates whether the system is in a processing-receptive mode at all. The May 15 coverage audit explicitly flagged this: *"`neuromodulation_state` table holds org-level arousal/focus. Not wired into retrieval/admission."* + +This proposal codifies ARAS as a thin first-class subsystem: + +- `aras_state` (single row) โ€” current sleep/wake mode + arousal level + phasic alertness +- `aras_transitions` โ€” log of mode changes with cause +- `aras_triggers` โ€” catalog of event classes that nudge arousal +- 5 MCP tools (`aras_status`, `aras_transition`, `aras_drive`, `aras_register_trigger`, `aras_history`) + +Phase 1 ships schema + tools + tests, **no behavior change** to retrieval, write gates, LC, NB, or anything else. Phase 2 wires ARAS into the dispatch shadow consult to log "would-be" mode transitions from event patterns. Phase 3 lets ARAS actually modulate the downstream neuromodulator response (e.g., low arousal damps LC phasic firings; high arousal amplifies NB attention bursts). Phase 4 enforces. + +## Architectural placement + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ARAS (this PR) โ”‚ global arousal / sleep-wake gate +โ”‚ sleep_wake_mode โ”‚ +โ”‚ arousal_level โ”‚ +โ”‚ phasic_alertness โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ (Phase 3: gates the response of) + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ LC (PR #121) โ”‚ โ”‚ NB (PR #122) โ”‚ + โ”‚ surprise โ†’ NE โ”‚ โ”‚ attention โ†’ ACh โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ bg_modulators โ”‚ + โ”‚ tonic_da, lc_ne, โ”‚ + โ”‚ serotonin, acetylcholine โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +In two-speed-motif terms (issue #116 ยง4): ARAS is the *very* slow, system-wide background driver; LC/NB sit one layer down as the medium-speed phasic broadcasters; the per-dial state in `bg_modulators` is the substrate. + +## Biological invariants encoded + +1. **Tonic vs phasic separation.** ARAS firing has two distinct modes โ€” sustained tonic drive that sets the global arousal baseline, and phasic pulses from external stimuli (novelty, threat, explicit alerts). brainctl's `aras_state` separates these as columns. + +2. **Discrete sleep/wake regimes.** Arousal is not just a scalar โ€” biology partitions it into qualitatively different regimes (NREM sleep, REM, drowsy, awake-relaxed, awake-focused, hyperalert). Each has different gating semantics. CHECK constraint on `aras_state.sleep_wake_mode`. + +3. **Recovery from suppression takes time.** Going from low arousal back to high arousal is not instantaneous (this is the post-anesthesia recovery curve). Tracked via `aras_state.last_transition_at` so callers can compute a recency-weighted responsiveness. + +4. **Specific event classes drive specific arousal deltas.** The seed `aras_triggers` catalog mirrors the LC `lc_triggers` and NB `nb_attention_targets` pattern. + +## Phase 1 schema (migration 069) + +```sql +CREATE TABLE aras_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + sleep_wake_mode TEXT NOT NULL DEFAULT 'awake_relaxed' CHECK(sleep_wake_mode IN ( + 'nrem_sleep', 'rem_sleep', 'drowsy', 'awake_relaxed', 'awake_focused', 'hyperalert' + )), + arousal_level REAL NOT NULL DEFAULT 0.5 CHECK(arousal_level BETWEEN 0.0 AND 1.0), + tonic_drive REAL NOT NULL DEFAULT 0.5 CHECK(tonic_drive BETWEEN 0.0 AND 1.0), + phasic_alertness REAL NOT NULL DEFAULT 0.0 CHECK(phasic_alertness BETWEEN 0.0 AND 1.0), + last_transition_at TEXT, + last_drive_at TEXT, + updated_at TEXT NOT NULL DEFAULT (...) +); + +CREATE TABLE aras_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transitioned_at TEXT NOT NULL DEFAULT (...), + agent_id TEXT, + from_mode TEXT NOT NULL, + to_mode TEXT NOT NULL, + reason TEXT, + trigger_id INTEGER, + arousal_before REAL, + arousal_after REAL, + notes TEXT +); + +CREATE TABLE aras_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + trigger_kind TEXT NOT NULL CHECK(trigger_kind IN ( + 'novelty', 'threat', 'explicit_alert', 'consolidation_signal', 'idle_decay', 'other' + )), + default_arousal_delta REAL NOT NULL DEFAULT 0.05, + default_target_mode TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT (...) +); +``` + +Seeded triggers: `novel_query`, `high_pe_event`, `consolidation_complete`, `idle_30min`, `explicit_user_alert`. + +## Phase 1 MCP tool surface + +- `aras_status` โ€” current state + last 5 transitions + recent trigger summary +- `aras_transition(to_mode, reason, agent_id)` โ€” explicit mode change; writes `aras_transitions` row +- `aras_drive(trigger_name, magnitude, agent_id)` โ€” phasic arousal pulse; updates `phasic_alertness` + may trigger automatic mode change above threshold +- `aras_register_trigger(name, trigger_kind, default_arousal_delta, ...)` โ€” idempotent UPSERT +- `aras_history(limit, since, agent_id, from_mode, to_mode)` โ€” paginated transitions + +Phase 1 does **not** modulate LC/NB/retrieval. That's Phase 3. + +## DoD + +- Migration 069 applies cleanly to /tmp copy + live (with backup) +- 5 seed triggers + 1 aras_state row after migration +- 5 MCP tools registered + discoverable +- โ‰ฅ6 tests passing +- Branch pushed, PR open, NOT merged to main diff --git a/docs/proposals/brain_region_coverage.md b/docs/proposals/brain_region_coverage.md index b734578..88020f9 100644 --- a/docs/proposals/brain_region_coverage.md +++ b/docs/proposals/brain_region_coverage.md @@ -19,7 +19,7 @@ Verdict per region: โœ… well-modelled ยท ๐ŸŸก partial / under-wired ยท ๐ŸŸฅ miss | Region | What it does (bio) | brainctl state | Gap | |---|---|---|---| -| **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. | +| **Brainstem / Ascending Reticular Activating System โ€” Phase 1** | global arousal broadcast via diffuse fan-out | `aras_state`, `aras_transitions`, `aras_triggers` tables + `aras_*` MCP tools. Phase 1 is manual/read+CRUD; Phase 2 wires shadow consult; Phase 3 modulates LC/NB response (see `docs/proposals/aras.md`). | | **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. | | **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." | diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..4fc4059 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -43,6 +43,7 @@ mcp_tools_allostatic, mcp_tools_amygdala, mcp_tools_analytics, + mcp_tools_aras, mcp_tools_dmem, mcp_tools_basal_ganglia, mcp_tools_belief_merge, @@ -86,6 +87,7 @@ mcp_tools_allostatic, mcp_tools_amygdala, mcp_tools_analytics, + mcp_tools_aras, mcp_tools_dmem, mcp_tools_basal_ganglia, mcp_tools_belief_merge, diff --git a/src/agentmemory/mcp_tools_aras.py b/src/agentmemory/mcp_tools_aras.py new file mode 100644 index 0000000..7c4ef13 --- /dev/null +++ b/src/agentmemory/mcp_tools_aras.py @@ -0,0 +1,411 @@ +"""brainctl MCP tools โ€” ARAS (ascending reticular activating system). + +Phase 1 of the ARAS subsystem per docs/proposals/aras.md. ARAS sits +above LC and NB as the global arousal / sleep-wake broadcast โ€” it +gates whether the rest of the neuromod surface is responsive at all. + +Phase 1 is inspection + idempotent writes only. No behavior change +to retrieval, LC, NB, or any existing subsystem. +""" +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_MODES = { + "nrem_sleep", "rem_sleep", "drowsy", + "awake_relaxed", "awake_focused", "hyperalert", +} +VALID_TRIGGER_KINDS = { + "novelty", "threat", "explicit_alert", + "consolidation_signal", "idle_decay", "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 ("aras_state", "aras_transitions", "aras_triggers") + if not _table_exists(conn, t) + ] + if missing: + return ( + "ARAS schema missing: " + ", ".join(missing) + + ". Run `brainctl migrate` (migration 069) and retry." + ) + return None + + +def _clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float: + return max(lo, min(hi, value)) + + +# --------------------------------------------------------------------- tools + + +def tool_aras_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 aras_state WHERE id = 1" + ).fetchone() + recent_transitions = _rows(conn.execute( + """ + SELECT id, transitioned_at, agent_id, from_mode, to_mode, + reason, arousal_before, arousal_after + FROM aras_transitions + WHERE (? IS NULL OR agent_id = ?) + ORDER BY id DESC LIMIT 5 + """, + (agent_id, agent_id), + ).fetchall()) + trigger_count = conn.execute( + "SELECT COUNT(*) FROM aras_triggers" + ).fetchone()[0] + last_24h = conn.execute( + """ + SELECT COUNT(*) AS n, + SUM(CASE WHEN to_mode IN ('hyperalert','awake_focused') THEN 1 ELSE 0 END) AS heightened, + SUM(CASE WHEN to_mode IN ('drowsy','nrem_sleep','rem_sleep') THEN 1 ELSE 0 END) AS lowered + FROM aras_transitions + WHERE transitioned_at >= datetime('now', '-24 hours') + AND (? IS NULL OR agent_id = ?) + """, + (agent_id, agent_id), + ).fetchone() + return { + "ok": True, + "state": dict(state) if state else None, + "last_5_transitions": recent_transitions, + "registered_triggers": trigger_count, + "transitions_24h": dict(last_24h) if last_24h else {}, + } + + +def tool_aras_register_trigger( + name: str, trigger_kind: str, + default_arousal_delta: float = 0.05, + default_target_mode: str | None = None, + description: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + if trigger_kind not in VALID_TRIGGER_KINDS: + return {"error": f"invalid trigger_kind {trigger_kind!r}; expected one of {sorted(VALID_TRIGGER_KINDS)}"} + if not -1.0 <= default_arousal_delta <= 1.0: + return {"error": "default_arousal_delta must be in [-1, 1]"} + if default_target_mode is not None and default_target_mode not in VALID_MODES: + return {"error": f"invalid default_target_mode {default_target_mode!r}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + conn.execute( + """ + INSERT INTO aras_triggers (name, trigger_kind, default_arousal_delta, default_target_mode, description) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + trigger_kind = excluded.trigger_kind, + default_arousal_delta = excluded.default_arousal_delta, + default_target_mode = excluded.default_target_mode, + description = COALESCE(excluded.description, aras_triggers.description) + """, + (name, trigger_kind, float(default_arousal_delta), default_target_mode, description), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM aras_triggers WHERE name = ?", (name,) + ).fetchone() + return {"ok": True, "trigger": dict(row) if row else None} + + +def tool_aras_transition( + to_mode: str, + reason: str | None = None, + agent_id: str | None = None, + notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + if to_mode not in VALID_MODES: + return {"error": f"invalid to_mode {to_mode!r}; expected one of {sorted(VALID_MODES)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM aras_state WHERE id = 1").fetchone() + if not state: + return {"error": "aras_state seed row missing"} + from_mode = state["sleep_wake_mode"] + arousal_before = float(state["arousal_level"]) + # Derive next arousal as a soft pull toward a mode-typical level. + mode_target = { + "nrem_sleep": 0.05, "rem_sleep": 0.20, "drowsy": 0.30, + "awake_relaxed": 0.50, "awake_focused": 0.75, "hyperalert": 0.95, + }[to_mode] + arousal_after = _clamp(0.5 * arousal_before + 0.5 * mode_target) + cur = conn.execute( + """ + INSERT INTO aras_transitions + (agent_id, from_mode, to_mode, reason, arousal_before, arousal_after, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (agent_id, from_mode, to_mode, reason, arousal_before, arousal_after, notes), + ) + transition_id = cur.lastrowid + conn.execute( + """ + UPDATE aras_state SET + sleep_wake_mode = ?, + arousal_level = ?, + last_transition_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (to_mode, arousal_after), + ) + conn.commit() + return { + "ok": True, "transition_id": transition_id, + "from_mode": from_mode, "to_mode": to_mode, + "arousal_before": arousal_before, "arousal_after": arousal_after, + } + + +def tool_aras_drive( + trigger_name: str, + magnitude: float = 1.0, + agent_id: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Apply a phasic arousal pulse via a registered trigger. + + `magnitude` โˆˆ [0, 1] scales the trigger's default_arousal_delta. + If the resulting arousal crosses a mode boundary, automatically + fires aras_transition to the trigger's default_target_mode. + """ + if not 0.0 <= magnitude <= 1.0: + return {"error": "magnitude must be in [0, 1]"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + trig = conn.execute( + "SELECT id, default_arousal_delta, default_target_mode FROM aras_triggers WHERE name = ?", + (trigger_name,), + ).fetchone() + if not trig: + return {"error": f"trigger {trigger_name!r} not registered"} + state = conn.execute("SELECT * FROM aras_state WHERE id = 1").fetchone() + if not state: + return {"error": "aras_state seed row missing"} + delta = float(trig["default_arousal_delta"]) * float(magnitude) + new_phasic = _clamp(float(state["phasic_alertness"]) + abs(delta)) + new_arousal = _clamp(float(state["arousal_level"]) + delta) + conn.execute( + """ + UPDATE aras_state SET + arousal_level = ?, + phasic_alertness = ?, + last_drive_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (new_arousal, new_phasic), + ) + transition_id = None + if trig["default_target_mode"] and trig["default_target_mode"] != state["sleep_wake_mode"]: + # Phasic alertness above 0.7 + a target_mode different from current โ†’ auto-transition + if new_phasic >= 0.7 or abs(delta) >= 0.2: + cur = conn.execute( + """ + INSERT INTO aras_transitions + (agent_id, from_mode, to_mode, reason, trigger_id, arousal_before, arousal_after) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (agent_id, state["sleep_wake_mode"], trig["default_target_mode"], + f"auto-fire from drive '{trigger_name}'", trig["id"], + float(state["arousal_level"]), new_arousal), + ) + transition_id = cur.lastrowid + conn.execute( + "UPDATE aras_state SET sleep_wake_mode = ?, last_transition_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') WHERE id = 1", + (trig["default_target_mode"],), + ) + conn.commit() + return { + "ok": True, "trigger_name": trigger_name, + "arousal_delta_applied": delta, + "new_arousal_level": new_arousal, + "new_phasic_alertness": new_phasic, + "auto_transition_id": transition_id, + } + + +def tool_aras_history( + limit: int = 20, since: str | None = None, + agent_id: str | None = None, + from_mode: str | None = None, to_mode: 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("transitioned_at >= ?"); params.append(since) + if agent_id: + clauses.append("agent_id = ?"); params.append(agent_id) + if from_mode: + clauses.append("from_mode = ?"); params.append(from_mode) + if to_mode: + clauses.append("to_mode = ?"); params.append(to_mode) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + rows = conn.execute( + f""" + SELECT id, transitioned_at, agent_id, from_mode, to_mode, + reason, trigger_id, arousal_before, arousal_after, notes + FROM aras_transitions + {where} + ORDER BY id DESC LIMIT ? + """, + (*params, limit), + ).fetchall() + return {"ok": True, "history": _rows(rows)} + + +# --------------------------------------------------------------------- registration + +TOOLS: list[Tool] = [ + Tool( + name="aras_status", + description=( + "ARAS Phase 1 inspection. Returns current aras_state (sleep_wake_mode, " + "arousal_level, tonic_drive, phasic_alertness) plus last 5 transitions " + "and a 24h transition summary." + ), + inputSchema={"type": "object", "properties": {"agent_id": {"type": "string"}}}, + ), + Tool( + name="aras_register_trigger", + description=( + "Idempotent UPSERT on aras_triggers. trigger_kind โˆˆ {novelty, threat, " + "explicit_alert, consolidation_signal, idle_decay, other}. " + "default_arousal_delta in [-1, 1]; default_target_mode optional." + ), + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "trigger_kind": {"type": "string", "enum": sorted(VALID_TRIGGER_KINDS)}, + "default_arousal_delta": {"type": "number", "default": 0.05}, + "default_target_mode": {"type": "string", "enum": sorted(VALID_MODES)}, + "description": {"type": "string"}, + }, + "required": ["name", "trigger_kind"], + }, + ), + Tool( + name="aras_transition", + description=( + "Explicit mode change. Writes aras_transitions row + updates " + "aras_state.sleep_wake_mode + arousal_level (soft-pull to mode-typical). " + "to_mode โˆˆ {nrem_sleep, rem_sleep, drowsy, awake_relaxed, awake_focused, " + "hyperalert}." + ), + inputSchema={ + "type": "object", + "properties": { + "to_mode": {"type": "string", "enum": sorted(VALID_MODES)}, + "reason": {"type": "string"}, + "agent_id": {"type": "string"}, + "notes": {"type": "string"}, + }, + "required": ["to_mode"], + }, + ), + Tool( + name="aras_drive", + description=( + "Phasic arousal pulse via a registered trigger. magnitude โˆˆ [0, 1] scales " + "trigger's default_arousal_delta. Auto-fires aras_transition to the trigger's " + "default_target_mode when phasic_alertness โ‰ฅ 0.7 or |delta| โ‰ฅ 0.2." + ), + inputSchema={ + "type": "object", + "properties": { + "trigger_name": {"type": "string"}, + "magnitude": {"type": "number", "default": 1.0}, + "agent_id": {"type": "string"}, + }, + "required": ["trigger_name"], + }, + ), + Tool( + name="aras_history", + description=( + "Paginated ARAS transition history with filters: since (ISO timestamp), " + "agent_id, from_mode, to_mode. limit clamped to [1, 200]." + ), + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "since": {"type": "string"}, + "agent_id": {"type": "string"}, + "from_mode": {"type": "string", "enum": sorted(VALID_MODES)}, + "to_mode": {"type": "string", "enum": sorted(VALID_MODES)}, + }, + }, + ), +] + + +_ARAS_TOOLS = { + "aras_status": tool_aras_status, + "aras_register_trigger": tool_aras_register_trigger, + "aras_transition": tool_aras_transition, + "aras_drive": tool_aras_drive, + "aras_history": tool_aras_history, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _ARAS_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_aras.py b/tests/test_mcp_tools_aras.py new file mode 100644 index 0000000..e7bf02c --- /dev/null +++ b/tests/test_mcp_tools_aras.py @@ -0,0 +1,134 @@ +"""Tests for mcp_tools_aras โ€” ARAS Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_069 = REPO_ROOT / "db" / "migrations" / "069_aras.sql" + + +def _bootstrap(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + description TEXT, + applied_at TEXT + ); + """ + ) + + +def _apply(db_path: Path) -> None: + conn = sqlite3.connect(str(db_path)) + try: + _bootstrap(conn) + conn.executescript(MIGRATION_069.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_aras 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 aras_triggers ORDER BY id").fetchall()] + assert names == ["novel_query", "high_pe_event", "consolidation_complete", "idle_30min", "explicit_user_alert"] + state = conn.execute("SELECT sleep_wake_mode, arousal_level FROM aras_state").fetchone() + assert state == ("awake_relaxed", 0.5) + sv = conn.execute("SELECT version FROM schema_version WHERE version=69").fetchone() + assert sv == (69,) + finally: + conn.close() + + +def test_status_empty(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_aras_status() + assert out["ok"] is True + assert out["state"]["sleep_wake_mode"] == "awake_relaxed" + assert out["last_5_transitions"] == [] + assert out["registered_triggers"] == 5 + + +def test_transition_writes_and_updates_state(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_aras_transition(to_mode="awake_focused", reason="test", agent_id="a1") + assert out["ok"] is True + assert out["from_mode"] == "awake_relaxed" + assert out["to_mode"] == "awake_focused" + # soft-pull from 0.5 toward 0.75 = 0.625 + assert abs(out["arousal_after"] - 0.625) < 0.01 + # state updated + status = mod.tool_aras_status() + assert status["state"]["sleep_wake_mode"] == "awake_focused" + + +def test_transition_rejects_invalid_mode(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_aras_transition(to_mode="invalid_mode") + assert "error" in out + + +def test_drive_applies_delta(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_aras_drive(trigger_name="novel_query", magnitude=1.0) + assert out["ok"] is True + # novel_query default_arousal_delta = 0.05 + assert abs(out["arousal_delta_applied"] - 0.05) < 1e-6 + assert out["new_arousal_level"] > 0.5 + + +def test_drive_auto_transition_on_large_delta(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # explicit_user_alert delta = 0.30 โ†’ above 0.2 threshold โ†’ auto-transition + out = mod.tool_aras_drive(trigger_name="explicit_user_alert", magnitude=1.0, agent_id="a1") + assert out["ok"] is True + assert out["auto_transition_id"] is not None + status = mod.tool_aras_status(agent_id="a1") + assert status["state"]["sleep_wake_mode"] == "hyperalert" + + +def test_drive_rejects_unknown_trigger(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_aras_drive(trigger_name="nope", magnitude=0.5) + assert "error" in out + + +def test_register_trigger_idempotent(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + first = mod.tool_aras_register_trigger( + name="custom_alert", trigger_kind="explicit_alert", + default_arousal_delta=0.15, default_target_mode="awake_focused", + ) + second = mod.tool_aras_register_trigger( + name="custom_alert", trigger_kind="explicit_alert", + default_arousal_delta=0.20, default_target_mode="hyperalert", + ) + assert first["ok"] is True and second["ok"] is True + assert first["trigger"]["id"] == second["trigger"]["id"] + assert second["trigger"]["default_arousal_delta"] == 0.20 + + +def test_history_filters(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_aras_transition(to_mode="awake_focused", agent_id="a1") + mod.tool_aras_transition(to_mode="drowsy", agent_id="a2") + mod.tool_aras_transition(to_mode="hyperalert", agent_id="a1") + all_h = mod.tool_aras_history(limit=10) + assert len(all_h["history"]) == 3 + a1 = mod.tool_aras_history(limit=10, agent_id="a1") + assert len(a1["history"]) == 2 + hyper = mod.tool_aras_history(limit=10, to_mode="hyperalert") + assert len(hyper["history"]) == 1