Skip to content
Open
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ venv/
.venv*
.env/

# claude
.claude/*.local.json

# codecov / coverage
.coverage
cov_*
Expand Down
2 changes: 2 additions & 0 deletions slack_bolt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .response import BoltResponse

# AI Agents & Assistants
from .agent import BoltAgent
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Maybe we should wait until the feature is GA before exporting it here?

Developers should still be able to import the class directly with something like
from slack_bolt.agent import BoltAgent

from .middleware.assistant.assistant import (
Assistant,
)
Expand All @@ -46,6 +47,7 @@
"CustomListenerMatcher",
"BoltRequest",
"BoltResponse",
"BoltAgent",
"Assistant",
"AssistantThreadContext",
"AssistantThreadContextStore",
Expand Down
5 changes: 5 additions & 0 deletions slack_bolt/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .agent import BoltAgent

__all__ = [
"BoltAgent",
]
77 changes: 77 additions & 0 deletions slack_bolt/agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from typing import Optional

from slack_sdk import WebClient
from slack_sdk.web.chat_stream import ChatStream


class BoltAgent:
"""Agent listener argument for building AI-powered Slack agents.
Experimental:
This API is experimental and may change in future releases.
Comment on lines +10 to +11
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: We're using an "Experimental" warning while we developer this feature. Rather than working on a long-standing branch, we'd like to merge into main under a semver:patch then release a semver:minor when the experimental status is removed.

FIXME: chat_stream() only works when thread_ts is available (DMs and threaded replies).
It does not work on channel messages because ts is not provided to BoltAgent yet.
Comment on lines +13 to +14
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Important callout. I'd like to add ts support in a follow-up PR so that we can discuss the best approach.

@app.event("app_mention")
def handle_mention(agent):
stream = agent.chat_stream()
stream.append(markdown_text="Hello!")
stream.stop()
"""

def __init__(
self,
*,
client: WebClient,
channel_id: Optional[str] = None,
thread_ts: Optional[str] = None,
team_id: Optional[str] = None,
user_id: Optional[str] = None,
):
self._client = client
self._channel_id = channel_id
self._thread_ts = thread_ts
self._team_id = team_id
self._user_id = user_id

def chat_stream(
self,
*,
channel: Optional[str] = None,
thread_ts: Optional[str] = None,
recipient_team_id: Optional[str] = None,
recipient_user_id: Optional[str] = None,
**kwargs,
) -> ChatStream:
"""Creates a ChatStream with defaults from event context.
Each call creates a new instance. Create multiple for parallel streams.
Args:
channel: Channel ID. Defaults to the channel from the event context.
thread_ts: Thread timestamp. Defaults to the thread_ts from the event context.
recipient_team_id: Team ID of the recipient. Defaults to the team from the event context.
recipient_user_id: User ID of the recipient. Defaults to the user from the event context.
**kwargs: Additional arguments passed to ``WebClient.chat_stream()``.
Returns:
A new ``ChatStream`` instance.
"""
resolved_channel = channel or self._channel_id
resolved_thread_ts = thread_ts or self._thread_ts
Comment on lines +61 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering here if falling back to the class instances channel_id, thread_ts, team_id and user_id is the best behavior 🤔

Like if a developer only passes one of these parameters and are unaware of the the fallback values they could end up chat streaming to the wrong location?
I'm not super familiar with chat_stream so this might be a non issue

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 i agree with @WilliamBergamin! channel_id and thread_ts seem to work as a pair so if a user changes one they should be aware they need to change the other. Maybe we throw a warning when one of these params is changed but not the other 🤔 ?

if resolved_channel is None:
raise ValueError(
"channel is required: provide it as an argument or ensure channel_id is set in the event context"
)
if resolved_thread_ts is None:
raise ValueError(
"thread_ts is required: provide it as an argument or ensure thread_ts is set in the event context"
)
return self._client.chat_stream(
channel=resolved_channel,
thread_ts=resolved_thread_ts,
recipient_team_id=recipient_team_id or self._team_id,
recipient_user_id=recipient_user_id or self._user_id,
**kwargs,
)
74 changes: 74 additions & 0 deletions slack_bolt/agent/async_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from typing import Optional

from slack_sdk.web.async_client import AsyncWebClient
from slack_sdk.web.async_chat_stream import AsyncChatStream


class AsyncBoltAgent:
"""Async agent listener argument for building AI-powered Slack agents.

Experimental:
This API is experimental and may change in future releases.

@app.event("app_mention")
async def handle_mention(agent):
stream = await agent.chat_stream()
await stream.append(markdown_text="Hello!")
await stream.stop()
"""

def __init__(
self,
*,
client: AsyncWebClient,
channel_id: Optional[str] = None,
thread_ts: Optional[str] = None,
team_id: Optional[str] = None,
user_id: Optional[str] = None,
):
self._client = client
self._channel_id = channel_id
self._thread_ts = thread_ts
self._team_id = team_id
self._user_id = user_id

async def chat_stream(
self,
*,
channel: Optional[str] = None,
thread_ts: Optional[str] = None,
recipient_team_id: Optional[str] = None,
recipient_user_id: Optional[str] = None,
**kwargs,
) -> AsyncChatStream:
"""Creates an AsyncChatStream with defaults from event context.

Each call creates a new instance. Create multiple for parallel streams.

Args:
channel: Channel ID. Defaults to the channel from the event context.
thread_ts: Thread timestamp. Defaults to the thread_ts from the event context.
recipient_team_id: Team ID of the recipient. Defaults to the team from the event context.
recipient_user_id: User ID of the recipient. Defaults to the user from the event context.
**kwargs: Additional arguments passed to ``AsyncWebClient.chat_stream()``.

Returns:
A new ``AsyncChatStream`` instance.
"""
resolved_channel = channel or self._channel_id
resolved_thread_ts = thread_ts or self._thread_ts
if resolved_channel is None:
raise ValueError(
"channel is required: provide it as an argument or ensure channel_id is set in the event context"
)
if resolved_thread_ts is None:
raise ValueError(
"thread_ts is required: provide it as an argument or ensure thread_ts is set in the event context"
)
return await self._client.chat_stream(
channel=resolved_channel,
thread_ts=resolved_thread_ts,
recipient_team_id=recipient_team_id or self._team_id,
recipient_user_id=recipient_user_id or self._user_id,
**kwargs,
)
33 changes: 32 additions & 1 deletion slack_bolt/context/async_context.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import TYPE_CHECKING, Optional

from slack_sdk.web.async_client import AsyncWebClient

Expand All @@ -15,6 +15,9 @@
from slack_bolt.context.set_title.async_set_title import AsyncSetTitle
from slack_bolt.util.utils import create_copy

if TYPE_CHECKING:
from slack_bolt.agent.async_agent import AsyncBoltAgent

Comment on lines +18 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL: TYPE_CHECKING, it seems awesome and maybe we can use it elsewhere to improve our types checking for async stuff

IIUC this is to ensure that we

  • Do not import AsyncBoltAgent whenever AsyncBoltContext is imported
  • Static type checking around AsyncBoltAgent passes
  • AsyncBoltAgent is only imported when context.agent is invoked


class AsyncBoltContext(BaseContext):
"""Context object associated with a request from Slack."""
Expand Down Expand Up @@ -187,6 +190,34 @@ async def handle_button_clicks(context):
self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id)
return self["fail"]

@property
def agent(self) -> "AsyncBoltAgent":
"""`agent` listener argument for building AI-powered Slack agents.

Experimental:
This API is experimental and may change in future releases.

@app.event("app_mention")
async def handle_mention(agent):
stream = await agent.chat_stream()
await stream.append(markdown_text="Hello!")
await stream.stop()

Returns:
`AsyncBoltAgent` instance
"""
if "agent" not in self:
from slack_bolt.agent.async_agent import AsyncBoltAgent

self["agent"] = AsyncBoltAgent(
client=self.client,
channel_id=self.channel_id,
thread_ts=self.thread_ts,
team_id=self.team_id,
user_id=self.user_id,
)
return self["agent"]

@property
def set_title(self) -> Optional[AsyncSetTitle]:
return self.get("set_title")
Expand Down
1 change: 1 addition & 0 deletions slack_bolt/context/base_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class BaseContext(dict):
"set_status",
"set_title",
"set_suggested_prompts",
"agent",
]
# Note that these items are not copyable, so when you add new items to this list,
# you must modify ThreadListenerRunner/AsyncioListenerRunner's _build_lazy_request method to pass the values.
Expand Down
33 changes: 32 additions & 1 deletion slack_bolt/context/context.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import TYPE_CHECKING, Optional

from slack_sdk import WebClient

Expand All @@ -15,6 +15,9 @@
from slack_bolt.context.set_title import SetTitle
from slack_bolt.util.utils import create_copy

if TYPE_CHECKING:
from slack_bolt.agent.agent import BoltAgent


class BoltContext(BaseContext):
"""Context object associated with a request from Slack."""
Expand Down Expand Up @@ -188,6 +191,34 @@ def handle_button_clicks(context):
self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
return self["fail"]

@property
def agent(self) -> "BoltAgent":
"""`agent` listener argument for building AI-powered Slack agents.

Experimental:
This API is experimental and may change in future releases.

@app.event("app_mention")
def handle_mention(agent):
stream = agent.chat_stream()
stream.append(markdown_text="Hello!")
stream.stop()

Returns:
`BoltAgent` instance
"""
if "agent" not in self:
from slack_bolt.agent.agent import BoltAgent

self["agent"] = BoltAgent(
client=self.client,
channel_id=self.channel_id,
thread_ts=self.thread_ts,
team_id=self.team_id,
user_id=self.user_id,
)
return self["agent"]

@property
def set_title(self) -> Optional[SetTitle]:
return self.get("set_title")
Expand Down
5 changes: 5 additions & 0 deletions slack_bolt/kwargs_injection/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from slack_bolt.context.fail import Fail
from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext
from slack_bolt.context.respond import Respond
from slack_bolt.agent.agent import BoltAgent
from slack_bolt.context.save_thread_context import SaveThreadContext
from slack_bolt.context.say import Say
from slack_bolt.context.set_status import SetStatus
Expand Down Expand Up @@ -102,6 +103,8 @@ def handle_buttons(args):
"""`get_thread_context()` utility function for AI Agents & Assistants"""
save_thread_context: Optional[SaveThreadContext]
"""`save_thread_context()` utility function for AI Agents & Assistants"""
agent: Optional[BoltAgent]
"""`agent` listener argument for AI Agents & Assistants"""
# middleware
next: Callable[[], None]
"""`next()` utility function, which tells the middleware chain that it can continue with the next one"""
Expand Down Expand Up @@ -135,6 +138,7 @@ def __init__(
set_suggested_prompts: Optional[SetSuggestedPrompts] = None,
get_thread_context: Optional[GetThreadContext] = None,
save_thread_context: Optional[SaveThreadContext] = None,
agent: Optional[BoltAgent] = None,
# As this method is not supposed to be invoked by bolt-python users,
# the naming conflict with the built-in one affects
# only the internals of this method
Expand Down Expand Up @@ -168,6 +172,7 @@ def __init__(
self.set_suggested_prompts = set_suggested_prompts
self.get_thread_context = get_thread_context
self.save_thread_context = save_thread_context
self.agent = agent

self.next: Callable[[], None] = next
self.next_: Callable[[], None] = next
5 changes: 5 additions & 0 deletions slack_bolt/kwargs_injection/async_args.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from logging import Logger
from typing import Callable, Awaitable, Dict, Any, Optional

from slack_bolt.agent.async_agent import AsyncBoltAgent
from slack_bolt.context.ack.async_ack import AsyncAck
from slack_bolt.context.async_context import AsyncBoltContext
from slack_bolt.context.complete.async_complete import AsyncComplete
Expand Down Expand Up @@ -101,6 +102,8 @@ async def handle_buttons(args):
"""`get_thread_context()` utility function for AI Agents & Assistants"""
save_thread_context: Optional[AsyncSaveThreadContext]
"""`save_thread_context()` utility function for AI Agents & Assistants"""
agent: Optional[AsyncBoltAgent]
"""`agent` listener argument for AI Agents & Assistants"""
# middleware
next: Callable[[], Awaitable[None]]
"""`next()` utility function, which tells the middleware chain that it can continue with the next one"""
Expand Down Expand Up @@ -134,6 +137,7 @@ def __init__(
set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] = None,
get_thread_context: Optional[AsyncGetThreadContext] = None,
save_thread_context: Optional[AsyncSaveThreadContext] = None,
agent: Optional[AsyncBoltAgent] = None,
next: Callable[[], Awaitable[None]],
**kwargs, # noqa
):
Expand Down Expand Up @@ -164,6 +168,7 @@ def __init__(
self.set_suggested_prompts = set_suggested_prompts
self.get_thread_context = get_thread_context
self.save_thread_context = save_thread_context
self.agent = agent

self.next: Callable[[], Awaitable[None]] = next
self.next_: Callable[[], Awaitable[None]] = next
8 changes: 6 additions & 2 deletions slack_bolt/kwargs_injection/async_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def build_async_required_kwargs(
error: Optional[Exception] = None, # for error handlers
next_keys_required: bool = True, # False for listeners / middleware / error handlers
) -> Dict[str, Any]:
all_available_args = {
all_available_args: Dict[str, Any] = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This fixed a linter warning

"logger": logger,
"client": request.context.client,
"req": request,
Expand Down Expand Up @@ -83,6 +83,10 @@ def build_async_required_kwargs(
if k not in all_available_args:
all_available_args[k] = v

# Defer agent creation to avoid constructing AsyncBoltAgent on every request
if "agent" in required_arg_names or "args" in required_arg_names:
all_available_args["agent"] = request.context.agent
Comment on lines +87 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever 💯 I like this and wonder if we should follow this pattern for other keyword arguments 🚀


What do you think about including a warning here that informs developers that the agent is an experimental feature subject to change?

From what I can tell we should be able to do this by taking advantage of the FutureWarning like this

import warnings

class ExperimentalWarning(FutureWarning):
    """Warning for features that are still in experimental phase."""
    pass

warnings.warn(
    "agent is experimental and may change in future versions.",
    category=ExperimentalWarning,
    stacklevel=2
)

IIUC every time a handler processes a request and uses the "agent" kwargs, this warning would be printed, this might be a bit annoying but it would be clear


if len(required_arg_names) > 0:
# To support instance/class methods in a class for listeners/middleware,
# check if the first argument is either self or cls
Expand All @@ -102,7 +106,7 @@ def build_async_required_kwargs(
for name in required_arg_names:
if name == "args":
if isinstance(request, AsyncBoltRequest):
kwargs[name] = AsyncArgs(**all_available_args) # type: ignore[arg-type]
kwargs[name] = AsyncArgs(**all_available_args)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: The above fix allows us to remove this type ignore

else:
logger.warning(f"Unknown Request object type detected ({type(request)})")

Expand Down
Loading