"""Finite sanitized execution-refusal copy; no graph/input/error interpolation.""" from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import replace from typing import TYPE_CHECKING, Any from runtime_contracts.builder.copy_ids import BuilderCopyId from runtime_contracts.builder.execution import ExecutionEvidenceSummary from runtime_contracts.builder.presentation import BuilderText, copy_text, text_parts from services.dify_builder.native_launch_observation import FailureResource if TYPE_CHECKING: from core.dify_builder.models import Run FALLBACK = copy_text(BuilderCopyId.REPLIES_EXECUTION_SAFETY_REQUIRES_REVIEW_OR_REVISED) _MESSAGES = { "execution_permission_denied": copy_text(BuilderCopyId.TEST_PERMISSION_DENIED), "execution_owner_mismatch": copy_text(BuilderCopyId.TEST_PERMISSION_DENIED), "agent_access_denied": copy_text(BuilderCopyId.TEST_PERMISSION_DENIED), "stale_execution_revision": copy_text(BuilderCopyId.TEST_CONTEXT_CHANGED), "test_input_binding_mismatch": copy_text(BuilderCopyId.TEST_CONTEXT_CHANGED), "effective_input_binding_mismatch": copy_text(BuilderCopyId.TEST_CONTEXT_CHANGED), "context_digest_mismatch": copy_text(BuilderCopyId.TEST_CONTEXT_CHANGED), "agent_capability_changed": copy_text(BuilderCopyId.TEST_CONTEXT_CHANGED), "code_risk_assessment_stale": copy_text(BuilderCopyId.TEST_POLICY_REVIEW), "installed_tool_unavailable": copy_text(BuilderCopyId.RESOURCES_CATALOGUE_UNAVAILABLE), "agent_tool_metadata_unavailable": copy_text(BuilderCopyId.RESOURCES_CATALOGUE_UNAVAILABLE), "agent_not_available": copy_text(BuilderCopyId.RESOURCES_CATALOGUE_UNAVAILABLE), "unsupported_node_implementation": copy_text(BuilderCopyId.REPLIES_BUILDER_DOES_NOT_YET_HAVE_SAFE), "unsupported_json_tool_configuration": copy_text(BuilderCopyId.REPLIES_BUILDER_CANNOT_SAFELY_TEST_THIS_TOOL), "unsupported_json_credentials": copy_text(BuilderCopyId.REPLIES_BUILDER_CANNOT_SAFELY_TEST_THIS_TOOL2), "unsupported_json_content_constant": copy_text(BuilderCopyId.REPLIES_PUT_THIS_J_S_O_N_CONTENT_IN_A), "unsupported_json_filter": copy_text(BuilderCopyId.REPLIES_USE_A_SUPPORTED_J_S_O_N_PATH_SELECTION), "unsupported_json_selector": copy_text(BuilderCopyId.REPLIES_BIND_J_S_O_N_PARSE_TEXT_FROM_A), "invalid_json_parameters": copy_text(BuilderCopyId.REPLIES_SET_J_S_O_N_PARSE_CONTENT_AND_FILTER), "json_execution_limit": copy_text(BuilderCopyId.REPLIES_REDUCE_J_S_O_N_PARSE_INPUT_OR_OUTPUT), "installed_json_tool_unavailable": copy_text(BuilderCopyId.REPLIES_INSTALL_THE_QUALIFIED_J_S_O_N_PROCESS_VERSION), "unsupported_json_tool_installation": copy_text( BuilderCopyId.REPLIES_INSTALL_THE_QUALIFIED_J_S_O_N_PROCESS_VERSION ), "json_tool_installation_changed": copy_text(BuilderCopyId.REPLIES_THE_INSTALLED_J_S_O_N_PROCESS_VERSION_CHANGED), "unsupported_template_syntax": copy_text(BuilderCopyId.REPLIES_TEMPLATE_SUPPORTS_SCALAR_TEXT_IF_ELIF), "unsupported_template_configuration": copy_text(BuilderCopyId.REPLIES_EDIT_TEMPLATE_VARIABLES_TO_USE_UNIQUE), "unsupported_template_selector": copy_text(BuilderCopyId.REPLIES_USE_TEMPLATE_INPUTS_FROM_START_SCALARS), "template_execution_limit": copy_text(BuilderCopyId.REPLIES_REDUCE_TEMPLATE_SIZE_OR_SELECTED_TEXT), } def execution_refusal_message(reason_code: str) -> BuilderText: return _MESSAGES.get(reason_code, FALLBACK) # One bounded semantic preparation fallback. Existing fixed-copy IDs are translated by # Web; this backend-only disclosure cannot add a frontend catalog dependency. _CODE_RISK_UNAVAILABLE = { "en": "The step risk explanation is unavailable. This test will simulate this step " "and cannot verify its real behavior. " "Prepare a new test to try the risk review again.", "zh": "暂时无法说明此步骤的风险。本次测试将模拟该节点,无法验证其业务行为。请重新准备测试以重试风险评估。", } def code_risk_unavailable_notice(reply_language: str) -> str: return _CODE_RISK_UNAVAILABLE["zh" if reply_language.lower().startswith("zh") else "en"] # Only bounded backend-owned display labels are translated here. Technical # codes remain in diagnostics/failure_code; resource names and user text are # never interpreted as localization keys. Other locales use the English label. _REASON_LABELS = { "connection_refused": ("connection refused", "连接被拒绝"), "timeout": ("request timed out", "请求超时"), "authentication": ("authentication failed", "身份认证失败"), "permission_denied": ("provider permission denied", "提供商权限被拒绝"), "credential_unavailable": ("credential missing or inaccessible", "凭据缺失或无权访问"), "authorization_lookup_failed": ("credential access could not be verified", "无法验证凭据访问权限"), "rate_limit": ("rate limit exceeded", "请求超过频率限制"), "service_unavailable": ("service unavailable", "服务不可用"), "provider_invocation_failed": ("model invocation failed", "模型调用失败"), "model_resolution_failed": ("model configuration unavailable", "模型配置不可用"), "empty_response": ("empty model response", "模型响应为空"), "json_decode_failed": ("invalid JSON response", "模型响应不是有效的 JSON"), "response_schema_invalid": ("response does not match the required schema", "模型响应不符合要求的结构"), "unknown": ("cause unavailable", "暂未获取到具体原因"), } _SCOPE_LABELS = {"structure": ("structure", "结构"), "configuration": ("configuration", "配置")} def _display_label(labels: Mapping[str, tuple[str, str]], value: str, reply_language: str) -> str: pair = labels.get(value) return pair[1 if reply_language.lower().startswith("zh") else 0] if pair else value def change_scope_label(scope: str, reply_language: str) -> str: """Translate the display scope without mutating the ChangeSet's machine scope.""" return _display_label(_SCOPE_LABELS, scope, reply_language) def observed_failure_message( summary: ExecutionEvidenceSummary | None, *, reply_language: str = "en-US" ) -> BuilderText | None: """Observed preparation failure and unknown effects need different recovery.""" if summary is None: return None if ( summary.failure_resource and summary.failure_reason in {"authentication", "permission_denied", "credential_unavailable", "authorization_lookup_failed"} and summary.sealed and ( summary.safety_outcome == "preparation_failed" or (summary.safety_outcome == "native_failed" and summary.failure_phase == "execution") ) ): return _resource_setup_guidance( reason=summary.failure_reason, resource=summary.failure_resource, node_ids=summary.failure_node_ids, reply_language=reply_language, ) if summary.safety_outcome == "preparation_failed" or ( summary.safety_outcome == "native_failed" and summary.failure_phase == "execution" ): message = ( copy_text(BuilderCopyId.TEST_RUNTIME_SERVICE_FAILED) if summary.failure_phase == "execution" else ( "测试准备失败({reason}),工作流尚未开始执行。请检查模型/工具服务和配置," "或选择其他工作区资源,再准备并确认一次新测试。" if reply_language.lower().startswith("zh") else "Test preparation failed ({reason}) before workflow execution started. " "Check model/tool services and configuration, or choose another workspace resource; " "then prepare and confirm a fresh test." ).format(reason=_display_label(_REASON_LABELS, summary.failure_reason or "unknown", reply_language)) ) if summary.failure_resource: _, provider, name = summary.failure_resource return text_parts( message, "\n", "/".join(filter(None, (provider, name))), "\n", ", ".join(summary.failure_node_ids) ) return message if summary.safety_outcome == "execution_evidence_unknown": return copy_text(BuilderCopyId.REPLIES_EXECUTION_OUTCOME_IS_UNKNOWN_RUN_AGAIN_BEFORE_PUBLISHING) return None def native_service_failure(run: Run) -> bool: """Observed resource failure: preserve the Run, avoid speculative graph repair.""" summary = run.verification.execution_evidence if run.verification else None if ( run.status == "failed" and not run.dify_run_id and summary is not None and summary.sealed and summary.safety_outcome == "preparation_failed" and summary.failure_phase == "preparation" and summary.failure_resource is not None and bool(summary.failure_node_ids) and summary.failure_reason in {"credential_unavailable", "authorization_lookup_failed"} ): return True return bool( run.status == "failed" and run.dify_run_id and summary is not None and summary.sealed and summary.native_run_id == run.dify_run_id and summary.failure_resource is not None and bool(summary.failure_node_ids) and summary.safety_outcome == "native_failed" and summary.failure_phase == "execution" and summary.failure_reason in {"service_unavailable", "authentication", "permission_denied"} ) def tool_credential_failure_message( run: Run, *, graph: Mapping[str, Any], graph_revision: str, reply_language: str = "en-US" ) -> str | None: """Observe an exact native Tool setup envelope, without altering Run evidence. Prefer sealed attribution elsewhere. This fallback requires the executed graph revision and one failed Tool node; titles and workflow-level text cannot establish the resource. Unknown, foreign and stale evidence stays unknown. This display observation grants no execution or retry authority. """ from services.dify_builder.native_launch_observation import _public_id evidence = run.verification summary = evidence.execution_evidence if evidence else None if ( run.status != "failed" or not run.dify_run_id or evidence is None or not graph_revision or evidence.executed_graph_revision != graph_revision or summary is None or not summary.sealed or summary.safety_outcome != "native_failed" or summary.native_run_id != run.dify_run_id or summary.failure_resource is not None or summary.failure_phase is not None or summary.failure_reason is not None or run.execution_refusal is not None ): return None failed = [node for node in run.per_node if node.status in {"failed", "exception"}] if len(failed) != 1 or not _public_id(failed[0].node_id): return None # These are backend-owned native runtime messages, not keyword matching. reasons = { "Failed to get tool runtime: Credentials not available: Please use ENTERPRISE global credentials": ( "enterprise_global_credential_required" ), "Failed to get tool runtime: Credentials not available": "credential_unavailable", } reason = reasons.get(failed[0].error) nodes = graph.get("nodes") if reason is None or not isinstance(nodes, list): return None matches = [node for node in nodes if isinstance(node, Mapping) and node.get("id") == failed[0].node_id] if len(matches) != 1 or not isinstance(data := matches[0].get("data"), Mapping): return None if data.get("type") != "tool" or data.get("provider_type") not in {"builtin", "plugin"}: return None provider, name = data.get("provider_id"), data.get("tool_name") if not isinstance(provider, str) or not isinstance(name, str) or not _public_id(provider) or not _public_id(name): return None return _resource_setup_guidance( reason=reason, resource=("tool", provider, name), node_ids=(failed[0].node_id,), reply_language=reply_language, ) def _resource_setup_guidance( *, reason: str, resource: FailureResource, node_ids: tuple[str, ...], reply_language: str ) -> str: label = _display_label(_REASON_LABELS, reason, reply_language) resource_label = "/".join(filter(None, resource[1:])) nodes = ", ".join(node_ids) global_credential = reason == "enterprise_global_credential_required" if reply_language.lower().startswith("zh"): kind = "工具" if resource[0] == "tool" else "模型" if global_credential: return ( f"{kind}设置失败:需要企业全局凭据。请联系工作区管理员配置或选择获准的企业全局凭据," f"然后重新准备并确认测试。\n{resource_label}\n{nodes}" ) recovery = ( "请检查工作区凭据访问权限后重新准备测试。" if reason == "authorization_lookup_failed" else "请配置或选择有权访问此资源的凭据,并检查提供商权限后重新准备测试。" ) return f"{kind}资源设置失败:{label}。{recovery}\n{resource_label}\n{nodes}" kind = "Tool" if resource[0] == "tool" else "Model" if global_credential: return ( f"{kind} setup failed: enterprise global credential required. Ask a workspace administrator " "to configure or select an authorized enterprise global credential; " f"then prepare and confirm a fresh test.\n{resource_label}\n{nodes}" ) recovery = ( "Check workspace credential access before preparing a fresh test." if reason == "authorization_lookup_failed" else "Configure or select an authorized credential and check provider permissions " "before preparing a fresh test." ) return f"{kind} resource setup failed: {label}. {recovery}\n{resource_label}\n{nodes}" def sanitize_observed_failure(run: Run) -> Run: """Bound Builder errors only; native authorized Run storage keeps original evidence.""" summary = run.verification.execution_evidence if run.verification else None if ( not native_service_failure(run) or summary is None or summary.failure_resource is None or summary.failure_reason is None or summary.failure_reason == "service_unavailable" ): return run guidance = _resource_setup_guidance( reason=summary.failure_reason, resource=summary.failure_resource, node_ids=summary.failure_node_ids, reply_language="en", ) return replace( run, error=guidance, per_node=[ replace(node, error=guidance) if node.node_id in summary.failure_node_ids and node.error else node for node in run.per_node ], ) def sanitize_resource_failure_event( payload: Mapping[str, Any], *, graph: Mapping[str, Any], recognized_errors: dict[str, str] ) -> Mapping[str, Any]: """Suppress recognized provider bodies in Builder streams, never seal evidence. The admitted graph and native stream ID scope presentation only. Terminal classification still requires the persisted executed graph and native rows. Code/unattributed errors retain their existing diagnostics. """ from core.dify_builder.models import NodeOutput, Run from services.dify_builder.native_runtime_failure import native_resource_failure from services.dify_builder.run_mapping import run_id_from_stream_chunk data = payload.get("data") native_id = run_id_from_stream_chunk(payload) if not native_id or not isinstance(data, Mapping) or data.get("status") not in {"failed", "exception"}: return payload error = data.get("error") if not isinstance(error, str) or not error: return payload key = f"{native_id}:{error}" guidance = None if payload.get("event") == "node_finished" and isinstance(node_id := data.get("node_id"), str): failure = native_resource_failure( Run( status="failed", dify_run_id=native_id, per_node=[NodeOutput(node_id=node_id, status="failed", error=error)], ), graph, ) if failure is not None and failure[2] in {"authentication", "permission_denied"}: guidance = _resource_setup_guidance( reason=failure[2], resource=failure[0], node_ids=failure[1], reply_language="en" ) recognized_errors[key] = guidance elif payload.get("event") == "workflow_finished": guidance = recognized_errors.get(key) return {**payload, "data": {**data, "error": guidance}} if guidance else payload def cognition_failure_message( records: Sequence[Mapping[str, Any]], fallback: BuilderText, *, reply_language: str = "en-US" ) -> BuilderText: """Use bounded observations from this operation; never provider error text.""" for record in reversed(records): code = record.get("code") if isinstance(code, str) and code in { "provider_invocation_failed", "model_resolution_failed", "empty_response", "json_decode_failed", "response_schema_invalid", }: reason = record.get("reason") if not isinstance(reason, str) or reason not in { "connection_refused", "timeout", "authentication", "rate_limit", "service_unavailable", }: reason = code return copy_text( BuilderCopyId.OPERATION_MODEL_FAILED, reason=_display_label(_REASON_LABELS, reason, reply_language) ) return fallback def capability_assessment_unavailable_message( records: Sequence[Mapping[str, Any]], *, reply_language: str ) -> BuilderText: """Disclose unknown feasibility using only this assessment's observations. The caller supplies records captured during the assessment invocation, excluding prior discovery failures. Return fixed guidance and optional sanitized model-failure copy; never claim an unsupported capability or alter approval, bindings, requirements or retry policy. No I/O is performed. """ guidance = ( "计划可行性检查暂不可用。请在批准前检查计划和资源,或调整计划后重试。" if reply_language.lower().startswith("zh") else "The plan feasibility check is unavailable. Review the plan and resources before approving, " "or adjust the plan to retry." ) detail = cognition_failure_message(records, "", reply_language=reply_language) return text_parts(guidance, "\n", detail) if detail else guidance