feat(telegram): group multi-agent rooms — turn-taking + loop guard + speaker tags (#30)#59
Conversation
…speaker tags (#30) Let several persona-bots share one Telegram group without the raw misbehaviour: every bot answering every message, and bots looping replies at each other. Three behaviours, all in the Telegram channel plus a thin agent hook: - Respond-gate: in a group a bot replies only when addressed (@mentioned or replying to one of its messages). Otherwise it stays silent but still records the turn — process(respond=False) folds it into the trailing user turn — so it has the full conversation in context the next time it is addressed. This is also the cost control (one message no longer fans out to N inferences). - Loop guard: a bot never replies to another bot's message (ignore_bots), though it still records it, so two assistants can't loop. - Speaker tagging: every recorded message carries a [from <author>] prefix ((bot) for bots). All participants share one conversation per bot, keyed by the group rather than the sender, so a persona is never confused about who spoke. Alternation safety net: silent recording produces consecutive user turns, which Anthropic rejects. llm.generate now coalesces adjacent user messages right before the API call — one place, both providers, both history modes. Config: channels.telegram.group_chat (enabled / reply_when_addressed_only / ignore_bots), inherited by per-persona bots. Needs the bot's Telegram privacy mode OFF to receive the unaddressed messages that feed the shared context. Drive-by: drop an extraneous f-prefix in test_markdown_tg.py so `ruff check .` is clean.
Addresses findings from a multi-lens review of the group-room change: - Addressing precision: a bot now decides "addressed to me" from Telegram's entities (exact @handle, bot_command suffix, text-mention, reply-to) instead of a loose substring. Stops a sibling bot whose @username overlaps another's (@coach vs @coachbot, @coachbot vs @coachbotpro) or an email/URL from making the wrong bot reply — which would re-open the N×-reply storm the gate exists to close. Entity text is read UTF-16-safely via parse_entity; the bare substring match is now a boundary-anchored last resort, used only when Telegram supplied no entities. - Whitelist vs. recording: the allowed_user_ids gate now applies to the reply/act path only. A record-only turn (respond=False) writes to history but runs no inference and takes no action, so it bypasses the gate — otherwise, under the recommended whitelist config, the loop guard never recorded other bots and cross-speaker tagging never happened. - Opt-in default: group_chat.enabled now defaults False (matching topics_enabled), so existing single-bot groups are unchanged on upgrade. A genuine forum topic under topics_enabled is exempt from the group gate, so a bound per-topic persona still replies without an @mention. - Session safety: the silent record refuses to fold into a structured (list-content) session message, so a concurrent record can't corrupt an in-flight Anthropic tool_result turn. The folded run is also length-capped so a busy never-addressed group can't grow one history row without bound; a ponytail note marks the residual non-locked read-modify-write ceiling. Tests extended for prefix-overlap / email / command / topic-exempt addressing, the whitelist-bypass record path, the session fold guard, the fold cap, and the silent-then-reply alternation safety net. Docs/config updated for the opt-in default and the upgrade/topics/`/new@bot` notes. Full suite green (576).
2599395 to
9719e4d
Compare
Second-review follow-ups: - _entity_text used parse_entity for every entity, but a photo/document caption has no .text and parse_entity raises there — the bare except dropped to the code-point slice, the exact UTF-16-unsafe path the helper exists to avoid, so a caption @mention after an emoji was silently treated as not-addressed. Pick parse_caption_entity for caption-only messages. Regression test added. - Make the silent-fold cap comment honest: a fresh turn only ages out via windowing in injection mode; in session mode it persists until the next reply compacts, so a sustained never-addressed flood can bloat the session — acceptable behind the opt-in flag, with a per-chat record budget noted as the upgrade path if a real room shows write abuse. Full suite green (596).
|
Update — rebased onto current Reconciled with #53 (now merged).
Review hardening folded in (two adversarial passes): entity-based addressing (exact CI green; |
#30) The group-room toggles were config-only. Surface them in the admin UI's Telegram channel editor alongside the topics toggle: - "Group multi-agent rooms" (group_chat.enabled), - "Reply only when addressed" (reply_when_addressed_only), - "Never reply to other bots" (ignore_bots). The save round-trips through the config store's dotted keys (channels.telegram.group_chat.*), which _unflatten reconstructs into GroupChatConfig with pydantic defaults filling any unset sub-option. Render context defaults the absent keys to the model defaults (feature off, sub-options on). Tests: wizard partial renders the toggles; a ConfigStore round-trip proves the nested keys reconstruct GroupChatConfig.
…ed (#30) The "Bot token is required." guard in saveTelegram() fired even when the token lives in the infra vault — there the field is rendered readonly+empty, so a typed token is neither present nor needed (the backend keeps the existing ${vault:} ref). This made the topics and new group_chat toggles unsavable once the token was vaulted. Skip the required-check when the field is readonly; the backend already leaves the vault ref untouched and persists the other settings. Test: POSTing /channels/telegram with an empty token but a vaulted ref present returns 200 (not 400) and persists the group_chat keys, leaving the ref intact.
Closes #30. Depends on #29 (bot-per-persona), already merged.
What
Lets several persona-bots share one Telegram group without the raw misbehaviour the issue calls out: every bot answering every message, and bots looping replies at each other. The infrastructure already exists (#29 runs each persona-bot as its own polling channel; all receive the group's messages) — this PR adds only the behaviour layer.
The three required behaviours from the issue:
/cmd@botcommand, a text-mention, or a reply to one of its own messages. Otherwise it stays silent but still records the turn for context, so it sees the full conversation the next time it is addressed. No persona/preamble/LLM runs on the silent path, so this is also the cost control (a group message no longer fans out to N inferences).ignore_bots); the message is still recorded (tagged) for context, so two assistants can't loop.[from <author>]prefix ((bot)for bots). All participants share one conversation per bot, keyed by the group rather than the sender, so a persona is never confused about who said what. The existing[reply_to]prefix was the seed.How
channels/telegram.py— group detected viachat.type; addressing decided from Telegram entities (exact handle /bot_commandsuffix / text-mention / reply-to), with a boundary-anchored substring match only as an entity-less last resort. Entity text is read UTF-16-safely viaparse_entity. Theallowed_user_idswhitelist gates the reply/act path only — record-only turns (no inference, no action) are still recorded, so the loop guard and cross-speaker tags work under a whitelist. For groups the conversation is keyed by the group id; the real sender drives the whitelist check and the speaker tag.core/agent.py—process(respond=False)records the inbound message (folded into the trailing user turn) and returns empty, running no LLM. The folded run is length-capped so a busy never-addressed group can't grow one history row without bound.core/llm.py—generate()coalesces consecutive same-role user turns right before the API call (both providers). Silent recording can produce consecutive user turns and Anthropic requires strict alternation; this is the one-place safety net.core/config.py—GroupChatConfig(enabled/reply_when_addressed_only/ignore_bots) nested underTelegramConfig.group_chat, inherited by per-persona bots.Scope / decisions
topics_enabled, so existing single-bot groups are unchanged on upgrade. Enabling it re-keys a group's history from per-sender to per-group (no migration; prior group history is left in place, not carried forward).topics_enabledis exempt from the group gate — a bound per-topic persona still replies without an @mention.Relationship to #36 / PR #53
#30 and #36 overlap. PR #53 (
feat/reply-decision, open) adds the optional LLM relevance gate + per-chat rate cap for #36. This PR implements the deterministic addressing gate + loop guard + speaker tagging onmain. They're complementary (the issue lists the relevance check as the optional rung of the same respond-gate), but both touch group-reply behaviour, so they'll want a small reconciliation when both land. Flagging for the manual review.Review
Built and verified through a multi-lens adversarial review; the confirmed findings are folded into the second commit (addressing precision against prefix-overlapping bot names, the whitelist-vs-recording fix, the opt-in default + topics exemption, and the session/
tool_resultfold guard).Tests
tests/test_group_chat.py(new) covers the coalescer, the silent-record fold (both history modes), addressing precision (mention / command / text-mention / reply / prefix-overlap both directions / email / entity-less boundary), routing (respond-gate, loop guard, gate-disabled, topic-exempt), the whitelist-bypass record path, the sessiontool_resultfold guard, the fold cap, and the silent-then-reply alternation safety net. Full suite green (uv run pytest— 576 passed).Docs: README bullet,
docs/.../channels.mdx"Group rooms" section (with privacy-mode + upgrade + topics +/new@botnotes),config.yml.example.