Refactor project structure and enhance backend integration
- Expanded package inclusion in `pyproject.toml` to support new modules. - Introduced new `adapters` and `protocol` packages for better organization. - Added backend adapter implementations for control plane integration. - Updated main application imports to reflect new package structure. - Removed deprecated core components and adjusted documentation accordingly. - Enhanced architecture documentation to clarify the new runtime and integration layers.
This commit is contained in:
1
engine/tools/__init__.py
Normal file
1
engine/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tools package."""
|
||||
362
engine/tools/executor.py
Normal file
362
engine/tools/executor.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Server-side tool execution helpers."""
|
||||
|
||||
import asyncio
|
||||
import ast
|
||||
import operator
|
||||
from datetime import datetime
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from adapters.control_plane.backend import build_backend_adapter_from_settings
|
||||
|
||||
ToolResourceFetcher = Callable[[str], Awaitable[Optional[Dict[str, Any]]]]
|
||||
|
||||
_BIN_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Mod: operator.mod,
|
||||
}
|
||||
|
||||
_UNARY_OPS = {
|
||||
ast.UAdd: operator.pos,
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
_SAFE_EVAL_FUNCS = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"len": len,
|
||||
}
|
||||
|
||||
|
||||
def _validate_safe_expr(node: ast.AST) -> None:
|
||||
"""Allow only a constrained subset of Python expression nodes."""
|
||||
if isinstance(node, ast.Expression):
|
||||
_validate_safe_expr(node.body)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.Constant):
|
||||
return
|
||||
|
||||
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
||||
for elt in node.elts:
|
||||
_validate_safe_expr(elt)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.Dict):
|
||||
for key in node.keys:
|
||||
if key is not None:
|
||||
_validate_safe_expr(key)
|
||||
for value in node.values:
|
||||
_validate_safe_expr(value)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.BinOp):
|
||||
if type(node.op) not in _BIN_OPS:
|
||||
raise ValueError("unsupported operator")
|
||||
_validate_safe_expr(node.left)
|
||||
_validate_safe_expr(node.right)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
if type(node.op) not in _UNARY_OPS:
|
||||
raise ValueError("unsupported unary operator")
|
||||
_validate_safe_expr(node.operand)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.BoolOp):
|
||||
for value in node.values:
|
||||
_validate_safe_expr(value)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.Compare):
|
||||
_validate_safe_expr(node.left)
|
||||
for comp in node.comparators:
|
||||
_validate_safe_expr(comp)
|
||||
return
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in _SAFE_EVAL_FUNCS and node.id not in {"True", "False", "None"}:
|
||||
raise ValueError("unknown symbol")
|
||||
return
|
||||
|
||||
if isinstance(node, ast.Call):
|
||||
if not isinstance(node.func, ast.Name):
|
||||
raise ValueError("unsafe call target")
|
||||
if node.func.id not in _SAFE_EVAL_FUNCS:
|
||||
raise ValueError("function not allowed")
|
||||
for arg in node.args:
|
||||
_validate_safe_expr(arg)
|
||||
for kw in node.keywords:
|
||||
_validate_safe_expr(kw.value)
|
||||
return
|
||||
|
||||
# Explicitly reject high-risk nodes (import/attribute/subscript/comprehensions/lambda, etc.)
|
||||
raise ValueError("unsupported expression")
|
||||
|
||||
|
||||
def _safe_eval_python_expr(expression: str) -> Any:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
_validate_safe_expr(tree)
|
||||
return eval( # noqa: S307 - validated AST + empty builtins
|
||||
compile(tree, "<code_interpreter>", "eval"),
|
||||
{"__builtins__": {}},
|
||||
dict(_SAFE_EVAL_FUNCS),
|
||||
)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _safe_eval_expr(expression: str) -> float:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
|
||||
def _eval(node: ast.AST) -> float:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.BinOp):
|
||||
op = _BIN_OPS.get(type(node.op))
|
||||
if not op:
|
||||
raise ValueError("unsupported operator")
|
||||
return float(op(_eval(node.left), _eval(node.right)))
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
op = _UNARY_OPS.get(type(node.op))
|
||||
if not op:
|
||||
raise ValueError("unsupported unary operator")
|
||||
return float(op(_eval(node.operand)))
|
||||
raise ValueError("unsupported expression")
|
||||
|
||||
return _eval(tree)
|
||||
|
||||
|
||||
def _extract_tool_name(tool_call: Dict[str, Any]) -> str:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict):
|
||||
return str(function_payload.get("name") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_tool_args(tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
||||
function_payload = tool_call.get("function")
|
||||
if not isinstance(function_payload, dict):
|
||||
return {}
|
||||
raw = function_payload.get("arguments")
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
return {}
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(text)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def fetch_tool_resource(tool_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Default tool resource resolver via backend adapter."""
|
||||
adapter = build_backend_adapter_from_settings()
|
||||
return await adapter.fetch_tool_resource(tool_id)
|
||||
|
||||
|
||||
async def execute_server_tool(
|
||||
tool_call: Dict[str, Any],
|
||||
tool_resource_fetcher: Optional[ToolResourceFetcher] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a server-side tool and return normalized result payload."""
|
||||
call_id = str(tool_call.get("id") or "").strip()
|
||||
tool_name = _extract_tool_name(tool_call)
|
||||
args = _extract_tool_args(tool_call)
|
||||
resource_fetcher = tool_resource_fetcher or fetch_tool_resource
|
||||
resource: Optional[Dict[str, Any]] = None
|
||||
if tool_name and tool_name not in {"calculator", "code_interpreter", "current_time"}:
|
||||
try:
|
||||
resource = await resource_fetcher(tool_name)
|
||||
except Exception:
|
||||
resource = None
|
||||
defaults = resource.get("parameter_defaults") if isinstance(resource, dict) else None
|
||||
if isinstance(defaults, dict) and defaults:
|
||||
merged_args = dict(defaults)
|
||||
merged_args.update(args)
|
||||
args = merged_args
|
||||
|
||||
if tool_name == "calculator":
|
||||
expression = str(args.get("expression") or "").strip()
|
||||
if not expression:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"error": "missing expression"},
|
||||
"status": {"code": 400, "message": "bad_request"},
|
||||
}
|
||||
if len(expression) > 200:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"expression": expression, "error": "expression too long"},
|
||||
"status": {"code": 422, "message": "invalid_expression"},
|
||||
}
|
||||
try:
|
||||
value = _safe_eval_expr(expression)
|
||||
if value.is_integer():
|
||||
value = int(value)
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"expression": expression, "result": value},
|
||||
"status": {"code": 200, "message": "ok"},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"expression": expression, "error": str(exc)},
|
||||
"status": {"code": 422, "message": "invalid_expression"},
|
||||
}
|
||||
|
||||
if tool_name == "code_interpreter":
|
||||
code = str(args.get("code") or args.get("expression") or "").strip()
|
||||
if not code:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"error": "missing code"},
|
||||
"status": {"code": 400, "message": "bad_request"},
|
||||
}
|
||||
if len(code) > 500:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"error": "code too long"},
|
||||
"status": {"code": 422, "message": "invalid_code"},
|
||||
}
|
||||
try:
|
||||
result = _safe_eval_python_expr(code)
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"code": code, "result": _json_safe(result)},
|
||||
"status": {"code": 200, "message": "ok"},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"code": code, "error": str(exc)},
|
||||
"status": {"code": 422, "message": "invalid_code"},
|
||||
}
|
||||
|
||||
if tool_name == "current_time":
|
||||
now = datetime.now().astimezone()
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {
|
||||
"local_time": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"iso": now.isoformat(),
|
||||
"timezone": str(now.tzinfo or ""),
|
||||
"timestamp": int(now.timestamp()),
|
||||
},
|
||||
"status": {"code": 200, "message": "ok"},
|
||||
}
|
||||
|
||||
if tool_name and tool_name not in {"calculator", "code_interpreter", "current_time"}:
|
||||
if resource and str(resource.get("category") or "") == "query":
|
||||
method = str(resource.get("http_method") or "GET").strip().upper()
|
||||
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
||||
method = "GET"
|
||||
url = str(resource.get("http_url") or "").strip()
|
||||
headers = resource.get("http_headers") if isinstance(resource.get("http_headers"), dict) else {}
|
||||
timeout_ms = resource.get("http_timeout_ms")
|
||||
try:
|
||||
timeout_s = max(1.0, float(timeout_ms) / 1000.0)
|
||||
except Exception:
|
||||
timeout_s = 10.0
|
||||
|
||||
if not url:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"error": "http_url not configured"},
|
||||
"status": {"code": 422, "message": "invalid_tool_config"},
|
||||
}
|
||||
|
||||
request_kwargs: Dict[str, Any] = {}
|
||||
if method in {"GET", "DELETE"}:
|
||||
request_kwargs["params"] = args
|
||||
else:
|
||||
request_kwargs["json"] = args
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_s)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(method, url, headers=headers, **request_kwargs) as resp:
|
||||
content_type = str(resp.headers.get("Content-Type") or "").lower()
|
||||
if "application/json" in content_type:
|
||||
body: Any = await resp.json()
|
||||
else:
|
||||
body = await resp.text()
|
||||
status_code = int(resp.status)
|
||||
if 200 <= status_code < 300:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"status_code": status_code,
|
||||
"response": _json_safe(body),
|
||||
},
|
||||
"status": {"code": 200, "message": "ok"},
|
||||
}
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"status_code": status_code,
|
||||
"response": _json_safe(body),
|
||||
},
|
||||
"status": {"code": status_code, "message": "http_error"},
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"method": method, "url": url, "error": "request timeout"},
|
||||
"status": {"code": 504, "message": "http_timeout"},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"output": {"method": method, "url": url, "error": str(exc)},
|
||||
"status": {"code": 502, "message": "http_request_failed"},
|
||||
}
|
||||
|
||||
return {
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name or "unknown_tool",
|
||||
"output": {"message": "server tool not implemented"},
|
||||
"status": {"code": 501, "message": "not_implemented"},
|
||||
}
|
||||
Reference in New Issue
Block a user