"""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, )