Skip to content

Commit 0bf9394

Browse files
committed
feat: tools registry
1 parent f32af71 commit 0bf9394

File tree

5 files changed

+652
-0
lines changed

5 files changed

+652
-0
lines changed

slack_bolt/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
# AI Agents & Assistants
2424
from .agent import BoltAgent
25+
from .agent import Tools
2526
from .middleware.assistant.assistant import (
2627
Assistant,
2728
)
@@ -48,6 +49,7 @@
4849
"BoltRequest",
4950
"BoltResponse",
5051
"BoltAgent",
52+
"Tools",
5153
"Assistant",
5254
"AssistantThreadContext",
5355
"AssistantThreadContextStore",

slack_bolt/agent/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from .agent import BoltAgent
2+
from .tools import Tools
23

34
__all__ = [
45
"BoltAgent",
6+
"Tools",
57
]

slack_bolt/agent/tools.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import copy
2+
import inspect
3+
from dataclasses import dataclass, field
4+
from typing import Any, Callable, Dict, List, Optional
5+
6+
from slack_sdk.web.chat_stream import ChatStream
7+
8+
9+
@dataclass
10+
class ToolDefinition:
11+
"""Internal representation of a registered tool."""
12+
13+
name: str
14+
handler: Callable[..., str]
15+
description: str
16+
parameters: Dict[str, Any]
17+
required: List[str]
18+
19+
20+
_TYPE_MAP = {
21+
str: "string",
22+
int: "integer",
23+
float: "number",
24+
bool: "boolean",
25+
list: "array",
26+
dict: "object",
27+
}
28+
29+
30+
def _introspect_handler(func: Callable) -> dict:
31+
"""Extract metadata from a function signature to build JSON Schema parameters.
32+
33+
Uses ``typing.get_type_hints()`` and ``inspect.signature()`` to derive
34+
parameter types. Falls back to the function docstring for the description.
35+
36+
Returns:
37+
dict with keys ``description``, ``parameters``, and ``required``.
38+
"""
39+
try:
40+
hints = __import__("typing").get_type_hints(func)
41+
except Exception:
42+
hints = {}
43+
44+
sig = inspect.signature(func)
45+
parameters: Dict[str, Any] = {}
46+
required: List[str] = []
47+
48+
for param_name, param in sig.parameters.items():
49+
json_type = _TYPE_MAP.get(hints.get(param_name), "string")
50+
parameters[param_name] = {"type": json_type}
51+
if param.default is inspect.Parameter.empty:
52+
required.append(param_name)
53+
54+
description = (func.__doc__ or "").strip()
55+
56+
return {
57+
"description": description,
58+
"parameters": parameters,
59+
"required": required,
60+
}
61+
62+
63+
class Tools:
64+
"""Tool registry for AI-powered Slack agents.
65+
66+
Experimental:
67+
This API is experimental and may change in future releases.
68+
69+
Example::
70+
71+
tools = Tools()
72+
73+
@tools.add("search")
74+
def search(query: str) -> str:
75+
return f"Results for {query}"
76+
77+
# Or programmatically:
78+
tools.add("greet", handler=greet_fn, description="Greet a user")
79+
"""
80+
81+
def __init__(self) -> None:
82+
self._definitions: Dict[str, ToolDefinition] = {}
83+
84+
# ------------------------------------------------------------------
85+
# Registration
86+
# ------------------------------------------------------------------
87+
88+
def add(
89+
self,
90+
name: str,
91+
handler: Optional[Callable[..., str]] = None,
92+
*,
93+
description: Optional[str] = None,
94+
parameters: Optional[Dict[str, Any]] = None,
95+
required: Optional[List[str]] = None,
96+
):
97+
"""Register a tool handler.
98+
99+
Can be used as a decorator::
100+
101+
@tools.add("search")
102+
def search(query: str) -> str:
103+
return f"Results for {query}"
104+
105+
Or called programmatically::
106+
107+
tools.add("search", handler=search_fn, description="Search")
108+
109+
Args:
110+
name: Unique tool identifier.
111+
handler: The function to invoke. When ``None``, returns a decorator.
112+
description: Human-readable description. Defaults to handler docstring.
113+
parameters: JSON Schema properties dict. Defaults to introspected signature.
114+
required: Required parameter names. Defaults to introspected signature.
115+
116+
Returns:
117+
The handler function (when used as a decorator) or ``None``.
118+
119+
Raises:
120+
ValueError: If a tool with *name* is already registered.
121+
"""
122+
if name in self._definitions:
123+
raise ValueError(f"Tool already registered: {name!r}")
124+
125+
def _register(func: Callable[..., str]) -> Callable[..., str]:
126+
introspected = _introspect_handler(func)
127+
self._definitions[name] = ToolDefinition(
128+
name=name,
129+
handler=func,
130+
description=description if description is not None else introspected["description"],
131+
parameters=parameters if parameters is not None else introspected["parameters"],
132+
required=required if required is not None else introspected["required"],
133+
)
134+
return func
135+
136+
if handler is not None:
137+
_register(handler)
138+
return None
139+
140+
return _register
141+
142+
# ------------------------------------------------------------------
143+
# Execution
144+
# ------------------------------------------------------------------
145+
146+
def execute(
147+
self,
148+
stream: ChatStream,
149+
call_id: str,
150+
name: str,
151+
arguments: Optional[Dict[str, Any]] = None,
152+
) -> str:
153+
"""Execute a registered tool and send status updates via *stream*.
154+
155+
Sends ``task_update`` kwargs through ``stream.append()`` to indicate
156+
``in_progress``, ``completed``, or ``failed`` status.
157+
158+
Args:
159+
stream: A ``ChatStream`` instance for status updates.
160+
call_id: Identifier for the tool call (passed in ``task_update``).
161+
name: The registered tool name.
162+
arguments: Keyword arguments forwarded to the handler.
163+
164+
Returns:
165+
The string result from the handler.
166+
167+
Raises:
168+
KeyError: If *name* is not registered.
169+
"""
170+
if name not in self._definitions:
171+
raise KeyError(f"Unknown tool: {name!r}")
172+
173+
definition = self._definitions[name]
174+
kwargs = arguments or {}
175+
176+
stream.append(
177+
markdown_text="",
178+
task_update={"call_id": call_id, "status": "in_progress"},
179+
)
180+
181+
try:
182+
result = definition.handler(**kwargs)
183+
except Exception as exc:
184+
stream.append(
185+
markdown_text="",
186+
task_update={"call_id": call_id, "status": "failed", "output": str(exc)},
187+
)
188+
raise
189+
190+
stream.append(
191+
markdown_text="",
192+
task_update={"call_id": call_id, "status": "completed", "output": result},
193+
)
194+
return result
195+
196+
# ------------------------------------------------------------------
197+
# Schema export
198+
# ------------------------------------------------------------------
199+
200+
def schema(self, provider: str) -> List[Dict[str, Any]]:
201+
"""Export tool schemas for an LLM provider.
202+
203+
Args:
204+
provider: ``"openai"`` or ``"anthropic"``.
205+
206+
Returns:
207+
List of tool schema dicts in the provider's format.
208+
209+
Raises:
210+
ValueError: If *provider* is not supported.
211+
"""
212+
if provider == "openai":
213+
return self._schema_openai()
214+
elif provider == "anthropic":
215+
return self._schema_anthropic()
216+
else:
217+
raise ValueError(f"Unsupported provider: {provider!r}. Use 'openai' or 'anthropic'.")
218+
219+
def _schema_openai(self) -> List[Dict[str, Any]]:
220+
result = []
221+
for defn in self._definitions.values():
222+
result.append(
223+
{
224+
"type": "function",
225+
"function": {
226+
"name": defn.name,
227+
"description": defn.description,
228+
"parameters": {
229+
"type": "object",
230+
"properties": defn.parameters,
231+
"required": defn.required,
232+
},
233+
},
234+
}
235+
)
236+
return result
237+
238+
def _schema_anthropic(self) -> List[Dict[str, Any]]:
239+
result = []
240+
for defn in self._definitions.values():
241+
result.append(
242+
{
243+
"name": defn.name,
244+
"description": defn.description,
245+
"input_schema": {
246+
"type": "object",
247+
"properties": defn.parameters,
248+
"required": defn.required,
249+
},
250+
}
251+
)
252+
return result
253+
254+
# ------------------------------------------------------------------
255+
# Utilities
256+
# ------------------------------------------------------------------
257+
258+
def copy(self) -> "Tools":
259+
"""Create an independent copy of the registry.
260+
261+
Handler references are shared; definitions are deep-copied.
262+
"""
263+
new = Tools()
264+
for name, defn in self._definitions.items():
265+
new._definitions[name] = ToolDefinition(
266+
name=defn.name,
267+
handler=defn.handler,
268+
description=defn.description,
269+
parameters=copy.deepcopy(defn.parameters),
270+
required=list(defn.required),
271+
)
272+
return new
273+
274+
def __len__(self) -> int:
275+
return len(self._definitions)
276+
277+
def __contains__(self, name: object) -> bool:
278+
return name in self._definitions

slack_bolt/app/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from http.server import SimpleHTTPRequestHandler, HTTPServer
1010
from typing import List, Union, Pattern, Callable, Dict, Optional, Sequence, Any
1111

12+
from slack_bolt.agent.agent import BoltAgent
1213
from slack_sdk.errors import SlackApiError
1314
from slack_sdk.oauth.installation_store import InstallationStore
1415
from slack_sdk.web import WebClient
@@ -703,6 +704,9 @@ def middleware_func(logger, body, next):
703704

704705
def assistant(self, assistant: Assistant) -> Optional[Callable]:
705706
return self.middleware(assistant)
707+
708+
def agent(self, agent: BoltAgent) -> Optional[Callable]:
709+
return self.middleware(agent)
706710

707711
# -------------------------
708712
# Workflows: Steps from apps

0 commit comments

Comments
 (0)