- 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.
155 lines
5.5 KiB
Python
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,
|
|
}
|