File size: 11,896 Bytes
d0d2f42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
"""Registro de herramientas para agentes de IA.

Provee un sistema de definición, validación y ejecución de herramientas
compatible con los formatos de OpenAI y Anthropic. Incluye herramientas
de ejemplo para búsqueda, fecha/hora y cálculo seguro.
"""

from __future__ import annotations

import ast
import datetime
import logging
import operator
from dataclasses import dataclass
from typing import Any, Callable

logger = logging.getLogger("orchestration.tools")

# Operadores permitidos para la calculadora segura
_ALLOWED_OPERATORS = {
    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,
    ast.USub: operator.neg,
    ast.UAdd: operator.pos,
}

_JSON_TYPE_MAP: dict[str, type | tuple[type, ...]] = {
    "string": str,
    "integer": int,
    "number": (int, float),
    "boolean": bool,
    "array": list,
    "object": dict,
}


@dataclass
class ToolDefinition:
    """Definición de una herramienta para agentes.

    Args:
        name: Nombre único de la herramienta.
        description: Descripción de lo que hace la herramienta.
        parameters: Esquema JSON Schema de los parámetros.
        function: Función que implementa la herramienta.
        requires_confirmation: Si requiere confirmación del usuario.
        timeout_seconds: Tiempo máximo de ejecución.
    """

    name: str
    description: str
    parameters: dict
    function: Callable
    requires_confirmation: bool = False
    timeout_seconds: float = 30.0

    def validate_params(self, params: dict) -> tuple[bool, str]:
        """Valida parámetros contra el JSON Schema (validación manual).

        Args:
            params: Diccionario de parámetros a validar.

        Returns:
            Tupla ``(válido, mensaje_error)``. Si es válido, el mensaje
            es una cadena vacía.
        """
        schema = self.parameters

        # Verificar campos requeridos
        required = schema.get("required", [])
        for field_name in required:
            if field_name not in params:
                return False, f"Missing required parameter: '{field_name}'"

        # Verificar tipos y enums
        properties = schema.get("properties", {})
        for param_name, value in params.items():
            if param_name not in properties:
                continue

            prop_schema = properties[param_name]

            # Verificar tipo
            expected_type_str = prop_schema.get("type")
            if expected_type_str and expected_type_str in _JSON_TYPE_MAP:
                expected_type = _JSON_TYPE_MAP[expected_type_str]
                # bool es subclase de int en Python, tratar como caso especial
                if expected_type_str == "integer" and isinstance(value, bool):
                    return (
                        False,
                        f"Parameter '{param_name}' expected type "
                        f"'{expected_type_str}', got 'boolean'",
                    )
                if not isinstance(value, expected_type):
                    actual = type(value).__name__
                    return (
                        False,
                        f"Parameter '{param_name}' expected type "
                        f"'{expected_type_str}', got '{actual}'",
                    )

            # Verificar enum
            enum_values = prop_schema.get("enum")
            if enum_values is not None and value not in enum_values:
                return (
                    False,
                    f"Parameter '{param_name}' must be one of {enum_values}, "
                    f"got '{value}'",
                )

        return True, ""

    def execute(self, params: dict) -> str:
        """Ejecuta la herramienta con los parámetros dados.

        Args:
            params: Diccionario de parámetros.

        Returns:
            Resultado como cadena, o mensaje de error.
        """
        valid, error_msg = self.validate_params(params)
        if not valid:
            return f"Validation error: {error_msg}"

        try:
            result = self.function(**params)
            return str(result)
        except Exception as exc:
            return f"Execution error: {type(exc).__name__}: {exc}"


