diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py
index 7c9a5a7696..f7992deecb 100644
--- a/backend/api_v2/serializers.py
+++ b/backend/api_v2/serializers.py
@@ -22,7 +22,7 @@
ValidationError,
)
from tags.serializers import TagParamsSerializer
-from utils.input_sanitizer import validate_name_field, validate_no_html_tags
+from utils.input_sanitizer import validate_name_field
from utils.serializer.integrity_error_mixin import IntegrityErrorMixin
from workflow_manager.endpoint_v2.models import WorkflowEndpoint
from workflow_manager.workflow_v2.exceptions import ExecutionDoesNotExistError
@@ -66,11 +66,6 @@ def validate_api_name(self, value: str) -> str:
def validate_display_name(self, value: str) -> str:
return validate_name_field(value, field_name="Display name")
- def validate_description(self, value: str) -> str:
- if value is None:
- return value
- return validate_no_html_tags(value, field_name="Description")
-
def validate_workflow(self, workflow):
"""Validate that the workflow has properly configured source and destination endpoints."""
# Get all endpoints for this workflow with related data
diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py
index 62a92fc8a3..1bb0ccb2a9 100644
--- a/backend/backend/serializers.py
+++ b/backend/backend/serializers.py
@@ -1,6 +1,6 @@
from typing import Any
-from rest_framework.serializers import ModelSerializer
+from utils.serializer import ModelSerializer
from backend.constants import RequestKey
diff --git a/backend/notification_v2/serializers.py b/backend/notification_v2/serializers.py
index 115487c481..cfbd174bb4 100644
--- a/backend/notification_v2/serializers.py
+++ b/backend/notification_v2/serializers.py
@@ -1,11 +1,12 @@
from rest_framework import serializers
from utils.input_sanitizer import validate_name_field
+from utils.serializer import ModelSerializer
from .enums import AuthorizationType, NotificationType, PlatformType
from .models import Notification
-class NotificationSerializer(serializers.ModelSerializer):
+class NotificationSerializer(ModelSerializer):
notification_type = serializers.ChoiceField(choices=NotificationType.choices())
authorization_type = serializers.ChoiceField(choices=AuthorizationType.choices())
platform = serializers.ChoiceField(choices=PlatformType.choices(), required=False)
diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
index 4f10ee2aa1..85015023d4 100644
--- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py
+++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
@@ -8,7 +8,7 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from utils.FileValidator import FileValidator
-from utils.input_sanitizer import validate_name_field, validate_no_html_tags
+from utils.input_sanitizer import validate_name_field
from utils.serializer.integrity_error_mixin import IntegrityErrorMixin
from backend.serializers import AuditSerializer
@@ -86,6 +86,13 @@ class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer):
class Meta:
model = CustomTool
fields = "__all__"
+ # LLM-facing text legitimately contains XML-like markup.
+ html_safe_fields = (
+ "summarize_prompt",
+ "preamble",
+ "postamble",
+ "output",
+ )
unique_error_message_map: dict[str, dict[str, str]] = {
"unique_tool_name": {
@@ -99,11 +106,6 @@ class Meta:
def validate_tool_name(self, value: str) -> str:
return validate_name_field(value, field_name="Tool name")
- def validate_description(self, value: str) -> str:
- if value is None:
- return value
- return validate_no_html_tags(value, field_name="Description")
-
def validate_summarize_llm_adapter(self, value):
"""Validate that the adapter type is LLM and is accessible to the user."""
if value is None:
diff --git a/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py b/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py
index 1c56e2323d..d484391a6d 100644
--- a/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py
+++ b/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py
@@ -16,6 +16,8 @@ class PromptStudioOutputSerializer(AuditSerializer):
class Meta:
model = PromptStudioOutputManager
fields = "__all__"
+ # LLM output/context legitimately contains XML-like tags.
+ html_safe_fields = ("output", "context")
def to_representation(self, instance):
data = super().to_representation(instance)
diff --git a/backend/prompt_studio/prompt_studio_v2/serializers.py b/backend/prompt_studio/prompt_studio_v2/serializers.py
index 6a4d28032d..04c7df7ce5 100644
--- a/backend/prompt_studio/prompt_studio_v2/serializers.py
+++ b/backend/prompt_studio/prompt_studio_v2/serializers.py
@@ -26,6 +26,13 @@ class ToolStudioPromptSerializer(AuditSerializer):
class Meta:
model = ToolStudioPrompt
fields = "__all__"
+ # Prompts and LLM output legitimately contain XML-like markup.
+ html_safe_fields = (
+ "prompt",
+ "assert_prompt",
+ "assertion_failure_prompt",
+ "output",
+ )
class ToolStudioIndexSerializer(serializers.Serializer):
diff --git a/backend/tags/serializers.py b/backend/tags/serializers.py
index a0b63dbc68..6dc6cfac98 100644
--- a/backend/tags/serializers.py
+++ b/backend/tags/serializers.py
@@ -3,11 +3,12 @@
from rest_framework import serializers
from rest_framework.serializers import CharField, ValidationError
+from utils.serializer import ModelSerializer
from tags.models import Tag
-class TagSerializer(serializers.ModelSerializer):
+class TagSerializer(ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name", "description"]
diff --git a/backend/utils/serializer/sanitization.py b/backend/utils/serializer/sanitization.py
index 3c53ed73d4..70853abd9a 100644
--- a/backend/utils/serializer/sanitization.py
+++ b/backend/utils/serializer/sanitization.py
@@ -36,9 +36,14 @@ def __init__(self, *args, **kwargs):
if name in exempt or field.read_only:
continue
if isinstance(field, drf.CharField):
+ # Prefer a user-visible label so errors read like
+ # "Prompt key must not contain..." instead of "prompt_key must...".
+ display_name = field.label or name.replace("_", " ").capitalize()
# partial binds the field name at iteration time, avoiding the
# late-binding closure trap of a bare lambda.
- field.validators.append(partial(validate_no_html_tags, field_name=name))
+ field.validators.append(
+ partial(validate_no_html_tags, field_name=display_name)
+ )
class ModelSerializer(SanitizedSerializerMixin, drf.ModelSerializer):
diff --git a/backend/utils/tests/test_sanitized_serializer_mixin.py b/backend/utils/tests/test_sanitized_serializer_mixin.py
index 20f4833a2a..a4ffe14d98 100644
--- a/backend/utils/tests/test_sanitized_serializer_mixin.py
+++ b/backend/utils/tests/test_sanitized_serializer_mixin.py
@@ -56,6 +56,17 @@ def test_each_field_gets_its_own_validator(self):
msg = str(s.errors["description"][0])
assert "description" in msg.lower()
+ def test_error_message_uses_humanised_field_name(self):
+ """snake_case field names are surfaced as 'Snake case' in user-visible errors."""
+
+ class SnakeCaseSerializer(Serializer):
+ prompt_key = drf.CharField()
+
+ s = SnakeCaseSerializer(data={"prompt_key": "
x
"})
+ assert not s.is_valid()
+ msg = str(s.errors["prompt_key"][0])
+ assert msg.startswith("Prompt key ")
+
def test_html_safe_fields_opts_out(self):
s = WithOptOutSerializer(
data={"name": "ok", "prompt": "step 1"}
diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py
index ed34592958..ab4051340f 100644
--- a/backend/workflow_manager/workflow_v2/serializers.py
+++ b/backend/workflow_manager/workflow_v2/serializers.py
@@ -14,7 +14,7 @@
)
from tool_instance_v2.serializers import ToolInstanceSerializer
from tool_instance_v2.tool_instance_helper import ToolInstanceHelper
-from utils.input_sanitizer import validate_name_field, validate_no_html_tags
+from utils.input_sanitizer import validate_name_field
from utils.serializer.integrity_error_mixin import IntegrityErrorMixin
from backend.constants import RequestKey
@@ -50,11 +50,6 @@ class Meta:
def validate_workflow_name(self, value: str) -> str:
return validate_name_field(value, field_name="Workflow name")
- def validate_description(self, value: str) -> str:
- if value is None:
- return value
- return validate_no_html_tags(value, field_name="Description")
-
def to_representation(self, instance: Workflow) -> dict[str, str]:
representation: dict[str, str] = super().to_representation(instance)
representation[WorkflowKey.WF_NAME] = instance.workflow_name
diff --git a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx
index 63b1a037c5..d65a4d2b33 100644
--- a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx
+++ b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx
@@ -205,6 +205,16 @@ function DocumentParser({
return res;
})
.catch((err) => {
+ // Field-keyed DRF validation errors are surfaced inline on the
+ // prompt card; re-throw so the card's catch can render them.
+ const data = err?.response?.data;
+ const hasFieldError =
+ data?.type === "validation_error" &&
+ Array.isArray(data?.errors) &&
+ data.errors.some((e) => e?.attr);
+ if (hasFieldError) {
+ throw err;
+ }
setAlertDetails(handleException(err, "Failed to update"));
});
};
diff --git a/frontend/src/components/custom-tools/editable-text/EditableText.css b/frontend/src/components/custom-tools/editable-text/EditableText.css
index fa526d14b3..7850b0e860 100644
--- a/frontend/src/components/custom-tools/editable-text/EditableText.css
+++ b/frontend/src/components/custom-tools/editable-text/EditableText.css
@@ -20,3 +20,9 @@
font-weight: bold;
font-size: 14px;
}
+
+.editable-text-error-message {
+ display: block;
+ margin-top: 2px;
+ font-size: 12px;
+}
diff --git a/frontend/src/components/custom-tools/editable-text/EditableText.jsx b/frontend/src/components/custom-tools/editable-text/EditableText.jsx
index c2bc5bcebd..7890d881b7 100644
--- a/frontend/src/components/custom-tools/editable-text/EditableText.jsx
+++ b/frontend/src/components/custom-tools/editable-text/EditableText.jsx
@@ -1,4 +1,4 @@
-import { Input } from "antd";
+import { Input, Typography } from "antd";
import debounce from "lodash/debounce";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -17,6 +17,7 @@ function EditableText({
isTextarea,
placeHolder,
isCoverageLoading,
+ error,
}) {
const name = isTextarea ? "prompt" : "prompt_key";
const [triggerHandleChange, setTriggerHandleChange] = useState(false);
@@ -94,24 +95,32 @@ function EditableText({
}
return (
- setIsHovered(true)}
- onMouseOut={() => setIsHovered(false)}
- onBlur={handleBlur}
- onClick={() => setIsEditing(true)}
- disabled={
- isCoverageLoading || isSinglePassExtractLoading || isPublicSource
- }
- />
+ <>
+ setIsHovered(true)}
+ onMouseOut={() => setIsHovered(false)}
+ onBlur={handleBlur}
+ onClick={() => setIsEditing(true)}
+ disabled={
+ isCoverageLoading || isSinglePassExtractLoading || isPublicSource
+ }
+ status={error ? "error" : undefined}
+ />
+ {error && (
+
+ {error}
+
+ )}
+ >
);
}
@@ -126,6 +135,7 @@ EditableText.propTypes = {
isTextarea: PropTypes.bool,
placeHolder: PropTypes.string,
isCoverageLoading: PropTypes.bool.isRequired,
+ error: PropTypes.string,
};
export { EditableText };
diff --git a/frontend/src/components/custom-tools/prompt-card/Header.jsx b/frontend/src/components/custom-tools/prompt-card/Header.jsx
index 30fe75fe54..16342f4af7 100644
--- a/frontend/src/components/custom-tools/prompt-card/Header.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/Header.jsx
@@ -92,6 +92,7 @@ function Header({
handleSpsLoading,
enforceType,
isAgenticTableReady = true,
+ promptKeyError,
}) {
const {
selectedDoc,
@@ -335,6 +336,7 @@ function Header({
handleChange={handleChange}
placeHolder={updatePlaceHolder}
isCoverageLoading={isCoverageLoading}
+ error={promptKeyError}
/>
@@ -493,6 +495,7 @@ Header.propTypes = {
handleSpsLoading: PropTypes.func.isRequired,
enforceType: PropTypes.string,
isAgenticTableReady: PropTypes.bool,
+ promptKeyError: PropTypes.string,
};
export { Header };
diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
index 29fda260e2..5e67360e52 100644
--- a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
@@ -53,6 +53,7 @@ const PromptCard = memo(
const [promptKey, setPromptKey] = useState("");
const [promptText, setPromptText] = useState("");
const [selectedLlmProfileId, setSelectedLlmProfileId] = useState(null);
+ const [fieldErrors, setFieldErrors] = useState({});
const [isCoverageLoading, setIsCoverageLoading] = useState(false);
const [openOutputForDoc, setOpenOutputForDoc] = useState(false);
@@ -157,6 +158,14 @@ const PromptCard = memo(
const updatedPromptDetailsState = { ...promptDetailsState };
updatedPromptDetailsState[name] = value;
+ // New attempt — drop any prior inline error for this field.
+ setFieldErrors((prev) => {
+ if (!(name in prev)) return prev;
+ const next = { ...prev };
+ delete next[name];
+ return next;
+ });
+
handleUpdateStatus(
isUpdateStatus,
promptId,
@@ -178,9 +187,26 @@ const PromptCard = memo(
setUpdateStatus,
);
})
- .catch(() => {
+ .catch((err) => {
handleUpdateStatus(isUpdateStatus, promptId, null, setUpdateStatus);
- setPromptDetailsState(prevPromptDetailsState);
+ const data = err?.response?.data;
+ const fieldErrorMap = {};
+ if (
+ data?.type === "validation_error" &&
+ Array.isArray(data?.errors)
+ ) {
+ data.errors.forEach((e) => {
+ if (e?.attr) {
+ fieldErrorMap[e.attr] = e.detail || "Invalid value";
+ }
+ });
+ }
+ if (Object.keys(fieldErrorMap).length > 0) {
+ // Keep the typed value so user can fix in place; show inline error.
+ setFieldErrors((prev) => ({ ...prev, ...fieldErrorMap }));
+ } else {
+ setPromptDetailsState(prevPromptDetailsState);
+ }
})
.finally(() => {
if (isUpdateStatus) {
@@ -381,6 +407,7 @@ const PromptCard = memo(
coverageCountData={coverageCountData}
isChallenge={isChallenge}
handleSelectHighlight={handleSelectHighlight}
+ fieldErrors={fieldErrors}
/>
@@ -369,6 +371,7 @@ function PromptCardItems({
}
PromptCardItems.propTypes = {
+ fieldErrors: PropTypes.object,
promptDetails: PropTypes.object.isRequired,
enforceTypeList: PropTypes.array,
allTableSettings: PropTypes.array,