Skip to content

Commit c699d71

Browse files
chiao-keclaude
andcommitted
feat(extensions): add register_tools() for raw Agno toolkits & providers
Lets custom/*.py attach any Agno tool that is not a single decorated function — a Toolkit instance or a context provider's get_tools() list (Drive, Gmail, Calendar, Slack, etc.) — to the agent without editing core code: from dango.extensions import register_tools from agno.context.gdrive import GoogleDriveContextProvider register_tools(*GoogleDriveContextProvider().get_tools()) register_tools(*objs) flattens lists/tuples and stores raw objects; they flow into the agent via get_custom_tools() alongside decorated tools, and are exposed to the agent only (never slash commands). Docs cover usage and note that Agno's Scheduler is a runtime/cron layer that does not fit Dango's model. 44 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18f5d45 commit c699d71

8 files changed

Lines changed: 144 additions & 15 deletions

File tree

custom/tools.py.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,17 @@ def whoami(ctx: Ctx) -> str:
4949
# import re
5050
# m = re.search(r"<title>(.*?)</title>", html, re.I | re.S)
5151
# return m.group(1).strip() if m else "(no title found)"
52+
53+
54+
# ── Attach a built-in Agno toolkit or context provider ───────────────────────
55+
# Use register_tools() for anything that isn't your own single function — an
56+
# Agno Toolkit instance, or a context provider's get_tools() list. These are
57+
# exposed to the agent only (never a slash command), with no core code changes.
58+
#
59+
# Some providers need extra packages / credentials, e.g. Drive:
60+
# uv add google-api-python-client google-auth-httplib2 google-auth-oauthlib
61+
#
62+
# from dango.extensions import register_tools
63+
# from agno.context.gdrive import GoogleDriveContextProvider
64+
#
65+
# register_tools(*GoogleDriveContextProvider(corpora="user").get_tools())

dango/extensions/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99

1010
from .context import Ctx
1111
from .loader import get_custom_tools, load_custom_modules, register_custom_commands
12-
from .registry import agent_tool, command, command_and_tool
12+
from .registry import agent_tool, command, command_and_tool, register_tools
1313

1414
__all__ = [
1515
# Decorators (used in custom/*.py)
1616
"command",
1717
"agent_tool",
1818
"command_and_tool",
19+
# Attach raw Agno toolkits / context providers (used in custom/*.py)
20+
"register_tools",
1921
# Call context
2022
"Ctx",
2123
# Loader (used by the app at startup)

dango/extensions/loader.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from typing import Any, Callable, get_type_hints
2020

2121
from .context import Ctx
22-
from .registry import ExtensionSpec, command_specs, tool_specs
22+
from .registry import ExtensionSpec, command_specs, raw_tools, tool_specs
2323

2424
_loaded = False
2525

@@ -112,20 +112,26 @@ def wrapper(*args, **kwargs):
112112

113113

114114
def get_custom_tools() -> list:
115-
"""Build Agno tool objects from the registered tool specs."""
115+
"""Build the agent's custom tool list.
116+
117+
Combines decorated @agent_tool / @command_and_tool functions with any raw
118+
tools/toolkits/context-provider tools registered via register_tools().
119+
"""
120+
tools = []
121+
116122
specs = tool_specs()
117-
if not specs:
118-
return []
123+
if specs:
124+
from agno.tools import tool
119125

120-
from agno.tools import tool
126+
for spec in specs:
127+
wrapper = _make_tool_wrapper(spec)
128+
try:
129+
tools.append(tool(name=spec.name, description=spec.description or None)(wrapper))
130+
except Exception as e:
131+
print(f"⚠️ [custom] could not register tool '{spec.name}': {e}")
121132

122-
tools = []
123-
for spec in specs:
124-
wrapper = _make_tool_wrapper(spec)
125-
try:
126-
tools.append(tool(name=spec.name, description=spec.description or None)(wrapper))
127-
except Exception as e:
128-
print(f"⚠️ [custom] could not register tool '{spec.name}': {e}")
133+
# Raw Agno toolkits / context-provider tools registered via register_tools().
134+
tools.extend(raw_tools())
129135
return tools
130136

131137

dango/extensions/registry.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ class ExtensionSpec:
3131
# Populated at import time, when the loader imports each custom/*.py file.
3232
_REGISTRY: list[ExtensionSpec] = []
3333

34+
# Raw Agno tools/toolkits/context-provider tools registered via register_tools().
35+
_RAW_TOOLS: list = []
36+
3437

3538
def _first_doc_line(fn: Callable) -> str:
3639
doc = (fn.__doc__ or "").strip()
@@ -87,6 +90,30 @@ def wrap(fn: Callable) -> Callable:
8790
"""Register a function as BOTH a Discord slash command and an Agent tool."""
8891

8992

93+
def register_tools(*tools) -> None:
94+
"""Attach raw Agno tools/toolkits/context providers to the agent.
95+
96+
For anything that is not a single decorated function — an Agno ``Toolkit``
97+
instance, or the list produced by a context provider's ``get_tools()`` — call
98+
this from a ``custom/*.py`` file so it reaches the agent without editing core
99+
code. Accepts plain functions, ``@tool`` functions, Toolkit instances, or
100+
lists/tuples of any of these::
101+
102+
from dango.extensions import register_tools
103+
from agno.context.gdrive import GoogleDriveContextProvider
104+
105+
register_tools(*GoogleDriveContextProvider().get_tools())
106+
107+
These are exposed to the agent only; they are never turned into Discord slash
108+
commands.
109+
"""
110+
for t in tools:
111+
if isinstance(t, (list, tuple)):
112+
_RAW_TOOLS.extend(t)
113+
else:
114+
_RAW_TOOLS.append(t)
115+
116+
90117
def command_specs() -> list[ExtensionSpec]:
91118
return [s for s in _REGISTRY if s.expose_command]
92119

@@ -95,6 +122,11 @@ def tool_specs() -> list[ExtensionSpec]:
95122
return [s for s in _REGISTRY if s.expose_tool]
96123

97124

125+
def raw_tools() -> list:
126+
return list(_RAW_TOOLS)
127+
128+
98129
def clear_registry() -> None:
99130
"""Reset the registry. Used by tests and (later) hot-reload."""
100131
_REGISTRY.clear()
132+
_RAW_TOOLS.clear()

docs/features/extensions.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,37 @@ own Discord permissions before doing anything:
158158
permissions, return them for the model to reason about) but cannot perform
159159
member/role mutations directly.
160160

161+
## Using built-in Agno toolkits & context providers
162+
163+
Beyond writing your own functions, you can attach any
164+
[Agno toolkit](https://docs.agno.com/tools/toolkits) or
165+
[context provider](https://docs.agno.com/context-providers) to the agent from a
166+
custom file with `register_tools()` — no core code changes:
167+
168+
```python
169+
from dango.extensions import register_tools
170+
from agno.context.gdrive import GoogleDriveContextProvider
171+
172+
# A context provider exposes its tools via get_tools()
173+
register_tools(*GoogleDriveContextProvider(corpora="user").get_tools())
174+
```
175+
176+
`register_tools()` accepts plain functions, `@tool` functions, `Toolkit`
177+
instances, or lists (such as a provider's `get_tools()`). Everything you pass is
178+
exposed to the **agent only** — it never becomes a Discord slash command. This is
179+
the same mechanism Dango uses internally for its SQL database tools.
180+
181+
Agno ships context providers for Google Drive, Gmail, Calendar, Slack, web, wiki,
182+
the filesystem, and more. Some need extra packages and credentials — e.g. Drive
183+
needs `google-api-python-client google-auth-httplib2 google-auth-oauthlib` plus
184+
OAuth. Install those in your clone the usual way (`uv add ...`).
185+
186+
!!! note "Agno's Scheduler does not apply"
187+
Agno's [Scheduler](https://docs.agno.com/scheduler/overview) is a runtime/cron
188+
orchestration layer that drives AgentOS endpoints — it is not a tool and does
189+
not plug into Dango's discord.py + Workflow model. For scheduled behaviour,
190+
use `discord.ext.tasks` or an external cron job.
191+
161192
## Interactive UI (modals, buttons, selects) — not supported here
162193

163194
This SDK is built around a simple contract: **your function takes arguments and

docs/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
- [Model Providers & Routing](https://raw.githubusercontent.com/zhiro-labs/dango/main/docs/features/models.md): `provider:model_id` format, supported providers, dual-model AUTO_ROUTE, error fallback, local models, context token budget.
1919
- [Tools](https://raw.githubusercontent.com/zhiro-labs/dango/main/docs/features/tools.md): DuckDuckGo search, website fetching, workspace file access, custom HTTP APIs, SQL databases.
20-
- [Custom Commands & Tools](https://raw.githubusercontent.com/zhiro-labs/dango/main/docs/features/extensions.md): Write your own Discord slash commands and agent tools in Python in a gitignored custom/ directory. Three decorators — @command, @agent_tool, @command_and_tool — with per-function opt-in for agent access. One function can be both a slash command and an LLM-callable tool; a normalized Ctx works across both call paths.
20+
- [Custom Commands & Tools](https://raw.githubusercontent.com/zhiro-labs/dango/main/docs/features/extensions.md): Write your own Discord slash commands and agent tools in Python in a gitignored custom/ directory. Three decorators — @command, @agent_tool, @command_and_tool — with per-function opt-in for agent access. One function can be both a slash command and an LLM-callable tool; a normalized Ctx works across both call paths. register_tools() attaches any Agno toolkit or context provider (Drive, Gmail, Calendar, Slack, etc.) to the agent without editing core code.
2121
- [Workflow Architecture](https://raw.githubusercontent.com/zhiro-labs/dango/main/docs/features/workflow.md): Four-step Agno Workflow — FetchHistory → LLMChat → ExtractRenderTables → SendResponse. Entry point: `on_message` in ChatCog (and the `/deep` command), which builds message_data and calls workflow.arun().
2222

2323
## Usage

docs/reference/api.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ When `name` is omitted the function name is used; when `description` is omitted
9090
the first line of the docstring is used. A leading `ctx` parameter is optional
9191
and is stripped from the public command/tool schema.
9292

93+
### `register_tools(*tools)`
94+
95+
Attach raw Agno tools to the agent — plain functions, `@tool` functions,
96+
`Toolkit` instances, or lists such as a context provider's `get_tools()`. Lists
97+
and tuples are flattened. Call it from a `custom/*.py` file; everything passed is
98+
exposed to the agent only (never a slash command).
99+
100+
```python
101+
from dango.extensions import register_tools
102+
from agno.context.gdrive import GoogleDriveContextProvider
103+
104+
register_tools(*GoogleDriveContextProvider().get_tools())
105+
```
106+
93107
### `Ctx`
94108

95109
Dataclass describing where a function was invoked from, passed as the first

tests/test_extensions.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from dango.extensions import agent_tool, command, command_and_tool
8+
from dango.extensions import agent_tool, command, command_and_tool, register_tools
99
from dango.extensions.context import (
1010
Ctx,
1111
reset_request_context,
@@ -182,6 +182,36 @@ def test_no_tools_returns_empty_list(self):
182182
assert get_custom_tools() == []
183183

184184

185+
# ── register_tools (raw toolkits / context providers) ──────────────────────────
186+
class TestRegisterTools:
187+
def test_single_object_passes_through(self):
188+
sentinel = object()
189+
register_tools(sentinel)
190+
assert sentinel in get_custom_tools()
191+
192+
def test_list_is_flattened(self):
193+
# Mirrors register_tools(*provider.get_tools()) and register_tools(provider.get_tools()).
194+
a, b = object(), object()
195+
register_tools([a, b])
196+
tools = get_custom_tools()
197+
assert a in tools and b in tools
198+
199+
def test_raw_tools_are_not_slash_commands(self):
200+
register_tools(object())
201+
assert command_specs() == []
202+
203+
def test_combined_with_decorated_tools(self):
204+
@agent_tool(name="decorated")
205+
def decorated(x: str):
206+
return x
207+
208+
sentinel = object()
209+
register_tools(sentinel)
210+
tools = get_custom_tools()
211+
assert sentinel in tools
212+
assert "decorated" in [getattr(t, "name", None) for t in tools]
213+
214+
185215
# ── Integration with discord.py ────────────────────────────────────────────────
186216
class TestDiscordIntegration:
187217
def test_make_app_command_strips_ctx_and_interaction(self):

0 commit comments

Comments
 (0)