Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Checks

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- run: python -m pip install -e ".[dev]"
- run: ruff check .
- run: python -m pytest
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ history and ordering can [bind directly to the channel](docs/slack.md#using-an-e

The [Slack guide](docs/slack.md) covers setup, working indicators, user profiles,
thread history, incoming attachments, rich replies, uploads, and mirrored inputs.
Existing `from agentchat.channels import Slack` imports remain supported.
Import each provider from its own package: `from agentchat.channels.slack import Slack`.
Slack can also [use an application-owned event source](docs/slack.md#composing-event-sources)
without an app token or Socket Mode connection.

## Development

Expand All @@ -87,3 +89,4 @@ python -m pytest
Provider behavior tests live under `tests/channels/`, framework tests under
`tests/integrations/`, and shared adapter contracts under `tests/contracts/`.
Shared fakes and event builders live in `tests/support/`.
CI runs lint and the full suite, including the channel-folder containment rule.
29 changes: 26 additions & 3 deletions docs/adding-a-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ A channel is an adapter for a messaging platform. Put its implementation in
pass an instance to `AgentChat(channels=[...])`. No dispatcher changes or provider
registration are required.

CI enforces that `channels/` contains only `__init__.py`, `base.py`, and provider
packages. Keep provider helpers inside their package; root-level shims fail the test.

## Required contract

Implement the existing `Channel` protocol in `agentchat.channels.base`:
Expand Down Expand Up @@ -90,6 +93,26 @@ python -m pytest

Finally add a runnable example and a provider setup guide. Keep the default
package import free of the provider SDK. If adding dependencies or optional
extras, test the installed package as well as the editable checkout. A new
provider may add a convenience namespace export, but it should not change core
runtime modules or another adapter.
extras, test the installed package as well as the editable checkout. Provider exports belong in their own package, keeping `agentchat.channels`
limited to shared contracts. Adding a provider should not change core runtime
modules, the shared namespace or another adapter.


## Compose transports when a provider needs them

Keep the generic `Channel` contract focused on normalized messages. If one provider
supports multiple ways to receive native events, inject its event source into the
adapter and keep parsing/delivery shared. Slack demonstrates this with
`SlackEventSource` in `channels/slack/transport.py` and the default `SocketMode`
implementation in `channels/slack/socket_mode.py`.

A source implements `run(receiver)`, `close()` and `is_connected()`. It owns
transport resources and must stop intake and clean up when closed or cancelled.
It supplies provider events to the adapter callback; it does not reimplement
normalization, conversation state or agent execution. Authentication of an HTTP
request and durable acknowledgement remain the host source's responsibility.

The Slack source tests exercise the same adapter with an injected event queue,
including duplicate messages, subscribed follow-ups and replies. This demonstrates
composition without adding a provider registry or requiring every channel to
adopt Slack's transport interface.
113 changes: 79 additions & 34 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,46 +53,91 @@ Applications own sender authorization, agent execution, durable work and deliver
recovery. Direct channel binding is also supported when an application already
owns deduplication, ordering and history; see the [Slack guide](slack.md#using-an-existing-agent-runner).

## Slack adapter
## Provider containment

The shared channel namespace contains only adapter contracts. A provider owns
its implementations, exports and optional dependency loading:

```text
channels/slack/
├── __init__.py public Slack and supporting types
├── adapter.py lifecycle, subscriptions, history and API coordination
├── events.py pure event/attachment normalization
├── rendering.py pure text and rich-reply formatting
├── files.py uploads and bounded downloads
└── mirroring.py external-input rendering and sender/role recovery
channels/
├── __init__.py shared contract exports only
├── base.py Channel and optional capability protocols
└── slack/
├── __init__.py lazy public Slack exports
├── adapter.py shared Slack message behavior and Web API operations
├── transport.py SlackEventSource and its event receiver contract
├── socket_mode.py Socket Mode connection, acknowledgements and shutdown
├── events.py pure event/attachment normalization
├── rendering.py pure text and rich-reply formatting
├── files.py uploads and bounded downloads
└── mirroring.py external-input rendering and sender/role recovery
```

Event decoding does not download files. File transport receives explicit request
callbacks and authorization guards. Slack thread subscriptions stay with Slack;
they are provider routing policy, not the application's conversation store.
The adapter retains socket acknowledgement, bounded background tasks and cleanup.

Keep new provider implementations in their own packages. Add internal files when
their responsibilities justify it; a small provider can start with `adapter.py`.
See [adding a channel](adding-a-channel.md) for the extension contract.

## Public imports and compatibility
`base.py` is shared because it defines what any messaging adapter must implement.
Every Slack-specific module lives below `slack/`. There are no sibling Slack
compatibility modules or provider aliases in the shared namespace.

Both `from agentchat.channels import Slack` and
`from agentchat.channels.slack import Slack` remain supported. Root-level message
and context imports are unchanged; `agentchat.models.MessageContext` and
`agentchat.models.Conversation` resolve to the same classes as `agentchat.context`.
## Composition

The `channels` and `integrations` namespaces load concrete implementations only
when requested. Importing the core does not import Slack, aiohttp or OpenAI Agents.
Installation defaults are unchanged: Slack dependencies remain installed with the
base package, while OpenAI Agents still uses the `openai` extra.
The generic adapter boundary remains `Channel`. Within Slack, replace event
delivery independently of message behavior:

The documented legacy helper modules forward to canonical implementations:
```text
SocketMode ────────────┐
├── SlackEventSource ── Slack.handle_event ── AgentChat
application event feed ┘ │
├── subscriptions/history
└── replies/files/status
```

| Compatibility module | Canonical location |
`Slack.run()` authenticates the bot and checks the configured workspace before
starting its source. The source calls the same `handle_event` method for every
payload, so normalization, subscription filtering, sender identity and replies
remain shared. `Slack.close()` and `is_connected()` delegate source lifecycle.
The default Socket Mode implementation owns socket SDK imports, acknowledgement,
bounded event tasks and graceful shutdown.

Supply exactly one of `app_token` (the Socket Mode convenience) or `event_source`
(an application-owned source). A custom source requires no app token and does
not import or instantiate Socket Mode. See the [composition guide](slack.md#composing-event-sources).

| Replaceable concern | Injection point | Responsibility |
| --- | --- | --- |
| Incoming events | `Slack(event_source=...)` | Deliver authenticated Slack events; own input lifecycle |
| Slack Web API calls | `Slack(web_client=...)` | Provide the existing `SlackWebClient` operations |
| Followed threads | `Slack(thread_subscriptions=...)` | Persist or expire Slack thread subscriptions |
| Conversation state | `AgentChat(state=...)` | Claim events, serialize conversations, retain history |
| Agent execution | `@app.on_message` | Authorize sender and call the application's runner |

Event decoding never downloads files. Standalone event and rendering helpers can
be imported without provider SDKs. File transport helpers accept explicit request
callbacks and authorization guards. Applications retain HTTP signature checking,
durable queues, authorization and ambiguous-delivery recovery.

Raw event envelopes, acknowledgement and connection behavior are provider-specific;
they do not belong in the generic `Channel` contract. A new provider can use a
similar composition seam when it has multiple input mechanisms, without inheriting
Slack code or changing `AgentChat`.

## Import migration

This cleanup deliberately removes old Slack shortcut/helper imports. Update them
as follows; no hidden aliases are retained:

| Previous import | Current import |
| --- | --- |
| `channels.slack_files` | `channels.slack.events` for attachment normalization; `channels.slack.files` for downloads |
| `channels.slack_media` | `channels.slack.rendering` for formatting; `channels.slack.files` for uploads |
| `channels.slack_mirror` | `channels.slack.mirroring` |

New code should import the canonical modules. Tests that patch implementation
details should patch the defining module, not a compatibility re-export.
| `from agentchat.channels import Slack, SlackUser` | `from agentchat.channels.slack import Slack, SlackUser` |
| `agentchat.channels.slack_files.slack_attachments` | `agentchat.channels.slack.events.slack_attachments` |
| `agentchat.channels.slack_files.download_slack_file` | `agentchat.channels.slack.files.download_slack_file` |
| `agentchat.channels.slack_media.upload_slack_files` | `agentchat.channels.slack.files.upload_slack_files` |
| `agentchat.channels.slack_media.rich_payload` | `agentchat.channels.slack.rendering.rich_payload` |
| `agentchat.channels.slack_mirror.mirror_payload` | `agentchat.channels.slack.mirroring.mirror_payload` |

Other rendering helpers (`text_blocks`), file helpers (`upload_bytes`,
`validate_destination`, `SlackRequest`, `DeliveryGuard`) and `read_mirror` are
available from the corresponding canonical modules above.

Root-level message and context imports are unchanged. Importing the core does
not import Slack, aiohttp or OpenAI Agents. Installation defaults are unchanged:
Slack dependencies remain installed with the base package, while OpenAI Agents
still uses the `openai` extra.
75 changes: 74 additions & 1 deletion docs/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,12 +404,85 @@ With direct binding, the application owns deduplication and request ordering.

`Slack.run()` checks the bot identity. Pass `workspace_id=` to reject a token or
event from another workspace. `await slack.is_connected()` exposes socket readiness.
Socket envelopes are acknowledged before processing in tracked background tasks.
The default Socket Mode source acknowledges envelopes before processing in tracked background tasks.
`max_pending_events` defaults to 64; excess envelopes are left unacknowledged for
Slack's retry policy. `await slack.close()` closes the socket, then drains tasks
for up to 20 seconds before cancelling unfinished work. This is bounded in-process
delivery, not a durable queue; applications performing writes need their own journal.

## Composing event sources

`Slack` combines a replaceable incoming event source with shared Slack message
behavior. Existing Socket Mode setup remains:

```python
slack = Slack(bot_token="xoxb-...", app_token="xapp-...")
# Equivalent environment-based convenience:
slack = Slack.from_env()
```

For a host-owned verified webhook queue or another Slack event feed, supply a
`SlackEventSource` instead. No app token or Socket Mode connection is needed:

```python
import os

from agentchat import AgentChat, MessageContext
from agentchat.channels.slack import Slack, SlackEventSource


async def serve(source: SlackEventSource) -> None:
slack = Slack(
bot_token=os.environ["SLACK_BOT_TOKEN"],
event_source=source,
ack_emoji=None,
)
app = AgentChat(channels=[slack])

@app.on_message
async def respond(context: MessageContext) -> str:
# The application authorizes the sender before running agent tools.
return context.message.text or "Received your attachment."

try:
await app.run()
finally:
await app.close()
```

Your source implements these operations structurally; no inheritance is required:

- `async run(receiver)`: deliver already authenticated Slack Events API payloads
through `await receiver(payload)` for the source's lifetime.
- `async close()`: stop intake and release resources, draining or cancelling
in-flight work according to the source's documented policy; repeated close calls
must be safe.
- `async is_connected() -> bool`: report that source's readiness.

The receiver takes the same mapping accepted by `slack.handle_event`, including
`team_id` and `event`. Slack verifies bot identity and the configured workspace
before starting the source. Subscriptions, parsing, files, history and replies
are unchanged across sources, and AgentChat continues to own deduplication and
conversation ordering. Outgoing Slack API calls are independently injectable
through `web_client=`. `max_pending_events` configures the default Socket Mode
source; a custom source owns its own concurrency and queue limits.

Supply exactly one of `app_token` and `event_source`. A bare `Slack(bot_token=...)`
without a source is rejected. For an existing host that binds directly to the
channel, supply its source and let `Slack.run()` authenticate before delivering
events through it.

The source owns HTTP signature/timestamp verification and transport acknowledgements.
`handle_event` accepts decoded events; it is not an HTTP signature verifier. Hosts
using durable queues must define when events are acknowledged and how uncertain
outcomes are recovered. The included Socket Mode source remains bounded,
in-process delivery. It owns socket acknowledgements, connection state, pending
event limits and shutdown independently of Slack's message behavior.

See [the source protocol](../src/agentchat/channels/slack/transport.py) and
[the injected-source tests](../tests/channels/slack/test_event_source.py) for a
concrete queue-source composition that reuses the same adapter.

## Development

```bash
Expand Down
47 changes: 21 additions & 26 deletions src/agentchat/channels/__init__.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
from typing import TYPE_CHECKING
"""Provider-neutral adapter contracts; import adapters from their own packages."""

from agentchat.channels.base import Channel, ThreadHistoryChannel
from agentchat.channels.base import (
Channel,
FileDownloadChannel,
FileUploadChannel,
MessageReceiver,
MirroringChannel,
RichReplyChannel,
StatusChannel,
ThreadHistoryChannel,
)

if TYPE_CHECKING:
from agentchat.channels.slack import Slack, SlackUser

__all__ = ["Channel", "Slack", "SlackUser", "ThreadHistoryChannel"]


def __getattr__(name: str) -> object:
if name in {"Slack", "SlackUser"}:
try:
from agentchat.channels import slack
except ModuleNotFoundError as exc:
if exc.name not in {"slack_sdk", "aiohttp"}:
raise
raise ModuleNotFoundError(
"Slack requires slack-sdk and aiohttp. "
"Install AgentChat with its dependencies: pip install agentchat",
name=exc.name,
) from exc
return getattr(slack, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))
__all__ = [
"Channel",
"FileDownloadChannel",
"FileUploadChannel",
"MessageReceiver",
"MirroringChannel",
"RichReplyChannel",
"StatusChannel",
"ThreadHistoryChannel",
]
38 changes: 30 additions & 8 deletions src/agentchat/channels/slack/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,39 @@
"""Slack channel and reusable Slack-specific helpers."""
"""Slack adapter, replaceable event sources and reusable provider helpers."""

from agentchat.channels.slack.adapter import (
MemoryThreadSubscriptions,
Slack,
SlackUser,
SlackWebClient,
ThreadSubscriptions,
)
from typing import TYPE_CHECKING

from agentchat.channels.slack.transport import SlackEventReceiver, SlackEventSource

if TYPE_CHECKING:
from agentchat.channels.slack.adapter import (
MemoryThreadSubscriptions,
Slack,
SlackUser,
SlackWebClient,
ThreadSubscriptions,
)
from agentchat.channels.slack.socket_mode import SocketMode

__all__ = [
"MemoryThreadSubscriptions",
"Slack",
"SlackEventReceiver",
"SlackEventSource",
"SlackUser",
"SlackWebClient",
"SocketMode",
"ThreadSubscriptions",
]


def __getattr__(name: str) -> object:
if name == "SocketMode":
from agentchat.channels.slack.socket_mode import SocketMode

return SocketMode
if name in {"MemoryThreadSubscriptions", "Slack", "SlackUser", "SlackWebClient",
"ThreadSubscriptions"}:
from agentchat.channels.slack import adapter

return getattr(adapter, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Loading
Loading