""" Mixed-Room Routing — Phase M2.1 + M2.2 (guard rails + rejection audit) Supports 1 room → N agents with deterministic message routing. Env: BRIDGE_MIXED_ROOM_MAP=!roomX:server=sofiia,helion;!roomY:server=druid,nutra BRIDGE_MIXED_DEFAULTS=!roomX:server=sofiia;!roomY:server=druid (optional) Routing priority (per message): 1. Slash command: /sofiia message text → agent=sofiia 2. Mention @: @sofiia message text → agent=sofiia 3. Mention name: sofiia: message text → agent=sofiia 4. Fallback: default_agent_for_room (first in list, or explicit BRIDGE_MIXED_DEFAULTS) Reply tagging (mixed room only): Worker prepends "Agentname: " to reply so users see who answered. Single-agent rooms are unaffected. """ import logging import re from dataclasses import dataclass, field from typing import Dict, FrozenSet, List, Optional, Tuple logger = logging.getLogger(__name__) # Room ID format: !: _ROOM_ID_RE = re.compile(r"^![A-Za-z0-9\-_.]+:[A-Za-z0-9\-_.]+$") # Routing patterns (compiled once) _SLASH_RE = re.compile(r"^/([A-Za-z0-9_\-]+)\s*(.*)", re.DOTALL) _MENTION_AT_RE = re.compile(r"^@([A-Za-z0-9_\-]+)\s*(.*)", re.DOTALL) _MENTION_COLON_RE = re.compile(r"^([A-Za-z0-9_\-]+):\s+(.*)", re.DOTALL) # Routing reason labels (successful routes) REASON_SLASH = "slash_command" REASON_AT_MENTION = "at_mention" REASON_COLON_MENTION = "colon_mention" REASON_DEFAULT = "default" # Rejection reason labels (route_message returns agent_id=None + one of these) REASON_REJECTED_UNKNOWN_AGENT = "unknown_agent" REASON_REJECTED_SLASH_TOO_LONG = "slash_too_long" REASON_REJECTED_NO_MAPPING = "no_mapping" # Hard guards _DEFAULT_MAX_AGENTS_PER_ROOM = 5 _DEFAULT_MAX_SLASH_LEN = 32 # ── Data structures ──────────────────────────────────────────────────────────── @dataclass class MixedRoom: """A single mixed room with its ordered agent list and default agent.""" room_id: str agents: List[str] # ordered; first = default if not overridden default_agent: str # explicit default (from BRIDGE_MIXED_DEFAULTS or first agent) def __post_init__(self) -> None: if self.default_agent not in self.agents: raise ValueError( f"MixedRoom {self.room_id!r}: default_agent {self.default_agent!r} " f"not in agents list {self.agents}" ) @dataclass class MixedRoomConfig: """Parsed configuration for all mixed rooms.""" rooms: Dict[str, MixedRoom] = field(default_factory=dict) # room_id → MixedRoom @property def total_rooms(self) -> int: return len(self.rooms) def is_mixed(self, room_id: str) -> bool: return room_id in self.rooms def agents_for_room(self, room_id: str) -> List[str]: room = self.rooms.get(room_id) return list(room.agents) if room else [] def default_agent(self, room_id: str) -> Optional[str]: room = self.rooms.get(room_id) return room.default_agent if room else None def as_summary(self) -> List[Dict]: return [ { "room_id": room_id, "agents": list(room.agents), "default_agent": room.default_agent, } for room_id, room in self.rooms.items() ] # ── Parsers ──────────────────────────────────────────────────────────────────── def parse_mixed_room_map( raw_map: str, raw_defaults: str, allowed_agents: FrozenSet[str], max_agents_per_room: int = _DEFAULT_MAX_AGENTS_PER_ROOM, ) -> MixedRoomConfig: """ Parse BRIDGE_MIXED_ROOM_MAP and BRIDGE_MIXED_DEFAULTS into MixedRoomConfig. Map format: "!room1:server=sofiia,helion;!room2:server=druid" Defaults fmt: "!room1:server=sofiia;!room2:server=druid" Raises ValueError on: - Malformed room_id - Empty agent list - Too many agents per room (> max_agents_per_room) - Agent not in allowed_agents - Duplicate room_id in map """ if not raw_map or not raw_map.strip(): return MixedRoomConfig() # Parse explicit defaults first explicit_defaults: Dict[str, str] = {} if raw_defaults and raw_defaults.strip(): for entry in raw_defaults.split(";"): entry = entry.strip() if not entry: continue if "=" not in entry: raise ValueError(f"BRIDGE_MIXED_DEFAULTS bad entry (no '='): {entry!r}") rid, agent = entry.split("=", 1) rid, agent = rid.strip(), agent.strip() if not _ROOM_ID_RE.match(rid): raise ValueError(f"BRIDGE_MIXED_DEFAULTS bad room_id: {rid!r}") explicit_defaults[rid] = agent rooms: Dict[str, MixedRoom] = {} errors: List[str] = [] for entry in raw_map.split(";"): entry = entry.strip() if not entry: continue if "=" not in entry: errors.append(f"BRIDGE_MIXED_ROOM_MAP bad entry (no '='): {entry!r}") continue room_id, agents_raw = entry.split("=", 1) room_id = room_id.strip() agents_raw = agents_raw.strip() if not _ROOM_ID_RE.match(room_id): errors.append(f"Invalid room_id format: {room_id!r}") continue if room_id in rooms: errors.append(f"Duplicate room_id in BRIDGE_MIXED_ROOM_MAP: {room_id!r}") continue agents = [a.strip() for a in agents_raw.split(",") if a.strip()] if not agents: errors.append(f"Empty agent list for room {room_id!r}") continue # M2.2 guard: fail-fast if too many agents per room if len(agents) > max_agents_per_room: errors.append( f"Room {room_id!r} has {len(agents)} agents > MAX_AGENTS_PER_MIXED_ROOM={max_agents_per_room}" ) continue invalid = [a for a in agents if a not in allowed_agents] if invalid: errors.append( f"Agents {invalid} for room {room_id!r} not in allowed_agents {set(allowed_agents)}" ) continue default = explicit_defaults.get(room_id, agents[0]) if default not in agents: errors.append( f"Default agent {default!r} for room {room_id!r} not in agents list {agents}" ) continue rooms[room_id] = MixedRoom(room_id=room_id, agents=agents, default_agent=default) if errors: raise ValueError(f"BRIDGE_MIXED_ROOM_MAP parse errors: {'; '.join(errors)}") config = MixedRoomConfig(rooms=rooms) logger.info( "Mixed room config loaded: %d rooms, total agents=%d", config.total_rooms, sum(len(r.agents) for r in rooms.values()), ) return config # ── Routing ──────────────────────────────────────────────────────────────────── def route_message( text: str, room_id: str, config: MixedRoomConfig, allowed_agents: FrozenSet[str], max_slash_len: int = _DEFAULT_MAX_SLASH_LEN, ) -> Tuple[Optional[str], str, str]: """ Determine which agent should handle this message. Returns: (agent_id, routing_reason, effective_text) agent_id: matched agent or None if unresolvable / rejected routing_reason: one of REASON_* or REASON_REJECTED_* constants effective_text: text with routing prefix stripped (for cleaner invoke) Priority: 1. /agentname ... (slash command) 2. @agentname ... (at-mention) 3. agentname: ... (colon-mention) 4. default agent for room (fallback) Guard rails (M2.2): - Slash command token longer than max_slash_len → REASON_REJECTED_SLASH_TOO_LONG - Unknown agent in slash → REASON_REJECTED_UNKNOWN_AGENT (no fallthrough) """ room = config.rooms.get(room_id) if room is None: return None, REASON_REJECTED_NO_MAPPING, text stripped = text.strip() # 1. Slash: /sofiia hello world m = _SLASH_RE.match(stripped) if m: candidate = m.group(1).lower() body = m.group(2).strip() or stripped # keep original if body empty # M2.2: reject suspiciously long slash tokens (garbage / injection attempts) if len(candidate) > max_slash_len: logger.warning( "Slash token too long (%d > %d) in room %s — rejected", len(candidate), max_slash_len, room_id, ) return None, REASON_REJECTED_SLASH_TOO_LONG, text agent = _resolve_agent(candidate, room, allowed_agents) if agent: logger.debug("Slash route: /%s → %s", candidate, agent) return agent, REASON_SLASH, body # Unknown agent → hard reject, do NOT fall through to default logger.warning( "Slash command /%s in room %s: agent not recognised or not allowed (available: %s)", candidate, room_id, room.agents, ) return None, REASON_REJECTED_UNKNOWN_AGENT, text # 2. @mention: @sofiia hello m = _MENTION_AT_RE.match(stripped) if m: candidate = m.group(1).lower() body = m.group(2).strip() or stripped agent = _resolve_agent(candidate, room, allowed_agents) if agent: logger.debug("@mention route: @%s → %s", candidate, agent) return agent, REASON_AT_MENTION, body # 3. colon-mention: sofiia: hello m = _MENTION_COLON_RE.match(stripped) if m: candidate = m.group(1).lower() body = m.group(2).strip() or stripped agent = _resolve_agent(candidate, room, allowed_agents) if agent: logger.debug("Colon-mention route: %s: → %s", candidate, agent) return agent, REASON_COLON_MENTION, body # 4. Default fallback return room.default_agent, REASON_DEFAULT, stripped def _resolve_agent( candidate: str, room: MixedRoom, allowed_agents: FrozenSet[str], ) -> Optional[str]: """ Return agent_id if candidate matches an allowed agent in this room. Matching is case-insensitive against agent ids and their base names. """ for agent in room.agents: if candidate == agent.lower(): if agent in allowed_agents: return agent return None def reply_prefix(agent_id: str, is_mixed: bool) -> str: """ Return reply prefix string for mixed rooms. Single-agent rooms get empty prefix (no change to M1 behaviour). """ if not is_mixed: return "" # Capitalise first letter of agent name: "sofiia" → "Sofiia" return f"{agent_id.capitalize()}: " def build_override_config( base_config: MixedRoomConfig, room_id: str, agents: List[str], default_agent: str, ) -> MixedRoomConfig: """ M6.1: Build a temporary MixedRoomConfig that uses a dynamic store override for room_id while keeping all other rooms from base_config unchanged. Used in _enqueue_from_mixed_room to inject PolicyStore agent overrides without mutating the shared base configuration. """ rooms = dict(base_config.rooms) rooms[room_id] = MixedRoom( room_id=room_id, agents=agents, default_agent=default_agent, ) return MixedRoomConfig(rooms=rooms)