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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. 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