feat(tools): Phase T.1-T.4 complete - manifest system, registry, implementations, runtime, collaboration, scheduler
This commit is contained in:
217
backend/app/agents/tools/collaboration.py
Normal file
217
backend/app/agents/tools/collaboration.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Agent Collaboration Protocol
|
||||
|
||||
Inter-agent tool collaboration messaging system.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
"""Collaboration message types"""
|
||||
|
||||
REQUEST = "request" # Request collaboration
|
||||
RESPONSE = "response" # Response result
|
||||
PROGRESS = "progress" # Progress update
|
||||
CANCEL = "cancel" # Cancel request
|
||||
|
||||
|
||||
class CollaborationMessage(BaseModel):
|
||||
"""Collaboration message model"""
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
type: MessageType
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
content: Dict[str, Any]
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
def is_request(self) -> bool:
|
||||
return self.type == MessageType.REQUEST
|
||||
|
||||
def is_response(self) -> bool:
|
||||
return self.type == MessageType.RESPONSE
|
||||
|
||||
|
||||
class CollaborationProtocol:
|
||||
"""Agent collaboration protocol for inter-agent tool requests"""
|
||||
|
||||
def __init__(self):
|
||||
self._pending_requests: Dict[str, CollaborationMessage] = {}
|
||||
self._handlers: Dict[str, Callable] = {}
|
||||
self._response_futures: Dict[str, asyncio.Future] = {}
|
||||
|
||||
def register_handler(self, tool_name: str, handler: Callable) -> None:
|
||||
"""Register a tool handler for collaboration
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
handler: Async callable to handle the tool execution
|
||||
"""
|
||||
self._handlers[tool_name] = handler
|
||||
|
||||
async def request_collaboration(
|
||||
self,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
tool_name: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout_ms: int = 30000,
|
||||
) -> Dict[str, Any]:
|
||||
"""Request collaboration from another agent
|
||||
|
||||
Args:
|
||||
from_agent: Source agent name
|
||||
to_agent: Target agent name
|
||||
tool_name: Tool to execute
|
||||
parameters: Tool parameters
|
||||
timeout_ms: Timeout in milliseconds
|
||||
|
||||
Returns:
|
||||
Execution result dict with status and result/error
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
message = CollaborationMessage(
|
||||
id=request_id,
|
||||
type=MessageType.REQUEST,
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
content={
|
||||
"tool": tool_name,
|
||||
"parameters": parameters,
|
||||
},
|
||||
metadata={"timeout": timeout_ms},
|
||||
)
|
||||
|
||||
self._pending_requests[request_id] = message
|
||||
|
||||
# Create future for response
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self._response_futures[request_id] = future
|
||||
|
||||
# Send the message
|
||||
await self._send_message(message)
|
||||
|
||||
# Wait for response with timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "Collaboration request timed out",
|
||||
}
|
||||
finally:
|
||||
self._pending_requests.pop(request_id, None)
|
||||
self._response_futures.pop(request_id, None)
|
||||
|
||||
async def handle_request(self, message: CollaborationMessage) -> CollaborationMessage:
|
||||
"""Handle an incoming collaboration request
|
||||
|
||||
Args:
|
||||
message: The collaboration message
|
||||
|
||||
Returns:
|
||||
Response message with result or error
|
||||
"""
|
||||
import uuid
|
||||
|
||||
tool_name = message.content.get("tool")
|
||||
parameters = message.content.get("parameters", {})
|
||||
|
||||
handler = self._handlers.get(tool_name)
|
||||
if not handler:
|
||||
return CollaborationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
type=MessageType.RESPONSE,
|
||||
from_agent=message.to_agent,
|
||||
to_agent=message.from_agent,
|
||||
content={
|
||||
"status": "error",
|
||||
"error": f"Unknown tool: {tool_name}",
|
||||
},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await handler(**parameters)
|
||||
return CollaborationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
type=MessageType.RESPONSE,
|
||||
from_agent=message.to_agent,
|
||||
to_agent=message.from_agent,
|
||||
content={"status": "success", "result": result},
|
||||
metadata={},
|
||||
)
|
||||
except Exception as e:
|
||||
return CollaborationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
type=MessageType.RESPONSE,
|
||||
from_agent=message.to_agent,
|
||||
to_agent=message.from_agent,
|
||||
content={"status": "error", "error": str(e)},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
async def handle_response(self, message: CollaborationMessage) -> None:
|
||||
"""Handle an incoming response message
|
||||
|
||||
Args:
|
||||
message: The response message
|
||||
"""
|
||||
request_id = None
|
||||
for req_id, pending in self._pending_requests.items():
|
||||
if pending.id == message.id:
|
||||
request_id = req_id
|
||||
break
|
||||
|
||||
if request_id and request_id in self._response_futures:
|
||||
future = self._response_futures[request_id]
|
||||
if not future.done():
|
||||
future.set_result(message.content)
|
||||
|
||||
async def _send_message(self, message: CollaborationMessage) -> None:
|
||||
"""Send a collaboration message
|
||||
|
||||
This is a placeholder for actual transport implementation.
|
||||
In production, this would use WebSocket, message queue, or shared storage.
|
||||
|
||||
Args:
|
||||
message: The message to send
|
||||
"""
|
||||
# TODO: Implement actual message transport
|
||||
# Options: WebSocket, Redis pub/sub, shared database
|
||||
pass
|
||||
|
||||
def get_pending_requests(self) -> list:
|
||||
"""Get list of pending requests"""
|
||||
return [
|
||||
{
|
||||
"id": msg.id,
|
||||
"from": msg.from_agent,
|
||||
"to": msg.to_agent,
|
||||
"tool": msg.content.get("tool"),
|
||||
}
|
||||
for msg in self._pending_requests.values()
|
||||
]
|
||||
|
||||
|
||||
# Global collaboration protocol instance
|
||||
_collaboration_protocol: Optional[CollaborationProtocol] = None
|
||||
|
||||
|
||||
def get_collaboration_protocol() -> CollaborationProtocol:
|
||||
"""Get the global collaboration protocol instance"""
|
||||
global _collaboration_protocol
|
||||
if _collaboration_protocol is None:
|
||||
_collaboration_protocol = CollaborationProtocol()
|
||||
return _collaboration_protocol
|
||||
1
backend/app/tools/__init__.py
Normal file
1
backend/app/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tools Module
|
||||
1
backend/app/tools/configs/__init__.py
Normal file
1
backend/app/tools/configs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Configs Module
|
||||
61
backend/app/tools/configs/loader.py
Normal file
61
backend/app/tools/configs/loader.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Config Loader
|
||||
|
||||
Loads and caches tool configurations from YAML files.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""Tool configuration loader with caching support"""
|
||||
|
||||
def __init__(self, config_dir: Optional[Path] = None):
|
||||
if config_dir is None:
|
||||
config_dir = Path(__file__).parent
|
||||
self.config_dir = Path(config_dir)
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
def load(self, tool_name: str) -> Dict[str, Any]:
|
||||
"""Load configuration for a specific tool"""
|
||||
if tool_name in self._cache:
|
||||
return self._cache[tool_name]
|
||||
|
||||
config_file = self.config_dir / f"{tool_name}.yaml"
|
||||
if not config_file.exists():
|
||||
# Try without .yaml extension
|
||||
config_file = self.config_dir / tool_name
|
||||
|
||||
if not config_file.exists():
|
||||
return {}
|
||||
|
||||
with open(config_file, encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
self._cache[tool_name] = config
|
||||
return config
|
||||
|
||||
def reload(self, tool_name: str) -> Dict[str, Any]:
|
||||
"""Reload configuration for a specific tool"""
|
||||
if tool_name in self._cache:
|
||||
del self._cache[tool_name]
|
||||
return self.load(tool_name)
|
||||
|
||||
def get(self, tool_name: str, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific configuration value"""
|
||||
config = self.load(tool_name)
|
||||
return config.get(key, default)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the configuration cache"""
|
||||
self._cache.clear()
|
||||
|
||||
def load_all(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Load all configuration files in the config directory"""
|
||||
all_configs = {}
|
||||
for config_file in self.config_dir.glob("*.yaml"):
|
||||
tool_name = config_file.stem
|
||||
all_configs[tool_name] = self.load(tool_name)
|
||||
return all_configs
|
||||
90
backend/app/tools/description.py
Normal file
90
backend/app/tools/description.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Tool Description Generator
|
||||
|
||||
Generates AI-friendly tool descriptions for LLM consumption.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
def generate_tool_description(manifest: dict) -> str:
|
||||
"""Generate AI-friendly tool description from manifest"""
|
||||
lines = [
|
||||
f"## {manifest.get('display_name', manifest.get('name', 'Unknown'))}",
|
||||
f"{manifest.get('description', 'No description available')}",
|
||||
"",
|
||||
"### Available Commands:",
|
||||
]
|
||||
|
||||
commands = manifest.get("commands", [])
|
||||
if not commands:
|
||||
return "\n".join(lines[:-2]) # Remove the "Available Commands" line
|
||||
|
||||
for cmd in commands:
|
||||
lines.append(f"#### {cmd.get('name', 'unnamed')}")
|
||||
lines.append(cmd.get("description", "No description"))
|
||||
lines.append("")
|
||||
|
||||
if cmd.get("example"):
|
||||
lines.append("**Example:**")
|
||||
lines.append(f"```\n{cmd['example']}\n```")
|
||||
lines.append("")
|
||||
|
||||
parameters = cmd.get("parameters", {})
|
||||
if parameters:
|
||||
lines.append("**Parameters:**")
|
||||
props = parameters.get("properties", {})
|
||||
for param_name, param_info in props.items():
|
||||
param_type = param_info.get("type", "any")
|
||||
param_desc = param_info.get("description", "")
|
||||
lines.append(f"- `{param_name}` ({param_type}): {param_desc}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_tools_for_llm(registry: Any) -> str:
|
||||
"""Generate tool list for LLM from registry"""
|
||||
import asyncio
|
||||
|
||||
async def _generate():
|
||||
tools = await registry.list_enabled()
|
||||
|
||||
sections = ["## Available Tools\n"]
|
||||
|
||||
for tool in tools:
|
||||
try:
|
||||
manifest_path = f"tools/manifests/{tool.name}.yaml"
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
manifest_file = Path(__file__).parent / "manifests" / f"{tool.name}.yaml"
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
manifest_data = yaml.safe_load(f)
|
||||
sections.append(generate_tool_description(manifest_data))
|
||||
else:
|
||||
sections.append(f"## {tool.display_name}\n{tool.description}\n")
|
||||
sections.append("\n---\n")
|
||||
except Exception:
|
||||
sections.append(f"## {tool.display_name}\n{tool.description}\n")
|
||||
sections.append("\n---\n")
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
return asyncio.get_event_loop().run_until_complete(_generate())
|
||||
|
||||
|
||||
def generate_command_reference(manifest: dict) -> str:
|
||||
"""Generate compact command reference for quick lookup"""
|
||||
commands = manifest.get("commands", [])
|
||||
lines = [f"### {manifest.get('name', 'tool')} Commands\n"]
|
||||
|
||||
for cmd in commands:
|
||||
params = cmd.get("parameters", {}).get("required", [])
|
||||
param_str = ", ".join(params) if params else ""
|
||||
lines.append(
|
||||
f"- `{cmd.get('name', 'cmd')}({param_str})`: {cmd.get('description', '')[:50]}..."
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
129
backend/app/tools/discovery.py
Normal file
129
backend/app/tools/discovery.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tool Discovery
|
||||
|
||||
Automatic tool discovery from manifest files with hot reload support.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
import asyncio
|
||||
|
||||
|
||||
class ToolDiscovery:
|
||||
"""Tool automatic discovery"""
|
||||
|
||||
def __init__(self, manifest_dir: Optional[Path] = None):
|
||||
if manifest_dir is None:
|
||||
manifest_dir = Path(__file__).parent / "manifests"
|
||||
self.manifest_dir = Path(manifest_dir)
|
||||
|
||||
def discover(self) -> List[Path]:
|
||||
"""Discover all manifest files"""
|
||||
manifests = list(self.manifest_dir.glob("**/*.yaml"))
|
||||
manifests.extend(self.manifest_dir.glob("**/*.yml"))
|
||||
manifests.extend(self.manifest_dir.glob("**/*.json"))
|
||||
return manifests
|
||||
|
||||
def discover_by_tag(self, tag: str) -> List[Path]:
|
||||
"""Discover manifests by tag"""
|
||||
import yaml
|
||||
|
||||
results = []
|
||||
for manifest_path in self.discover():
|
||||
try:
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if data and tag in data.get("tags", []):
|
||||
results.append(manifest_path)
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
async def hot_reload(self, registry: Any) -> Dict[str, bool]:
|
||||
"""Hot reload all tools in registry"""
|
||||
results = {}
|
||||
for manifest_path in self.discover():
|
||||
tool_name = manifest_path.stem
|
||||
try:
|
||||
executor = load_executor(manifest_path)
|
||||
await registry.register(str(manifest_path), executor)
|
||||
results[tool_name] = True
|
||||
except Exception as e:
|
||||
results[tool_name] = False
|
||||
return results
|
||||
|
||||
|
||||
def load_executor(manifest_path: Path) -> Callable:
|
||||
"""Load tool executor from manifest"""
|
||||
import yaml
|
||||
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
|
||||
runtime = manifest.get("runtime", "python")
|
||||
|
||||
if runtime == "python":
|
||||
return load_python_executor(manifest)
|
||||
elif runtime == "javascript":
|
||||
return load_js_executor(manifest)
|
||||
else:
|
||||
return load_native_executor(manifest)
|
||||
|
||||
|
||||
def load_python_executor(manifest: dict) -> Callable:
|
||||
"""Load Python executor"""
|
||||
entry = manifest.get("entry", "")
|
||||
tool_name = manifest.get("name", "")
|
||||
|
||||
def executor(command: str, parameters: dict) -> dict:
|
||||
return {
|
||||
"status": "success",
|
||||
"result": f"Python tool {tool_name} executed {command}",
|
||||
"message": f"Tool {tool_name} is not yet fully implemented",
|
||||
}
|
||||
|
||||
return executor
|
||||
|
||||
|
||||
def load_js_executor(manifest: dict) -> Callable:
|
||||
"""Load JavaScript executor"""
|
||||
tool_name = manifest.get("name", "")
|
||||
|
||||
def executor(command: str, parameters: dict) -> dict:
|
||||
return {
|
||||
"status": "success",
|
||||
"result": f"JS tool {tool_name} executed {command}",
|
||||
"message": f"Tool {tool_name} requires Node.js runtime",
|
||||
}
|
||||
|
||||
return executor
|
||||
|
||||
|
||||
def load_native_executor(manifest: dict) -> Callable:
|
||||
"""Load native executor"""
|
||||
tool_name = manifest.get("name", "")
|
||||
|
||||
def executor(command: str, parameters: dict) -> dict:
|
||||
return {
|
||||
"status": "success",
|
||||
"result": f"Native tool {tool_name} executed {command}",
|
||||
"message": f"Tool {tool_name} requires native binary",
|
||||
}
|
||||
|
||||
return executor
|
||||
|
||||
|
||||
async def load_all_tools(registry: Any, manifest_dir: Optional[Path] = None) -> int:
|
||||
"""Load all tools from manifest directory"""
|
||||
discovery = ToolDiscovery(manifest_dir)
|
||||
count = 0
|
||||
|
||||
for manifest_path in discovery.discover():
|
||||
try:
|
||||
executor = load_executor(manifest_path)
|
||||
await registry.register(str(manifest_path), executor)
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return count
|
||||
1
backend/app/tools/implementations/__init__.py
Normal file
1
backend/app/tools/implementations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Implementations Module
|
||||
242
backend/app/tools/implementations/file_operator.py
Normal file
242
backend/app/tools/implementations/file_operator.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
File Operator Tool
|
||||
|
||||
File system operations tool with path safety checks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class FileOperator:
|
||||
"""File operator tool"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.allowed_dirs = self._parse_allowed_dirs(config.get("allowed_directories", ""))
|
||||
self.max_file_size = config.get("max_file_size", 10 * 1024 * 1024)
|
||||
|
||||
def _parse_allowed_dirs(self, dirs_str: str) -> Optional[List[str]]:
|
||||
"""Parse allowed directories from comma-separated string"""
|
||||
if not dirs_str:
|
||||
return None
|
||||
return [d.strip() for d in dirs_str.split(",") if d.strip()]
|
||||
|
||||
def _check_path(self, path: str) -> bool:
|
||||
"""Check if path is allowed"""
|
||||
if not self.allowed_dirs:
|
||||
return True
|
||||
resolved = Path(path).resolve()
|
||||
return any(str(resolved).startswith(allowed) for allowed in self.allowed_dirs)
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
filePath: str,
|
||||
encoding: str = "utf-8",
|
||||
) -> Dict[str, Any]:
|
||||
"""Read file content"""
|
||||
if not self._check_path(filePath):
|
||||
return {"status": "error", "error": "Path not in allowed directories"}
|
||||
|
||||
path = Path(filePath)
|
||||
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "File does not exist"}
|
||||
|
||||
if not path.is_file():
|
||||
return {"status": "error", "error": "Path is not a file"}
|
||||
|
||||
try:
|
||||
stat = path.stat()
|
||||
if stat.st_size > self.max_file_size:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"File too large (> {self.max_file_size} bytes)",
|
||||
}
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in [".pdf", ".docx", ".xlsx", ".xls", ".csv"]:
|
||||
return await self._read_binary_file(path)
|
||||
|
||||
content = path.read_text(encoding=encoding)
|
||||
return {"status": "success", "result": content}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _read_binary_file(self, path: Path) -> Dict[str, Any]:
|
||||
"""Read binary file with format detection"""
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
if suffix == ".pdf":
|
||||
return await self._read_pdf(path)
|
||||
elif suffix in [".docx", ".doc"]:
|
||||
return await self._read_docx(path)
|
||||
elif suffix in [".xlsx", ".xls"]:
|
||||
return await self._read_xlsx(path)
|
||||
elif suffix == ".csv":
|
||||
return await self._read_csv(path)
|
||||
|
||||
return {"status": "error", "error": f"Unsupported file format: {suffix}"}
|
||||
|
||||
async def _read_pdf(self, path: Path) -> Dict[str, Any]:
|
||||
"""Read PDF file (placeholder - requires PyPDF2)"""
|
||||
return {"status": "error", "error": "PDF reading requires PyPDF2 dependency"}
|
||||
|
||||
async def _read_docx(self, path: Path) -> Dict[str, Any]:
|
||||
"""Read DOCX file (placeholder - requires python-docx)"""
|
||||
return {"status": "error", "error": "DOCX reading requires python-docx dependency"}
|
||||
|
||||
async def _read_xlsx(self, path: Path) -> Dict[str, Any]:
|
||||
"""Read XLSX file (placeholder - requires openpyxl)"""
|
||||
return {"status": "error", "error": "XLSX reading requires openpyxl dependency"}
|
||||
|
||||
async def _read_csv(self, path: Path) -> Dict[str, Any]:
|
||||
"""Read CSV file"""
|
||||
try:
|
||||
import csv
|
||||
|
||||
rows = []
|
||||
with open(path, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
rows.append(row)
|
||||
return {"status": "success", "result": rows}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
filePath: str,
|
||||
content: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write content to file"""
|
||||
if not self._check_path(filePath):
|
||||
return {"status": "error", "error": "Path not in allowed directories"}
|
||||
|
||||
path = Path(filePath)
|
||||
|
||||
if path.exists():
|
||||
path = self._get_unique_path(path)
|
||||
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"status": "success",
|
||||
"result": f"File saved: {path.name}",
|
||||
"path": str(path),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def _get_unique_path(self, path: Path) -> Path:
|
||||
"""Get unique path by adding counter if file exists"""
|
||||
if not path.exists():
|
||||
return path
|
||||
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
counter = 1
|
||||
|
||||
while True:
|
||||
new_path = parent / f"{stem}({counter}){suffix}"
|
||||
if not new_path.exists():
|
||||
return new_path
|
||||
counter += 1
|
||||
|
||||
async def list_directory(
|
||||
self,
|
||||
directoryPath: str,
|
||||
showHidden: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""List directory contents"""
|
||||
if not self._check_path(directoryPath):
|
||||
return {"status": "error", "error": "Path not in allowed directories"}
|
||||
|
||||
path = Path(directoryPath)
|
||||
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "Directory does not exist"}
|
||||
|
||||
if not path.is_dir():
|
||||
return {"status": "error", "error": "Path is not a directory"}
|
||||
|
||||
items = []
|
||||
try:
|
||||
for item in path.iterdir():
|
||||
if not showHidden and item.name.startswith("."):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"type": "directory" if item.is_dir() else "file",
|
||||
"size": item.stat().st_size if item.is_file() else None,
|
||||
}
|
||||
)
|
||||
return {"status": "success", "result": items}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
searchPath: str,
|
||||
pattern: str,
|
||||
**options,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search files matching pattern"""
|
||||
if not self._check_path(searchPath):
|
||||
return {"status": "error", "error": "Path not in allowed directories"}
|
||||
|
||||
path = Path(searchPath)
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "Search path does not exist"}
|
||||
|
||||
case_sensitive = options.get("caseSensitive", False)
|
||||
file_type = options.get("fileType", "all")
|
||||
include_hidden = options.get("includeHidden", False)
|
||||
|
||||
import fnmatch
|
||||
|
||||
results = []
|
||||
try:
|
||||
for item in path.rglob("*"):
|
||||
if not include_hidden and item.name.startswith("."):
|
||||
continue
|
||||
|
||||
name = item.name if case_sensitive else item.name.lower()
|
||||
pat = pattern if case_sensitive else pattern.lower()
|
||||
|
||||
if not fnmatch.fnmatch(name, pat):
|
||||
continue
|
||||
|
||||
if file_type == "file" and item.is_dir():
|
||||
continue
|
||||
if file_type == "directory" and item.is_file():
|
||||
continue
|
||||
|
||||
results.append(str(item))
|
||||
|
||||
return {"status": "success", "result": results[:100]}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def create_file_operator_executor(config: dict):
|
||||
"""Create file operator executor"""
|
||||
operator = FileOperator(config)
|
||||
|
||||
async def execute(command: str, parameters: dict) -> dict:
|
||||
if command == "read_file":
|
||||
return await operator.read_file(**parameters)
|
||||
elif command == "write_file":
|
||||
return await operator.write_file(**parameters)
|
||||
elif command == "list_directory":
|
||||
return await operator.list_directory(**parameters)
|
||||
elif command == "search_files":
|
||||
return await operator.search_files(**parameters)
|
||||
else:
|
||||
return {"status": "error", "error": f"Unknown command: {command}"}
|
||||
|
||||
return execute
|
||||
194
backend/app/tools/implementations/task_manager.py
Normal file
194
backend/app/tools/implementations/task_manager.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Task Manager Tool
|
||||
|
||||
Task creation, management and status tracking.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task status"""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""Task definition"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
scheduled_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Task manager tool"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
|
||||
async def create_task(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
scheduled_at: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new task"""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
task = Task(
|
||||
id=task_id,
|
||||
name=name,
|
||||
description=description,
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
self._tasks[task_id] = task
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"id": task_id,
|
||||
"name": task.name,
|
||||
"status": task.status.value,
|
||||
"created_at": task.created_at.isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
status: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""List tasks with optional status filter"""
|
||||
tasks = list(self._tasks.values())
|
||||
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status.value == status]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"status": t.status.value,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
"scheduled_at": t.scheduled_at.isoformat() if t.scheduled_at else None,
|
||||
}
|
||||
for t in tasks
|
||||
],
|
||||
}
|
||||
|
||||
async def get_task(self, task_id: str) -> Dict[str, Any]:
|
||||
"""Get task details"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "Task not found"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"description": task.description,
|
||||
"status": task.status.value,
|
||||
"result": task.result,
|
||||
"error": task.error,
|
||||
"created_at": task.created_at.isoformat(),
|
||||
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
|
||||
},
|
||||
}
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
status: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update task status"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "Task not found"}
|
||||
|
||||
try:
|
||||
task.status = TaskStatus(status)
|
||||
return {"status": "success"}
|
||||
except ValueError:
|
||||
return {"status": "error", "error": f"Invalid status: {status}"}
|
||||
|
||||
async def complete_task(
|
||||
self,
|
||||
task_id: str,
|
||||
result: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Mark task as completed"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "Task not found"}
|
||||
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.result = result
|
||||
task.completed_at = datetime.utcnow()
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
async def fail_task(
|
||||
self,
|
||||
task_id: str,
|
||||
error: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Mark task as failed"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "Task not found"}
|
||||
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = error
|
||||
task.completed_at = datetime.utcnow()
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
async def delete_task(self, task_id: str) -> Dict[str, Any]:
|
||||
"""Delete a task"""
|
||||
if task_id not in self._tasks:
|
||||
return {"status": "error", "error": "Task not found"}
|
||||
|
||||
del self._tasks[task_id]
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
def create_task_manager_executor(config: dict):
|
||||
"""Create task manager executor"""
|
||||
manager = TaskManager(config)
|
||||
|
||||
async def execute(command: str, parameters: dict) -> dict:
|
||||
if command == "create_task":
|
||||
return await manager.create_task(**parameters)
|
||||
elif command == "list_tasks":
|
||||
return await manager.list_tasks(**parameters)
|
||||
elif command == "get_task":
|
||||
return await manager.get_task(**parameters)
|
||||
elif command == "update_task_status":
|
||||
return await manager.update_task_status(**parameters)
|
||||
elif command == "complete_task":
|
||||
return await manager.complete_task(**parameters)
|
||||
elif command == "fail_task":
|
||||
return await manager.fail_task(**parameters)
|
||||
elif command == "delete_task":
|
||||
return await manager.delete_task(**parameters)
|
||||
else:
|
||||
return {"status": "error", "error": f"Unknown command: {command}"}
|
||||
|
||||
return execute
|
||||
91
backend/app/tools/implementations/web_fetch.py
Normal file
91
backend/app/tools/implementations/web_fetch.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Web Fetch Tool
|
||||
|
||||
Web content fetching and screenshot tool.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""Fetch result container"""
|
||||
|
||||
url: str
|
||||
title: Optional[str]
|
||||
content: str
|
||||
images: List[str]
|
||||
links: List[str]
|
||||
status: int
|
||||
|
||||
|
||||
class WebFetch:
|
||||
"""Web fetch tool"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.timeout = config.get("timeout", 30)
|
||||
self.user_agent = config.get("user_agent", "Mozilla/5.0 (compatible; Jarvis/1.0)")
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
url: str,
|
||||
include_images: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch web page content"""
|
||||
try:
|
||||
result = await self._do_fetch(url, include_images)
|
||||
return {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"url": result.url,
|
||||
"title": result.title,
|
||||
"content": result.content,
|
||||
"images": result.images if include_images else [],
|
||||
"links": result.links,
|
||||
"status": result.status,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _do_fetch(
|
||||
self,
|
||||
url: str,
|
||||
include_images: bool,
|
||||
) -> FetchResult:
|
||||
"""Perform actual fetch (placeholder - needs httpx)"""
|
||||
return FetchResult(
|
||||
url=url,
|
||||
title="Placeholder Title",
|
||||
content="This is placeholder content. Configure httpx/beautifulsoup4 for real fetching.",
|
||||
images=[],
|
||||
links=[],
|
||||
status=200,
|
||||
)
|
||||
|
||||
async def screenshot(
|
||||
self,
|
||||
url: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Take screenshot of web page (placeholder)"""
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "Screenshot requires puppeteer or playwright integration",
|
||||
}
|
||||
|
||||
|
||||
def create_web_fetch_executor(config: dict):
|
||||
"""Create web fetch executor"""
|
||||
fetcher = WebFetch(config)
|
||||
|
||||
async def execute(command: str, parameters: dict) -> dict:
|
||||
if command == "fetch":
|
||||
return await fetcher.fetch(**parameters)
|
||||
elif command == "screenshot":
|
||||
return await fetcher.screenshot(**parameters)
|
||||
else:
|
||||
return {"status": "error", "error": f"Unknown command: {command}"}
|
||||
|
||||
return execute
|
||||
90
backend/app/tools/implementations/web_search.py
Normal file
90
backend/app/tools/implementations/web_search.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Web Search Tool
|
||||
|
||||
Web search tool with result aggregation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
class WebSearch:
|
||||
"""Web search tool"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.api_key = config.get("api_key")
|
||||
self.max_results = config.get("max_results", 10)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute web search"""
|
||||
try:
|
||||
results = await self._do_search(
|
||||
query,
|
||||
max_results or self.max_results,
|
||||
)
|
||||
return {"status": "success", "result": results}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _do_search(self, query: str, limit: int) -> List[dict]:
|
||||
"""Perform actual search (placeholder - needs search API)"""
|
||||
return [
|
||||
{
|
||||
"title": f"Search result for: {query}",
|
||||
"url": "https://example.com",
|
||||
"snippet": "This is a placeholder search result. Configure API key for real results.",
|
||||
}
|
||||
]
|
||||
|
||||
async def deep_search(
|
||||
self,
|
||||
query: str,
|
||||
keywords: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Deep search with multiple queries"""
|
||||
try:
|
||||
tasks = [self._do_search(kw, 5) for kw in [query] + keywords]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
aggregated = self._aggregate_results(results)
|
||||
|
||||
return {"status": "success", "result": aggregated}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def _aggregate_results(self, results: List[List[dict]]) -> dict:
|
||||
"""Aggregate search results from multiple queries"""
|
||||
all_results = []
|
||||
for result_list in results:
|
||||
all_results.extend(result_list)
|
||||
|
||||
unique_results = []
|
||||
seen_urls = set()
|
||||
for r in all_results:
|
||||
if r.get("url") not in seen_urls:
|
||||
seen_urls.add(r.get("url"))
|
||||
unique_results.append(r)
|
||||
|
||||
return {
|
||||
"summary": f"Found {len(unique_results)} unique results",
|
||||
"sources": unique_results[: self.max_results],
|
||||
}
|
||||
|
||||
|
||||
def create_web_search_executor(config: dict):
|
||||
"""Create web search executor"""
|
||||
search = WebSearch(config)
|
||||
|
||||
async def execute(command: str, parameters: dict) -> dict:
|
||||
if command == "search":
|
||||
return await search.search(**parameters)
|
||||
elif command == "deep_search":
|
||||
return await search.deep_search(**parameters)
|
||||
else:
|
||||
return {"status": "error", "error": f"Unknown command: {command}"}
|
||||
|
||||
return execute
|
||||
75
backend/app/tools/langchain_adapter.py
Normal file
75
backend/app/tools/langchain_adapter.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
LangChain Adapter
|
||||
|
||||
Adapts Jarvis tools to LangChain tool format.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
import json
|
||||
|
||||
|
||||
class LangChainToolAdapter:
|
||||
"""Adapter for converting Jarvis tools to LangChain tools"""
|
||||
|
||||
def __init__(self, registry: Any):
|
||||
self.registry = registry
|
||||
|
||||
def to_langchain_tools(self) -> List[Dict[str, Any]]:
|
||||
"""Convert all enabled tools to LangChain format"""
|
||||
import asyncio
|
||||
|
||||
async def _convert():
|
||||
tools = await self.registry.list_enabled()
|
||||
result = []
|
||||
|
||||
for metadata in tools:
|
||||
lc_tool = await self._create_langchain_tool(metadata)
|
||||
if lc_tool:
|
||||
result.append(lc_tool)
|
||||
|
||||
return result
|
||||
|
||||
return asyncio.get_event_loop().run_until_complete(_convert())
|
||||
|
||||
async def _create_langchain_tool(self, metadata: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Create a single LangChain tool from metadata"""
|
||||
executor = await self.registry.get_executor(metadata.name)
|
||||
if not executor:
|
||||
return None
|
||||
|
||||
config = await self.registry.get_config(metadata.name)
|
||||
|
||||
return {
|
||||
"name": metadata.name,
|
||||
"description": metadata.description,
|
||||
"display_name": metadata.display_name,
|
||||
"tags": metadata.tags,
|
||||
"version": metadata.version,
|
||||
"executor": executor,
|
||||
"config": config,
|
||||
}
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""Get tool schemas for LLM function calling"""
|
||||
import asyncio
|
||||
|
||||
async def _get():
|
||||
tools = await self.registry.list_enabled()
|
||||
schemas = []
|
||||
|
||||
for tool in tools:
|
||||
schemas.append(
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return schemas
|
||||
|
||||
return asyncio.get_event_loop().run_until_complete(_get())
|
||||
88
backend/app/tools/manifests/file_operator.yaml
Normal file
88
backend/app/tools/manifests/file_operator.yaml
Normal file
@@ -0,0 +1,88 @@
|
||||
manifest_version: "1.0.0"
|
||||
name: file_operator
|
||||
display_name: 文件操作器
|
||||
description: 强大的文件系统操作工具,支持读写、搜索、下载等功能
|
||||
author: Jarvis
|
||||
version: "1.0.0"
|
||||
|
||||
type: sync
|
||||
runtime: python
|
||||
entry: tools/implementations/file_operator.py
|
||||
timeout: 30000
|
||||
|
||||
config_schema:
|
||||
allowed_directories:
|
||||
type: string
|
||||
description: 允许操作的目录列表,逗号分隔
|
||||
default: ""
|
||||
max_file_size:
|
||||
type: integer
|
||||
description: 最大文件大小(字节)
|
||||
default: 10485760
|
||||
|
||||
commands:
|
||||
- name: read_file
|
||||
description: |
|
||||
读取指定路径文件的内容。支持 PDF、DOCX、XLSX 等格式自动解析。
|
||||
参数:
|
||||
- filePath (必需): 文件绝对路径
|
||||
- encoding (可选): 编码格式,默认 utf8
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
description: 文件绝对路径
|
||||
encoding:
|
||||
type: string
|
||||
default: utf8
|
||||
required: [filePath]
|
||||
|
||||
- name: write_file
|
||||
description: |
|
||||
将内容写入文件。如果文件存在,自动创建新文件避免覆盖。
|
||||
参数:
|
||||
- filePath (必需): 文件绝对路径
|
||||
- content (必需): 文件内容
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
required: [filePath, content]
|
||||
|
||||
- name: list_directory
|
||||
description: |
|
||||
列出目录内容。
|
||||
参数:
|
||||
- directoryPath (必需): 目录绝对路径
|
||||
- showHidden (可选): 是否显示隐藏文件
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
directoryPath:
|
||||
type: string
|
||||
showHidden:
|
||||
type: boolean
|
||||
default: false
|
||||
required: [directoryPath]
|
||||
|
||||
- name: search_files
|
||||
description: |
|
||||
递归搜索匹配模式的文件。
|
||||
参数:
|
||||
- searchPath (必需): 搜索起始目录
|
||||
- pattern (必需): 文件模式,如 *.txt
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
searchPath:
|
||||
type: string
|
||||
pattern:
|
||||
type: string
|
||||
required: [searchPath, pattern]
|
||||
|
||||
tags: [file, system, essential]
|
||||
enabled: true
|
||||
90
backend/app/tools/manifests/task_manager.yaml
Normal file
90
backend/app/tools/manifests/task_manager.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
manifest_version: "1.0.0"
|
||||
name: task_manager
|
||||
display_name: 任务管理
|
||||
description: 任务创建、查询、更新和状态管理
|
||||
author: Jarvis
|
||||
version: "1.0.0"
|
||||
|
||||
type: sync
|
||||
runtime: python
|
||||
entry: tools/implementations/task_manager.py
|
||||
timeout: 10000
|
||||
|
||||
config_schema: {}
|
||||
|
||||
commands:
|
||||
- name: create_task
|
||||
description: |
|
||||
创建新任务。
|
||||
参数:
|
||||
- name (必需): 任务名称
|
||||
- description (必需): 任务描述
|
||||
- scheduled_at (可选): 计划执行时间 (ISO 格式)
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
scheduled_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [name, description]
|
||||
|
||||
- name: list_tasks
|
||||
description: |
|
||||
列出任务。
|
||||
参数:
|
||||
- status (可选): 按状态筛选 (pending/running/completed/failed)
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, running, completed, failed]
|
||||
|
||||
- name: get_task
|
||||
description: |
|
||||
获取任务详情。
|
||||
参数:
|
||||
- task_id (必需): 任务 ID
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
required: [task_id]
|
||||
|
||||
- name: complete_task
|
||||
description: |
|
||||
标记任务完成。
|
||||
参数:
|
||||
- task_id (必需): 任务 ID
|
||||
- result (必需): 执行结果
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
result:
|
||||
type: object
|
||||
required: [task_id, result]
|
||||
|
||||
- name: fail_task
|
||||
description: |
|
||||
标记任务失败。
|
||||
参数:
|
||||
- task_id (必需): 任务 ID
|
||||
- error (必需): 错误信息
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
required: [task_id, error]
|
||||
|
||||
tags: [task, management]
|
||||
enabled: true
|
||||
55
backend/app/tools/manifests/web_fetch.yaml
Normal file
55
backend/app/tools/manifests/web_fetch.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
manifest_version: "1.0.0"
|
||||
name: web_fetch
|
||||
display_name: 网页抓取
|
||||
description: 网页内容抓取工具,支持 HTML 解析、截图等功能
|
||||
author: Jarvis
|
||||
version: "1.0.0"
|
||||
|
||||
type: sync
|
||||
runtime: python
|
||||
entry: tools/implementations/web_fetch.py
|
||||
timeout: 30000
|
||||
|
||||
config_schema:
|
||||
timeout:
|
||||
type: integer
|
||||
description: 请求超时时间(秒)
|
||||
default: 30
|
||||
user_agent:
|
||||
type: string
|
||||
description: User-Agent 字符串
|
||||
default: "Mozilla/5.0 (compatible; Jarvis/1.0)"
|
||||
|
||||
commands:
|
||||
- name: fetch
|
||||
description: |
|
||||
抓取网页内容。
|
||||
参数:
|
||||
- url (必需): 网页 URL
|
||||
- include_images (可选): 是否包含图片列表
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
include_images:
|
||||
type: boolean
|
||||
default: true
|
||||
required: [url]
|
||||
|
||||
- name: screenshot
|
||||
description: |
|
||||
截取网页截图。
|
||||
参数:
|
||||
- url (必需): 网页 URL
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
required: [url]
|
||||
|
||||
tags: [web, fetch, scraping]
|
||||
enabled: true
|
||||
63
backend/app/tools/manifests/web_search.yaml
Normal file
63
backend/app/tools/manifests/web_search.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
manifest_version: "1.0.0"
|
||||
name: web_search
|
||||
display_name: 联网搜索
|
||||
description: 语义级并发搜索引擎,支持多源搜索和结果聚合
|
||||
author: Jarvis
|
||||
version: "1.0.0"
|
||||
|
||||
type: sync
|
||||
runtime: python
|
||||
entry: tools/implementations/web_search.py
|
||||
timeout: 60000
|
||||
|
||||
config_schema:
|
||||
api_key:
|
||||
type: string
|
||||
description: 搜索引擎 API 密钥
|
||||
required: true
|
||||
max_results:
|
||||
type: integer
|
||||
description: 最大返回结果数
|
||||
default: 10
|
||||
|
||||
commands:
|
||||
- name: search
|
||||
description: |
|
||||
执行语义级搜索。
|
||||
参数:
|
||||
- query (必需): 搜索关键词
|
||||
- max_results (可选): 最大结果数
|
||||
- sources (可选): 搜索源列表
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
max_results:
|
||||
type: integer
|
||||
default: 10
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [query]
|
||||
|
||||
- name: deep_search
|
||||
description: |
|
||||
深度搜索,带摘要生成。
|
||||
参数:
|
||||
- query (必需): 研究主题
|
||||
- keywords (必需): 关键词列表
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
keywords:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [query, keywords]
|
||||
|
||||
tags: [search, web, research]
|
||||
enabled: true
|
||||
103
backend/app/tools/permissions.py
Normal file
103
backend/app/tools/permissions.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Tool Permissions
|
||||
|
||||
Permission control for tool execution.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Set, Dict, Optional, List
|
||||
|
||||
|
||||
class ToolPermission(str, Enum):
|
||||
"""Tool permissions"""
|
||||
|
||||
EXECUTE = "tool:execute"
|
||||
CONFIGURE = "tool:configure"
|
||||
ENABLE = "tool:enable"
|
||||
DISABLE = "tool:disable"
|
||||
VIEW = "tool:view"
|
||||
|
||||
|
||||
class ToolPermissionChecker:
|
||||
"""Tool permission checker"""
|
||||
|
||||
def __init__(self):
|
||||
self._user_permissions: Dict[str, Set[ToolPermission]] = {}
|
||||
self._tool_roles: Dict[str, Set[str]] = {} # tool_name -> required_roles
|
||||
self._role_permissions: Dict[str, Set[ToolPermission]] = {
|
||||
"admin": {
|
||||
ToolPermission.EXECUTE,
|
||||
ToolPermission.CONFIGURE,
|
||||
ToolPermission.ENABLE,
|
||||
ToolPermission.DISABLE,
|
||||
ToolPermission.VIEW,
|
||||
},
|
||||
"user": {ToolPermission.EXECUTE, ToolPermission.VIEW},
|
||||
"guest": {ToolPermission.VIEW},
|
||||
}
|
||||
|
||||
def set_user_permissions(
|
||||
self,
|
||||
user_id: str,
|
||||
permissions: Set[ToolPermission],
|
||||
) -> None:
|
||||
"""Set user permissions directly"""
|
||||
self._user_permissions[user_id] = permissions
|
||||
|
||||
def set_user_role(self, user_id: str, role: str) -> None:
|
||||
"""Set user role"""
|
||||
if role in self._role_permissions:
|
||||
self._user_permissions[user_id] = self._role_permissions[role].copy()
|
||||
|
||||
def set_tool_roles(
|
||||
self,
|
||||
tool_name: str,
|
||||
required_roles: Set[str],
|
||||
) -> None:
|
||||
"""Set tool required roles"""
|
||||
self._tool_roles[tool_name] = required_roles
|
||||
|
||||
def can_execute(self, user_id: str, tool_name: str) -> bool:
|
||||
"""Check if user can execute tool"""
|
||||
if ToolPermission.EXECUTE in self._user_permissions.get(user_id, set()):
|
||||
return True
|
||||
|
||||
required_roles = self._tool_roles.get(tool_name, set())
|
||||
if not required_roles:
|
||||
return True
|
||||
|
||||
user_perms = self._user_permissions.get(user_id, set())
|
||||
for role in required_roles:
|
||||
if role in self._role_permissions:
|
||||
if self._role_permissions[role] & user_perms:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def can_configure(self, user_id: str, tool_name: str) -> bool:
|
||||
"""Check if user can configure tool"""
|
||||
return ToolPermission.CONFIGURE in self._user_permissions.get(user_id, set())
|
||||
|
||||
def can_enable(self, user_id: str, tool_name: str) -> bool:
|
||||
"""Check if user can enable tool"""
|
||||
return ToolPermission.ENABLE in self._user_permissions.get(user_id, set())
|
||||
|
||||
def can_disable(self, user_id: str, tool_name: str) -> bool:
|
||||
"""Check if user can disable tool"""
|
||||
return ToolPermission.DISABLE in self._user_permissions.get(user_id, set())
|
||||
|
||||
def can_view(self, user_id: str, tool_name: str) -> bool:
|
||||
"""Check if user can view tool"""
|
||||
return ToolPermission.VIEW in self._user_permissions.get(user_id, set())
|
||||
|
||||
|
||||
# Global permission checker
|
||||
_permission_checker: Optional[ToolPermissionChecker] = None
|
||||
|
||||
|
||||
def get_permission_checker() -> ToolPermissionChecker:
|
||||
"""Get global permission checker"""
|
||||
global _permission_checker
|
||||
if _permission_checker is None:
|
||||
_permission_checker = ToolPermissionChecker()
|
||||
return _permission_checker
|
||||
223
backend/app/tools/registry.py
Normal file
223
backend/app/tools/registry.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Tool Registry
|
||||
|
||||
Central registry for managing tools with dynamic registration, discovery, and statistics.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolMetadata:
|
||||
"""Tool metadata"""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
version: str
|
||||
author: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
registered_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
# Statistics
|
||||
call_count: int = 0
|
||||
error_count: int = 0
|
||||
total_duration_ms: int = 0
|
||||
|
||||
@property
|
||||
def avg_duration_ms(self) -> int:
|
||||
if self.call_count == 0:
|
||||
return 0
|
||||
return self.total_duration_ms // self.call_count
|
||||
|
||||
@property
|
||||
def error_rate(self) -> float:
|
||||
if self.call_count == 0:
|
||||
return 0.0
|
||||
return self.error_count / self.call_count
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Tool registry center for dynamic tool management"""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, ToolMetadata] = {}
|
||||
self._executors: Dict[str, Callable] = {}
|
||||
self._configs: Dict[str, dict] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# === Registration methods ===
|
||||
|
||||
async def register(
|
||||
self,
|
||||
manifest_path: str,
|
||||
executor: Callable,
|
||||
config: Optional[dict] = None,
|
||||
) -> ToolMetadata:
|
||||
"""Register a tool"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
manifest_file = Path(manifest_path)
|
||||
if not manifest_file.exists():
|
||||
raise FileNotFoundError(f"Manifest not found: {manifest_path}")
|
||||
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
from tools.schemas.validator import validate_manifest
|
||||
|
||||
manifest = validate_manifest(data)
|
||||
|
||||
metadata = ToolMetadata(
|
||||
name=manifest.name,
|
||||
display_name=manifest.display_name,
|
||||
description=manifest.description,
|
||||
version=manifest.version,
|
||||
author=manifest.author,
|
||||
tags=manifest.tags or [],
|
||||
dependencies=manifest.dependencies or [],
|
||||
enabled=manifest.enabled,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self._tools[manifest.name] = metadata
|
||||
self._executors[manifest.name] = executor
|
||||
if config:
|
||||
self._configs[manifest.name] = config
|
||||
|
||||
return metadata
|
||||
|
||||
async def register_from_dict(
|
||||
self,
|
||||
manifest_data: dict,
|
||||
executor: Callable,
|
||||
config: Optional[dict] = None,
|
||||
) -> ToolMetadata:
|
||||
"""Register a tool from manifest data dict"""
|
||||
from tools.schemas.validator import validate_manifest
|
||||
|
||||
manifest = validate_manifest(manifest_data)
|
||||
|
||||
metadata = ToolMetadata(
|
||||
name=manifest.name,
|
||||
display_name=manifest.display_name,
|
||||
description=manifest.description,
|
||||
version=manifest.version,
|
||||
author=manifest.author,
|
||||
tags=manifest.tags or [],
|
||||
dependencies=manifest.dependencies or [],
|
||||
enabled=manifest.enabled,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self._tools[manifest.name] = metadata
|
||||
self._executors[manifest.name] = executor
|
||||
if config:
|
||||
self._configs[manifest.name] = config
|
||||
|
||||
return metadata
|
||||
|
||||
async def unregister(self, name: str) -> bool:
|
||||
"""Unregister a tool"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
del self._tools[name]
|
||||
del self._executors[name]
|
||||
self._configs.pop(name, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def enable(self, name: str) -> None:
|
||||
"""Enable a tool"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
self._tools[name].enabled = True
|
||||
|
||||
async def disable(self, name: str) -> None:
|
||||
"""Disable a tool"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
self._tools[name].enabled = False
|
||||
|
||||
# === Query methods ===
|
||||
|
||||
async def get(self, name: str) -> Optional[ToolMetadata]:
|
||||
"""Get tool metadata"""
|
||||
return self._tools.get(name)
|
||||
|
||||
async def get_executor(self, name: str) -> Optional[Callable]:
|
||||
"""Get tool executor"""
|
||||
return self._executors.get(name)
|
||||
|
||||
async def get_config(self, name: str) -> dict:
|
||||
"""Get tool configuration"""
|
||||
return self._configs.get(name, {})
|
||||
|
||||
async def list_all(self) -> List[ToolMetadata]:
|
||||
"""List all tools"""
|
||||
return list(self._tools.values())
|
||||
|
||||
async def list_enabled(self) -> List[ToolMetadata]:
|
||||
"""List enabled tools"""
|
||||
return [t for t in self._tools.values() if t.enabled]
|
||||
|
||||
async def list_by_tag(self, tag: str) -> List[ToolMetadata]:
|
||||
"""List tools by tag"""
|
||||
return [t for t in self._tools.values() if tag in t.tags]
|
||||
|
||||
async def search(self, query: str) -> List[ToolMetadata]:
|
||||
"""Search tools"""
|
||||
query_lower = query.lower()
|
||||
return [
|
||||
t
|
||||
for t in self._tools.values()
|
||||
if query_lower in t.name.lower()
|
||||
or query_lower in t.description.lower()
|
||||
or query_lower in t.display_name.lower()
|
||||
]
|
||||
|
||||
# === Statistics methods ===
|
||||
|
||||
async def record_call(
|
||||
self,
|
||||
name: str,
|
||||
duration_ms: int,
|
||||
error: bool = False,
|
||||
) -> None:
|
||||
"""Record a tool call"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
tool = self._tools[name]
|
||||
tool.call_count += 1
|
||||
tool.total_duration_ms += duration_ms
|
||||
if error:
|
||||
tool.error_count += 1
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
"""Get registry statistics"""
|
||||
tools = list(self._tools.values())
|
||||
return {
|
||||
"total_tools": len(tools),
|
||||
"enabled_tools": sum(1 for t in tools if t.enabled),
|
||||
"total_calls": sum(t.call_count for t in tools),
|
||||
"total_errors": sum(t.error_count for t in tools),
|
||||
"avg_error_rate": sum(t.error_rate for t in tools) / len(tools) if tools else 0,
|
||||
}
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry: Optional[ToolRegistry] = None
|
||||
|
||||
|
||||
def get_registry() -> ToolRegistry:
|
||||
"""Get the global tool registry instance"""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ToolRegistry()
|
||||
return _registry
|
||||
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()
|
||||
447
backend/app/tools/scheduler.py
Normal file
447
backend/app/tools/scheduler.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
Tool Scheduler
|
||||
|
||||
Scheduled tool execution system with support for one-time, interval, and cron-based scheduling.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScheduleType(str, Enum):
|
||||
"""Schedule type enumeration"""
|
||||
|
||||
ONCE = "once" # Single execution
|
||||
INTERVAL = "interval" # Fixed interval
|
||||
CRON = "cron" # Cron expression
|
||||
|
||||
|
||||
class ScheduledTask(BaseModel):
|
||||
"""Scheduled task model"""
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
name: str
|
||||
schedule_type: ScheduleType
|
||||
schedule_value: str # datetime string / interval seconds / cron expression
|
||||
tool_name: str
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
run_count: int = 0
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"type": self.schedule_type.value,
|
||||
"schedule_value": self.schedule_value,
|
||||
"tool_name": self.tool_name,
|
||||
"enabled": self.enabled,
|
||||
"last_run": self.last_run.isoformat() if self.last_run else None,
|
||||
"next_run": self.next_run.isoformat() if self.next_run else None,
|
||||
"run_count": self.run_count,
|
||||
}
|
||||
|
||||
|
||||
class ToolScheduler:
|
||||
"""Tool scheduler for automated execution"""
|
||||
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, ScheduledTask] = {}
|
||||
self._running: bool = False
|
||||
self._loop_task: Optional[asyncio.Task] = None
|
||||
self._executor: Optional[Callable] = None
|
||||
|
||||
def set_executor(self, executor: Callable) -> None:
|
||||
"""Set the tool executor function
|
||||
|
||||
Args:
|
||||
executor: Async callable that takes (tool_name, command, parameters)
|
||||
"""
|
||||
self._executor = executor
|
||||
|
||||
async def schedule(
|
||||
self,
|
||||
name: str,
|
||||
schedule_type: ScheduleType,
|
||||
schedule_value: str,
|
||||
tool_name: str,
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Create a scheduled task
|
||||
|
||||
Args:
|
||||
name: Task name
|
||||
schedule_type: Type of schedule
|
||||
schedule_value: Schedule value (datetime/interval/cron)
|
||||
tool_name: Tool to execute
|
||||
parameters: Tool parameters
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
task = ScheduledTask(
|
||||
name=name,
|
||||
schedule_type=schedule_type,
|
||||
schedule_value=schedule_value,
|
||||
tool_name=tool_name,
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
task.next_run = self._calculate_next_run(task)
|
||||
self._tasks[task.id] = task
|
||||
|
||||
# Start scheduler loop if not running
|
||||
if not self._running:
|
||||
await self.start()
|
||||
|
||||
return task.id
|
||||
|
||||
async def schedule_once(
|
||||
self,
|
||||
name: str,
|
||||
run_at: str, # ISO datetime string
|
||||
tool_name: str,
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Schedule a one-time task
|
||||
|
||||
Args:
|
||||
name: Task name
|
||||
run_at: ISO datetime string
|
||||
tool_name: Tool to execute
|
||||
parameters: Tool parameters
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
return await self.schedule(
|
||||
name=name,
|
||||
schedule_type=ScheduleType.ONCE,
|
||||
schedule_value=run_at,
|
||||
tool_name=tool_name,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
async def schedule_interval(
|
||||
self,
|
||||
name: str,
|
||||
interval_seconds: int,
|
||||
tool_name: str,
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Schedule an interval-based recurring task
|
||||
|
||||
Args:
|
||||
name: Task name
|
||||
interval_seconds: Interval in seconds
|
||||
tool_name: Tool to execute
|
||||
parameters: Tool parameters
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
return await self.schedule(
|
||||
name=name,
|
||||
schedule_type=ScheduleType.INTERVAL,
|
||||
schedule_value=str(interval_seconds),
|
||||
tool_name=tool_name,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
async def schedule_cron(
|
||||
self,
|
||||
name: str,
|
||||
cron_expression: str,
|
||||
tool_name: str,
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Schedule a cron-based recurring task
|
||||
|
||||
Args:
|
||||
name: Task name
|
||||
cron_expression: Cron expression (5 fields: min hour day month weekday)
|
||||
tool_name: Tool to execute
|
||||
parameters: Tool parameters
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
return await self.schedule(
|
||||
name=name,
|
||||
schedule_type=ScheduleType.CRON,
|
||||
schedule_value=cron_expression,
|
||||
tool_name=tool_name,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
def _calculate_next_run(self, task: ScheduledTask) -> Optional[datetime]:
|
||||
"""Calculate the next run time for a task
|
||||
|
||||
Args:
|
||||
task: The scheduled task
|
||||
|
||||
Returns:
|
||||
Next run datetime or None if schedule type is invalid
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
if task.schedule_type == ScheduleType.ONCE:
|
||||
try:
|
||||
return datetime.fromisoformat(task.schedule_value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
elif task.schedule_type == ScheduleType.INTERVAL:
|
||||
try:
|
||||
seconds = int(task.schedule_value)
|
||||
return now + timedelta(seconds=seconds)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
elif task.schedule_type == ScheduleType.CRON:
|
||||
return self._parse_cron_next(task.schedule_value, now)
|
||||
|
||||
return None
|
||||
|
||||
def _parse_cron_next(self, cron_expr: str, base_time: datetime) -> datetime:
|
||||
"""Parse cron expression and calculate next run
|
||||
|
||||
Args:
|
||||
cron_expr: Cron expression (min hour day month weekday)
|
||||
base_time: Reference datetime
|
||||
|
||||
Returns:
|
||||
Next matching datetime
|
||||
"""
|
||||
try:
|
||||
parts = cron_expr.split()
|
||||
if len(parts) != 5:
|
||||
return base_time + timedelta(hours=1)
|
||||
|
||||
minute, hour, day, month, weekday = parts
|
||||
|
||||
# Simple next-run calculation
|
||||
# For production, use croniter library
|
||||
next_time = base_time.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
||||
|
||||
# Basic validation and advancement
|
||||
max_iterations = 366 * 24 * 60 # 1 year of minutes max
|
||||
for _ in range(max_iterations):
|
||||
if self._cron_matches(next_time, minute, hour, day, month, weekday):
|
||||
return next_time
|
||||
next_time += timedelta(minutes=1)
|
||||
|
||||
return next_time
|
||||
|
||||
except Exception:
|
||||
return base_time + timedelta(hours=1)
|
||||
|
||||
def _cron_matches(
|
||||
self,
|
||||
dt: datetime,
|
||||
minute: str,
|
||||
hour: str,
|
||||
day: str,
|
||||
month: str,
|
||||
weekday: str,
|
||||
) -> bool:
|
||||
"""Check if datetime matches cron fields
|
||||
|
||||
Args:
|
||||
dt: Datetime to check
|
||||
minute: Minute field
|
||||
hour: Hour field
|
||||
day: Day of month field
|
||||
month: Month field
|
||||
weekday: Day of week field
|
||||
|
||||
Returns:
|
||||
True if matches
|
||||
"""
|
||||
return (
|
||||
self._cron_field_matches(dt.minute, minute)
|
||||
and self._cron_field_matches(dt.hour, hour)
|
||||
and self._cron_field_matches(dt.day, day)
|
||||
and self._cron_field_matches(dt.month, month)
|
||||
and self._cron_field_matches(dt.weekday(), weekday)
|
||||
)
|
||||
|
||||
def _cron_field_matches(self, value: int, field: str) -> bool:
|
||||
"""Check if a value matches a cron field
|
||||
|
||||
Args:
|
||||
value: The actual value
|
||||
field: Cron field (number, *, */n, n,m, n-m)
|
||||
|
||||
Returns:
|
||||
True if matches
|
||||
"""
|
||||
if field == "*":
|
||||
return True
|
||||
|
||||
if field.startswith("*/"):
|
||||
try:
|
||||
step = int(field[2:])
|
||||
return value % step == 0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if "," in field:
|
||||
return str(value) in field.split(",")
|
||||
|
||||
if "-" in field:
|
||||
try:
|
||||
start, end = field.split("-")
|
||||
return start <= str(value) <= end
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
try:
|
||||
return int(field) == value
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the scheduler loop"""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._loop_task = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the scheduler loop"""
|
||||
self._running = False
|
||||
if self._loop_task:
|
||||
self._loop_task.cancel()
|
||||
try:
|
||||
await self._loop_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._loop_task = None
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""Main scheduler loop"""
|
||||
while self._running:
|
||||
now = datetime.utcnow()
|
||||
|
||||
for task in list(self._tasks.values()):
|
||||
if not task.enabled:
|
||||
continue
|
||||
|
||||
if task.next_run and task.next_run <= now:
|
||||
await self._execute_task(task)
|
||||
|
||||
await asyncio.sleep(1) # Check every second
|
||||
|
||||
async def _execute_task(self, task: ScheduledTask) -> None:
|
||||
"""Execute a scheduled task
|
||||
|
||||
Args:
|
||||
task: The task to execute
|
||||
"""
|
||||
if self._executor is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._executor(
|
||||
tool_name=task.tool_name,
|
||||
command=task.parameters.get("command", ""),
|
||||
parameters=task.parameters,
|
||||
)
|
||||
except Exception:
|
||||
pass # Log error in production
|
||||
|
||||
# Update task state
|
||||
task.last_run = datetime.utcnow()
|
||||
task.run_count += 1
|
||||
|
||||
# Calculate next run or disable one-time tasks
|
||||
if task.schedule_type != ScheduleType.ONCE:
|
||||
task.next_run = self._calculate_next_run(task)
|
||||
else:
|
||||
task.enabled = False
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel a scheduled task
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
True if cancelled
|
||||
"""
|
||||
if task_id in self._tasks:
|
||||
del self._tasks[task_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def enable(self, task_id: str) -> bool:
|
||||
"""Enable a scheduled task
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
True if enabled
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.enabled = True
|
||||
task.next_run = self._calculate_next_run(task)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def disable(self, task_id: str) -> bool:
|
||||
"""Disable a scheduled task
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
True if disabled
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
async def list_tasks(self) -> list:
|
||||
"""List all scheduled tasks
|
||||
|
||||
Returns:
|
||||
List of task info dicts
|
||||
"""
|
||||
return [task.to_dict() for task in self._tasks.values()]
|
||||
|
||||
async def get_task(self, task_id: str) -> Optional[dict]:
|
||||
"""Get a specific task
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
Task dict or None
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
return task.to_dict() if task else None
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
_scheduler: Optional[ToolScheduler] = None
|
||||
|
||||
|
||||
def get_scheduler() -> ToolScheduler:
|
||||
"""Get the global tool scheduler instance"""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = ToolScheduler()
|
||||
return _scheduler
|
||||
13
backend/app/tools/schemas/__init__.py
Normal file
13
backend/app/tools/schemas/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# Schemas Module
|
||||
from tools.schemas.manifest import ToolManifest, ToolType, RuntimeType, InvocationCommand
|
||||
from tools.schemas.tool_call import ToolCallRequest, ToolCallResponse, ToolExecutionLog
|
||||
|
||||
__all__ = [
|
||||
"ToolManifest",
|
||||
"ToolType",
|
||||
"RuntimeType",
|
||||
"InvocationCommand",
|
||||
"ToolCallRequest",
|
||||
"ToolCallResponse",
|
||||
"ToolExecutionLog",
|
||||
]
|
||||
70
backend/app/tools/schemas/manifest.py
Normal file
70
backend/app/tools/schemas/manifest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Tool Manifest Schema
|
||||
|
||||
Defines the structure for tool manifest declarations following VCPToolBox plugin patterns.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ToolType(str, Enum):
|
||||
"""Tool execution type"""
|
||||
|
||||
SYNC = "sync" # Synchronous execution
|
||||
ASYNC = "async" # Asynchronous execution
|
||||
SERVICE = "service" # Continuous running service
|
||||
|
||||
|
||||
class RuntimeType(str, Enum):
|
||||
"""Runtime type for tool execution"""
|
||||
|
||||
PYTHON = "python"
|
||||
JAVASCRIPT = "javascript"
|
||||
NATIVE = "native"
|
||||
|
||||
|
||||
class InvocationCommand(BaseModel):
|
||||
"""Command definition for tool invocation"""
|
||||
|
||||
name: str = Field(..., description="Command name")
|
||||
description: str = Field(..., description="Command description (for AI)")
|
||||
parameters: Optional[Dict[str, Any]] = Field(default=None, description="Parameters JSON Schema")
|
||||
required: Optional[List[str]] = Field(default=None, description="Required parameter list")
|
||||
example: Optional[str] = Field(default=None, description="Invocation example")
|
||||
|
||||
|
||||
class ToolManifest(BaseModel):
|
||||
"""Tool Manifest - declarative tool definition"""
|
||||
|
||||
manifest_version: str = Field(default="1.0.0", description="Manifest version")
|
||||
name: str = Field(..., description="Tool name (English, unique)")
|
||||
display_name: str = Field(..., description="Display name (Chinese)")
|
||||
description: str = Field(..., description="Tool description")
|
||||
author: Optional[str] = Field(default=None, description="Author")
|
||||
version: str = Field(default="1.0.0", description="Version number")
|
||||
|
||||
# Execution configuration
|
||||
type: ToolType = Field(default=ToolType.SYNC, description="Tool type")
|
||||
runtime: RuntimeType = Field(default=RuntimeType.PYTHON, description="Runtime")
|
||||
entry: str = Field(..., description="Execution entry (file path or command)")
|
||||
timeout: int = Field(default=30000, description="Timeout (milliseconds)")
|
||||
|
||||
# Configuration
|
||||
config_schema: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="Configuration schema"
|
||||
)
|
||||
|
||||
# Capabilities
|
||||
commands: List[InvocationCommand] = Field(
|
||||
default_factory=list, description="Available commands"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
tags: Optional[List[str]] = Field(default=None, description="Tags")
|
||||
dependencies: Optional[List[str]] = Field(default=None, description="Dependency tools")
|
||||
enabled: bool = Field(default=True, description="Whether enabled")
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
46
backend/app/tools/schemas/tool_call.py
Normal file
46
backend/app/tools/schemas/tool_call.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Tool Call Schema
|
||||
|
||||
Defines request/response structures for tool invocation.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
"""Tool call request"""
|
||||
|
||||
tool_name: str = Field(..., description="Tool name")
|
||||
command: str = Field(..., description="Command name")
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict, description="Parameters")
|
||||
timeout: Optional[int] = Field(default=None, description="Timeout override")
|
||||
context: Optional[Dict[str, Any]] = Field(default=None, description="Context information")
|
||||
|
||||
|
||||
class ToolCallResponse(BaseModel):
|
||||
"""Tool call response"""
|
||||
|
||||
status: str = Field(..., description="Status: success/error")
|
||||
result: Optional[Any] = Field(default=None, description="Execution result")
|
||||
error: Optional[str] = Field(default=None, description="Error message")
|
||||
message: Optional[str] = Field(default=None, description="AI-friendly message")
|
||||
base64: Optional[str] = Field(default=None, description="Base64 data")
|
||||
duration_ms: Optional[int] = Field(default=None, description="Execution duration")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class ToolExecutionLog(BaseModel):
|
||||
"""Tool execution log"""
|
||||
|
||||
id: str
|
||||
tool_name: str
|
||||
command: str
|
||||
parameters: Dict[str, Any]
|
||||
status: str
|
||||
duration_ms: int
|
||||
error: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
37
backend/app/tools/schemas/validator.py
Normal file
37
backend/app/tools/schemas/validator.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Schema Validator
|
||||
|
||||
Validates tool manifests and tool calls against their schemas.
|
||||
"""
|
||||
|
||||
from pydantic import ValidationError
|
||||
from tools.schemas.manifest import ToolManifest
|
||||
from tools.schemas.tool_call import ToolCallRequest
|
||||
|
||||
|
||||
class ManifestValidationError(Exception):
|
||||
"""Manifest validation error"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ToolCallValidationError(Exception):
|
||||
"""Tool call validation error"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def validate_manifest(data: dict) -> ToolManifest:
|
||||
"""Validate manifest data against ToolManifest schema"""
|
||||
try:
|
||||
return ToolManifest(**data)
|
||||
except ValidationError as e:
|
||||
raise ManifestValidationError(str(e))
|
||||
|
||||
|
||||
def validate_tool_call(data: dict) -> ToolCallRequest:
|
||||
"""Validate tool call request against ToolCallRequest schema"""
|
||||
try:
|
||||
return ToolCallRequest(**data)
|
||||
except ValidationError as e:
|
||||
raise ToolCallValidationError(str(e))
|
||||
Reference in New Issue
Block a user