File size: 6,080 Bytes
e0265b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


class RegistryError(RuntimeError):
    pass


@dataclass(frozen=True, slots=True)
class ToolSpec:
    id: str
    name: str
    description: str
    category: str
    entry_function: str
    arguments: tuple[str, ...] = ()
    required_arguments: tuple[str, ...] = ()
    capabilities: tuple[str, ...] = ()
    model_trainers: tuple[str, ...] = ()
    generation_options: dict[str, Any] = field(default_factory=dict)
    requires_confirmation: bool = False
    enabled: bool = True
    demo: bool = False
    backend: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ToolSpec":
        required = {"id", "name", "description", "category", "backend"}
        missing = required - data.keys()
        if missing:
            raise RegistryError(f"Tool entry is missing: {', '.join(sorted(missing))}")
        return cls(
            id=str(data["id"]),
            name=str(data["name"]),
            description=str(data["description"]),
            category=str(data["category"]),
            entry_function=str(data.get("entry_function", "")),
            arguments=tuple(str(item) for item in data.get("arguments", [])),
            required_arguments=tuple(
                str(item) for item in data.get("required_arguments", [])
            ),
            capabilities=tuple(str(item) for item in data.get("capabilities", [])),
            model_trainers=tuple(
                str(item) for item in data.get("model_trainers", [])
            ),
            generation_options=dict(data.get("generation_options", {})),
            requires_confirmation=bool(data.get("requires_confirmation", False)),
            enabled=bool(data.get("enabled", True)),
            demo=bool(data.get("demo", False)),
            backend=dict(data["backend"]),
        )


class ToolRegistry:
    def __init__(self, root: Path) -> None:
        self.root = root.resolve()
        self.path = self.root / "config" / "tools.json"
        self._tools: dict[str, ToolSpec] = {}
        self.load()

    def load(self) -> None:
        try:
            payload = json.loads(self.path.read_text(encoding="utf-8"))
        except FileNotFoundError as exc:
            raise RegistryError(f"Tool registry not found: {self.path}") from exc
        except json.JSONDecodeError as exc:
            raise RegistryError(f"Tool registry is invalid JSON: {exc}") from exc

        entries = payload.get("tools")
        if not isinstance(entries, list):
            raise RegistryError("Tool registry must contain a 'tools' list.")

        loaded: dict[str, ToolSpec] = {}
        for entry in entries:
            spec = ToolSpec.from_dict(entry)
            if spec.id in loaded:
                raise RegistryError(f"Duplicate tool id: {spec.id}")
            loaded[spec.id] = spec
        external_path = self.root / "config" / "external_tools.json"
        try:
            external_payload = json.loads(external_path.read_text(encoding="utf-8"))
            external_entries = external_payload.get("tools", [])
        except FileNotFoundError:
            external_entries = []
        except json.JSONDecodeError as exc:
            raise RegistryError(f"External tool registry is invalid JSON: {exc}") from exc
        if not isinstance(external_entries, list):
            raise RegistryError("External tool registry must contain a 'tools' list.")
        for entry in external_entries:
            if not isinstance(entry, dict):
                raise RegistryError("External tool entry must be an object.")
            safe_entry = dict(entry)
            tool_id = str(safe_entry.get("id", ""))
            backend = dict(safe_entry.get("backend", {}))
            path = Path(str(backend.get("path", ""))).expanduser()
            root = Path(str(backend.get("root", ""))).expanduser()
            if not tool_id.startswith("external_"):
                raise RegistryError("External tool ids must start with 'external_'.")
            if tool_id in loaded:
                raise RegistryError(f"External tool cannot replace registered tool: {tool_id}")
            if backend.get("type") != "script" or not path.is_absolute() or not root.is_absolute():
                raise RegistryError(f"External tool {tool_id} has an invalid script backend.")
            try:
                path.resolve().relative_to(root.resolve())
            except ValueError as exc:
                raise RegistryError(f"External tool {tool_id} script is outside its folder.") from exc
            safe_entry["requires_confirmation"] = True
            safe_entry["demo"] = False
            spec = ToolSpec.from_dict(safe_entry)
            loaded[spec.id] = spec
        self._tools = loaded

    def get(self, tool_id: str, *, require_enabled: bool = True) -> ToolSpec:
        try:
            tool = self._tools[tool_id]
        except KeyError as exc:
            raise RegistryError(f"Unregistered tool: {tool_id}") from exc
        if require_enabled and not tool.enabled:
            raise RegistryError(f"Tool is not configured: {tool.name}")
        return tool

    def all(self) -> list[ToolSpec]:
        return list(self._tools.values())

    def enabled(self) -> list[ToolSpec]:
        return [tool for tool in self._tools.values() if tool.enabled]

    def safe_llm_catalog(self) -> list[dict[str, Any]]:
        return [
            {
                "id": tool.id,
                "name": tool.name,
                "description": tool.description,
                "arguments": list(tool.arguments),
                "required_arguments": list(tool.required_arguments),
                "capabilities": list(tool.capabilities),
                "model_trainers": list(tool.model_trainers),
                "generation_options": dict(tool.generation_options),
                "requires_confirmation": tool.requires_confirmation,
            }
            for tool in self.enabled()
        ]