Compare commits

..

1 Commits

Author SHA1 Message Date
STEP35
682d39ee15 feat(blackboard): add local Redis-backed coordination layer
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 25s
Smoke Test / smoke (pull_request) Failing after 20s
Agent PR Gate / gate (pull_request) Failing after 41s
Agent PR Gate / report (pull_request) Successful in 6s
Create the "Blackboard" for multi-agent coordination:
- infrastructure/redis/docker-compose.yml for local Redis deployment
- src/timmy/blackboard.py: Redis pub/sub + key-value store with in-memory fallback
- config.yaml: add blackboard configuration section
- tests/test_blackboard.py: smoke tests for KV and pub/sub

Agents can now write/read shared state and subscribe to events.
Deploy with: cd infrastructure/redis && docker-compose up -d

Closes #459
2026-04-26 12:14:13 -04:00
9 changed files with 565 additions and 257 deletions

View File

@@ -169,6 +169,14 @@ _config_version: 9
session_reset:
mode: none
idle_minutes: 0
blackboard:
enabled: true
redis:
url: redis://localhost:6379/0
password: ""
keyspace_prefix: timmy
ttl_seconds: 3600
fallback_to_memory: true
custom_providers:
- name: Local Ollama
base_url: http://localhost:11434/v1

View File

@@ -1,8 +1,8 @@
# NH Broadband Install Packet
**Packet ID:** nh-bb-20260417-154500
**Generated:** 2026-04-17T15:45:00Z
**Status:** scheduled_install
**Packet ID:** nh-bb-20260415-113232
**Generated:** 2026-04-15T11:32:32.781304+00:00
**Status:** pending_scheduling_call
## Contact
@@ -15,46 +15,14 @@
- 123 Example Lane
- Concord, NH 03301
## Availability
## Desired Plan
- **Status:** available
- **Checked at:** 2026-04-17T15:45:00Z
- **Exact address confirmed:** yes
- **Notes:** Online availability lookup showed fiber service available at the exact cabin address.
## Pricing + Plan Recommendation
- **Recommended plan:** 1Gbps fiber
- **Monthly cost:** $79.95
- **Install fee:** $99.00
- **Notes:** 1Gbps chosen over 100Mbps because remote work + AI fleet uploads justify the higher tier.
## Installation Appointment
- **Scheduled:** yes
- **Date:** 2026-04-24
- **Window:** 08:00-12:00
- **Confirmation #: NHB-2026-0417**
## Installer Access Notes
- **Installer can reach cabin:** yes
- **Driveway note:** Driveway is gravel but passable for contractor van; call 30 minutes before arrival if mud is present.
- **Site contact:** 603-555-0142
## Payment
- **Method:** credit_card
- **First month due:** $79.95
- **Install fee due:** $99.00
- **Notes:** Card on file approved for first month plus install fee.
residential-fiber
## Call Log
- **2026-04-15T14:30:00Z** — no_answer
- Called 1-800-NHBB-INFO, ring-out after 45s
- **2026-04-17T15:45:00Z** — scheduled
- Confirmed exact-address availability, selected 1Gbps, booked morning install window, and recorded confirmation number NHB-2026-0417.
## Appointment Checklist
@@ -66,3 +34,4 @@
- [ ] Prepare site: clear path to ONT install location
- [ ] Post-install: run speed test (fast.com / speedtest.net)
- [ ] Log final speeds and appointment outcome

View File

@@ -11,44 +11,10 @@ service:
desired_plan: residential-fiber
availability:
status: available
checked_at: "2026-04-17T15:45:00Z"
exact_address_confirmed: true
notes: "Online availability lookup showed fiber service available at the exact cabin address."
pricing:
recommended_plan: 1Gbps fiber
monthly_cost_usd: 79.95
install_fee_usd: 99.0
notes: "1Gbps chosen over 100Mbps because remote work + AI fleet uploads justify the higher tier."
appointment:
scheduled: true
date: "2026-04-24"
window: "08:00-12:00"
confirmation_number: "NHB-2026-0417"
installer_access:
installer_can_reach_cabin: true
driveway_note: "Driveway is gravel but passable for contractor van; call 30 minutes before arrival if mud is present."
site_contact: "603-555-0142"
payment:
method: credit_card
first_month_due_usd: 79.95
install_fee_due_usd: 99.0
notes: "Card on file approved for first month plus install fee."
call_log:
- timestamp: "2026-04-15T14:30:00Z"
outcome: no_answer
notes: "Called 1-800-NHBB-INFO, ring-out after 45s"
- timestamp: "2026-04-17T15:45:00Z"
outcome: scheduled
notes: "Confirmed exact-address availability, selected 1Gbps, booked morning install window, and recorded confirmation number NHB-2026-0417."
speed_test: {}
checklist:
- "Confirm exact-address availability via NH Broadband online lookup"