class ToolRegistry:
    """Registro centralizado de herramientas disponibles para agentes.

    Permite registrar, consultar y ejecutar herramientas, y exportar
    sus definiciones en formatos compatibles con OpenAI y Anthropic.
    """

    def __init__(self) -> None:
        self._tools: dict[str, ToolDefinition] = {}

    def register(self, tool: ToolDefinition) -> None:
        """Registra una herramienta.

        Raises:
            ValueError: Si ya existe una herramienta con el mismo nombre.
        """
        if tool.name in self._tools:
            raise ValueError(f"Tool '{tool.name}' is already registered")
        self._tools[tool.name] = tool
        logger.info("Registered tool: %s", tool.name)

    def register_function(
        self,
        name: str,
        description: str,
        parameters: dict,
        function: Callable,
        **kwargs: Any,
    ) -> None:
        """Atajo para registrar una función como herramienta.

        Args:
            name: Nombre de la herramienta.
            description: Descripción.
            parameters: JSON Schema de parámetros.
            function: Función implementadora.
            **kwargs: Parámetros extra para ``ToolDefinition``.
        """
        tool = ToolDefinition(
            name=name,
            description=description,
            parameters=parameters,
            function=function,
            **kwargs,
        )
        self.register(tool)

    def get(self, name: str) -> ToolDefinition:
        """Obtiene una herramienta por nombre.

        Raises:
            KeyError: Si la herramienta no existe.
        """
        if name not in self._tools:
            raise KeyError(
                f"Tool '{name}' not found. "
                f"Available: {list(self._tools.keys())}"
            )
        return self._tools[name]

    def remove(self, name: str) -> None:
        """Elimina una herramienta del registro.

        Raises:
            KeyError: Si la herramienta no existe.
        """
        if name not in self._tools:
            raise KeyError(f"Tool '{name}' not found")
        del self._tools[name]
        logger.info("Removed tool: %s", name)

    def list_tools(self) -> list[str]:
        """Retorna la lista de nombres de herramientas registradas."""
        return list(self._tools.keys())

    def to_openai_format(self) -> list[dict]:
        """Exporta las herramientas en formato OpenAI function calling."""
        return [
            {
                "type": "function",
                "function": {
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": tool.parameters,
                },
            }
            for tool in self._tools.values()
        ]

    def to_anthropic_format(self) -> list[dict]:
        """Exporta las herramientas en formato Anthropic tool use."""
        return [
            {
                "name": tool.name,
                "description": tool.description,
                "input_schema": tool.parameters,
            }
            for tool in self._tools.values()
        ]

    def execute_tool(self, name: str, params: dict) -> str:
        """Ejecuta una herramienta por nombre con los parámetros dados.

        Args:
            name: Nombre de la herramienta.
            params: Diccionario de parámetros.

        Returns:
            Resultado como cadena, o mensaje de error.
        """
        try:
            tool = self.get(name)
            return tool.execute(params)
        except Exception as exc:
            return f"Error: {type(exc).__name__}: {exc}"


# ---------------------------------------------------------------------------
# Herramientas de ejemplo
# ---------------------------------------------------------------------------


def search_documents(query: str, top_k: int = 5) -> str:
    """Busca documentos relevantes (mock).

    Args:
        query: Consulta de búsqueda.
        top_k: Número de resultados a retornar.

    Returns:
        Texto con resultados placeholder.
    """
    results = [
        f"Document {i + 1}: Result for '{query}' (relevance: {0.9 - i * 0.1:.1f})"
        for i in range(top_k)
    ]
    return "\n".join(results)


def get_current_datetime() -> str:
    """Retorna la fecha y hora actual en formato ISO."""
    return datetime.datetime.now().isoformat()


def _safe_eval_node(node: ast.AST) -> int | float:
    """Evalúa un nodo AST aritmético de forma segura."""
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp):
        op_type = type(node.op)
        if op_type not in _ALLOWED_OPERATORS:
            raise ValueError(f"Unsupported operator: {op_type.__name__}")
        left = _safe_eval_node(node.left)
        right = _safe_eval_node(node.right)
        return _ALLOWED_OPERATORS[op_type](left, right)
    if isinstance(node, ast.UnaryOp):
        op_type = type(node.op)
        if op_type not in _ALLOWED_OPERATORS:
            raise ValueError(f"Unsupported operator: {op_type.__name__}")
        operand = _safe_eval_node(node.operand)
        return _ALLOWED_OPERATORS[op_type](operand)
    raise ValueError(f"Unsupported expression node: {type(node).__name__}")


def calculate(expression: str) -> str:
    """Evalúa una expresión aritmética de forma segura usando AST.

    Solo permite constantes numéricas y operadores aritméticos básicos.
    Nunca usa ``eval()`` directamente.

    Args:
        expression: Expresión aritmética (e.g., ``"2 + 3 * 4"``).

    Returns:
        Resultado como cadena.

    Raises:
        ValueError: Si la expresión contiene elementos no permitidos.
    """
    tree = ast.parse(expression, mode="eval")
    result = _safe_eval_node(tree.body)
    return str(result)


if __name__ == "__main__":
    # Demo de herramientas
    registry = ToolRegistry()

    registry.register(
        ToolDefinition(
            name="search",
            description="Search documents",
            parameters={
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Search query"},
                    "top_k": {"type": "integer", "description": "Results count"},
                },
                "required": ["query"],
            },
            function=search_documents,
        )
    )

    registry.register(
        ToolDefinition(
            name="datetime",
            description="Get current date and time",
            parameters={"type": "object", "properties": {}},
            function=get_current_datetime,
        )
    )

    registry.register(
        ToolDefinition(
            name="calculate",
            description="Evaluate arithmetic expression",
            parameters={
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "Arithmetic expression",
                    },
                },
                "required": ["expression"],
            },
            function=calculate,
        )
    )

    print("Tools:", registry.list_tools())
    print("\nOpenAI format:", registry.to_openai_format())
    print("\nSearch result:", registry.execute_tool("search", {"query": "AI agents"}))
    print("\nDatetime:", registry.execute_tool("datetime", {}))
    print("\nCalculate:", registry.execute_tool("calculate", {"expression": "2 + 3 * 4"}))