feat(tools): Phase T.1-T.4 complete - manifest system, registry, implementations, runtime, collaboration, scheduler
This commit is contained in:
16
backend/app/tools/runtime/__init__.py
Normal file
16
backend/app/tools/runtime/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Runtime Module
|
||||
|
||||
Multi-runtime support for tool execution:
|
||||
- Python runtime: native Python execution
|
||||
- JavaScript runtime: Node.js stdio protocol
|
||||
- Native runtime: binary execution
|
||||
"""
|
||||
|
||||
from tools.runtime.base import BaseRuntime
|
||||
from tools.runtime.manager import RuntimeManager
|
||||
|
||||
__all__ = [
|
||||
"BaseRuntime",
|
||||
"RuntimeManager",
|
||||
]
|
||||
33
backend/app/tools/runtime/base.py
Normal file
33
backend/app/tools/runtime/base.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Base Runtime
|
||||
|
||||
Abstract base class for all tool runtimes.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class BaseRuntime(ABC):
|
||||
"""Runtime abstract base class"""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a tool"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def validate(self, entry: str) -> bool:
|
||||
"""Validate if the tool is available"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Get runtime name"""
|
||||
pass
|
||||
125
backend/app/tools/runtime/js_runtime.py
Normal file
125
backend/app/tools/runtime/js_runtime.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
JavaScript Runtime
|
||||
|
||||
Node.js stdio protocol runtime for JavaScript tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tools.runtime.base import BaseRuntime
|
||||
|
||||
|
||||
class JavaScriptRuntime(BaseRuntime):
|
||||
"""JavaScript runtime using Node.js stdio protocol"""
|
||||
|
||||
def __init__(self, node_path: Optional[str] = None):
|
||||
self.node_path = node_path or self._detect_node()
|
||||
self._validated: bool = False
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "javascript"
|
||||
|
||||
def _detect_node(self) -> str:
|
||||
"""Detect Node.js executable path"""
|
||||
node = shutil.which("node")
|
||||
if node:
|
||||
return node
|
||||
# Fallback for Windows
|
||||
return "node"
|
||||
|
||||
async def validate(self, entry: str) -> bool:
|
||||
"""Validate Node.js runtime and entry file"""
|
||||
# Check node is available
|
||||
try:
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
self.node_path,
|
||||
"--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await result.wait()
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Check entry file exists
|
||||
path = Path(entry)
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
self._validated = True
|
||||
return True
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a JavaScript tool via stdio protocol"""
|
||||
if not self._validated:
|
||||
is_valid = await self.validate(entry)
|
||||
if not is_valid:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "JavaScript runtime not available or entry file invalid",
|
||||
}
|
||||
|
||||
# Build input data per stdio protocol
|
||||
input_data = {
|
||||
"command": command,
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.node_path,
|
||||
entry,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=json.dumps(input_data).encode()),
|
||||
timeout=timeout / 1000,
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": stderr.decode() if stderr else "Unknown error",
|
||||
}
|
||||
|
||||
result_text = stdout.decode()
|
||||
if not result_text:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "Empty response from Node.js runtime",
|
||||
}
|
||||
|
||||
try:
|
||||
result = json.loads(result_text)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"status": "success",
|
||||
"result": result_text,
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Execution timed out after {timeout}ms",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
79
backend/app/tools/runtime/manager.py
Normal file
79
backend/app/tools/runtime/manager.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Runtime Manager
|
||||
|
||||
Manages multiple runtimes and routes tool execution to the appropriate runtime.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tools.runtime.base import BaseRuntime
|
||||
from tools.runtime.python_runtime import PythonRuntime
|
||||
from tools.runtime.js_runtime import JavaScriptRuntime
|
||||
from tools.runtime.native_runtime import NativeRuntime
|
||||
|
||||
|
||||
class RuntimeManager:
|
||||
"""Runtime manager for multi-runtime tool execution"""
|
||||
|
||||
def __init__(self):
|
||||
self._runtimes: Dict[str, BaseRuntime] = {
|
||||
"python": PythonRuntime(),
|
||||
"javascript": JavaScriptRuntime(),
|
||||
"native": NativeRuntime(),
|
||||
}
|
||||
|
||||
def get_runtime(self, name: str) -> Optional[BaseRuntime]:
|
||||
"""Get runtime by name"""
|
||||
return self._runtimes.get(name)
|
||||
|
||||
def register_runtime(self, name: str, runtime: BaseRuntime) -> None:
|
||||
"""Register a custom runtime"""
|
||||
self._runtimes[name] = runtime
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
runtime_name: str,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int = 30000,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute tool through the specified runtime"""
|
||||
runtime = self.get_runtime(runtime_name)
|
||||
if not runtime:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Unknown runtime: {runtime_name}",
|
||||
}
|
||||
|
||||
# Validate first
|
||||
is_valid = await runtime.validate(entry)
|
||||
if not is_valid:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Validation failed for runtime {runtime_name}, entry: {entry}",
|
||||
}
|
||||
|
||||
return await runtime.execute(entry, command, parameters, timeout)
|
||||
|
||||
def list_runtimes(self) -> list:
|
||||
"""List all registered runtimes"""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"available": runtime.get_name() == name,
|
||||
}
|
||||
for name, runtime in self._runtimes.items()
|
||||
]
|
||||
|
||||
|
||||
# Global runtime manager instance
|
||||
_runtime_manager: Optional[RuntimeManager] = None
|
||||
|
||||
|
||||
def get_runtime_manager() -> RuntimeManager:
|
||||
"""Get the global runtime manager instance"""
|
||||
global _runtime_manager
|
||||
if _runtime_manager is None:
|
||||
_runtime_manager = RuntimeManager()
|
||||
return _runtime_manager
|
||||
93
backend/app/tools/runtime/native_runtime.py
Normal file
93
backend/app/tools/runtime/native_runtime.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Native Runtime
|
||||
|
||||
Native binary execution runtime for system executables.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from tools.runtime.base import BaseRuntime
|
||||
|
||||
|
||||
class NativeRuntime(BaseRuntime):
|
||||
"""Native binary execution runtime"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "native"
|
||||
|
||||
async def validate(self, entry: str) -> bool:
|
||||
"""Validate native binary exists and is executable"""
|
||||
path = Path(entry)
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
# Check if file is executable
|
||||
file_stat = path.stat()
|
||||
executable_bit = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
if not (file_stat.st_mode & executable_bit):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a native binary"""
|
||||
try:
|
||||
# Build argument list
|
||||
args = [entry, command] + self._format_args(parameters)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout / 1000,
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": stderr.decode() if stderr else f"Exit code: {process.returncode}",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": stdout.decode() if stdout else "",
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Execution timed out after {timeout}ms",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def _format_args(self, parameters: Dict[str, Any]) -> List[str]:
|
||||
"""Format parameters as command-line arguments"""
|
||||
args: List[str] = []
|
||||
for key, value in parameters.items():
|
||||
# Use --key=value format
|
||||
if isinstance(value, bool):
|
||||
if value:
|
||||
args.append(f"--{key}")
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
args.extend([f"--{key}", str(item)])
|
||||
else:
|
||||
args.extend([f"--{key}", str(value)])
|
||||
return args
|
||||
113
backend/app/tools/runtime/python_runtime.py
Normal file
113
backend/app/tools/runtime/python_runtime.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Python Runtime
|
||||
|
||||
Native Python tool execution runtime.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from tools.runtime.base import BaseRuntime
|
||||
|
||||
|
||||
class PythonRuntime(BaseRuntime):
|
||||
"""Python runtime for executing Python-based tools"""
|
||||
|
||||
def __init__(self):
|
||||
self._executors: Dict[str, Callable] = {}
|
||||
self._modules: Dict[str, Any] = {}
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "python"
|
||||
|
||||
async def validate(self, entry: str) -> bool:
|
||||
"""Validate Python tool entry point"""
|
||||
path = Path(entry)
|
||||
if not path.exists():
|
||||
return False
|
||||
if path.suffix != ".py":
|
||||
return False
|
||||
return True
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a Python tool"""
|
||||
try:
|
||||
# Load module dynamically
|
||||
module = self._load_module(entry, command)
|
||||
if module is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Failed to load module from {entry}",
|
||||
}
|
||||
|
||||
# Get the execute function
|
||||
if not hasattr(module, "execute"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "Module does not have 'execute' function",
|
||||
}
|
||||
|
||||
execute_func = module.execute
|
||||
|
||||
# Run in executor to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_func(command, parameters),
|
||||
),
|
||||
timeout=timeout / 1000,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Execution timed out after {timeout}ms",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def _load_module(self, entry: str, command: str) -> Any:
|
||||
"""Load Python module from file path"""
|
||||
cache_key = f"{entry}:{command}"
|
||||
if cache_key in self._modules:
|
||||
return self._modules[cache_key]
|
||||
|
||||
try:
|
||||
path = Path(entry)
|
||||
module_name = path.stem
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
self._modules[cache_key] = module
|
||||
return module
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear module cache"""
|
||||
self._modules.clear()
|
||||
Reference in New Issue
Block a user