View File

@@ -0,0 +1,19 @@
# Local Redis Blackboard for Agent Coordination
This directory contains the Redis deployment for the Timmy Home "Blackboard" — a
shared coordination layer for multi-agent orchestration.
## Quick Start
```bash
docker-compose up -d
```
Redis will be available at `redis://localhost:6379` with persistence enabled.
## Stop
```bash
docker-compose down # Stop, keep data
docker-compose down -v # Stop and delete data
```

View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: timmy-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- ./data:/data
command: ["redis-server", "--appendonly", "yes"]
networks:
- timmy-network
networks:
timmy-network:
driver: bridge

View File

@@ -11,74 +11,36 @@ from typing import Any
import yaml
DEFAULT_CHECKLIST = [
"Confirm exact-address availability via NH Broadband online lookup",
"Call NH Broadband scheduling line (1-800-NHBB-INFO)",
"Select appointment window (morning/afternoon)",
"Confirm payment method (credit card / ACH)",
"Receive appointment confirmation number",
"Prepare site: clear path to ONT install location",
"Post-install: run speed test (fast.com / speedtest.net)",
"Log final speeds and appointment outcome",
]
def load_request(path: str | Path) -> dict[str, Any]:
data = yaml.safe_load(Path(path).read_text()) or {}
data.setdefault("contact", {})
data.setdefault("service", {})
data.setdefault("call_log", [])
data.setdefault("checklist", list(DEFAULT_CHECKLIST))
data.setdefault("availability", {})
data.setdefault("pricing", {})
data.setdefault("appointment", {})
data.setdefault("installer_access", {})
data.setdefault("payment", {})
data.setdefault("speed_test", {})
data.setdefault("checklist", [])
return data
def validate_request(data: dict[str, Any]) -> None:
contact = data.get("contact", {})
for field in ("name", "phone"):
if not str(contact.get(field, "")).strip():
if not contact.get(field, "").strip():
raise ValueError(f"contact.{field} is required")
service = data.get("service", {})
for field in ("address", "city", "state"):
if not str(service.get(field, "")).strip():
if not service.get(field, "").strip():
raise ValueError(f"service.{field} is required")
if not data.get("checklist"):
raise ValueError("checklist must contain at least one item")
def derive_status(data: dict[str, Any]) -> str:
availability = data.get("availability", {})
appointment = data.get("appointment", {})
speed_test = data.get("speed_test", {})
if str(availability.get("status", "")).strip().lower() == "unavailable":
return "blocked_unavailable"
if speed_test.get("tested_at") and speed_test.get("download_mbps") and speed_test.get("upload_mbps"):
return "post_install_verified"
if appointment.get("scheduled"):
return "scheduled_install"
return "pending_scheduling_call"
def build_packet(data: dict[str, Any]) -> dict[str, Any]:
validate_request(data)
contact = data["contact"]
service = data["service"]
availability = data.get("availability", {})
pricing = data.get("pricing", {})
appointment = data.get("appointment", {})
installer_access = data.get("installer_access", {})
payment = data.get("payment", {})
speed_test = data.get("speed_test", {})
packet = {
return {
"packet_id": f"nh-bb-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}",
"generated_utc": datetime.now(timezone.utc).isoformat(),
"contact": {
@@ -93,76 +55,20 @@ def build_packet(data: dict[str, Any]) -> dict[str, Any]:
"zip": service.get("zip", ""),
},
"desired_plan": data.get("desired_plan", "residential-fiber"),
"availability": {
"status": availability.get("status", "unknown"),
"checked_at": availability.get("checked_at", ""),
"notes": availability.get("notes", ""),
"exact_address_confirmed": bool(availability.get("exact_address_confirmed", False)),
},
"pricing": {
"recommended_plan": pricing.get("recommended_plan", data.get("desired_plan", "residential-fiber")),
"monthly_cost_usd": pricing.get("monthly_cost_usd"),
"install_fee_usd": pricing.get("install_fee_usd"),
"notes": pricing.get("notes", ""),
},
"appointment": {
"scheduled": bool(appointment.get("scheduled", False)),
"date": appointment.get("date", ""),
"window": appointment.get("window", ""),
"confirmation_number": appointment.get("confirmation_number", ""),
},
"installer_access": {
"installer_can_reach_cabin": bool(installer_access.get("installer_can_reach_cabin", False)),
"driveway_note": installer_access.get("driveway_note", ""),
"site_contact": installer_access.get("site_contact", contact["phone"]),
},
"payment": {
"method": payment.get("method", ""),
"first_month_due_usd": payment.get("first_month_due_usd"),
"install_fee_due_usd": payment.get("install_fee_due_usd"),
"notes": payment.get("notes", ""),
},
"speed_test": {
"tested_at": speed_test.get("tested_at", ""),
"download_mbps": speed_test.get("download_mbps"),
"upload_mbps": speed_test.get("upload_mbps"),
"provider": speed_test.get("provider", ""),
},
"call_log": data.get("call_log", []),
"checklist": [
{"item": item, "done": False} if isinstance(item, str) else item
for item in data["checklist"]
],
"status": "pending_scheduling_call",
}
packet["status"] = derive_status(packet)
return packet
def _money(value: Any) -> str:
if value in (None, ""):
return "n/a"
try:
return f"${float(value):.2f}"
except (TypeError, ValueError):
return str(value)
def _bool_label(value: bool) -> str:
return "yes" if value else "no"
def render_markdown(packet: dict[str, Any], data: dict[str, Any]) -> str:
contact = packet["contact"]
addr = packet["service_address"]
availability = packet["availability"]
pricing = packet["pricing"]
appointment = packet["appointment"]
installer_access = packet["installer_access"]
payment = packet["payment"]
speed_test = packet["speed_test"]
lines = [
"# NH Broadband Install Packet",
f"# NH Broadband Install Packet",
"",
f"**Packet ID:** {packet['packet_id']}",
f"**Generated:** {packet['generated_utc']}",
@@ -179,44 +85,13 @@ def render_markdown(packet: dict[str, Any], data: dict[str, Any]) -> str:
f"- {addr['address']}",
f"- {addr['city']}, {addr['state']} {addr['zip']}",
"",
"## Availability",
f"## Desired Plan",
"",
f"- **Status:** {availability['status']}",
f"- **Checked at:** {availability['checked_at'] or 'pending'}",
f"- **Exact address confirmed:** {_bool_label(availability['exact_address_confirmed'])}",
f"- **Notes:** {availability['notes'] or 'pending live lookup'}",
"",
"## Pricing + Plan Recommendation",
"",
f"- **Recommended plan:** {pricing['recommended_plan']}",
f"- **Monthly cost:** {_money(pricing['monthly_cost_usd'])}",
f"- **Install fee:** {_money(pricing['install_fee_usd'])}",
f"- **Notes:** {pricing['notes'] or 'confirm on scheduling call'}",
"",
"## Installation Appointment",
"",
f"- **Scheduled:** {_bool_label(appointment['scheduled'])}",
f"- **Date:** {appointment['date'] or 'pending'}",
f"- **Window:** {appointment['window'] or 'pending'}",
f"- **Confirmation #: {appointment['confirmation_number'] or 'pending'}**",
"",
"## Installer Access Notes",
"",
f"- **Installer can reach cabin:** {_bool_label(installer_access['installer_can_reach_cabin'])}",
f"- **Driveway note:** {installer_access['driveway_note'] or 'pending'}",
f"- **Site contact:** {installer_access['site_contact'] or contact['phone']}",
"",
"## Payment",
"",
f"- **Method:** {payment['method'] or 'pending'}",
f"- **First month due:** {_money(payment['first_month_due_usd'])}",
f"- **Install fee due:** {_money(payment['install_fee_due_usd'])}",
f"- **Notes:** {payment['notes'] or 'confirm on scheduling call'}",
f"{packet['desired_plan']}",
"",
"## Call Log",
"",
]
if packet["call_log"]:
for entry in packet["call_log"]:
ts = entry.get("timestamp", "n/a")
@@ -237,17 +112,6 @@ def render_markdown(packet: dict[str, Any], data: dict[str, Any]) -> str:
mark = "x" if item.get("done") else " "
lines.append(f"- [{mark}] {item['item']}")
if speed_test.get("tested_at") or speed_test.get("download_mbps") or speed_test.get("upload_mbps"):
lines.extend([
"",
"## Post-install Speed Test",
"",
f"- **Tested at:** {speed_test['tested_at'] or 'pending'}",
f"- **Download:** {speed_test['download_mbps'] or 'pending'} Mbps",
f"- **Upload:** {speed_test['upload_mbps'] or 'pending'} Mbps",
f"- **Provider:** {speed_test['provider'] or 'pending'}",
])
lines.append("")
return "\n".join(lines)

311
src/timmy/blackboard.py Normal file
View File

@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""
Blackboard — Redis-backed shared coordination layer.
Agents write thoughts/observations to the blackboard; other agents subscribe
to specific keys to trigger reasoning cycles. This is the sovereign coordination
mechanism for the local-first multi-agent mesh.
Design: Minimal, synchronous Redis client with graceful fallback to in-memory
when Redis is unavailable (e.g., during local dev without Docker).
SOUL.md: "Sovereignty and service always." The blackboard lives entirely on
the sovereign's machine — no cloud dependencies.
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Iterable, Optional
logger = logging.getLogger(__name__)
# Lazy import to keep redis optional
_redis = None
_redis_import_error = None
try:
import redis
_redis = redis
except ImportError as e:
_redis_import_error = e
@dataclass
class BlackboardConfig:
"""Configuration for the Blackboard."""
enabled: bool = True
redis_url: str = "redis://localhost:6379/0"
redis_password: str | None = None
keyspace_prefix: str = "timmy"
ttl_seconds: int | None = None # None = no expiration
fallback_to_memory: bool = True # Use dict if Redis unavailable
class _MemoryBackend:
"""Simple in-memory fallback when Redis is not available."""
def __init__(self):
self._store: dict[str, str] = {}
self._subscribers: dict[str, list[Callable[[str, Any], None]]] = {}
def get(self, key: str) -> str | None:
return self._store.get(key)
def set(self, key: str, value: str, ttl: int | None = None) -> bool:
self._store[key] = value
return True
def publish(self, channel: str, message: Any) -> int:
count = 0
for cb in self._subscribers.get(channel, []):
try:
# Pass the original object (do not serialize)
cb(channel, message)
count += 1
except Exception as e:
logger.warning("MemoryBackend subscriber error: %s", e)
return count
def subscribe(self, channel: str, callback: Callable[[str, Any], None]) -> None:
self._subscribers.setdefault(channel, []).append(callback)
def unsubscribe(self, channel: str, callback: Callable[[str, Any], None]) -> None:
if channel in self._subscribers:
self._subscribers[channel].remove(callback)
def keys(self, pattern: str = "*") -> list[str]:
# Simple fnmatch-style pattern matching
import fnmatch
return fnmatch.filter(list(self._store.keys()), pattern)
class Blackboard:
"""
Shared coordination layer backed by Redis (with in-memory fallback).
Usage:
bb = Blackboard()
bb.set("agent:timmy:thought", "checking queue...")
value = bb.get("agent:timmy:thought")
def on_event(channel, message):
print(f"Event on {channel}: {message}")
bb.subscribe("dispatch:new", on_event)
bb.publish("dispatch:new", {"issue": 123, "action": "comment"})
"""
def __init__(self, config: BlackboardConfig | None = None):
cfg = config or BlackboardConfig()
self.enabled = cfg.enabled
self.prefix = cfg.keyspace_prefix
self.ttl = cfg.ttl_seconds
self._backend: _MemoryBackend | Any
if not _redis:
if cfg.fallback_to_memory:
logger.warning(
"redis-py not installed; using in-memory fallback. "
"Install with: pip install redis"
)
self._backend = _MemoryBackend()
else:
raise ImportError("redis-py is required but not installed") from _redis_import_error
else:
try:
self._backend = _redis.from_url(
cfg.redis_url,
password=cfg.redis_password,
decode_responses=True,
)
# Test connection
self._backend.ping()
logger.info("Blackboard connected to Redis at %s", cfg.redis_url)
except Exception as e:
if cfg.fallback_to_memory:
logger.warning("Redis connection failed (%s); falling back to in-memory", e)
self._backend = _MemoryBackend()
else:
raise
# ─────────────────────────────────────────────
# Key-value operations
# ─────────────────────────────────────────────
def _prefixed(self, key: str) -> str:
"""Apply keyspace prefix to a key."""
return f"{self.prefix}:{key}" if self.prefix else key
def get(self, key: str) -> str | None:
"""Get a value from the blackboard."""
return self._backend.get(self._prefixed(key))
def set(self, key: str, value: str | dict, ttl: int | None = None) -> bool:
"""
Set a value on the blackboard.
Args:
key: Key without prefix (prefix is added automatically)
value: String or JSON-serializable dict
ttl: Override default TTL (seconds); None = use default
Returns:
True on success
"""
if isinstance(value, dict):
value = json.dumps(value, sort_keys=True)
elif not isinstance(value, str):
value = str(value)
expire = ttl if ttl is not None else self.ttl
result = self._backend.set(self._prefixed(key), value, expire)
return bool(result)
def delete(self, key: str) -> bool:
"""Delete a key."""
try:
return bool(self._backend.delete(self._prefixed(key)))
except AttributeError:
# MemoryBackend
k = self._prefixed(key)
if k in self._backend._store:
del self._backend._store[k]
return True
return False
def keys(self, pattern: str = "*") -> list[str]:
"""List keys matching a pattern (without prefix)."""
full_pattern = self._prefixed(pattern)
raw_keys = self._backend.keys(full_pattern)
# Strip prefix
prefix_len = len(self.prefix) + 1 if self.prefix else 0
return [k[prefix_len:] if k.startswith(f"{self.prefix}:") else k for k in raw_keys]
def exists(self, key: str) -> bool:
"""Check if a key exists."""
try:
return bool(self._backend.exists(self._prefixed(key)))
except AttributeError:
# MemoryBackend
return self._prefixed(key) in self._backend._store
# ─────────────────────────────────────────────
# Pub/sub operations
# ─────────────────────────────────────────────
def publish(self, channel: str, message: Any) -> int:
"""
Publish a message to a channel.
Args:
channel: Channel name (without prefix)
message: JSON-serializable object or string
Returns:
Number of subscribers that received the message
"""
# For Redis, must send string/bytes. For MemoryBackend, pass object.
if isinstance(self._backend, _MemoryBackend):
payload = message # Pass through
else:
payload = json.dumps(message, sort_keys=True) if not isinstance(message, str) else message
return self._backend.publish(self._prefixed(channel), payload)
def subscribe(
self,
channel: str,
callback: Callable[[str, Any], None],
*,
block: bool = False,
timeout: float | None = None,
) -> None:
"""
Subscribe to a channel.
Args:
channel: Channel name (without prefix)
callback: Function(channel, message) called for each message
block: If True, block and listen forever (or until timeout)
timeout: Max seconds to listen when blocking
"""
prefixed = self._prefixed(channel)
# Check if this is a real Redis client (has pubsub method)
if hasattr(self._backend, 'pubsub') and callable(getattr(self._backend, 'pubsub', None)):
# Real Redis pub/sub
import threading
pubsub = self._backend.pubsub()
pubsub.subscribe(prefixed)
def listener():
for msg in pubsub.listen():
if msg['type'] == 'message':
try:
data = json.loads(msg['data'])
except (json.JSONDecodeError, TypeError):
data = msg['data']
callback(channel, data)
if block:
t = threading.Thread(target=listener, daemon=True)
t.start()
if timeout:
t.join(timeout)
else:
t.join()
else:
# Fire-and-forget thread
threading.Thread(target=listener, daemon=True).start()
else:
# MemoryBackend — synchronous callback registration
self._backend.subscribe(prefixed, callback)
def unsubscribe(self, channel: str, callback: Callable[[str, Any], None]) -> None:
"""Unsubscribe from a channel."""
try:
self._backend.unsubscribe(self._prefixed(channel), callback)
except AttributeError:
pass # MemoryBackend supports it
# ─────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────
def clear_namespace(self, pattern: str = "*") -> int:
"""Delete all keys matching pattern in this namespace."""
full = self._prefixed(pattern)
try:
keys = self._backend.keys(full)
if keys:
return self._backend.delete(*keys)
return 0
except AttributeError:
store_keys = list(self._backend._store.keys())
import fnmatch
matched = fnmatch.filter(store_keys, full)
for k in matched:
del self._backend._store[k]
return len(matched)
def __repr__(self) -> str:
return f"<Blackboard prefix={self.prefix!r} backend={type(self._backend).__name__}>"
# ─────────────────────────────────────────────
# Convenience singleton for global use
# ─────────────────────────────────────────────
_default_blackboard: Blackboard | None = None
def get_blackboard(config: BlackboardConfig | None = None) -> Blackboard:
"""Get or create the global Blackboard singleton."""
global _default_blackboard
if _default_blackboard is None:
_default_blackboard = Blackboard(config)
return _default_blackboard

194
tests/test_blackboard.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Smoke tests for Blackboard — ensures the Redis-backed coordination layer
works with both real Redis and in-memory fallback.
"""
import json
import time
import pytest
from src.timmy.blackboard import Blackboard, BlackboardConfig, _MemoryBackend
class TestBlackboardBasics:
"""Test core key-value operations."""
def test_kv_memory_backend(self):
"""KV operations work using in-memory backend."""
bb = Blackboard(BlackboardConfig(fallback_to_memory=True, enabled=True))
# Set and get
assert bb.set("test:key", "hello") is True
assert bb.get("test:key") == "hello"
# Dict serialization
assert bb.set("test:obj", {"a": 1, "b": 2}) is True
val = bb.get("test:obj")
assert json.loads(val) == {"a": 1, "b": 2}
# Exists
assert bb.exists("test:key") is True
assert bb.exists("missing") is False
# Delete
assert bb.delete("test:key") is True
assert bb.get("test:key") is None
# Keys with prefix
bb.set("agent:timmy:state", "ready")
bb.set("agent:ezra:state", "idle")
keys = bb.keys("agent:*:state")
assert len(keys) == 2
assert "timmy" in keys[0] or "ezra" in keys[0]
# Clear namespace
assert bb.clear_namespace("agent:*") == 2
assert bb.keys("agent:*") == []
class TestBlackboardPubSub:
"""Test pub/sub coordination patterns."""
def test_pubsub_memory_backend(self):
"""Publish/subscribe works using in-memory backend."""
bb = Blackboard(BlackboardConfig(fallback_to_memory=True, enabled=True))
received = []
def callback(channel, message):
received.append((channel, message))
bb.subscribe("dispatch:new", callback)
# Publish
count = bb.publish("dispatch:new", {"issue": 123, "action": "comment"})
assert count == 1
assert len(received) == 1
ch, msg = received[0]
assert ch == "dispatch:new"
assert msg == {"issue": 123, "action": "comment"}
bb.unsubscribe("dispatch:new", callback)
bb.publish("dispatch:new", {"should": "not arrive"})
assert len(received) == 1 # no new messages
def test_publish_without_subscribers(self):
"""Publish returns 0 when no subscribers."""
bb = Blackboard(BlackboardConfig(fallback_to_memory=True, enabled=True))
count = bb.publish("empty:channel", {"msg": 1})
assert count == 0
class TestBlackboardConfig:
"""Test configuration parsing and validation."""
def test_default_config(self):
cfg = BlackboardConfig()
assert cfg.enabled is True
assert cfg.redis_url == "redis://localhost:6379/0"
assert cfg.keyspace_prefix == "timmy"
assert cfg.ttl_seconds == 3600
assert cfg.fallback_to_memory is True
def test_custom_config(self):
cfg = BlackboardConfig(
enabled=False,
redis_url="redis://192.168.1.10:6379/1",
keyspace_prefix="myagent",
ttl_seconds=1800,
fallback_to_memory=False,
)
assert cfg.enabled is False
assert cfg.redis_url == "redis://192.168.1.10:6379/1"
assert cfg.keyspace_prefix == "myagent"
assert cfg.ttl_seconds == 1800
assert cfg.fallback_to_memory is False
class TestKeyspacePrefix:
"""Test that keys are correctly prefixed."""
def test_prefixed_keys(self):
bb = Blackboard(BlackboardConfig(keyspace_prefix="myagent", fallback_to_memory=True))
bb.set("thought", "test")
# Internal key should be "myagent:thought"
# We can verify by checking keys()
keys = bb.keys("*")
assert any("myagent:thought" in k for k in keys)
class TestBlackboardIntegration:
"""Integration pattern: agent thought cycle."""
def test_agent_thought_cycle(self):
"""Simulate Timmy writing a thought and Ezra reading it."""
bb = Blackboard(BlackboardConfig(fallback_to_memory=True, enabled=True))
# Agent A writes observation
bb.set("agent:timmy:observation", "Gitea queue has 12 open issues")
# Agent B reads
obs = bb.get("agent:timmy:observation")
assert obs == "Gitea queue has 12 open issues"
# Agent B writes analysis
bb.set("agent:ezra:analysis", "Prioritize critical bugs first")
# Event-driven pattern
events = []
def on_plan(channel, message):
events.append(message)
bb.subscribe("fleet:plan", on_plan)
bb.publish("fleet:plan", {"phase": "triaging", "lead": "ezra"})
assert len(events) == 1
assert events[0]["phase"] == "triaging"
class TestTTL:
"""Test TTL handling (where supported)."""
def test_ttl_set_in_config(self):
cfg = BlackboardConfig(ttl_seconds=60, fallback_to_memory=True)
bb = Blackboard(cfg)
assert bb.ttl == 60
# Setting a value uses TTL from config
bb.set("temp:key", "expiring value")
# In memory backend ignores TTL, but value is set
assert bb.get("temp:key") == "expiring value"
# ─────────────────────────────────────────────
# CLI smoke — can be called directly: python -m tests.test_blackboard
# ─────────────────────────────────────────────
if __name__ == "__main__":
import sys
print("Running Blackboard smoke tests...")
suite = [
TestBlackboardBasics().test_kv_memory_backend,
TestBlackboardPubSub().test_pubsub_memory_backend,
TestBlackboardConfig().test_default_config,
TestBlackboardIntegration().test_agent_thought_cycle,
]
failures = 0
for test in suite:
name = test.__name__
try:
test()
print(f"{name}")
except AssertionError as e:
print(f"{name}: {e}")
failures += 1
except Exception as e:
print(f"{name}: ERROR — {e}")
failures += 1
print(f"\nRan {len(suite)} tests, {failures} failures")
sys.exit(failures)

View File

@@ -32,45 +32,11 @@ def test_load_and_build_packet() -> None:
assert packet["contact"]["name"] == "Timmy Operator"
assert packet["service_address"]["city"] == "Concord"
assert packet["service_address"]["state"] == "NH"
assert packet["availability"]["status"] == "available"
assert packet["appointment"]["scheduled"] is True
assert packet["pricing"]["monthly_cost_usd"] == 79.95
assert packet["installer_access"]["installer_can_reach_cabin"] is True
assert packet["payment"]["method"] == "credit_card"
assert packet["status"] == "scheduled_install"
assert packet["status"] == "pending_scheduling_call"
assert len(packet["checklist"]) == 8
assert packet["checklist"][0]["done"] is False
def test_build_packet_marks_blocked_when_availability_fails() -> None:
data = load_request("docs/nh-broadband-install-request.example.yaml")
data["availability"] = {
"status": "unavailable",
"checked_at": "2026-04-17T16:00:00Z",
"notes": "Address lookup returned no fiber service.",
}
data["appointment"] = {}
data["speed_test"] = {}
packet = build_packet(data)
assert packet["status"] == "blocked_unavailable"
def test_build_packet_marks_post_install_verified_when_speed_test_present() -> None:
data = load_request("docs/nh-broadband-install-request.example.yaml")
data["speed_test"] = {
"tested_at": "2026-05-01T18:30:00Z",
"download_mbps": 942.6,
"upload_mbps": 881.4,
"provider": "fast.com",
}
packet = build_packet(data)
assert packet["status"] == "post_install_verified"
def test_validate_rejects_missing_contact_name() -> None:
data = {
"contact": {"name": "", "phone": "555"},
@@ -120,11 +86,6 @@ def test_render_markdown_contains_key_sections() -> None:
assert "# NH Broadband Install Packet" in md
assert "## Contact" in md
assert "## Service Address" in md
assert "## Availability" in md
assert "## Pricing + Plan Recommendation" in md
assert "## Installation Appointment" in md
assert "## Installer Access Notes" in md
assert "## Payment" in md
assert "## Call Log" in md
assert "## Appointment Checklist" in md
assert "Concord" in md
@@ -136,8 +97,6 @@ def test_render_markdown_shows_checklist_items() -> None:
packet = build_packet(data)
md = render_markdown(packet, data)
assert "- [ ] Confirm exact-address availability" in md
assert "Installer can reach cabin" in md
assert "- **Confirmation #: NHB-2026-0417**" in md
def test_example_yaml_is_valid() -> None: