Files
microdao-daarion/services/matrix-bridge-dagi/app/config.py
Apple d40b1e87c6 feat(matrix-bridge-dagi): harden mixed rooms with safe defaults and ops visibility (M2.2)
Guard rails (mixed_routing.py):
  - MAX_AGENTS_PER_MIXED_ROOM (default 5): fail-fast at parse time
  - MAX_SLASH_LEN (default 32): reject garbage/injection slash tokens
  - Unified rejection reasons: unknown_agent, slash_too_long, no_mapping
  - REASON_REJECTED_* constants (separate from success REASON_*)

Ingress (ingress.py):
  - per-room-agent concurrency semaphore (MIXED_CONCURRENCY_CAP, default 1)
  - active_lock_count property for /health + prometheus
  - UNKNOWN_AGENT_BEHAVIOR: "ignore" (silent) | "reply_error" (inform user)
  - on_routed(agent_id, reason) callback for routing metrics
  - on_route_rejected(room_id, reason) callback for rejection metrics
  - matrix.route.rejected audit event on every rejection

Config + main:
  - max_agents_per_mixed_room, max_slash_len, unknown_agent_behavior, mixed_concurrency_cap
  - matrix_bridge_routed_total{agent_id, reason} counter
  - matrix_bridge_route_rejected_total{room_id, reason} counter
  - matrix_bridge_active_room_agent_locks gauge
  - /health: mixed_guard_rails section + total_agents_in_mixed_rooms
  - docker-compose: all 4 new guard rail env vars

Runbook: section 9 — mixed room debug guide (6 acceptance tests, routing metrics, session isolation, lock hang, config guard)

Tests: 108 pass (94 → 108, +14 new tests for guard rails + callbacks + concurrency)
Made-with: Cursor
2026-03-05 01:41:20 -08:00

95 lines
4.0 KiB
Python

"""
matrix-bridge-dagi — configuration and validation (M2.1 + M2.2: mixed rooms + guard rails)
"""
import os
from dataclasses import dataclass, field
from typing import FrozenSet
@dataclass(frozen=True)
class BridgeConfig:
# Matrix homeserver
matrix_homeserver_url: str
matrix_access_token: str
matrix_user_id: str # e.g. @dagi_bridge:daarion.space
# Room → agent mapping (M1: single room)
sofiia_room_id: str # e.g. !abcdef:daarion.space
# DAGI backend
dagi_gateway_url: str # e.g. http://dagi-gateway-node1:9300
default_node_id: str # e.g. NODA1
# Sofiia Console (audit write)
sofiia_console_url: str # e.g. http://dagi-sofiia-console-node1:8002
sofiia_internal_token: str # X-Internal-Service-Token for audit ingest
# Policy
bridge_allowed_agents: FrozenSet[str]
rate_limit_room_rpm: int # max messages per room per minute
rate_limit_sender_rpm: int # max messages per sender per minute
# H2: Backpressure queue
queue_max_events: int # max pending items (drops oldest on full)
worker_concurrency: int # parallel invoke workers
queue_drain_timeout_s: float # graceful shutdown drain timeout
# M2.1: Mixed rooms
# "!roomX:server=sofiia,helion;!roomY:server=druid"
bridge_mixed_room_map: str
# "!roomX:server=helion" — explicit default per mixed room (optional)
bridge_mixed_defaults: str
# M2.2: Mixed room guard rails
max_agents_per_mixed_room: int # fail-fast if room defines more agents than this
max_slash_len: int # reject slash token longer than this (anti-garbage)
unknown_agent_behavior: str # "ignore" | "reply_error"
mixed_concurrency_cap: int # max parallel invokes per (room, agent); 0 = unlimited
# Service identity
node_id: str
build_sha: str
build_time: str
def load_config() -> BridgeConfig:
"""Load and validate config from environment variables."""
def _require(key: str) -> str:
v = os.getenv(key, "").strip()
if not v:
raise RuntimeError(f"Required env var {key!r} is not set")
return v
def _optional(key: str, default: str = "") -> str:
return os.getenv(key, default).strip()
allowed_raw = _optional("BRIDGE_ALLOWED_AGENTS", "sofiia")
allowed = frozenset(a.strip() for a in allowed_raw.split(",") if a.strip())
return BridgeConfig(
matrix_homeserver_url=_require("MATRIX_HOMESERVER_URL").rstrip("/"),
matrix_access_token=_require("MATRIX_ACCESS_TOKEN"),
matrix_user_id=_require("MATRIX_USER_ID"),
sofiia_room_id=_require("SOFIIA_ROOM_ID"),
dagi_gateway_url=_require("DAGI_GATEWAY_URL").rstrip("/"),
default_node_id=_optional("DEFAULT_NODE_ID", "NODA1"),
sofiia_console_url=_optional("SOFIIA_CONSOLE_URL", "").rstrip("/"),
sofiia_internal_token=_optional("SOFIIA_INTERNAL_TOKEN", ""),
bridge_allowed_agents=allowed,
rate_limit_room_rpm=int(_optional("RATE_LIMIT_ROOM_RPM", "20")),
rate_limit_sender_rpm=int(_optional("RATE_LIMIT_SENDER_RPM", "10")),
queue_max_events=max(1, int(_optional("QUEUE_MAX_EVENTS", "100"))),
worker_concurrency=max(1, int(_optional("WORKER_CONCURRENCY", "2"))),
queue_drain_timeout_s=max(1.0, float(_optional("QUEUE_DRAIN_TIMEOUT_S", "5"))),
bridge_mixed_room_map=_optional("BRIDGE_MIXED_ROOM_MAP", ""),
bridge_mixed_defaults=_optional("BRIDGE_MIXED_DEFAULTS", ""),
max_agents_per_mixed_room=max(1, int(_optional("MAX_AGENTS_PER_MIXED_ROOM", "5"))),
max_slash_len=max(4, int(_optional("MAX_SLASH_LEN", "32"))),
unknown_agent_behavior=_optional("UNKNOWN_AGENT_BEHAVIOR", "ignore"),
mixed_concurrency_cap=max(0, int(_optional("MIXED_CONCURRENCY_CAP", "1"))),
node_id=_optional("NODE_ID", "NODA1"),
build_sha=_optional("BUILD_SHA", "dev"),
build_time=_optional("BUILD_TIME", "local"),
)