2026-07-13 16:13:27 +08:00
|
|
|
"""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
|
2026-08-14 00:15:00 +08:00
|
|
|
from models import RuntimeTool, RuntimeToolBinding
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
from services.runtime_variables import (
|
|
|
|
|
DynamicVariableError,
|
|
|
|
|
DynamicVariableStore,
|
|
|
|
|
value_at_path,
|
|
|
|
|
)
|
2026-07-30 19:06:03 +08:00
|
|
|
from services.client_tools import ClientToolError, ClientToolPort
|
|
|
|
|
from services.tool_policy import policy_for_tool
|
2026-07-18 00:00:06 +08:00
|
|
|
from services.tools import McpClientError, McpToolClient
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ToolExecutionError(RuntimeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ToolExecutor:
|
2026-07-18 00:00:06 +08:00
|
|
|
"""Execute supported tools and apply their results to session variables."""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
store: DynamicVariableStore,
|
|
|
|
|
*,
|
|
|
|
|
mcp_client: McpToolClient | None = None,
|
2026-07-30 19:06:03 +08:00
|
|
|
client_tools: ClientToolPort | None = None,
|
2026-07-18 00:00:06 +08:00
|
|
|
):
|
2026-07-13 16:13:27 +08:00
|
|
|
self.store = store
|
2026-07-18 00:00:06 +08:00
|
|
|
self._mcp_client = mcp_client or McpToolClient()
|
2026-07-30 19:06:03 +08:00
|
|
|
self._client_tools = client_tools
|
|
|
|
|
|
|
|
|
|
def set_client_tools(self, client_tools: ClientToolPort | None) -> None:
|
|
|
|
|
self._client_tools = client_tools
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
@staticmethod
|
2026-08-14 00:15:00 +08:00
|
|
|
def input_schema(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
|
2026-07-13 16:13:27 +08:00
|
|
|
config = (tool.definition or {}).get("config") or {}
|
2026-07-18 00:00:06 +08:00
|
|
|
if tool.type == "mcp":
|
|
|
|
|
schema = config.get("input_schema") or {}
|
|
|
|
|
return (
|
|
|
|
|
dict(schema.get("properties") or {}),
|
|
|
|
|
[str(name) for name in schema.get("required") or []],
|
|
|
|
|
)
|
2026-07-13 16:13:27 +08:00
|
|
|
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
|
|
|
|
|
|
2026-08-14 00:15:00 +08:00
|
|
|
@classmethod
|
|
|
|
|
def schema_parts(
|
|
|
|
|
cls,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
binding: RuntimeToolBinding | None = None,
|
|
|
|
|
) -> tuple[dict[str, Any], list[str]]:
|
|
|
|
|
properties, required = cls.input_schema(tool)
|
|
|
|
|
if binding is None:
|
|
|
|
|
return properties, required
|
|
|
|
|
visible = {
|
|
|
|
|
name
|
|
|
|
|
for name, source in binding.argument_bindings.items()
|
|
|
|
|
if str((source or {}).get("source") or "") == "model"
|
|
|
|
|
}
|
|
|
|
|
missing_required = [
|
|
|
|
|
name for name in required if name not in binding.argument_bindings
|
|
|
|
|
]
|
|
|
|
|
if missing_required:
|
|
|
|
|
raise ToolExecutionError(
|
|
|
|
|
"缺少必填工具参数绑定: " + ", ".join(missing_required)
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
{name: spec for name, spec in properties.items() if name in visible},
|
|
|
|
|
[name for name in required if name in visible],
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-13 16:13:27 +08:00
|
|
|
async def execute(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
arguments: dict[str, Any] | None = None,
|
|
|
|
|
*,
|
2026-08-14 00:15:00 +08:00
|
|
|
binding: RuntimeToolBinding | None = None,
|
2026-07-13 16:13:27 +08:00
|
|
|
result_assignments: dict[str, str] | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
2026-08-14 00:15:00 +08:00
|
|
|
normalized_arguments = self.resolve_arguments(
|
|
|
|
|
tool,
|
|
|
|
|
binding,
|
|
|
|
|
dict(arguments or {}),
|
|
|
|
|
)
|
|
|
|
|
template_values = self.resolve_template_bindings(binding)
|
2026-07-18 00:00:06 +08:00
|
|
|
if tool.type == "http":
|
2026-08-14 00:15:00 +08:00
|
|
|
result = await self._execute_http(
|
|
|
|
|
tool,
|
|
|
|
|
normalized_arguments,
|
|
|
|
|
template_values=template_values,
|
|
|
|
|
)
|
2026-07-18 00:00:06 +08:00
|
|
|
elif tool.type == "mcp":
|
2026-08-14 00:15:00 +08:00
|
|
|
result = await self._execute_mcp(
|
|
|
|
|
tool,
|
|
|
|
|
normalized_arguments,
|
|
|
|
|
template_values=template_values,
|
|
|
|
|
)
|
2026-07-30 19:06:03 +08:00
|
|
|
elif tool.type == "client":
|
|
|
|
|
result = await self._execute_client(tool, normalized_arguments)
|
2026-07-18 00:00:06 +08:00
|
|
|
else:
|
|
|
|
|
raise ToolExecutionError(f"不支持工具类型: {tool.type}")
|
2026-07-30 19:06:03 +08:00
|
|
|
if result.get("status") != "ok":
|
|
|
|
|
return {**result, "updated_variables": []}
|
2026-07-18 00:00:06 +08:00
|
|
|
return self._apply_result_assignments(
|
2026-07-13 16:13:27 +08:00
|
|
|
tool,
|
2026-07-18 00:00:06 +08:00
|
|
|
result,
|
2026-08-14 00:15:00 +08:00
|
|
|
result_assignments=(
|
|
|
|
|
result_assignments
|
|
|
|
|
if result_assignments is not None
|
|
|
|
|
else (binding.result_assignments if binding is not None else {})
|
|
|
|
|
),
|
2026-07-13 16:13:27 +08:00
|
|
|
)
|
|
|
|
|
|
2026-08-14 00:15:00 +08:00
|
|
|
def resolve_arguments(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
binding: RuntimeToolBinding | None,
|
|
|
|
|
model_arguments: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
if binding is None:
|
|
|
|
|
return model_arguments
|
|
|
|
|
properties, required = self.input_schema(tool)
|
|
|
|
|
visible_model_parameters = {
|
|
|
|
|
name
|
|
|
|
|
for name, source in binding.argument_bindings.items()
|
|
|
|
|
if str((source or {}).get("source") or "") == "model"
|
|
|
|
|
}
|
|
|
|
|
resolved: dict[str, Any] = {}
|
|
|
|
|
for name, source in binding.argument_bindings.items():
|
|
|
|
|
if name not in properties:
|
|
|
|
|
raise ToolExecutionError(f"工具参数不存在: {name}")
|
|
|
|
|
resolved[name] = self._resolve_argument_source(
|
|
|
|
|
name,
|
|
|
|
|
source,
|
|
|
|
|
model_arguments,
|
|
|
|
|
)
|
|
|
|
|
unknown_model_arguments = sorted(
|
|
|
|
|
set(model_arguments) - visible_model_parameters
|
|
|
|
|
)
|
|
|
|
|
if unknown_model_arguments:
|
|
|
|
|
raise ToolExecutionError(
|
|
|
|
|
"模型提供了未授权工具参数: "
|
|
|
|
|
+ ", ".join(unknown_model_arguments)
|
|
|
|
|
)
|
|
|
|
|
missing = [name for name in required if name not in resolved]
|
|
|
|
|
if missing:
|
|
|
|
|
raise ToolExecutionError("缺少必填工具参数: " + ", ".join(missing))
|
|
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
|
def resolve_template_bindings(
|
|
|
|
|
self,
|
|
|
|
|
binding: RuntimeToolBinding | None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
if binding is None:
|
|
|
|
|
return {}
|
|
|
|
|
return {
|
|
|
|
|
name: self._resolve_source(source, {})
|
|
|
|
|
for name, source in binding.template_bindings.items()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _resolve_source(
|
|
|
|
|
self,
|
|
|
|
|
source: dict[str, Any],
|
|
|
|
|
model_arguments: dict[str, Any],
|
|
|
|
|
) -> Any:
|
|
|
|
|
kind = str((source or {}).get("source") or "")
|
|
|
|
|
if kind == "model":
|
|
|
|
|
raise ToolExecutionError("模型参数来源缺少参数上下文")
|
|
|
|
|
if kind == "variable":
|
|
|
|
|
name = str(
|
|
|
|
|
source.get("variable_name") or source.get("variableName") or ""
|
|
|
|
|
)
|
|
|
|
|
return self.store.value(name)
|
|
|
|
|
if kind == "literal":
|
|
|
|
|
return deepcopy(source.get("value"))
|
|
|
|
|
if kind == "template":
|
|
|
|
|
return self.store.render_data(deepcopy(source.get("value")))
|
|
|
|
|
raise ToolExecutionError(f"不支持的参数来源: {kind or 'empty'}")
|
|
|
|
|
|
|
|
|
|
def _resolve_argument_source(
|
|
|
|
|
self,
|
|
|
|
|
name: str,
|
|
|
|
|
source: dict[str, Any],
|
|
|
|
|
model_arguments: dict[str, Any],
|
|
|
|
|
) -> Any:
|
|
|
|
|
if str((source or {}).get("source") or "") == "model":
|
|
|
|
|
if name not in model_arguments:
|
|
|
|
|
raise ToolExecutionError(f"模型未提供工具参数: {name}")
|
|
|
|
|
return model_arguments[name]
|
|
|
|
|
return self._resolve_source(source, model_arguments)
|
|
|
|
|
|
2026-07-13 16:13:27 +08:00
|
|
|
async def _execute_http(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
arguments: dict[str, Any],
|
2026-08-14 00:15:00 +08:00
|
|
|
*,
|
|
|
|
|
template_values: dict[str, Any],
|
2026-07-13 16:13:27 +08:00
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
config = (tool.definition or {}).get("config") or {}
|
|
|
|
|
parameters = list(config.get("parameters") or [])
|
2026-08-14 00:15:00 +08:00
|
|
|
renderer = self._tool_renderer(tool, template_values)
|
|
|
|
|
url = renderer.render(str(config.get("url") or ""))
|
|
|
|
|
configured_headers = renderer.render_data(
|
2026-07-13 16:13:27 +08:00
|
|
|
deepcopy(config.get("headers") or {}), allow_secrets=True
|
|
|
|
|
)
|
2026-08-14 00:15:00 +08:00
|
|
|
secret_headers = renderer.render_data(
|
2026-07-13 16:13:27 +08:00
|
|
|
deepcopy((tool.secrets or {}).get("headers") or {}), allow_secrets=True
|
|
|
|
|
)
|
|
|
|
|
headers: dict[str, str] = {}
|
|
|
|
|
query: dict[str, object] = {}
|
2026-08-14 00:15:00 +08:00
|
|
|
body = renderer.render_data(deepcopy(config.get("body") or {}))
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
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]}
|
|
|
|
|
|
2026-07-18 00:00:06 +08:00
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"status_code": response.status_code,
|
|
|
|
|
"data": payload,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def _execute_mcp(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
arguments: dict[str, Any],
|
2026-08-14 00:15:00 +08:00
|
|
|
*,
|
|
|
|
|
template_values: dict[str, Any],
|
2026-07-18 00:00:06 +08:00
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
server = tool.mcp_server
|
|
|
|
|
if server is None:
|
|
|
|
|
raise ToolExecutionError("MCP 工具缺少可用的 Server 连接")
|
|
|
|
|
config = (tool.definition or {}).get("config") or {}
|
|
|
|
|
remote_tool_name = str(config.get("remote_tool_name") or "")
|
|
|
|
|
if not remote_tool_name:
|
|
|
|
|
raise ToolExecutionError("MCP 工具缺少远端工具名称")
|
|
|
|
|
|
2026-08-14 00:15:00 +08:00
|
|
|
renderer = self._tool_renderer(tool, template_values)
|
2026-07-18 00:00:06 +08:00
|
|
|
resolved_server = server.model_copy(
|
|
|
|
|
update={
|
2026-08-14 00:15:00 +08:00
|
|
|
"url": renderer.render(server.url),
|
|
|
|
|
"headers": renderer.render_data(server.headers),
|
|
|
|
|
"secret_headers": dict(server.secret_headers),
|
2026-07-18 00:00:06 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
payload = await self._mcp_client.call_tool(
|
|
|
|
|
resolved_server,
|
|
|
|
|
remote_tool_name,
|
|
|
|
|
arguments,
|
|
|
|
|
)
|
|
|
|
|
except McpClientError as exc:
|
|
|
|
|
raise ToolExecutionError(str(exc)) from exc
|
|
|
|
|
return {"status": "ok", "data": payload}
|
|
|
|
|
|
2026-08-14 00:15:00 +08:00
|
|
|
def _tool_renderer(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
template_values: dict[str, Any],
|
|
|
|
|
) -> DynamicVariableStore:
|
|
|
|
|
values = dict(self.store.values)
|
|
|
|
|
values.update(
|
|
|
|
|
{f"input__{name}": value for name, value in template_values.items()}
|
|
|
|
|
)
|
|
|
|
|
secrets = {
|
|
|
|
|
str(name): str(value)
|
|
|
|
|
for name, value in (
|
|
|
|
|
(tool.secrets or {}).get("dynamic_variables") or {}
|
|
|
|
|
).items()
|
|
|
|
|
}
|
|
|
|
|
invalid = [name for name in secrets if not name.startswith("secret__")]
|
|
|
|
|
if invalid:
|
|
|
|
|
raise DynamicVariableError(
|
|
|
|
|
"工具密钥变量必须以 secret__ 开头: " + ", ".join(invalid)
|
|
|
|
|
)
|
|
|
|
|
return DynamicVariableStore(
|
|
|
|
|
values,
|
|
|
|
|
secrets,
|
|
|
|
|
optional_names=self.store.optional_names,
|
|
|
|
|
variable_types=self.store.variable_types,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-30 19:06:03 +08:00
|
|
|
async def _execute_client(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
arguments: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
if self._client_tools is None:
|
|
|
|
|
raise ToolExecutionError("当前运行模式不支持客户端工具")
|
2026-08-14 00:15:00 +08:00
|
|
|
supports = getattr(self._client_tools, "supports", None)
|
|
|
|
|
if supports is not None and not supports(tool.function_name):
|
|
|
|
|
raise ToolExecutionError(f"客户端未注册工具: {tool.function_name}")
|
2026-07-30 19:06:03 +08:00
|
|
|
config = (tool.definition or {}).get("config") or {}
|
|
|
|
|
policy = policy_for_tool(tool)
|
|
|
|
|
try:
|
|
|
|
|
return await self._client_tools.call(
|
|
|
|
|
tool.function_name,
|
|
|
|
|
arguments,
|
|
|
|
|
timeout_seconds=float(config.get("timeout_seconds") or 3),
|
|
|
|
|
wait_for_response=policy.wait_for_response,
|
2026-07-31 23:53:47 +08:00
|
|
|
response_wait_mode=policy.response_wait_mode,
|
2026-07-30 19:06:03 +08:00
|
|
|
)
|
|
|
|
|
except ClientToolError as exc:
|
|
|
|
|
raise ToolExecutionError(str(exc)) from exc
|
|
|
|
|
|
2026-07-18 00:00:06 +08:00
|
|
|
def _apply_result_assignments(
|
|
|
|
|
self,
|
|
|
|
|
tool: RuntimeTool,
|
|
|
|
|
result: dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
result_assignments: dict[str, str] | None,
|
|
|
|
|
) -> dict[str, Any]:
|
2026-08-14 00:15:00 +08:00
|
|
|
assignments = result_assignments or {}
|
2026-07-18 00:00:06 +08:00
|
|
|
payload = result.get("data")
|
2026-08-14 00:15:00 +08:00
|
|
|
staged: dict[str, Any] = {}
|
2026-07-13 16:13:27 +08:00
|
|
|
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
|
2026-08-14 00:15:00 +08:00
|
|
|
staged[str(variable_name)] = value
|
|
|
|
|
updated = self.store.assign_declared_many(staged) if staged else []
|
2026-07-18 00:00:06 +08:00
|
|
|
return {**result, "updated_variables": updated}
|