Skip to content

feat(telegram): group multi-agent rooms — turn-taking + loop guard + speaker tags (#30)#59

Merged
mattmezza merged 5 commits into
mainfrom
feat/group-multi-agent
Jun 29, 2026
Merged

feat(telegram): group multi-agent rooms — turn-taking + loop guard + speaker tags (#30)#59
mattmezza merged 5 commits into
mainfrom
feat/group-multi-agent

Conversation

@mattmezza

Copy link
Copy Markdown
Owner

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:

  • Respond-gate — in a group a bot replies only when addressed: @mentioned, a /cmd@bot command, 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).
  • Loop guard — a bot never replies to a message authored by another bot (ignore_bots); the message is still recorded (tagged) for context, 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 said what. The existing [reply_to] prefix was the seed.

How

  • channels/telegram.py — group detected via chat.type; addressing decided from Telegram entities (exact handle / bot_command suffix / text-mention / reply-to), with a boundary-anchored substring match only as an entity-less last resort. Entity text is read UTF-16-safely via parse_entity. The allowed_user_ids whitelist 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.pyprocess(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.pygenerate() 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.pyGroupChatConfig (enabled / reply_when_addressed_only / ignore_bots) nested under TelegramConfig.group_chat, inherited by per-persona bots.

Scope / decisions

  • Opt-in (off by default), matching 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).
  • A genuine forum topic under topics_enabled is exempt from the group gate — a bound per-topic persona still replies without an @mention.
  • Telegram-only (WhatsApp uses a single number, so multi-bot rooms don't apply). The autonomous "bots debate each other" loop is out of scope, per the issue.
  • Requires the bots' Telegram privacy mode OFF (via BotFather) to receive the unaddressed messages that feed the shared context — documented.

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 on main. 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_result fold 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 session tool_result fold 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@bot notes), config.yml.example.

…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).
@mattmezza mattmezza force-pushed the feat/group-multi-agent branch from 2599395 to 9719e4d Compare June 29, 2026 13:54
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).
@mattmezza

Copy link
Copy Markdown
Owner Author

Update — rebased onto current main and hardened after review.

Reconciled with #53 (now merged). feat/reply-decision (#36) landed in main after this PR opened. Rebased onto it; the two gates compose cleanly and coexist (full suite green):

Review hardening folded in (two adversarial passes): entity-based addressing (exact @handle / bot_command suffix / text-mention / reply-to; UTF-16-safe via parse_entity/parse_caption_entity, including photo captions; boundary-anchored entity-less fallback) so a sibling bot with an overlapping name / an email / a caption-emoji mention never mis-fires; whitelist gates the reply path while record-only turns still feed the loop guard + tags; group_chat.enabled defaults off (opt-in) with a forum-topic exemption; session tool_result fold guard + a length-capped silent fold.

CI green; uv run pytest 596 passing locally; ruff check . clean.

#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.
@mattmezza mattmezza merged commit d988d2e into main Jun 29, 2026
1 check passed
@mattmezza mattmezza deleted the feat/group-multi-agent branch June 29, 2026 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Group multi-agent rooms — turn-taking + loop guard

1 participant