mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
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:
parent
b56e5f74e7
commit
26b0ff2a01
@ -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__])
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user