| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124 |
- """Tool registry: typed tools the assistant may invoke.
- Arguments arrive from the model as a JSON string; they are parsed and validated
- against the tool's schema before anything runs. Unknown tools, malformed JSON,
- missing/extra/wrong-typed arguments are all rejected (parse, don't trust).
- """
- import json
- import logging
- from dataclasses import dataclass
- from datetime import datetime
- from typing import Callable
- logger = logging.getLogger(__name__)
- READ_ONLY = "read_only"
- MUTATING = "mutating"
- _TYPE_MAP = {
- "string": str,
- "number": (int, float),
- "integer": int,
- "boolean": bool,
- "array": list,
- "object": dict,
- }
- @dataclass
- class Tool:
- """A callable tool with an OpenAI-style JSON schema."""
- name: str
- description: str
- parameters: dict
- run: Callable[[dict], str]
- side_effect: str = READ_ONLY
- requires_confirmation: bool = False
- def spec(self) -> dict:
- """OpenAI `tools` entry for this tool."""
- return {
- "type": "function",
- "function": {
- "name": self.name,
- "description": self.description,
- "parameters": self.parameters,
- },
- }
- def _validate_schema(schema: dict, args: dict) -> str | None:
- """Return an error message, or None when the arguments are acceptable."""
- properties = schema.get("properties", {})
- for key in schema.get("required", []):
- if key not in args:
- return f"missing required argument '{key}'"
- for key, value in args.items():
- if key not in properties:
- return f"unexpected argument '{key}'"
- expected = properties[key].get("type")
- if expected not in _TYPE_MAP:
- continue
- if expected in ("integer", "number") and isinstance(value, bool):
- return f"argument '{key}' must be {expected}"
- if not isinstance(value, _TYPE_MAP[expected]):
- return f"argument '{key}' must be {expected}"
- return None
- class ToolRegistry:
- """Holds the tools the assistant may call."""
- def __init__(self) -> None:
- self._tools: dict[str, Tool] = {}
- def register(self, tool: Tool) -> None:
- self._tools[tool.name] = tool
- def get(self, name: str) -> Tool | None:
- return self._tools.get(name)
- def names(self) -> list[str]:
- return list(self._tools)
- def specs(self) -> list[dict]:
- return [tool.spec() for tool in self._tools.values()]
- def validate(self, name: str, arguments) -> tuple[dict | None, str | None]:
- """Parse and validate model-supplied arguments for `name`."""
- tool = self.get(name)
- if tool is None:
- return None, f"unknown tool: {name}"
- if isinstance(arguments, str):
- try:
- parsed = json.loads(arguments) if arguments.strip() else {}
- except json.JSONDecodeError:
- return None, f"invalid JSON arguments for {name}"
- else:
- parsed = dict(arguments or {})
- if not isinstance(parsed, dict):
- return None, f"arguments must be an object for {name}"
- error = _validate_schema(tool.parameters, parsed)
- if error is not None:
- return None, f"{name}: {error}"
- return parsed, None
- def _get_datetime(_args: dict) -> str:
- return datetime.now().astimezone().isoformat(timespec="seconds")
- def default_registry() -> ToolRegistry:
- """Registry with the built-in tools."""
- registry = ToolRegistry()
- registry.register(
- Tool(
- name="get_datetime",
- description="Get the current local date and time.",
- parameters={"type": "object", "properties": {}, "required": []},
- run=_get_datetime,
- )
- )
- return registry
|