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))
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
| Phase | 名称 | 状态 | 工作量 |
|
| Phase | 名称 | 状态 | 工作量 |
|
||||||
|-------|------|------|--------|
|
|-------|------|------|--------|
|
||||||
| T.0 | 现状与目标 | ✅ 完成 | - |
|
| T.0 | 现状与目标 | ✅ 完成 | - |
|
||||||
| T.1 | Manifest 驱动系统 | ⬜ 待开始 | 3 天 |
|
| T.1 | Manifest 驱动系统 | ✅ 完成 | 3 天 |
|
||||||
| T.2 | 工具注册中心 | ⬜ 待开始 | 2 天 |
|
| T.2 | 工具注册中心 | ✅ 完成 | 2 天 |
|
||||||
| T.3 | 核心工具实现 | ⬜ 待开始 | 5 天 |
|
| T.3 | 核心工具实现 | ✅ 完成 | 5 天 |
|
||||||
| T.4 | 高级特性 | ⬜ 待开始 | 4 天 |
|
| T.4 | 高级特性 | ✅ 完成 | 4 天 |
|
||||||
| **总计** | | | **14 天** |
|
| **总计** | | | **14 天** |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -249,3 +249,4 @@
|
|||||||
| 日期 | Phase | 变更内容 |
|
| 日期 | Phase | 变更内容 |
|
||||||
|------|-------|----------|
|
|------|-------|----------|
|
||||||
| 2026-04-04 | T.0 | 创建文档 |
|
| 2026-04-04 | T.0 | 创建文档 |
|
||||||
|
| 2026-04-05 | T.1-T.4 | 完成所有 Phase 实现 |
|
||||||
|
|||||||
Reference in New Issue
Block a user