Skip to content

feat(slack): UI + API to change the DM-default agent per workspace #584

Description

@pavshulin

Summary

When a Slack workspace has multiple agents bound to it, DMs to the bot are routed to a single "DM-default" agent. Today there is no way to choose or change which agent that is from the UI or any API — the flag is auto-set on the first agent ever connected to the workspace and is permanent unless an admin edits the SQLite database directly.

Current Behavior

The DM router (adapters/slack_adapter.py:201-240) uses this priority:

  1. Channel binding (channel_id → agent) — works for non-DM channel messages
  2. DM default agent (is_dm_default = 1) — used when message is a DM
  3. Single connected agent fallback — works only if exactly one agent in workspace
  4. Legacy slack_link_connections lookup
  5. None

The is_dm_default flag on slack_channel_agents is set automatically in three places (all use is_first = len(agents_in_workspace) == 0):

Location When
routers/slack.py:186 OAuth callback for first agent connected to a workspace
routers/slack.py:296 First agent bound via POST /api/agents/{name}/public-links/{link_id}/slack/connect
routers/slack.py:474 First agent bound via POST /api/agents/{name}/slack/channel

After that point, no setter exists. SlackChannelPanel.vue:33 only renders (DM default) as a label — no toggle, no dropdown.

Why This Matters

  • Most common scenario — owner connects agent A first to test, later adds agent B/C/D. All workspace DMs continue going to agent A even when A isn't the right destination.
  • Operational pain — only workaround is docker exec trinity-backend python3 -c "..." against /data/trinity.db, run by an operator with shell access. There's no audit trail and no operator-facing affordance.
  • Unbinding leaves zero defaultsunbind_slack_agent deletes the row but doesn't promote anyone. Workspace ends up with no DM default; routing falls to step 3, which only works with exactly one remaining agent.

Proposed Scope

1. DB layer (db/slack_channels.py)

Add a single-transaction setter:

def set_slack_dm_default(self, team_id: str, agent_name: str) -> bool:
    """
    Make agent_name the DM-default for the workspace.
    Returns True if a row was updated, False if agent_name isn't bound there.
    """
    with get_db_connection() as conn:
        cur = conn.cursor()
        cur.execute("BEGIN")
        cur.execute(
            "UPDATE slack_channel_agents SET is_dm_default = 0 WHERE team_id = ?",
            (team_id,),
        )
        cur.execute(
            \"\"\"UPDATE slack_channel_agents SET is_dm_default = 1
                 WHERE team_id = ? AND agent_name = ?\"\"\",
            (team_id, agent_name),
        )
        changed = cur.rowcount > 0
        conn.commit()
    return changed

The clear-then-set inside one transaction avoids a transient "two defaults" state.

Also: when unbind_slack_agent removes the current default, promote the oldest-bound remaining agent (ORDER BY created_at ASC LIMIT 1) to keep the workspace addressable.

2. Backend API

New endpoint, owner/admin-only:

PUT /api/agents/{name}/slack/channel/dm-default

200 → {"workspace": "...", "team_id": "...", "previous": "agent-a", "new_default": "agent-b"}
404 → agent not bound in any workspace
403 → caller is not the owner / admin

Audit-logged via platform_audit_service.log with event_type=AGENT_LIFECYCLE, event_action="slack_dm_default_changed", target_id=agent_name, details={"team_id": ..., "previous": ..., "new_default": ...}.

3. MCP tool (optional)

Add set_slack_dm_default(agent_name) to src/mcp-server/src/tools/slack.ts (if a Slack tools module exists; otherwise skip — UI is the primary surface).

4. Frontend

src/frontend/src/components/SlackChannelPanel.vue:

  • Replace the read-only (DM default) label with a dropdown listing all agents in the workspace.
  • Selection PUTs the new default; success → toast, refresh.
  • Disabled for non-owner / non-admin.

If only one agent is bound, the dropdown is suppressed (no choice to make).

Suggested Tier / Categorization

Acceptance Criteria

  • DB method set_slack_dm_default(team_id, agent_name) with single-tx clear-then-set
  • unbind_slack_agent auto-promotes oldest-bound remaining agent if it removed the default
  • PUT /api/agents/{name}/slack/channel/dm-default endpoint, owner/admin-gated, audit-logged
  • SlackChannelPanel.vue shows a dropdown for the DM default; works end-to-end
  • Unit test: set_slack_dm_default is idempotent and exclusive (no two-defaults race)
  • Unit test: unbind_slack_agent promotes correctly when removing the default
  • Manual smoke: connect 2 agents, flip default via UI, send DM, confirm routing changes

Out of Scope

  • Per-channel routing changes (that already exists via channel bindings)
  • A new "DM router" with rules — just a single default agent
  • Migrating away from the is_dm_default boolean schema

References

  • Routing logic: src/backend/adapters/slack_adapter.py:201
  • DB layer: src/backend/db/slack_channels.py:138-198
  • Frontend label: src/frontend/src/components/SlackChannelPanel.vue:33
  • Auto-set sites: src/backend/routers/slack.py:186, 296, 474
  • Local runbook for the DB-direct workaround: .local/notes/runbook-slack-dm-default-flip.md

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions