"""
The protocol that a Smarter Extension's tools implement.
A tool is a subclass of :class:`SmarterExtensionTool`. It declares its name, the Pydantic
model of its arguments, and a :meth:`~SmarterExtensionTool.call` method:
.. code-block:: python
from pydantic import BaseModel, Field
from smarter.apps.llmtool.extension import ExtensionContext, ExtensionToolError, SmarterExtensionTool
class WeatherArguments(BaseModel):
location: str = Field(..., description="City and state, e.g. San Francisco, CA")
class GetWeather(SmarterExtensionTool):
\"\"\"Get the current weather and a 24-hour forecast for a location.\"\"\"
name = "get_weather"
arguments_model = WeatherArguments
def call(self, arguments: WeatherArguments, context: ExtensionContext):
api_key = context.settings.google_maps_api_key.get_secret_value()
...
return {"location": arguments.location, "forecast": [...]}
The platform does everything else:
- the OpenAI tool definition: the class docstring is its description, and the arguments
model's JSON schema is its parameters;
- parsing and validating the LLM's arguments, and returning validation errors to the LLM;
- the ``llm_tool_presented``, ``llm_tool_requested`` and ``llm_tool_responded`` signals;
- turning an exception into an ``{"error": ...}`` result, so that the LLM can recover;
- a timeout, from the manifest's ``[tool.smarter] timeout``.
Raise :class:`ExtensionToolError` to return a message to the LLM. Any other exception is
logged with its traceback, and the LLM is told that the tool failed.
"""
import inspect
import logging as python_logging
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, ClassVar, Optional, Union
from pydantic import BaseModel, JsonValue
from waffle import get_waffle_switch_model
from smarter.lib import logging
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.typing import SmarterDictType
if TYPE_CHECKING:
from smarter.apps.account.models import Account, UserProfile
TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,47}$")
"""A tool's name: letters, digits and underscores, starting with a letter, at most 48 characters."""
MAX_DESCRIPTION_LENGTH = 1024
"""The maximum length of a tool description offered to the LLM."""
ExtensionLogger = Union[python_logging.Logger, logging.WaffleSwitchedLoggerWrapper]
"""The logger of an extension's code."""
[docs]
def switch_name(slug: str, name: str) -> str:
"""
Return the waffle switch name of one of an extension's switches.
:param slug: The extension's slug, e.g. ``date-calculator``.
:param name: The switch's name in the manifest, e.g. ``logging``.
:returns: e.g. ``ext_date_calculator_logging``.
"""
return f"ext_{slug.replace('-', '_')}_{name}"
[docs]
def extension_logger(slug: str) -> ExtensionLogger:
"""
Return the logger of an extension's code, switched by ``SmarterWaffleSwitches.EXTENSION_LOGGING``.
:param slug: The extension's slug.
"""
return logging.getSmarterLogger(
f"smarter_extensions.{slug}", any_switches=[SmarterWaffleSwitches.EXTENSION_LOGGING]
)
[docs]
@dataclass(frozen=True)
class ExtensionContext:
"""
What a tool call knows about where it runs.
:param slug: The extension's slug.
:param version: The extension's installed version.
:param settings: The extension's settings, one attribute per setting in its manifest.
A ``secret`` setting is a :class:`pydantic.SecretStr`; an unset setting is None.
:param account: The account of the LLMClient that called the tool, if any.
:param user_profile: The user whose prompt called the tool, if any.
:param llmclient: The LLMClient that called the tool, if any.
"""
slug: str
version: str
settings: BaseModel
account: Optional["Account"] = None
user_profile: Optional["UserProfile"] = None
llmclient: Optional[object] = None
logger: ExtensionLogger = field(default=None, compare=False) # type: ignore[assignment]
def __post_init__(self):
if self.logger is None:
object.__setattr__(self, "logger", extension_logger(self.slug))
[docs]
def switch_is_active(self, name: str) -> bool:
"""
Return whether one of the extension's waffle switches is on.
:param name: The switch's name in the manifest's ``[[tool.smarter.switches]]``, without the ``ext_<slug>_`` prefix.
:returns: True if the switch exists and is on.
"""
switch = get_waffle_switch_model().get(switch_name(self.slug, name))
return bool(switch.pk) and switch.is_active()
__all__ = [
"ExtensionContext",
"ExtensionLogger",
"ExtensionToolError",
"SmarterExtensionTool",
"extension_logger",
"switch_name",
]