tools.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. """Tool registry: typed tools the assistant may invoke.
  2. Arguments arrive from the model as a JSON string; they are parsed and validated
  3. against the tool's schema before anything runs. Unknown tools, malformed JSON,
  4. missing/extra/wrong-typed arguments are all rejected (parse, don't trust).
  5. """
  6. import json
  7. import logging
  8. from dataclasses import dataclass
  9. from datetime import datetime
  10. from typing import Callable
  11. logger = logging.getLogger(__name__)
  12. READ_ONLY = "read_only"
  13. MUTATING = "mutating"
  14. _TYPE_MAP = {
  15. "string": str,
  16. "number": (int, float),
  17. "integer": int,
  18. "boolean": bool,
  19. "array": list,
  20. "object": dict,
  21. }
  22. @dataclass
  23. class Tool:
  24. """A callable tool with an OpenAI-style JSON schema."""
  25. name: str
  26. description: str
  27. parameters: dict
  28. run: Callable[[dict], str]
  29. side_effect: str = READ_ONLY
  30. requires_confirmation: bool = False
  31. def spec(self) -> dict:
  32. """OpenAI `tools` entry for this tool."""
  33. return {
  34. "type": "function",
  35. "function": {
  36. "name": self.name,
  37. "description": self.description,
  38. "parameters": self.parameters,
  39. },
  40. }
  41. def _validate_schema(schema: dict, args: dict) -> str | None:
  42. """Return an error message, or None when the arguments are acceptable."""
  43. properties = schema.get("properties", {})
  44. for key in schema.get("required", []):
  45. if key not in args:
  46. return f"missing required argument '{key}'"
  47. for key, value in args.items():
  48. if key not in properties:
  49. return f"unexpected argument '{key}'"
  50. expected = properties[key].get("type")
  51. if expected not in _TYPE_MAP:
  52. continue
  53. if expected in ("integer", "number") and isinstance(value, bool):
  54. return f"argument '{key}' must be {expected}"
  55. if not isinstance(value, _TYPE_MAP[expected]):
  56. return f"argument '{key}' must be {expected}"
  57. return None
  58. class ToolRegistry:
  59. """Holds the tools the assistant may call."""
  60. def __init__(self) -> None:
  61. self._tools: dict[str, Tool] = {}
  62. def register(self, tool: Tool) -> None:
  63. self._tools[tool.name] = tool
  64. def get(self, name: str) -> Tool | None:
  65. return self._tools.get(name)
  66. def names(self) -> list[str]:
  67. return list(self._tools)
  68. def specs(self) -> list[dict]:
  69. return [tool.spec() for tool in self._tools.values()]
  70. def validate(self, name: str, arguments) -> tuple[dict | None, str | None]:
  71. """Parse and validate model-supplied arguments for `name`."""
  72. tool = self.get(name)
  73. if tool is None:
  74. return None, f"unknown tool: {name}"
  75. if isinstance(arguments, str):
  76. try:
  77. parsed = json.loads(arguments) if arguments.strip() else {}
  78. except json.JSONDecodeError:
  79. return None, f"invalid JSON arguments for {name}"
  80. else:
  81. parsed = dict(arguments or {})
  82. if not isinstance(parsed, dict):
  83. return None, f"arguments must be an object for {name}"
  84. error = _validate_schema(tool.parameters, parsed)
  85. if error is not None:
  86. return None, f"{name}: {error}"
  87. return parsed, None
  88. def _get_datetime(_args: dict) -> str:
  89. return datetime.now().astimezone().isoformat(timespec="seconds")
  90. def default_registry() -> ToolRegistry:
  91. """Registry with the built-in tools."""
  92. registry = ToolRegistry()
  93. registry.register(
  94. Tool(
  95. name="get_datetime",
  96. description="Get the current local date and time.",
  97. parameters={"type": "object", "properties": {}, "required": []},
  98. run=_get_datetime,
  99. )
  100. )
  101. return registry