feat(matrix-bridge-dagi): add mixed-room routing by slash/mention (M2.1)

- mixed_routing.py: parse BRIDGE_MIXED_ROOM_MAP, route by /slash > @mention > name: > default
- ingress.py: _try_enqueue_mixed for mixed rooms, session isolation {room}:{agent}, reply tagging
- config.py: bridge_mixed_room_map + bridge_mixed_defaults fields
- main.py: parse mixed config, pass to MatrixIngressLoop, expose in /health + /bridge/mappings
- docker-compose: BRIDGE_MIXED_ROOM_MAP / BRIDGE_MIXED_DEFAULTS env vars, BRIDGE_ALLOWED_AGENTS multi-value
- tests: 25 routing unit tests + 10 ingress integration tests (94 total pass)

Made-with: Cursor
This commit is contained in:
Apple
2026-03-05 01:29:18 -08:00
parent 79db053b38
commit a85a11984b
7 changed files with 1049 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
"""
matrix-bridge-dagi — configuration and validation
matrix-bridge-dagi — configuration and validation (M2.1: mixed rooms)
"""
import os
from dataclasses import dataclass, field
@@ -34,6 +34,12 @@ class BridgeConfig:
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
# Service identity
node_id: str
build_sha: str
@@ -70,6 +76,8 @@ def load_config() -> BridgeConfig:
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", ""),
node_id=_optional("NODE_ID", "NODA1"),
build_sha=_optional("BUILD_SHA", "dev"),
build_time=_optional("BUILD_TIME", "local"),

View File

