onetool-mcp 1.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot/registry/parser.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""AST parsing utilities for extracting function information."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from docstring_parser import parse as parse_docstring_lib
|
|
9
|
+
|
|
10
|
+
from .models import ArgInfo, ToolInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_function(
|
|
14
|
+
node: ast.FunctionDef, module: str, pack: str | None = None
|
|
15
|
+
) -> ToolInfo:
|
|
16
|
+
"""Extract information from a function AST node.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
node: AST FunctionDef node.
|
|
20
|
+
module: Module path for the function.
|
|
21
|
+
pack: Optional pack name for namespace-qualified tool names.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
ToolInfo with extracted signature and docstring info.
|
|
25
|
+
"""
|
|
26
|
+
# Extract signature (with pack-qualified name if pack is provided)
|
|
27
|
+
signature = extract_signature(node, pack=pack)
|
|
28
|
+
|
|
29
|
+
# Parse docstring
|
|
30
|
+
docstring = ast.get_docstring(node) or ""
|
|
31
|
+
doc_info = parse_docstring(docstring)
|
|
32
|
+
|
|
33
|
+
# Extract args
|
|
34
|
+
args = extract_args(node, doc_info.get("args", {}))
|
|
35
|
+
|
|
36
|
+
# Extract @tool decorator metadata if present
|
|
37
|
+
decorator_info = extract_tool_decorator(node)
|
|
38
|
+
|
|
39
|
+
# Decorator description overrides docstring if provided
|
|
40
|
+
description = decorator_info.get("description") or doc_info.get("description", "")
|
|
41
|
+
|
|
42
|
+
# Use pack-qualified name if pack is provided
|
|
43
|
+
qualified_name = f"{pack}.{node.name}" if pack else node.name
|
|
44
|
+
|
|
45
|
+
return ToolInfo(
|
|
46
|
+
name=qualified_name,
|
|
47
|
+
pack=pack,
|
|
48
|
+
module=module,
|
|
49
|
+
signature=signature,
|
|
50
|
+
description=description,
|
|
51
|
+
args=args,
|
|
52
|
+
returns=doc_info.get("returns", ""),
|
|
53
|
+
examples=decorator_info.get("examples", []),
|
|
54
|
+
tags=decorator_info.get("tags", []),
|
|
55
|
+
enabled=decorator_info.get("enabled", True),
|
|
56
|
+
deprecated=decorator_info.get("deprecated", False),
|
|
57
|
+
deprecated_message=decorator_info.get("deprecated_message"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_tool_decorator(node: ast.FunctionDef) -> dict[str, Any]:
|
|
62
|
+
"""Extract metadata from @tool decorator if present.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
node: AST FunctionDef node.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dict with decorator metadata (description, examples, tags, enabled, deprecated).
|
|
69
|
+
"""
|
|
70
|
+
result: dict[str, Any] = {}
|
|
71
|
+
|
|
72
|
+
for decorator in node.decorator_list:
|
|
73
|
+
# Look for @tool(...) decorator
|
|
74
|
+
if isinstance(decorator, ast.Call):
|
|
75
|
+
func = decorator.func
|
|
76
|
+
if isinstance(func, ast.Name) and func.id == "tool":
|
|
77
|
+
# Extract keyword arguments
|
|
78
|
+
for keyword in decorator.keywords:
|
|
79
|
+
if keyword.arg == "description":
|
|
80
|
+
if isinstance(keyword.value, ast.Constant):
|
|
81
|
+
result["description"] = keyword.value.value
|
|
82
|
+
elif keyword.arg == "examples":
|
|
83
|
+
if isinstance(keyword.value, ast.List):
|
|
84
|
+
result["examples"] = [
|
|
85
|
+
elt.value
|
|
86
|
+
for elt in keyword.value.elts
|
|
87
|
+
if isinstance(elt, ast.Constant)
|
|
88
|
+
]
|
|
89
|
+
elif keyword.arg == "tags":
|
|
90
|
+
if isinstance(keyword.value, ast.List):
|
|
91
|
+
result["tags"] = [
|
|
92
|
+
elt.value
|
|
93
|
+
for elt in keyword.value.elts
|
|
94
|
+
if isinstance(elt, ast.Constant)
|
|
95
|
+
]
|
|
96
|
+
elif keyword.arg == "enabled":
|
|
97
|
+
if isinstance(keyword.value, ast.Constant):
|
|
98
|
+
result["enabled"] = keyword.value.value
|
|
99
|
+
elif keyword.arg == "deprecated" and isinstance(
|
|
100
|
+
keyword.value, ast.Constant
|
|
101
|
+
):
|
|
102
|
+
result["deprecated"] = keyword.value.value
|
|
103
|
+
elif keyword.arg == "deprecated_message" and isinstance(
|
|
104
|
+
keyword.value, ast.Constant
|
|
105
|
+
):
|
|
106
|
+
result["deprecated_message"] = keyword.value.value
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_signature(node: ast.FunctionDef, pack: str | None = None) -> str:
|
|
113
|
+
"""Extract function signature string.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
node: AST FunctionDef node.
|
|
117
|
+
pack: Optional pack name for namespace-qualified tool names.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Signature string like 'pack.func_name(arg: type = default) -> return_type'
|
|
121
|
+
"""
|
|
122
|
+
parts: list[str] = []
|
|
123
|
+
|
|
124
|
+
# Process regular args
|
|
125
|
+
args = node.args
|
|
126
|
+
defaults = args.defaults
|
|
127
|
+
num_defaults = len(defaults)
|
|
128
|
+
num_args = len(args.args)
|
|
129
|
+
|
|
130
|
+
for i, arg in enumerate(args.args):
|
|
131
|
+
arg_str = arg.arg
|
|
132
|
+
# Add type annotation
|
|
133
|
+
if arg.annotation:
|
|
134
|
+
arg_str += f": {annotation_to_str(arg.annotation)}"
|
|
135
|
+
|
|
136
|
+
# Add default value if present
|
|
137
|
+
default_idx = i - (num_args - num_defaults)
|
|
138
|
+
if default_idx >= 0:
|
|
139
|
+
default = defaults[default_idx]
|
|
140
|
+
arg_str += f" = {value_to_str(default)}"
|
|
141
|
+
|
|
142
|
+
parts.append(arg_str)
|
|
143
|
+
|
|
144
|
+
# Process keyword-only args
|
|
145
|
+
kw_defaults = args.kw_defaults
|
|
146
|
+
for i, arg in enumerate(args.kwonlyargs):
|
|
147
|
+
arg_str = arg.arg
|
|
148
|
+
if arg.annotation:
|
|
149
|
+
arg_str += f": {annotation_to_str(arg.annotation)}"
|
|
150
|
+
if kw_defaults[i] is not None:
|
|
151
|
+
arg_str += f" = {value_to_str(kw_defaults[i])}"
|
|
152
|
+
parts.append(arg_str)
|
|
153
|
+
|
|
154
|
+
# Build signature with pack-qualified name if pack is provided
|
|
155
|
+
func_name = f"{pack}.{node.name}" if pack else node.name
|
|
156
|
+
sig = f"{func_name}({', '.join(parts)})"
|
|
157
|
+
|
|
158
|
+
# Add return type
|
|
159
|
+
if node.returns:
|
|
160
|
+
sig += f" -> {annotation_to_str(node.returns)}"
|
|
161
|
+
|
|
162
|
+
return sig
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def annotation_to_str(node: ast.expr) -> str:
|
|
166
|
+
"""Convert AST annotation node to string.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
node: AST expression node representing a type annotation.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
String representation of the type.
|
|
173
|
+
"""
|
|
174
|
+
return ast.unparse(node)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def value_to_str(node: ast.expr | None) -> str:
|
|
178
|
+
"""Convert AST value node to string representation.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
node: AST expression node representing a default value.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
String representation of the value.
|
|
185
|
+
"""
|
|
186
|
+
if node is None:
|
|
187
|
+
return "None"
|
|
188
|
+
return ast.unparse(node)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def extract_args(
|
|
192
|
+
node: ast.FunctionDef, docstring_args: dict[str, str]
|
|
193
|
+
) -> list[ArgInfo]:
|
|
194
|
+
"""Extract argument information from function node.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
node: AST FunctionDef node.
|
|
198
|
+
docstring_args: Dict of arg_name -> description from docstring.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of ArgInfo objects.
|
|
202
|
+
"""
|
|
203
|
+
result: list[ArgInfo] = []
|
|
204
|
+
args = node.args
|
|
205
|
+
defaults = args.defaults
|
|
206
|
+
num_defaults = len(defaults)
|
|
207
|
+
num_args = len(args.args)
|
|
208
|
+
|
|
209
|
+
for i, arg in enumerate(args.args):
|
|
210
|
+
type_str = "Any"
|
|
211
|
+
if arg.annotation:
|
|
212
|
+
type_str = annotation_to_str(arg.annotation)
|
|
213
|
+
|
|
214
|
+
default_str: str | None = None
|
|
215
|
+
default_idx = i - (num_args - num_defaults)
|
|
216
|
+
if default_idx >= 0:
|
|
217
|
+
default_str = value_to_str(defaults[default_idx])
|
|
218
|
+
|
|
219
|
+
result.append(
|
|
220
|
+
ArgInfo(
|
|
221
|
+
name=arg.arg,
|
|
222
|
+
type=type_str,
|
|
223
|
+
default=default_str,
|
|
224
|
+
description=docstring_args.get(arg.arg, ""),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Keyword-only args
|
|
229
|
+
kw_defaults = args.kw_defaults
|
|
230
|
+
for i, arg in enumerate(args.kwonlyargs):
|
|
231
|
+
type_str = "Any"
|
|
232
|
+
if arg.annotation:
|
|
233
|
+
type_str = annotation_to_str(arg.annotation)
|
|
234
|
+
|
|
235
|
+
default_str = None
|
|
236
|
+
if kw_defaults[i] is not None:
|
|
237
|
+
default_str = value_to_str(kw_defaults[i])
|
|
238
|
+
|
|
239
|
+
result.append(
|
|
240
|
+
ArgInfo(
|
|
241
|
+
name=arg.arg,
|
|
242
|
+
type=type_str,
|
|
243
|
+
default=default_str,
|
|
244
|
+
description=docstring_args.get(arg.arg, ""),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def parse_docstring(docstring: str) -> dict[str, Any]:
|
|
252
|
+
"""Parse Google-style docstring.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
docstring: The docstring to parse.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Dict with 'description', 'args', and 'returns' keys.
|
|
259
|
+
"""
|
|
260
|
+
if not docstring:
|
|
261
|
+
return {"description": "", "args": {}, "returns": ""}
|
|
262
|
+
|
|
263
|
+
parsed = parse_docstring_lib(docstring)
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
"description": parsed.short_description or "",
|
|
267
|
+
"args": {p.arg_name: p.description or "" for p in parsed.params},
|
|
268
|
+
"returns": parsed.returns.description if parsed.returns else "",
|
|
269
|
+
}
|
ot/registry/registry.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Tool registry class for auto-discovering Python tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from ot.logging import LogEntry
|
|
13
|
+
|
|
14
|
+
from .parser import parse_function
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .models import ToolInfo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolRegistry:
|
|
21
|
+
"""Registry for auto-discovered Python tools.
|
|
22
|
+
|
|
23
|
+
Scans a directory for Python files and extracts function information
|
|
24
|
+
using AST parsing (no execution required).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, tools_path: Path | None = None) -> None:
|
|
28
|
+
"""Initialize the registry.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
tools_path: Path to tools directory. Defaults to 'src/ot_tools/'.
|
|
32
|
+
"""
|
|
33
|
+
self._tools_path = tools_path or Path("src/ot_tools")
|
|
34
|
+
self._tools: dict[str, ToolInfo] = {}
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def tools_path(self) -> Path:
|
|
38
|
+
"""Return the tools directory path."""
|
|
39
|
+
return self._tools_path
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def tools(self) -> dict[str, ToolInfo]:
|
|
43
|
+
"""Return dictionary of registered tools by name."""
|
|
44
|
+
return self._tools.copy()
|
|
45
|
+
|
|
46
|
+
def scan_files(self, files: list[Path]) -> list[ToolInfo]:
|
|
47
|
+
"""Scan specific Python files and register public functions.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
files: List of Python files to scan.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of registered ToolInfo objects.
|
|
54
|
+
"""
|
|
55
|
+
# Track previous state to detect changes
|
|
56
|
+
previous_tools = dict(self._tools)
|
|
57
|
+
self._tools.clear()
|
|
58
|
+
|
|
59
|
+
for py_file in files:
|
|
60
|
+
if not py_file.exists():
|
|
61
|
+
logger.debug(
|
|
62
|
+
LogEntry(span="registry.scan", path=str(py_file), exists=False)
|
|
63
|
+
)
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
tools = self.parse_file(py_file)
|
|
67
|
+
for tool in tools:
|
|
68
|
+
if tool.name in self._tools:
|
|
69
|
+
logger.warning(
|
|
70
|
+
LogEntry(
|
|
71
|
+
span="registry.duplicate",
|
|
72
|
+
tool=tool.name,
|
|
73
|
+
file=str(py_file),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
self._tools[tool.name] = tool
|
|
77
|
+
except SyntaxError as e:
|
|
78
|
+
logger.warning(
|
|
79
|
+
LogEntry(
|
|
80
|
+
span="registry.error",
|
|
81
|
+
file=str(py_file),
|
|
82
|
+
error=str(e),
|
|
83
|
+
errorType="SyntaxError",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.warning(
|
|
88
|
+
LogEntry(
|
|
89
|
+
span="registry.error",
|
|
90
|
+
file=str(py_file),
|
|
91
|
+
error=str(e),
|
|
92
|
+
errorType=type(e).__name__,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Detect added/changed/removed tools
|
|
97
|
+
added: list[str] = []
|
|
98
|
+
changed: list[str] = []
|
|
99
|
+
removed: list[str] = []
|
|
100
|
+
|
|
101
|
+
for name, tool in self._tools.items():
|
|
102
|
+
if name not in previous_tools:
|
|
103
|
+
added.append(name)
|
|
104
|
+
elif tool.signature != previous_tools[name].signature:
|
|
105
|
+
changed.append(name)
|
|
106
|
+
|
|
107
|
+
for name in previous_tools:
|
|
108
|
+
if name not in self._tools:
|
|
109
|
+
removed.append(name)
|
|
110
|
+
|
|
111
|
+
# Only log if this is first scan or there are changes
|
|
112
|
+
if not previous_tools:
|
|
113
|
+
# First scan - log all tools
|
|
114
|
+
logger.info(
|
|
115
|
+
LogEntry(
|
|
116
|
+
span="registry.ready",
|
|
117
|
+
path="tools",
|
|
118
|
+
toolCount=len(self._tools),
|
|
119
|
+
tools=[tool.name for tool in self._tools.values()],
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
elif added or changed or removed:
|
|
123
|
+
# Subsequent scan with changes
|
|
124
|
+
logger.info(
|
|
125
|
+
LogEntry(
|
|
126
|
+
span="registry.changed",
|
|
127
|
+
path="tools",
|
|
128
|
+
toolCount=len(self._tools),
|
|
129
|
+
added=added if added else None,
|
|
130
|
+
changed=changed if changed else None,
|
|
131
|
+
removed=removed if removed else None,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return list(self._tools.values())
|
|
136
|
+
|
|
137
|
+
def scan_directory(self, path: Path | None = None) -> list[ToolInfo]:
|
|
138
|
+
"""Scan directory for Python files and register public functions.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
path: Directory to scan. Defaults to self.tools_path.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
List of registered ToolInfo objects.
|
|
145
|
+
"""
|
|
146
|
+
scan_path = path or self._tools_path
|
|
147
|
+
|
|
148
|
+
if not scan_path.exists():
|
|
149
|
+
logger.debug(
|
|
150
|
+
LogEntry(span="registry.scan", path=str(scan_path), exists=False)
|
|
151
|
+
)
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
if not scan_path.is_dir():
|
|
155
|
+
logger.warning(
|
|
156
|
+
LogEntry(span="registry.scan", path=str(scan_path), isDir=False)
|
|
157
|
+
)
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
py_files = list(scan_path.glob("*.py"))
|
|
161
|
+
return self.scan_files(py_files)
|
|
162
|
+
|
|
163
|
+
def parse_file(self, path: Path) -> list[ToolInfo]:
|
|
164
|
+
"""Parse a Python file and extract public function information.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
path: Path to Python file.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of ToolInfo for exported functions (respects __all__ and pack).
|
|
171
|
+
"""
|
|
172
|
+
source = path.read_text(encoding="utf-8")
|
|
173
|
+
tree = ast.parse(source, filename=str(path))
|
|
174
|
+
|
|
175
|
+
# Module name: tools/gold_prices.py -> tools.gold_prices
|
|
176
|
+
module_name = f"tools.{path.stem}"
|
|
177
|
+
|
|
178
|
+
# Extract pack variable (e.g., pack = "code")
|
|
179
|
+
pack = self._extract_pack(tree)
|
|
180
|
+
|
|
181
|
+
# Extract __all__ list if present
|
|
182
|
+
export_names = self._extract_all(tree)
|
|
183
|
+
|
|
184
|
+
# Extract __ot_requires__ dependencies
|
|
185
|
+
requires = self._extract_requires(tree)
|
|
186
|
+
|
|
187
|
+
tools: list[ToolInfo] = []
|
|
188
|
+
for node in ast.walk(tree):
|
|
189
|
+
if isinstance(node, ast.FunctionDef):
|
|
190
|
+
# Skip private functions
|
|
191
|
+
if node.name.startswith("_"):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# If __all__ is defined, only include exported functions
|
|
195
|
+
if export_names is not None and node.name not in export_names:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
tool = parse_function(node, module_name, pack=pack)
|
|
199
|
+
# Attach module-level requires to tool
|
|
200
|
+
if requires:
|
|
201
|
+
tool.requires = requires
|
|
202
|
+
tools.append(tool)
|
|
203
|
+
|
|
204
|
+
return tools
|
|
205
|
+
|
|
206
|
+
def _extract_pack(self, tree: ast.Module) -> str | None:
|
|
207
|
+
"""Extract pack variable from module AST.
|
|
208
|
+
|
|
209
|
+
Looks for module-level assignment: pack = "pack_name"
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
tree: Parsed AST module.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Pack name string if found, None otherwise.
|
|
216
|
+
"""
|
|
217
|
+
for node in tree.body:
|
|
218
|
+
if isinstance(node, ast.Assign):
|
|
219
|
+
for target in node.targets:
|
|
220
|
+
if (
|
|
221
|
+
isinstance(target, ast.Name)
|
|
222
|
+
and target.id == "pack"
|
|
223
|
+
and isinstance(node.value, ast.Constant)
|
|
224
|
+
and isinstance(node.value.value, str)
|
|
225
|
+
):
|
|
226
|
+
return node.value.value
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def _extract_all(self, tree: ast.Module) -> set[str] | None:
|
|
230
|
+
"""Extract __all__ list from module AST.
|
|
231
|
+
|
|
232
|
+
Looks for module-level assignment: __all__ = ["func1", "func2"]
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
tree: Parsed AST module.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Set of exported names if __all__ is defined, None otherwise.
|
|
239
|
+
"""
|
|
240
|
+
for node in tree.body:
|
|
241
|
+
if isinstance(node, ast.Assign):
|
|
242
|
+
for target in node.targets:
|
|
243
|
+
if (
|
|
244
|
+
isinstance(target, ast.Name)
|
|
245
|
+
and target.id == "__all__"
|
|
246
|
+
and isinstance(node.value, ast.List)
|
|
247
|
+
):
|
|
248
|
+
names: set[str] = set()
|
|
249
|
+
for elt in node.value.elts:
|
|
250
|
+
if isinstance(elt, ast.Constant) and isinstance(
|
|
251
|
+
elt.value, str
|
|
252
|
+
):
|
|
253
|
+
names.add(elt.value)
|
|
254
|
+
return names
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def _extract_requires(
|
|
258
|
+
self, tree: ast.Module
|
|
259
|
+
) -> dict[str, list[tuple[str, ...] | dict[str, str] | str]] | None:
|
|
260
|
+
"""Extract __ot_requires__ dict from module AST.
|
|
261
|
+
|
|
262
|
+
Looks for module-level assignment: __ot_requires__ = {"cli": [...], "lib": [...]}
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
tree: Parsed AST module.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Dict with 'cli' and 'lib' dependency lists if found, None otherwise.
|
|
269
|
+
"""
|
|
270
|
+
for node in tree.body:
|
|
271
|
+
if isinstance(node, ast.Assign):
|
|
272
|
+
for target in node.targets:
|
|
273
|
+
if isinstance(target, ast.Name) and target.id == "__ot_requires__":
|
|
274
|
+
try:
|
|
275
|
+
# Safely evaluate the dict literal
|
|
276
|
+
result = ast.literal_eval(ast.unparse(node.value))
|
|
277
|
+
return cast(
|
|
278
|
+
"dict[str, list[tuple[str, ...] | dict[str, str] | str]]",
|
|
279
|
+
result,
|
|
280
|
+
)
|
|
281
|
+
except (ValueError, TypeError):
|
|
282
|
+
return None
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
def format_json(self) -> str:
|
|
286
|
+
"""Format registry as JSON for LLM context.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
JSON string with tool definitions.
|
|
290
|
+
"""
|
|
291
|
+
if not self._tools:
|
|
292
|
+
return '{"tools":[]}'
|
|
293
|
+
|
|
294
|
+
tools_list: list[dict[str, Any]] = []
|
|
295
|
+
for tool in self._tools.values():
|
|
296
|
+
tool_dict: dict[str, Any] = {
|
|
297
|
+
"name": tool.name,
|
|
298
|
+
"module": tool.module,
|
|
299
|
+
"signature": tool.signature,
|
|
300
|
+
}
|
|
301
|
+
if tool.description:
|
|
302
|
+
tool_dict["description"] = tool.description
|
|
303
|
+
if tool.args:
|
|
304
|
+
tool_dict["args"] = [
|
|
305
|
+
{
|
|
306
|
+
k: v
|
|
307
|
+
for k, v in {
|
|
308
|
+
"name": arg.name,
|
|
309
|
+
"type": arg.type,
|
|
310
|
+
"default": arg.default,
|
|
311
|
+
"description": arg.description if arg.description else None,
|
|
312
|
+
}.items()
|
|
313
|
+
if v is not None
|
|
314
|
+
}
|
|
315
|
+
for arg in tool.args
|
|
316
|
+
]
|
|
317
|
+
if tool.returns:
|
|
318
|
+
tool_dict["returns"] = tool.returns
|
|
319
|
+
|
|
320
|
+
tools_list.append(tool_dict)
|
|
321
|
+
|
|
322
|
+
return json.dumps({"tools": tools_list}, ensure_ascii=False, indent=2)
|
|
323
|
+
|
|
324
|
+
def format_summary(self) -> str:
|
|
325
|
+
"""Format registry summary for CLI display.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Human-readable summary of registered tools.
|
|
329
|
+
"""
|
|
330
|
+
if not self._tools:
|
|
331
|
+
return "No tools registered."
|
|
332
|
+
|
|
333
|
+
lines = [f"Registered tools ({len(self._tools)}):"]
|
|
334
|
+
for tool in self._tools.values():
|
|
335
|
+
lines.append(f" - {tool.signature}")
|
|
336
|
+
if tool.description:
|
|
337
|
+
lines.append(f" {tool.description}")
|
|
338
|
+
|
|
339
|
+
return "\n".join(lines)
|
|
340
|
+
|
|
341
|
+
def get_tool(self, name: str) -> ToolInfo | None:
|
|
342
|
+
"""Get tool by name.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
name: Tool function name.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
ToolInfo if found, None otherwise.
|
|
349
|
+
"""
|
|
350
|
+
return self._tools.get(name)
|
|
351
|
+
|
|
352
|
+
def register_tool(self, tool: ToolInfo) -> None:
|
|
353
|
+
"""Register a tool programmatically.
|
|
354
|
+
|
|
355
|
+
Used for tools that aren't loaded from files (e.g., ot pack tools).
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
tool: ToolInfo object to register.
|
|
359
|
+
"""
|
|
360
|
+
self._tools[tool.name] = tool
|
|
361
|
+
|
|
362
|
+
def describe_tool(self, name: str) -> str:
|
|
363
|
+
"""Get detailed description of a tool.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
name: Tool function name.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Detailed tool description string.
|
|
370
|
+
"""
|
|
371
|
+
tool = self._tools.get(name)
|
|
372
|
+
if not tool:
|
|
373
|
+
return f"Tool '{name}' not found."
|
|
374
|
+
|
|
375
|
+
lines = [
|
|
376
|
+
f"Tool: {tool.name}",
|
|
377
|
+
f"Module: {tool.module}",
|
|
378
|
+
f"Signature: {tool.signature}",
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
# Show deprecation warning first
|
|
382
|
+
if tool.deprecated:
|
|
383
|
+
msg = tool.deprecated_message or "This tool is deprecated"
|
|
384
|
+
lines.append(f"DEPRECATED: {msg}")
|
|
385
|
+
|
|
386
|
+
if not tool.enabled:
|
|
387
|
+
lines.append("Status: DISABLED")
|
|
388
|
+
|
|
389
|
+
if tool.description:
|
|
390
|
+
lines.append(f"Description: {tool.description}")
|
|
391
|
+
|
|
392
|
+
if tool.tags:
|
|
393
|
+
lines.append(f"Tags: {', '.join(tool.tags)}")
|
|
394
|
+
|
|
395
|
+
if tool.args:
|
|
396
|
+
lines.append("Arguments:")
|
|
397
|
+
for arg in tool.args:
|
|
398
|
+
arg_line = f" - {arg.name}: {arg.type}"
|
|
399
|
+
if arg.default is not None:
|
|
400
|
+
arg_line += f" = {arg.default}"
|
|
401
|
+
lines.append(arg_line)
|
|
402
|
+
if arg.description:
|
|
403
|
+
lines.append(f" {arg.description}")
|
|
404
|
+
|
|
405
|
+
if tool.returns:
|
|
406
|
+
lines.append(f"Returns: {tool.returns}")
|
|
407
|
+
|
|
408
|
+
if tool.examples:
|
|
409
|
+
lines.append("Examples:")
|
|
410
|
+
for example in tool.examples:
|
|
411
|
+
lines.append(f" {example}")
|
|
412
|
+
|
|
413
|
+
return "\n".join(lines)
|