172 lines
8.0 KiB
Python
172 lines
8.0 KiB
Python
"""Validate reusable task backends and resolve their server-only snapshots."""
|
|
|
|
import json
|
|
import re
|
|
|
|
from db.models import Assistant, McpServer, ModelResource, TaskBackendResource, Tool
|
|
from models import (
|
|
RuntimeModelResource,
|
|
RuntimeTaskBackend,
|
|
RuntimeTool,
|
|
RuntimeToolBinding,
|
|
)
|
|
from schemas import AssistantTaskBackendBinding
|
|
from services.masking import mask_secrets
|
|
from services.system_tools import RESERVED_TOOL_NAMES
|
|
from services.tool_executor import ToolExecutor
|
|
from sqlalchemy import or_, select
|
|
from task_backend_schemas import (
|
|
ReasonerBackendConfig,
|
|
TaskBackendOut,
|
|
TaskBackendUpsert,
|
|
)
|
|
|
|
|
|
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
|
SYSTEM_VARIABLES = {
|
|
"system__agent_id", "system__conversation_id", "system__time", "system__time_utc",
|
|
"system__timezone", "system__agent_turns", "system__conversation_history",
|
|
"system__last_action_status", "system__last_action_invocation_id", "system__last_action_duration_ms",
|
|
"system__last_action_error", "system__last_action_error_code", "system__handoff_status",
|
|
}
|
|
|
|
|
|
def task_backend_to_out(resource: TaskBackendResource) -> TaskBackendOut:
|
|
return TaskBackendOut(
|
|
id=resource.id, name=resource.name, type=resource.type,
|
|
description=resource.description, config=resource.config,
|
|
secrets=mask_secrets(resource.secrets or {}), enabled=resource.enabled,
|
|
updated_at=resource.updated_at.isoformat() if resource.updated_at else None,
|
|
)
|
|
|
|
|
|
def validate_source_variables(source: dict, variables: set[str]) -> None:
|
|
names = set()
|
|
if source.get("source") == "variable":
|
|
names.add(str(source.get("variable_name") or source.get("variableName") or ""))
|
|
elif source.get("source") == "template":
|
|
names.update(PLACEHOLDER.findall(json.dumps(source.get("value"), ensure_ascii=False)))
|
|
unknown = names - variables - SYSTEM_VARIABLES
|
|
if unknown:
|
|
raise ValueError("任务配置引用了未声明变量: " + ", ".join(sorted(unknown)))
|
|
|
|
|
|
async def validate_backend_resource(
|
|
session,
|
|
body: TaskBackendUpsert,
|
|
*,
|
|
variables: set[str] | None = None,
|
|
) -> None:
|
|
if body.type != "reasoner":
|
|
return
|
|
config = body.config
|
|
assert isinstance(config, ReasonerBackendConfig)
|
|
model = await session.get(ModelResource, config.model_resource_id)
|
|
if (
|
|
not model
|
|
or not model.enabled
|
|
or model.capability != "LLM"
|
|
or model.interface_type not in {"openai-llm", "dashscope-llm"}
|
|
):
|
|
raise ValueError("Reasoner 必须引用已启用的 OpenAI-compatible LLM 模型")
|
|
if (
|
|
config.reasoning_effort != "provider_default"
|
|
and (model.values or {}).get("supportsReasoningEffort") is not True
|
|
):
|
|
raise ValueError("该模型未声明支持 reasoning_effort,不能设置推理强度")
|
|
names = set()
|
|
for binding in config.tool_bindings:
|
|
tool = await session.get(Tool, binding.tool_id)
|
|
if not tool or tool.status != "active" or tool.type not in {"http", "mcp"}:
|
|
raise ValueError("Reasoner 仅允许已启用的 HTTP/MCP 服务端工具: " + binding.tool_id)
|
|
if tool.function_name in RESERVED_TOOL_NAMES or tool.function_name in names:
|
|
raise ValueError("Reasoner 工具函数名重复或属于平台保留名称: " + tool.function_name)
|
|
names.add(tool.function_name)
|
|
if tool.type == "mcp":
|
|
server = await session.get(McpServer, tool.mcp_server_id) if tool.mcp_server_id else None
|
|
if not server or server.status != "active":
|
|
raise ValueError("Reasoner MCP 工具的 Server 不存在或未启用")
|
|
runtime_tool = RuntimeTool(
|
|
id=tool.id,
|
|
name=tool.name,
|
|
function_name=tool.function_name,
|
|
type=tool.type,
|
|
definition=tool.definition or {},
|
|
)
|
|
properties, required = ToolExecutor.input_schema(runtime_tool)
|
|
if set(binding.argument_bindings) - set(properties):
|
|
raise ValueError("Reasoner 工具绑定引用了未知参数")
|
|
if set(required) - set(binding.argument_bindings):
|
|
raise ValueError("Reasoner 工具绑定缺少必填参数")
|
|
template_inputs = ((tool.definition or {}).get("config") or {}).get("template_inputs") or []
|
|
template_names = {item["name"] for item in template_inputs if item.get("name")}
|
|
required_templates = {item["name"] for item in template_inputs if item.get("name") and item.get("required", True)}
|
|
if set(binding.template_bindings) - template_names or required_templates - set(binding.template_bindings):
|
|
raise ValueError("Reasoner 工具模板输入缺失或引用了未知模板输入")
|
|
if variables is not None:
|
|
for source in [*binding.argument_bindings.values(), *binding.template_bindings.values()]:
|
|
validate_source_variables(source.model_dump(), variables)
|
|
unknown_targets = set(binding.result_assignments) - variables
|
|
if unknown_targets:
|
|
raise ValueError("Reasoner 结果映射目标未声明: " + ", ".join(sorted(unknown_targets)))
|
|
|
|
|
|
async def backend_consumers(session, backend_id: str) -> list[Assistant]:
|
|
return list((await session.execute(select(Assistant).where(or_(
|
|
Assistant.task_backend["backend_id"].astext == backend_id,
|
|
Assistant.task_backend["backendId"].astext == backend_id,
|
|
)))).scalars().all())
|
|
|
|
|
|
async def validate_backend_binding(
|
|
session, assistant, *, resource=None
|
|
) -> TaskBackendResource | None:
|
|
raw = getattr(assistant, "task_backend", None)
|
|
if not raw:
|
|
return None
|
|
if assistant.type != "prompt" or getattr(assistant, "runtime_mode", "pipeline") != "pipeline":
|
|
raise ValueError("任务后端仅支持提示词 Pipeline")
|
|
binding = (
|
|
raw
|
|
if isinstance(raw, AssistantTaskBackendBinding)
|
|
else AssistantTaskBackendBinding.model_validate(raw)
|
|
)
|
|
resource = resource or await session.get(TaskBackendResource, binding.backend_id)
|
|
if resource is None or not resource.enabled:
|
|
raise ValueError("任务后端不存在或未启用")
|
|
variables = set(assistant.dynamic_variable_definitions or {})
|
|
validate_source_variables(binding.instruction_source.model_dump(), variables)
|
|
validate_source_variables({"source": "template", "value": binding.instructions}, variables)
|
|
body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config)
|
|
await validate_backend_resource(session, body, variables=variables)
|
|
return resource
|
|
|
|
|
|
async def resolve_task_backend(session, assistant) -> RuntimeTaskBackend | None:
|
|
resource = await validate_backend_binding(session, assistant)
|
|
if resource is None:
|
|
return None
|
|
binding = AssistantTaskBackendBinding.model_validate(assistant.task_backend)
|
|
body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config)
|
|
config = body.config.model_dump()
|
|
model = None
|
|
tool_bindings = []
|
|
tools = []
|
|
if resource.type == "reasoner":
|
|
from services.config_resolver import _tools_for
|
|
|
|
row = await session.get(ModelResource, config["model_resource_id"])
|
|
model = RuntimeModelResource(
|
|
id=row.id, name=row.name, capability=row.capability, interface_type=row.interface_type,
|
|
values=row.values or {}, secrets=row.secrets or {}, support_image_input=bool(row.support_image_input),
|
|
)
|
|
tool_bindings = [RuntimeToolBinding(**item) for item in config["tool_bindings"]]
|
|
tools = await _tools_for(session, assistant, [item.tool_id for item in tool_bindings])
|
|
if len(tools) != len(tool_bindings):
|
|
raise ValueError("Reasoner 工具解析失败,请检查工具与 MCP Server")
|
|
return RuntimeTaskBackend(
|
|
id=resource.id, name=resource.name, type=resource.type, config=config,
|
|
secrets=resource.secrets or {}, model=model, tools=tools, tool_bindings=tool_bindings,
|
|
instruction_source=binding.instruction_source.model_dump(), instructions=binding.instructions,
|
|
)
|