diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 810dfda965a..ac3f7ef4824 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -14,7 +14,6 @@ from controllers.console.app.app import ( ) from controllers.console.app.app import ( AppListQuery, - CopyAppPayload, _normalize_app_list_query_args, ) from controllers.console.app.app import ( @@ -110,6 +109,25 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload): return role +class AgentAppCopyPayload(BaseModel): + name: str | None = Field(default=None, description="Name for the copied agent") + description: str | None = Field(default=None, description="Description for the copied agent", max_length=400) + role: str | None = Field(default=None, description="Role for the copied agent", max_length=255) + icon_type: IconType | None = Field(default=None, description="Icon type") + icon: str | None = Field(default=None, description="Icon") + icon_background: str | None = Field(default=None, description="Icon background color") + + @field_validator("role") + @classmethod + def validate_role(cls, value: str | None) -> str | None: + if value is None: + return None + role = value.strip() + if not role: + raise ValueError("Agent role is required when provided.") + return role + + class AgentApiStatusPayload(BaseModel): enable_api: bool = Field(..., description="Enable or disable Agent service API") @@ -242,8 +260,8 @@ register_schema_models( console_ns, AgentAppCreatePayload, AgentAppUpdatePayload, + AgentAppCopyPayload, AgentApiStatusPayload, - CopyAppPayload, AgentInviteOptionsQuery, AgentLogsQuery, AgentStatisticsQuery, @@ -567,7 +585,7 @@ class AgentDebugConversationRefreshApi(Resource): @console_ns.route("/agent//copy") class AgentAppCopyApi(Resource): - @console_ns.expect(console_ns.models[CopyAppPayload.__name__]) + @console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__]) @console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(400, "Invalid request parameters") @@ -578,13 +596,14 @@ class AgentAppCopyApi(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - args = CopyAppPayload.model_validate(console_ns.payload or {}) + args = AgentAppCopyPayload.model_validate(console_ns.payload or {}) copied_app = _agent_roster_service().duplicate_agent_app( tenant_id=tenant_id, agent_id=str(agent_id), account=current_user, name=args.name, description=args.description, + role=args.role, icon_type=args.icon_type, icon=args.icon, icon_background=args.icon_background, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index a60e958f84b..881e83061cc 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -592,7 +592,7 @@ Stop a running Agent App chat message generation | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [CopyAppPayload](#copyapppayload)
| +| Yes | **application/json**: [AgentAppCopyPayload](#agentappcopypayload)
| #### Responses @@ -12157,6 +12157,17 @@ Default namespace | validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No | | variant | string | | Yes | +#### AgentAppCopyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Description for the copied agent | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| name | string | Name for the copied agent | No | +| role | string | Role for the copied agent | No | + #### AgentAppCreatePayload | Name | Type | Description | Required | diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index b75fa0bb1ae..6a9d5818647 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -633,6 +633,7 @@ class AgentRosterService: account: Any, name: str | None = None, description: str | None = None, + role: str | None = None, icon_type: Any = None, icon: str | None = None, icon_background: str | None = None, @@ -644,6 +645,7 @@ class AgentRosterService: copied_name = name or self._next_duplicate_agent_name(tenant_id=tenant_id, base_name=source_app.name) copied_description = description if description is not None else source_app.description + copied_role = role if role is not None else source_agent.role or "" copied_icon_type = icon_type if icon_type is not None else source_app.icon_type copied_icon = icon if icon is not None else source_app.icon copied_icon_background = icon_background if icon_background is not None else source_app.icon_background @@ -654,7 +656,7 @@ class AgentRosterService: name=copied_name, description=copied_description, mode="agent", - agent_role=source_agent.role or "", + agent_role=copied_role, icon_type=self._normalize_app_icon_type(copied_icon_type), icon=copied_icon, icon_background=copied_icon_background, diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index 3fbcb81e61f..f8e2c8c4633 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -17,6 +17,8 @@ normalization. from __future__ import annotations +import mimetypes +import posixpath import re from typing import Any @@ -62,7 +64,8 @@ class SkillStandardizeService: skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path) slug = slugify_skill_name(manifest.name) - # Two drive-owned ToolFiles: canonical SKILL.md + the full archive. + # Drive-owned files: canonical SKILL.md, every inspectable archive file, + # and the full archive for future restore/export. md_tool_file = self._tool_files.create_file_by_raw( user_id=user_id, tenant_id=tenant_id, @@ -82,6 +85,30 @@ class SkillStandardizeService: skill_md_key = f"{slug}/{_SKILL_MD_NAME}" archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}" + member_items: list[DriveCommitItem] = [] + for member_path in sorted(set(manifest.files)): + member_key = f"{slug}/{member_path}" + if member_key in {skill_md_key, archive_key}: + continue + + member_bytes = self._package.read_member_bytes(content=content, member_path=member_path) + mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream" + member_tool_file = self._tool_files.create_file_by_raw( + user_id=user_id, + tenant_id=tenant_id, + conversation_id=None, + file_binary=member_bytes, + mimetype=mimetype, + filename=posixpath.basename(member_path), + ) + member_items.append( + DriveCommitItem( + key=member_key, + file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id), + value_owned_by_drive=True, + ) + ) + self._drive.commit( tenant_id=tenant_id, user_id=user_id, @@ -103,6 +130,7 @@ class SkillStandardizeService: file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id), value_owned_by_drive=True, ), + *member_items, ], ) diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 32a165ccd01..ec3de9928a5 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -464,6 +464,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( json={ "name": "Iris copy", "description": "Copied", + "role": "Copied role", "icon_type": "emoji", "icon": "sparkles", "icon_background": "#fff", @@ -479,6 +480,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( "account": current_user, "name": "Iris copy", "description": "Copied", + "role": "Copied role", "icon_type": "emoji", "icon": "sparkles", "icon_background": "#fff", diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 846ce5a3e62..01db8caede4 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1883,8 +1883,11 @@ class TestAgentAppBackingAgent: monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") + captured: dict[str, object] = {} + class FakeAppService: def create_app(self, tenant_id: str, params, account: object) -> object: + captured["params"] = params return target_app access_mode_updates = [] @@ -1910,9 +1913,11 @@ class TestAgentAppBackingAgent: tenant_id="tenant-1", agent_id="source-agent", account=SimpleNamespace(id="account-1"), + role="Custom Analyst", ) assert duplicated is target_app + assert captured["params"].agent_role == "Custom Analyst" assert access_mode_updates == [("target-app", "private")] def test_duplicate_agent_app_falls_back_to_public_access_mode(self, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py index 29b4c7e59d6..cd27e1b40d0 100644 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -32,13 +32,14 @@ def test_slugify_skill_name(): assert slugify_skill_name("") == "skill" -def test_standardize_creates_two_drive_owned_toolfiles_and_commits(): +def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members(): content = _zip({"SKILL.md": _SKILL_MD, "scripts/run.py": b"print('x')\n"}) tool_files = MagicMock() tool_files.create_file_by_raw.side_effect = [ SimpleNamespace(id="md-tool-file"), SimpleNamespace(id="zip-tool-file"), + SimpleNamespace(id="script-tool-file"), ] drive = MagicMock() drive.commit.return_value = [] @@ -52,25 +53,33 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits(): agent_id="agent-1", ) - # Two ToolFiles: SKILL.md (markdown) + full archive (zip). - assert tool_files.create_file_by_raw.call_count == 2 - md_call, zip_call = tool_files.create_file_by_raw.call_args_list + # ToolFiles: SKILL.md, full archive, and each inspectable package member. + assert tool_files.create_file_by_raw.call_count == 3 + md_call, zip_call, script_call = tool_files.create_file_by_raw.call_args_list assert md_call.kwargs["mimetype"] == "text/markdown" assert md_call.kwargs["file_binary"] == _SKILL_MD assert zip_call.kwargs["mimetype"] == "application/zip" assert zip_call.kwargs["file_binary"] == content + assert script_call.kwargs["mimetype"] in {"text/x-python", "text/plain", "application/octet-stream"} + assert script_call.kwargs["file_binary"] == b"print('x')\n" + assert script_call.kwargs["filename"] == "run.py" # Committed as drive-owned with the standardized keys. commit_kwargs = drive.commit.call_args.kwargs assert commit_kwargs["agent_id"] == "agent-1" items = commit_kwargs["items"] - assert [item.key for item in items] == ["pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"] + assert [item.key for item in items] == [ + "pdf-toolkit/SKILL.md", + "pdf-toolkit/.DIFY-SKILL-FULL.zip", + "pdf-toolkit/scripts/run.py", + ] assert all(item.value_owned_by_drive for item in items) - assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"] + assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file", "script-tool-file"] assert items[0].is_skill is True assert items[0].skill_metadata.name == "PDF Toolkit" assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"] assert items[1].is_skill is False + assert items[2].is_skill is False # The returned skill ref carries stable drive paths + file ids. skill = result["skill"] diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 988a8999c30..fb096b9c520 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -158,12 +158,13 @@ export type AgentComposerValidateResponse = { warnings?: Array } -export type CopyAppPayload = { +export type AgentAppCopyPayload = { description?: string | null icon?: string | null icon_background?: string | null icon_type?: IconType | null name?: string | null + role?: string | null } export type AgentDebugConversationRefreshResponse = { @@ -1952,7 +1953,7 @@ export type PostAgentByAgentIdComposerValidateResponse = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] export type PostAgentByAgentIdCopyData = { - body: CopyAppPayload + body: AgentAppCopyPayload path: { agent_id: string } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index aeab80c9463..e97bb5b71f9 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -170,14 +170,15 @@ export const zAgentAppUpdatePayload = z.object({ }) /** - * CopyAppPayload + * AgentAppCopyPayload */ -export const zCopyAppPayload = z.object({ +export const zAgentAppCopyPayload = z.object({ description: z.string().max(400).nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: zIconType.nullish(), name: z.string().nullish(), + role: z.string().max(255).nullish(), }) /** @@ -2433,7 +2434,7 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({ */ export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse -export const zPostAgentByAgentIdCopyBody = zCopyAppPayload +export const zPostAgentByAgentIdCopyBody = zAgentAppCopyPayload export const zPostAgentByAgentIdCopyPath = z.object({ agent_id: z.uuid(),