diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 975586c635c..b54cf4b6daf 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -28,9 +28,9 @@ from libs.login import login_required from models.model import App, AppMode from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.entities.agent_entities import ComposerSavePayload +from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload -register_schema_models(console_ns, ComposerSavePayload) +register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload) register_response_schema_models( console_ns, AgentAppComposerResponse, @@ -91,6 +91,38 @@ class WorkflowAgentComposerApi(Resource): ) +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/copy-from-roster") +class WorkflowAgentComposerCopyFromRosterApi(Resource): + @console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__]) + @console_ns.response( + 200, + "Workflow roster agent copied to inline agent", + console_ns.models[WorkflowAgentComposerResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + @with_current_user_id + @with_current_tenant_id + def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str): + payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {}) + return dump_response( + WorkflowAgentComposerResponse, + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id=tenant_id, + app_id=app_model.id, + node_id=node_id, + account_id=account_id, + source_agent_id=payload.source_agent_id, + source_snapshot_id=payload.source_snapshot_id, + idempotency_key=payload.idempotency_key, + ), + ) + + @console_ns.route("/apps//workflows/draft/nodes//agent-composer/validate") class WorkflowAgentComposerValidateApi(Resource): @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index cff0286ad8f..6041662f303 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -3807,6 +3807,26 @@ Submit human input form preview for workflow | ---- | ----------- | ------ | | 200 | Workflow agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowComposerCopyFromRosterPayload](#workflowcomposercopyfromrosterpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow roster agent copied to inline agent | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + ### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact #### Parameters @@ -14385,9 +14405,14 @@ Button styles for user actions. | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No | | binding | [ComposerBindingPayload](#composerbindingpayload) | | No | | client_revision_id | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | [AgentIconType](#agenticontype) | | No | | idempotency_key | string | | No | | new_agent_name | string | | No | | node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | No | +| role | string | | No | | save_strategy | [ComposerSaveStrategy](#composersavestrategy) | | Yes | | soul_lock | [ComposerSoulLockPayload](#composersoullockpayload) | | No | | variant | [ComposerVariant](#composervariant) | | Yes | @@ -20560,6 +20585,14 @@ How a workflow node is bound to an Agent. | position_x | number | Comment X position | No | | position_y | number | Comment Y position | No | +#### WorkflowComposerCopyFromRosterPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| idempotency_key | string | | No | +| source_agent_id | string | | Yes | +| source_snapshot_id | string | | No | + #### WorkflowConversationVariableResponse | Name | Type | Description | Required | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 0a17c06300f..230475d5b2a 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -4,6 +4,7 @@ from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql.elements import ColumnElement from extensions.ext_database import db from libs.helper import to_timestamp @@ -13,6 +14,7 @@ from models.agent import ( AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDriveFile, + AgentIconType, AgentKind, AgentScope, AgentSource, @@ -20,9 +22,7 @@ from models.agent import ( WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import ( - DeclaredOutputConfig, -) +from models.agent_config_entities import DeclaredOutputConfig from models.agent_config_entities import ( effective_declared_outputs as _effective_declared_outputs, ) @@ -32,7 +32,9 @@ from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import ( AgentNameConflictError, AgentNotFoundError, + AgentVersionConflictError, AgentVersionNotFoundError, + InvalidComposerConfigError, ) from services.entities.agent_entities import ( AgentSoulConfig, @@ -172,6 +174,86 @@ class AgentComposerService: ) return state + @classmethod + def copy_workflow_composer_from_roster( + cls, + *, + tenant_id: str, + app_id: str, + node_id: str, + account_id: str, + source_agent_id: str, + source_snapshot_id: str | None = None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + binding = cls._require_binding( + cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + ) + + if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and idempotency_key: + agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id if agent else None, + version_id=binding.current_snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + + if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT: + raise InvalidComposerConfigError("Workflow agent node must be bound to a roster agent.") + if binding.agent_id != source_agent_id: + raise InvalidComposerConfigError("Source agent does not match the current workflow node binding.") + + source_agent = cls._require_agent(tenant_id=tenant_id, agent_id=source_agent_id) + if source_agent.scope != AgentScope.ROSTER or source_agent.status != AgentStatus.ACTIVE: + raise InvalidComposerConfigError("Source agent must be an active roster agent.") + source_version = cls._require_version( + tenant_id=tenant_id, + agent_id=source_agent.id, + version_id=source_agent.active_config_snapshot_id, + ) + if source_snapshot_id and source_snapshot_id != source_version.id: + raise AgentVersionConflictError() + + agent_soul = AgentSoulConfig.model_validate(source_version.config_snapshot_dict) + inline_agent = cls._create_workflow_only_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow.id, + node_id=node_id, + account_id=account_id, + agent_soul=agent_soul, + name=source_agent.name, + description=source_agent.description, + role=source_agent.role, + icon_type=source_agent.icon_type, + icon=source_agent.icon, + icon_background=source_agent.icon_background, + ) + cls._copy_agent_drive_rows( + tenant_id=tenant_id, + source_agent_id=source_agent.id, + target_agent_id=inline_agent.id, + account_id=account_id, + agent_soul=agent_soul, + node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), + ) + + binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT + binding.agent_id = inline_agent.id + binding.current_snapshot_id = inline_agent.active_config_snapshot_id + binding.updated_by = account_id + db.session.flush() + db.session.commit() + + version = cls._require_version( + tenant_id=tenant_id, + agent_id=inline_agent.id, + version_id=inline_agent.active_config_snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=inline_agent, version=version) + @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: agent = db.session.scalar( @@ -849,6 +931,11 @@ class AgentComposerService: tenant_id=tenant_id, account_id=account_id, name=agent_name, + description=payload.description or "", + role=payload.role or "", + icon_type=payload.icon_type, + icon=payload.icon, + icon_background=payload.icon_background, agent_soul=payload.agent_soul, operation=AgentConfigRevisionOperation.SAVE_NEW_AGENT, version_note=payload.version_note, @@ -894,6 +981,13 @@ class AgentComposerService: tenant_id=tenant_id, account_id=account_id, name=agent_name, + description=payload.description if payload.description is not None else source_agent.description, + role=payload.role if payload.role is not None else source_agent.role, + icon_type=payload.icon_type if payload.icon_type is not None else source_agent.icon_type, + icon=payload.icon if payload.icon is not None else source_agent.icon, + icon_background=payload.icon_background + if payload.icon_background is not None + else source_agent.icon_background, agent_soul=agent_soul, operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, version_note=payload.version_note, @@ -916,11 +1010,21 @@ class AgentComposerService: node_id: str, account_id: str, agent_soul: AgentSoulConfig, + name: str | None = None, + description: str = "", + role: str = "", + icon_type: Any | None = None, + icon: str | None = None, + icon_background: str | None = None, ) -> Agent: agent = Agent( tenant_id=tenant_id, - name=f"Workflow Agent {node_id}", - description="", + name=name or f"Workflow Agent {node_id}", + description=description, + role=role, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, @@ -945,6 +1049,98 @@ class AgentComposerService: agent.active_config_has_model = agent_soul_has_model(agent_soul) return agent + @classmethod + def _copy_agent_drive_rows( + cls, + *, + tenant_id: str, + source_agent_id: str, + target_agent_id: str, + account_id: str, + agent_soul: AgentSoulConfig, + node_job: WorkflowNodeJobConfig | None = None, + ) -> None: + exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job) + predicates: list[ColumnElement[bool]] = [] + if exact_keys: + predicates.append(AgentDriveFile.key.in_(sorted(exact_keys))) + predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes)) + if not predicates: + return + + source_rows = list( + db.session.scalars( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == source_agent_id, + or_(*predicates), + ) + ).all() + ) + if not source_rows: + return + + existing_target_keys = set( + db.session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == target_agent_id, + AgentDriveFile.key.in_([row.key for row in source_rows]), + ) + ).all() + ) + for row in source_rows: + if row.key in existing_target_keys: + continue + db.session.add( + AgentDriveFile( + tenant_id=tenant_id, + agent_id=target_agent_id, + key=row.key, + file_kind=row.file_kind, + file_id=row.file_id, + value_owned_by_drive=row.value_owned_by_drive, + is_skill=row.is_skill, + skill_metadata=row.skill_metadata, + size=row.size, + hash=row.hash, + mime_type=row.mime_type, + created_by=account_id, + ) + ) + + @staticmethod + def _drive_copy_scopes_from_agent_configs( + *, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None + ) -> tuple[set[str], set[str]]: + from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions + from services.agent_drive_service import decode_drive_mention_ref + + exact_keys: set[str] = set() + prefixes: set[str] = set() + + for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt): + if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: + continue + drive_key = decode_drive_mention_ref(mention.ref_id) + if not drive_key: + continue + if mention.kind == MentionKind.SKILL and "/" in drive_key: + prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/") + else: + exact_keys.add(drive_key) + + if node_job is not None: + for file_ref in node_job.metadata.file_refs or []: + if file_ref.drive_key: + exact_keys.add(file_ref.drive_key) + for output in node_job.declared_outputs: + benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None + if benchmark_ref and benchmark_ref.drive_key: + exact_keys.add(benchmark_ref.drive_key) + + return exact_keys, prefixes + @classmethod def _create_roster_agent_for_composer( cls, @@ -955,11 +1151,20 @@ class AgentComposerService: agent_soul: AgentSoulConfig, operation: AgentConfigRevisionOperation, version_note: str | None, + description: str = "", + role: str = "", + icon_type: AgentIconType | None = None, + icon: str | None = None, + icon_background: str | None = None, ) -> Agent: agent = Agent( tenant_id=tenant_id, name=name, - description="", + description=description, + role=role, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.WORKFLOW, diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py index dcc8f69961f..6a1dc6fb628 100644 --- a/api/services/agent/errors.py +++ b/api/services/agent/errors.py @@ -17,6 +17,10 @@ class AgentArchivedError(Conflict): description = "Archived agent cannot be modified." +class AgentVersionConflictError(Conflict): + description = "Agent config version changed. Please reload and try again." + + class AgentSoulLockedError(BadRequest): description = "Agent Soul is locked for this workflow node." diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py index e7b5cbd7c6d..a8634bceb09 100644 --- a/api/services/entities/agent_entities.py +++ b/api/services/entities/agent_entities.py @@ -42,6 +42,11 @@ class ComposerSavePayload(BaseModel): idempotency_key: str | None = None client_revision_id: str | None = None new_agent_name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + role: str | None = Field(default=None, max_length=255) + icon_type: AgentIconType | None = None + icon: str | None = Field(default=None, max_length=255) + icon_background: str | None = Field(default=None, max_length=255) @model_validator(mode="after") def validate_variant_sections(self) -> "ComposerSavePayload": @@ -58,6 +63,12 @@ class ComposerSavePayload(BaseModel): return self +class WorkflowComposerCopyFromRosterPayload(BaseModel): + source_agent_id: str = Field(min_length=1, max_length=255) + source_snapshot_id: str | None = Field(default=None, max_length=255) + idempotency_key: str | None = Field(default=None, max_length=255) + + class RosterAgentCreatePayload(BaseModel): name: str = Field(min_length=1, max_length=255) mode: Literal["agent"] = "agent" 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 27bb75e21f8..3d84f899379 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 @@ -15,6 +15,7 @@ from controllers.console.agent.composer import ( AgentComposerValidateApi, WorkflowAgentComposerApi, WorkflowAgentComposerCandidatesApi, + WorkflowAgentComposerCopyFromRosterApi, WorkflowAgentComposerImpactApi, WorkflowAgentComposerSaveToRosterApi, WorkflowAgentComposerValidateApi, @@ -1017,6 +1018,58 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( )["save_options"] == ["node_job_only"] +def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None: + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + def fake_copy_from_roster(**kwargs): + captured.update(kwargs) + return _workflow_composer_response( + binding={ + "id": "binding-1", + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-version-1", + "workflow_id": "workflow-1", + "node_id": kwargs["node_id"], + }, + agent={ + "id": "inline-agent-1", + "name": "Nadia", + "description": "", + "scope": "workflow_only", + "status": "active", + }, + active_config_snapshot={"id": "inline-version-1", "version": 1}, + ) + + monkeypatch.setattr( + composer_controller.AgentComposerService, "copy_workflow_composer_from_roster", fake_copy_from_roster + ) + + with app.test_request_context( + json={ + "source_agent_id": "roster-agent-1", + "source_snapshot_id": "roster-version-1", + "idempotency_key": "copy-1", + } + ): + result = unwrap(WorkflowAgentComposerCopyFromRosterApi.post)( + WorkflowAgentComposerCopyFromRosterApi(), "tenant-1", account_id, app_model, "node-1" + ) + + assert result["binding"]["binding_type"] == "inline_agent" + assert captured == { + "tenant_id": "tenant-1", + "app_id": "app-1", + "node_id": "node-1", + "account_id": account_id, + "source_agent_id": "roster-agent-1", + "source_snapshot_id": "roster-version-1", + "idempotency_key": "copy-1", + } + + def test_workflow_impact_returns_empty_without_version(app: Flask) -> None: payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value} diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index e82ba92029b..23988c2ec20 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -105,6 +105,28 @@ def test_agent_app_soul_allows_app_features_and_variables(): assert payload.agent_soul.app_variables[0].name == "company_name" +def test_composer_save_payload_accepts_new_roster_metadata(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.SAVE_TO_ROSTER, + "new_agent_name": "Research Agent", + "description": "Finds relevant sources.", + "role": "Research Assistant", + "icon_type": "emoji", + "icon": "search", + "icon_background": "#E0F2FE", + } + ) + + assert payload.new_agent_name == "Research Agent" + assert payload.description == "Finds relevant sources." + assert payload.role == "Research Assistant" + assert payload.icon_type == "emoji" + assert payload.icon == "search" + assert payload.icon_background == "#E0F2FE" + + def test_knowledge_query_mode_uses_stable_backend_enums(): config = AgentSoulConfig.model_validate( { 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 9ba62d60375..1d0d5ed42c6 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -10,6 +10,7 @@ from models.agent import ( AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDebugConversation, + AgentDriveFile, AgentKind, AgentScope, AgentSource, @@ -31,7 +32,7 @@ from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import InvalidComposerConfigError +from services.agent.errors import AgentVersionConflictError, InvalidComposerConfigError from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.app_service import AppListParams, AppService @@ -415,9 +416,28 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) workflow_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") - roster_agent = SimpleNamespace(id="roster-agent-1", active_config_snapshot_id="roster-version-1", name="Roster") + roster_agent = SimpleNamespace( + id="roster-agent-1", + active_config_snapshot_id="roster-version-1", + name="Roster", + description="Source description", + role="Source role", + icon_type="emoji", + icon="source", + icon_background="#FFFFFF", + ) + create_roster_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) - monkeypatch.setattr(AgentComposerService, "_create_roster_agent_for_composer", lambda **kwargs: roster_agent) + + def fake_create_roster_agent_for_composer(**kwargs): + create_roster_calls.append(kwargs) + return roster_agent + + monkeypatch.setattr( + AgentComposerService, + "_create_roster_agent_for_composer", + fake_create_roster_agent_for_composer, + ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, @@ -443,6 +463,11 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk "agent_soul": {"prompt": {"system_prompt": "new"}}, "node_job": {"workflow_prompt": "use prior output"}, "new_agent_name": "Copied Agent", + "description": "Copied description", + "role": "Copied role", + "icon_type": "emoji", + "icon": "copied", + "icon_background": "#E0F2FE", } ) existing_binding = WorkflowAgentNodeBinding(agent_id="inline-agent-1", current_snapshot_id="inline-version-1") @@ -500,6 +525,14 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk assert new_agent_binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert save_to_roster_binding.agent_id == "roster-agent-1" assert new_version_binding.current_snapshot_id == "new-version-1" + assert create_roster_calls[0]["description"] == "Copied description" + assert create_roster_calls[0]["role"] == "Copied role" + assert create_roster_calls[0]["icon"] == "copied" + assert create_roster_calls[0]["icon_background"] == "#E0F2FE" + assert create_roster_calls[1]["description"] == "Copied description" + assert create_roster_calls[1]["role"] == "Copied role" + assert create_roster_calls[1]["icon"] == "copied" + assert create_roster_calls[1]["icon_background"] == "#E0F2FE" def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch): @@ -715,6 +748,428 @@ def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypat ) +def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_node_job( + monkeypatch: pytest.MonkeyPatch, +): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + workflow = SimpleNamespace(id="workflow-1") + node_job = WorkflowNodeJobConfig(workflow_prompt="keep this node task") + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="old-roster-version", + node_job_config=node_job, + ) + roster_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Nadia", + description="Clarification Drafter", + role="Clarifies tenders", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="roster-version-2", + ) + source_version = AgentConfigSnapshot( + id="roster-version-2", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=2, + config_snapshot='{"prompt":{"system_prompt":"copy me"}}', + ) + inline_agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Nadia", + description="Clarification Drafter", + role="Clarifies tenders", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-1", + ) + captured: dict[str, object] = {} + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: workflow) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) + + def fake_create_workflow_only_agent(**kwargs): + captured["create"] = kwargs + return inline_agent + + def fake_copy_drive_rows(**kwargs): + captured["drive"] = kwargs + + monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) + monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: { + "binding": { + "binding_type": kwargs["binding"].binding_type.value, + "agent_id": kwargs["binding"].agent_id, + "current_snapshot_id": kwargs["binding"].current_snapshot_id, + }, + "node_job": kwargs["binding"].node_job_config_dict, + }, + ) + + state = AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + source_snapshot_id="roster-version-2", + ) + + assert state["binding"]["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value + assert state["binding"]["agent_id"] == "inline-agent-1" + assert state["node_job"]["workflow_prompt"] == "keep this node task" + assert binding.node_job_config is node_job + create_kwargs = captured["create"] + assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me" + assert create_kwargs["name"] == "Nadia" + assert create_kwargs["role"] == "Clarifies tenders" + drive_kwargs = captured["drive"] + assert drive_kwargs["source_agent_id"] == "roster-agent-1" + assert drive_kwargs["target_agent_id"] == "inline-agent-1" + assert fake_session.commits == 1 + + +def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + lambda **kwargs: WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=WorkflowNodeJobConfig(), + ), + ) + roster_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Nadia", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="roster-version-2", + ) + source_version = AgentConfigSnapshot( + id="roster-version-2", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=2, + config_snapshot='{"prompt":{"system_prompt":"copy me"}}', + ) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) + + with pytest.raises(AgentVersionConflictError): + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + source_snapshot_id="roster-version-1", + ) + + +def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(monkeypatch: pytest.MonkeyPatch): + inline_binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + inline_agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Inline", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-1", + ) + inline_version = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"inline"}}', + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding) + monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: {"binding_type": kwargs["binding"].binding_type.value}, + ) + + state = AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + idempotency_key="same-click", + ) + + assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value} + + +@pytest.mark.parametrize( + ("binding_agent_id", "binding_type", "source_scope", "source_status", "expected_message"), + [ + ( + "roster-agent-1", + WorkflowAgentBindingType.INLINE_AGENT, + AgentScope.ROSTER, + AgentStatus.ACTIVE, + "must be bound to a roster agent", + ), + ( + "other-agent", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.ROSTER, + AgentStatus.ACTIVE, + "does not match", + ), + ( + "roster-agent-1", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.WORKFLOW_ONLY, + AgentStatus.ACTIVE, + "must be an active roster agent", + ), + ( + "roster-agent-1", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.ROSTER, + AgentStatus.ARCHIVED, + "must be an active roster agent", + ), + ], +) +def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding( + monkeypatch: pytest.MonkeyPatch, + binding_agent_id: str, + binding_type: WorkflowAgentBindingType, + source_scope: AgentScope, + source_status: AgentStatus, + expected_message: str, +): + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=binding_type, + agent_id=binding_agent_id, + current_snapshot_id="version-1", + node_job_config=WorkflowNodeJobConfig(), + ) + source_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Source", + scope=source_scope, + source=AgentSource.AGENT_APP, + status=source_status, + active_config_snapshot_id="version-1", + ) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: source_agent) + + with pytest.raises(InvalidComposerConfigError, match=expected_message): + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + ) + + +def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch): + skill_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="tender-analyzer/SKILL.md", + file_kind="tool_file", + file_id="tool-file-1", + value_owned_by_drive=True, + is_skill=True, + skill_metadata='{"name":"Tender Analyzer"}', + size=10, + mime_type="text/markdown", + ) + script_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="tender-analyzer/scripts/run.sh", + file_kind="tool_file", + file_id="tool-file-2", + value_owned_by_drive=True, + size=20, + mime_type="text/x-shellscript", + ) + file_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="files/qna.pdf", + file_kind="upload_file", + file_id="upload-file-1", + value_owned_by_drive=False, + size=30, + mime_type="application/pdf", + ) + fake_session = FakeSession(scalars=[[skill_row, script_row, file_row], []]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate( + { + "prompt": { + "system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]", + }, + } + ) + node_job = WorkflowNodeJobConfig.model_validate( + {"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}} + ) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + node_job=node_job, + ) + + copied = [row for row in fake_session.added if isinstance(row, AgentDriveFile)] + assert [row.key for row in copied] == [ + "tender-analyzer/SKILL.md", + "tender-analyzer/scripts/run.sh", + "files/qna.pdf", + ] + assert {row.agent_id for row in copied} == {"inline-agent-1"} + assert copied[0].file_id == "tool-file-1" + assert copied[0].is_skill is True + assert copied[2].value_owned_by_drive is False + + +def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}}) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + ) + + assert fake_session.added == [] + + +def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch): + source_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="files/qna.pdf", + file_kind="upload_file", + file_id="upload-file-1", + value_owned_by_drive=False, + size=30, + mime_type="application/pdf", + ) + fake_session = FakeSession(scalars=[[source_row], ["files/qna.pdf"]]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}}) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + ) + + assert [row for row in fake_session.added if isinstance(row, AgentDriveFile)] == [] + + +def test_drive_copy_scopes_include_declared_output_benchmark_files(): + agent_soul = AgentSoulConfig.model_validate( + { + "prompt": { + "system_prompt": ( + "[§file:files/source.pdf:source.pdf§] " + "[§knowledge:dataset-1:Docs§] " + "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]" + ) + }, + } + ) + node_job = WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + { + "name": "qna_report", + "type": "file", + "check": { + "enabled": True, + "prompt": "Compare the generated file with the benchmark.", + "benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"}, + }, + }, + { + "name": "summary", + "type": "string", + "check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}}, + }, + ], + } + ) + + exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs( + agent_soul=agent_soul, + node_job=node_job, + ) + + assert exact_keys == {"files/source.pdf", "files/expected.pdf"} + assert prefixes == {"tender-analyzer/"} + + def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index aa21f2ce651..43119c4f1f4 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -134,9 +134,14 @@ export type ComposerSavePayload = { agent_soul?: AgentSoulConfig | null binding?: ComposerBindingPayload | null client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null idempotency_key?: string | null new_agent_name?: string | null node_job?: WorkflowNodeJobConfig | null + role?: string | null save_strategy: ComposerSaveStrategy soul_lock?: ComposerSoulLockPayload variant: ComposerVariant @@ -536,6 +541,8 @@ export type ComposerBindingPayload = { current_snapshot_id?: string | null } +export type AgentIconType = 'emoji' | 'image' | 'link' + export type WorkflowNodeJobConfig = { declared_outputs?: Array human_contacts?: Array @@ -876,8 +883,6 @@ export type LlmMode = 'chat' | 'completion' export type AgentKind = 'dify_agent' -export type AgentIconType = 'emoji' | 'image' | 'link' - export type AgentPublishedReferenceResponse = { app_icon?: string | null app_icon_background?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index cb4107f2d53..d7f5681ffc4 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -282,6 +282,13 @@ export const zComposerBindingPayload = z.object({ current_snapshot_id: z.string().nullish(), }) +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + /** * ComposerSoulLockPayload */ @@ -830,13 +837,6 @@ export const zAgentAppDetailWithSite = z.object({ */ export const zAgentKind = z.enum(['dify_agent']) -/** - * AgentIconType - * - * Supported icon storage formats for Agent roster entries. - */ -export const zAgentIconType = z.enum(['emoji', 'image', 'link']) - /** * AgentPublishedReferenceResponse */ @@ -1876,9 +1876,14 @@ export const zComposerSavePayload = z.object({ agent_soul: zAgentSoulConfig.nullish(), binding: zComposerBindingPayload.nullish(), client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), idempotency_key: z.string().nullish(), new_agent_name: z.string().min(1).max(255).nullish(), node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), save_strategy: zComposerSaveStrategy, soul_lock: zComposerSoulLockPayload.optional(), variant: zComposerVariant, diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 7a93572885e..ea72df28458 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -392,6 +392,9 @@ import { zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse, @@ -3479,6 +3482,26 @@ export const candidates = { } export const post51 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRoster', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + }), + ) + .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) + +export const copyFromRoster = { + post: post51, +} + +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3495,10 +3518,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post51, + post: post52, } -export const post52 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3515,10 +3538,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post52, + post: post53, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3535,7 +3558,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post53, + post: post54, } export const get62 = oc @@ -3569,6 +3592,7 @@ export const agentComposer = { get: get62, put: put4, candidates, + copyFromRoster, impact, saveToRoster, validate, @@ -3598,7 +3622,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post54 = oc +export const post55 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3617,7 +3641,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post54, + post: post55, } /** @@ -3625,7 +3649,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post55 = oc +export const post56 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3639,7 +3663,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post55, + post: post56, } export const trigger = { @@ -3699,7 +3723,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post56 = oc +export const post57 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3718,7 +3742,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post56, + post: post57, } /** @@ -3840,7 +3864,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post57 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3859,7 +3883,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post57, + post: post58, } /** @@ -3867,7 +3891,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post58 = oc +export const post59 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -3886,7 +3910,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post58, + post: post59, } export const trigger2 = { @@ -4039,7 +4063,7 @@ export const get72 = oc * * Sync draft workflow configuration */ -export const post59 = oc +export const post60 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4059,7 +4083,7 @@ export const post59 = oc export const draft2 = { get: get72, - post: post59, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4095,7 +4119,7 @@ export const get73 = oc /** * Publish workflow */ -export const post60 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4114,7 +4138,7 @@ export const post60 = oc export const publish = { get: get73, - post: post60, + post: post61, } /** @@ -4251,7 +4275,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post61 = oc +export const post62 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4264,7 +4288,7 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post61, + post: post62, } /** @@ -4489,7 +4513,7 @@ export const get81 = oc * * Create a new API key for an app */ -export const post62 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4505,7 +4529,7 @@ export const post62 = oc export const apiKeys = { get: get81, - post: post62, + post: post63, byApiKeyId, } @@ -4563,7 +4587,7 @@ export const get83 = oc * * Create a new application */ -export const post63 = oc +export const post64 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4579,7 +4603,7 @@ export const post63 = oc export const apps = { get: get83, - post: post63, + post: post64, imports, starred, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index fa56590f0a4..9e79518f3cd 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -986,9 +986,14 @@ export type ComposerSavePayload = { agent_soul?: AgentSoulConfig | null binding?: ComposerBindingPayload | null client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null idempotency_key?: string | null new_agent_name?: string | null node_job?: WorkflowNodeJobConfig | null + role?: string | null save_strategy: ComposerSaveStrategy soul_lock?: ComposerSoulLockPayload variant: ComposerVariant @@ -1003,6 +1008,12 @@ export type AgentComposerCandidatesResponse = { variant: ComposerVariant } +export type WorkflowComposerCopyFromRosterPayload = { + idempotency_key?: string | null + source_agent_id: string + source_snapshot_id?: string | null +} + export type AgentComposerImpactResponse = { bindings?: Array current_snapshot_id?: string | null @@ -1873,6 +1884,8 @@ export type ComposerBindingPayload = { current_snapshot_id?: string | null } +export type AgentIconType = 'emoji' | 'image' | 'link' + export type ComposerSoulLockPayload = { locked?: boolean unlocked_from_version_id?: string | null @@ -5415,6 +5428,23 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResp export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterData = { + body: WorkflowComposerCopyFromRosterPayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses = { + 200: WorkflowAgentComposerResponse +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse + = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses] + export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = { body: ComposerSavePayload path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 043fc11261f..9b86fda0a62 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -642,6 +642,15 @@ export const zHumanInputDeliveryTestPayload = z.object({ */ export const zEmptyObjectResponse = z.record(z.string(), z.unknown()) +/** + * WorkflowComposerCopyFromRosterPayload + */ +export const zWorkflowComposerCopyFromRosterPayload = z.object({ + idempotency_key: z.string().max(255).nullish(), + source_agent_id: z.string().min(1).max(255), + source_snapshot_id: z.string().max(255).nullish(), +}) + /** * DraftWorkflowNodeRunPayload */ @@ -1835,6 +1844,13 @@ export const zComposerBindingPayload = z.object({ current_snapshot_id: z.string().nullish(), }) +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + /** * ComposerSoulLockPayload */ @@ -3336,9 +3352,14 @@ export const zComposerSavePayload = z.object({ agent_soul: zAgentSoulConfig.nullish(), binding: zComposerBindingPayload.nullish(), client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), idempotency_key: z.string().nullish(), new_agent_name: z.string().min(1).max(255).nullish(), node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), save_strategy: zComposerSaveStrategy, soul_lock: zComposerSoulLockPayload.optional(), variant: zComposerVariant, @@ -5342,6 +5363,20 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody + = zWorkflowComposerCopyFromRosterPayload + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath = z.object({ + app_id: z.uuid(), + node_id: z.string(), +}) + +/** + * Workflow roster agent copied to inline agent + */ +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse + = zWorkflowAgentComposerResponse + export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody = zComposerSavePayload