Files
ai-video-fullstack/backend/services/tool_executor.py
Xin Wang 32aef14ddb Add workflow support and enhance runtime configuration in models and services
- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources.
- Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration.
- Refactor validation and processing logic in routes and services to accommodate workflow types.
- Implement dynamic variable support for workflow assistants and enhance graph normalization.
- Add ToolExecutor for reusable tool execution across different assistant types.
- Update various services to ensure compatibility with new workflow features and improve error handling.
2026-07-13 16:13:27 +08:00

155 lines
5.5 KiB
Python

"""Reusable deterministic tool execution shared by Prompt, Agent, and Action."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from urllib.parse import quote
import httpx
from models import RuntimeTool
from services.runtime_variables import (
DynamicVariableError,
DynamicVariableStore,
value_at_path,
)
class ToolExecutionError(RuntimeError):
pass
class ToolExecutor:
def __init__(self, store: DynamicVariableStore):
self.store = store
def register_secrets(self, tool: RuntimeTool) -> None:
dynamic = (tool.secrets or {}).get("dynamic_variables") or {}
for name, value in dynamic.items():
if not str(name).startswith("secret__"):
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
self.store.secrets[str(name)] = str(value)
@staticmethod
def schema_parts(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
properties = {
str(parameter.get("name")): {
"type": str(parameter.get("type") or "string"),
"description": str(parameter.get("description") or ""),
}
for parameter in parameters
if parameter.get("name")
}
required = [
str(parameter["name"])
for parameter in parameters
if parameter.get("name") and parameter.get("required", True)
]
return properties, required
async def execute(
self,
tool: RuntimeTool,
arguments: dict[str, Any] | None = None,
*,
result_assignments: dict[str, str] | None = None,
) -> dict[str, Any]:
self.register_secrets(tool)
if tool.type != "http":
raise ToolExecutionError(f"Action 暂不支持工具类型: {tool.type}")
return await self._execute_http(
tool,
dict(arguments or {}),
result_assignments=result_assignments,
)
async def _execute_http(
self,
tool: RuntimeTool,
arguments: dict[str, Any],
*,
result_assignments: dict[str, str] | None,
) -> dict[str, Any]:
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
url = self.store.render(str(config.get("url") or ""))
configured_headers = self.store.render_data(
deepcopy(config.get("headers") or {}), allow_secrets=True
)
secret_headers = self.store.render_data(
deepcopy((tool.secrets or {}).get("headers") or {}), allow_secrets=True
)
headers: dict[str, str] = {}
query: dict[str, object] = {}
body = self.store.render_data(deepcopy(config.get("body") or {}))
for parameter in parameters:
name = str(parameter.get("name") or "")
if not name or name not in arguments:
continue
value = arguments[name]
location = str(parameter.get("location") or "body")
if location == "path":
url = url.replace(f"{{{name}}}", quote(str(value), safe=""))
elif location == "query":
query[name] = value
elif location == "header":
headers[name] = str(value)
else:
body[name] = value
headers.update({str(key): str(value) for key, value in configured_headers.items()})
headers.update({str(key): str(value) for key, value in secret_headers.items()})
try:
async with httpx.AsyncClient(
timeout=float(config.get("timeout_seconds") or 15),
follow_redirects=False,
) as client:
response = await client.request(
str(config.get("method") or "GET"),
url,
headers=headers,
params=query,
json=body if body else None,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
raise ToolExecutionError("HTTP 工具调用超时") from exc
except httpx.HTTPStatusError as exc:
raise ToolExecutionError(f"HTTP 工具返回错误状态:{exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise ToolExecutionError(f"HTTP 工具调用失败:{exc}") from exc
if len(response.content) > 1_000_000:
raise ToolExecutionError("HTTP 工具响应超过 1 MB 限制")
try:
payload: Any = response.json()
except ValueError:
payload = {"text": response.text[:8000]}
assignments = (
result_assignments
if result_assignments is not None
else config.get("dynamic_variable_assignments") or {}
)
updated: list[str] = []
for variable_name, path in assignments.items():
try:
value = value_at_path(payload, str(path))
except KeyError:
try:
value = value_at_path({"response": payload}, str(path))
except KeyError:
continue
self.store.assign(str(variable_name), value)
updated.append(str(variable_name))
return {
"status": "ok",
"status_code": response.status_code,
"data": payload,
"updated_variables": updated,
}