|
|
@@ -58,6 +58,7 @@ from gateway.platforms.base import (
|
|
|
get_inbound_media_max_bytes,
|
|
|
validate_inbound_media_size,
|
|
|
)
|
|
|
+from gateway.session import SessionSource
|
|
|
|
|
|
# Chattolib imports (vendored)
|
|
|
# Using vendored chattolib from vendor/chattolib/
|
|
|
@@ -1218,6 +1219,25 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self.chatto_config, room_id, self._room_kinds.get(room_id)
|
|
|
)
|
|
|
|
|
|
+ def toolsets_for_source(self, source: SessionSource) -> list[str] | None:
|
|
|
+ """Restrict which toolsets a turn in a listed room may use.
|
|
|
+
|
|
|
+ ``CHATTO_ROOM_TOOLSETS`` holds ``room_id:toolset`` entries. A room with
|
|
|
+ at least one entry *replaces* the platform's toolset list for turns in
|
|
|
+ that room — Hermes' contract for this hook — so the entries have to name
|
|
|
+ every toolset the room should keep. A room without an entry returns
|
|
|
+ None, leaving ``platform_toolsets`` in charge. DM rooms are looked up
|
|
|
+ like any other room, and a thread turn carries its room's ID, so a room
|
|
|
+ entry covers its threads too.
|
|
|
+
|
|
|
+ BasePlatformAdapter override
|
|
|
+ """
|
|
|
+ room_id = str(getattr(source, "chat_id", "") or "")
|
|
|
+ if not room_id:
|
|
|
+ return None
|
|
|
+ by_room, _malformed = _parse_room_toolsets(self.chatto_config.room_toolsets.value)
|
|
|
+ return by_room.get(room_id) or None
|
|
|
+
|
|
|
async def _room_roster_context(
|
|
|
self, client: ChattoClient, room_id: str
|
|
|
) -> _RoomRoster | None:
|
|
|
@@ -4165,6 +4185,17 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
|
|
|
)
|
|
|
return False
|
|
|
|
|
|
+ _by_room, malformed_toolsets = _parse_room_toolsets(
|
|
|
+ resolved.room_toolsets.value
|
|
|
+ )
|
|
|
+ if malformed_toolsets:
|
|
|
+ logger.error(
|
|
|
+ "Chatto: 'room_toolsets' entries must look like 'room_id:toolset' — "
|
|
|
+ "these could not be read: %s",
|
|
|
+ ", ".join(repr(entry) for entry in malformed_toolsets),
|
|
|
+ )
|
|
|
+ return False
|
|
|
+
|
|
|
# base_url always resolves (it defaults to ChattoHQ), so the only real
|
|
|
# question is whether a token came in.
|
|
|
if resolved.token.value is None:
|
|
|
@@ -4291,6 +4322,7 @@ _CAPABILITY_LABELS = {
|
|
|
"send_typing": "typing indicators",
|
|
|
"create_handoff_thread": "threads",
|
|
|
"create_room": "room creation",
|
|
|
+ "toolsets_for_source": "per-room toolsets (CHATTO_ROOM_TOOLSETS)",
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -4986,6 +5018,33 @@ def _chatto_config_for_tools() -> ChattoConfiguration | None:
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
+def _parse_room_toolsets(
|
|
|
+ entries: Iterable[str],
|
|
|
+) -> tuple[dict[str, list[str]], list[str]]:
|
|
|
+ """Split ``room_id:toolset`` entries into per-room toolset lists.
|
|
|
+
|
|
|
+ Returns the mapping plus the entries that could not be read. Several
|
|
|
+ entries may name the same room; they accumulate in the order given, which
|
|
|
+ is what lets a room keep more than one toolset without inventing a nested
|
|
|
+ separator inside a comma-separated list. Malformed entries are reported
|
|
|
+ rather than raised: startup validation refuses the config, and a running
|
|
|
+ gateway must not die on a typo it can ignore.
|
|
|
+ """
|
|
|
+ by_room: dict[str, list[str]] = {}
|
|
|
+ malformed: list[str] = []
|
|
|
+ for raw in entries:
|
|
|
+ entry = str(raw).strip()
|
|
|
+ room_id, _, toolset = entry.partition(":")
|
|
|
+ room_id, toolset = room_id.strip(), toolset.strip()
|
|
|
+ if not room_id or not toolset:
|
|
|
+ malformed.append(entry)
|
|
|
+ continue
|
|
|
+ toolsets = by_room.setdefault(room_id, [])
|
|
|
+ if toolset not in toolsets:
|
|
|
+ toolsets.append(toolset)
|
|
|
+ return by_room, malformed
|
|
|
+
|
|
|
+
|
|
|
def _room_answers_in(
|
|
|
cfg: ChattoConfiguration | None, room_id: str, kind: RoomKind | None
|
|
|
) -> bool:
|