Add Ability to Disable Tools (#215)

# PR Description
This PR introduces a new environment variable `ARCADE_DISABLED_TOOLS`.
Tools that are added to this env var are not added to the worker's
`ToolCatalog`. In effect, they are disabled for the worker.

## How to use the `ARCADE_DISABLED_TOOLS` environment variable
* Each tool is separated by a comma.
* For each tool, specify the toolkit name in camel case and the tool
name in camel case.
* Do not include versions. (This is a simple implementation. We can add
disabling specific versions in the future if needed)
* Separate the toolkit name and the tool name with your environment's
tool name separator. By default, the tool name separator is `.`, but you
can override this with the `ARCADE_TOOL_NAME_SEPARATOR` environment
variable.

Correct: `export
ARCADE_DISABLED_TOOLS="Math.Add,Spotify.GetAvailableDevices,Math.Sqrt"`
Incorrect: `export
ARCADE_DISABLED_TOOLS="Math.Add@0.1.0,Spotify.get_available_devices,Sqrt`
This commit is contained in:
Eric Gustin 2025-01-21 15:56:40 -08:00 committed by GitHub
parent 899c84929b
commit 1bd8eac6ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 98 additions and 5 deletions

View file

@ -100,11 +100,6 @@ def serve_default_worker(
if not toolkits: if not toolkits:
raise RuntimeError("No toolkits found in Python environment.") raise RuntimeError("No toolkits found in Python environment.")
logger.info("Serving the following toolkits:")
for toolkit in toolkits:
num_tools = sum(len(tools) for tools in toolkit.tools.values())
logger.info(f" - {toolkit.name} ({toolkit.package_name}): {num_tools} tools")
worker_secret = os.environ.get("ARCADE_WORKER_SECRET") worker_secret = os.environ.get("ARCADE_WORKER_SECRET")
if not disable_auth and not worker_secret: if not disable_auth and not worker_secret:
logger.warning( logger.warning(
@ -124,8 +119,19 @@ def serve_default_worker(
worker = FastAPIWorker( worker = FastAPIWorker(
app, secret=worker_secret, disable_auth=disable_auth, otel_meter=otel_handler.get_meter() app, secret=worker_secret, disable_auth=disable_auth, otel_meter=otel_handler.get_meter()
) )
toolkit_tool_counts = {}
for toolkit in toolkits: for toolkit in toolkits:
prev_tool_count = worker.catalog.get_tool_count()
worker.register_toolkit(toolkit) worker.register_toolkit(toolkit)
new_tool_count = worker.catalog.get_tool_count()
toolkit_tool_counts[f"{toolkit.name} ({toolkit.package_name})"] = (
new_tool_count - prev_tool_count
)
logger.info("Serving the following toolkits:")
for name, tool_count in toolkit_tool_counts.items():
logger.info(f" - {name}: {tool_count} tools")
logger.info("Starting FastAPI server...") logger.info("Starting FastAPI server...")

View file

@ -1,5 +1,8 @@
import asyncio import asyncio
import inspect import inspect
import logging
import os
import re
import typing import typing
from collections.abc import Iterator from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
@ -49,6 +52,8 @@ from arcade.core.utils import (
snake_to_pascal_case, snake_to_pascal_case,
) )
logger = logging.getLogger(__name__)
InnerWireType = Literal["string", "integer", "number", "boolean", "json"] InnerWireType = Literal["string", "integer", "number", "boolean", "json"]
WireType = Union[InnerWireType, Literal["array"]] WireType = Union[InnerWireType, Literal["array"]]
@ -112,6 +117,34 @@ class ToolCatalog(BaseModel):
_tools: dict[FullyQualifiedName, MaterializedTool] = {} _tools: dict[FullyQualifiedName, MaterializedTool] = {}
_disabled_tools: set[str] = set()
def __init__(self, **data) -> None: # type: ignore[no-untyped-def]
super().__init__(**data)
self._load_disabled_tools()
def _load_disabled_tools(self) -> None:
"""Load disabled tools from the environment variable.
The ARCADE_DISABLED_TOOLS environment variable should contain a
comma-separated list of tools that are to be excluded from the
catalog.
The expected format for each disabled tool is:
- [CamelCaseToolkitName][TOOL_NAME_SEPARATOR][CamelCaseToolName]
"""
disabled_tools = os.getenv("ARCADE_DISABLED_TOOLS", "").strip().split(",")
if not disabled_tools:
return
pattern = re.compile(rf"^[a-zA-Z]+{re.escape(TOOL_NAME_SEPARATOR)}[a-zA-Z]+$")
for tool in disabled_tools:
if not pattern.match(tool):
continue
self._disabled_tools.add(tool.lower())
def add_tool( def add_tool(
self, self,
tool_func: Callable, tool_func: Callable,
@ -146,6 +179,10 @@ class ToolCatalog(BaseModel):
if fully_qualified_name in self._tools: if fully_qualified_name in self._tools:
raise KeyError(f"Tool '{definition.name}' already exists in the catalog.") raise KeyError(f"Tool '{definition.name}' already exists in the catalog.")
if str(fully_qualified_name).lower() in self._disabled_tools:
logger.info(f"Tool '{fully_qualified_name!s}' is disabled and will not be cataloged.")
return
self._tools[fully_qualified_name] = MaterializedTool( self._tools[fully_qualified_name] = MaterializedTool(
definition=definition, definition=definition,
tool=tool_func, tool=tool_func,
@ -271,6 +308,12 @@ class ToolCatalog(BaseModel):
raise ValueError(f"Tool {name} not found.") raise ValueError(f"Tool {name} not found.")
def get_tool_count(self) -> int:
"""
Get the number of tools in the catalog.
"""
return len(self._tools)
@staticmethod @staticmethod
def create_tool_definition( def create_tool_definition(
tool: Callable, tool: Callable,

View file

@ -136,3 +136,47 @@ def test_get_tool_by_name_with_invalid_version():
with pytest.raises(ValueError): with pytest.raises(ValueError):
catalog.get_tool_by_name("SampleToolkit.SampleTool", version="2.0.0") catalog.get_tool_by_name("SampleToolkit.SampleTool", version="2.0.0")
def test_load_disabled_tools(monkeypatch):
disabled_tools = (
"SampleToolkitOne.SampleToolOne," # valid
+ "SampleToolkitOne_SampleToolTwo," # invalid
+ "SampleToolkitTwo.SampleToolThree," # valid
+ "SampleToolkitTwo.SampleToolFour@0.0.1," # invalid
+ "SampleToolkitThree_SampleToolFive@0.0.1," # invalid
+ "SampleToolkitFour.sample_tool_six," # invalid
+ "sample_toolkit5.SampleTool7," # invalid
+ "sample_toolkit6.sample_tool_8" # invalid
)
expected_disabled_tools = {
"sampletoolkitone.sampletoolone",
"sampletoolkittwo.sampletoolthree",
}
monkeypatch.setenv("ARCADE_DISABLED_TOOLS", disabled_tools)
catalog = ToolCatalog()
assert catalog._disabled_tools == expected_disabled_tools
def test_add_tool_with_disabled_tool(monkeypatch):
monkeypatch.setenv("ARCADE_DISABLED_TOOLS", "SampleToolkitOne.SampleTool")
catalog = ToolCatalog()
catalog.add_tool(sample_tool, "SampleToolkitOne")
assert len(catalog._tools) == 0
def test_add_tool_with_empty_string_disabled_tools(monkeypatch):
monkeypatch.setenv("ARCADE_DISABLED_TOOLS", "")
catalog = ToolCatalog()
catalog.add_tool(sample_tool, "SampleToolkitOne")
assert len(catalog._tools) == 1
def test_add_tool_with_whitespace_disabled_tools(monkeypatch):
monkeypatch.setenv("ARCADE_DISABLED_TOOLS", " ")
catalog = ToolCatalog()
catalog.add_tool(sample_tool, "SampleToolkitOne")
assert len(catalog._tools) == 1