172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
import ast
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
import json
|
|
import math
|
|
import operator
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
|
|
|
|
class CurrentTimeArguments(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
timezone: str = Field(default="UTC", description="IANA timezone, e.g. Asia/Shanghai")
|
|
|
|
|
|
class CalculatorArguments(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
expression: str = Field(description="Arithmetic expression to evaluate")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ToolSpec:
|
|
name: str
|
|
description: str
|
|
arguments_model: type[BaseModel]
|
|
handler: Callable[[BaseModel], dict[str, object]]
|
|
|
|
def api_definition(self) -> dict[str, object]:
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"parameters": self.arguments_model.model_json_schema(),
|
|
},
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ToolExecutionResult:
|
|
content: str
|
|
is_error: bool
|
|
|
|
|
|
class ToolRegistry:
|
|
def __init__(self, timeout_seconds: float = 5.0) -> None:
|
|
if timeout_seconds <= 0:
|
|
raise ValueError("tool timeout must be greater than zero")
|
|
self.timeout_seconds = timeout_seconds
|
|
specs = [
|
|
ToolSpec(
|
|
name="get_current_time",
|
|
description="Get the current date and time in an IANA timezone.",
|
|
arguments_model=CurrentTimeArguments,
|
|
handler=_get_current_time,
|
|
),
|
|
ToolSpec(
|
|
name="calculate",
|
|
description="Safely evaluate a basic arithmetic expression.",
|
|
arguments_model=CalculatorArguments,
|
|
handler=_calculate,
|
|
),
|
|
]
|
|
self._specs = {spec.name: spec for spec in specs}
|
|
|
|
def definitions(self) -> list[dict[str, object]]:
|
|
return [spec.api_definition() for spec in self._specs.values()]
|
|
|
|
async def execute(self, name: str, arguments: str) -> ToolExecutionResult:
|
|
spec = self._specs.get(name)
|
|
if spec is None:
|
|
return _tool_error("unknown_tool", f"Unknown tool: {name}")
|
|
|
|
try:
|
|
raw_arguments = json.loads(arguments)
|
|
if not isinstance(raw_arguments, dict):
|
|
raise ValueError("arguments must be a JSON object")
|
|
parsed_arguments = spec.arguments_model.model_validate(raw_arguments)
|
|
except (json.JSONDecodeError, ValidationError, ValueError) as exc:
|
|
return _tool_error("invalid_arguments", str(exc))
|
|
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
asyncio.to_thread(spec.handler, parsed_arguments),
|
|
timeout=self.timeout_seconds,
|
|
)
|
|
return ToolExecutionResult(
|
|
content=json.dumps(result, ensure_ascii=False),
|
|
is_error=False,
|
|
)
|
|
except TimeoutError:
|
|
return _tool_error("timeout", f"Tool {name} timed out")
|
|
except Exception:
|
|
return _tool_error("execution_failed", f"Tool {name} failed")
|
|
|
|
|
|
def _tool_error(code: str, message: str) -> ToolExecutionResult:
|
|
return ToolExecutionResult(
|
|
content=json.dumps(
|
|
{"error": {"code": code, "message": message}},
|
|
ensure_ascii=False,
|
|
),
|
|
is_error=True,
|
|
)
|
|
|
|
|
|
def _get_current_time(arguments: BaseModel) -> dict[str, object]:
|
|
assert isinstance(arguments, CurrentTimeArguments)
|
|
try:
|
|
timezone = ZoneInfo(arguments.timezone)
|
|
except ZoneInfoNotFoundError as exc:
|
|
raise ValueError("unknown timezone") from exc
|
|
now = datetime.now(timezone)
|
|
return {"timezone": arguments.timezone, "datetime": now.isoformat()}
|
|
|
|
|
|
_BINARY_OPERATORS: dict[type[ast.operator], Callable[[Any, Any], Any]] = {
|
|
ast.Add: operator.add,
|
|
ast.Sub: operator.sub,
|
|
ast.Mult: operator.mul,
|
|
ast.Div: operator.truediv,
|
|
ast.FloorDiv: operator.floordiv,
|
|
ast.Mod: operator.mod,
|
|
ast.Pow: operator.pow,
|
|
}
|
|
_UNARY_OPERATORS: dict[type[ast.unaryop], Callable[[Any], Any]] = {
|
|
ast.UAdd: operator.pos,
|
|
ast.USub: operator.neg,
|
|
}
|
|
_MAX_EXPRESSION_LENGTH = 200
|
|
_MAX_ABSOLUTE_RESULT = 1e100
|
|
_MAX_EXPONENT = 100
|
|
|
|
|
|
def _calculate(arguments: BaseModel) -> dict[str, object]:
|
|
assert isinstance(arguments, CalculatorArguments)
|
|
expression = arguments.expression.strip()
|
|
if not expression or len(expression) > _MAX_EXPRESSION_LENGTH:
|
|
raise ValueError("expression is empty or too long")
|
|
tree = ast.parse(expression, mode="eval")
|
|
result = _evaluate_node(tree.body)
|
|
if isinstance(result, float) and not math.isfinite(result):
|
|
raise ValueError("result is not finite")
|
|
if abs(result) > _MAX_ABSOLUTE_RESULT:
|
|
raise ValueError("result is too large")
|
|
return {"expression": expression, "result": result}
|
|
|
|
|
|
def _evaluate_node(node: ast.AST) -> int | float:
|
|
if isinstance(node, ast.Constant):
|
|
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
|
raise ValueError("only numeric constants are allowed")
|
|
return node.value
|
|
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPERATORS:
|
|
return _UNARY_OPERATORS[type(node.op)](_evaluate_node(node.operand))
|
|
if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS:
|
|
left = _evaluate_node(node.left)
|
|
right = _evaluate_node(node.right)
|
|
if isinstance(node.op, ast.Pow) and abs(right) > _MAX_EXPONENT:
|
|
raise ValueError("exponent is too large")
|
|
result = _BINARY_OPERATORS[type(node.op)](left, right)
|
|
if abs(result) > _MAX_ABSOLUTE_RESULT:
|
|
raise ValueError("intermediate result is too large")
|
|
return result
|
|
raise ValueError("unsupported expression")
|