diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py
index d4806a6f796..54edf22355d 100644
--- a/api/services/skill_management_service.py
+++ b/api/services/skill_management_service.py
@@ -108,13 +108,28 @@ draft file operations:
- delete: delete a draft file or directory. Never delete SKILL.md.
Allowed write targets are SKILL.md and files/directories under scripts/,
-references/, and assets/. When revising SKILL.md, preserve valid frontmatter and
-include a lowercase kebab-case name, a non-empty description, and
-metadata.display-name when appropriate. Do not claim that you published a Skill
-or changed anything outside the draft files. Only SKILL.md should contain Skill
-frontmatter fields such as name, description, or metadata.display-name. Ordinary
-Markdown files under references/ should contain only their own document content
-unless the user explicitly asks for YAML frontmatter in that file.
+references/, and assets/. When creating or revising SKILL.md, preserve valid
+frontmatter and include a meaningful lowercase kebab-case name, a non-empty
+description, and metadata.display-name. If the current draft is untitled, never
+keep placeholder values such as name: untitled-skill-*, metadata.display-name:
+Untitled skill, or the default placeholder description in the completed
+SKILL.md. The frontmatter name, metadata.display-name, and first H1 heading must
+describe the same Skill. Derive the kebab-case name from the actual Skill title,
+for example:
+---
+name: customer-issue-tiered-handling
+description: Classify and route customer support issues by severity and handling path.
+metadata:
+ display-name: Customer Issue Tiered Handling
+---
+
+# Customer Issue Tiered Handling
+
+Do not claim that you published a Skill or changed anything outside the draft
+files. Only SKILL.md should contain Skill frontmatter fields such as name,
+description, or metadata.display-name. Ordinary Markdown files under references/
+should contain only their own document content unless the user explicitly asks
+for YAML frontmatter in that file.
Respond with JSON only:
{
@@ -761,6 +776,19 @@ class SkillManagementService:
"status": exc.status_code,
}
)
+ except IntegrityError as exc:
+ logger.warning("skill_assistant_action_conflict skill_id=%s error=%s", skill_id, exc)
+ error_message, details = self._skill_name_conflict_from_integrity_error(exc)
+ payload: dict[str, Any] = {
+ "event": "error",
+ "id": message_id,
+ "code": "skill_name_conflict",
+ "message": error_message,
+ "status": 422,
+ }
+ if details:
+ payload["details"] = details
+ yield self._assistant_sse(payload)
except Exception:
logger.exception("skill_assistant_action_failed skill_id=%s", skill_id)
yield self._assistant_sse(
@@ -886,6 +914,15 @@ class SkillManagementService:
def _assistant_sse(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
+ @staticmethod
+ def _skill_name_conflict_from_integrity_error(exc: IntegrityError) -> tuple[str, dict[str, str]]:
+ text = str(getattr(exc, "orig", exc))
+ match = re.search(r"Key \(tenant_id, name\)=\([^,]+,\s*([^)]+)\) already exists", text)
+ if match:
+ name = match.group(1)
+ return f'Skill name "{name}" already exists. Please choose a different name.', {"name": name}
+ return "Skill name already exists. Please choose a different name.", {}
+
@classmethod
def _sanitize_assistant_operation_content(cls, operation: SkillAssistDraftOperationPayload) -> str | None:
content = operation.content
@@ -2381,10 +2418,7 @@ class SkillManagementService:
display_name: str,
current_skill_id: str,
) -> str:
- base = re.sub(r"[^a-z0-9]+", "-", display_name.strip().lower()).strip("-")
- if not base:
- base = _UNTITLED_SKILL_NAME_PREFIX
- base = validate_skill_name(base[:64].strip("-") or _UNTITLED_SKILL_NAME_PREFIX)
+ base = SkillManagementService._name_from_display_name(display_name)
names = set(
session.scalars(select(Skill.name).where(Skill.tenant_id == tenant_id, Skill.id != current_skill_id))
)
@@ -2398,6 +2432,38 @@ class SkillManagementService:
return candidate
suffix += 1
+ @staticmethod
+ def _name_from_display_name(display_name: str) -> str:
+ base = re.sub(r"[^a-z0-9]+", "-", display_name.strip().lower()).strip("-")
+ if not base:
+ base = _UNTITLED_SKILL_NAME_PREFIX
+ return validate_skill_name(base[:64].strip("-") or _UNTITLED_SKILL_NAME_PREFIX)
+
+ @staticmethod
+ def _ensure_skill_name_available(
+ session,
+ *,
+ tenant_id: str,
+ current_skill_id: str,
+ name: str,
+ ) -> None:
+ with session.no_autoflush:
+ existing_id = session.scalar(
+ select(Skill.id)
+ .where(
+ Skill.tenant_id == tenant_id,
+ Skill.id != current_skill_id,
+ Skill.name == name,
+ )
+ .limit(1)
+ )
+ if existing_id is not None:
+ raise SkillManagementServiceError(
+ "skill_name_conflict",
+ f'Skill name "{name}" already exists. Please choose a different name.',
+ details={"name": name},
+ )
+
@staticmethod
def _parse_frontmatter(content: str) -> dict[str, Any]:
match = _FRONTMATTER_RE.match(content)
@@ -2424,7 +2490,6 @@ class SkillManagementService:
)
return payload
- @staticmethod
@staticmethod
def _frontmatter_field_line(content: str, field: str) -> int:
match = _FRONTMATTER_RE.match(content)
@@ -2507,7 +2572,7 @@ class SkillManagementService:
skill.name_manually_edited = True
skill.name = name
skill.description = self._require_frontmatter_description(frontmatter, content=content)
- display_name = self._display_name_override_from_frontmatter(frontmatter)
+ display_name = self._display_name_from_draft_skill_md(frontmatter=frontmatter, content=content)
if display_name is not None:
skill.display_name = display_name
@@ -2806,6 +2871,9 @@ class SkillManagementService:
if skill_md is None or skill_md.kind != SkillFileKind.FILE or skill_md.storage != SkillFileStorage.TEXT:
raise SkillManagementServiceError("missing_skill_md", "skill must contain text SKILL.md")
skill_md_content = skill_md.content or ""
+ if not strict_frontmatter and sync_frontmatter_name:
+ skill_md_content = self._normalize_untitled_draft_skill_md_name(skill=skill, content=skill_md_content)
+ entries_by_path[_SKILL_MD] = skill_md.model_copy(update={"content": skill_md_content})
if strict_frontmatter:
frontmatter = self._parse_frontmatter(skill_md_content)
frontmatter_name = self._require_frontmatter_name(frontmatter, content=skill_md_content)
@@ -2894,22 +2962,139 @@ class SkillManagementService:
except SkillManagementServiceError:
return
name = frontmatter.get("name")
+ display_name = self._display_name_override_from_frontmatter(frontmatter)
if isinstance(name, str) and name.strip():
try:
validated_name = validate_skill_name(name)
except ValueError:
validated_name = None
if validated_name is not None:
- if validated_name != skill.name:
+ session = object_session(skill)
+ auto_generated_name = False
+ if (
+ self._should_auto_sync_name(skill)
+ and display_name is not None
+ and display_name != _UNTITLED_DISPLAY_NAME
+ and session is not None
+ ):
+ generated_name = self._name_from_display_name(display_name)
+ validated_name = generated_name
+ auto_generated_name = True
+ if validated_name != skill.name and session is not None:
+ self._ensure_skill_name_available(
+ session,
+ tenant_id=skill.tenant_id,
+ current_skill_id=skill.id,
+ name=validated_name,
+ )
+ if validated_name != skill.name and not auto_generated_name:
skill.name_manually_edited = True
skill.name = validated_name
description = frontmatter.get("description")
if isinstance(description, str) and description.strip():
skill.description = description.strip()[:1024]
- display_name = self._display_name_override_from_frontmatter(frontmatter)
if display_name is not None:
skill.display_name = display_name
+ def _normalize_untitled_draft_skill_md_name(self, *, skill: Skill, content: str) -> str:
+ """Replace placeholder builder names with the generated display-name slug.
+
+ Skill Builder starts from an untitled draft. Some models preserve the
+ placeholder ``name: untitled-skill-*`` while correctly generating a
+ meaningful ``metadata.display-name``. Normalize the file before it is
+ saved so the editor, detail payload, and future export all show the same
+ generated kebab-case name.
+ """
+ if not self._should_auto_sync_name(skill):
+ return content
+
+ try:
+ frontmatter = self._parse_frontmatter(content)
+ except SkillManagementServiceError:
+ return content
+
+ name = frontmatter.get("name")
+ if not isinstance(name, str) or not name.strip():
+ return content
+ try:
+ validated_name = validate_skill_name(name)
+ except ValueError:
+ return content
+ display_name = self._display_name_from_draft_skill_md(frontmatter=frontmatter, content=content)
+ if display_name is None:
+ return content
+
+ session = object_session(skill)
+ if session is None:
+ return content
+ generated_name = self._name_from_display_name(display_name)
+ next_content = content
+ if display_name != self._display_name_override_from_frontmatter(frontmatter):
+ next_content = self._replace_or_insert_frontmatter_display_name(next_content, display_name)
+ if generated_name == validated_name:
+ return next_content
+ self._ensure_skill_name_available(
+ session,
+ tenant_id=skill.tenant_id,
+ current_skill_id=skill.id,
+ name=generated_name,
+ )
+
+ return re.sub(r"(?m)^name:\s*.*$", f"name: {generated_name}", next_content, count=1)
+
+ def _display_name_from_draft_skill_md(self, *, frontmatter: dict[str, Any], content: str) -> str | None:
+ display_name = self._display_name_override_from_frontmatter(frontmatter)
+ if display_name is not None and display_name != _UNTITLED_DISPLAY_NAME:
+ return display_name
+ if display_name is None:
+ return None
+
+ heading = self._first_markdown_heading(content)
+ if heading is None or heading == _UNTITLED_DISPLAY_NAME:
+ return None
+ return heading[:128]
+
+ @staticmethod
+ def _first_markdown_heading(content: str) -> str | None:
+ body = _FRONTMATTER_RE.sub("", content, count=1)
+ match = re.search(r"(?m)^#\s+(.+?)\s*$", body)
+ if match is None:
+ return None
+ heading = match.group(1).strip()
+ return heading or None
+
+ @staticmethod
+ def _replace_or_insert_frontmatter_display_name(content: str, display_name: str) -> str:
+ match = _FRONTMATTER_RE.match(content)
+ if match is None:
+ return content
+
+ frontmatter = match.group(1)
+ escaped_display_name = yaml.safe_dump(
+ display_name,
+ allow_unicode=True,
+ default_flow_style=True,
+ sort_keys=False,
+ ).splitlines()[0]
+ if re.search(r"(?m)^\s*(display-name|display_name)\s*:", frontmatter):
+ next_frontmatter = re.sub(
+ r"(?m)^(\s*)(display-name|display_name)\s*:.*$",
+ lambda match: f"{match.group(1)}display-name: {escaped_display_name}",
+ frontmatter,
+ count=1,
+ )
+ elif re.search(r"(?m)^metadata\s*:\s*$", frontmatter):
+ next_frontmatter = re.sub(
+ r"(?m)^metadata\s*:\s*$",
+ f"metadata:\n display-name: {escaped_display_name}",
+ frontmatter,
+ count=1,
+ )
+ else:
+ next_frontmatter = f"{frontmatter}\nmetadata:\n display-name: {escaped_display_name}"
+
+ return f"---\n{next_frontmatter}\n---\n{content[match.end():]}"
+
def _sync_skill_md_text(self, skill: Skill, content: str) -> str:
body = _FRONTMATTER_RE.sub("", content, count=1)
metadata = self._parse_frontmatter(content)
diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py
index d31dd45bb5e..be4eedc230a 100644
--- a/api/tests/unit_tests/services/test_skill_management_service.py
+++ b/api/tests/unit_tests/services/test_skill_management_service.py
@@ -13,6 +13,7 @@ from uuid import uuid4
import pytest
from sqlalchemy import Table, delete, func, select
+from sqlalchemy.exc import IntegrityError
from core.db.session_factory import session_factory
from core.tools.tool_file_manager import ToolFileManager
@@ -491,6 +492,69 @@ def test_create_assistant_action_stream_strips_skill_frontmatter_from_reference_
assert reference["content"] == "# Refund Policy\n"
+def test_create_assistant_action_stream_reports_skill_name_database_conflict() -> None:
+ service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
+ created = service.create_skill(
+ tenant_id=TENANT,
+ user_id=USER,
+ payload=SkillCreatePayload(name="untitled-skill-1", description="Draft skill."),
+ )
+ model_output = json.dumps(
+ {
+ "reply": "已创建用于客户问题分级处理的 skill 草案",
+ "operations": [
+ {
+ "operation": "upsert_text",
+ "path": "SKILL.md",
+ "mime_type": "text/markdown",
+ "content": (
+ "---\n"
+ "name: customer-issue-triage\n"
+ "description: Customer issue triage.\n"
+ "metadata:\n"
+ " display-name: Customer Issue Triage\n"
+ "---\n"
+ "# Customer Issue Triage\n"
+ ),
+ }
+ ],
+ }
+ )
+ model = SimpleNamespace(
+ invoke_llm=lambda **_kwargs: SimpleNamespace(
+ message=SimpleNamespace(get_text_content=lambda: model_output),
+ )
+ )
+ manager = SimpleNamespace(get_default_model_instance=lambda **_kwargs: model)
+ integrity_error = IntegrityError(
+ "UPDATE skills",
+ {},
+ Exception(
+ 'duplicate key value violates unique constraint "skill_tenant_name_unique"\n'
+ "DETAIL: Key (tenant_id, name)=(tenant, customer-issue-triage) already exists."
+ ),
+ )
+
+ with (
+ patch("services.skill_management_service.ModelManager.for_tenant", return_value=manager),
+ patch.object(service, "apply_draft_file_operation", side_effect=integrity_error),
+ ):
+ response = list(
+ service.create_assistant_action_stream(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ message="创建客户问题分级处理 skill",
+ )
+ )
+
+ events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response]
+ assert [event["event"] for event in events] == ["message", "error"]
+ assert events[1]["code"] == "skill_name_conflict"
+ assert events[1]["message"] == 'Skill name "customer-issue-triage" already exists. Please choose a different name.'
+ assert events[1]["details"] == {"name": "customer-issue-triage"}
+
+
def test_sync_assistant_model_config_updates_debugger_draft() -> None:
openai_model = AgentSoulModelConfig(
plugin_id="langgenius/openai",
@@ -1564,6 +1628,187 @@ def test_apply_draft_file_operation_syncs_frontmatter_display_name_to_db() -> No
assert updated["description"] == "Handle refund approvals."
+def test_apply_draft_file_operation_generates_name_for_builder_created_skill() -> None:
+ service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
+ created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
+
+ updated = service.apply_draft_file_operation(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ payload=SkillDraftFileOperationPayload(
+ operation="upsert_text",
+ path="SKILL.md",
+ content=(
+ "---\n"
+ f"name: {created['name']}\n"
+ "description: Classify and route customer issues.\n"
+ "metadata:\n"
+ " display-name: Customer Issue Tiered Handling\n"
+ "---\n"
+ "# Customer Issue Tiered Handling\n"
+ ),
+ ),
+ )
+
+ skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
+ assert updated["name"] == "customer-issue-tiered-handling"
+ assert updated["display_name"] == "Customer Issue Tiered Handling"
+ assert updated["name_manually_edited"] is False
+ assert "name: customer-issue-tiered-handling" in skill_md["content"]
+ assert f"name: {created['name']}" not in skill_md["content"]
+
+
+def test_apply_draft_file_operation_prefers_builder_display_name_for_generated_name() -> None:
+ service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
+ created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
+
+ updated = service.apply_draft_file_operation(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ payload=SkillDraftFileOperationPayload(
+ operation="upsert_text",
+ path="SKILL.md",
+ content=(
+ "---\n"
+ "name: customer-issue-triage\n"
+ "description: Classify and route customer issues.\n"
+ "metadata:\n"
+ " display-name: Customer Issue Tiered Handling\n"
+ "---\n"
+ "# Customer Issue Tiered Handling\n"
+ ),
+ ),
+ )
+
+ skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
+ assert updated["name"] == "customer-issue-tiered-handling"
+ assert updated["display_name"] == "Customer Issue Tiered Handling"
+ assert updated["name_manually_edited"] is False
+ assert "name: customer-issue-tiered-handling" in skill_md["content"]
+ assert "name: customer-issue-triage" not in skill_md["content"]
+
+
+def test_apply_draft_file_operation_uses_builder_heading_when_display_name_is_placeholder() -> None:
+ service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
+ created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
+
+ updated = service.apply_draft_file_operation(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ payload=SkillDraftFileOperationPayload(
+ operation="upsert_text",
+ path="SKILL.md",
+ content=(
+ "---\n"
+ f"name: {created['name']}\n"
+ "description: Classify and route customer issues.\n"
+ "metadata:\n"
+ " display-name: Untitled skill\n"
+ "---\n"
+ "# Customer Issue Tiered Handling\n"
+ ),
+ ),
+ )
+
+ skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
+ assert updated["name"] == "customer-issue-tiered-handling"
+ assert updated["display_name"] == "Customer Issue Tiered Handling"
+ assert updated["name_manually_edited"] is False
+ assert "name: customer-issue-tiered-handling" in skill_md["content"]
+ assert "display-name: Customer Issue Tiered Handling" in skill_md["content"]
+ assert f"name: {created['name']}" not in skill_md["content"]
+
+
+def test_apply_draft_file_operation_keeps_auto_generated_name_in_sync_with_builder_display_name() -> None:
+ service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
+ created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
+ first_update = service.apply_draft_file_operation(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ payload=SkillDraftFileOperationPayload(
+ operation="upsert_text",
+ path="SKILL.md",
+ content=(
+ "---\n"
+ "name: customer-issue-triage\n"
+ "description: Classify customer issues.\n"
+ "metadata:\n"
+ " display-name: Customer Issue Triage\n"
+ "---\n"
+ "# Customer Issue Triage\n"
+ ),
+ ),
+ )
+ assert first_update["name"] == "customer-issue-triage"
+ assert first_update["name_manually_edited"] is False
+
+ updated = service.apply_draft_file_operation(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ payload=SkillDraftFileOperationPayload(
+ operation="upsert_text",
+ path="SKILL.md",
+ content=(
+ "---\n"
+ "name: customer-issue-triage\n"
+ "description: Classify and route customer issues.\n"
+ "metadata:\n"
+ " display-name: Customer Issue Tiered Handling\n"
+ "---\n"
+ "# Customer Issue Tiered Handling\n"
+ ),
+ ),
+ )
+
+ skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
+ assert updated["name"] == "customer-issue-tiered-handling"
+ assert updated["display_name"] == "Customer Issue Tiered Handling"
+ assert updated["name_manually_edited"] is False
+ assert "name: customer-issue-tiered-handling" in skill_md["content"]
+ assert "name: customer-issue-triage" not in skill_md["content"]
+
+
+def test_apply_draft_file_operation_reports_builder_generated_name_conflict() -> None:
+ service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
+ service.create_skill(
+ tenant_id=TENANT,
+ user_id=USER,
+ payload=SkillCreatePayload(name="customer-issue-tiered-handling"),
+ )
+ created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
+
+ with pytest.raises(SkillManagementServiceError) as exc_info:
+ service.apply_draft_file_operation(
+ tenant_id=TENANT,
+ user_id=USER,
+ skill_id=created["id"],
+ payload=SkillDraftFileOperationPayload(
+ operation="upsert_text",
+ path="SKILL.md",
+ content=(
+ "---\n"
+ "name: customer-issue-triage\n"
+ "description: Classify and route customer issues.\n"
+ "metadata:\n"
+ " display-name: Customer Issue Tiered Handling\n"
+ "---\n"
+ "# Customer Issue Tiered Handling\n"
+ ),
+ ),
+ )
+
+ assert exc_info.value.code == "skill_name_conflict"
+ assert exc_info.value.details == {"name": "customer-issue-tiered-handling"}
+ assert exc_info.value.message == (
+ 'Skill name "customer-issue-tiered-handling" already exists. Please choose a different name.'
+ )
+
+
def test_publish_syncs_frontmatter_display_name_from_existing_draft() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx
new file mode 100644
index 00000000000..4241969d93c
--- /dev/null
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx
@@ -0,0 +1,81 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useState } from 'react'
+import { AgentOrchestrateAddActionsProvider } from '../add-actions'
+import {
+ useAgentOrchestrateAddActions,
+ useRegisterAgentOrchestrateAddAction,
+} from '../add-actions-context'
+import { AgentOrchestrateViewingVersionContext } from '../read-only-context'
+
+function RegisteredActionProbe({ onRegister }: { onRegister: () => void }) {
+ useRegisterAgentOrchestrateAddAction('skills', onRegister)
+ return