feat(agent): copy roster agent into workflow inline agent (#37813)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-23 20:28:29 +08:00 committed by GitHub
parent b56e5f74e7
commit 26b0ff2a01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 959 additions and 45 deletions

View File

@ -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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])

View File

@ -3807,6 +3807,26 @@ Submit human input form preview for workflow
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow roster agent copied to inline agent | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)<br> |
### [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 |

View File

@ -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,

View File

@ -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."

View File

@ -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"

View File

@ -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}

View File

@ -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(
{

View File

@ -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)

View File

@ -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<DeclaredOutputConfig>
human_contacts?: Array<AgentHumanContactConfig>
@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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<AgentComposerImpactBindingResponse>
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: {

View File

@ -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