You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[feat] Channel limits: choose where an agent answers and whom it answers - #7890
Do not merge. The owner reviews it, and the release conductor picks its release.
Context
An agent owner could not fully choose where a Slack or Telegram agent answers, or whom it answers:
"Who can message it" showed only for Telegram. Slack had the same check, but the screen hid it.
"Add channel" added a channel to the list but did not limit the agent to it.
The people list was stored on the installation. The hosted Slack app has one installation per workspace, so a list set for one agent applied to every agent there.
Slack people were member ids. The owner could not pick them by name.
A refused person got no reply at all, so a refusal looked like a fault.
This implements the channel limits spec (openspec/changes/channel-limits, spec PR #7836). All seven questions were decided by the owner on 2026-10-26.
Changes
Where it answers. The "Answers in" switches stay. Advanced gets a new row, "Channels it answers in" ("Groups it answers in" on Telegram): "All channels the bot is in" or "Only these channels" with a checklist. It is stored as channel_grants allow rules, never a deny for channels:
All channels, both switches on -> no rule (as before)
Only these: #support, DMs on -> allow kind=private, allow space=#support
Only these: #support, DMs off -> deny kind=private, allow space=#support
Only these, nothing checked -> allow (or deny) kind=private only
One function writes the switches and the row together. It creates the new rules before it removes the old ones, so a save never passes through "no rule" (which means "answers everywhere"). The screen reads single-channel allow rules back as chosen. An unchecked channel stays in the list, marked "Off". "Add channel" now has a search field and adds several channels at once.
Whom it answers. "Who can message it" (Everyone or Specific people) shows for Slack too. The list moves from the installation to the agent binding:
The check compares ids only; labels are for display. Slack people are found by name or email from three sources: people who wrote to the bot (stored messages), the workspace list (Slack users.list, loaded in the background and kept 24 hours in Redis, with Refresh), and a live lookup of a full email. New settings-time routes: POST /channels/connections/{id}/people/query (returns {count, people, directory}) and .../people/refresh (returns {count, directory}). Each person has a source: recent_senders, directory or email_lookup. Which sources a platform offers is declared in its capabilities (people: {directory, email_lookup}; Slack declares both). Telegram keeps numeric ids plus "People who wrote to the bot", and now refuses a typed "@name".
The note. A refused person who addressed the agent (a DM, or a mention or command in a channel) gets one short private note, at most once a day per person, place and reason: an ephemeral message in a Slack channel, a normal reply in a DM, nothing in a Telegram group. The inbox worker sends it like today's fixed notices, keyed by a UUID v5 of person, place, reason (sender_not_listed or space_off) and day. A failed send is logged and never retried. A refusal opens no turn, no session, and charges nothing.
Who may change the limits. Only the owner of a private agent changes its people list and rules, with the binding on or off, and only the owner creates a binding that already carries a people list. The check runs inside the same database transaction as the write (the existing guard_agent_activation lock). It also covers moving the default grant and pointing a binding at another agent.
Other. A refused person's message is not attached to its space, so it never reaches the agent as stored context (also while the binding or the place is off). The channel card in /m says "Only N people" and counts chosen channels. The TypeScript client is regenerated from an EE spec. The Slack guide documents the new settings.
Migration oss000000055 (data only): copies each installation's allowed_senders to every binding of that installation that has no audience. The downgrade removes audience. It is chained after main's newest core head (oss000000053); the release conductor sets the final revision number at merge time. test_channel_people_lists_migration.py checks the copy, a second run, and the downgrade on a scratch Postgres.
Rollout. API pods of the previous release read the list from the installation. Until the next release, every people-list change on a binding (create or edit) is also written to the installation's allowed_senders, in the same database transaction, so an old pod sees the change at once. On an installation with several bindings, the last list written applies to all of them on an old pod, which is how old pods always behaved. New pods read only the binding. Remove this write in the next release. One narrow case is left: a connection rename or credential update at the same moment as a people-list save can put the old list back on the installation; only old pods read it, only during the rollout.
Round trips
Allowed messages cost exactly what they cost before. test_channels_limits_counts_postgres.py counts the SQL statements of resolve for one allowed message against real Postgres. The same file run on main (aaa2701) gave the same numbers:
Case
First message
Follow-up
Same as main
No limits
14
10
yes
People list (sender on it)
14
10
yes
Only these channels
13
9
yes
Both
13
9
yes
No added network calls on the message path: the people check reads the agent row already loaded; the place check reads the rules already read. Extra cost appears only on refusals: an addressed refusal reads one note key a day per person and place, and sends at most one note.
Tests
API unit: whole OSS layer 9775 passed, EE layer 10723 passed (rebased on v0.127.1) (new: test_channels_limits.py, test_channels_people.py, slack/test_slack_people_and_ephemeral.py).
API integration on the stack DB: integration/channels 118 passed, including test_channels_people_list_rollout_postgres.py, the count test and the note end to end (test_channels_limits_note_postgres.py: Ben refused with one ephemeral note a day and no turn; his message not stored; Ana answered).
API acceptance on a local EE stack: acceptance/channels and acceptance/sharing together 169 passed, 5 skipped (live Slack/WhatsApp), including the new test_channel_limits_owner.py (a non-owner cannot change a private agent's limits, on or off, cannot create a binding of it with a people list, and cannot move a binding away, edit it and move it back). The people routes are in the permission matrix.
Web: @agenta/settings-ui vitest 247 passed (new channelLimits.test.ts plus panel, Advanced and picker tests); web/mobile tsc passes.
Migration: test_channel_people_lists_migration.py (copy, second run, downgrade) passed on a scratch Postgres.
Live QA
On the final head af9daf1f53, on a local EE stack with a production build of /m, a new account and seeded Slack and Telegram connections (no real Slack or Telegram): every Slack and Telegram cell passed, two quick switch clicks never fall back to all channels, the people picker recovers by itself after failed asks, the card and hub count the chosen channels, the saved rules route a mention in an unchecked channel to a refusal and one in a checked channel to a turn, and a self-managed agent on the ChatGPT subscription still answers. Self-managed Claude was not run: the host Claude login has expired. Details in the QA comment below; the recording is kept locally (the GitHub upload was not available).
Review
After the owner's review, the branch was rebased on v0.127.1 and the review loop ran again on the new head: naming, slop and simplify, three rounds with Opus and Codex, all Low at the end. It renamed the refusal reasons, the picker source values and a few fields (listed in the record below) and made the switches and the channel row share one save. Earlier, the review loop ran on this branch: slop and simplify (Opus and Codex), correctness and security (Codex), three rounds plus a delta check of the last fixes. Final ratings are Low. The record is in a comment below. Fixed along the way: a save that could pass through "no rule", the owner check moving inside the write, the default-move partial write, excluded senders stored while a binding or place is off, a retarget bypass of the owner check, and the people picker client.
Left on purpose:
A space default-agent rule set through the API counts as a chosen channel (the backend counts it that way), so a later save of "Channels it answers in" can remove it. Only the API writes such rules today. Tell me if a save should keep them.
Messages from refused people still reach the agent through the one-time Slack backfill and the channel read tools, as today (the spec keeps "as today").
Switching Channels off and on after "Only these channels" with nothing checked comes back as "All channels" (the screen shows it).
Copy for approval
All new user-facing text is a draft:
Refusal notes: "This agent only answers chosen people. Ask its owner in Agenta to add you." / "This agent does not answer in this channel." / "This agent does not answer direct messages. Mention it in a channel instead."
Advanced: "Channels it answers in" / "Groups it answers in", "Where it answers when mentioned. Direct messages follow the switch above. Channels include group DMs." (Telegram: "Private chats follow the switch above."), "All channels the bot is in" / "Only these channels" (and "groups"), "With no channel checked, the agent answers in no channel.", "A channel the bot joins later stays off until you check it.", "Channels are switched off above, so it answers in none." (Telegram: "Group chats ..."), "The bot is in no channel yet."
Who can message it: "Any Slack member can message the bot.", "Add at least one person below.", "Only N person/people. Everyone else is ignored.", "Name or email in Slack", "Search people in , or type a full email.", "Wrote to ", "In ", "Updated N min ago · Refresh", "Refreshing…", "Loading people from Slack…", "Type a name to search.", "No match.", "By email", "No one in uses this email.", "Could not load people from . Reinstall the Slack app, or type a full email.", "Bot · cannot be added", "Guest", "No longer in Slack", "Until you add someone, answers everyone.", "People who wrote to the bot", "Enter the numeric Telegram ID. A username cannot be matched."
Add channel: "Pick one or more channels. The app joins public channels itself. For a private channel, run /invite in it first.", "Search channels", "No channel matches “”.", "Run /invite first", "Add N channels".
Channel card: "Only N people/accounts", "Answers nowhere".
Errors: "Could not save who can message it.", "Could not search people.", "Could not refresh the people list.", "Could not read who can message it."
Docs: the new section of the Slack guide.
Raised by the naming review, for your call (not changed): "Who can message it" names a send permission, but anyone can still message the bot and the list decides whom it answers ("Who it answers" would match the note). "Only N people. Everyone else is ignored." is the panel's existing line, but people off the list now get a private note. Telegram uses "account", "user ID" and "people" for one person. The guide uses "assistant" and "agent" in one section.
How to review
api/oss/src/core/channels/service.py: resolve_or_refuse (people check after the agent lookup, attach after it), _refusal, _limits_guard and the grant and agent writes.
It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.
Choose which channels or groups an agent can answer in. Unselected channels—including channels joined later—remain off, and multiple channels can be added at once.
Limit an agent’s audience to specific people or allow everyone. Search Slack workspace members by name or email, or add recent senders; Telegram audiences can be set by numeric ID.
Refresh the people directory and send private notices to people who aren’t allowed to message an agent. Notices are limited to once per day for each sender and refusal reason.
Bug Fixes
Private-agent channel settings and access grants are restricted to the agent’s owner.
This change adds per-agent sender audiences, channel-specific reply controls, people-directory search, refusal notifications, ownership checks for private-agent limits, migration support, and corresponding API, settings UI, documentation, and tests.
Changes: Channel controls and people access
Layer / File(s)
Summary
Audience data and ownership guards api/oss/src/core/channels/*, api/oss/src/dbs/postgres/channels/dao.py, api/oss/src/apis/fastapi/channels/router.py, api/oss/databases/postgres/migrations/*
Agent bindings gain audience data. Sender lists remain synchronized with installation data. Agent and grant writes pass ownership and artifact guards. The migration copies existing sender lists into bindings.
People directory and query API api/oss/src/core/channels/people.py, api/oss/src/core/channels/adapters/*, api/oss/src/apis/fastapi/channels/*
The service combines recent senders with cached Slack directory results and email lookup. Query and refresh endpoints expose people results and directory state.
Refusal routing and notifications api/oss/src/core/channels/service.py, api/oss/src/tasks/asyncio/channels/inbox.py, api/oss/src/core/channels/render/render.py
Routing returns addressed sender or space refusals separately from silent outcomes. The dispatcher sends eligible notes and applies daily deduplication.
The settings UI represents channel rules with grants, supports selected-channel behavior, and supports multi-selection when adding discovered channels.
Audience controls in settings web/packages/agenta-settings-ui/src/channels/ChannelPeopleSection.tsx, web/packages/agenta-settings-ui/src/channels/actions.ts, web/packages/agenta-settings-ui/src/channels/types.ts
The UI adds audience selection and people search for Slack and Telegram. It replaces Telegram-only allowed-user actions with audience and people actions.
sequenceDiagram
participant SettingsUI
participant ChannelsRouter
participant ChannelsService
participant ChannelsDAO
participant PeopleModule
participant SlackAdapter
SettingsUI->>ChannelsRouter: Query people with text and IDs
ChannelsRouter->>ChannelsService: query people
ChannelsService->>ChannelsDAO: query recent senders
ChannelsService->>PeopleModule: query people
PeopleModule->>SlackAdapter: list people or find person by email
PeopleModule-->>ChannelsService: people and directory state
ChannelsService-->>ChannelsRouter: people and directory state
ChannelsRouter-->>SettingsUI: people response
Loading
sequenceDiagram
participant InboxDispatcher
participant ChannelsService
participant SlackAdapter
InboxDispatcher->>ChannelsService: resolve_or_refuse event
ChannelsService-->>InboxDispatcher: routing with refusal
InboxDispatcher->>SlackAdapter: post ephemeral refusal note
SlackAdapter-->>InboxDispatcher: delivery result
InboxDispatcher->>InboxDispatcher: record delivery state
Loading
Merge Risk: Moderate · previous review ad0b6
Fix the refusal path before merging so opted-out WhatsApp spaces do not get refusal notes and a refused STOP is still recorded. The oversized sender-list input and the Channels card summary showing destinations the agent does not answer in are small follow-ups.
Pre-merge checks: 4 · 1
Failed checks: 1 warning
Check name
Status
Explanation
Resolution
Docstring Coverage
Docstring coverage is 21.43% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta…
Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed checks: 4 passed
Check name
Status
Explanation
Title check
The title clearly summarizes the main change: adding per-agent limits for where an agent answers and which people it answers.
Linked Issues check
Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check
Check skipped because no linked issues were found for this pull request.
Description check
The description is specific and on-topic. It names the channel-limits feature for Slack and Telegram, the migration oss000000055, the owner checks, the refusal note, the rollout write to `allowed_se…
Full details: Description check
Explanation
The description is specific and on-topic. It names the channel-limits feature for Slack and Telegram, the migration oss000000055, the owner checks, the refusal note, the rollout write to allowed_senders, the test coverage, and the review steps. It matches the changeset in detail.
Full details: Docstring Coverage
Explanation
Docstring coverage is 21.43% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 84 functions across 45 files.
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
Finishing Touches: 💡 1
📝 Generate docstrings: 💡
Commit to this branch
Create a new PR
🧪 Generate unit tests (beta)
Commit to this branch
Create a new PR
Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
Nitpick comments: 1
api/oss/src/core/channels/people.py: 1
188-200: Trivial · Performance & Scalability · Heavy lift
Avoid loading the whole workspace directory on every picker query.
The cached value is one CachedDirectory that holds every workspace member. Each query_people call runs get_cache(model=CachedDirectory). That call reads the full Redis value and validates every ChannelPerson through pydantic. The picker runs on each search keystroke, so a large Slack workspace pays this cost repeatedly on the API event loop.
Options:
Cache the directory in a form that is cheap to filter, such as a lowercase search index or sharded entries.
Keep a short-lived in-process copy of the parsed directory keyed by updated_at.
Debounce or rate-limit the query route.
Measure with a 50k-member fixture before choosing.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 0 remain after this review.
Review loop result for head 4a2c1ef38a (base aaa2701a6f), 3 rounds plus one delta round on the commits after round 3.
Lens
Final rating
Simplify
Low
Slop
Low
Round 1 ran Opus and Codex on each lens. Rounds 2 and 3 alternated the models. The delta round ran Codex on both lenses.
Fixed: rule saves now create new rules before removing old ones, so a save never passes through "no rule" (answers everywhere). The owner check runs inside each grant and agent write. The channel picker keeps its selection by key. A person who left Slack is marked "No longer in Slack" through the normal search. A failed "Channels it answers in" save shows the stored choice again. People polling slows down instead of stopping. Grant guard locks are taken in a fixed order. The people client was regenerated. Left on purpose:post_ephemeral keeps its boolean, for adapters that declare ephemeral messages without sending them. The resolve wrapper stays for its existing callers. Settings-time read counts (the per-connection grants read for the card summary, the grant reads on a default move) stay, since they are not on the message path.
Review loop result for head 4a2c1ef38a (base aaa2701a6f), 3 rounds plus two delta rounds, all with Codex.
Lens
Final rating
Correctness
Low
Security
Low
Fixed: a person excluded by the people list is not stored as context while the binding or the place is off. Moving the default grant checks both agents' owners in its first write, so a refusal leaves the old default in place. Pointing a binding at another agent is a limits write for that agent, which closes a move-away, edit, move-back bypass of the owner check. A reference that resolves to no agent fails closed. Saving DMs off with no channel checked no longer reads as "answers everywhere". Left on purpose: messages from refused people still reach the agent through the one-time Slack backfill and the channel read tools, as today (the spec keeps this). Switching Channels off and on after "Only these channels" with nothing checked comes back as "All channels" (a three-step case that the screen shows). During a rollout, old pods enforce the installation list and new pods the binding list.
Dependent tests finished. Post /preview for manual review. Environment absence verified; retained storage requires separate provider accounting.
Post /preview as a new comment to request a one-hour preview. Repeating it while active does not extend the hour.
Preview data is disposable. Expiry is enforced by the next successful cleanup sweep, not an exact timer.
Environment deletion stops its compute. Provider-retained storage and permanent services may still be billed.
The reason will be displayed to describe this comment to others. Learn more.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
⚠️ Outside diff range comments (1)
Major · Attach events only after all refusal checks. · service.py:2280-2287
api/oss/src/core/channels/service.py:2280-2287 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Attach events only after all refusal checks.
attach_event_to_space commits the event’s space_id before the inactive-place, DENY-grant, and restricted-unmatched-grant returns. The dispatcher sends the refusal note and returns without undoing that attachment. Move the call below those returns and immediately before policy = resolve_policy(...).
Suggested fix
- # the ingress wrote this row before any space existed; attach it before- # the remaining refusal paths, so an unanswered message still belongs- # to its space- await self.channels_dao.attach_event_to_space(- project_id=project_id,- event_id=event.id,- space_id=space.id,- )-
if not answers_here:
return ChannelRouting()
@@
# zero grants anywhere for this agent: unrestricted
+ await self.channels_dao.attach_event_to_space(+ project_id=project_id,+ event_id=event.id,+ space_id=space.id,+ )+
channel_defaults = _channel_defaults(capabilities)
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 0 remain after this review.
The reason will be displayed to describe this comment to others. Learn more.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
⚠️ Outside diff range comments (1)
Minor · Publish audience as optional but non-nullable. · dtos.py:733-737
api/oss/src/core/channels/dtos.py:733-737 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Publish audience as optional but non-nullable.
ChannelAgentDataEdit generates audience?: ChannelAgentAudience | null, but the reachable edit_channel_agent route validates that field and rejects explicit null. A generated-client caller can therefore send a type-valid value and receive a validation error. Preserve omission as “keep the stored audience,” and regenerate the client after changing the API definition.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 0 remain after this review.
🤖 The AI agent says:Live QA on a local EE stack built from this branch (4a2c1ef38a for the UI journey; later commits are a CI fix, a validator fix and a one-line UI fix). No real Slack or Telegram was used: a Slack connection ("Acme QA") and a Telegram bot were seeded in the local database in place of the platform installs, with two people who "wrote to the bot" on each. The QA agent used a fresh account and a production build of /m.
Cell
Result
Slack "Who can message it": Specific people shows "Wrote to @Support-Bot" (Ana, Ben) and "Could not load people from Acme QA" (no real Slack); add Ana; "Only 1 person"; kept after reload; remove Ana returns to Everyone
Pass
Advanced "Channels it answers in": Only these channels, check support, Save; random shows Off; kept after reload; All channels clears Off
Pass
Add channel: search field shown; the list fails to load without a real Slack and shows an error, no crash
Pass
Telegram: "@Tara" refused with the numeric-id message; People who wrote to the bot lists Tara and Omar; add Tara and 12345; "Only 2 accounts"
Pass
Agent card: Slack "DMs and 1 channel", Telegram "DMs and 2 groups · Only 2 accounts"
Routing on those saved rows: a mention in #random is refused with reason place_off (note sent); a mention in #support is answered
Pass
Self-managed agent (ChatGPT subscription through Pi, no connection slug) answers a turn
Pass
Self-managed with a slug: the run says "No subscription connection named 'chatgpt'" (no signed-in connection in the project, as before)
Pass
Self-managed Claude
Not run: the host Claude login has expired and needs a human sign-in
Found and fixed during QA: the Telegram id error stayed on screen after picking a person from the list (9918bf0811).
Seen and not caused by this change: the Turbopack dev server of /m served broken client modules ("module factory is not available") on the home and agent pages of this stack; a production build has no such error.
The recording (about 3 minutes, the Slack and Telegram cells) is kept locally; the browser upload to GitHub was not available in this session.
refresh awaits actions.refreshPeople, which can return loading. A search that started before the refresh can then resolve afterward because searchSeq is unchanged. Its accepted response calls setDirectory(result.directory), which can replace loading with cached results and stop polling. Stale people can remain visible.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 2 remain after this review.
Commits after 4a2c1ef: b4115dd895 passes the limits guard to the agent write only when present (CI fake compatibility, no behavior change), 24945c0c03 validator fix (people list drops nulls and repeats, at most 1000 ids), 9918bf0811 clears a Telegram id error on pick (one line).
Deploy steps: migration oss000000055 (data only); its revision order is fixed at merge time.
Not covered: self-managed Claude (host login expired, needs a human sign-in); the MP4 is kept locally.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.
Review loop result for head 441578ab75, a delta review of the commits after 4a2c1ef38a (the earlier record covers the rest). Two rounds: Codex on both lenses, then Codex on both lenses plus Opus on slop.
Lens
Final rating
Simplify
Low
Slop
Low
Fixed: a binding created switched off with a people list is now an owner-only limits write. During the rollout every people-list change is also written to the installation's allowed_senders, the field the previous release reads, in the same database transaction as the binding (round 1 found that a separate write could leave the two lists apart after a failed save). New tests: the create case (unit and acceptance), the rollout write on Postgres, and a migration test of oss000000055 (copy, second run, downgrade). Left on purpose: the migration test uses the existing scratch-Postgres pattern (localhost:5444, skipped when none is reachable), like the tracing migration test; it ran on a scratch Postgres for this change. The guard and installation arguments are passed to the DAO only when set, because a test fake of the sharing suite rejects unknown arguments.
Review loop result for head 441578ab75, a delta review of the commits after 4a2c1ef38a (the earlier record covers the rest). Codex security: Low, no findings. The correctness items in this range (the two-write rollout gap) were found by the slop and simplify lenses and fixed in the same transaction.
Lens
Final rating
Correctness
Low
Security
Low
Fixed: a non-owner can no longer create a switched-off binding of someone else's private agent with a people list already set. Left on purpose: nothing new.
Review loop result for head aceff694e4. The slop and simplify record for 441578ab75 stands; the commits since then are three small correctness fixes (no limit on stored people lists, connection-first lock order in binding writes and in archiving), each covered by the existing channels tests.
Review loop result for head aceff694e4, a delta review of the commits after 4a2c1ef38a. Security: Low (no findings). Correctness: three rounds with Codex, stopped at the round limit.
Lens
Final rating
Correctness
Medium (one narrow rollout race left, below)
Security
Low
Fixed: owner-only people list when creating a binding; the installation list written in the same transaction as the binding; no size limit on stored people lists (a stored list longer than a limit would stop loading); the connection row is locked before the agent's artifact in binding writes and before the bindings when archiving, the order a restore uses. Left on purpose: a connection edit (a rename or a credential update) that runs at the same moment as a people-list save can write the old allowed_senders back on the installation. Only pods of the previous release read that field, only during the rollout, and the binding list that new pods read stays right. The rollout write goes away in the next release, so I did not add machinery for it.
Review loop result for head ad0b64dd57 (base a77e503782, main at v0.127.1), 3 rounds.
Lens
Round 1
Round 2
Round 3 (final)
Naming
Opus Medium, Codex Medium
Codex Low
Opus Low
Fixed: refusal reasons sender_not_listed and space_off (the domain word is space); one name for the owner-check argument (guard_artifact_ids) on agent and grant writes; people-picker support declared in the capabilities (people: {directory, email_lookup}) instead of an adapter attribute; ChannelPerson.source values recent_senders | directory | email_lookup; post_ephemeral(recipient_id); spaceIds and audienceCount on the screen side; the outbox turn_id comment lists the note marker; /people/refresh returns {count, directory}; the story fixture says audience. The client is regenerated. Left on purpose:guard_agent_activation keeps its name (a shared sharing-module function; its docstring says it also guards limits). installation_senders names a rollout-only write that goes away next release. For the owner (copy, not changed): "Who can message it" against whom it answers, "Everyone else is ignored" now that refused people get a note, the Telegram words "account"/"user ID"/"people", and "assistant"/"agent" in the guide. Listed in the PR body.
Review loop result for head ad0b64dd57 (base a77e503782, main at v0.127.1), 3 rounds.
Lens
Round 1
Round 2
Round 3 (final)
Slop
Opus Low, Codex Medium
Opus Low
Codex Low
Simplify
Opus Low, Codex High
Codex Low
Opus Low
Fixed: the switches and the "Channels it answers in" row share one save state, so two saves can no longer overlap and leave every channel open; the picker keeps polling after a failed ask; a repeat refusal note costs one read (the key is checked before the connection); the rule diff computes each stored row's key directly; _limits_guard takes references; an id typed twice in one entry is added once; rename leftovers. Tests added for each. Left on purpose: one SQL statement for the rollout write (the edit path has no connection id; the subquery keeps the lock order; the write goes away next release); _grant_limits_guard reads the grant again (settings time); the people picker stays on component state like the rest of the channels panel rather than moving to TanStack Query; a rename racing an owner's people-list save on an inactive private binding (every agent edit writes the whole data block it read, a pre-existing pattern). For the owner: a space default-agent rule set through the API counts as a chosen channel, so a later save of the channel row can remove it. Listed in the PR body.
The last two commits after round 3 are a story fixture rename and the one-line duplicate-id fix with its test.
Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
Review loop result for head af9daf1f53. The record for ad0b64dd57 (posted above) stands for these lenses. The two commits since then fix three CodeRabbit items (no refusal note to an opted-out person, or for STOP/START where the platform has consent words; a 1000-person cap on create and edit requests only, stored lists stay readable; both channel summaries count the chosen channels), each with a test. A Codex slop delta review of those commits rated Low after one fix (the consent-word guard now applies only where the platform declares consent words).
Review loop result for head af9daf1f53. The record for ad0b64dd57 (posted above) stands for these lenses. The two commits since then fix three CodeRabbit items (no refusal note to an opted-out person, or for STOP/START where the platform has consent words; a 1000-person cap on create and edit requests only, stored lists stay readable; both channel summaries count the chosen channels), each with a test. A Codex slop delta review of those commits rated Low after one fix (the consent-word guard now applies only where the platform declares consent words).
API unit OSS 9775 and EE 10723 passed; channels integration 118 passed; channels and sharing acceptance 169 passed, 5 skipped; settings-ui vitest 247 passed; mobile types pass; migration test passed on a scratch Postgres
Changes since the live QA head are renames from the naming review (refusal reasons, picker source values, field names, people support in the capabilities), one shared save state for the switches and the channel row, the owner rule on creating a binding, the rollout write of the installation list, lock order, and the CodeRabbit fixes. Each has unit or integration tests; the screen journeys were not rerun live.
Deploy steps: migration oss000000055 (data only), chained after oss000000053; the release conductor sets the final revision number at merge time.
Not covered: self-managed Claude (the host Claude login has expired; needs a human sign-in); the QA recording is kept locally.
🤖 The AI agent says:Live QA on the final head af9daf1f53, on a local EE stack with a production build of /m and a new account. No real Slack or Telegram was used: a Slack connection ("Acme QA") and a Telegram bot were seeded in the local database in place of the platform installs.
Cell
Result
Slack "Who can message it": add Ana from "Wrote to @Support-Bot", "Only 1 person", kept after reload, remove Ana returns to Everyone
Pass
Advanced "Channels it answers in": Only these channels, support checked; random "Off", support "Mentions only"; kept after reload; card says "DMs and 1 channel"
Pass
Two quick switch clicks (both orders): the second click is ignored while the first save runs; the channel choice never falls back to "All channels the bot is in", and random never reads "Mentions only"
Pass
People picker recovers after failed asks: with the workspace list loading, the network went offline for 9 seconds, then back; the picker showed the list within 12 seconds with no reload, and "cara" found Cara Diaz
Pass
Add channel: search field shown; the list fails without a real Slack and shows an error, no crash
Pass
Telegram: "@Tara" refused with the numeric-id message; Tara added from "People who wrote to the bot" (the error clears); "12345, 12345" adds one row; "Only 2 accounts"
Pass
Agent card and Publish hub: Slack "DMs and 1 channel", Telegram "DMs and 2 groups · Only 2 accounts"
Pass
Saved rows: Slack allow private + allow space=support; Telegram audience.senders = [8883745180, 12345], and the same list written to the installation for the rollout
Pass
Routing on those rows: a mention in #random is refused (space_off), a mention in #support is answered
Pass
Self-managed agent on the ChatGPT subscription, no connection slug: answers a turn
Pass
Self-managed with a slug: "No subscription connection named 'chatgpt'" (no signed-in connection, as before)
Pass
Self-managed Claude
Not run: the host Claude login has expired
Seen and not changed: the agent card truncates a long line ("Only 2 accounts" is cut off at normal width); the Add channel error is the generic one when Slack discovery fails, and "No new channels." shows under it; the panel's sticky footer can cover the last row's buttons. These come from existing screens. No console errors from this feature.
The recording (about 3 minutes) is kept locally; the browser upload to GitHub was not available in this session.
API unit OSS 9775 and EE 10723 passed; channels integration 118 passed; channels and sharing acceptance 169 passed, 5 skipped; settings-ui vitest 247 passed; mobile types pass; migration test passed on a scratch Postgres
Deploy steps: migration oss000000055 (data only), chained after oss000000053; the release conductor sets the final revision number at merge time.
Not covered: self-managed Claude (the host Claude login has expired; needs a human sign-in); the QA recording is kept locally.
A search answer that arrived after Refresh could put the old directory state
back and stop the polling. Refresh now moves the search counter, so that late
answer is dropped.
🤖 The AI agent says: Merge queue for v0.127.2. This answers the three CodeRabbit items that sat outside the diff, so they had no thread.
Attach events only after all refusal checks (service.py, Major). Not changed. Only the sender check comes before the attach, because a person who is not on the list must not become stored context. A message in a place that is off, or under a deny rule, still belongs to its space as it does today, so the agent can read it later. This follows the spec.
audience optional but not nullable (dtos.py, Minor). Not changed. references and tools on the same edit model use the same pattern: the type allows null and the validator refuses it with a clear message. Changing only audience would make the three fields differ, and it needs a client regenerated from an EE spec.
Refresh does not stop older searches (ChannelPeopleSection.tsx, Minor). Fixed in 63910f1cb3. Refresh now moves the search counter, so a search answer that arrives after Refresh is dropped. The settings-ui unit tests pass.
I also merged release/v0.127.2 into this branch (c9f7daf1ab, no conflicts). The migration oss000000055 stays the single new head after oss000000053.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
An agent owner could not fully choose where a Slack or Telegram agent answers, or whom it answers:
This implements the channel limits spec (
openspec/changes/channel-limits, spec PR #7836). All seven questions were decided by the owner on 2026-10-26.Changes
Where it answers. The "Answers in" switches stay. Advanced gets a new row, "Channels it answers in" ("Groups it answers in" on Telegram): "All channels the bot is in" or "Only these channels" with a checklist. It is stored as
channel_grantsallow rules, never a deny for channels:One function writes the switches and the row together. It creates the new rules before it removes the old ones, so a save never passes through "no rule" (which means "answers everywhere"). The screen reads single-channel allow rules back as chosen. An unchecked channel stays in the list, marked "Off". "Add channel" now has a search field and adds several channels at once.
Whom it answers. "Who can message it" (Everyone or Specific people) shows for Slack too. The list moves from the installation to the agent binding:
The check compares ids only; labels are for display. Slack people are found by name or email from three sources: people who wrote to the bot (stored messages), the workspace list (Slack
users.list, loaded in the background and kept 24 hours in Redis, with Refresh), and a live lookup of a full email. New settings-time routes:POST /channels/connections/{id}/people/query(returns{count, people, directory}) and.../people/refresh(returns{count, directory}). Each person has asource:recent_senders,directoryoremail_lookup. Which sources a platform offers is declared in its capabilities (people: {directory, email_lookup}; Slack declares both). Telegram keeps numeric ids plus "People who wrote to the bot", and now refuses a typed "@name".The note. A refused person who addressed the agent (a DM, or a mention or command in a channel) gets one short private note, at most once a day per person, place and reason: an ephemeral message in a Slack channel, a normal reply in a DM, nothing in a Telegram group. The inbox worker sends it like today's fixed notices, keyed by a UUID v5 of person, place, reason (
sender_not_listedorspace_off) and day. A failed send is logged and never retried. A refusal opens no turn, no session, and charges nothing.Who may change the limits. Only the owner of a private agent changes its people list and rules, with the binding on or off, and only the owner creates a binding that already carries a people list. The check runs inside the same database transaction as the write (the existing
guard_agent_activationlock). It also covers moving the default grant and pointing a binding at another agent.Other. A refused person's message is not attached to its space, so it never reaches the agent as stored context (also while the binding or the place is off). The channel card in
/msays "Only N people" and counts chosen channels. The TypeScript client is regenerated from an EE spec. The Slack guide documents the new settings.Migration
oss000000055(data only): copies each installation'sallowed_sendersto every binding of that installation that has noaudience. The downgrade removesaudience. It is chained after main's newest core head (oss000000053); the release conductor sets the final revision number at merge time.test_channel_people_lists_migration.pychecks the copy, a second run, and the downgrade on a scratch Postgres.Rollout. API pods of the previous release read the list from the installation. Until the next release, every people-list change on a binding (create or edit) is also written to the installation's
allowed_senders, in the same database transaction, so an old pod sees the change at once. On an installation with several bindings, the last list written applies to all of them on an old pod, which is how old pods always behaved. New pods read only the binding. Remove this write in the next release. One narrow case is left: a connection rename or credential update at the same moment as a people-list save can put the old list back on the installation; only old pods read it, only during the rollout.Round trips
Allowed messages cost exactly what they cost before.
test_channels_limits_counts_postgres.pycounts the SQL statements ofresolvefor one allowed message against real Postgres. The same file run on main (aaa2701) gave the same numbers:No added network calls on the message path: the people check reads the agent row already loaded; the place check reads the rules already read. Extra cost appears only on refusals: an addressed refusal reads one note key a day per person and place, and sends at most one note.
Tests
test_channels_limits.py,test_channels_people.py,slack/test_slack_people_and_ephemeral.py).integration/channels118 passed, includingtest_channels_people_list_rollout_postgres.py, the count test and the note end to end (test_channels_limits_note_postgres.py: Ben refused with one ephemeral note a day and no turn; his message not stored; Ana answered).acceptance/channelsandacceptance/sharingtogether 169 passed, 5 skipped (live Slack/WhatsApp), including the newtest_channel_limits_owner.py(a non-owner cannot change a private agent's limits, on or off, cannot create a binding of it with a people list, and cannot move a binding away, edit it and move it back). The people routes are in the permission matrix.@agenta/settings-uivitest 247 passed (newchannelLimits.test.tsplus panel, Advanced and picker tests);web/mobiletsc passes.test_channel_people_lists_migration.py(copy, second run, downgrade) passed on a scratch Postgres.Live QA
On the final head
af9daf1f53, on a local EE stack with a production build of/m, a new account and seeded Slack and Telegram connections (no real Slack or Telegram): every Slack and Telegram cell passed, two quick switch clicks never fall back to all channels, the people picker recovers by itself after failed asks, the card and hub count the chosen channels, the saved rules route a mention in an unchecked channel to a refusal and one in a checked channel to a turn, and a self-managed agent on the ChatGPT subscription still answers. Self-managed Claude was not run: the host Claude login has expired. Details in the QA comment below; the recording is kept locally (the GitHub upload was not available).Review
After the owner's review, the branch was rebased on v0.127.1 and the review loop ran again on the new head: naming, slop and simplify, three rounds with Opus and Codex, all Low at the end. It renamed the refusal reasons, the picker source values and a few fields (listed in the record below) and made the switches and the channel row share one save. Earlier, the review loop ran on this branch: slop and simplify (Opus and Codex), correctness and security (Codex), three rounds plus a delta check of the last fixes. Final ratings are Low. The record is in a comment below. Fixed along the way: a save that could pass through "no rule", the owner check moving inside the write, the default-move partial write, excluded senders stored while a binding or place is off, a retarget bypass of the owner check, and the people picker client.
Left on purpose:
Copy for approval
All new user-facing text is a draft:
How to review
api/oss/src/core/channels/service.py:resolve_or_refuse(people check after the agent lookup, attach after it),_refusal,_limits_guardand the grant and agent writes.api/oss/src/tasks/asyncio/channels/inbox.py:_send_refusal_note.api/oss/src/core/channels/people.pyand the Slack adapter'spost_ephemeral,list_people,find_person_by_email.web/packages/agenta-settings-ui/src/channels/actions.ts:behaviorFromGrants,rulesForBehavior,writeBehavior; thenChannelPeopleSection.tsx,ChannelAdvancedSection.tsx,ChannelManagePanel.tsx.