From c9ab1255efbebe038e0b9b1fa50eb1c11335cde8 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Thu, 23 Jul 2026 17:16:05 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(slack):=20per-agent=20bot=20routing=20?= =?UTF-8?q?seam=20=E2=80=94=20OSS=20half=20of=20ent#222?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edition-agnostic seam that lets the enterprise slack_per_agent_bots module (trinity-enterprise#222) route an inbound Slack event to the agent whose DEDICATED bot received it, and reply through that bot's own token — coexisting with the workspace-level single bot. - adapters/per_agent_bot.py — inert-by-default hook registry (set_resolver / set_token_provider). With no resolver registered (OSS-only, or before the enterprise module loads) every path is a no-op and channel routing is byte-for-byte unchanged. Both hooks FAIL OPEN — an error falls back to normal routing, so a bug can never take Slack offline. - message_router._resolve_agent_and_token — consult the per-agent resolver first; a match wins over the channel binding and uses the per-agent bot token. Falls through to channel routing when no binding / no token. - slack_adapter.parse_message — stamp the RECEIVING bot identity (authorizations[0].user_id + api_app_id + team_id) into message.metadata so the resolver can key on it. Additive; inert for every non-Slack channel. Mirrors the connector seam (#118): OSS owns the seam, the enterprise module owns the policy (it registers the resolver + token provider at startup). 5 unit tests: inert-by-default, resolver reads recipient bot, non-Slack ignored, both hooks fail-open, token provider. The end-to-end path (real Slack event -> per-agent routing) needs a live per-agent Slack app to verify — the recipient-bot extraction from authorizations is the live-verification point. Related to trinity-enterprise#222 Co-Authored-By: Claude Opus 4.8 --- src/backend/adapters/message_router.py | 14 +++++ src/backend/adapters/per_agent_bot.py | 75 +++++++++++++++++++++++ src/backend/adapters/slack_adapter.py | 37 ++++++++--- tests/unit/test_222_per_agent_bot_seam.py | 74 ++++++++++++++++++++++ 4 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 src/backend/adapters/per_agent_bot.py create mode 100644 tests/unit/test_222_per_agent_bot_seam.py diff --git a/src/backend/adapters/message_router.py b/src/backend/adapters/message_router.py index c6a78737c..a8a61403a 100644 --- a/src/backend/adapters/message_router.py +++ b/src/backend/adapters/message_router.py @@ -784,6 +784,20 @@ async def _resolve_agent_and_token( (the caller short-circuits). Both return silently — there is no token to reply with on the no-token path. """ + # ent#222: if the event was received by an agent's DEDICATED Slack bot, + # that agent wins over the channel binding and its own bot token is used + # for the reply. Inert for non-Slack / OSS-only (no resolver registered). + from adapters import per_agent_bot + pab_agent = per_agent_bot.resolve_from_message(message) + if pab_agent: + pab_token = per_agent_bot.get_token(pab_agent) + if pab_token: + logger.debug(f"[ROUTER:{channel}] per-agent bot → agent {pab_agent}") + return pab_agent, pab_token + # Binding without a usable token → fall through to channel routing. + logger.warning(f"[ROUTER:{channel}] per-agent bot for {pab_agent} has no token; " + "falling back to channel routing") + agent_name = await adapter.get_agent_name(message) logger.debug(f"[ROUTER:{channel}] Step 1 - resolved agent: {agent_name}") if not agent_name: diff --git a/src/backend/adapters/per_agent_bot.py b/src/backend/adapters/per_agent_bot.py new file mode 100644 index 000000000..bd46987f7 --- /dev/null +++ b/src/backend/adapters/per_agent_bot.py @@ -0,0 +1,75 @@ +"""Per-agent bot resolution seam (ent#222) — the OSS half of per-agent Slack bots. + +Edition-agnostic and **inert by default**: with no resolver registered (OSS-only +builds, or before the enterprise module loads) every function here is a no-op and +channel routing is byte-for-byte unchanged. The private ``slack_per_agent_bots`` +enterprise module registers a resolver + token provider at startup; from then on +an inbound Slack event received by an agent's *dedicated* bot resolves to that +agent (over the channel binding) and replies through that bot's own token. + +Mirrors the connector-seam pattern (#118): the OSS code owns the seam, the +enterprise module owns the policy. Both hooks fail **open** — any error falls +back to normal channel routing, so a bug here can never take Slack offline. +""" +from __future__ import annotations + +import logging +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +# (team_id, bot_user_id, app_id) -> agent_name | None +_resolver: Optional[Callable[[Optional[str], Optional[str], Optional[str]], Optional[str]]] = None +# agent_name -> per-agent bot token | None +_token_provider: Optional[Callable[[str], Optional[str]]] = None + + +def set_resolver(fn: Optional[Callable]) -> None: + """Install the enterprise resolver. ``None`` disables (returns to inert).""" + global _resolver + _resolver = fn + + +def set_token_provider(fn: Optional[Callable]) -> None: + global _token_provider + _token_provider = fn + + +def is_active() -> bool: + return _resolver is not None + + +def resolve_from_message(message) -> Optional[str]: + """The agent whose dedicated bot RECEIVED this event, or None. + + Reads only the receiving-bot identity the Slack adapter stamps into + ``message.metadata`` (``slack_team_id`` + ``slack_recipient_bot_user_id`` / + ``slack_recipient_app_id``). Non-Slack messages carry none of these, so this + is inert for every other channel. Fail-open: any error → None → normal + channel routing. + """ + if _resolver is None: + return None + md = getattr(message, "metadata", None) or {} + team_id = md.get("slack_team_id") + bot_user_id = md.get("slack_recipient_bot_user_id") + app_id = md.get("slack_recipient_app_id") + if not team_id or not (bot_user_id or app_id): + return None + try: + return _resolver(team_id, bot_user_id, app_id) + except Exception as e: # noqa: BLE001 — never break routing + logger.warning("per-agent bot resolver raised; falling back to channel routing: %s", e) + return None + + +def get_token(agent_name: str) -> Optional[str]: + """The dedicated bot token for a per-agent-routed reply, or None to fall back + to the workspace token. Fail-open.""" + if _token_provider is None or not agent_name: + return None + try: + return _token_provider(agent_name) + except Exception as e: # noqa: BLE001 + logger.warning("per-agent bot token provider raised; falling back: %s", e) + return None diff --git a/src/backend/adapters/slack_adapter.py b/src/backend/adapters/slack_adapter.py index 2a3570307..f3ef854dd 100644 --- a/src/backend/adapters/slack_adapter.py +++ b/src/backend/adapters/slack_adapter.py @@ -109,19 +109,40 @@ def parse_message(self, raw_event: dict) -> Optional[NormalizedMessage]: team_id = raw_event.get("team_id") event_type = event.get("type") + message = None # Handle DM messages if event_type == "message" and event.get("channel_type") == "im": - return self._parse_dm(event, team_id) - + message = self._parse_dm(event, team_id) # Handle @mentions in channels - if event_type == "app_mention": - return self._parse_mention(event, team_id) - + elif event_type == "app_mention": + message = self._parse_mention(event, team_id) # Handle thread replies in bot channels (no @mention needed) - if event_type == "message" and event.get("thread_ts"): - return self._parse_thread_reply(event, team_id) + elif event_type == "message" and event.get("thread_ts"): + message = self._parse_thread_reply(event, team_id) - return None + if message is None: + return None + return self._stamp_recipient_bot(message, raw_event, team_id) + + def _stamp_recipient_bot(self, message, raw_event: dict, team_id): + """ent#222: record WHICH bot received this event so the router can route a + per-agent dedicated bot (over the channel binding). ``authorizations[0] + .user_id`` is the bot the event was delivered to; ``api_app_id`` its app. + Additive metadata — inert unless a per-agent resolver is registered.""" + auths = raw_event.get("authorizations") or [] + recipient_bot = ( + auths[0].get("user_id") if auths and isinstance(auths[0], dict) else None + ) + md = dict(getattr(message, "metadata", None) or {}) + md.update({ + "slack_team_id": team_id, + "slack_recipient_bot_user_id": recipient_bot, + "slack_recipient_app_id": raw_event.get("api_app_id"), + }) + try: + return message.model_copy(update={"metadata": md}) + except Exception: # noqa: BLE001 — never fail parsing over metadata + return message def format_response(self, text: str) -> str: """Convert standard markdown to Slack mrkdwn format. diff --git a/tests/unit/test_222_per_agent_bot_seam.py b/tests/unit/test_222_per_agent_bot_seam.py new file mode 100644 index 000000000..40002b5b2 --- /dev/null +++ b/tests/unit/test_222_per_agent_bot_seam.py @@ -0,0 +1,74 @@ +"""The OSS per-agent bot resolution seam (ent#222). + +The seam is edition-agnostic and inert by default: with no resolver registered, +every path is a no-op and channel routing is unchanged. Once the enterprise +module registers a resolver + token provider, an event received by an agent's +dedicated bot resolves to that agent. Both hooks fail OPEN. +""" +from __future__ import annotations + +import types + +import pytest + +from adapters import per_agent_bot + + +def _msg(metadata=None): + return types.SimpleNamespace(metadata=metadata or {}) + + +@pytest.fixture(autouse=True) +def _reset_hooks(): + per_agent_bot.set_resolver(None) + per_agent_bot.set_token_provider(None) + yield + per_agent_bot.set_resolver(None) + per_agent_bot.set_token_provider(None) + + +def test_inert_by_default(): + assert per_agent_bot.is_active() is False + # A real Slack-shaped message still resolves to nothing with no resolver. + m = _msg({"slack_team_id": "T1", "slack_recipient_bot_user_id": "UBOT"}) + assert per_agent_bot.resolve_from_message(m) is None + assert per_agent_bot.get_token("analytics") is None + + +def test_resolver_reads_recipient_bot_and_returns_agent(): + seen = {} + + def resolver(team_id, bot_user_id, app_id): + seen.update(team_id=team_id, bot_user_id=bot_user_id, app_id=app_id) + return "analytics" if bot_user_id == "U_ANALYTICS" else None + + per_agent_bot.set_resolver(resolver) + m = _msg({"slack_team_id": "T1", "slack_recipient_bot_user_id": "U_ANALYTICS", + "slack_recipient_app_id": "A1"}) + assert per_agent_bot.resolve_from_message(m) == "analytics" + assert seen == {"team_id": "T1", "bot_user_id": "U_ANALYTICS", "app_id": "A1"} + + +def test_non_slack_message_is_ignored(): + per_agent_bot.set_resolver(lambda *a: "should-not-be-used") + # A Telegram/other message carries none of the slack_* metadata → inert. + assert per_agent_bot.resolve_from_message(_msg({"telegram_chat_id": "123"})) is None + assert per_agent_bot.resolve_from_message(_msg(None)) is None + + +def test_resolver_and_token_provider_fail_open(): + def boom(*a): + raise RuntimeError("db down") + + per_agent_bot.set_resolver(boom) + per_agent_bot.set_token_provider(boom) + m = _msg({"slack_team_id": "T1", "slack_recipient_bot_user_id": "UBOT"}) + # Both swallow the error and fall back (None) rather than break routing. + assert per_agent_bot.resolve_from_message(m) is None + assert per_agent_bot.get_token("analytics") is None + + +def test_token_provider_returns_per_agent_token(): + per_agent_bot.set_token_provider(lambda name: "xoxb-analytics" if name == "analytics" else None) + assert per_agent_bot.get_token("analytics") == "xoxb-analytics" + assert per_agent_bot.get_token("other") is None From 2d74b6147fa9e0c80efbefd297f606cef859f584 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Fri, 24 Jul 2026 10:46:38 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(slack):=20Phase=205=20=E2=80=94=20per-?= =?UTF-8?q?agent=20Slack=20bot=20config=20UI=20(ent#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SlackAgentBotPanel: configure an agent's dedicated Slack bot from the Sharing tab, mounted beside the existing workspace SlackChannelPanel. - Entitlement-gated on `slack_per_agent_bots` via the enterprise store, so it is hidden entirely in OSS / unentitled builds — never a blank or broken section. - Paste bot (xoxb-) + app-level (xapp-) tokens; on save the backend validates them against Slack auth.test and the panel then shows the RESOLVED identity (bot name, bot_user_id, team) rather than echoing anything secret. Tokens are write-only — a read never returns them. - Enable/disable without re-entering credentials, replace tokens, and remove. - Refusals surface the backend's NAMED code (wrong token type, bot already bound to another agent, Slack unreachable) instead of a generic failure, so the operator knows what to fix. Verified the component compiles under Vite (HMR, no build errors); the flows themselves still need a live per-agent Slack app to exercise end to end. Related to trinity-enterprise#222 --- src/frontend/src/components/SharingPanel.vue | 3 + .../src/components/SlackAgentBotPanel.vue | 212 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 src/frontend/src/components/SlackAgentBotPanel.vue diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue index b83073287..cfe7ec5ff 100644 --- a/src/frontend/src/components/SharingPanel.vue +++ b/src/frontend/src/components/SharingPanel.vue @@ -169,6 +169,8 @@ :derive-status="slackStatus" > + + + +
+
+
+

+ Dedicated Slack bot +

+

+ Give this agent its own Slack bot identity — its own name and avatar, + directly DM-able and @mention-able, alongside other agents + in the same channel. +

+
+ + {{ status.enabled ? 'Active' : 'Disabled' }} + +
+ +

Loading…

+ + +
+
+ {{ status.bot_name || 'bot' }} + + · {{ status.bot_user_id }} · team {{ status.team_id }} + +
+
+ + + +
+
+ +

+ Not configured — this agent posts under the shared workspace bot. +

+ + +
+ + + +

+ Tokens are validated against Slack and stored encrypted; they are never shown again. +

+
+ +

+ {{ message.text }} +

+
+ + +