@@ -1,5 +1,5 @@
"""
Matrix Ingress + Egress Loop — Phase M1.4 + H1 + H2 + H3
Matrix Ingress + Egress Loop — Phase M1.4 + H1 + H2 + H3 + M2.1 (mixed rooms)
Architecture (H2):
Reader task → asyncio.Queue(maxsize) → N Worker tasks
@@ -22,18 +22,19 @@ Shutdown:
2. queue.join() with drain_timeout → workers finish in-flight
3. worker tasks cancelled
Queue entry: _QueueEntry(event, room_id, agent_id, enqueue_time)
Queue entry: _QueueEntry(event, room_id, agent_id, enqueue_time, routing_reason, is_mixed)
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
import httpx
from .matrix_client import MatrixClient
from .mixed_routing import MixedRoomConfig, route_message, reply_prefix
from .rate_limit import InMemoryRateLimiter
from .room_mapping import RoomMappingConfig, RoomMapping
@@ -56,7 +57,9 @@ class _QueueEntry:
event: Dict[str, Any]
room_id: str
agent_id: str
enqueue_time: float # time.monotonic() at enqueue
enqueue_time: float # time.monotonic() at enqueue
routing_reason: str = "direct"
is_mixed: bool = False # True for mixed-room entries (reply tagging, session isolation)
# ── Router invoke ──────────────────────────────────────────────────────────────
@@ -165,6 +168,7 @@ class MatrixIngressLoop:
queue_max_events: int = 100,
worker_concurrency: int = 2,
queue_drain_timeout_s: float = 5.0,
mixed_room_config: Optional[MixedRoomConfig] = None,
# Callbacks
on_message_received: Optional[Callable[[str, str], None]] = None,
on_message_replied: Optional[Callable[[str, str, str], None]] = None,
@@ -198,6 +202,7 @@ class MatrixIngressLoop:
self._on_invoke_latency = on_invoke_latency
self._on_send_latency = on_send_latency
self._on_queue_wait = on_queue_wait
self._mixed_room_config = mixed_room_config
self._next_batch: Optional[str] = None
self._queue: Optional[asyncio.Queue] = None # exposed for /health
@@ -216,14 +221,15 @@ class MatrixIngressLoop:
# ── Public run ─────────────────────────────────────────────────────────────
async def run(self, stop_event: asyncio.Event) -> None:
mixed_rooms_count = self._mixed_room_config.total_rooms if self._mixed_room_config else 0
logger.info(
"Matrix ingress loop started | hs=%s node=%s mappings=%d "
"Matrix ingress loop started | hs=%s node=%s mappings=%d mixed_rooms=%d "
"queue_max=%d workers=%d",
self._hs_url, self._node_id, self._room_map.total_mappings,
self._hs_url, self._node_id, self._room_map.total_mappings, mixed_rooms_count,
self._queue_max, self._worker_count,
)
if self._room_map.total_mappings == 0:
if self._room_map.total_mappings == 0 and mixed_rooms_count == 0:
logger.warning("No room mappings — ingress loop is idle")
queue: asyncio.Queue[Optional[_QueueEntry]] = asyncio.Queue(
@@ -238,6 +244,12 @@ class MatrixIngressLoop:
await client.join_room(mapping.room_id)
except Exception as exc:
logger.warning("Could not join room %s: %s", mapping.room_id, exc)
if self._mixed_room_config:
for room_id in self._mixed_room_config.rooms:
try:
await client.join_room(room_id)
except Exception as exc:
logger.warning("Could not join mixed room %s: %s", room_id, exc)
async with httpx.AsyncClient() as http_client:
# Start workers
@@ -312,6 +324,7 @@ class MatrixIngressLoop:
http_client: httpx.AsyncClient,
sync_resp: Dict[str, Any],
) -> None:
# Regular rooms: 1 room → 1 agent (M1 / M2.0)
for mapping in self._room_map.mappings:
if mapping.agent_id not in self._room_map.allowed_agents:
continue
@@ -319,6 +332,13 @@ class MatrixIngressLoop:
for event in messages:
await self._try_enqueue(client, queue, http_client, event, mapping)
# Mixed rooms: 1 room → N agents, routing per message (M2.1)
if self._mixed_room_config:
for room_id in self._mixed_room_config.rooms:
messages = client.extract_room_messages(sync_resp, room_id)
for event in messages:
await self._try_enqueue_mixed(client, queue, http_client, event, room_id)
async def _try_enqueue(
self,
client: MatrixClient,
@@ -388,6 +408,103 @@ class MatrixIngressLoop:
data={"queue_max": self._queue_max, "sender": sender},
)
async def _try_enqueue_mixed(
self,
client: MatrixClient,
queue: "asyncio.Queue[Optional[_QueueEntry]]",
http_client: httpx.AsyncClient,
event: Dict[str, Any],
room_id: str,
) -> None:
"""Enqueue a message from a mixed room, routing to the appropriate agent."""
assert self._mixed_room_config is not None
event_id = event.get("event_id", "")
sender = event.get("sender", "")
text = event.get("content", {}).get("body", "").strip()
if not text:
return
# Route message to determine target agent
agent_id, routing_reason, effective_text = route_message(
text, room_id, self._mixed_room_config, self._room_map.allowed_agents,
)
if agent_id is None:
logger.warning(
"Mixed room %s: unresolvable routing reason=%s event=%s — skipping",
room_id, routing_reason, event_id,
)
await _write_audit(
http_client, self._console_url, self._internal_token,
event="matrix.error",
agent_id="unknown", node_id=self._node_id,
room_id=room_id, event_id=event_id,
status="error", error_code="no_agent_for_message",
data={"routing_reason": routing_reason, "sender": sender},
)
return
# H1: Rate limit (uses final agent_id for metric tagging)
if self._rate_limiter is not None:
allowed, limit_type = self._rate_limiter.check(room_id=room_id, sender=sender)
if not allowed:
logger.warning(
"Rate limited (mixed): room=%s sender=%s agent=%s limit_type=%s",
room_id, sender, agent_id, limit_type,
)
if self._on_rate_limited:
self._on_rate_limited(room_id, agent_id, limit_type or "unknown")
await _write_audit(
http_client, self._console_url, self._internal_token,
event="matrix.rate_limited",
agent_id=agent_id, node_id=self._node_id,
room_id=room_id, event_id=event_id,
status="error", error_code=f"rate_limit_{limit_type}",
data={"sender": sender, "limit_type": limit_type, "routing_reason": routing_reason},
)
return
client.mark_seen(event_id)
# Store effective_text (stripped of routing token) in a patched event copy
effective_event = dict(event)
effective_event["content"] = dict(event.get("content", {}))
effective_event["content"]["body"] = effective_text
entry = _QueueEntry(
event=effective_event,
room_id=room_id,
agent_id=agent_id,
enqueue_time=time.monotonic(),
routing_reason=routing_reason,
is_mixed=True,
)
try:
queue.put_nowait(entry)
qsize = queue.qsize()
logger.debug(
"Enqueued (mixed): event=%s agent=%s reason=%s qsize=%d",
event_id, agent_id, routing_reason, qsize,
)
if self._on_queue_size:
self._on_queue_size(qsize)
except asyncio.QueueFull:
logger.warning(
"Queue full (max=%d): dropping mixed event=%s room=%s agent=%s",
self._queue_max, event_id, room_id, agent_id,
)
if self._on_queue_dropped:
self._on_queue_dropped(room_id, agent_id)
await _write_audit(
http_client, self._console_url, self._internal_token,
event="matrix.queue_full",
agent_id=agent_id, node_id=self._node_id,
room_id=room_id, event_id=event_id,
status="error", error_code="queue_full",
data={"queue_max": self._queue_max, "sender": sender},
)
# ── Worker ─────────────────────────────────────────────────────────────────
async def _worker(
@@ -428,9 +545,12 @@ class MatrixIngressLoop:
if self._on_queue_wait:
self._on_queue_wait(agent_id, wait_s)
routing_reason = entry.routing_reason
is_mixed = entry.is_mixed
logger.info(
"Processing: room=%s agent=%s event=%s len=%d wait=%.3fs",
room_id, agent_id, event_id, len(text), wait_s,
"Processing: room=%s agent=%s event=%s len=%d wait=%.3fs mixed=%s reason=%s",
room_id, agent_id, event_id, len(text), wait_s, is_mixed, routing_reason,
)
if self._on_message_received:
@@ -442,10 +562,21 @@ class MatrixIngressLoop:
agent_id=agent_id, node_id=self._node_id,
room_id=room_id, event_id=event_id,
status="ok",
data={"sender": sender, "text_len": len(text), "queue_wait_ms": int(wait_s * 1000)},
data={
"sender": sender,
"text_len": len(text),
"queue_wait_ms": int(wait_s * 1000),
"routing_reason": routing_reason,
"is_mixed": is_mixed,
},
)
session_id = f"matrix:{room_id.replace('!', '').replace(':', '_')}"
# M2.1: session isolation per (room, agent) for mixed rooms
room_key = room_id.replace("!", "").replace(":", "_")
if is_mixed:
session_id = f"matrix:{room_key}:{agent_id}"
else:
session_id = f"matrix:{room_key}"
# H3: Invoke with latency
t0 = time.monotonic()
@@ -516,7 +647,10 @@ class MatrixIngressLoop:
return
# H3: Send with latency
send_text = reply_text[:_REPLY_TEXT_MAX]
# M2.1: prefix reply with agent identity in mixed rooms ("Sofiia: ...")
prefix = reply_prefix(agent_id, is_mixed)
raw_reply = reply_text[:_REPLY_TEXT_MAX - len(prefix)]
send_text = prefix + raw_reply
txn_id = MatrixClient.make_txn_id(room_id, event_id)
send_t0 = time.monotonic()
@@ -537,6 +671,8 @@ class MatrixIngressLoop:
"truncated": len(reply_text) > _REPLY_TEXT_MAX,
"router_duration_ms": int(invoke_duration_s * 1000),
"queue_wait_ms": int(wait_s * 1000),
"routing_reason": routing_reason,
"is_mixed": is_mixed,
},
)
logger.info(

View File

@@ -33,6 +33,7 @@ except ImportError: # pragma: no cover
from .config import BridgeConfig, load_config
from .ingress import MatrixIngressLoop
from .mixed_routing import MixedRoomConfig, parse_mixed_room_map
from .rate_limit import InMemoryRateLimiter
from .room_mapping import RoomMappingConfig, parse_room_map
@@ -112,6 +113,7 @@ _config_error: Optional[str] = None
_matrix_reachable: Optional[bool] = None
_gateway_reachable: Optional[bool] = None
_room_map: Optional[RoomMappingConfig] = None
_mixed_room_config: Optional[MixedRoomConfig] = None
_rate_limiter: Optional[InMemoryRateLimiter] = None
_ingress_loop: Optional["MatrixIngressLoop"] = None # for /health queue_size
_ingress_task: Optional[asyncio.Task] = None
@@ -133,16 +135,31 @@ async def _probe_url(url: str, timeout: float = 5.0) -> bool:
@asynccontextmanager
async def lifespan(app_: Any):
global _cfg, _config_error, _matrix_reachable, _gateway_reachable
global _room_map, _rate_limiter, _ingress_loop
global _room_map, _mixed_room_config, _rate_limiter, _ingress_loop
try:
_cfg = load_config()
# Parse room mapping
# Parse regular room mapping (M1/M2.0: 1 room → 1 agent)
_room_map = parse_room_map(
os.getenv("BRIDGE_ROOM_MAP", ""),
_cfg.bridge_allowed_agents,
)
# Parse mixed room mapping (M2.1: 1 room → N agents)
if _cfg.bridge_mixed_room_map:
_mixed_room_config = parse_mixed_room_map(
_cfg.bridge_mixed_room_map,
_cfg.bridge_mixed_defaults,
_cfg.bridge_allowed_agents,
)
logger.info(
"✅ Mixed room config: %d rooms, agents=%s",
_mixed_room_config.total_rooms,
[a for r in _mixed_room_config.rooms.values() for a in r.agents],
)
else:
_mixed_room_config = None
# H1: Rate limiter (inmemory, per config)
_rate_limiter = InMemoryRateLimiter(
room_rpm=_cfg.rate_limit_room_rpm,
@@ -153,12 +170,13 @@ async def lifespan(app_: Any):
_cfg.rate_limit_room_rpm, _cfg.rate_limit_sender_rpm,
)
mixed_count = _mixed_room_config.total_rooms if _mixed_room_config else 0
logger.info(
"✅ matrix-bridge-dagi started | node=%s build=%s homeserver=%s "
"room=%s agents=%s mappings=%d",
"agents=%s mappings=%d mixed_rooms=%d",
_cfg.node_id, _cfg.build_sha, _cfg.matrix_homeserver_url,
_cfg.sofiia_room_id, list(_cfg.bridge_allowed_agents),
_room_map.total_mappings,
list(_cfg.bridge_allowed_agents),
_room_map.total_mappings, mixed_count,
)
# Connectivity smoke probes (non-blocking failures)
@@ -180,7 +198,10 @@ async def lifespan(app_: Any):
_bridge_up.set(1)
# Start ingress loop (fire-and-forget asyncio task)
if _room_map and _room_map.total_mappings > 0:
_has_rooms = (_room_map and _room_map.total_mappings > 0) or (
_mixed_room_config and _mixed_room_config.total_rooms > 0
)
if _has_rooms:
_ingress_stop = asyncio.Event()
def _on_msg(room_id: str, agent_id: str) -> None:
@@ -241,6 +262,7 @@ async def lifespan(app_: Any):
queue_max_events=_cfg.queue_max_events,
worker_concurrency=_cfg.worker_concurrency,
queue_drain_timeout_s=_cfg.queue_drain_timeout_s,
mixed_room_config=_mixed_room_config,
on_message_received=_on_msg,
on_message_replied=_on_replied,
on_gateway_error=_on_gw_error,
@@ -328,6 +350,7 @@ async def health() -> Dict[str, Any]:
"gateway": _cfg.dagi_gateway_url,
"gateway_reachable": _gateway_reachable,
"mappings_count": _room_map.total_mappings if _room_map else 0,
"mixed_rooms_count": _mixed_room_config.total_rooms if _mixed_room_config else 0,
"config_ok": True,
"rate_limiter": _rate_limiter.stats() if _rate_limiter else None,
"queue": {
@@ -350,12 +373,15 @@ async def bridge_mappings() -> Dict[str, Any]:
"ok": False,
"error": _config_error or "service not initialised",
"mappings": [],
"mixed_rooms": [],
}
return {
"ok": True,
"total": _room_map.total_mappings,
"allowed_agents": list(_cfg.bridge_allowed_agents),
"mappings": _room_map.as_summary(),
"mixed_rooms_total": _mixed_room_config.total_rooms if _mixed_room_config else 0,
"mixed_rooms": _mixed_room_config.as_summary() if _mixed_room_config else [],
}

View File

@@ -0,0 +1,279 @@
"""
Mixed-Room Routing — Phase M2.1
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: !<localpart>:<server>
_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
REASON_SLASH = "slash_command"
REASON_AT_MENTION = "at_mention"
REASON_COLON_MENTION = "colon_mention"
REASON_DEFAULT = "default"
# ── 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],
) -> 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
- 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
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],
) -> 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
routing_reason: one of REASON_* 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)
"""
room = config.rooms.get(room_id)
if room is None:
return None, "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
agent = _resolve_agent(candidate, room, allowed_agents)
if agent:
logger.debug("Slash route: /%s%s", candidate, agent)
return agent, REASON_SLASH, body
# Unknown agent → return None + log; do not fall through to default
logger.warning(
"Slash command /%s in room %s: agent not recognised or not allowed",
candidate, room_id,
)
return None, f"unknown_slash_{candidate}", 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()}: "