diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index ee494d160f6..ae2062d0314 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -818,7 +818,7 @@ class SkillManagementService: with session_factory.create_session() as session: skill = self._require_skill(session, tenant_id=tenant_id, skill_id=skill_id) self._check_expected_updated_at(skill, payload.expected_updated_at) - files = self._build_draft_rows_from_tree(skill=skill, payload=payload) + files = self._build_draft_rows_from_tree(skill=skill, payload=payload, strict_frontmatter=False) session.execute(delete(SkillDraftFile).where(SkillDraftFile.skill_id == skill.id)) session.flush() for file in files: @@ -864,6 +864,7 @@ class SkillManagementService: files = self._build_draft_rows_from_tree( skill=skill, payload=SkillDraftTreePayload(files=updated_items), + strict_frontmatter=False, ) existing_files_by_path = { file.path: file @@ -1424,14 +1425,21 @@ class SkillManagementService: agent = session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) if agent is None: raise SkillManagementServiceError("agent_not_found", "agent not found", status_code=404) - found = set(session.scalars(select(Skill.id).where(Skill.tenant_id == tenant_id, Skill.id.in_(skill_ids)))) - missing = [skill_id for skill_id in skill_ids if skill_id not in found] + skills = list(session.scalars(select(Skill).where(Skill.tenant_id == tenant_id, Skill.id.in_(skill_ids)))) + skills_by_id = {skill.id: skill for skill in skills} + missing = [skill_id for skill_id in skill_ids if skill_id not in skills_by_id] if missing: raise SkillManagementServiceError( "skill_not_found", "one or more skills were not found", status_code=404, ) + self._check_agent_skill_name_conflicts( + session, + tenant_id=tenant_id, + agent_id=agent_id, + selected_skill_names=[skills_by_id[skill_id].name for skill_id in skill_ids], + ) session.query(AgentSkillBinding).filter( AgentSkillBinding.tenant_id == tenant_id, AgentSkillBinding.agent_id == agent_id, @@ -1450,6 +1458,66 @@ class SkillManagementService: session.commit() return {"agent_id": agent_id, "skill_ids": skill_ids} + @staticmethod + def _check_agent_skill_name_conflicts( + session, + *, + tenant_id: str, + agent_id: str, + selected_skill_names: list[str], + ) -> None: + if not selected_skill_names: + return + + current_bound_names = set( + session.scalars( + select(Skill.name) + .join(AgentSkillBinding, AgentSkillBinding.skill_id == Skill.id) + .where( + AgentSkillBinding.tenant_id == tenant_id, + AgentSkillBinding.agent_id == agent_id, + Skill.tenant_id == tenant_id, + ) + ) + ) + configured_names: set[str] = set() + snapshot = session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id + == select(Agent.active_config_snapshot_id) + .where(Agent.tenant_id == tenant_id, Agent.id == agent_id) + .scalar_subquery(), + ) + ) + if snapshot is not None: + configured_names.update( + skill.name + for skill in AgentSoulConfig.model_validate(snapshot.config_snapshot_dict).config_skills + if not skill.is_missing + ) + drafts = session.scalars( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + ) + ) + for draft in drafts: + configured_names.update( + skill.name + for skill in AgentSoulConfig.model_validate(draft.config_snapshot_dict).config_skills + if not skill.is_missing + ) + + conflicts = sorted(set(selected_skill_names) & (configured_names - current_bound_names)) + if conflicts: + raise SkillManagementServiceError( + "agent_skill_name_conflict", + "agent already has a config skill with the same name", + details={"names": conflicts}, + ) + def list_agent_bindings( self, *, @@ -2687,6 +2755,7 @@ class SkillManagementService: skill: Skill, payload: SkillDraftTreePayload, sync_frontmatter_name: bool = True, + strict_frontmatter: bool = True, ) -> list[SkillDraftFile]: entries_by_path: dict[str, SkillDraftTreeItemPayload] = {} for item in payload.files: @@ -2698,15 +2767,18 @@ 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 "" - frontmatter = self._parse_frontmatter(skill_md_content) - frontmatter_name = self._require_frontmatter_name(frontmatter, content=skill_md_content) - if sync_frontmatter_name: - self._sync_skill_metadata_from_skill_md( - skill=skill, - content=skill_md_content, - parsed_frontmatter=frontmatter, - validated_name=frontmatter_name, - ) + if strict_frontmatter: + frontmatter = self._parse_frontmatter(skill_md_content) + frontmatter_name = self._require_frontmatter_name(frontmatter, content=skill_md_content) + if sync_frontmatter_name: + self._sync_skill_metadata_from_skill_md( + skill=skill, + content=skill_md_content, + parsed_frontmatter=frontmatter, + validated_name=frontmatter_name, + ) + elif sync_frontmatter_name: + self._sync_skill_metadata_from_draft_skill_md(skill=skill, content=skill_md_content) file_paths = {path for path, item in entries_by_path.items() if item.kind == SkillFileKind.FILE} for path in file_paths: @@ -2743,7 +2815,7 @@ class SkillManagementService: file_size = item.size file_hash = item.hash if item.kind == SkillFileKind.FILE and item.storage == SkillFileStorage.TEXT: - if item.path == _SKILL_MD: + if item.path == _SKILL_MD and strict_frontmatter: content_text = self._sync_skill_md_text(skill, content_text or "") content_bytes = (content_text or "").encode("utf-8") if len(content_bytes) > _MAX_FILE_BYTES: @@ -2772,6 +2844,33 @@ class SkillManagementService: raise SkillManagementServiceError("skill_too_large", "skill exceeds 5MB limit") return rows + def _sync_skill_metadata_from_draft_skill_md(self, *, skill: Skill, content: str) -> None: + """Best-effort metadata sync for editor autosave. + + Draft saves must accept temporarily incomplete frontmatter while the user + is editing. Strict validation still runs on import and publish. + """ + try: + frontmatter = self._parse_frontmatter(content) + except SkillManagementServiceError: + return + name = frontmatter.get("name") + 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: + 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 _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 bc4d5dafa82..9d4d205df62 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -459,6 +459,76 @@ def test_list_agent_bindings_returns_draft_skill_card_data() -> None: assert bindings["data"][0]["latest_published_at"] is None +def test_replace_agent_bindings_rejects_skill_name_conflict_with_agent_config_skill() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) + with session_factory.create_session() as session: + snapshot = AgentConfigSnapshot( + tenant_id=TENANT, + agent_id=AGENT, + version=1, + config_snapshot=AgentSoulConfig( + config_skills=[ + AgentConfigSkillRefConfig( + name="finance-sop", + description="Existing uploaded config skill.", + file_id="tool-file-1", + ) + ] + ), + created_by=USER, + ) + session.add(snapshot) + session.flush() + agent = session.get(Agent, AGENT) + assert agent is not None + agent.active_config_snapshot_id = snapshot.id + session.commit() + + with pytest.raises(SkillManagementServiceError) as exc_info: + service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) + + assert exc_info.value.code == "agent_skill_name_conflict" + assert exc_info.value.details == {"names": ["finance-sop"]} + + +def test_replace_agent_bindings_allows_existing_bound_workspace_skill_name_in_agent_config() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) + service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) + with session_factory.create_session() as session: + snapshot = AgentConfigSnapshot( + tenant_id=TENANT, + agent_id=AGENT, + version=1, + config_snapshot=AgentSoulConfig( + config_skills=[ + AgentConfigSkillRefConfig( + name="finance-sop", + description="Synced workspace skill.", + file_id="tool-file-1", + ) + ] + ), + created_by=USER, + ) + session.add(snapshot) + session.flush() + agent = session.get(Agent, AGENT) + assert agent is not None + agent.active_config_snapshot_id = snapshot.id + session.commit() + + result = service.replace_agent_bindings( + tenant_id=TENANT, + user_id=USER, + agent_id=AGENT, + skill_ids=[created["id"]], + ) + + assert result["skill_ids"] == [created["id"]] + + def test_list_skill_references_resolves_agent_apps_and_inline_workflow_nodes() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill( @@ -629,6 +699,24 @@ def test_publish_updates_referenced_agent_config_skill_archives() -> None: payload=SkillCreatePayload(name="finance-sop"), ) inline_agent_id = "77777777-7777-7777-7777-777777777777" + with session_factory.create_session() as session: + session.add( + Agent( + id=inline_agent_id, + tenant_id=TENANT, + name="Agent 内嵌节点 C", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="66666666-6666-6666-6666-666666666666", + workflow_id="88888888-8888-8888-8888-888888888888", + workflow_node_id="node-c", + ) + ) + session.commit() + + service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) + service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=inline_agent_id, skill_ids=[created["id"]]) + with session_factory.create_session() as session: agent_snapshot = AgentConfigSnapshot( tenant_id=TENANT, @@ -675,18 +763,6 @@ def test_publish_updates_referenced_agent_config_skill_archives() -> None: updated_by=USER, ) ) - session.add( - Agent( - id=inline_agent_id, - tenant_id=TENANT, - name="Agent 内嵌节点 C", - scope=AgentScope.WORKFLOW_ONLY, - source=AgentSource.WORKFLOW, - app_id="66666666-6666-6666-6666-666666666666", - workflow_id="88888888-8888-8888-8888-888888888888", - workflow_node_id="node-c", - ) - ) inline_snapshot = AgentConfigSnapshot( tenant_id=TENANT, agent_id=inline_agent_id, @@ -724,8 +800,6 @@ def test_publish_updates_referenced_agent_config_skill_archives() -> None: ) session.commit() - service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) - service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=inline_agent_id, skill_ids=[created["id"]]) service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"], payload=SkillPublishPayload()) with session_factory.create_session() as session: @@ -1032,77 +1106,90 @@ def test_publish_syncs_frontmatter_display_name_from_existing_draft() -> None: assert detail["description"] == "Handle refund approvals." -def test_replace_draft_tree_rejects_missing_frontmatter_name() -> None: +def test_replace_draft_tree_allows_missing_frontmatter_name_until_publish() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) - with pytest.raises(SkillManagementServiceError) as exc_info: - service.replace_draft_tree( - tenant_id=TENANT, - user_id=USER, - skill_id=created["id"], - payload=SkillDraftTreePayload(files=[{"path": "SKILL.md", "content": "# Missing frontmatter"}]), - ) + draft = service.replace_draft_tree( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftTreePayload(files=[{"path": "SKILL.md", "content": "# Missing frontmatter"}]), + ) + assert draft["name"] == "finance-sop" + assert next(item for item in draft["files"] if item["path"] == "SKILL.md")["content"] == "# Missing frontmatter" + + with pytest.raises(SkillManagementServiceError) as exc_info: + service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"], payload=SkillPublishPayload()) assert exc_info.value.code == "missing_skill_name" assert exc_info.value.details == {"path": "SKILL.md", "field": "name", "line": 2} -def test_replace_draft_tree_rejects_missing_frontmatter_description() -> None: +def test_replace_draft_tree_allows_missing_frontmatter_description_until_publish() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) - with pytest.raises(SkillManagementServiceError) as exc_info: - service.replace_draft_tree( - tenant_id=TENANT, - user_id=USER, - skill_id=created["id"], - payload=SkillDraftTreePayload( - files=[{"path": "SKILL.md", "content": "---\nname: finance-sop\n---\n# Missing description"}] - ), - ) + draft = service.replace_draft_tree( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftTreePayload( + files=[{"path": "SKILL.md", "content": "---\nname: finance-sop\n---\n# Missing description"}] + ), + ) + assert draft["description"] == "Describe what this Skill does and when an Agent should use it." + assert "description:" not in next(item for item in draft["files"] if item["path"] == "SKILL.md")["content"] + + with pytest.raises(SkillManagementServiceError) as exc_info: + service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"], payload=SkillPublishPayload()) assert exc_info.value.code == "missing_skill_description" assert exc_info.value.details == {"path": "SKILL.md", "field": "description", "line": 2} -def test_replace_draft_tree_rejects_blank_frontmatter_description() -> None: +def test_replace_draft_tree_allows_blank_frontmatter_description_until_publish() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) - with pytest.raises(SkillManagementServiceError) as exc_info: - service.replace_draft_tree( - tenant_id=TENANT, - user_id=USER, - skill_id=created["id"], - payload=SkillDraftTreePayload( - files=[{"path": "SKILL.md", "content": "---\nname: finance-sop\ndescription: ''\n---\n# Blank"}] - ), - ) + draft = service.replace_draft_tree( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftTreePayload( + files=[{"path": "SKILL.md", "content": "---\nname: finance-sop\ndescription: ''\n---\n# Blank"}] + ), + ) + assert draft["description"] == "Describe what this Skill does and when an Agent should use it." + assert "description: ''" in next(item for item in draft["files"] if item["path"] == "SKILL.md")["content"] + + with pytest.raises(SkillManagementServiceError) as exc_info: + service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"], payload=SkillPublishPayload()) assert exc_info.value.code == "missing_skill_description" assert exc_info.value.details == {"path": "SKILL.md", "field": "description", "line": 3} -def test_replace_draft_tree_reports_actual_frontmatter_name_line() -> None: +def test_publish_reports_actual_frontmatter_name_line() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) - with pytest.raises(SkillManagementServiceError) as exc_info: - service.replace_draft_tree( - tenant_id=TENANT, - user_id=USER, - skill_id=created["id"], - payload=SkillDraftTreePayload( - files=[ - { - "path": "SKILL.md", - "content": "---\ndescription: x\nmetadata:\nname: bad_name\n---\n# Body", - } - ] - ), - ) + service.replace_draft_tree( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftTreePayload( + files=[ + { + "path": "SKILL.md", + "content": "---\ndescription: x\nmetadata:\nname: bad_name\n---\n# Body", + } + ] + ), + ) + with pytest.raises(SkillManagementServiceError) as exc_info: + service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"], payload=SkillPublishPayload()) assert exc_info.value.code == "invalid_skill_name" assert exc_info.value.details == {"path": "SKILL.md", "field": "name", "line": 4} @@ -1311,6 +1398,7 @@ def test_delete_skill_requires_confirmation_when_referenced() -> None: def test_delete_skill_removes_synced_agent_config_skill_refs() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) + service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) with session_factory.create_session() as session: agent_snapshot = AgentConfigSnapshot( tenant_id=TENANT, @@ -1366,8 +1454,6 @@ def test_delete_skill_removes_synced_agent_config_skill_refs() -> None: ) session.commit() - service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) - deleted = service.delete_skill(tenant_id=TENANT, skill_id=created["id"], confirmation_name="finance-sop") with session_factory.create_session() as session: diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 2a0f2cbf561..4886e97d6d3 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -27,6 +27,23 @@ export const viewport: Viewport = { viewportFit: 'cover', } +const resizeObserverErrorFilterScript = ` +(() => { + const ignoredMessages = new Set([ + 'ResizeObserver loop completed with undelivered notifications.', + 'ResizeObserver loop limit exceeded', + ]); + const ignore = (event) => { + const message = event?.message || event?.reason?.message; + if (!ignoredMessages.has(message)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + window.addEventListener('error', ignore, true); + window.addEventListener('unhandledrejection', ignore, true); +})(); +` + export default async function RootLayout({ children }: { children: React.ReactNode }) { const datasetMap = getDatasetMap() const queryClient = getQueryClientServer() @@ -43,6 +60,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
+ diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 7c4871a9fa3..8414404e957 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -2,6 +2,7 @@ import type { AgentAppPartial, AgentIconType } from '@dify/contracts/api/console/agent/types.gen' import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -224,7 +225,12 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { -