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/executor/runner.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Unified command runner for OneTool.
|
|
2
|
+
|
|
3
|
+
Routes all command execution through Python code mode:
|
|
4
|
+
- Function calls: search(query="test")
|
|
5
|
+
- Python code blocks: for metal in metals: search(...)
|
|
6
|
+
- Code with fences: ```python ... ```
|
|
7
|
+
|
|
8
|
+
Delegates to specialized modules:
|
|
9
|
+
- fence_processor: Strips markdown fences and execution prefixes
|
|
10
|
+
- tool_loader: Discovers and caches tool functions
|
|
11
|
+
- pack_proxy: Creates proxy objects for dot notation access
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import asyncio
|
|
18
|
+
import io
|
|
19
|
+
from contextlib import redirect_stdout
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from loguru import logger
|
|
24
|
+
|
|
25
|
+
from ot.config import get_config
|
|
26
|
+
from ot.executor.fence_processor import strip_fences
|
|
27
|
+
from ot.executor.pack_proxy import build_execution_namespace
|
|
28
|
+
from ot.executor.result_store import get_result_store
|
|
29
|
+
from ot.executor.tool_loader import load_tool_functions, load_tool_registry
|
|
30
|
+
from ot.logging import LogSpan
|
|
31
|
+
from ot.utils import sanitize_output, serialize_result
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from ot.executor import SimpleExecutor
|
|
37
|
+
from ot.registry import ToolRegistry
|
|
38
|
+
from ot.utils.format import FormatMode
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CommandResult:
|
|
43
|
+
"""Result from command execution."""
|
|
44
|
+
|
|
45
|
+
command: str
|
|
46
|
+
result: str
|
|
47
|
+
executor: str = "runner"
|
|
48
|
+
success: bool = True
|
|
49
|
+
error_type: str | None = None
|
|
50
|
+
line_number: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Sentinel value to distinguish explicit None return from no return
|
|
54
|
+
_NO_RETURN = object()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# -----------------------------------------------------------------------------
|
|
58
|
+
# Code Execution
|
|
59
|
+
# -----------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _has_top_level_return(tree: ast.Module) -> bool:
|
|
63
|
+
"""Check for return statements at top level only (not inside functions/classes).
|
|
64
|
+
|
|
65
|
+
Returns inside function definitions should not prevent implicit return capture
|
|
66
|
+
for the final expression at module level.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
tree: Parsed AST module
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
True if there's a return statement at the top level
|
|
73
|
+
"""
|
|
74
|
+
for node in tree.body:
|
|
75
|
+
# Skip function and class definitions - returns inside them don't count
|
|
76
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
77
|
+
continue
|
|
78
|
+
# Check this top-level statement and its children for return
|
|
79
|
+
for child in ast.walk(node):
|
|
80
|
+
if isinstance(child, ast.Return):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prepare_code_for_exec(
|
|
86
|
+
code: str, tree: ast.Module | None = None
|
|
87
|
+
) -> tuple[str, bool]:
|
|
88
|
+
"""Prepare code for execution, handling result capture.
|
|
89
|
+
|
|
90
|
+
Uses AST to detect if the last statement is an expression (needs return),
|
|
91
|
+
or if there's an explicit return statement, or if we should just execute.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
code: Python code to prepare
|
|
95
|
+
tree: Pre-parsed AST tree (optional, avoids reparsing)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Tuple of (prepared code, whether result capture was added)
|
|
99
|
+
"""
|
|
100
|
+
stripped = code.strip()
|
|
101
|
+
|
|
102
|
+
if tree is None:
|
|
103
|
+
try:
|
|
104
|
+
tree = ast.parse(stripped)
|
|
105
|
+
except SyntaxError:
|
|
106
|
+
# Syntax error - return as-is and let exec() report the error
|
|
107
|
+
return code, False
|
|
108
|
+
|
|
109
|
+
if not tree.body:
|
|
110
|
+
return code, False
|
|
111
|
+
|
|
112
|
+
last_stmt = tree.body[-1]
|
|
113
|
+
|
|
114
|
+
# Check if already has explicit return at top level (not inside functions)
|
|
115
|
+
if _has_top_level_return(tree):
|
|
116
|
+
# Has explicit return - use as-is
|
|
117
|
+
return stripped, False
|
|
118
|
+
|
|
119
|
+
if isinstance(last_stmt, ast.Expr):
|
|
120
|
+
# Last statement is an expression - capture its value
|
|
121
|
+
# Use AST to find where the expression starts (handles semicolon-separated statements)
|
|
122
|
+
lines = stripped.split("\n")
|
|
123
|
+
expr_start_line = last_stmt.lineno - 1 # AST is 1-indexed
|
|
124
|
+
expr_col = last_stmt.col_offset
|
|
125
|
+
|
|
126
|
+
# Insert 'return ' at the expression start position
|
|
127
|
+
line = lines[expr_start_line]
|
|
128
|
+
lines[expr_start_line] = line[:expr_col] + "return " + line[expr_col:]
|
|
129
|
+
|
|
130
|
+
return "\n".join(lines), True
|
|
131
|
+
|
|
132
|
+
# Last statement is not an expression (e.g., assignment, for loop)
|
|
133
|
+
return stripped, False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def wrap_code_for_exec(code: str, has_explicit_return: bool) -> tuple[str, int]:
|
|
137
|
+
"""Wrap code in a function for execution.
|
|
138
|
+
|
|
139
|
+
Handles indentation correctly for already-indented code.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
code: Python code to wrap
|
|
143
|
+
has_explicit_return: Whether the code has an explicit return statement
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Tuple of (wrapped code with __execute__ function, line offset for error mapping)
|
|
147
|
+
"""
|
|
148
|
+
lines = code.split("\n")
|
|
149
|
+
|
|
150
|
+
# Indent each line by 4 spaces
|
|
151
|
+
indented_lines = []
|
|
152
|
+
for line in lines:
|
|
153
|
+
if line.strip(): # Non-empty line
|
|
154
|
+
indented_lines.append(" " + line)
|
|
155
|
+
else: # Empty line - preserve
|
|
156
|
+
indented_lines.append("")
|
|
157
|
+
|
|
158
|
+
indented_code = "\n".join(indented_lines)
|
|
159
|
+
|
|
160
|
+
# Add global declarations for magic variables so they can be read from outer namespace
|
|
161
|
+
global_decl = " global __format__, __sanitize__"
|
|
162
|
+
|
|
163
|
+
# Use sentinel if no explicit return to distinguish from explicit None
|
|
164
|
+
if has_explicit_return:
|
|
165
|
+
wrapped = f"""def __execute__():
|
|
166
|
+
{global_decl}
|
|
167
|
+
{indented_code}
|
|
168
|
+
|
|
169
|
+
__result__ = __execute__()
|
|
170
|
+
"""
|
|
171
|
+
else:
|
|
172
|
+
wrapped = f"""def __execute__():
|
|
173
|
+
{global_decl}
|
|
174
|
+
{indented_code}
|
|
175
|
+
return __NO_RETURN__
|
|
176
|
+
|
|
177
|
+
__result__ = __execute__()
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# Line offset: "def __execute__():" + global decl adds 2 lines before user code
|
|
181
|
+
return wrapped, 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _map_error_line(error: Exception, line_offset: int) -> tuple[str, int | None]:
|
|
185
|
+
"""Extract and adjust error line number from exception.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
error: The exception that occurred
|
|
189
|
+
line_offset: Number of lines added by wrapping
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Tuple of (error message, adjusted line number or None)
|
|
193
|
+
"""
|
|
194
|
+
import traceback
|
|
195
|
+
|
|
196
|
+
# Get the last frame from the traceback
|
|
197
|
+
tb = traceback.extract_tb(error.__traceback__)
|
|
198
|
+
if tb:
|
|
199
|
+
for frame in reversed(tb):
|
|
200
|
+
if frame.filename == "<string>" and frame.lineno is not None:
|
|
201
|
+
# This is from our exec'd code
|
|
202
|
+
original_line = frame.lineno - line_offset
|
|
203
|
+
if original_line > 0:
|
|
204
|
+
return str(error), original_line
|
|
205
|
+
|
|
206
|
+
return str(error), None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def execute_python_code(
|
|
210
|
+
code: str,
|
|
211
|
+
tool_functions: dict[str, Any] | None = None,
|
|
212
|
+
tools_dir: Path | None = None,
|
|
213
|
+
validate: bool = True,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Execute Python code with tool functions available.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
code: Python code to execute
|
|
219
|
+
tool_functions: Pre-loaded tool functions (optional)
|
|
220
|
+
tools_dir: Path to tools directory for loading functions
|
|
221
|
+
validate: Whether to validate code before execution (default True)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
String result from the code execution
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ValueError: If validation fails or execution fails
|
|
228
|
+
"""
|
|
229
|
+
from ot.executor.validator import validate_for_exec
|
|
230
|
+
|
|
231
|
+
# Step 1: Validate code before execution
|
|
232
|
+
ast_tree: ast.Module | None = None
|
|
233
|
+
if validate:
|
|
234
|
+
validation = validate_for_exec(code)
|
|
235
|
+
if not validation.valid:
|
|
236
|
+
errors = "; ".join(validation.errors)
|
|
237
|
+
raise ValueError(f"Code validation failed: {errors}")
|
|
238
|
+
|
|
239
|
+
# Log warnings but continue execution
|
|
240
|
+
for warning in validation.warnings:
|
|
241
|
+
logger.warning(f"Code validation warning: {warning}")
|
|
242
|
+
|
|
243
|
+
# Reuse AST from validation
|
|
244
|
+
ast_tree = validation.ast_tree
|
|
245
|
+
|
|
246
|
+
# Step 2: Load tool functions if not provided
|
|
247
|
+
if tool_functions is None:
|
|
248
|
+
tool_functions = load_tool_functions(tools_dir)
|
|
249
|
+
|
|
250
|
+
# Step 3: Create execution namespace with tools and sentinel
|
|
251
|
+
namespace: dict[str, Any] = {
|
|
252
|
+
**tool_functions,
|
|
253
|
+
"__builtins__": __builtins__,
|
|
254
|
+
"__NO_RETURN__": _NO_RETURN,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# Step 4: Prepare code for result capture (reuse AST if available)
|
|
258
|
+
prepared_code, has_return = prepare_code_for_exec(code, tree=ast_tree)
|
|
259
|
+
|
|
260
|
+
# Step 5: Wrap in function for execution
|
|
261
|
+
wrapped_code, line_offset = wrap_code_for_exec(prepared_code, has_return)
|
|
262
|
+
|
|
263
|
+
# Step 6: Execute with stdout capture
|
|
264
|
+
stdout_buffer = io.StringIO()
|
|
265
|
+
try:
|
|
266
|
+
with redirect_stdout(stdout_buffer):
|
|
267
|
+
exec(wrapped_code, namespace)
|
|
268
|
+
result = namespace.get("__result__")
|
|
269
|
+
stdout_output = stdout_buffer.getvalue().strip()
|
|
270
|
+
|
|
271
|
+
# Read __format__ from namespace (default to "json" for compact output)
|
|
272
|
+
fmt: FormatMode = namespace.get("__format__", "json")
|
|
273
|
+
if fmt not in ("json", "json_h", "yml", "yml_h", "raw"):
|
|
274
|
+
fmt = "json" # Fall back to default for invalid format
|
|
275
|
+
|
|
276
|
+
# Read __sanitize__ from namespace, defaulting to config setting
|
|
277
|
+
config = get_config()
|
|
278
|
+
default_sanitize = config.security.sanitize.enabled
|
|
279
|
+
should_sanitize: bool = namespace.get("__sanitize__", default_sanitize)
|
|
280
|
+
|
|
281
|
+
# Helper to apply sanitization if enabled
|
|
282
|
+
def _maybe_sanitize(content: str) -> str:
|
|
283
|
+
if should_sanitize:
|
|
284
|
+
return sanitize_output(content, enabled=True)
|
|
285
|
+
return content
|
|
286
|
+
|
|
287
|
+
# Check for sentinel - no return value
|
|
288
|
+
if result is _NO_RETURN:
|
|
289
|
+
# Return stdout if available, otherwise success message
|
|
290
|
+
output = stdout_output or "Code executed successfully (no return value)"
|
|
291
|
+
return _maybe_sanitize(output)
|
|
292
|
+
|
|
293
|
+
# Explicit None return (e.g., from print())
|
|
294
|
+
if result is None:
|
|
295
|
+
# Return stdout if available (captures print output)
|
|
296
|
+
output = stdout_output or "None"
|
|
297
|
+
return _maybe_sanitize(output)
|
|
298
|
+
|
|
299
|
+
# If we have both a result and stdout, include both
|
|
300
|
+
if stdout_output:
|
|
301
|
+
output = f"{stdout_output}\n{serialize_result(result, fmt)}"
|
|
302
|
+
else:
|
|
303
|
+
output = serialize_result(result, fmt)
|
|
304
|
+
|
|
305
|
+
return _maybe_sanitize(output)
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
error_msg, line_num = _map_error_line(e, line_offset)
|
|
309
|
+
if line_num is not None:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"Python execution error at line {line_num}: {error_msg}"
|
|
312
|
+
) from e
|
|
313
|
+
raise ValueError(f"Python execution error: {error_msg}") from e
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class PreparedCommand:
|
|
318
|
+
"""Result of command preparation (before execution)."""
|
|
319
|
+
|
|
320
|
+
code: str
|
|
321
|
+
original: str
|
|
322
|
+
error: str | None = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def prepare_command(command: str) -> PreparedCommand:
|
|
326
|
+
"""Prepare a command for execution (validate but don't execute).
|
|
327
|
+
|
|
328
|
+
This performs all preprocessing steps:
|
|
329
|
+
- Strips markdown fences
|
|
330
|
+
- Expands snippets
|
|
331
|
+
- Resolves aliases
|
|
332
|
+
- Validates for security patterns
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
PreparedCommand with prepared code and any errors.
|
|
336
|
+
"""
|
|
337
|
+
from ot.config import get_config
|
|
338
|
+
from ot.executor.validator import validate_for_exec
|
|
339
|
+
from ot.shortcuts.aliases import resolve_alias
|
|
340
|
+
from ot.shortcuts.snippets import expand_snippet, is_snippet, parse_snippet
|
|
341
|
+
|
|
342
|
+
# Step 1: Check for legacy !onetool prefix (rejected)
|
|
343
|
+
stripped_cmd = command.strip()
|
|
344
|
+
if stripped_cmd.startswith("!onetool"):
|
|
345
|
+
return PreparedCommand(
|
|
346
|
+
code="",
|
|
347
|
+
original=command,
|
|
348
|
+
error="The !onetool prefix is no longer supported. "
|
|
349
|
+
"Use backtick syntax: `func(args)` or ```python\\ncode\\n```",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Step 2: Strip fences
|
|
353
|
+
stripped, _ = strip_fences(command)
|
|
354
|
+
|
|
355
|
+
# Step 3: Load configuration for aliases and snippets
|
|
356
|
+
config = get_config()
|
|
357
|
+
|
|
358
|
+
# Step 4: Handle snippet expansion ($name key=val)
|
|
359
|
+
if is_snippet(stripped):
|
|
360
|
+
try:
|
|
361
|
+
parsed = parse_snippet(stripped)
|
|
362
|
+
stripped = expand_snippet(parsed, config)
|
|
363
|
+
except ValueError as e:
|
|
364
|
+
return PreparedCommand(
|
|
365
|
+
code="",
|
|
366
|
+
original=command,
|
|
367
|
+
error=str(e),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Step 5: Resolve aliases (ws -> brave.web_search)
|
|
371
|
+
stripped = resolve_alias(stripped, config)
|
|
372
|
+
|
|
373
|
+
# Step 6: Validate code (but don't execute)
|
|
374
|
+
validation = validate_for_exec(stripped)
|
|
375
|
+
if not validation.valid:
|
|
376
|
+
errors = "; ".join(validation.errors)
|
|
377
|
+
return PreparedCommand(
|
|
378
|
+
code=stripped,
|
|
379
|
+
original=command,
|
|
380
|
+
error=f"Code validation failed: {errors}",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return PreparedCommand(
|
|
384
|
+
code=stripped,
|
|
385
|
+
original=command,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# -----------------------------------------------------------------------------
|
|
390
|
+
# Unified Command Execution
|
|
391
|
+
# -----------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def execute_command(
|
|
395
|
+
command: str,
|
|
396
|
+
registry: ToolRegistry, # noqa: ARG001
|
|
397
|
+
executor: SimpleExecutor, # noqa: ARG001
|
|
398
|
+
tools_dir: Path | None = None,
|
|
399
|
+
*,
|
|
400
|
+
skip_validation: bool = False,
|
|
401
|
+
prepared_code: str | None = None,
|
|
402
|
+
) -> CommandResult:
|
|
403
|
+
"""Execute a command through the unified runner.
|
|
404
|
+
|
|
405
|
+
This is the single entry point for all command execution:
|
|
406
|
+
- Strips markdown fences
|
|
407
|
+
- Rejects legacy !onetool prefix
|
|
408
|
+
- Expands snippets ($name key=val)
|
|
409
|
+
- Resolves aliases (ws -> brave.web_search)
|
|
410
|
+
- Executes as Python code with namespace support
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
command: Raw command from LLM (may have fences)
|
|
414
|
+
registry: Tool registry for looking up functions
|
|
415
|
+
executor: Executor for running tool functions
|
|
416
|
+
tools_dir: Path to tools directory
|
|
417
|
+
skip_validation: If True, skip validation (use when already validated)
|
|
418
|
+
prepared_code: Pre-processed code to execute (bypasses preparation steps)
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
CommandResult with execution result
|
|
422
|
+
"""
|
|
423
|
+
# If prepared_code is provided, use it directly (already preprocessed)
|
|
424
|
+
if prepared_code is not None:
|
|
425
|
+
stripped = prepared_code
|
|
426
|
+
else:
|
|
427
|
+
# Use prepare_command for preprocessing
|
|
428
|
+
prepared = prepare_command(command)
|
|
429
|
+
if prepared.error:
|
|
430
|
+
return CommandResult(
|
|
431
|
+
command=command,
|
|
432
|
+
result=f"Error: {prepared.error}",
|
|
433
|
+
executor="python",
|
|
434
|
+
success=False,
|
|
435
|
+
error_type="ValueError",
|
|
436
|
+
)
|
|
437
|
+
stripped = prepared.code
|
|
438
|
+
|
|
439
|
+
# Step 6: Load tools with pack support
|
|
440
|
+
tool_registry = load_tool_registry(tools_dir)
|
|
441
|
+
tool_namespace = build_execution_namespace(tool_registry)
|
|
442
|
+
|
|
443
|
+
# Step 7: Execute as Python code
|
|
444
|
+
# Use thread pool only when proxy servers are connected (to avoid deadlock)
|
|
445
|
+
from ot.proxy import get_proxy_manager
|
|
446
|
+
|
|
447
|
+
proxy = get_proxy_manager()
|
|
448
|
+
use_thread_pool = bool(proxy.servers)
|
|
449
|
+
|
|
450
|
+
# Determine validation behavior
|
|
451
|
+
should_validate = not skip_validation and prepared_code is None
|
|
452
|
+
|
|
453
|
+
with LogSpan(span="runner.execute", mode="code", command=stripped[:200]) as span:
|
|
454
|
+
try:
|
|
455
|
+
if use_thread_pool:
|
|
456
|
+
# Run in thread pool so event loop can process proxy calls
|
|
457
|
+
result = await asyncio.to_thread(
|
|
458
|
+
execute_python_code,
|
|
459
|
+
stripped,
|
|
460
|
+
tool_functions=tool_namespace,
|
|
461
|
+
validate=should_validate,
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
# Direct execution for non-proxy calls (no overhead)
|
|
465
|
+
result = execute_python_code(
|
|
466
|
+
stripped, tool_functions=tool_namespace, validate=should_validate
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Check for large output and store if needed
|
|
470
|
+
config = get_config()
|
|
471
|
+
max_size = config.output.max_inline_size
|
|
472
|
+
result_size = len(result.encode("utf-8"))
|
|
473
|
+
|
|
474
|
+
if max_size > 0 and result_size > max_size:
|
|
475
|
+
# Store large output and return summary
|
|
476
|
+
store = get_result_store()
|
|
477
|
+
stored = store.store(result, tool=stripped[:50])
|
|
478
|
+
result = serialize_result(stored.to_dict(), "json")
|
|
479
|
+
span.add("storedHandle", stored.handle)
|
|
480
|
+
span.add("storedSize", result_size)
|
|
481
|
+
|
|
482
|
+
span.add("resultLength", len(result))
|
|
483
|
+
return CommandResult(
|
|
484
|
+
command=command,
|
|
485
|
+
result=result,
|
|
486
|
+
executor="python",
|
|
487
|
+
success=True,
|
|
488
|
+
)
|
|
489
|
+
except ValueError as e:
|
|
490
|
+
return CommandResult(
|
|
491
|
+
command=command,
|
|
492
|
+
result=str(e),
|
|
493
|
+
executor="python",
|
|
494
|
+
success=False,
|
|
495
|
+
error_type="ValueError",
|
|
496
|
+
)
|
ot/executor/simple.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Simple executor - host process execution.
|
|
2
|
+
|
|
3
|
+
Executes tool code directly in the host Python process.
|
|
4
|
+
V1 uses this executor for all execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from ot.executor.base import ExecutionResult
|
|
17
|
+
from ot.logging import LogEntry, LogSpan
|
|
18
|
+
from ot.utils import serialize_result
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ot.registry import ToolInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SimpleExecutor:
|
|
25
|
+
"""Host process executor (v1 behaviour).
|
|
26
|
+
|
|
27
|
+
Loads and executes tool modules directly in the host Python process.
|
|
28
|
+
Fast but no isolation - tools have full filesystem access.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
"""Return the executor name."""
|
|
34
|
+
return "simple"
|
|
35
|
+
|
|
36
|
+
def _load_tool_module(self, module_name: str) -> Any:
|
|
37
|
+
"""Dynamically load a tool module.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
module_name: Module path like 'tools.example'
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The loaded module object
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ImportError: If module cannot be loaded
|
|
47
|
+
"""
|
|
48
|
+
from ot.config.loader import get_config
|
|
49
|
+
|
|
50
|
+
# Get tool files from config
|
|
51
|
+
config = get_config()
|
|
52
|
+
tool_files = config.get_tool_files() if config else []
|
|
53
|
+
if not tool_files:
|
|
54
|
+
raise ImportError(
|
|
55
|
+
f"No tool files configured. Cannot load module: {module_name}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Convert module path to file name (tools.example -> example.py)
|
|
59
|
+
parts = module_name.split(".")
|
|
60
|
+
if len(parts) < 2 or parts[-2] != "tools":
|
|
61
|
+
raise ImportError(f"Invalid tool module: {module_name}")
|
|
62
|
+
|
|
63
|
+
target_name = f"{parts[-1]}.py"
|
|
64
|
+
|
|
65
|
+
# Find matching tool file from config
|
|
66
|
+
file_path = None
|
|
67
|
+
for tf in tool_files:
|
|
68
|
+
if tf.name == target_name:
|
|
69
|
+
file_path = tf
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
if file_path is None or not file_path.exists():
|
|
73
|
+
raise ImportError(f"Tool file not found: {target_name}")
|
|
74
|
+
|
|
75
|
+
# Load the module dynamically
|
|
76
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
77
|
+
if spec is None or spec.loader is None:
|
|
78
|
+
raise ImportError(f"Cannot load module spec for: {file_path}")
|
|
79
|
+
|
|
80
|
+
module = importlib.util.module_from_spec(spec)
|
|
81
|
+
sys.modules[module_name] = module
|
|
82
|
+
spec.loader.exec_module(module)
|
|
83
|
+
|
|
84
|
+
return module
|
|
85
|
+
|
|
86
|
+
async def execute(
|
|
87
|
+
self,
|
|
88
|
+
func_name: str,
|
|
89
|
+
kwargs: dict[str, Any],
|
|
90
|
+
tool: ToolInfo,
|
|
91
|
+
) -> ExecutionResult:
|
|
92
|
+
"""Execute a tool function in the host process.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
func_name: Name of the function to execute
|
|
96
|
+
kwargs: Keyword arguments for the function
|
|
97
|
+
tool: ToolInfo with module and signature info
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
ExecutionResult with success status and result string
|
|
101
|
+
"""
|
|
102
|
+
start_time = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Load the module and get the function
|
|
106
|
+
module = self._load_tool_module(tool.module)
|
|
107
|
+
func = getattr(module, func_name, None)
|
|
108
|
+
|
|
109
|
+
if func is None:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Function '{func_name}' not found in module {tool.module}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Execute the function with timing via LogSpan
|
|
115
|
+
with LogSpan(span="executor.simple", tool=func_name) as span:
|
|
116
|
+
span.add("kwargs", {k: str(v) for k, v in kwargs.items()})
|
|
117
|
+
result = func(**kwargs)
|
|
118
|
+
result_str = serialize_result(result)
|
|
119
|
+
span.add("resultLength", len(result_str))
|
|
120
|
+
|
|
121
|
+
duration = time.perf_counter() - start_time
|
|
122
|
+
|
|
123
|
+
return ExecutionResult(
|
|
124
|
+
success=True,
|
|
125
|
+
result=result_str,
|
|
126
|
+
duration_seconds=duration,
|
|
127
|
+
executor="simple",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
duration = time.perf_counter() - start_time
|
|
132
|
+
logger.error(
|
|
133
|
+
LogEntry(
|
|
134
|
+
span="executor.simple.error",
|
|
135
|
+
tool=func_name,
|
|
136
|
+
error=str(e),
|
|
137
|
+
errorType=type(e).__name__,
|
|
138
|
+
duration=duration,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return ExecutionResult(
|
|
143
|
+
success=False,
|
|
144
|
+
result=f"Error executing tool '{func_name}': {e}",
|
|
145
|
+
duration_seconds=duration,
|
|
146
|
+
executor="simple",
|
|
147
|
+
error_type=type(e).__name__,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def start(self) -> None:
|
|
151
|
+
"""Start the executor (no-op for simple executor)."""
|
|
152
|
+
logger.debug(LogEntry(span="executor.simple.start"))
|
|
153
|
+
|
|
154
|
+
async def stop(self) -> None:
|
|
155
|
+
"""Stop the executor (no-op for simple executor)."""
|
|
156
|
+
logger.debug(LogEntry(span="executor.simple.stop"))
|
|
157
|
+
|
|
158
|
+
async def health_check(self) -> bool:
|
|
159
|
+
"""Check if the executor is healthy.
|
|
160
|
+
|
|
161
|
+
Simple executor is always healthy.
|
|
162
|
+
"""
|
|
163
|
+
return True
|